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:
184
internal/handlers/firewall_log.go
Normal file
184
internal/handlers/firewall_log.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user