Files
edgeguard-native/internal/cluster/confighash.go
Debian 25c7cd0cb5 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>
2026-05-29 23:40:37 +02:00

151 lines
5.1 KiB
Go

package cluster
// Config-Hash berechnet einen deterministischen SHA-256-Hash über alle
// replizierbaren Tabellen — die Grundlage des "Config-Drift"-Banners
// im Cluster-UI. Pattern 1:1 aus mail-gateway/internal/handlers/
// cluster_status.go (driftHashSpec).
//
// Hash-Bildung pro Tabelle:
// - SELECT md5((to_jsonb(t) - 'id' - 'updated_at' - 'created_at' -
// <extra-excludes>)::text) AS rh
// FROM <table> t
// - SELECT md5(string_agg(rh, '|' ORDER BY rh)) → table-hash
// - Singleton-Tabellen (mail_config-Stil) hashen die row direkt
// ohne string_agg.
//
// Outer-Hash: SHA-256 über concat(table-name, 0x00, table-hash, 0x00)
// für jede spec-Tabelle in stabiler Reihenfolge, dann Hex + truncate
// auf 16 chars. 16 hex = 64 bit Entropie reichen für drift-Detection;
// längere Strings frühstückt das UI nur unnötig.
//
// Replizierbar = wird per goose-Migration auf beiden Cluster-Nodes
// gleich angelegt UND vom Operator über die UI mutiert. NICHT drin:
// audit_log (transient log), ha_nodes (cluster-state selbst), licenses
// (per-node), backups (per-node).
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
type hashTable struct {
Name string
Singleton bool // dns_settings, ntp_settings → eine row, id=1
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
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
// ergänzen wenn sie repliziert werden sollen — sonst flackert das
// Drift-Banner.
var hashSpec = []hashTable{
{Name: "domains"},
{Name: "backends"},
{Name: "backend_servers"},
{Name: "routing_rules"},
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
{Name: "firewall_zones", MigrationDefault: true},
{Name: "firewall_address_objects"},
{Name: "firewall_address_groups"},
{Name: "firewall_services", MigrationDefault: true},
{Name: "firewall_service_groups", MigrationDefault: true},
{Name: "firewall_rules"},
{Name: "firewall_nat_rules"},
{Name: "wireguard_interfaces"},
{Name: "wireguard_peers"},
{Name: "forward_proxy_acls"},
{Name: "dns_zones"},
{Name: "dns_records"},
{Name: "ntp_pools", MigrationDefault: true},
// 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.
func hashSQL(t hashTable) string {
excl := []string{"'id'"}
if !t.SkipUpdatedAt {
excl = append(excl, "'updated_at'")
}
excl = append(excl, "'created_at'")
for _, c := range t.ExtraExclude {
excl = append(excl, "'"+c+"'")
}
subtract := strings.Join(excl, " - ")
if t.Singleton {
return `SELECT COALESCE(md5((to_jsonb(t) - ` + subtract + `)::text), '')
FROM ` + t.Name + ` t WHERE id = 1`
}
return `SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '')
FROM (
SELECT md5((to_jsonb(t) - ` + subtract + `)::text) AS rh
FROM ` + t.Name + ` t
) sub`
}
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
// leerer Per-Table-Hash behandelt — kein Abbruch.
//
// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind
// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config).
// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries.
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
if pool == nil {
return "", fmt.Errorf("nil pool")
}
h := sha256.New()
hasUserConfig := false
for _, t := range hashSpec {
var s string
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
// Migration fehlt o.ä. → leeren string nehmen, weiter.
s = ""
}
if s != "" && !t.Singleton && !t.MigrationDefault {
hasUserConfig = true
}
h.Write([]byte(t.Name))
h.Write([]byte{0})
h.Write([]byte(s))
h.Write([]byte{0})
}
if !hasUserConfig {
// Frisch gejoincter Secondary oder komplett leere DB →
// leerer String signalisiert "kein Drift prüfen" im Status-Handler.
return "", nil
}
return hex.EncodeToString(h.Sum(nil))[:16], nil
}
// RefreshLocalHash berechnet den Hash und schreibt ihn in die eigene
// ha_nodes-Row. Idempotent. Verwendet vom Scheduler + Cluster-Status-
// Handler (on-demand).
func RefreshLocalHash(ctx context.Context, pool *pgxpool.Pool, localID string) (string, error) {
if pool == nil || localID == "" {
return "", nil
}
hash, err := ComputeConfigHash(ctx, pool)
if err != nil {
return "", err
}
_, err = pool.Exec(ctx, `
UPDATE ha_nodes SET config_hash = $1, updated_at = NOW() WHERE id = $2`,
hash, localID)
return hash, err
}