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

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

View File

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

View File

@@ -15,6 +15,9 @@ import (
"os"
"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/license"
"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"
)
var version = "1.0.70"
var version = "1.0.71"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
@@ -45,6 +48,11 @@ const (
// alignment ist approximativ, weil time.Ticker bei Boot startet).
// Retention: 14 erfolgreiche Backups (default in backup.Service).
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() {
@@ -89,12 +97,22 @@ func main() {
}
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)
defer renewTick.Stop()
licTick := time.NewTicker(licenseTickInterval)
defer licTick.Stop()
backupTick := time.NewTicker(backupTickInterval)
defer backupTick.Stop()
hashTick := time.NewTicker(configHashTickInterval)
defer hashTick.Stop()
for {
select {
@@ -106,10 +124,30 @@ func main() {
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID)
case <-backupTick.C:
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
// loggen wir nur — der Tick läuft morgen wieder, kein Notfall.
func runBackup(ctx context.Context, svc *backup.Service, version string) {