feat(firewall-log): Phase 2 — HTTP-Tail + WebSocket-Live-Stream

Backend für /firewall-Live-Tail und historische Recherche der
ulogd2-JSONL aus Phase 1.

internal/services/firewalllog/
  reader.go  — JSONL parser + Filter (since/until/rule_id/src/dst/
               proto/action/limit). Proto-Mapping aus IP-Protocol-Number
               (1=icmp, 6=tcp, 17=udp, 58=icmpv6). RuleID wird aus
               oob.prefix "edgeguard:<id>" extrahiert.
  tailer.go  — fsnotify-Watcher auf /var/log/edgeguard/, In-Memory
               Ring-Buffer 1000 Events, fan-out an Subscribe()-Channel.
               Robust gegen logrotate copytruncate (truncate-detection
               via stat.Size() < offset → seek(0)). Safety-Net 2s-poll
               falls fsnotify einen Write verschluckt. Non-blocking send
               an Subscriber — langsame Clients droppen Events statt
               die Pipeline zu blockieren.

internal/handlers/firewall_log.go:
  GET /api/v1/firewall/log     — typed JSON list, Filter via Query
  WS  /api/v1/firewall/log/live — Snapshot + live broadcast
                                  (gorilla/websocket, 30s-ping)

main.go: Tailer beim Startup gestartet (context.Background) — UI
landet in Phase 3.

deps: gorilla/websocket v1.5.3, fsnotify v1.10.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 21:05:39 +02:00
parent 3c817b7080
commit a798d1b796
10 changed files with 684 additions and 5 deletions

View File

@@ -0,0 +1,184 @@
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)
}
}()
}