package main import ( "crypto/rand" "crypto/tls" "crypto/x509" "encoding/json" "flag" "fmt" "net" "net/http" "os" "os/exec" "os/user" "path/filepath" "strconv" "strings" "time" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls" ) const ( egReplSecret = "/var/lib/edgeguard/pg-replication-secret" egReplUser = "edgeguard_replicator" egPubName = "edgeguard_shared" egSubName = "edgeguard_sub" ) // 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{ "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 "dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden) "radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen "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 } 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) if err := setupReplicationPrimary(pg); err != nil { fmt.Fprintln(os.Stderr, "cluster-init-replication:", err) return 1 } fmt.Println() fmt.Println("Nächste Schritte:") fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby ") fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren") fmt.Println(" → Settings → Cluster → VIP/Keepalived") return 0 } // setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical- // Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical), // pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical // AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht). // Idempotent. Gemeinsam genutzt von cluster-init-replication und promote. func setupReplicationPrimary(pg pgConfig) error { // 1. Passwort generieren pass, err := generatePassword(32) if err != nil { return fmt.Errorf("generate password: %w", err) } // 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 { return fmt.Errorf("create replication role: %w", err) } fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser) // 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest) if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil { return fmt.Errorf("mkdir: %w", err) } if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil { return fmt.Errorf("write secret: %w", err) } 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 if err := os.MkdirAll(pg.ConfD, 0o755); err != nil { return fmt.Errorf("conf.d mkdir: %w", err) } replConf := `# EdgeGuard Logical Replication — automatisch generiert # Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet. wal_level = logical max_wal_senders = 10 max_replication_slots = 20 max_logical_replication_workers = 4 wal_keep_size = 512MB # '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt. listen_addresses = '*' ` confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf") if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil { return fmt.Errorf("write postgresql conf: %w", err) } fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) // 5. pg_hba.conf aktualisieren if err := ensureHBAReplication(pg.HBAPath); err != nil { return fmt.Errorf("pg_hba.conf: %w", err) } fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath) // 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber // postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig. if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil { return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out))) } fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster) if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" { fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...") if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil { return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out))) } ready := false deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil { ready = true break } time.Sleep(2 * time.Second) } if !ready { return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs") } fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)") } // 7. SELECT-Grants (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 { return fmt.Errorf("grant SELECT: %w", err) } fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser) // 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE) if err := createPublication(); err != nil { return fmt.Errorf("create publication: %w", err) } fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName) return nil } // dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription // idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den // Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen. func dropSubscriptionIfExists() error { dropSQL := fmt.Sprintf(` DO $$ BEGIN IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN ALTER SUBSCRIPTION %s DISABLE; ALTER SUBSCRIPTION %s SET (slot_name = NONE); DROP SUBSCRIPTION %s; END IF; END $$;`, egSubName, egSubName, egSubName, egSubName) return psqlDBExec("edgeguard", dropSQL) } // createPublication baut die PUBLICATION dynamisch aus allen Tabellen // 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(hbaPath string) error { data, err := os.ReadFile(hbaPath) if err != nil { return fmt.Errorf("read: %w", err) } const marker = "# EdgeGuard replication" if strings.Contains(string(data), marker) { return nil } 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(hbaPath, 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) if err := dropSubscriptionIfExists(); err != nil { // Nicht fatal — wenn PG noch keine Subscription kennt ist das OK fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n") } else { 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, ) // Via stdin (nicht -c), damit das Replikations-Passwort nicht in der // Prozess-Argv (ps/proc) oder in PG-log_statement landet. if err := psqlDBExecStdin("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. 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("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: sudo -u edgeguard 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 := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info" 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 } // 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 := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key" 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" 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 } // psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit // Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen. func psqlDBExecStdin(db, sql string) error { cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"}) cmd.Stdin = strings.NewReader(sql) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } return nil } // 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...)) }