// 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) }