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' - // )::text) AS rh // FROM 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 } // 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: "network_interfaces"}, {Name: "ip_addresses"}, {Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}}, {Name: "firewall_zones"}, {Name: "firewall_address_objects"}, {Name: "firewall_address_groups"}, {Name: "firewall_services"}, {Name: "firewall_service_groups"}, {Name: "firewall_rules"}, {Name: "firewall_nat_rules"}, {Name: "wireguard_interfaces"}, {Name: "wireguard_peers"}, {Name: "forward_proxy_acls"}, {Name: "dns_zones"}, {Name: "dns_records"}, {Name: "dns_settings", Singleton: true}, {Name: "ntp_pools"}, {Name: "ntp_settings", Singleton: true}, {Name: "static_routes"}, } // 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. func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) { if pool == nil { return "", fmt.Errorf("nil pool") } h := sha256.New() 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 = "" } h.Write([]byte(t.Name)) h.Write([]byte{0}) h.Write([]byte(s)) h.Write([]byte{0}) } 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 }