7 Commits

Author SHA1 Message Date
Debian
7611572062 refactor(cluster): promote auf Logical-Replication umgestellt + internal/proxy-Stub entfernt — v1.2.99
Code-Altlasten aus dem Architektur-Audit bereinigt:
- internal/proxy: leerer .gitkeep-Stub (geplanter Write-Proxy nie implementiert) entfernt — keine Go-Referenzen.
- promote.go: war reines Physical-Replication-Failover (standby.signal + pg_ctlcluster promote + pg_is_in_recovery) und damit auf dem Logical-Setup TOT (ein Subscriber hat kein standby.signal / ist nie in recovery → Abbruch bei Schritt 1). Neu Logical-aware: Idempotenz-Check (schon Publisher ohne Subscription → fertig) → Subscription lösen (DISABLE+slot_name=NONE+DROP, hängt nicht am toten Publisher) → setupReplicationPrimary (Publisher werden) → ha_nodes.pg_role=primary → keepalived MASTER. Toter KeyDB-Update (cluster:pg-primary-url, wurde nie gelesen) entfernt.
- setupReplicationPrimary + dropSubscriptionIfExists aus cluster-init-replication/cluster-setup-standby extrahiert (DRY, bewährte SQL wiederverwendet). WICHTIG: setupReplicationPrimary stellt jetzt sicher dass wal_level=logical AKTIV ist — PG-RESTART falls nötig (reload reicht für wal_level/max_wal_senders nicht; Secondary hat wal_level=replica). Idempotent: Restart nur wenn wal_level != logical.
- Doku (CLAUDE.md + architecture.md) auf den bereinigten Stand gezogen.
Hinweis: echtes Cross-Node-Failover ist nur im Drill testbar; Build/vet/Tests grün, Bausteine sind die bereits produktiv genutzten SQL-Primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:26:52 +02:00
Debian
3b5bf578a0 docs(architecture): Cluster/HA-Abschnitte an Ist-Stand angeglichen (code-verifiziert)
architecture.md war Entwurfsstand; Cluster/HA wich stark vom Code ab. Per 5 parallelen Code-Audits verifiziert + korrigiert:
- Replikation: Streaming/physisch → LOGICAL (edgeguard_shared/edgeguard_sub, wal_level=logical, copy_data=true); localOnlyTables dokumentiert; pg_basebackup nur Legacy.
- VIP/Ingress (§9): 'Floating-IP statt VRRP' war invertiert → real keepalived/VRRP (prio aus pg_role, VIPs aus ip_addresses); kein Hoster-API/promote-this-node.
- KeyDB (§7): Active-Active-State-Layer NICHT umgesetzt (kein Redis-Client in go.mod); Cluster-State/Heartbeat/Locks in PostgreSQL; KeyDB optional (Recommends). license-leader/acme:lock/cluster:nodes = nur Kommentare.
- Write-Path: internal/proxy ist leerer Stub; kein Write-Proxy → Writes am Primary.
- Plattform: nur Debian 13 trixie (Pipeline), Ubuntu/noble nicht implementiert.
- §1/§5/§8 + Strukturbaum/Depends/Units an reale Renderer (keepalived/chrony/kea/freeradius/crowdsec/waf) angeglichen. Offene Punkte: Code-Altlasten (proxy-Stub, standby.signal in promote.go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:56 +02:00
Debian
bf211ca273 docs: Feature-Stand nachgezogen — WAF/IDS-IPS/DHCP/RADIUS/OIDC/2FA umgesetzt
CLAUDE.md + architecture.md spiegelten noch den ursprünglichen v1-Scope (WAF/IDS/IPS/DHCP/RADIUS als Nicht-Ziele) wider. Real umgesetzt: WAF (Coraza+SPOE), IDS/IPS (CrowdSec, managed-wenn-installiert), DHCP (Kea), RADIUS (FreeRADIUS), OIDC-SSO, 2FA, IPv6-FW. Nicht-Ziele bereinigt (verbleibend: Suricata-Network-IDS, Docker, Mail, Multi-Tenant, ISO, Debian-only). Stack-Tabelle + Paket-Tabelle/Depends an control angeglichen; edgeguard-waf als Binary im edgeguard-api-Paket dokumentiert. KeyDB als optional (Recommends) korrigiert; Cluster-Primary-Ermittlung via pg_publication/setup.json statt KeyDB; Doku-Drift internal/proxy (nicht vorhanden) markiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:34:10 +02:00
Debian
becd068637 fix(ui): freeradius + kea-dhcp4 in Service-Status-Grid aufnehmen — v1.2.98
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>
2026-06-06 17:05:01 +02:00
Debian
b3dda81b49 feat(cluster): bidirektionaler Peer-Heartbeat (Primary→Secondary Push) — v1.2.97
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>
2026-06-06 13:24:13 +02:00
Debian
b20ace8763 fix(cluster): periodischer Peer-Heartbeat (30s) + Rolling-Update candidate-aware — v1.2.96
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>
2026-06-06 13:10:40 +02:00
Debian
053b38e46c fix: AlertWriter graceful flush (#15) + Rolling-Update Robustheit (#19) — v1.2.95
#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>
2026-06-06 11:21:50 +02:00
14 changed files with 565 additions and 277 deletions

View File

@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
# EdgeGuard Native (`eg`) # EdgeGuard Native (`eg`)
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`. > Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
>
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
--- ---
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) | | **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 | | **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` | | **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) | | **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB | | **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) | | **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon | | **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
| **NTP** | chrony (Distro) |
| **VIP/HA** | keepalived (VRRP) |
| **FW** | nftables (Distro) | | **FW** | nftables (Distro) |
| **Forward-Proxy** | Squid (Distro) | | **Forward-Proxy** | Squid (Distro) |
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
| **TLS** | certbot + webroot-Plugin | | **TLS** | certbot + webroot-Plugin |
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) | | **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
| **Plattform** | Debian 13 Trixie · amd64 + arm64 | | **Plattform** | Debian 13 Trixie · amd64 + arm64 |
--- ---
## Nicht-Ziele (v1) ## Feature-Stand (Stand 2026-06)
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
- **2FA** — TOTP
- **IPv6** — Firewall-Regeln + NAT familienbewusst
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
## Nicht-Ziele (weiterhin)
- **Kein Docker** — alle Dienste nativ unter systemd - **Kein Docker** — alle Dienste nativ unter systemd
- **Kein WAF** (kein Coraza, kein ModSecurity) - **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
- **Kein DHCP-Server** (kein Kea)
- **Kein RADIUS** (kein FreeRADIUS)
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway) - **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
- **Keine Multi-Tenant-GuardZones**
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL - **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
--- ---
@@ -154,7 +177,6 @@ cd management-ui && bun run dev
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS) │ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Generator │ ├── firewall/ # nftables-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery │ ├── cluster/ # Join/Promote/Peer-Discovery
│ ├── proxy/ # Write-Proxy → Cluster-Primary
│ ├── aggregator/ # Cluster-View APIs │ ├── aggregator/ # Cluster-View APIs
│ └── license/ # Lizenz-Validierung │ └── license/ # Lizenz-Validierung
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern) ├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
@@ -185,7 +207,8 @@ cd management-ui && bun run dev
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung - **ORM:** GORM für Queries, nicht für Schema-Verwaltung
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/` - **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben - **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url` - **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
### Packaging ### Packaging
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm - `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm

View File

@@ -1 +1 @@
1.2.94 1.2.99

View File

@@ -194,6 +194,12 @@ func main() {
} }
// runSecondaryConfigRender wird weiter unten gestartet sobald // runSecondaryConfigRender wird weiter unten gestartet sobald
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync). // clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
// Primary/Founder (kein joined Secondary): self (role=primary) an
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
// als offline. No-op solange keine Peers existieren (Single-Node).
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
} }
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem // Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
@@ -826,12 +832,20 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secre
} }
// runPrimaryPush periodically pushes this secondary node's config_hash to the // runPrimaryPush periodically pushes this secondary node's config_hash to the
// primary via mTLS. The primary's ha_nodes view only gets config_hash written // primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
// during join-time autoRegister — after that the primary never hears about // written during join-time autoRegister — after that the primary never hears about
// hash changes unless we push. Without this, the drift banner shows stale // the secondary unless we push. Without this, the drift banner shows stale hashes
// hashes from join-time forever. // from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
// marks it offline.
//
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) { func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
const tick = 5 * time.Minute const tick = 30 * time.Second
t := time.NewTicker(tick) t := time.NewTicker(tick)
defer t.Stop() defer t.Stop()
push := func() { push := func() {
@@ -855,6 +869,51 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
} }
} }
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
push := func() {
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
peers, err := store.List(pCtx)
if err != nil {
slog.Warn("cluster: peer-push list failed", "error", err)
return
}
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
for i := range peers {
p := peers[i]
if p.ID == nodeID {
continue // nicht an sich selbst pushen
}
target := p.APIURL
if target == "" {
target = "https://" + p.FQDN
}
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
}
}
}
push() // immediate push on API startup
for {
select {
case <-ctx.Done():
return
case <-t.C:
push()
}
}
}
func randomEphemeralSecret() []byte { func randomEphemeralSecret() []byte {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {

View File

@@ -115,11 +115,29 @@ func cmdClusterInitReplication(args []string) int {
} }
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster) fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
return 1
}
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
return 0
}
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
func setupReplicationPrimary(pg pgConfig) error {
// 1. Passwort generieren // 1. Passwort generieren
pass, err := generatePassword(32) pass, err := generatePassword(32)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err) return fmt.Errorf("generate password: %w", err)
return 1
} }
// 2. edgeguard_replicator-Rolle anlegen/updaten // 2. edgeguard_replicator-Rolle anlegen/updaten
@@ -133,21 +151,17 @@ BEGIN
END END
$$`, egReplUser, egReplUser, pass, egReplUser, pass) $$`, egReplUser, egReplUser, pass, egReplUser, pass)
if err := psqlExec(roleSQL); err != nil { if err := psqlExec(roleSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err) return fmt.Errorf("create replication role: %w", err)
return 1
} }
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser) fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
// 3. Passwort speichern // 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err) return fmt.Errorf("mkdir: %w", err)
return 1
} }
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil { if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err) return fmt.Errorf("write secret: %w", err)
return 1
} }
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
if u, err := user.Lookup("edgeguard"); err == nil { if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid) uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid) gid, _ := strconv.Atoi(u.Gid)
@@ -156,71 +170,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass)
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret) fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
// 4. conf.d/edgeguard-replication.conf schreiben // 4. conf.d/edgeguard-replication.conf schreiben
// wal_level=logical ist eine Obermenge von replica — unterstützt
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil { if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err) return fmt.Errorf("conf.d mkdir: %w", err)
return 1
} }
replConf := `# EdgeGuard Logical Replication — automatisch generiert replConf := `# EdgeGuard Logical Replication — automatisch generiert
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet. # Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
wal_level = logical wal_level = logical
max_wal_senders = 10 max_wal_senders = 10
max_replication_slots = 20 max_replication_slots = 20
max_logical_replication_workers = 4 max_logical_replication_workers = 4
wal_keep_size = 512MB wal_keep_size = 512MB
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers # '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
# bekannte Replikations-User beschränkt.
listen_addresses = '*' listen_addresses = '*'
` `
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf") confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil { if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err) return fmt.Errorf("write postgresql conf: %w", err)
return 1
} }
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
// 5. pg_hba.conf aktualisieren // 5. pg_hba.conf aktualisieren
if err := ensureHBAReplication(pg.HBAPath); err != nil { if err := ensureHBAReplication(pg.HBAPath); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err) return fmt.Errorf("pg_hba.conf: %w", err)
return 1
} }
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath) fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
// 6. PG reload (damit wal_level + pg_hba aktiv werden) // 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil { if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out) return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
return 1
} }
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster) fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
}
ready := false
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
ready = true
break
}
time.Sleep(2 * time.Second)
}
if !ready {
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
}
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
}
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden // 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
grantSQL := fmt.Sprintf(` grantSQL := fmt.Sprintf(`
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s; GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
`, egReplUser, egReplUser) `, egReplUser, egReplUser)
if err := psqlDBExec("edgeguard", grantSQL); err != nil { if err := psqlDBExec("edgeguard", grantSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err) return fmt.Errorf("grant SELECT: %w", err)
return 1
} }
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser) fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables. // 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
// Idempotent: DROP IF EXISTS + CREATE.
if err := createPublication(); err != nil { if err := createPublication(); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err) return fmt.Errorf("create publication: %w", err)
return 1
} }
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName) fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
return nil
}
fmt.Println() // dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
fmt.Println("Nächste Schritte:") // idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>") // Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren") func dropSubscriptionIfExists() error {
fmt.Println(" → Settings → Cluster → VIP/Keepalived") dropSQL := fmt.Sprintf(`
return 0 DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
return psqlDBExec("edgeguard", dropSQL)
} }
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen // createPublication baut die PUBLICATION dynamisch aus allen Tabellen
@@ -334,15 +365,7 @@ func cmdClusterSetupStandby(args []string) int {
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort) fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
// 2. Bestehende Subscription löschen (idempotent) // 2. Bestehende Subscription löschen (idempotent)
dropSQL := fmt.Sprintf(` if err := dropSubscriptionIfExists(); err != nil {
DO $$ BEGIN
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
ALTER SUBSCRIPTION %s DISABLE;
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
DROP SUBSCRIPTION %s;
END IF;
END $$;`, egSubName, egSubName, egSubName, egSubName)
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK // Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n") fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
} else { } else {

View File

@@ -4,8 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -14,18 +12,22 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" "git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
) )
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby // cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain // Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
// in 2-Node-Clustern ohne externen Quorum zu verhindern. // ohne externes Quorum zu verhindern.
//
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
// (toten/alten) Primary lösen und selbst Publisher werden.
// //
// Ablauf: // Ablauf:
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby) // 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
// 2. pg_ctlcluster promote → PG wird Primary // 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
// 3. Warten bis pg_is_in_recovery() = false // 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
// 4. ha_nodes.pg_role auf 'primary' setzen // + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
// 5. KeyDB cluster:pg-primary-url auf lokal setzen // 4. ha_nodes.pg_role/role = 'primary'
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200) // 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
// 7. keepalived reload
func cmdPromote(args []string) int { func cmdPromote(args []string) int {
pg, err := detectPGConfig() pg, err := detectPGConfig()
if err != nil { if err != nil {
@@ -33,51 +35,43 @@ func cmdPromote(args []string) int {
return 1 return 1
} }
// 1. Standby-Signal prüfen // 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
signalPath := filepath.Join(pg.DataDir, "standby.signal") pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
if _, err := os.Stat(signalPath); os.IsNotExist(err) { fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
fmt.Fprintf(os.Stderr, subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n", fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
signalPath) hasPub := strings.TrimSpace(string(pubOut)) == "1"
return 1 hasSub := strings.TrimSpace(string(subOut)) == "1"
if hasPub && !hasSub {
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
return 0
} }
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster) fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
return 1
}
fmt.Println("✓ pg_ctlcluster promote gesendet")
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false) // 2. Subscription zum alten/toten Primary lösen
fmt.Print("→ Warte auf PG Primary-Mode") if hasSub {
deadline := time.Now().Add(60 * time.Second) if err := dropSubscriptionIfExists(); err != nil {
for time.Now().Before(deadline) { fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"}) return 1
if err == nil && strings.TrimSpace(string(out)) == "f" {
break
} }
fmt.Print(".") fmt.Println("✓ Subscription zum alten Primary entfernt")
time.Sleep(2 * time.Second)
} }
fmt.Println()
// Nochmal prüfen // 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"}) if err := setupReplicationPrimary(pg); err != nil {
if err != nil || strings.TrimSpace(string(out)) != "f" { fmt.Fprintln(os.Stderr, "promote:", err)
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
return 1 return 1
} }
fmt.Println("✓ PostgreSQL ist jetzt Primary")
// 3. ha_nodes.pg_role + role aktualisieren // 4. ha_nodes-Rolle aktualisieren
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv()) pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "promote: db connect:", err) fmt.Fprintln(os.Stderr, "promote: db connect:", err)
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';") fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else { } else {
defer pool.Close() defer pool.Close()
localID, err := loadLocalID() localID, err := loadLocalID()
@@ -93,15 +87,7 @@ func cmdPromote(args []string) int {
} }
} }
// 4. KeyDB cluster:pg-primary-url updaten // 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
if err := updateKeyDBPrimaryURL(); err != nil {
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
} else {
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
}
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
if pool != nil { if pool != nil {
localID, _ := loadLocalID() localID, _ := loadLocalID()
kg := keepalived.New(pool, localID) kg := keepalived.New(pool, localID)
@@ -109,9 +95,9 @@ func cmdPromote(args []string) int {
defer renderCancel() defer renderCancel()
if err := kg.Render(renderCtx); err != nil { if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err) fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived") fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
} else { } else {
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)") fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
} }
} }
@@ -119,8 +105,8 @@ func cmdPromote(args []string) int {
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.") fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println() fmt.Println()
fmt.Println("Empfohlene Nachschritte:") fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)") fmt.Println(" 1) sudo systemctl restart edgeguard-api")
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:") fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>") fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
return 0 return 0
} }
@@ -136,25 +122,3 @@ func loadLocalID() (string, error) {
} }
return c.NodeID, nil return c.NodeID, nil
} }
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
func updateKeyDBPrimaryURL() error {
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
out, err := exec.Command("redis-cli",
"-s", "/var/run/keydb/keydb.sock",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err != nil {
// Fallback: Standard-Port
out2, err2 := exec.Command("redis-cli",
"-p", "6379",
"SET", "cluster:pg-primary-url", localDSN,
).CombinedOutput()
if err2 != nil {
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
}
}
return nil
}

View File

@@ -83,6 +83,8 @@ func main() {
slog.Error("waf: SPOE agent stopped", "error", err) slog.Error("waf: SPOE agent stopped", "error", err)
os.Exit(1) os.Exit(1)
} }
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
alertWriter.Close()
} }
// reload fetches all domain+waf_config pairs from DB and rebuilds engines. // reload fetches all domain+waf_config pairs from DB and rebuilds engines.

View File

@@ -1,6 +1,8 @@
# EdgeGuard — Architektur # EdgeGuard — Architektur
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64. > Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
>
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`). EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
@@ -8,14 +10,14 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 0. Leitplanken (nicht verhandelbar) ## 0. Leitplanken (nicht verhandelbar)
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl). - **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt. - **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`. - **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR). - **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6§9.)_
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2. - **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate. - **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). **Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
--- ---
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
| Komponente | Rolle | | Komponente | Rolle |
|---|---| |---|---|
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary | | `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) | | `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) | | `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) | | `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. | | **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload | | **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
--- ---
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf) │ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS) │ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Ruleset-Generator │ ├── firewall/ # nftables-Ruleset-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup │ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls │ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States) │ ├── chrony/ # chrony-Config-Generator (NTP)
── license/ # License-Validation, License-Leader-Election (KeyDB-Lock) ── kea/ # Kea-DHCP4-Config-Generator
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/) ├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
├── packaging/ ├── packaging/
│ └── debian/ │ └── debian/
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 3. Debian-Pakete ## 3. Debian-Pakete
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1. Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
| Paket | Arch | Inhalt | Depends | | Paket | Arch | Inhalt | Depends |
|---|---|---|---| |---|---|---|---|
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` | | `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` | | `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` | | `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` **8 Artefakte je Release**. Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus. **KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow. **Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
| Unit | Typ | Depends-on | User | Restart | | Unit | Typ | Depends-on | User | Restart |
|---|---|---|---|---| |---|---|---|---|---|
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` | | `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` | | `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
| `edgeguard-cert-deploy.path` | `path` | — | — | — | | `edgeguard-cert-deploy.path` | `path` | — | — | — |
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — | | `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
``` ```
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**. Drittsoftware läuft als **Distro-Units** EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends. API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
## 6. Datenbank-Setup ## 6. Datenbank-Setup
- **PostgreSQL 16**, Distro-Paket `postgresql-16`. - **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication. - **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`). - **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate. - **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`. GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
--- ---
## 7. KeyDB Active-Active ## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant). > **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
**Verwendung:** **Wie Cluster-State tatsächlich gehalten wird:**
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary? - **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`) - **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h) - **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min) - **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt) - `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication. _Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
--- ---
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`). - **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`. - Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`). - Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation. - Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers). - `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
### Config-Schichten ### Config-Schichten
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
## 8. Cluster-Topologie & HA pro Service ## 8. Cluster-Topologie & HA pro Service
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9). **N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
| Service | HA-Strategie | | Service | HA-Strategie |
|---|---| |---|---|
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. | | **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. | | **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
| **Squid** | stateless (Cache lokal). Pro Node identische ACL-Config. |
| **WireGuard** | siehe §8.1 | | **WireGuard** | siehe §8.1 |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). | | **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). | | **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. | | **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
| **edgeguard-ui** | statisch, pro Node identisch. | | **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). | | **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
| **KeyDB** | Active-Active. | | **KeyDB** | optional/ungenutzt (§7). |
### 8.1 WireGuard im Cluster ### 8.1 WireGuard im Cluster
Drei Optionen, für v1 wählen wir **Option A**: Drei Optionen, für v1 wählen wir **Option A**:
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant. - **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil. - B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität. - C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`. **Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal `wg syncconf`.
### 8.2 Manual Promote (PG-Primary-Failover) ### 8.2 Manual Promote (PG-Primary-Failover)
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat. Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
### 8.3 License-Leader-Election ### 8.3 License-Verifikation
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min. **Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
--- ---
## 9. Public-Ingress — Floating-IP statt VRRP ## 9. Public-Ingress — VIP via keepalived/VRRP
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL. > **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup. **Mechanik (`internal/keepalived`):**
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
Optionen pro Hoster: **Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2. **v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
**OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt. **OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
--- ---
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
Schritte (idempotent, analog `netcell-webpanel/install.sh`): Schritte (idempotent, analog `netcell-webpanel/install.sh`):
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch. 1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
2. **Arch-Detection**: nur amd64 *oder* arm64. 2. **Arch-Detection**: nur amd64 *oder* arm64.
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`. 3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
4. **APT-Keyrings:** 4. **APT-Keyrings:**
@@ -329,7 +339,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
--token <cluster-join-token> --token <cluster-join-token>
``` ```
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start. `edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
--- ---
@@ -337,7 +347,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`). - **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea). - **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
- **Suiten:** `stable` · `testing` · `security` pro Codename (`trixie`, `noble`). - **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`. - **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`. - **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`). - **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
@@ -355,13 +365,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key). - **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`. - **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`. - **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h)`/var/lib/edgeguard/trial.json` (30 Tage) → `expired`. - **Caching:** Live-Verify → Ergebnis in PG `licenses``/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
- **Leader-Election** wie nmg §6.3. - **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
### 12.2 ACME ### 12.2 ACME
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert. - **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes. - **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu. - **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`. - **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
@@ -384,14 +394,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
## 14. Plattform-Matrix ## 14. Plattform-Matrix
| Distribution | Codename | Arch | Status v1 | | Distribution | Codename | Arch | Status |
|---|---|---|---| |---|---|---|---|
| Debian 13 | trixie | amd64 | Tier 1 | | Debian 13 | trixie | amd64 | Tier 1 |
| Debian 13 | trixie | arm64 | Tier 1 | | Debian 13 | trixie | arm64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab. **Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
--- ---
@@ -402,7 +410,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
1. **Frische Installation** auf Test-VM via `install.sh`. 1. **Frische Installation** auf Test-VM via `install.sh`.
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format. 2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen). 3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
4. **Cutover** via Floating-IP-Switch. 4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features. Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
@@ -410,6 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
## Offene Punkte ## Offene Punkte
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren. - **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen). - **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen. - **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.

