|
|
|
|
@@ -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/<version>/<cluster>/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"
|
|
|
|
|
|