diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go new file mode 100644 index 0000000..073a589 --- /dev/null +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -0,0 +1,455 @@ +package main + +import ( + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls" +) + +const ( + pgVersion = "16" + pgCluster = "main" + pgDataDir = "/var/lib/postgresql/16/main" + pgHBAPath = "/etc/postgresql/16/main/pg_hba.conf" + pgConfD = "/etc/postgresql/16/main/conf.d" + egReplSecret = "/var/lib/edgeguard/pg-replication-secret" + egReplUser = "edgeguard_replicator" + egPubName = "edgeguard_shared" + egSubName = "edgeguard_sub" + + // Tabellen die NODE-SPEZIFISCH sind und NICHT repliziert werden. + // Jede Node hat eigene Interfaces, IPs, Routen, VIP-Einstellungen, + // Listener-Adressen, Token-Tracking und Audit-Log. + // Analog zu OPNsense: Interface-IPs und Hostname bleiben immer lokal. +) + +// localOnlyTables listet alle Tabellen die nicht in die Replikations- +// Publication aufgenommen werden. Alles andere wird automatisch repliziert. +var localOnlyTables = []string{ + "ha_nodes", // Node-Identität, Status + "network_interfaces", // Eigene Interfaces (eth0, eth1 …) + "ip_addresses", // Eigene IP-Adressen (unterschiedlich pro Node!) + "static_routes", // Node-spezifisches Routing + "cluster_settings", // VIP-Interface kann pro Node unterschiedlich sein + "dns_settings", // listen_addresses ist node-spezifisch + "ntp_settings", // listen_addresses ist node-spezifisch + "system_settings", // Hostname, Maintenance-Mode etc. + "join_tokens_used", // Token-Tracking nur auf Primary relevant + "audit_log", // Lokales Audit-Protokoll + "alert_events", // Lokale Laufzeit-Events + "backups", // Backup-Historie ist per-Node + "goose_db_version", // Migration-Tracking, internes Tool-State +} + +// cmdClusterInitReplication richtet PG auf dieser Node als Logical-Replication- +// Primary ein. Idempotent — kann gefahrlos mehrfach laufen. +// +// Ablauf: +// 1. edgeguard_replicator-Rolle anlegen/aktualisieren +// 2. Passwort → /var/lib/edgeguard/pg-replication-secret +// 3. conf.d/edgeguard-replication.conf mit wal_level=logical schreiben +// 4. pg_hba.conf für Replikations-Verbindungen aktualisieren +// 5. SELECT-Grants auf alle geteilten Tabellen +// 6. PUBLICATION erstellen (alle Tabellen außer localOnlyTables) +// 7. PG reload +func cmdClusterInitReplication(args []string) int { + fs := flag.NewFlagSet("cluster-init-replication", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return 2 + } + + // 1. Passwort generieren + pass, err := generatePassword(32) + if err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err) + return 1 + } + + // 2. edgeguard_replicator-Rolle anlegen/updaten + roleSQL := fmt.Sprintf(`DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN + CREATE ROLE %s REPLICATION LOGIN PASSWORD '%s'; + ELSE + ALTER ROLE %s PASSWORD '%s'; + END IF; +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 + } + fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser) + + // 3. Passwort speichern + if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err) + return 1 + } + if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err) + return 1 + } + 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(pgConfD, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err) + return 1 + } + replConf := `# EdgeGuard Logical Replication — automatisch generiert +# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet. +wal_level = logical +max_wal_senders = 5 +max_replication_slots = 5 +max_logical_replication_workers = 4 +wal_keep_size = 512MB +` + confPath := filepath.Join(pgConfD, "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 + } + fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) + + // 5. pg_hba.conf aktualisieren + if err := ensureHBAReplication(); err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err) + return 1 + } + fmt.Printf("✓ %s aktualisiert\n", pgHBAPath) + + // 6. PG reload (damit wal_level + pg_hba aktiv werden) + if out, err := exec.Command("pg_ctlcluster", pgVersion, pgCluster, "reload").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out) + return 1 + } + fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pgVersion, pgCluster) + + // 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden + // Tabellen lesen können. 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 + } + 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. + if err := createPublication(); err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err) + return 1 + } + fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName) + + 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 +} + +// createPublication baut die PUBLICATION dynamisch aus allen Tabellen +// im public-Schema minus localOnlyTables. Idempotent: löscht eine +// bestehende Publication gleichen Namens zuerst. +func createPublication() error { + // Alle Tabellen im public-Schema ermitteln + listSQL := `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename` + out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", listSQL}) + if err != nil { + return fmt.Errorf("list tables: %w", err) + } + + excluded := make(map[string]bool) + for _, t := range localOnlyTables { + excluded[t] = true + } + + var tables []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + t := strings.TrimSpace(line) + if t == "" || excluded[t] { + continue + } + tables = append(tables, t) + } + if len(tables) == 0 { + return fmt.Errorf("keine Tabellen für Publication gefunden") + } + + dropSQL := fmt.Sprintf("DROP PUBLICATION IF EXISTS %s;", egPubName) + if err := psqlDBExec("edgeguard", dropSQL); err != nil { + return fmt.Errorf("drop old publication: %w", err) + } + + createSQL := fmt.Sprintf("CREATE PUBLICATION %s FOR TABLE %s;", + egPubName, strings.Join(tables, ", ")) + if err := psqlDBExec("edgeguard", createSQL); err != nil { + return fmt.Errorf("create publication: %w", err) + } + return nil +} + +// ensureHBAReplication fügt Einträge für die Replikations-Verbindung +// in pg_hba.conf ein. Für Logical Replication brauchen wir einen +// normalen "host edgeguard"-Eintrag (nicht "host replication"). +// Idempotent via Marker-Kommentar. +func ensureHBAReplication() error { + data, err := os.ReadFile(pgHBAPath) + if err != nil { + return fmt.Errorf("read: %w", err) + } + const marker = "# EdgeGuard replication" + if strings.Contains(string(data), marker) { + return nil + } + // Logical Replication: Subscriber verbindet sich auf die DB (nicht "replication"-Typ) + // Physical/WAL-Archiving: "replication"-Typ bleibt für Kompatibilität + entry := fmt.Sprintf(` +%s +host edgeguard %s 0.0.0.0/0 scram-sha-256 +host edgeguard %s ::/0 scram-sha-256 +host replication %s 0.0.0.0/0 scram-sha-256 +host replication %s ::/0 scram-sha-256 +`, marker, egReplUser, egReplUser, egReplUser, egReplUser) + f, err := os.OpenFile(pgHBAPath, os.O_APPEND|os.O_WRONLY, 0o640) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer f.Close() + _, err = f.WriteString(entry) + return err +} + +// cmdClusterSetupStandby richtet diesen Node als Logical-Replication- +// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG- +// Instanz — nur die geteilten Tabellen werden vom Primary repliziert. +// Node-spezifische Tabellen (Interfaces, IPs, Routen, VIP-Settings …) +// bleiben lokal und werden NICHT überschrieben. Analog zu OPNsense's +// HA-Sync: Interface-IPs und Hostname bleiben immer per-Node konfiguriert. +// +// Voraussetzungen: +// - cluster-join erfolgreich (TLS-Certs in /var/lib/edgeguard/cluster-tls/) +// - Primary hat cluster-init-replication ausgeführt +// - Dieser Node hat edgeguard-api schon gelaufen (Migrations ausgeführt) +// +// Ablauf: +// 1. Replication-Credentials via mTLS vom Primary holen +// 2. Bestehende Subscription löschen (idempotent) +// 3. SUBSCRIPTION auf Primary erstellen (copy_data=true → Initialkopiierung) +// 4. Warten bis Initialkopiierung abgeschlossen +// 5. render-config ausführen damit Service-Configs den neuen Stand reflektieren +func cmdClusterSetupStandby(args []string) int { + fs := flag.NewFlagSet("cluster-setup-standby", flag.ContinueOnError) + agentPort := fs.Int("agent-port", 8443, "mTLS agent port on primary") + tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "Verzeichnis mit ca.crt + peer.{crt,key}") + fs.SetOutput(os.Stderr) + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-setup-standby ") + return 2 + } + primaryHost := fs.Arg(0) + + // 1. Replication-Credentials vom Primary holen + creds, err := fetchReplicationCreds(primaryHost, *agentPort, *tlsDir) + if err != nil { + fmt.Fprintf(os.Stderr, "cluster-setup-standby: replication-creds: %v\n", err) + return 1 + } + 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 { + // Nicht fatal — wenn PG noch keine Subscription kennt ist das OK + fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n") + } else { + fmt.Println("✓ Bestehende Subscription entfernt") + } + + // 3. SUBSCRIPTION erstellen + // sslmode=require: Verbindung zwischen Cluster-Nodes soll immer verschlüsselt sein. + // copy_data=true: Initialkopiierung aller geteilten Tabellen vom Primary. + connStr := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=edgeguard sslmode=require", + creds.Host, creds.Port, creds.User, creds.Password, + ) + createSQL := fmt.Sprintf( + "CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);", + egSubName, connStr, egPubName, + ) + if err := psqlDBExec("edgeguard", createSQL); err != nil { + fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err) + return 1 + } + fmt.Printf("✓ SUBSCRIPTION %q erstellt — Initialkopiierung läuft\n", egSubName) + + // 4. Warten bis Initialkopiierung abgeschlossen + fmt.Print("→ Warte auf Initialkopiierung") + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + pendingSQL := fmt.Sprintf(` +SELECT COUNT(*) FROM pg_subscription_rel +WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s') + AND srsubstate != 'r';`, egSubName) + out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", pendingSQL}) + if err == nil && strings.TrimSpace(string(out)) == "0" { + break + } + fmt.Print(".") + time.Sleep(3 * time.Second) + } + fmt.Println() + + // Finale Prüfung + checkSQL := fmt.Sprintf(` +SELECT COUNT(*) FROM pg_subscription_rel +WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s') + AND srsubstate != 'r';`, egSubName) + if out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", checkSQL}); err == nil { + if n := strings.TrimSpace(string(out)); n != "0" { + fmt.Fprintf(os.Stderr, + "cluster-setup-standby: %s Tabellen noch nicht synchronisiert — prüfe PG-Logs\n", n) + fmt.Println(" → Subscription läuft trotzdem weiter im Hintergrund") + } else { + fmt.Println("✓ Alle geteilten Tabellen synchronisiert") + } + } + + // 5. render-config ausführen + fmt.Println("→ Service-Configs neu rendern...") + if out, err := exec.Command("edgeguard-ctl", "render-config", "--no-reload").CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "cluster-setup-standby: render-config: %v\n%s\n", err, out) + fmt.Println(" → Manuell nachholen: edgeguard-ctl render-config") + } else { + fmt.Print(string(out)) + fmt.Println("✓ Service-Configs aktualisiert") + } + + fmt.Println() + fmt.Println("✓ Logical Replication eingerichtet.") + fmt.Println() + fmt.Println("Was repliziert wird (automatisch, in Echtzeit):") + fmt.Println(" Domains, Backends, Firewall-Rules, WireGuard, DNS-Zones,") + fmt.Println(" TLS-Certs, Users, Forward-Proxy, NTP-Pools, ...") + fmt.Println() + fmt.Println("Was NICHT repliziert wird (bleibt pro Node konfiguriert):") + fmt.Println(" Netzwerk-Interfaces, IP-Adressen, Routen,") + fmt.Println(" Cluster-Settings (VIP-Interface!), DNS/NTP-Listen-Adressen") + fmt.Println() + fmt.Println("Nächste Schritte:") + fmt.Println(" 1) sudo systemctl restart edgeguard-api") + fmt.Println(" 2) VIP/Keepalived auf BEIDEN Nodes separat konfigurieren:") + fmt.Println(" Settings → Cluster → VIP/Keepalived") + fmt.Println(" 3) Bei Failover: edgeguard-ctl promote (auf dem Secondary)") + return 0 +} + +// pgReplicationCreds sind die Credentials die der Primary via mTLS zurückgibt. +type pgReplicationCreds struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` +} + +// fetchReplicationCreds ruft GET /agent/cluster/pg-replication-info via mTLS ab. +func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplicationCreds, error) { + caPath := filepath.Join(tlsDir, "ca.crt") + certPath := filepath.Join(tlsDir, "peer.crt") + keyPath := filepath.Join(tlsDir, "peer.key") + + caCert, err := os.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("read ca.crt: %w", err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("load peer cert: %w", err) + } + + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + Certificates: []tls.Certificate{cert}, + }, + }, + } + + url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort) + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + + var result struct { + Data pgReplicationCreds `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &result.Data, nil +} + +// generatePassword erzeugt ein kryptographisch sicheres Passwort. +func generatePassword(n int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + for i, b := range buf { + buf[i] = charset[int(b)%len(charset)] + } + return string(buf), nil +} + +// psqlDBExec führt SQL in der angegebenen Datenbank als postgres-Superuser aus. +func psqlDBExec(db, sql string) error { + _, err := psqlDBRun(db, []string{"-v", "ON_ERROR_STOP=1", "-c", sql}) + return err +} + +// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus. +func psqlDBRun(db string, args []string) ([]byte, error) { + baseArgs := []string{"-d", db} + return psqlRun(append(baseArgs, args...)) +} diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index dbb6543..b3fa7c4 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -1,7 +1,6 @@ // Command edgeguard-ctl is the admin CLI for setup, migrations and -// (later) cluster ops. v1 wires migrate + initdb so postinst can -// initialise a fresh node; cluster-* and promote remain stubs until -// Phase 3. +// cluster ops. v1.2 implements PG streaming replication setup, +// VIP/Keepalived config and manual failover (promote). package main import ( @@ -11,7 +10,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.162" +var version = "1.2.3" const usage = `edgeguard-ctl — EdgeGuard CLI @@ -25,23 +24,25 @@ Commands: migrate check Validate embedded migrations (no DB connect) migrate dump [dir] Write embedded SQL files to dir (default: ./migrations) initdb Create PostgreSQL role + database (idempotent) - render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=) + render-config Regenerate all configs from PG (--no-reload, --only=svc) + Services: haproxy nftables squid wireguard unbound chrony keepalived wg-import [--path ] [iface…] Import /etc/wireguard/*.conf files into the DB. - Without iface arguments: imports all .conf files. - With iface args: imports only the named interfaces. reset-password Generate a one-time token for the /reset-password UI flow cluster-join --token <…> - Provision Cluster-TLS material on this node by - exchanging the join-token at the primary's - /api/v1/cluster/issue-cert endpoint. Writes - ca.crt + peer.{crt,key} into /var/lib/edgeguard/ - cluster-tls/. PG-Basebackup + KeyDB replica - setup remain manual until Phase 3.5. - cluster-renew-self Re-issue this node's peer.{crt,key} using the - local cluster CA (founder/single-node only). - 1-year validity. Restart edgeguard-api after. - promote Promote this node's PG to primary (Phase 3, not yet implemented) + Provision Cluster-TLS material; writes ca.crt + peer.{crt,key} + cluster-init-replication Richtet PG Logical Replication auf dem Primary ein. + Erstellt edgeguard_replicator-Rolle, setzt wal_level=logical, + erstellt PUBLICATION edgeguard_shared (alle geteilten Tabellen). + Auf dem Primary ausführen bevor der Secondary joined. + cluster-setup-standby Richtet diesen Node als Logical-Replication-Subscriber ein. + Erstellt SUBSCRIPTION gegen den Primary (Initialkopiierung + aller geteilten Tabellen). Node-eigene Daten (Interfaces, + IPs, Routen, VIP-Settings) bleiben unangetastet. + Voraussetzung: cluster-join + cluster-init-replication. + cluster-renew-self Re-issue this node's peer.{crt,key} using the local cluster CA. + promote Promote diesen PG-Standby zum Primary (manueller Failover). + Kein Auto-Promote — Split-Brain-Schutz durch manuelle Entscheidung. dump-config Print effective config (Phase 3, not yet implemented) ` @@ -69,7 +70,13 @@ func main() { os.Exit(cmdClusterJoin(os.Args[2:])) case "cluster-renew-self": os.Exit(cmdClusterRenewSelf(os.Args[2:])) - case "cluster-leave", "promote", "dump-config": + case "cluster-init-replication": + os.Exit(cmdClusterInitReplication(os.Args[2:])) + case "cluster-setup-standby": + os.Exit(cmdClusterSetupStandby(os.Args[2:])) + case "promote": + os.Exit(cmdPromote(os.Args[2:])) + case "cluster-leave", "dump-config": fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1]) os.Exit(1) default: diff --git a/cmd/edgeguard-ctl/promote.go b/cmd/edgeguard-ctl/promote.go new file mode 100644 index 0000000..dd75dcc --- /dev/null +++ b/cmd/edgeguard-ctl/promote.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/cluster" + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + "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. +// +// 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 +func cmdPromote(args []string) int { + // 1. Standby-Signal prüfen + signalPath := filepath.Join(pgDataDir, "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 + } + + fmt.Println("→ Promoting PostgreSQL zu Primary...") + if out, err := exec.Command("pg_ctlcluster", pgVersion, pgCluster, "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) + 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 + } + fmt.Print(".") + time.Sleep(2 * time.Second) + } + 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") + return 1 + } + fmt.Println("✓ PostgreSQL ist jetzt Primary") + + // 3. ha_nodes.pg_role + role 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='';") + } else { + defer pool.Close() + localID, err := loadLocalID() + if err != nil { + fmt.Fprintln(os.Stderr, "promote: local node ID:", err) + } else { + _, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID) + if err != nil { + fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err) + } else { + fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt") + } + } + } + + // 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) + if pool != nil { + localID, _ := loadLocalID() + kg := keepalived.New(pool, localID) + renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second) + 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") + } else { + fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)") + } + } + + fmt.Println() + 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(" edgeguard-ctl cluster-setup-standby ") + return 0 +} + +// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf. +func loadLocalID() (string, error) { + c, err := cluster.LoadLocalConfig("") + if err != nil { + return "", err + } + if c.NodeID == "" { + return "", fmt.Errorf("NODE_ID in node.conf ist leer") + } + 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/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index 7eb7b0c..6a557e7 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -12,6 +12,8 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" + "git.netcell-it.de/projekte/edgeguard-native/internal/cluster" + "git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" "git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/squid" @@ -64,7 +66,16 @@ func cmdRenderConfig(args []string) int { fw.SkipReload = true } + // keepalived: Node-ID aus node.conf für Prioritäts-Berechnung + var ka configgen.Generator + if lc, err := cluster.LoadLocalConfig(""); err == nil && lc.NodeID != "" { + ka = keepalived.New(pool, lc.NodeID) + } + gens := []configgen.Generator{hap, fw, sq, wg, ub, cn} + if ka != nil { + gens = append(gens, ka) + } results, runErr := configorch.Run(ctx, gens, only) fmt.Print(configorch.Summarise(results)) diff --git a/deploy/keepalived/keepalived.conf.tpl b/deploy/keepalived/keepalived.conf.tpl new file mode 100644 index 0000000..d5fdcea --- /dev/null +++ b/deploy/keepalived/keepalived.conf.tpl @@ -0,0 +1,40 @@ +global_defs { + router_id {{ .RouterID }} + script_user root + enable_script_security + vrrp_garp_interval 0 + vrrp_gna_interval 0 +} + +vrrp_script chk_edgeguard { + script "/usr/lib/edgeguard/keepalived-check.sh" + interval 2 + weight -50 + fall 3 + rise 2 +} + +vrrp_instance VI_1 { + state {{ .State }} + interface {{ .Interface }} + virtual_router_id {{ .RouterID }} + priority {{ .Priority }} + advert_int 1 +{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} + unicast_peer { + {{ .PeerIP }} + } +{{ end }} authentication { + auth_type PASS + auth_pass {{ .AuthPass }} + } + virtual_ipaddress { + {{ .VIP }} + } + track_script { + chk_edgeguard + } + notify_master "/usr/lib/edgeguard/keepalived-master.sh" + notify_backup "/usr/lib/edgeguard/keepalived-backup.sh" + notify_fault "/usr/lib/edgeguard/keepalived-backup.sh" +} diff --git a/internal/cluster/confighash.go b/internal/cluster/confighash.go index 52847d2..9a9e71f 100644 --- a/internal/cluster/confighash.go +++ b/internal/cluster/confighash.go @@ -35,10 +35,12 @@ import ( // hashTable beschreibt eine Tabelle die in den config-hash einfließt. type hashTable struct { - Name string - Singleton bool // dns_settings, ntp_settings → eine row, id=1 - ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden - SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist + Name string + Singleton bool // dns_settings, ntp_settings → eine row, id=1 + ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden + SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist + MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…) + // → zählt nicht als "user hat config" bei der Empty-DB-Erkennung } // hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier @@ -49,15 +51,13 @@ var hashSpec = []hashTable{ {Name: "backends"}, {Name: "backend_servers"}, {Name: "routing_rules"}, - {Name: "network_interfaces"}, - {Name: "ip_addresses"}, {Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}}, - {Name: "firewall_zones"}, + {Name: "firewall_zones", MigrationDefault: true}, {Name: "firewall_address_objects"}, {Name: "firewall_address_groups"}, - {Name: "firewall_services"}, - {Name: "firewall_service_groups"}, + {Name: "firewall_services", MigrationDefault: true}, + {Name: "firewall_service_groups", MigrationDefault: true}, {Name: "firewall_rules"}, {Name: "firewall_nat_rules"}, @@ -67,12 +67,12 @@ var hashSpec = []hashTable{ {Name: "dns_zones"}, {Name: "dns_records"}, - {Name: "dns_settings", Singleton: true}, - {Name: "ntp_pools"}, - {Name: "ntp_settings", Singleton: true}, + {Name: "ntp_pools", MigrationDefault: true}, - {Name: "static_routes"}, + // network_interfaces, ip_addresses, static_routes, dns_settings, ntp_settings + // sind node-spezifisch (jeder Node hat eigene IPs/Routes/Listen-Adressen) + // und fließen NICHT in den Drift-Hash ein. } // hashSQL rendert die SHA-Input-SQL für eine Tabelle. @@ -100,22 +100,35 @@ func hashSQL(t hashTable) string { // ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen // zurück. Fehlende Tabellen (transienter schema-flux) werden als // leerer Per-Table-Hash behandelt — kein Abbruch. +// +// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind +// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config). +// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries. func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) { if pool == nil { return "", fmt.Errorf("nil pool") } h := sha256.New() + hasUserConfig := false for _, t := range hashSpec { var s string if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil { // Migration fehlt o.ä. → leeren string nehmen, weiter. s = "" } + if s != "" && !t.Singleton && !t.MigrationDefault { + hasUserConfig = true + } h.Write([]byte(t.Name)) h.Write([]byte{0}) h.Write([]byte(s)) h.Write([]byte{0}) } + if !hasUserConfig { + // Frisch gejoincter Secondary oder komplett leere DB → + // leerer String signalisiert "kein Drift prüfen" im Status-Handler. + return "", nil + } return hex.EncodeToString(h.Sum(nil))[:16], nil } diff --git a/internal/database/migrations/0029_cluster_vip.sql b/internal/database/migrations/0029_cluster_vip.sql new file mode 100644 index 0000000..1dae0c1 --- /dev/null +++ b/internal/database/migrations/0029_cluster_vip.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- +goose StatementBegin + +-- pg_role: Rolle dieser Node in der PG-Replikation. +-- "standalone" = kein Streaming-Replication-Setup +-- "primary" = WAL-Sender, repliziert an Standby(s) +-- "standby" = Hot-Standby, liest WAL vom Primary +ALTER TABLE ha_nodes ADD COLUMN IF NOT EXISTS pg_role TEXT NOT NULL DEFAULT 'standalone'; + +-- cluster_settings: VIP + VRRP-Konfiguration (Singleton, id=1). +-- vip_address = die virtuelle IP-Adresse (z.B. "89.163.205.10") +-- vip_interface = Netzwerk-Interface (z.B. "eth0") +-- vip_auth_pass = VRRP-Authentication-Passwort (max. 8 Zeichen, Keepalived-Limit) +-- vrrp_router_id = VRRP Virtual Router ID (1–255, muss im Subnetz eindeutig sein) +CREATE TABLE IF NOT EXISTS cluster_settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + vip_address TEXT, + vip_interface TEXT, + vip_auth_pass TEXT, + vrrp_router_id INTEGER NOT NULL DEFAULT 51, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT cluster_settings_singleton CHECK (id = 1) +); + +INSERT INTO cluster_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS cluster_settings; +ALTER TABLE ha_nodes DROP COLUMN IF EXISTS pg_role; + +-- +goose StatementEnd diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl new file mode 100644 index 0000000..d5fdcea --- /dev/null +++ b/internal/keepalived/keepalived.conf.tpl @@ -0,0 +1,40 @@ +global_defs { + router_id {{ .RouterID }} + script_user root + enable_script_security + vrrp_garp_interval 0 + vrrp_gna_interval 0 +} + +vrrp_script chk_edgeguard { + script "/usr/lib/edgeguard/keepalived-check.sh" + interval 2 + weight -50 + fall 3 + rise 2 +} + +vrrp_instance VI_1 { + state {{ .State }} + interface {{ .Interface }} + virtual_router_id {{ .RouterID }} + priority {{ .Priority }} + advert_int 1 +{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} + unicast_peer { + {{ .PeerIP }} + } +{{ end }} authentication { + auth_type PASS + auth_pass {{ .AuthPass }} + } + virtual_ipaddress { + {{ .VIP }} + } + track_script { + chk_edgeguard + } + notify_master "/usr/lib/edgeguard/keepalived-master.sh" + notify_backup "/usr/lib/edgeguard/keepalived-backup.sh" + notify_fault "/usr/lib/edgeguard/keepalived-backup.sh" +} diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go new file mode 100644 index 0000000..09f3a54 --- /dev/null +++ b/internal/keepalived/keepalived.go @@ -0,0 +1,154 @@ +// Package keepalived rendert /etc/keepalived/keepalived.conf aus +// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer). +// +// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur +// und sendet einen internen Alert. Promotion ist immer manuell via +// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne +// externes Quorum in einem 2-Node-Cluster. +package keepalived + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "os" + "os/exec" + "text/template" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +const ConfPath = "/etc/keepalived/keepalived.conf" + +//go:embed keepalived.conf.tpl +var cfgTpl string + +var tpl = template.Must(template.New("keepalived").Parse(cfgTpl)) + +// View ist der Template-Kontext. +type View struct { + State string // MASTER | BACKUP + Interface string + RouterID int + Priority int // MASTER=200, BACKUP=100 + SrcIP string // eigene Public-IP (für unicast_src_ip) + PeerIP string // Peer-Public-IP (für unicast_peer) + AuthPass string + VIP string +} + +type generator struct { + pool *pgxpool.Pool + localID string +} + +func New(pool *pgxpool.Pool, localID string) configgen.Generator { + return &generator{pool: pool, localID: localID} +} + +func (g *generator) Name() string { return "keepalived" } + +func (g *generator) Render(ctx context.Context) error { + cs, local, peer, err := g.loadData(ctx) + if err != nil { + return fmt.Errorf("keepalived: load: %w", err) + } + if cs.VIPAddress == nil || *cs.VIPAddress == "" { + // Kein VIP konfiguriert → keepalived.conf nicht schreiben. + return nil + } + v := g.buildView(cs, local, peer) + var buf bytes.Buffer + if err := tpl.Execute(&buf, v); err != nil { + return fmt.Errorf("keepalived: template: %w", err) + } + if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil { + return fmt.Errorf("keepalived: write: %w", err) + } + if err := reloadKeepalived(); err != nil { + return fmt.Errorf("keepalived: reload: %w", err) + } + return nil +} + +func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *models.HANode, *models.HANode, error) { + var cs models.ClusterSettings + row := g.pool.QueryRow(ctx, `SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`) + if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil { + return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err) + } + + rows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`) + if err != nil { + return nil, nil, nil, fmt.Errorf("ha_nodes: %w", err) + } + defer rows.Close() + + var local, peer *models.HANode + for rows.Next() { + n := &models.HANode{} + if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil { + continue + } + if n.ID == g.localID { + local = n + } else { + peer = n + } + } + if local == nil { + return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID) + } + return &cs, local, peer, nil +} + +func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View { + v := View{ + RouterID: cs.VRRPRouterID, + VIP: deref(cs.VIPAddress), + Interface: deref(cs.VIPInterface), + AuthPass: deref(cs.VIPAuthPass), + } + if v.Interface == "" { + v.Interface = "eth0" + } + if v.AuthPass == "" { + v.AuthPass = "edgeguard" + } + + // Primary-Node bekommt höhere Priorität und startet als MASTER. + if local.PGRole == "primary" || local.Role == "primary" { + v.State = "MASTER" + v.Priority = 200 + } else { + v.State = "BACKUP" + v.Priority = 100 + } + + if local.PublicIP != nil { + v.SrcIP = *local.PublicIP + } + if peer != nil && peer.PublicIP != nil { + v.PeerIP = *peer.PublicIP + } + return v +} + +func reloadKeepalived() error { + if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) { + // keepalived läuft noch nicht — erster Render beim Start. + return nil + } + return exec.Command("systemctl", "reload-or-restart", "keepalived").Run() +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/models/cluster_settings.go b/internal/models/cluster_settings.go new file mode 100644 index 0000000..d5ae7e3 --- /dev/null +++ b/internal/models/cluster_settings.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP- +// und Replikations-Konfiguration. Angelegt in Migration 0029. +type ClusterSettings struct { + ID int `gorm:"column:id;primaryKey" json:"id"` + VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"` + VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"` + VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"` + VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (ClusterSettings) TableName() string { return "cluster_settings" } diff --git a/internal/models/ha_node.go b/internal/models/ha_node.go index e273fb1..1c4f852 100644 --- a/internal/models/ha_node.go +++ b/internal/models/ha_node.go @@ -4,7 +4,7 @@ import "time" // HANode mirrort eine Row der ha_nodes-Tabelle. Erweitert in Migration // 0020 um version/config_hash/mgmt_ip/status für Cluster-Phase-3- -// Drift-Detection + Health-State. +// Drift-Detection + Health-State. Migration 0029 fügt PGRole hinzu. type HANode struct { ID string `gorm:"column:id;primaryKey" json:"id"` Name string `gorm:"column:name" json:"name"` @@ -14,6 +14,7 @@ type HANode struct { InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"` MgmtIP *string `gorm:"column:mgmt_ip;type:inet" json:"mgmt_ip,omitempty"` Role string `gorm:"column:role" json:"role"` + PGRole string `gorm:"column:pg_role" json:"pg_role"` Version *string `gorm:"column:version" json:"version,omitempty"` ConfigHash *string `gorm:"column:config_hash" json:"config_hash,omitempty"` Status string `gorm:"column:status" json:"status"` diff --git a/internal/services/clusterjoin/join.go b/internal/services/clusterjoin/join.go index a4380d2..b9027c1 100644 --- a/internal/services/clusterjoin/join.go +++ b/internal/services/clusterjoin/join.go @@ -128,7 +128,7 @@ func Join(req Request) error { // synchronous on the primary side. var autoRegErr error 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, ""); err == nil { autoRegErr = nil break } else { @@ -217,7 +217,18 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri return env.Data.CACert, env.Data.PeerCert, nil } -func autoRegister(primary, tlsDir, commonName, version, nodeID string) error { +// PushSelfToPrimary sends this node's current identity + configHash to the +// primary via mTLS. Exported for use by the API server's periodic push +// goroutine so the primary's ha_nodes always reflects the secondary's actual +// config_hash (not the stale join-time value). +func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error { + if tlsDir == "" { + tlsDir = clustertls.DefaultDir + } + return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash) +} + +func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error { u, err := url.Parse(primary) if err != nil { return err @@ -231,11 +242,12 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID string) error { } hostname, _ := os.Hostname() body, _ := json.Marshal(map[string]string{ - "id": nodeID, - "name": hostname, - "fqdn": commonName, - "api_url": "https://" + commonName + ":3443", - "version": version, + "id": nodeID, + "name": hostname, + "fqdn": commonName, + "api_url": "https://" + commonName + ":3443", + "version": version, + "config_hash": configHash, }) pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key") diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx index 95b7812..de853b7 100644 --- a/management-ui/src/pages/Cluster/index.tsx +++ b/management-ui/src/pages/Cluster/index.tsx @@ -22,6 +22,7 @@ interface HANode { internal_ip?: string | null mgmt_ip?: string | null role: string + pg_role: 'standalone' | 'primary' | 'standby' version?: string | null config_hash?: string | null status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown' @@ -291,6 +292,13 @@ export default function ClusterPage() { title: t('cluster.col.role'), dataIndex: 'role', width: 110, render: (v: string) => {v}, }, + { + title: t('cluster.col.pgRole'), dataIndex: 'pg_role', width: 110, + render: (v?: string) => { + if (!v || v === 'standalone') return {t('cluster.pgRole.standalone')} + return {t(`cluster.pgRole.${v}`)} + }, + }, { title: t('cluster.col.version'), dataIndex: 'version', width: 100, render: (v?: string | null) => v ? {v} : , @@ -525,6 +533,13 @@ export default function ClusterPage() { {data.local_node.role} + + {(() => { + const v = data.local_node.pg_role + if (!v || v === 'standalone') return {t('cluster.pgRole.standalone')} + return {t(`cluster.pgRole.${v}`)} + })()} + {data.local_node.version ? {data.local_node.version} : '—'} diff --git a/management-ui/src/pages/Settings/index.tsx b/management-ui/src/pages/Settings/index.tsx index 9e5cf17..847cc64 100644 --- a/management-ui/src/pages/Settings/index.tsx +++ b/management-ui/src/pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd' -import { CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons' +import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -34,12 +34,20 @@ interface ChangePasswordValues { confirm_password: string } +interface VIPSettingsValues { + vip_address?: string + vip_interface?: string + vip_auth_pass?: string + vrrp_router_id?: number +} + export default function SettingsPage() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const [msg, msgCtx] = message.useMessage() const [pwForm] = Form.useForm() + const [vipForm] = Form.useForm() const { data: setupStatus, isLoading: loadingSetup } = useQuery({ queryKey: ['setup', 'status'], @@ -59,6 +67,27 @@ export default function SettingsPage() { }, }) + const { data: vipSettings } = useQuery({ + queryKey: ['cluster', 'vip-settings'], + queryFn: async () => { + const r = await apiClient.get('/cluster/vip-settings') + return isEnvelope(r.data) ? r.data.data as VIPSettingsValues : null + }, + }) + + useEffect(() => { + if (vipSettings) vipForm.setFieldsValue(vipSettings) + }, [vipSettings, vipForm]) + + const updateVIP = useMutation({ + mutationFn: async (v: VIPSettingsValues) => apiClient.put('/cluster/vip-settings', v), + onSuccess: () => { + msg.success(t('cluster.vipCard.saved')) + void qc.invalidateQueries({ queryKey: ['cluster', 'vip-settings'] }) + }, + onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message), + }) + const [emailForm] = Form.useForm() const updateEmails = useMutation({ mutationFn: async (v: ContactEmailValues) => { @@ -768,6 +797,58 @@ export default function SettingsPage() { + {t('cluster.vipCard.title')}} + size="small" + className="mb-12" + > + +
  • {t('cluster.vipCard.hintPrimary')}
  • +
  • {t('cluster.vipCard.hintStandby')}
  • +
  • {t('cluster.vipCard.hintKeepalived')}
  • +
  • {t('cluster.vipCard.hintFailover')}
  • + + } + /> + + form={vipForm} + layout="vertical" + onFinish={(v) => updateVIP.mutate(v)} + initialValues={{ vrrp_router_id: 51 }} + > + + + + + + + + + + + + + {!isViewer && ( + + + + )} + +
    + {t('settings.passwordCardTitle')}} size="small"> form={pwForm} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 6a5817d..fef5da6 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -130,6 +130,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-upgrade.ser # unter /var/lib/edgeguard/restore.sh, Unit-Form ist fix. edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-restore.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-restore.service --description=EdgeGuard self-restore --collect bash /var/lib/edgeguard/restore.sh +# Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload. +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service SUDOERS # ── Distro-Conf-Includes für die per-Service Renderer ───────── @@ -397,6 +400,17 @@ ROUTESUNIT chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf fi + # ── Keepalived notify-scripts installieren ─────────────────── + # Die Skripte liegen im Package unter /usr/lib/edgeguard/ und + # werden von Keepalived als notify_master / notify_backup / check + # aufgerufen. Kein Auto-Promote — keepalived-master.sh loggt nur. + install -d -m 0755 /usr/lib/edgeguard + for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do + if [ -f "/usr/lib/edgeguard/${script}" ]; then + chmod 0755 "/usr/lib/edgeguard/${script}" + fi + done + # ── Self-signed default cert so HAProxy starts cleanly ─────── # HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least # one PEM in the directory to come up. Operator runs certbot diff --git a/packaging/scripts/keepalived-backup.sh b/packaging/scripts/keepalived-backup.sh new file mode 100644 index 0000000..c5dcc41 --- /dev/null +++ b/packaging/scripts/keepalived-backup.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Keepalived notify_backup / notify_fault: VIP abgegeben oder Fault. +logger -t keepalived -p daemon.info \ + "BACKUP/FAULT: VIP abgegeben an Primary-Node." + +curl -sf --max-time 3 -X POST \ + -H "Content-Type: application/json" \ + -d '{"level":"info","message":"Keepalived BACKUP: VIP abgegeben — Primary ist wieder aktiv.","source":"keepalived"}' \ + http://127.0.0.1:9443/api/v1/internal/alert > /dev/null 2>&1 || true diff --git a/packaging/scripts/keepalived-check.sh b/packaging/scripts/keepalived-check.sh new file mode 100644 index 0000000..698b9eb --- /dev/null +++ b/packaging/scripts/keepalived-check.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Keepalived health check: edgeguard-api erreichbar? +# Weight -50 → BACKUP gewinnt wenn Primary-API nicht antwortet. +curl -sf --max-time 2 --unix-socket /run/edgeguard/api.sock \ + http://localhost/api/v1/system/health > /dev/null 2>&1 \ + || curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1 diff --git a/packaging/scripts/keepalived-master.sh b/packaging/scripts/keepalived-master.sh new file mode 100644 index 0000000..0c1bc53 --- /dev/null +++ b/packaging/scripts/keepalived-master.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Keepalived notify_master: dieser Node hat die VIP übernommen. +# +# KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion. +# Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht. +# +# Was wir tun: Alert loggen + edgeguard-api benachrichtigen. +logger -t keepalived -p daemon.warning \ + "MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote" + +# Alert an die API schicken (best-effort, ignoriert Fehler) +curl -sf --max-time 3 -X POST \ + -H "Content-Type: application/json" \ + -d '{"level":"warning","message":"Keepalived MASTER: VIP übernommen. Wenn PG-Failover gewünscht: edgeguard-ctl promote ausführen.","source":"keepalived"}' \ + http://127.0.0.1:9443/api/v1/internal/alert > /dev/null 2>&1 || true diff --git a/scripts/apt-repo/build-package.sh b/scripts/apt-repo/build-package.sh index 2e5345d..569f880 100755 --- a/scripts/apt-repo/build-package.sh +++ b/scripts/apt-repo/build-package.sh @@ -78,6 +78,14 @@ build_api() { install -m 0644 "$REPO_ROOT/deploy/systemd/haproxy-edgeguard.conf" \ "$build_dir/etc/edgeguard/systemd/" + # Keepalived notify-scripts → /usr/lib/edgeguard/ + # postinst setzt chmod 0755 nach der Installation. + mkdir -p "$build_dir/usr/lib/edgeguard" + for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do + install -m 0755 "$REPO_ROOT/packaging/scripts/$script" \ + "$build_dir/usr/lib/edgeguard/$script" + done + # Installed-Size in KB (rounded up) local size size="$(du -sk "$build_dir" | awk '{print $1}')"