diff --git a/VERSION b/VERSION index 0495c4a..1fc5b82 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.3 +1.2.15 diff --git a/cmd/edgeguard-ctl/cluster_replication.go b/cmd/edgeguard-ctl/cluster_replication.go index 073a589..05c3231 100644 --- a/cmd/edgeguard-ctl/cluster_replication.go +++ b/cmd/edgeguard-ctl/cluster_replication.go @@ -10,7 +10,9 @@ import ( "net/http" "os" "os/exec" + "os/user" "path/filepath" + "strconv" "strings" "time" @@ -18,22 +20,55 @@ import ( ) 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. ) +// pgConfig hält die zur Laufzeit erkannten PG-Pfade. +type pgConfig struct { + Version string // z.B. "17" + Cluster string // z.B. "main" + DataDir string // /var/lib/postgresql/17/main + HBAPath string // /etc/postgresql/17/main/pg_hba.conf + ConfD string // /etc/postgresql/17/main/conf.d +} + +// detectPGConfig ermittelt Version, Cluster und Pfade aus der laufenden +// PG-Instanz via SHOW hba_file / SHOW data_directory. Damit ist der Code +// unabhängig von der PG-Hauptversion (16, 17, …). +func detectPGConfig() (pgConfig, error) { + hbaRaw, err := psqlRun([]string{"-tA", "-c", "SHOW hba_file;"}) + if err != nil { + return pgConfig{}, fmt.Errorf("cannot detect pg hba_file: %w", err) + } + hbaPath := strings.TrimSpace(string(hbaRaw)) + + dataRaw, err := psqlRun([]string{"-tA", "-c", "SHOW data_directory;"}) + if err != nil { + return pgConfig{}, fmt.Errorf("cannot detect pg data_directory: %w", err) + } + dataDir := strings.TrimSpace(string(dataRaw)) + + // hbaPath: /etc/postgresql///pg_hba.conf + parts := strings.Split(filepath.ToSlash(hbaPath), "/") + if len(parts) < 6 { + return pgConfig{}, fmt.Errorf("unexpected hba_file path: %s", hbaPath) + } + version := parts[3] + cluster := parts[4] + confD := filepath.Join("/etc/postgresql", version, cluster, "conf.d") + + return pgConfig{ + Version: version, + Cluster: cluster, + DataDir: dataDir, + HBAPath: hbaPath, + ConfD: confD, + }, nil +} + // localOnlyTables listet alle Tabellen die nicht in die Replikations- // Publication aufgenommen werden. Alles andere wird automatisch repliziert. var localOnlyTables = []string{ @@ -70,6 +105,13 @@ func cmdClusterInitReplication(args []string) int { return 2 } + pg, err := detectPGConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "cluster-init-replication: PG-Erkennung:", err) + return 1 + } + fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster) + // 1. Passwort generieren pass, err := generatePassword(32) if err != nil { @@ -102,24 +144,34 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass) fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err) return 1 } + // Ownership an edgeguard-api-User übergeben damit die API lesen kann + if u, err := user.Lookup("edgeguard"); err == nil { + uid, _ := strconv.Atoi(u.Uid) + gid, _ := strconv.Atoi(u.Gid) + _ = os.Chown(egReplSecret, uid, gid) + } 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 { + if err := os.MkdirAll(pg.ConfD, 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_wal_senders = 10 +max_replication_slots = 20 max_logical_replication_workers = 4 wal_keep_size = 512MB +# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers +# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf +# bekannte Replikations-User beschränkt. +listen_addresses = '*' ` - confPath := filepath.Join(pgConfD, "edgeguard-replication.conf") + confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf") if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil { fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err) return 1 @@ -127,18 +179,18 @@ wal_keep_size = 512MB fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) // 5. pg_hba.conf aktualisieren - if err := ensureHBAReplication(); err != nil { + if err := ensureHBAReplication(pg.HBAPath); err != nil { fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err) return 1 } - fmt.Printf("✓ %s aktualisiert\n", pgHBAPath) + fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath) // 6. PG reload (damit wal_level + pg_hba aktiv werden) - if out, err := exec.Command("pg_ctlcluster", pgVersion, pgCluster, "reload").CombinedOutput(); err != nil { + if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out) return 1 } - fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pgVersion, pgCluster) + fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster) // 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden // Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen. @@ -213,8 +265,8 @@ func createPublication() error { // 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) +func ensureHBAReplication(hbaPath string) error { + data, err := os.ReadFile(hbaPath) if err != nil { return fmt.Errorf("read: %w", err) } @@ -222,16 +274,14 @@ func ensureHBAReplication() error { 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 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) + f, err := os.OpenFile(hbaPath, os.O_APPEND|os.O_WRONLY, 0o640) if err != nil { return fmt.Errorf("open: %w", err) } @@ -345,11 +395,19 @@ WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s') } } - // 5. render-config ausführen + // 5. Master-Key vom Primary holen — für WireGuard-Key-Entschlüsselung + fmt.Println("→ Secrets Master-Key vom Primary synchronisieren...") + if err := syncMasterKey(primaryHost, *agentPort, *tlsDir); err != nil { + fmt.Fprintf(os.Stderr, "cluster-setup-standby: master-key: %v (WireGuard-Keys können nicht entschlüsselt werden)\n", err) + } else { + fmt.Println("✓ Master-Key synchronisiert") + } + + // 6. render-config ausführen — muss als edgeguard-User laufen (DB-Zugriff) fmt.Println("→ Service-Configs neu rendern...") - if out, err := exec.Command("edgeguard-ctl", "render-config", "--no-reload").CombinedOutput(); err != nil { + if out, err := exec.Command("sudo", "-u", "edgeguard", "edgeguard-ctl", "render-config").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") + fmt.Println(" → Manuell nachholen: sudo -u edgeguard edgeguard-ctl render-config") } else { fmt.Print(string(out)) fmt.Println("✓ Service-Configs aktualisiert") @@ -429,6 +487,66 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic return &result.Data, nil } +// syncMasterKey holt den Secrets-Master-Key vom Primary via mTLS und schreibt +// ihn nach /var/lib/edgeguard/.master_key. Dadurch können replizierte +// verschlüsselte WireGuard-Keys und PSKs auf dem Secondary entschlüsselt werden. +func syncMasterKey(host string, agentPort int, tlsDir string) 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 fmt.Errorf("read ca.crt: %w", err) + } + rootPool := x509.NewCertPool() + rootPool.AppendCertsFromPEM(caCert) + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return fmt.Errorf("load peer cert: %w", err) + } + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: rootPool, + Certificates: []tls.Certificate{cert}, + }, + }, + } + url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort) + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("GET %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) + } + var result struct { + Data struct { + KeyHex string `json:"key_hex"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode response: %w", err) + } + key := make([]byte, 32) + if _, err := fmt.Sscanf(result.Data.KeyHex, "%x", &key); err != nil { + return fmt.Errorf("decode key_hex: %w", err) + } + const masterKeyPath = "/var/lib/edgeguard/.master_key" + if err := os.WriteFile(masterKeyPath, key, 0o600); err != nil { + return fmt.Errorf("write master key: %w", err) + } + if u, err := user.Lookup("edgeguard"); err == nil { + uid, _ := strconv.Atoi(u.Uid) + gid, _ := strconv.Atoi(u.Gid) + _ = os.Chown(masterKeyPath, uid, gid) + } + return nil +} + // generatePassword erzeugt ein kryptographisch sicheres Passwort. func generatePassword(n int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index b3fa7c4..501ab11 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -10,7 +10,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.2.3" +var version = "1.2.15" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-ctl/promote.go b/cmd/edgeguard-ctl/promote.go index dd75dcc..f5e0b71 100644 --- a/cmd/edgeguard-ctl/promote.go +++ b/cmd/edgeguard-ctl/promote.go @@ -27,8 +27,14 @@ import ( // 6. keepalived.conf neu rendern (Primary bekommt Priorität 200) // 7. keepalived reload func cmdPromote(args []string) int { + pg, err := detectPGConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err) + return 1 + } + // 1. Standby-Signal prüfen - signalPath := filepath.Join(pgDataDir, "standby.signal") + signalPath := filepath.Join(pg.DataDir, "standby.signal") if _, err := os.Stat(signalPath); os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n", @@ -36,8 +42,8 @@ func cmdPromote(args []string) int { return 1 } - fmt.Println("→ Promoting PostgreSQL zu Primary...") - if out, err := exec.Command("pg_ctlcluster", pgVersion, pgCluster, "promote"). + fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster) + if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote"). CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out) return 1 diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 077ef15..95c3327 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.2.3" +var version = "1.2.15" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 719ddc0..e3d3cdb 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -49,6 +49,11 @@ table inet edgeguard { # Cluster-internal: peers reach edgeguard-api over mTLS on :8443 tcp dport 8443 ip saddr @peer_ipv4 accept tcp dport 8443 ip6 saddr @peer_ipv6 accept + # Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379) + tcp dport 5432 ip saddr @peer_ipv4 accept + tcp dport 5432 ip6 saddr @peer_ipv6 accept + tcp dport 6379 ip saddr @peer_ipv4 accept + tcp dport 6379 ip6 saddr @peer_ipv6 accept # ── Service-Auto-Rules (DNS/Squid/WG/...) ── # Aus dem laufenden Service-State abgeleitet — Operator diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 4b98380..3b45294 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -215,6 +215,7 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) { g.POST("/peers", h.AgentRegisterPeer) g.GET("/identity", h.AgentIdentity) g.GET("/pg-replication-info", h.AgentPGReplicationInfo) + g.GET("/master-key", h.AgentMasterKey) g.GET("/version", h.AgentVersion) g.POST("/trigger-update", h.AgentTriggerUpdate) } @@ -270,6 +271,20 @@ func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) { }) } +// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby +// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener +// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte +// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden. +func (h *ClusterHandler) AgentMasterKey(c *gin.Context) { + const keyPath = "/var/lib/edgeguard/.master_key" + data, err := os.ReadFile(keyPath) + if err != nil { + response.NotFound(c, simpleError("master key nicht gefunden")) + return + } + response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)}) +} + func readFileString(path string) (string, error) { b, err := os.ReadFile(path) if err != nil { @@ -719,7 +734,7 @@ rm -f /var/lib/edgeguard/upgrade.sh _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run() cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", "--unit="+unitName, - "--description=EdgeGuard rolling-update (triggered by primary)", + "--description=EdgeGuard self-upgrade", "--collect", "bash", scriptPath) if err := cmd.Run(); err != nil {