Zwei keepalived-Flapping-Restursachen:
1) Strukturell: der Cluster-Push (autoRegister, Primary→Secondary) trägt KEINE
public_ip → der Empfänger AgentRegisterPeer ließ sie NULL → Peer fehlte im
nft-peer_ipv4-Set → VRRP-Adverts (eth0/VI_1) nur via conntrack → Flapping.
(utm-1 lernte utm-2 korrekt via Joiner-Client-IP in preRegisterJoiner; die
Gegenrichtung fehlte.) Fix: AgentRegisterPeer fällt bei leerem req.PublicIP
auf die mTLS-Client-IP (c.ClientIP()) zurück — die EIGEN-IP des Peers, nicht
die VIP. Selbstheilend, überlebt Re-Joins/Failover.
2) advert_int 1 → 2 (Master-Down ~6s statt ~3s): reißt nicht mehr bei kurzen
VM-/Heartbeat-Hiccups (VI_HB). Trade-off: Failover-Erkennung ~6s.
Tests: advert_int 2 im Render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Zwei Bugs, die WireGuard-Verbindungen verhinderten/abrissen:
1) Client-Config-Endpoint war hartkodierter Platzhalter REPLACE_WITH_PUBLIC_HOST → neue Clients bauten nie einen Tunnel auf (Host löst nicht auf). Jetzt: WireguardHandler.PublicHost (aus setup.json FQDN, main.go) → Endpoint = <fqdn>:<port>. Platzhalter nur noch als Fallback wenn FQDN unbekannt.
2) Renderer machte bei JEDER Config-Änderung 'systemctl restart wg-quick@<iface>' → voller Link-Flap, alle Peers droppen (verstößt gegen 'wireguard darf nie abbrechen'). Jetzt: laufendes Interface → 'wg-quick strip | wg syncconf' (Peers/Listen-Port live, KEIN Abbruch); nur erstmaliges Hochfahren via systemctl start; restart nur noch als Fallback mit WARN. interfaceExists() via 'ip link show'. Neue sudoers: wg syncconf *, wg-quick strip *.
Ein edgeguard-api-Restart (Deploy) fasst wg-quick@<iface> nicht an → Tunnel bleibt während Deploy bestehen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Das Dashboard-Service-Grid (servicesToCheck in system.go) listete weder freeradius (RADIUS, v1.2.93) noch kea-dhcp4-server (DHCP, v1.2.92). Beide sind via Depends installiert + default-disabled → erscheinen jetzt als 'Inaktiv' bis aktiviert. systemctl show liefert für disabled Units sauber inactive, kein Fehler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
- 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>
- 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>
- FinishRollingUpdateIfPending() auf API-Startup: transitiert
updating-primary → done damit der UI-Flow nach Restart abschließt
- RollingUpdateStatus: setzt done nach Auslieferung auf idle zurück
(verhindert Stale-done bei Page-Reload)
- wasRollingActiveRef: reagiert auf done nur wenn rolling in DIESER
Session aktiv war — kein sofortiger Reload bei Stale-State
- UI-Fallback für updating-primary: poll auf /system/health version-flip
- Cluster-Erkennung via /cluster/status; Rolling-Update-Button nur im Cluster
- Update-Banner-Button nicht mehr gequetscht (flex-shrink:0 + nowrap)
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>
AgentRegisterPeer löschte bisher nur den neuen prenode-{fqdn}-Placeholder.
Alte pre-{timestamp}-Rows (aus Versionen vor 1.1.158) blieben stehen und
zeigten dauerhaft status=joining.
Fix: DeletePlaceholdersByFQDN löscht ALLE ha_nodes-Rows mit gleicher FQDN
außer der echten Node-ID — unabhängig vom ID-Format.
Auch preRegisterByFQDN nutzt jetzt das stabile prenode-{fqdn}-Format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. preRegisterJoiner now runs SYNCHRONOUSLY before IssueCert responds,
so nftables @peer_ipv4 is updated before the joiner calls autoRegister.
Previously it was a goroutine → race → autoRegister failed → "joining"
forever.
2. Stable node ID for pre-registered placeholder (prenode-{fqdn}) instead
of time-based ID — re-joins are now idempotent.
3. AgentRegisterPeer sets status="online" immediately (peer proved it is
online by connecting via mTLS) and deletes the prenode-{fqdn} placeholder.
4. autoRegister retries 3× with 2s delay in case of transient nftables lag.
5. Auth federation: cluster nodes forward failed logins to the primary via
mTLS /agent/auth/check so users can log in on any node with primary
credentials (no PG replication needed).
- SystemHandler.AgentAuthCheck: new endpoint on :8443
- AuthHandler.checkWithPrimary: mTLS call to primary when local auth fails
- AuthHandler.WithClusterTLS: inject cluster TLS store
- startAgentListener now uses the wired systemHdl with Users repo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On API restart, cluster nodes now re-register their primary in the local
ha_nodes and reload nftables so @peer_ipv4 is correct after a package
update or reboot without requiring a re-join.
Also fixes duplicate ha_nodes rows: preRegisterPrimary previously used
time.Now().UnixNano() as node ID, creating a fresh row each call.
Now uses a deterministic ID derived from the FQDN so repeated upserts
are idempotent.
PrimaryFQDN is now persisted in setup.json during CompleteAsNode so the
startup sync knows which primary to contact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Nach erfolgreichem cluster-join trägt der Joining-Node (utm-2) den Primary
per DNS-Lookup in seine lokale ha_nodes ein und lädt nftables neu.
Damit ist @peer_ipv4 auf utm-2 sofort mit utm-1's IP befüllt und Port 8443
ist bidirektional offen — der Aggregator auf utm-1 kann utm-2 auf :8443
abklappern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cluster-Seite: Schritt 2 fragt jetzt den FQDN des neuen Knotens bevor
der Token generiert wird. Der Knoten wird sofort in ha_nodes (status=pending)
eingetragen. Nach Token-Generierung wird direkt die Setup-Wizard-URL des
neuen Knotens angezeigt (https://<fqdn>:3443/setup).
Backend: POST /cluster/join-tokens nimmt jetzt node_fqdn entgegen,
pre-registriert via preRegisterByFQDN(). Die IP wird beim issue-cert
nachgetragen (preRegisterJoiner).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IssueCert registriert den beitretenden Node jetzt direkt in ha_nodes
(public_ip = ClientIP, status='joining') und triggert den Firewall-Reload,
bevor die Response zurückgeht. Damit ist Port 8443 schon offen wenn der
Node im nächsten Schritt auto-register via mTLS versucht.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sicherheit des Joins kommt vom HMAC-Token, nicht von TLS-Cert-Trust.
Insecure=true wird jetzt immer im Handler gesetzt — GUI-Nutzer müssen
das nicht kennen oder konfigurieren.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Der API-Boot erstellt für Single-Node-Betrieb automatisch ein selbst-signiertes
peer-Cert. Beim Join über die GUI wurde das als Fehler gemeldet.
clusterjoin.Request.Force=true im Setup-Handler überspringt die HasPeer-Prüfung;
CLI (edgeguard-ctl cluster-join) bleibt vorsichtig (Force=false, manuelles rm nötig).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
POST /setup/join-cluster macht alles was bisher edgeguard-ctl cluster-join
tat: CSR generieren, Certs vom Primary holen, schreiben, auto-registrieren,
Setup als Cluster-Node markieren.
Setup-Wizard Node-Modus fragt jetzt direkt Primary-FQDN + Join-Token ab.
Nach Submit: Erfolgsmeldung + einziger verbleibender Schritt (systemctl restart).
Neue interne Bibliothek: internal/services/clusterjoin — wird von Handler
und CLI (edgeguard-ctl cluster-join) gleichermaßen genutzt, keine Duplizierung.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Setup-Wizard zeigt jetzt beim ersten Aufruf eine Modusauswahl:
- "Neuinstallation" → bisheriger Flow (Admin-Account + FQDN + ACME)
- "Cluster-Knoten beitreten" → nur FQDN + ACME, kein Admin-Account;
nach dem Submit werden die cluster-join-Befehle direkt angezeigt
Backend: NodeRequest + Store.CompleteAsNode() + POST /setup/complete-node
State: is_cluster_node Flag; login via PG-Replikation vom Primary
Install-Script: Setup-URL zeigt jetzt korrekt :3443/setup statt /setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upgrade war die einzige system-Mutation ohne Audit-Eintrag. Alle
anderen (maintenance, service-restart, haproxy-reload, IPv6, render-
configs, auto-update) loggen bereits. Jetzt wird system.upgrade mit
actor + unit-Name geloggt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Login handler hatte h.Audit injiziert (mit Kommentar "login-success/fail
ins audit_log fließen") aber nie aufgerufen. Jetzt werden geloggt:
- auth.login.failed (reason: invalid_credentials | account_disabled)
- auth.login.success (mit role + remote IP)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DeleteZone, DeleteRecord und NTPPool.DeletePool hatten keine Audit-
Einträge. Alle anderen Mutations derselben Handler loggen korrekt —
nur Delete war vergessen worden. Jetzt konsistent:
dns.zone.delete, dns.record.delete, ntp.pool.delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vorher hat ChangePassword das Passwort ausschließlich gegen den
setup-store-Admin geprüft und dort gespeichert. User die via
User-Management angelegt wurden, bekamen immer 401.
Fix: DB-Lookup via Users.FindByEmail, Verify + SetPassword im DB-Store.
Setup-Store-Admin bleibt synchron wenn die E-Mail übereinstimmt.
Fallback auf setup-store bleibt erhalten für Legacy-Installs (pre-DB).
Bonus: %m-Formatverb-Fehler im Scheduler-Alert-Text behoben.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api_backend (management API) and rl_* (rate-limit stick-tables) were
appearing in the dashboard's backend health card alongside customer
backends. Skip these internal HAProxy proxy entries in the stats parser.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two root causes for Dashboard backends not showing:
1. haproxy.cfg.tpl had no stats socket directive → /run/haproxy/admin.sock
never created → handler got "connection refused" → empty array returned.
Fixed: added stats socket /run/haproxy/admin.sock mode 0660 level admin.
2. safeAt() used i<=0 instead of i<0 → pxname (column 0) always returned ""
→ all backend names were empty in the API response.
After upgrade: HAProxy reload (triggered by any domain/backend save, or
edgeguard-ctl render-config) will create the socket. v1.1.113.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GET /ntp/sources endpoint (runs chronyc sources, parses tabular
output) and a new "Peer status" tab in the NTP page showing all
configured peers with mode, state badge, stratum, poll interval,
reach register, last-rx and offset/error sample.
v1.1.101
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>