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>
This commit is contained in:
Debian
2026-06-06 18:26:52 +02:00
parent 3b5bf578a0
commit 7611572062
6 changed files with 119 additions and 134 deletions

View File

@@ -177,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)
@@ -208,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-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 <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.98 1.2.99

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

@@ -71,7 +71,6 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS) │ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert) │ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik │ ├── 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) │ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election) │ └── 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/)
@@ -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`. - **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. - **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`). - **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. - **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) ### 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 <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-Verifikation ### 8.3 License-Verifikation
@@ -419,7 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
## Offene Punkte ## 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. - **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-Failover** in der Praxis testen (Handshake-Latenz nach VIP-Umzug messen).
- **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt. - **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.