Files
edgeguard-native/internal/cluster/confighash.go
Debian bac7c7e349 feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:56:30 +02:00

168 lines
5.9 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
CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL)
}
// 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},
// DHCP: Subnets + Reservierungen sind geteilte Config (repliziert).
// dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier.
{Name: "dhcp_subnets"},
{Name: "dhcp_reservations"},
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
//
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
}
// 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
sql := t.CustomSQL
if sql == "" {
sql = hashSQL(t)
}
if err := pool.QueryRow(ctx, sql).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
}