View File

@@ -866,6 +866,7 @@ type registerPeerRequest struct {
MgmtIP string `json:"mgmt_ip"` // optional MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"` Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
} }
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen. // AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
@@ -904,12 +905,21 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite. // Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was // Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
// wir brauchen.) // wir brauchen.)
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
// Rollenerkennung läuft über pg_publication).
role := strings.TrimSpace(req.Role)
if role == "" {
role = "peer"
}
n := models.HANode{ n := models.HANode{
ID: req.ID, ID: req.ID,
Name: req.Name, Name: req.Name,
FQDN: req.FQDN, FQDN: req.FQDN,
APIURL: req.APIURL, APIURL: req.APIURL,
Role: "peer", Role: role,
Status: "online", // peer IS online — it just connected via mTLS Status: "online", // peer IS online — it just connected via mTLS
} }
if req.PublicIP != "" { if req.PublicIP != "" {
@@ -965,7 +975,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
}() }()
} }
slog.Info("cluster: peer registered via mTLS", // Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
// würden sonst das Log fluten.
logFn := slog.Debug
if ipChanged {
logFn = slog.Info
}
logFn("cluster: peer registered via mTLS",
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status, "id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
"client_cn", cn, "remote", c.ClientIP()) "client_cn", cn, "remote", c.ClientIP())
response.OK(c, out) response.OK(c, out)

