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>
185 lines
4.7 KiB
Go
185 lines
4.7 KiB
Go
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)
|
|
}
|
|
}()
|
|
}
|