From 7611572062cf508e93df52dd76bd45a618387cd9 Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 6 Jun 2026 18:26:52 +0200 Subject: [PATCH] =?UTF-8?q?refactor(cluster):=20promote=20auf=20Logical-Re?= =?UTF-8?q?plication=20umgestellt=20+=20internal/proxy-Stub=20entfernt=20?= =?UTF-8?q?=E2=80=94=20v1.2.99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 4 +- VERSION | 2 +- cmd/edgeguard-ctl/cluster_replication.go | 119 +++++++++++++--------- cmd/edgeguard-ctl/promote.go | 120 ++++++++--------------- docs/architecture.md | 8 +- internal/proxy/.gitkeep | 0 6 files changed, 119 insertions(+), 134 deletions(-) delete mode 100644 internal/proxy/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index ac7700c..7f0f9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,7 +177,6 @@ cd management-ui && bun run dev │ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS) │ ├── firewall/ # nftables-Generator │ ├── cluster/ # Join/Promote/Peer-Discovery -│ ├── proxy/ # Write-Proxy → Cluster-Primary │ ├── aggregator/ # Cluster-View APIs │ └── license/ # Lizenz-Validierung ├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern) @@ -208,7 +207,8 @@ cd management-ui && bun run dev - **ORM:** GORM für Queries, nicht für Schema-Verwaltung - **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/` - **Config-Reload:** `systemctl reload ` nach Config-Schreiben -- **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. `edgeguard-ctl promote` aktualisiert KeyDB `cluster:pg-primary-url` nur, wenn KeyDB vorhanden ist. _(Hinweis: das in der Projektstruktur gelistete `internal/proxy` existiert derzeit nicht — Doku-Drift, siehe unten.)_ +- **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 ` zurückhängen. ### Packaging - `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm diff --git a/VERSION b/VERSION index f8af30f..97fc465 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.98 \ No newline at end of file +1.2.99 \ No newline at end of file diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go index be46dcb..3f0af00 100644 --- a/cmd/edgeguard-ctl/cluster_replication.go +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -115,11 +115,29 @@ func cmdClusterInitReplication(args []string) int { } 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 ") + 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 pass, err := generatePassword(32) if err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err) - return 1 + return fmt.Errorf("generate password: %w", err) } // 2. edgeguard_replicator-Rolle anlegen/updaten @@ -133,21 +151,17 @@ BEGIN END $$`, egReplUser, egReplUser, pass, egReplUser, pass) if err := psqlExec(roleSQL); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err) - return 1 + return fmt.Errorf("create replication role: %w", err) } 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 { - fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err) - return 1 + return fmt.Errorf("mkdir: %w", err) } if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err) - return 1 + return fmt.Errorf("write secret: %w", err) } - // Ownership an edgeguard-api-User übergeben damit die API lesen kann if u, err := user.Lookup("edgeguard"); err == nil { uid, _ := strconv.Atoi(u.Uid) gid, _ := strconv.Atoi(u.Gid) @@ -156,71 +170,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass) fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret) // 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 { - fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err) - return 1 + return fmt.Errorf("conf.d mkdir: %w", err) } 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 max_wal_senders = 10 max_replication_slots = 20 max_logical_replication_workers = 4 wal_keep_size = 512MB -# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers -# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf -# bekannte Replikations-User beschränkt. +# '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt. listen_addresses = '*' ` confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf") if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err) - return 1 + return fmt.Errorf("write postgresql conf: %w", err) } fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) // 5. pg_hba.conf aktualisieren if err := ensureHBAReplication(pg.HBAPath); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err) - return 1 + return fmt.Errorf("pg_hba.conf: %w", err) } 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 { - fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out) - return 1 + return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out))) } 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 - // Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen. + // 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen) grantSQL := fmt.Sprintf(` GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s; `, egReplUser, egReplUser) if err := psqlDBExec("edgeguard", grantSQL); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err) - return 1 + return fmt.Errorf("grant SELECT: %w", err) } fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser) - // 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables. - // Idempotent: DROP IF EXISTS + CREATE. + // 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE) if err := createPublication(); err != nil { - fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err) - return 1 + return fmt.Errorf("create publication: %w", err) } fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName) + return nil +} - fmt.Println() - fmt.Println("Nächste Schritte:") - fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby ") - fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren") - fmt.Println(" → Settings → Cluster → VIP/Keepalived") - return 0 +// dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription +// idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den +// Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen. +func dropSubscriptionIfExists() error { + dropSQL := fmt.Sprintf(` +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 @@ -334,15 +365,7 @@ func cmdClusterSetupStandby(args []string) int { fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort) // 2. Bestehende Subscription löschen (idempotent) - dropSQL := fmt.Sprintf(` -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 { + if err := dropSubscriptionIfExists(); err != nil { // Nicht fatal — wenn PG noch keine Subscription kennt ist das OK fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n") } else { diff --git a/cmd/edgeguard-ctl/promote.go b/cmd/edgeguard-ctl/promote.go index f5e0b71..647c584 100644 --- a/cmd/edgeguard-ctl/promote.go +++ b/cmd/edgeguard-ctl/promote.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" "strings" "time" @@ -14,18 +12,22 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" ) -// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby -// to Primary. Manual failover — keine automatische Promotion, um Split-Brain -// in 2-Node-Clustern ohne externen Quorum zu verhindern. +// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles +// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern +// 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: -// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby) -// 2. pg_ctlcluster promote → PG wird Primary -// 3. Warten bis pg_is_in_recovery() = false -// 4. ha_nodes.pg_role auf 'primary' setzen -// 5. KeyDB cluster:pg-primary-url auf lokal setzen -// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200) -// 7. keepalived reload +// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig +// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP) +// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication +// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig) +// 4. ha_nodes.pg_role/role = 'primary' +// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP) func cmdPromote(args []string) int { pg, err := detectPGConfig() if err != nil { @@ -33,51 +35,43 @@ func cmdPromote(args []string) int { return 1 } - // 1. Standby-Signal prüfen - signalPath := filepath.Join(pg.DataDir, "standby.signal") - if _, err := os.Stat(signalPath); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, - "promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n", - signalPath) - return 1 + // 1. Idempotenz: bereits Publisher (Primary) ohne Subscription? + pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c", + fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)}) + subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c", + fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)}) + hasPub := strings.TrimSpace(string(pubOut)) == "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) - 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") + fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster) - // 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false) - fmt.Print("→ Warte auf PG Primary-Mode") - deadline := time.Now().Add(60 * time.Second) - for time.Now().Before(deadline) { - out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"}) - if err == nil && strings.TrimSpace(string(out)) == "f" { - break + // 2. Subscription zum alten/toten Primary lösen + if hasSub { + if err := dropSubscriptionIfExists(); err != nil { + fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err) + return 1 } - fmt.Print(".") - time.Sleep(2 * time.Second) + fmt.Println("✓ Subscription zum alten Primary entfernt") } - fmt.Println() - // Nochmal prüfen - out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"}) - if err != nil || strings.TrimSpace(string(out)) != "f" { - fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs") + + // 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart) + if err := setupReplicationPrimary(pg); err != nil { + fmt.Fprintln(os.Stderr, "promote:", err) 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) defer cancel() pool, err := database.Open(ctx, database.ConnStringFromEnv()) if err != nil { 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='';") + fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='';") } else { defer pool.Close() localID, err := loadLocalID() @@ -93,15 +87,7 @@ func cmdPromote(args []string) int { } } - // 4. KeyDB cluster:pg-primary-url updaten - 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) + // 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP) if pool != nil { localID, _ := loadLocalID() kg := keepalived.New(pool, localID) @@ -109,9 +95,9 @@ func cmdPromote(args []string) int { defer renderCancel() if err := kg.Render(renderCtx); err != nil { 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 { - 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() fmt.Println("Empfohlene Nachschritte:") - fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)") - fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:") + fmt.Println(" 1) sudo systemctl restart edgeguard-api") + fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:") fmt.Println(" edgeguard-ctl cluster-setup-standby ") return 0 } @@ -136,25 +122,3 @@ func loadLocalID() (string, error) { } 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 -} diff --git a/docs/architecture.md b/docs/architecture.md index 84023dc..8e8cc8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,6 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox │ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS) │ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert) │ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik -│ ├── proxy/ # LEER (Stub) — geplante Write-Proxy-Middleware nie implementiert │ ├── 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/) @@ -203,7 +202,7 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf - **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`. - **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 (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** (`internal/proxy` ist leer) — 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). +- **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). - **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. @@ -289,7 +288,7 @@ Drei Optionen, für v1 wählen wir **Option A**: ### 8.2 Manual Promote (PG-Primary-Failover) -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` / UI. _Hinweis: `promote.go` enthält noch `standby.signal`-Logik aus einem früheren Physical-Replication-Entwurf; der aktive Replikationspfad ist Logical (§6). Diese Inkonsistenz im CLI-Code ist offen (siehe „Offene Punkte")._ +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 ` 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-Verifikation @@ -419,7 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine ## Offene Punkte -- **Code-Altlasten bereinigen:** `internal/proxy` (leerer Stub), `promote.go` `standby.signal`-Reste (Physical-Replication-Entwurf vs. aktuelles Logical), tote KeyDB-/Floating-IP-Kommentare. -- **WireGuard-Failover** in der Praxis testen (Handshake-Latenz nach VIP-Umzug messen). +- **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.)_ - **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. diff --git a/internal/proxy/.gitkeep b/internal/proxy/.gitkeep deleted file mode 100644 index e69de29..0000000