feat(cluster): PG Logical Replication + VIP/Keepalived + config_hash sync (v1.2.1–1.2.2)

- PG Logical Replication: edgeguard_shared PUBLICATION auf Primary,
  edgeguard_sub SUBSCRIPTION auf Secondary. Nur geteilte Config-Tabellen
  werden repliziert; node-eigene Daten (network_interfaces, ip_addresses,
  static_routes, cluster_settings, dns_settings, ntp_settings) bleiben
  lokal — OPNsense-Muster.
- cluster-init-replication: Erstellt PUBLICATION, Rolle + pg_hba-Einträge
  (logical + replication), WAL-Level auf logical.
- cluster-setup-standby: Erstellt SUBSCRIPTION (copy_data=true), pollt
  pg_subscription_rel bis alle Tabellen sync = 'r', rendert dann Configs.
- promote: manueller Failover via pg_promote() + touch recovery.signal.
- VIP/Keepalived: cluster_settings-Tabelle (vip_address, vip_interface,
  vrrp_router_id), /cluster/vip-settings API, Keepalived-Config-Generator
  mit VRRP + check_script + notify-Skripten in /usr/lib/edgeguard/scripts/.
- config_hash sync: Secondary pusht alle 5 Min seinen Hash via mTLS an
  Primary (PushSelfToPrimary). Heartbeat schreibt nur LOCAL, daher ohne
  aktiven Push wäre Primary-Sicht des Secondary-Hash stale gewesen.
- runSecondaryConfigRender: Goroutine auf Secondary rendert HAProxy+nftables
  neu wenn config_hash sich ändert (Logical-Replication-Nachzügler).
- confighash: node-spezifische Tabellen aus hashSpec entfernt.
- postinst: Keepalived-Skripte installieren, sudoers für keepalived.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 23:40:37 +02:00
parent c1a4ccff8f
commit 25c7cd0cb5
19 changed files with 1128 additions and 40 deletions

View File

@@ -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 <primary-ip>")
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 <primary-ip-or-host>")
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...))
}

View File

