From 86aee33308d2073a80abbceb898e049c60132a14 Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 11 Sep 2026 12:21:22 +0200 Subject: [PATCH] fix(cluster): jeder Node registrierte sich selbst fest als "primary" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die self-Registrierung in ha_nodes uebergab beim API-Start hart "primary" — fuer JEDEN Node. Da ha_nodes node-lokal ist (nicht repliziert), trug sich damit auch ein per Join dazugekommener Standby bei sich selbst als Primary ein. In der Cluster-Ansicht DIESES Nodes erschienen beide Knoten als Primary, und eine Korrektur direkt in der DB hielt nur bis zum naechsten Neustart. Nicht kosmetisch: keepalived.go nimmt `role` als Fallback, wenn pg_role nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain ausgeloest hat (beide Knoten Prioritaet 200, hoehere IP gewinnt). Aktuell deckt pg_role='standby' das ab — aber als alleinige Absicherung ist das duenn. Die Rolle wird jetzt aus der Replikations-Topologie abgeleitet, also der in cluster_repair.go dokumentierten verlaesslichen Quelle: nur der Primary hat die PUBLICATION, nur der Standby die SUBSCRIPTION (beide Kataloge darf der edgeguard-DB-User lesen, verifiziert). Das ist selbstheilend und ueberlebt `edgeguard-ctl promote` korrekt — eine Ableitung aus setup.json wuerde den Promote dagegen bei jedem Neustart wieder ueberschreiben. Ohne eingerichtete Replikation entscheidet IsClusterNode. Co-Authored-By: Claude Opus 5 --- cmd/edgeguard-api/main.go | 59 ++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 08ccdb1..9c0b07a 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -168,7 +168,8 @@ func main() { if st != nil && st.Completed { // Auto-create /etc/edgeguard/node.conf falls fehlt. _, _ = cluster.EnsureLocalConfig("") - if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, "primary", version); err != nil { + if _, err := cluster.EnsureSelfRegistered(ctx, clusterStore, st.FQDN, + localClusterRole(ctx, pool, st), version); err != nil { slog.Warn("self-register in ha_nodes failed", "error", err) } } @@ -303,12 +304,12 @@ func main() { systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithDB(pool) systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){ - "haproxy": haproxy.New(pool).RenderToString, - "nftables": firewallrender.New(pool).RenderToString, - "squid": squidrender.New(pool).RenderToString, - "unbound": unboundrender.New(pool).RenderToString, - "chrony": chronyrender.New(pool).RenderToString, - "wireguard": wgrender.New(pool, secretsBox).RenderToString, + "haproxy": haproxy.New(pool).RenderToString, + "nftables": firewallrender.New(pool).RenderToString, + "squid": squidrender.New(pool).RenderToString, + "unbound": unboundrender.New(pool).RenderToString, + "chrony": chronyrender.New(pool).RenderToString, + "wireguard": wgrender.New(pool, secretsBox).RenderToString, "crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString, }) setupHdl.WithAudit(auditRepo, nodeID) @@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte { } return b } + +// localClusterRole ermittelt die eigene Cluster-Rolle für die node-lokale +// ha_nodes-Zeile. +// +// Befund 2026-09-11: Hier stand fest "primary" — für JEDEN Node, bei jedem +// API-Start. ha_nodes ist node-lokal (nicht repliziert), also trug sich auch +// ein per Join dazugekommener Standby bei sich selbst als "primary" ein. In +// der Cluster-Ansicht DIESES Nodes erschienen dadurch beide Knoten als +// Primary, und eine Korrektur direkt in der DB hielt nur bis zum nächsten +// Neustart. +// +// Nicht kosmetisch: keepalived.go nutzt `role` als Fallback, wenn pg_role +// nicht 'standby' ist. Ein Standby, der sich selbst "primary" nennt, ist +// damit genau der Zustand, der 2026-05 schon einmal einen Split-Brain +// ausgelöst hat (beide Knoten Priorität 200, höhere IP gewinnt). +// +// Verlässlich ist — wie in cluster_repair.go dokumentiert — die +// Replikations-Topologie selbst: nur der Primary hat die PUBLICATION, nur +// der Standby die SUBSCRIPTION. Beide Kataloge darf der edgeguard-DB-User +// lesen. Das ist zugleich selbstheilend: nach `edgeguard-ctl promote` hat +// der neue Primary die Publication und meldet sich ab dem nächsten Start +// korrekt als "primary" — anders als eine Ableitung aus setup.json, die +// den Promote überschreiben würde. +func localClusterRole(ctx context.Context, pool *pgxpool.Pool, st *setup.State) string { + if pool != nil { + var hasPub, hasSub bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared')`, + ).Scan(&hasPub); err == nil && hasPub { + return "primary" + } + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname = 'edgeguard_sub')`, + ).Scan(&hasSub); err == nil && hasSub { + return "peer" + } + } + // Keine Replikation eingerichtet: ein per Join dazugekommener Node ist + // trotzdem kein Primary, alles andere (Founder/Single-Node) schon. + if st != nil && st.IsClusterNode { + return "peer" + } + return "primary" +}