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,223 @@
// Package firewalllog liest die von ulogd2 nach /var/log/edgeguard/
// firewall.jsonl geschriebenen NFLOG-Events. Phase 2 des Log-Systems:
//
// - Reader: parst JSONL-Zeilen, filtert by since/action/src/dst/...
// - Tailer: live-tailt das File via fsnotify, hält einen Ring-Buffer
// der letzten N Events und broadcastet neue Events an WebSocket-
// Clients.
//
// Schema-Felder kommen direkt aus ulogd2 + ulogd2-json — siehe
// /etc/ulogd.conf in postinst (NFLOG + BASE + IFINDEX + IP2STR +
// HWHDR → JSON-output). Beispiel-Line:
//
// {"timestamp":"2026-05-12T20:43:45+0200","oob.prefix":"edgeguard:42 ",
// "src_ip":"10.0.5.1","dest_ip":"10.0.20.23","ip.protocol":6,
// "src_port":54321,"dest_port":443,"oob.in":"ens18","action":"blocked",...}
package firewalllog
import (
"bufio"
"encoding/json"
"errors"
"io"
"os"
"strconv"
"strings"
"time"
)
// DefaultLogPath ist der Pfad, in den ulogd2 (via postinst-gerendertem
// /etc/ulogd.conf) die JSON-Lines schreibt.
const DefaultLogPath = "/var/log/edgeguard/firewall.jsonl"
// Entry ist die typisierte Sicht auf eine ulogd-JSON-Zeile. Nur die
// Felder die das UI braucht — den Rest können Operatoren mit `jq` auf
// dem File selber rauspulen.
type Entry struct {
Timestamp time.Time `json:"timestamp"`
RuleID string `json:"rule_id,omitempty"` // aus oob.prefix "edgeguard:42 " → "42"
Prefix string `json:"prefix,omitempty"` // raw oob.prefix (zum debuggen)
SrcIP string `json:"src_ip,omitempty"`
DstIP string `json:"dst_ip,omitempty"`
SrcPort int `json:"src_port,omitempty"`
DstPort int `json:"dst_port,omitempty"`
Proto string `json:"proto,omitempty"` // "tcp"/"udp"/"icmp"/"icmpv6"
IfIn string `json:"if_in,omitempty"`
IfOut string `json:"if_out,omitempty"`
PktLen int `json:"pkt_len,omitempty"`
Action string `json:"action,omitempty"` // ulogd setzt das pauschal auf "blocked" — wir leiten besser aus Prefix ab
}
// Filter ist der Query-Param-Container für GET /firewall/log.
// Empty-String-Felder = kein Filter.
type Filter struct {
Since time.Time // events >= since
Until time.Time // events <= until (Zero = jetzt)
RuleID string
SrcIP string
DstIP string
Proto string // "tcp"/"udp"/"icmp"/...
Action string
Limit int // max entries (default 200)
}
// Matches prüft ob ein Entry dem Filter entspricht.
func (f *Filter) Matches(e *Entry) bool {
if !f.Since.IsZero() && e.Timestamp.Before(f.Since) {
return false
}
if !f.Until.IsZero() && e.Timestamp.After(f.Until) {
return false
}
if f.RuleID != "" && e.RuleID != f.RuleID {
return false
}
if f.SrcIP != "" && e.SrcIP != f.SrcIP {
return false
}
if f.DstIP != "" && e.DstIP != f.DstIP {
return false
}
if f.Proto != "" && !strings.EqualFold(e.Proto, f.Proto) {
return false
}
if f.Action != "" && !strings.EqualFold(e.Action, f.Action) {
return false
}
return true
}
// ReadTail liest die letzten Events aus path die zum Filter passen.
// Bewusst forward-read mit limit-Ringbuffer — das jsonl-File ist
// rotated (logrotate, 14d), also nie absurd groß. Bei >10 MB wäre
// reverse-read sinnvoller; das machen wir wenn's relevant wird.
func ReadTail(path string, f Filter) ([]Entry, error) {
if f.Limit <= 0 {
f.Limit = 200
}
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []Entry{}, nil // noch keine Events — leeres Result, kein Fehler
}
return nil, err
}
defer file.Close()
// Ring-Buffer für die letzten f.Limit matching entries.
buf := make([]Entry, 0, f.Limit)
sc := bufio.NewScanner(file)
// ulogd-Lines können breit werden (Mac-Header + alle Optional-Fields).
// Default-Buffer 64 KB reicht knapp; wir geben 1 MB.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 || line[0] != '{' {
continue
}
e, err := parseLine(line)
if err != nil {
continue // fehlerhafte Zeile überspringen, nicht abbrechen
}
if !f.Matches(&e) {
continue
}
if len(buf) >= f.Limit {
// FIFO-rotate
buf = append(buf[1:], e)
} else {
buf = append(buf, e)
}
}
return buf, sc.Err()
}
// rawEntry mirrors the JSON-shape that ulogd2-json emits. Wir
// unmarshallen NICHT direkt in Entry weil ulogd Felder mit Punkten im
// Namen liefert ("ip.protocol", "oob.in", "oob.prefix") — Go-Tags
// können das, aber wir wollen außerdem Type-Coercion (Strings die
// numeric sind, missing fields, etc.).
type rawEntry struct {
Timestamp string `json:"timestamp"`
OobPrefix string `json:"oob.prefix"`
OobIn string `json:"oob.in"`
OobOut string `json:"oob.out"`
SrcIP string `json:"src_ip"`
DstIP string `json:"dest_ip"`
SrcPort int `json:"src_port"`
DstPort int `json:"dest_port"`
IPProto int `json:"ip.protocol"`
PktLen int `json:"raw.pktlen"`
Action string `json:"action"`
ICMPType *int `json:"icmp.type"`
ICMP6Type *int `json:"icmpv6.type"`
}
func parseLine(b []byte) (Entry, error) {
var r rawEntry
if err := json.Unmarshal(b, &r); err != nil {
return Entry{}, err
}
e := Entry{
Prefix: strings.TrimSpace(r.OobPrefix),
SrcIP: r.SrcIP,
DstIP: r.DstIP,
SrcPort: r.SrcPort,
DstPort: r.DstPort,
IfIn: r.OobIn,
IfOut: r.OobOut,
PktLen: r.PktLen,
Action: r.Action,
}
// Timestamp parsen — ulogd liefert RFC3339 mit Zone-Offset.
if r.Timestamp != "" {
if t, err := time.Parse(time.RFC3339Nano, r.Timestamp); err == nil {
e.Timestamp = t
} else if t2, err := time.Parse("2006-01-02T15:04:05-0700", r.Timestamp); err == nil {
e.Timestamp = t2
}
}
// RuleID aus prefix extrahieren: "edgeguard:42" → "42".
if strings.HasPrefix(e.Prefix, "edgeguard:") {
e.RuleID = strings.TrimSpace(strings.TrimPrefix(e.Prefix, "edgeguard:"))
}
// Proto-Mapping aus IP-Protocol-Number.
switch r.IPProto {
case 1:
e.Proto = "icmp"
case 6:
e.Proto = "tcp"
case 17:
e.Proto = "udp"
case 58:
e.Proto = "icmpv6"
default:
if r.IPProto != 0 {
e.Proto = strconv.Itoa(r.IPProto)
}
}
return e, nil
}
// parseReader ist exportiert für Tests + den Tailer, der zeilenweise
// parsed statt eine ganze Datei zu lesen.
func ParseReader(rd io.Reader) ([]Entry, error) {
sc := bufio.NewScanner(rd)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
out := []Entry{}
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 || line[0] != '{' {
continue
}
e, err := parseLine(line)
if err != nil {
continue
}
out = append(out, e)
}
return out, sc.Err()
}
// ParseLine ist exportiert für den Tailer (eine Zeile vom inotify-Event).
func ParseLine(b []byte) (Entry, error) { return parseLine(b) }