diff --git a/VERSION b/VERSION index 9d8637c..1edd062 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.70 +1.0.71 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index e9b241d..5610170 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 072139a..ce4b3b7 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.70" +var version = "1.0.71" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index de2c364..7f281b6 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -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) { diff --git a/internal/cluster/confighash.go b/internal/cluster/confighash.go new file mode 100644 index 0000000..52847d2 --- /dev/null +++ b/internal/cluster/confighash.go @@ -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' - +// )::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 +} diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 30b50ee..490c80b 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -1,6 +1,8 @@ package handlers import ( + "context" + "log/slog" "time" "github.com/gin-gonic/gin" @@ -50,7 +52,19 @@ type ClusterStatus struct { } // 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) { + 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()) if err != nil { response.Internal(c, err) diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 804cd06..ad82a25 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -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: // -