package handlers import ( "context" "errors" "log/slog" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog" ) // FirewallLogHandler exposes: // // GET /api/v1/firewall/log?since=&until=&rule_id=&src=&dst=&proto=&action=&limit= // WS /api/v1/firewall/log/live // // since/until: RFC3339 timestamp. limit defaults to 200, max 1000. // Die HTTP-Variante liest direkt das jsonl-File (Historie der letzten // 14 Tage), die WS-Variante streamt aus dem In-Memory-Ringbuffer + // jeden neuen Event ab dem Connect. type FirewallLogHandler struct { Tailer *firewalllog.Tailer LogPath string } func NewFirewallLogHandler(tailer *firewalllog.Tailer, path string) *FirewallLogHandler { if path == "" { path = firewalllog.DefaultLogPath } return &FirewallLogHandler{Tailer: tailer, LogPath: path} } func (h *FirewallLogHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/firewall") g.GET("/log", h.Tail) g.GET("/log/live", h.Live) } // Tail liest die letzten matching Events aus dem rotated jsonl-File. // Die HTTP-Antwort ist eine Liste — keine Pagination, der Ring-Cap // (limit, max 1000) ist die Grenze. UI ruft das beim Page-Open auf // und holt danach Live-Events über den WS. func (h *FirewallLogHandler) Tail(c *gin.Context) { f := parseFilter(c) entries, err := firewalllog.ReadTail(h.LogPath, f) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"entries": entries, "count": len(entries)}) } // upgrader: kein CheckOrigin (Same-Origin durch HAProxy bzw. dev-vite- // proxy; ohne Origin-Check geht's außerdem in der Dev-Konsole auf). var fwLogUpgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 32 * 1024, CheckOrigin: func(r *http.Request) bool { return true }, } // Live upgraded auf WebSocket, schickt einen Initial-Snapshot aus dem // Ringbuffer (gefiltert nach Query-Params) und dann jeden neuen Event // als JSON-Zeile. func (h *FirewallLogHandler) Live(c *gin.Context) { if h.Tailer == nil { response.Err(c, http.StatusServiceUnavailable, errors.New("firewall log tailer not running")) return } conn, err := fwLogUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { // Upgrade-Failures sind Browser-side; nichts loggen return } defer conn.Close() f := parseFilter(c) // 1) Snapshot aus dem Ring — UI sieht sofort die letzten Events. for _, e := range h.Tailer.Snapshot(f) { if err := conn.WriteJSON(e); err != nil { return } } // 2) Subscribe + stream forward. ch, unsub := h.Tailer.Subscribe() defer unsub() // Read-loop nebenher um Close-Frames + Ping-Pong sauber zu // behandeln. Wenn der Client zumacht, returnen wir aus der Write- // Schleife. ctx, cancel := context.WithCancel(c.Request.Context()) defer cancel() go func() { defer cancel() for { if _, _, err := conn.NextReader(); err != nil { return } } }() ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() _ = conn.SetWriteDeadline(time.Now().Add(60 * time.Second)) for { select { case <-ctx.Done(): return case e, ok := <-ch: if !ok { return } if !f.Matches(&e) { continue } _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteJSON(e); err != nil { return } case <-ticker.C: // Ping um stale-connection-detection durch HAProxy auf // timeout client/tunnel zu vermeiden. _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second)); err != nil { return } } } } func parseFilter(c *gin.Context) firewalllog.Filter { f := firewalllog.Filter{ RuleID: c.Query("rule_id"), SrcIP: c.Query("src"), DstIP: c.Query("dst"), Proto: c.Query("proto"), Action: c.Query("action"), } if s := c.Query("since"); s != "" { if t, err := time.Parse(time.RFC3339, s); err == nil { f.Since = t } } if s := c.Query("until"); s != "" { if t, err := time.Parse(time.RFC3339, s); err == nil { f.Until = t } } if s := c.Query("limit"); s != "" { if n, err := strconv.Atoi(s); err == nil { f.Limit = n } } if f.Limit <= 0 { f.Limit = 200 } if f.Limit > 1000 { f.Limit = 1000 } return f } // startFirewallLogTailer is a small helper main.go invokes — wird // hier definiert damit der Lifecycle (ctx, slog.Warn) eine Heimat hat. func StartFirewallLogTailer(ctx context.Context, t *firewalllog.Tailer) { if t == nil { return } go func() { if err := t.Start(ctx); err != nil && err != context.Canceled { slog.Warn("firewalllog tailer exited", "error", err) } }() }