View File

@@ -7,14 +7,21 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/models"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
) )
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
var ruStateMu sync.Mutex
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json" const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
const ( const (
@@ -26,17 +33,24 @@ const (
phaseFailed = "failed" phaseFailed = "failed"
) )
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die // FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary // - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben. // Update abgeschlossen → "done".
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
func FinishRollingUpdateIfPending() { func FinishRollingUpdateIfPending() {
st := readRollingUpdateState() st := readRollingUpdateState()
if st.Phase == phaseUpdatingPrimary { switch st.Phase {
case phaseUpdatingPrimary:
writeRollingUpdateState(RollingUpdateState{ writeRollingUpdateState(RollingUpdateState{
Phase: phaseDone, Phase: phaseDone,
SecondaryID: st.SecondaryID, SecondaryID: st.SecondaryID,
SecondaryFQDN: st.SecondaryFQDN, SecondaryFQDN: st.SecondaryFQDN,
}) })
case phaseUpdatingSecondary, phaseWaitingSecondary:
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
} }
} }
@@ -53,6 +67,8 @@ type RollingUpdateState struct {
} }
func readRollingUpdateState() RollingUpdateState { func readRollingUpdateState() RollingUpdateState {
ruStateMu.Lock()
defer ruStateMu.Unlock()
data, err := os.ReadFile(rollingUpdateStateFile) data, err := os.ReadFile(rollingUpdateStateFile)
if err != nil { if err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()} return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
@@ -61,6 +77,13 @@ func readRollingUpdateState() RollingUpdateState {
if err := json.Unmarshal(data, &s); err != nil { if err := json.Unmarshal(data, &s); err != nil {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()} return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
} }
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
// gilt done/failed als idle — so verliert kein paralleler Poller das
// Ergebnis und ein alter Stand bleibt nicht hängen.
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
time.Since(s.UpdatedAt) > 10*time.Minute {
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
}
return s return s
} }
@@ -71,7 +94,10 @@ func writeRollingUpdateState(s RollingUpdateState) {
slog.Warn("rolling-update: failed to marshal state", "error", err) slog.Warn("rolling-update: failed to marshal state", "error", err)
return return
} }
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil { ruStateMu.Lock()
defer ruStateMu.Unlock()
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
slog.Warn("rolling-update: failed to write state file", "error", err) slog.Warn("rolling-update: failed to write state file", "error", err)
} }
} }
@@ -127,76 +153,108 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
} }
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück. // RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt // Read-only — terminale Zustände altern in readRollingUpdateState aus
// damit der nächste Pageload keinen Stale-done vorfindet. // (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) { func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
st := readRollingUpdateState() response.OK(c, readRollingUpdateState())
response.OK(c, st)
if st.Phase == phaseDone {
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
}
} }
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) { func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
ctx := context.Background() ctx := context.Background()
// 1. Secondary triggern // Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN) // die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update") // Secondary überhaupt etwas zu tun hat.
if !result.OK { candidate := rollingCandidateVersion(ctx)
baseline := secondaryVersion(ctx, h, secondary)
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
// wenn beide Nodes schon aktuell waren).
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
if secondaryUpToDate {
slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate)
} else {
// 1. Secondary triggern
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "trigger-update failed: " + result.Err,
})
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
return
}
// 2. Secondary-Version pollen — der Secondary restartet nach dem
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
writeRollingUpdateState(RollingUpdateState{ writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed, Phase: phaseWaitingSecondary,
SecondaryID: secondary.ID, SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN, SecondaryFQDN: secondary.FQDN,
Error: "trigger-update failed: " + result.Err,
}) })
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err) slog.Info("rolling-update: waiting for secondary version flip",
return "baseline", baseline, "candidate", candidate)
}
// 2. Secondary-Version pollen — der Secondary restartet nach dem // Kurze Wartezeit damit apt auf dem Secondary erst losläuft
// Upgrade, danach zeigt /agent/cluster/version eine neue Version. time.Sleep(20 * time.Second)
writeRollingUpdateState(RollingUpdateState{
Phase: phaseWaitingSecondary,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
})
slog.Info("rolling-update: waiting for secondary version flip")
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft deadline := time.Now().Add(10 * time.Minute)
time.Sleep(20 * time.Second) versionFlipped := false
for time.Now().Before(deadline) {
deadline := time.Now().Add(10 * time.Minute) results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
versionFlipped := false if len(results) > 0 && results[0].OK {
for time.Now().Before(deadline) { var ver struct {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID) Version string `json:"version"`
if len(results) > 0 && results[0].OK { }
var ver struct { if err := json.Unmarshal(results[0].Data, &ver); err == nil {
Version string `json:"version"` slog.Info("rolling-update: secondary version", "version", ver.Version,
} "baseline", baseline, "candidate", candidate)
if err := json.Unmarshal(results[0].Data, &ver); err == nil { // Erfolg = Secondary hat die Zielversion erreicht (candidate)
slog.Info("rolling-update: secondary version", "version", ver.Version, "primary", h.Version) // ODER hat sich gegenüber der Baseline überhaupt bewegt
if ver.Version != h.Version { // (Fallback, wenn candidate nicht ermittelbar war).
versionFlipped = true if ver.Version != "" &&
break ((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
versionFlipped = true
break
}
} }
} }
time.Sleep(10 * time.Second)
}
if !versionFlipped {
writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed,
SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN,
Error: "timeout (10 min) waiting for secondary version flip",
})
slog.Warn("rolling-update: secondary version flip timeout")
return
} }
time.Sleep(10 * time.Second)
} }
if !versionFlipped { // 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
// "updating-primary".
if candidate != "" && h.Version == candidate {
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
writeRollingUpdateState(RollingUpdateState{ writeRollingUpdateState(RollingUpdateState{
Phase: phaseFailed, Phase: phaseDone,
SecondaryID: secondary.ID, SecondaryID: secondary.ID,
SecondaryFQDN: secondary.FQDN, SecondaryFQDN: secondary.FQDN,
Error: "timeout (10 min) waiting for secondary version flip",
}) })
slog.Warn("rolling-update: secondary version flip timeout")
return return
} }
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
writeRollingUpdateState(RollingUpdateState{ writeRollingUpdateState(RollingUpdateState{
Phase: phaseUpdatingPrimary, Phase: phaseUpdatingPrimary,
SecondaryID: secondary.ID, SecondaryID: secondary.ID,
@@ -256,3 +314,26 @@ rm -f /var/lib/edgeguard/upgrade.sh
// UI erkennt Version-Flip via /system/health und schließt den Flow. // UI erkennt Version-Flip via /system/health und schließt den Flow.
slog.Info("rolling-update: primary upgrade dispatched, process will restart") slog.Info("rolling-update: primary upgrade dispatched, process will restart")
} }
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
func rollingCandidateVersion(ctx context.Context) string {
vers := aptsvc.PackageVersions(ctx, false)
return vers["edgeguard_available"]
}
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
if len(results) > 0 && results[0].OK {
var ver struct {
Version string `json:"version"`
}
if json.Unmarshal(results[0].Data, &ver) == nil {
return ver.Version
}
}
return ""
}

View File

@@ -194,6 +194,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"unbound", "unbound"}, {"unbound", "unbound"},
{"chrony", "chrony"}, {"chrony", "chrony"},
{"squid", "squid"}, {"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"}, {"postgresql", "postgresql"},
{"crowdsec", "crowdsec"}, {"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"}, {"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},

View File

@@ -128,7 +128,7 @@ func Join(req Request) error {
// synchronous on the primary side. // synchronous on the primary side.
var autoRegErr error var autoRegErr error
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil { if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil {
autoRegErr = nil autoRegErr = nil
break break
} else { } else {
@@ -222,13 +222,21 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
// goroutine so the primary's ha_nodes always reflects the secondary's actual // goroutine so the primary's ha_nodes always reflects the secondary's actual
// config_hash (not the stale join-time value). // config_hash (not the stale join-time value).
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error { func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer")
}
// PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS,
// /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node
// beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der
// Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat).
func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error {
if tlsDir == "" { if tlsDir == "" {
tlsDir = clustertls.DefaultDir tlsDir = clustertls.DefaultDir
} }
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash) return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role)
} }
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error { func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role string) error {
u, err := url.Parse(primary) u, err := url.Parse(primary)
if err != nil { if err != nil {
return err return err
@@ -241,6 +249,9 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
nodeID = strings.TrimSpace(string(raw)) nodeID = strings.TrimSpace(string(raw))
} }
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
if role == "" {
role = "peer"
}
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"id": nodeID, "id": nodeID,
"name": hostname, "name": hostname,
@@ -248,6 +259,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
"api_url": "https://" + commonName + ":3443", "api_url": "https://" + commonName + ":3443",
"version": version, "version": version,
"config_hash": configHash, "config_hash": configHash,
"role": role,
}) })
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key") pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")