@@ -1,7 +1,6 @@
// Command edgeguard-ctl is the admin CLI for setup, migrations and // Command edgeguard-ctl is the admin CLI for setup, migrations and
// (later) cluster ops. v1 wires migrate + initdb so postinst can // cluster ops. v1.2 implements PG streaming replication setup,
// initialise a fresh node; cluster-* and promote remain stubs until // VIP/Keepalived config and manual failover (promote).
// Phase 3.
package main package main
import ( import (
@@ -11,7 +10,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "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 const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -25,23 +24,25 @@ Commands:
migrate check Validate embedded migrations (no DB connect) migrate check Validate embedded migrations (no DB connect)
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations) migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
initdb Create PostgreSQL role + database (idempotent) 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 <dir>] [iface…] wg-import [--path <dir>] [iface…]
Import /etc/wireguard/*.conf files into the DB. 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 reset-password Generate a one-time token for the /reset-password UI flow
cluster-join <primary> --token <…> cluster-join <primary> --token <…>
Provision Cluster-TLS material on this node by Provision Cluster-TLS material; writes ca.crt + peer.{crt,key}
exchanging the join-token at the primary's cluster-init-replication Richtet PG Logical Replication auf dem Primary ein.
/api/v1/cluster/issue-cert endpoint. Writes Erstellt edgeguard_replicator-Rolle, setzt wal_level=logical,
ca.crt + peer.{crt,key} into /var/lib/edgeguard/ erstellt PUBLICATION edgeguard_shared (alle geteilten Tabellen).
cluster-tls/. PG-Basebackup + KeyDB replica Auf dem Primary ausführen bevor der Secondary joined.
setup remain manual until Phase 3.5. cluster-setup-standby <ip> Richtet diesen Node als Logical-Replication-Subscriber ein.
cluster-renew-self Re-issue this node's peer.{crt,key} using the Erstellt SUBSCRIPTION gegen den Primary (Initialkopiierung
local cluster CA (founder/single-node only). aller geteilten Tabellen). Node-eigene Daten (Interfaces,
1-year validity. Restart edgeguard-api after. IPs, Routen, VIP-Settings) bleiben unangetastet.
promote Promote this node's PG to primary (Phase 3, not yet implemented) 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) dump-config Print effective config (Phase 3, not yet implemented)
` `
@@ -69,7 +70,13 @@ func main() {
os.Exit(cmdClusterJoin(os.Args[2:])) os.Exit(cmdClusterJoin(os.Args[2:]))
case "cluster-renew-self": case "cluster-renew-self":
os.Exit(cmdClusterRenewSelf(os.Args[2:])) 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]) fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1) os.Exit(1)
default: default:

View File

@@ -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='<local-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 <diese-node-ip>")
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
}

View File

@@ -12,6 +12,8 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/database" "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/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "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/configorch"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/squid" "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
@@ -64,7 +66,16 @@ func cmdRenderConfig(args []string) int {
fw.SkipReload = true 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} gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
if ka != nil {
gens = append(gens, ka)
}
results, runErr := configorch.Run(ctx, gens, only) results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results)) fmt.Print(configorch.Summarise(results))

View File

@@ -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"
}

View File

@@ -39,6 +39,8 @@ type hashTable struct {
Singleton bool // dns_settings, ntp_settings → eine row, id=1 Singleton bool // dns_settings, ntp_settings → eine row, id=1
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist 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 // hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
@@ -49,15 +51,13 @@ var hashSpec = []hashTable{
{Name: "backends"}, {Name: "backends"},
{Name: "backend_servers"}, {Name: "backend_servers"},
{Name: "routing_rules"}, {Name: "routing_rules"},
{Name: "network_interfaces"},
{Name: "ip_addresses"},
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}}, {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_objects"},
{Name: "firewall_address_groups"}, {Name: "firewall_address_groups"},
{Name: "firewall_services"}, {Name: "firewall_services", MigrationDefault: true},
{Name: "firewall_service_groups"}, {Name: "firewall_service_groups", MigrationDefault: true},
{Name: "firewall_rules"}, {Name: "firewall_rules"},
{Name: "firewall_nat_rules"}, {Name: "firewall_nat_rules"},
@@ -67,12 +67,12 @@ var hashSpec = []hashTable{
{Name: "dns_zones"}, {Name: "dns_zones"},
{Name: "dns_records"}, {Name: "dns_records"},
{Name: "dns_settings", Singleton: true},
{Name: "ntp_pools"}, {Name: "ntp_pools", MigrationDefault: true},
{Name: "ntp_settings", Singleton: 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. // 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 // ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
// zurück. Fehlende Tabellen (transienter schema-flux) werden als // zurück. Fehlende Tabellen (transienter schema-flux) werden als
// leerer Per-Table-Hash behandelt — kein Abbruch. // 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) { func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
if pool == nil { if pool == nil {
return "", fmt.Errorf("nil pool") return "", fmt.Errorf("nil pool")
} }
h := sha256.New() h := sha256.New()
hasUserConfig := false
for _, t := range hashSpec { for _, t := range hashSpec {
var s string var s string
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil { if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
// Migration fehlt o.ä. → leeren string nehmen, weiter. // Migration fehlt o.ä. → leeren string nehmen, weiter.
s = "" s = ""
} }
if s != "" && !t.Singleton && !t.MigrationDefault {
hasUserConfig = true
}
h.Write([]byte(t.Name)) h.Write([]byte(t.Name))
h.Write([]byte{0}) h.Write([]byte{0})
h.Write([]byte(s)) h.Write([]byte(s))
h.Write([]byte{0}) 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 return hex.EncodeToString(h.Sum(nil))[:16], nil
} }

View File

@@ -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 (1255, 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

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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" }

View File

@@ -4,7 +4,7 @@ import "time"
// HANode mirrort eine Row der ha_nodes-Tabelle. Erweitert in Migration // HANode mirrort eine Row der ha_nodes-Tabelle. Erweitert in Migration
// 0020 um version/config_hash/mgmt_ip/status für Cluster-Phase-3- // 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 { type HANode struct {
ID string `gorm:"column:id;primaryKey" json:"id"` ID string `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"` 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"` InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"`
MgmtIP *string `gorm:"column:mgmt_ip;type:inet" json:"mgmt_ip,omitempty"` MgmtIP *string `gorm:"column:mgmt_ip;type:inet" json:"mgmt_ip,omitempty"`
Role string `gorm:"column:role" json:"role"` Role string `gorm:"column:role" json:"role"`
PGRole string `gorm:"column:pg_role" json:"pg_role"`
Version *string `gorm:"column:version" json:"version,omitempty"` Version *string `gorm:"column:version" json:"version,omitempty"`
ConfigHash *string `gorm:"column:config_hash" json:"config_hash,omitempty"` ConfigHash *string `gorm:"column:config_hash" json:"config_hash,omitempty"`
Status string `gorm:"column:status" json:"status"` Status string `gorm:"column:status" json:"status"`

View File

@@ -128,7 +128,7 @@ func Join(req Request) error {
// synchronous on the primary side. // synchronous on the primary side.
var autoRegErr error var autoRegErr error
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID); err == nil { if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
autoRegErr = nil autoRegErr = nil
break break
} else { } else {
@@ -217,7 +217,18 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
return env.Data.CACert, env.Data.PeerCert, nil 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) u, err := url.Parse(primary)
if err != nil { if err != nil {
return err return err
@@ -236,6 +247,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID string) error {
"fqdn": commonName, "fqdn": commonName,
"api_url": "https://" + commonName + ":3443", "api_url": "https://" + commonName + ":3443",
"version": version, "version": version,
"config_hash": configHash,
}) })
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key") pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")

View File

@@ -22,6 +22,7 @@ interface HANode {
internal_ip?: string | null internal_ip?: string | null
mgmt_ip?: string | null mgmt_ip?: string | null
role: string role: string
pg_role: 'standalone' | 'primary' | 'standby'
version?: string | null version?: string | null
config_hash?: string | null config_hash?: string | null
status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown' status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown'
@@ -291,6 +292,13 @@ export default function ClusterPage() {
title: t('cluster.col.role'), dataIndex: 'role', width: 110, title: t('cluster.col.role'), dataIndex: 'role', width: 110,
render: (v: string) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag>, render: (v: string) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag>,
}, },
{
title: t('cluster.col.pgRole'), dataIndex: 'pg_role', width: 110,
render: (v?: string) => {
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
},
},
{ {
title: t('cluster.col.version'), dataIndex: 'version', width: 100, title: t('cluster.col.version'), dataIndex: 'version', width: 100,
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text>, render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text>,
@@ -525,6 +533,13 @@ export default function ClusterPage() {
{data.local_node.role} {data.local_node.role}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('cluster.col.pgRole')}>
{(() => {
const v = data.local_node.pg_role
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
})()}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.version')}> <Descriptions.Item label={t('cluster.col.version')}>
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'} {data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
</Descriptions.Item> </Descriptions.Item>

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd' 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -34,12 +34,20 @@ interface ChangePasswordValues {
confirm_password: string confirm_password: string
} }
interface VIPSettingsValues {
vip_address?: string
vip_interface?: string
vip_auth_pass?: string
vrrp_router_id?: number
}
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation() const { t } = useTranslation()
const qc = useQueryClient() const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage() const [msg, msgCtx] = message.useMessage()
const [pwForm] = Form.useForm<ChangePasswordValues>() const [pwForm] = Form.useForm<ChangePasswordValues>()
const [vipForm] = Form.useForm<VIPSettingsValues>()
const { data: setupStatus, isLoading: loadingSetup } = useQuery({ const { data: setupStatus, isLoading: loadingSetup } = useQuery({
queryKey: ['setup', 'status'], 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<ContactEmailValues>() const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({ const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => { mutationFn: async (v: ContactEmailValues) => {
@@ -768,6 +797,58 @@ export default function SettingsPage() {
</Space> </Space>
</Card> </Card>
<Card
title={<><ApartmentOutlined /> {t('cluster.vipCard.title')}</>}
size="small"
className="mb-12"
>
<Alert
type="info"
showIcon
className="mb-12"
message={t('cluster.vipCard.hintTitle')}
description={
<ul style={{ margin: '4px 0', paddingLeft: 18 }}>
<li><Typography.Text code>{t('cluster.vipCard.hintPrimary')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintStandby')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintKeepalived')}</Typography.Text></li>
<li><Typography.Text code>{t('cluster.vipCard.hintFailover')}</Typography.Text></li>
</ul>
}
/>
<Form<VIPSettingsValues>
form={vipForm}
layout="vertical"
onFinish={(v) => updateVIP.mutate(v)}
initialValues={{ vrrp_router_id: 51 }}
>
<Form.Item label={t('cluster.vipCard.vipAddress')} name="vip_address"
extra={t('cluster.vipCard.vipAddressHelp')}>
<Input placeholder="89.163.205.10" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vipInterface')} name="vip_interface"
extra={t('cluster.vipCard.vipInterfaceHelp')}>
<Input placeholder="eth0" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vipAuthPass')} name="vip_auth_pass"
extra={t('cluster.vipCard.vipAuthPassHelp')}
rules={[{ max: 8, message: 'Max. 8 Zeichen (Keepalived-Limit)' }]}>
<Input.Password placeholder="max 8 chars" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.vrrpRouterId')} name="vrrp_router_id"
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
<InputNumber min={1} max={255} disabled={isViewer} />
</Form.Item>
{!isViewer && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
{t('cluster.vipCard.saveBtn')}
</Button>
</Form.Item>
)}
</Form>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small"> <Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues> <Form<ChangePasswordValues>
form={pwForm} form={pwForm}

View File

@@ -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. # 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/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 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 SUDOERS
# ── Distro-Conf-Includes für die per-Service Renderer ───────── # ── Distro-Conf-Includes für die per-Service Renderer ─────────
@@ -397,6 +400,17 @@ ROUTESUNIT
chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf
fi 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 ─────── # ── Self-signed default cert so HAProxy starts cleanly ───────
# HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least # HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least
# one PEM in the directory to come up. Operator runs certbot # one PEM in the directory to come up. Operator runs certbot

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -78,6 +78,14 @@ build_api() {
install -m 0644 "$REPO_ROOT/deploy/systemd/haproxy-edgeguard.conf" \ install -m 0644 "$REPO_ROOT/deploy/systemd/haproxy-edgeguard.conf" \
"$build_dir/etc/edgeguard/systemd/" "$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) # Installed-Size in KB (rounded up)
local size local size
size="$(du -sk "$build_dir" | awk '{print $1}')" size="$(du -sk "$build_dir" | awk '{print $1}')"