User-Feedback: das Live-Log zeigte nur die Smoke-Test-Snapshots von gestern weil keine einzige Firewall-Rule den log-Flag hatte. „Das ist kein Live-Log." Fix: das nft-Template emittiert jetzt am Ende der input und forward chain einen `limit rate 10/second log prefix "edgeguard:drop-*" group 0` direkt vor dem default `policy drop`. Damit fließen ALLE Pakete die keine Custom-Rule erlaubt hat ins Log — ohne dass der Operator pro Rule den Log-Switch setzen muss. limit rate 10/second burst 5: schützt vor Log-Floods durch Port- Scanner, ohne die normale Visibility zu verlieren. Bei einer typischen Edge-Box mit 99% Drop auf WAN-Inbound liegt das Volumen so bei ~300 Events/min = 5MB/h gzipped — logrotate keeps 14 days. Reader: drop-input/drop-forward-Prefix wird NICHT als RuleID gemappt (es gibt keine zugehörige Rule), Action explizit auf "drop". UI rendert die mit eigenem Tag "default-input" / "default-fwd" (volcano-Farbe) in der Rule-Spalte. Verifiziert auf der Box: 26 echte Drop-Pakete in 5s nach Re-render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
7.1 KiB
Go
236 lines
7.1 KiB
Go
// 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".
|
|
// Spezial-Prefixes für default-policy-Logs werden NICHT als RuleID
|
|
// gemappt — die haben keine echte Rule-Referenz und sollten im UI
|
|
// als "default-drop"-Marker erscheinen statt als Rule-Tag.
|
|
if strings.HasPrefix(e.Prefix, "edgeguard:") {
|
|
id := strings.TrimSpace(strings.TrimPrefix(e.Prefix, "edgeguard:"))
|
|
switch id {
|
|
case "drop-input", "drop-forward":
|
|
// kein RuleID — Prefix bleibt verfügbar, UI rendert ihn
|
|
// als "DEFAULT-DROP" mit Hook (-input/-forward) als
|
|
// Hinweis-Tag. Action ist eindeutig drop.
|
|
e.Action = "drop"
|
|
default:
|
|
e.RuleID = id
|
|
}
|
|
}
|
|
// 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) }
|