feat(cluster): Config-Hash-Compute für Drift-Detection

Setzt die Foundation aus 1.0.70 fort — bisher war ha_nodes.config_hash
noch NULL und das UI konnte keinen Drift erkennen.

internal/cluster/confighash.go:
  - ComputeConfigHash() berechnet SHA-256 (truncated auf 16 hex chars)
    über alle replizierbaren Tabellen. Pattern 1:1 aus mail-gateway/
    internal/handlers/cluster_status.go (driftHashSpec).
  - Pro Tabelle: md5((to_jsonb(t) - id - updated_at - created_at -
    excludes)::text) per row, dann string_agg ORDER BY rh.
  - Singleton-Tabellen (dns_settings, ntp_settings, mail_config-Stil)
    hashen direkt ohne agg.
  - 23 Tabellen: domains, backends, backend_servers, routing_rules,
    network_interfaces, ip_addresses, tls_certs (mit ExtraExclude
    last_renewed_at + last_error damit cert-renewal keinen drift
    erzeugt), firewall_zones+address_objects+address_groups+services+
    service_groups+rules+nat_rules, wireguard_interfaces+peers,
    forward_proxy_acls, dns_zones+records+settings, ntp_pools+settings,
    static_routes.
  - RefreshLocalHash() schreibt den Hash in die eigene ha_nodes-Row.

Scheduler:
  - 5-min-Tick ruft RefreshLocalHash. Pro-Mutation-Refresh wäre zu
    teuer (jede UI-Action triggert sonst 23 jsonb-Queries).
  - Initial-Refresh beim Scheduler-Boot damit /cluster/status nicht
    5 min auf den ersten Wert wartet.

handlers/cluster.go:
  - Status() ruft RefreshLocalHash mit 2s-Timeout on-demand. Damit
    sieht das UI auch zwischen den Scheduler-Ticks immer frische
    Werte; bei Timeout fallback auf den DB-Wert (eventuell stale).

Verifiziert auf 1.0.71: ha_nodes-Row hat config_hash=728834dce5ca4e48,
scheduler-log "config-hash refresh enabled tick=5m0s".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 08:33:42 +02:00
parent ea7c356455
commit e07b484a48
7 changed files with 194 additions and 5 deletions

View File

@@ -1 +1 @@
1.0.70 1.0.71

View File

@@ -52,7 +52,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.70" var version = "1.0.71"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.70" var version = "1.0.71"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -15,6 +15,9 @@ import (
"os" "os"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/license" "git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
@@ -25,7 +28,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.70" var version = "1.0.71"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.
@@ -45,6 +48,11 @@ const (
// alignment ist approximativ, weil time.Ticker bei Boot startet). // alignment ist approximativ, weil time.Ticker bei Boot startet).
// Retention: 14 erfolgreiche Backups (default in backup.Service). // Retention: 14 erfolgreiche Backups (default in backup.Service).
backupTickInterval = 24 * time.Hour backupTickInterval = 24 * time.Hour
// configHashTickInterval — alle 5 min config_hash neu berechnen
// und in ha_nodes der eigenen Row schreiben. Cluster-UI nutzt
// das fürs Drift-Banner — pro-Mutation-Refresh wäre teuer.
configHashTickInterval = 5 * time.Minute
) )
func main() { func main() {
@@ -89,12 +97,22 @@ func main() {
} }
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID) runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID)
// Lokale Node-ID für config-hash-refresh. EnsureNodeID liefert
// dieselbe ID die die API hat (gleiches /var/lib/edgeguard/node-id).
localID, _ := cluster.EnsureNodeID("")
slog.Info("scheduler: config-hash refresh enabled", "tick", configHashTickInterval, "node_id", localID)
// Initial-Refresh damit /cluster/status nach API+Scheduler-Boot
// nicht 5min auf den ersten Wert wartet.
runConfigHash(ctx, pool, localID)
renewTick := time.NewTicker(renewTickInterval) renewTick := time.NewTicker(renewTickInterval)
defer renewTick.Stop() defer renewTick.Stop()
licTick := time.NewTicker(licenseTickInterval) licTick := time.NewTicker(licenseTickInterval)
defer licTick.Stop() defer licTick.Stop()
backupTick := time.NewTicker(backupTickInterval) backupTick := time.NewTicker(backupTickInterval)
defer backupTick.Stop() defer backupTick.Stop()
hashTick := time.NewTicker(configHashTickInterval)
defer hashTick.Stop()
for { for {
select { select {
@@ -106,10 +124,30 @@ func main() {
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID) runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID)
case <-backupTick.C: case <-backupTick.C:
runBackup(ctx, backupSvc, version) runBackup(ctx, backupSvc, version)
case <-hashTick.C:
runConfigHash(ctx, pool, localID)
} }
} }
} }
// runConfigHash berechnet den Hash und schreibt ihn in ha_nodes.
// Pool kann nil sein (scheduler-pool-fail beim boot) — dann no-op.
func runConfigHash(ctx context.Context, pool *pgxpoolPool, localID string) {
if pool == nil || localID == "" {
return
}
hash, err := cluster.RefreshLocalHash(ctx, pool, localID)
if err != nil {
slog.Warn("scheduler: config-hash refresh failed", "error", err)
return
}
slog.Debug("scheduler: config-hash refreshed", "hash", hash)
}
// pgxpoolPool ist ein lokaler Alias damit die Signatur stabil bleibt
// wenn wir später den pool austauschen wollen (z.B. read-only-replica).
type pgxpoolPool = pgxpool.Pool
// runBackup führt einen scheduled Backup aus + prunet alte. Failures // runBackup führt einen scheduled Backup aus + prunet alte. Failures
// loggen wir nur — der Tick läuft morgen wieder, kein Notfall. // loggen wir nur — der Tick läuft morgen wieder, kein Notfall.
func runBackup(ctx context.Context, svc *backup.Service, version string) { func runBackup(ctx context.Context, svc *backup.Service, version string) {

View File

@@ -0,0 +1,137 @@
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
}
// 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
}

View File

@@ -1,6 +1,8 @@
package handlers package handlers
import ( import (
"context"
"log/slog"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -50,7 +52,19 @@ type ClusterStatus struct {
} }
// Status splittet alle Nodes in local + peers, berechnet mode + health. // Status splittet alle Nodes in local + peers, berechnet mode + health.
// On-demand: bevor wir die Rows lesen refreshen wir den eigenen
// config_hash, sodass das UI immer aktuelle Werte sieht — auch wenn
// der 5min-Scheduler-Tick gerade vorher nicht gelaufen ist.
func (h *ClusterHandler) Status(c *gin.Context) { func (h *ClusterHandler) Status(c *gin.Context) {
if h.Store != nil && h.Store.Pool != nil && h.LocalID != "" {
// 2s Timeout — der Hash-Compute braucht im normal-case <50ms.
// Bei timeout fallen wir auf den (eventuell stale) DB-Wert zurück.
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
if _, err := cluster.RefreshLocalHash(ctx, h.Store.Pool, h.LocalID); err != nil {
slog.Warn("cluster: config_hash refresh failed", "error", err)
}
cancel()
}
all, err := h.Store.List(c.Request.Context()) all, err := h.Store.List(c.Request.Context())
if err != nil { if err != nil {
response.Internal(c, err) response.Internal(c, err)

View File

@@ -81,7 +81,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.70' const VERSION = '1.0.71'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent // - <nav> als root, dunkler Gradient + Teal/Blue-Accent