View File

@@ -3,6 +3,8 @@ package waf
import ( import (
"context" "context"
"log/slog" "log/slog"
"sync"
"sync/atomic"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -26,8 +28,12 @@ type Alert struct {
// AlertWriter accepts Alert values via a buffered channel and writes // AlertWriter accepts Alert values via a buffered channel and writes
// them to PostgreSQL asynchronously so SPOE handling stays low-latency. // them to PostgreSQL asynchronously so SPOE handling stays low-latency.
type AlertWriter struct { type AlertWriter struct {
pool *pgxpool.Pool pool *pgxpool.Pool
ch chan Alert ch chan Alert
stop chan struct{}
done chan struct{}
closeOnce sync.Once
closed atomic.Bool
} }
// NewAlertWriter creates an AlertWriter and starts its background goroutine. // NewAlertWriter creates an AlertWriter and starts its background goroutine.
@@ -36,14 +42,19 @@ func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
aw := &AlertWriter{ aw := &AlertWriter{
pool: pool, pool: pool,
ch: make(chan Alert, bufSize), ch: make(chan Alert, bufSize),
stop: make(chan struct{}),
done: make(chan struct{}),
} }
go aw.run() go aw.run()
return aw return aw
} }
// Send enqueues an alert. Drops silently if the channel is full to // Send enqueues an alert. Drops silently if the channel is full (or the
// avoid slowing down SPOE request handling. // writer is closing) to avoid slowing down / panicking SPOE handling.
func (aw *AlertWriter) Send(a Alert) { func (aw *AlertWriter) Send(a Alert) {
if aw.closed.Load() {
return
}
select { select {
case aw.ch <- a: case aw.ch <- a:
default: default:
@@ -51,9 +62,34 @@ func (aw *AlertWriter) Send(a Alert) {
} }
} }
// Close stops the writer and flushes buffered alerts (best-effort).
// Safe to call multiple times. The channel is never closed → Send never
// panics even if it races with Close.
func (aw *AlertWriter) Close() {
aw.closeOnce.Do(func() {
aw.closed.Store(true)
close(aw.stop)
})
<-aw.done
}
func (aw *AlertWriter) run() { func (aw *AlertWriter) run() {
for a := range aw.ch { defer close(aw.done)
aw.write(a) for {
select {
case a := <-aw.ch:
aw.write(a)
case <-aw.stop:
// Restliche gepufferte Alerts noch wegschreiben, dann Ende.
for {
select {
case a := <-aw.ch:
aw.write(a)
default:
return
}
}
}
} }
} }

View File

@@ -0,0 +1,61 @@
package waf
import (
"context"
"os"
"sync"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
)
// Beweist Fix #15: AlertWriter.Close() flusht, ist idempotent, und Send/Close
// racen ohne Panic (Kanal wird nie geschlossen). Guarded per EG_FWTEST_DSN.
func TestAlertWriter_CloseFlush(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the alert-writer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
aw := NewAlertWriter(pool, 64)
for i := 0; i < 20; i++ {
aw.Send(Alert{Hostname: "t.local", ClientIP: "203.0.113.1", Method: "GET", URI: "/", Action: "detected"})
}
// Send parallel zu Close → darf nicht paniken.
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() { defer wg.Done(); aw.Send(Alert{Hostname: "t.local", Action: "detected"}) }()
}
done := make(chan struct{})
go func() { aw.Close(); close(done) }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Close() did not return (flush hung)")
}
wg.Wait()
// Idempotent + Send nach Close ist No-op (kein Panic).
aw.Close()
aw.Send(Alert{Hostname: "after.local", Action: "detected"})
}