Bisher pushte nur der Secondary seine Liveness an den Primary (runPrimaryPush). Der Primary pushte nichts → in der lokalen ha_nodes des Secondary fror die Primary-Row nach dem Boot ein → die vom Secondary ausgelieferte UI zeigte den Primary als offline.
Neu: runPeerPush auf dem Primary/Founder pusht alle 30s self (role=primary) an jeden Peer via mTLS (/agent/cluster/peers). PushSelfToPeer(role) generalisiert PushSelfToPrimary; registerPeerRequest+AgentRegisterPeer akzeptieren ein role-Feld (default 'peer' → joining-Peer-Verhalten unverändert). Peer-Register-Log bei Routine-Pushes auf Debug (Info nur bei neuem Peer/IP-Wechsel) gegen 30s-Spam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix 1 — Peer zeigt fälschlich 'offline': runPrimaryPush (Secondary→Primary, einziger periodischer Cross-Node-ha_nodes-Refresh) tickte mit 5 min, SweepStaleNodes-Threshold ist aber 2 min → Secondary war 2 min online, dann 3 min offline, im 5-min-Takt. Tick auf 30s (4× Marge unter Threshold). Receiver lädt nftables nur bei IP-Änderung → kein Reload-Sturm.
Fix 2 — Rolling-Update konnte nie fertig werden wenn der Secondary die Zielversion schon hatte (baseline==target → Warten auf unmöglichen Flip → 10-min-Timeout). runRollingUpdate ist jetzt candidate-aware: ermittelt apt-Candidate, überspringt den Secondary-Schritt wenn dieser schon aktuell ist, erkennt den Flip via 'erreicht candidate ODER bewegt sich von baseline', und schließt direkt mit 'done' wenn auch der Primary schon aktuell ist. FinishRollingUpdateIfPending setzt hängende updating/waiting-secondary-Phasen beim Boot auf idle zurück (tote Orchestrierungs-Goroutine nach Restart).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#15 waf/alerts.go: AlertWriter.Close() flusht gepufferte Alerts + stoppt die Goroutine (stop/done-Channels, sync.Once, atomic closed; Kanal wird NIE geschlossen → Send racet ohne Panic). Wiring in cmd/edgeguard-waf nach ListenAndServe (graceful shutdown). -race-Test alerts_test.go.
#19 handlers/cluster_rollingupdate.go: (a) RollingUpdateStatus mutiert State nicht mehr beim GET — terminale Zustände altern in readRollingUpdateState nach 10 min aus (kein verlorenes 'done' bei parallelen Pollern). (b) State-File via sync.Mutex + configgen.AtomicWrite (kein partieller Read / Race zwischen Handler & Goroutine). (c) Version-Flip wird gegen die VORHER erfasste Secondary-Baseline geprüft statt gegen die Primary-Version (verhindert sofort-/nie-Flip).
Bewusst belassen: geteilter upgrade.sh-Pfad ist deterministischer Inhalt + an exakte sudoers-Zeile gebunden → Überschreib-Race benign; MST-Timestamp-Parse locale (Server laufen C-Locale).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bisher rendete das Template alle Adress-Matches als 'ip saddr/daddr' (v4-only); ein v6-Eintrag hätte 'nft -f' (und damit das ganze Ruleset) gebrochen. Jetzt:
- Adressausdrücke werden je Eintrag als v4/v6 klassifiziert (addrFamily).
- Regeln mit Adressen werden pro Familie als separate nft-Zeile gerendert (ip vs ip6 saddr/daddr); adresslose Regeln bleiben eine familienagnostische Zeile (v4-Verhalten unverändert).
- icmp nur auf v4-, icmpv6 nur auf v6-Zeilen.
- NAT familienbewusst inkl. v6-DNAT-Target [..]:port; gemischte v4/v6-NAT-Regeln werden übersprungen (statt nft -f zu brechen) + geloggt.
- WireGuard site-to-site Masquerade v6-fähig.
Eingabeseite war bereits v6-fähig (validateAddrObjValue/validateRule via net.ParseIP/ParseCIDR; Service-Proto-CHECK erlaubt icmpv6; Builtin PING-v6). Neue Unit-Tests (firewall_ipv6_test.go) inkl. optionalem 'nft -c'-Syntaxcheck.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
network_interfaces + ip_addresses standen in confighash hashSpec, aber in cluster_replication.go localOnlyTables (= nicht repliziert, node-spezifische IPs). Dadurch waren die config_hash-Werte zweier Nodes ZWANGSLÄUFIG dauerhaft verschieden → Drift-Banner, das kein Resync beheben konnte. Beide Tabellen aus dem Hash entfernt; Drift erkennt jetzt nur noch wirklich replizierte Service-Config. Muss auf BEIDEN Nodes installiert sein (gleicher hashSpec für vergleichbare Hashes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug: Dispatch an utm-2 schlug fehl ('dieser Node ist der Cluster-Primary'), weil ha_nodes je Node lokal ist und sich JEDE Node selbst als role=primary markiert. Fix: Primary-Erkennung über pg_publication (edgeguard_shared, für jeden DB-User lesbar) statt role/pg_role. Primary gibt dem Subscriber seine eigene Adresse als primary_host mit (PostPeerWithBody); Agent-Handler vertraut dem mTLS-Dispatch mit Safety-Guard 'läuft nie auf dem Publication-Primary'. Funktioniert auch bei Direktzugriff auf den Subscriber. UI-Gating vereinfacht (Drift + Peer + Admin).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pg_role bleibt nach cluster-setup-standby auf 'standalone' (nur 'promote' setzt 'primary'), daher erschien der Button auf dem Primary (role=primary, pg_role=standalone) nicht. Gating + Dispatch + Status nutzen jetzt isPrimaryNode = role=='primary' || pg_role=='primary' (wie keepalived); Resync-Ziel = Nicht-Primary-Peer. Backend (cluster_repair.go) + UI (Cluster/index.tsx).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cluster/Replication:
- Drift-Banner: Button 'Resync erzwingen' baut die PG-Logical-Replication-
Subscription neu auf (via edgeguard-ctl cluster-setup-standby).
- Primary-Dispatch: Button auf dem Primary delegiert per mTLS an den
Standby (POST /agent/cluster/repair-replication); auf dem Standby lokal.
- Status-Proxy Primary->Standby via Aggregator.FanOut; Erfolg = Job-success
ODER drift_found wird false (--collect-Unit verschwindet nach Erfolg).
- Job als transiente systemd-Unit edgeguard-repair-replication.service
(sudoers exact-match + festes Script wie upgrade.sh).
- Banner-Text korrigiert (keine 'Outbox').
Frontend-Stabilität:
- Stale-Chunk-Auto-Reload: Lazy-Import-Fehler nach Deploy ('Failed to fetch
dynamically imported module') lösen einen einmaligen Reload aus (Loop-
Schutz via sessionStorage) statt einer Fehlerseite. Globaler
vite:preloadError-Listener + ErrorBoundary-Integration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CRS 900xxx/901xxx (init, body inspection, paranoia setup) feuern auf
JEDEM Request — keine Security-Events. Filter: nur rule_id >= 910000
wird als Alert in DB geschrieben. Buffer 512 → 2048.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- crsRules.ts neu: 331 echte CRS v4.7.0 Regeln aus installierten Dateien
(v3-Nummernschema war falsch, v4 hat andere IDs — 949152 war skip-Regel)
- spoe.go: Regeln ohne Message nicht als Alert speichern
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration 0039: exclusion_notes JSONB in waf_configs (rule_id → note)
- Model/Service/Handler: exclusion_notes in Upsert + GET durchgereicht
- Alerts-Tab: "Als Ausnahme"-Button öffnet Modal mit Notiz-Textarea;
Notiz wird in exclusion_notes gespeichert
- Config-Drawer: Ausnahmen als Liste (Rule-ID + Notiz + Entfernen-Button)
statt rohem Textfeld; Ausnahmen nur noch via Alert-Tab hinzufügbar
- i18n EN + DE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jede Alert-Zeile hat jetzt einen "Als Ausnahme"-Button. Klick:
1. Lädt aktuelle WAF-Config der betroffenen Domain
2. Fügt die Rule-ID zu rule_exclusions hinzu (dedupliziert)
3. Speichert via PUT /waf/configs/:domain_id → triggert HAProxy-Reload
4. Erfolgsmeldung + WAF-Config-Query invalidiert
Button disabled wenn domain_id fehlt (Domain nicht aufgelöst) oder Viewer-Rolle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HAProxy 3.x kennt weder 'option tcplog' in Backends noch 'no option forwardfor'
als Negation — beides ALERT-Fehler die HAProxy am Start hindern.
SPOE-Backend benötigt weder forwardfor noch tcplog.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Manager.Reload() hat bisher bei jedem 30s-Tick alle Engines neu gebaut
(BuildEngine mit CRS = 2-5s). Fix: configKey (enabled, mode, paranoia_level,
updatedAt) cachen — Engine wird nur neu gebaut wenn sich der Key ändert.
Spart CPU und verhindert sporadische Latenzen im SPOE-Handling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SPOE-Backend ist TCP-Mode — forwardfor aus defaults-Block gilt nicht,
HAProxy wirft Warning. no option forwardfor explizit setzen unterdrückt das.
timeout server auf 1s angehoben (konsistent mit processing-Timeout).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SPOE-Config: option continue-on-error — HAProxy blockt nie wegen
SPOE-Timeout (z.B. CRS-Engine-Load beim ersten Request). Ohne dieses
Flag waren alle Requests geblockt wenn der WAF-Agent kurz nicht
antwortete, auch für Domains ohne WAF-Konfiguration.
- SPOE-Config: timeout processing 50ms → 1s — CRS-Load braucht >50ms
- postinst: *.conf → * beim CRS-Copy — .data-Dateien wurden nicht
mitkopiert, SecRule @pmFromFile scanners-user-agents.data fehlte
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pages/WAF/index.tsx: neue WAF-Seite mit Status-Strip,
Domänen-Tabelle (Toggle/Mode/PL) + Konfigurations-Drawer pro Domain
(enabled, mode, paranoia_level 1-4, rule_exclusions, trusted_proxies,
custom_rules). Quick-Toggle ohne Drawer; Hinweis: erst Detection, dann Blocking.
- App.tsx: /waf Route + lazy import
- Sidebar.tsx: WAF im Security-Bereich
- i18n EN + DE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ErrorBoundary lag in main.tsx außerhalb des Routers und hatte keinen
Zugriff auf useLocation. Einmal gecatchter Fehler blieb erhalten bis
zum nächsten Reload — daher "EdgeGuard konnte nicht laden" bei
Navigation zum Dashboard.
Fix: LocationKeyBoundary-Wrapper innerhalb des BrowserRouter mit
key={pathname} — React remountet die ErrorBoundary bei jedem
Routenwechsel und löscht damit den Error-State automatisch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Regeln werden standardmäßig nach Zone-Pair (src→dst) gruppiert
- Jede Gruppe hat einen farbigen Header mit Zone-Badges + Regelanzahl
- Toggle-Button in der Filter-Bar: Gruppen-Ansicht ↔ flache Liste
- Move-up/down bleibt global korrekt (Priority über alle Gruppen)
- CSS: .fw-zone-section* mit nahtlosem Header → Tabelle Übergang
- i18n EN + DE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wenn CrowdSec gestoppt war, hing ServiceStatus auf cscli decisions/alerts/
bouncers/machines (Socket des gestoppten Agents). Der Status-Endpoint
antwortete nie → status=undefined im UI → Switch war disabled (rotes Schild).
Fix: cscli-Datenabrufe (decisions, alerts, bouncers, machines) nur wenn
AgentRunning=true. Version-Abruf via cscli version bleibt (schlägt schnell
fehl wenn Agent gestoppt, kein Hang).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Migration 0035: note TEXT + labels TEXT[] für firewall_rules + nat_rules
- PATCH /firewall/rules/:id + /nat-rules/:id für note/labels Updates
- InlineNote: gold Tag mit MessageOutlined, Klick zum Bearbeiten (Enter/Blur speichert)
- InlineLabels: geekblue Tags mit X-Button zum Entfernen, "+" zum Hinzufügen
- Name-Spalte: Name + Labels + Note in Zeile 1, comment/auto-desc in Zeile 2
- Gleiche UX für NAT-Regeln
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
applyAsync()-Goroutinen in networks.go und ipaddresses.go nutzten
c.Request.Context() — der wird gecancelt wenn der Handler zurückkehrt,
was zu "query: context canceled" in jedem Apply-Lauf führte.
Fix: context.Background() direkt in der Goroutine.
StartLimitIntervalSec=0 in beiden apply-Services verhindert dass
systemd bei mehrfachen schnellen Triggers (Burst > 5) drosselt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fügt zwei neue systemd-oneshot-Services hinzu:
- edgeguard-interfaces.service: erstellt VLAN/bridge/bond-Interfaces
(ip link add/del) wenn der Operator sie über die GUI anlegt/entfernt.
Ethernet + WireGuard bleiben OS-managed.
- edgeguard-ipaddresses.service: bindet/entfernt IP-Adressen (ip addr
add/del) nach jeder GUI-Mutation; läuft jetzt After=edgeguard-interfaces
damit Interfaces immer vor den Adressen existieren.
Beide Services triggern per applyAsync() in den zugehörigen Handlern
(networks.go → Interfaces, ipaddresses.go → Adressen). Diff-Ansatz
über *-applied.conf verhindert dass manuell gebundene Adressen/
Interfaces angefasst werden.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cluster-init-replication:
- listen_addresses = '*' damit Cluster-Peers PG auf :5432 erreichen können
- max_replication_slots = 20 / max_wal_senders = 10 (verhindert Slot-Erschöpfung
bei initaler Tabellen-Synchronisation mit vielen gleichzeitigen Sync-Workern)
- pg-replication-secret: Ownership an edgeguard-User (API-Lesezugriff)
- detectPGConfig() statt hardcoded PG 16 (System läuft PG 17)
cluster-setup-standby:
- syncMasterKey(): holt /var/lib/edgeguard/.master_key via mTLS vom Primary —
ohne identischen Master-Key können replizierte WireGuard-Keys nicht entschlüsselt werden
- render-config: sudo -u edgeguard statt als root (DB-Zugriff)
nftables Template:
- Port 5432 (PG) + 6379 (KeyDB) für Cluster-Peers (@peer_ipv4/@peer_ipv6) freigegeben
handlers/cluster.go:
- GET /agent/cluster/master-key: gibt .master_key via mTLS zurück (hex-kodiert)
v1.2.15
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /cluster/rolling-update startet den gestaffelten Upgrade-Prozess:
1. Secondary via mTLS /agent/cluster/trigger-update anstoßen
2. /agent/cluster/version pollen bis Secondary Version-Flip zeigt (max 10 min)
3. Primary self-upgrade via systemd-run (identisch zu /system/upgrade)
State wird in /var/lib/edgeguard/rolling-update-state.json persistiert:
Phasen: updating-secondary → waiting-secondary → updating-primary.
"done" wird nicht geschrieben — Prozess stirbt beim Upgrade. UI erkennt
Abschluss via /system/health version-flip (analog Single-Node-Upgrade).
UI: UpdateBanner erkennt Cluster-Modus (/cluster/status mode="cluster")
und tauscht den "Install now"-Button gegen "Rolling Update (Cluster)" aus.
Multi-Step-Modal zeigt die drei Phasen; ab updating-primary wechselt der
Client auf /system/health polling.
Aggregator.PostPeer: neuer einzel-POST-Helper für mTLS-trigger-update.
WithVersion(): ClusterHandler bekommt Binary-Version für /agent/cluster/version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ha_nodes hat UNIQUE(fqdn). UpsertSelf nutzt ON CONFLICT(id) — schlägt
bei fqdn-Konflikten fehl. Wenn Placeholder und echter Node die gleiche
FQDN haben, schlug der INSERT der echten Row mit "duplicate key on
ha_nodes_fqdn_unique" fehl.
Root cause für "joining" stuck forever: der Peer blieb ewig als
Placeholder weil AgentRegisterPeer + reconcileJoiningPeers die Echte-
ID-Row nie erfolgreich einfügen konnten.
Fix: DeletePlaceholdersByFQDN vor UpsertSelf in beiden Code-Paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wenn autoRegister (Push utm-2→utm-1) fehlschlägt, bleibt der Peer
dauerhaft im 'joining'-Status. Neuer Self-Healing-Mechanismus:
- GET /agent/cluster/identity auf dem Agent-Listener: gibt die eigene
ha_nodes-Row zurück (echte Node-ID, FQDN, Version)
- ClusterHandler.Status() startet für jeden 'joining'/'pending'-Peer
einen Background-Reconcile via Aggregator: holt Identity, upsertet
mit echter ID (public_ip aus Placeholder übernommen für nftables),
löscht Placeholder
- autoRegister-Fehler werden jetzt als Warn-Log sichtbar statt silent
verworfen
Damit reicht ein Cluster-Page-Aufruf nach Update beider Nodes um den
Status von 'joining' auf 'online' zu bringen.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>