6 Commits

Author SHA1 Message Date
noroot
25bc9c3673 chore(release): v1.3.36 stable 2026-09-11 12:37:09 +02:00
noroot
a54d367c24 fix(cluster): duplicate-key-Warnung bei jedem API-Start auf dem Standby
preRegisterPrimary legte unbedingt eine Platzhalter-Zeile fuer den
Primary an. Sobald der Primary sich aber selbst gemeldet hat, existiert
bereits eine Zeile mit seiner ECHTEN Node-ID und demselben FQDN — und
UpsertSelf nutzt ON CONFLICT (id), greift also nicht. Die Synthetik-ID
"prenode-<fqdn>" lief damit in den fqdn-Unique-Index:

  duplicate key value violates unique constraint "ha_nodes_fqdn_unique"

bei JEDEM API-Start auf dem Standby. Folgenlos — die echte Zeile ist ja
korrekt und der Firewall-Reload lief trotzdem — aber es sah nach einem
Defekt aus und verdeckte echte Warnungen im selben Log.

Jetzt wird zuerst die IP einer vorhandenen Zeile aufgefrischt (mehr will
die Funktion gar nicht) und nur bei RowsAffected()==0 der Platzhalter
angelegt — also genau dann, wenn der Primary sich noch nie gemeldet hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:36:30 +02:00
noroot
6149670375 chore(release): v1.3.35 stable 2026-09-11 12:26:20 +02:00
noroot
1b13df4032 fix(cluster): pg_role fehlte im SELECT — Feld kam nie beim Client an
baseSelect in cluster/store.go selektierte pg_role nicht. HANode.PGRole
war dadurch ueberall ein leerer String, wo Store.List/Get benutzt wird:
/cluster/nodes, /cluster/status und damit auch die pg_role-Spalte der
Cluster-Seite. Aufgefallen ist es erst, als die Dashboard-Cluster-Karte
in v1.3.33 auf pg_role umgestellt wurde und daraufhin bei beiden Knoten
"—" zeigte — der Fehler lag aber schon vorher im Store.

pg_role ist jetzt in baseSelect, im RETURNING von UpsertSelf und in
scanNode ergaenzt; die drei muessen spaltenweise zueinander passen.
UpsertSelf SCHREIBT pg_role weiterhin bewusst nicht (ON CONFLICT laesst
die Spalte unangetastet, damit eine Self-Registrierung den per promote
gesetzten Wert nicht ueberbuegelt) — liest sie aber im RETURNING mit,
sonst stimmt die Scan-Reihenfolge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:25:40 +02:00
noroot
3ca37ee226 chore(release): v1.3.34 stable 2026-09-11 12:21:59 +02:00
noroot
86aee33308 fix(cluster): jeder Node registrierte sich selbst fest als "primary"
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 <noreply@anthropic.com>
2026-09-11 12:21:22 +02:00
4 changed files with 101 additions and 25 deletions

View File

@@ -1 +1 @@
1.3.33 1.3.36

View File

@@ -168,7 +168,8 @@ func main() {
if st != nil && st.Completed { if st != nil && st.Completed {
// Auto-create /etc/edgeguard/node.conf falls fehlt. // Auto-create /etc/edgeguard/node.conf falls fehlt.
_, _ = cluster.EnsureLocalConfig("") _, _ = 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) slog.Warn("self-register in ha_nodes failed", "error", err)
} }
} }
@@ -303,12 +304,12 @@ func main() {
systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool) systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){ systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString, "haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString, "nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString, "squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString, "unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString, "chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString, "wireguard": wgrender.New(pool, secretsBox).RenderToString,
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString, "crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
}) })
setupHdl.WithAudit(auditRepo, nodeID) setupHdl.WithAudit(auditRepo, nodeID)
@@ -927,3 +928,47 @@ func randomEphemeralSecret() []byte {
} }
return b 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"
}

View File

@@ -25,8 +25,15 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} } func NewStore(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
// baseSelect MUSS spaltenweise zu scanNode passen. pg_role fehlte hier
// urspruenglich (Befund 2026-09-11): HANode.PGRole kam dadurch ueberall als
// leerer String an, wo Store.List/Get benutzt wird — /cluster/nodes,
// /cluster/status und damit auch die pg_role-Spalte der Cluster-Seite.
// UpsertSelf schreibt pg_role bewusst NICHT (ON CONFLICT laesst die Spalte
// unangetastet), liest sie aber im RETURNING mit — sonst passt die
// Scan-Reihenfolge nicht.
const baseSelect = ` const baseSelect = `
SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, SELECT id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, role, pg_role,
version, config_hash, status, version, config_hash, status,
last_seen, joined_at, created_at, updated_at last_seen, joined_at, created_at, updated_at
FROM ha_nodes FROM ha_nodes
@@ -90,7 +97,7 @@ ON CONFLICT (id) DO UPDATE SET
last_seen = EXCLUDED.last_seen, last_seen = EXCLUDED.last_seen,
updated_at = NOW() updated_at = NOW()
RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip, RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
role, version, config_hash, status, role, pg_role, version, config_hash, status,
last_seen, joined_at, created_at, updated_at`, last_seen, joined_at, created_at, updated_at`,
n.ID, n.Name, n.FQDN, n.APIURL, n.ID, n.Name, n.FQDN, n.APIURL,
n.PublicIP, n.InternalIP, n.MgmtIP, n.PublicIP, n.InternalIP, n.MgmtIP,
@@ -181,7 +188,7 @@ func scanNode(row interface{ Scan(...any) error }) (*models.HANode, error) {
if err := row.Scan( if err := row.Scan(
&n.ID, &n.Name, &n.FQDN, &n.APIURL, &n.ID, &n.Name, &n.FQDN, &n.APIURL,
&n.PublicIP, &n.InternalIP, &n.MgmtIP, &n.PublicIP, &n.InternalIP, &n.MgmtIP,
&n.Role, &n.Version, &n.ConfigHash, &n.Status, &n.Role, &n.PGRole, &n.Version, &n.ConfigHash, &n.Status,
&n.LastSeen, &n.JoinedAt, &n.LastSeen, &n.JoinedAt,
&n.CreatedAt, &n.UpdatedAt, &n.CreatedAt, &n.UpdatedAt,
); err != nil { ); err != nil {

View File

@@ -253,22 +253,46 @@ func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
} }
ip := addrs[0] ip := addrs[0]
// Stable ID so repeated calls (join + startup) don't accumulate rows. // Sobald der Primary sich selbst gemeldet hat (Heartbeat-Push), steht
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-")) // hier bereits eine Zeile mit seiner ECHTEN Node-ID. Dann ist nur die
n := models.HANode{ // IP frisch zu halten — mehr will diese Funktion gar nicht.
ID: nodeID, //
Name: primaryFQDN, // Vorher lief in dem Fall unbedingt der Platzhalter-Upsert unten, der
FQDN: primaryFQDN, // am fqdn-Unique-Index scheiterte: UpsertSelf nutzt ON CONFLICT (id),
APIURL: "https://" + primaryFQDN + ":3443", // und die Synthetik-ID "prenode-<fqdn>" trifft die echte Zeile nicht.
Role: "primary", // Ergebnis war ein "duplicate key value violates unique constraint
Status: "online", // ha_nodes_fqdn_unique" bei JEDEM API-Start auf dem Standby — folgenlos
} // (die echte Zeile stimmt ja), aber es sah nach einem Defekt aus und
n.PublicIP = &ip // verdeckte echte Warnungen.
tag, err := h.ClusterStore.Pool.Exec(ctx,
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil { `UPDATE ha_nodes SET public_ip = $1, updated_at = NOW() WHERE fqdn = $2`,
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err) ip, primaryFQDN)
if err != nil {
slog.Warn("setup: refreshing primary IP in ha_nodes failed",
"fqdn", primaryFQDN, "error", err)
return return
} }
if tag.RowsAffected() == 0 {
// Noch keine Zeile: Platzhalter anlegen, damit @peer_ipv4 den
// Primary schon kennt, bevor er sich das erste Mal meldet.
// Stabile ID, damit wiederholte Aufrufe (Join + Start) keine
// Zeilen anhaeufen.
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
n := models.HANode{
ID: nodeID,
Name: primaryFQDN,
FQDN: primaryFQDN,
APIURL: "https://" + primaryFQDN + ":3443",
Role: "primary",
Status: "online",
}
n.PublicIP = &ip
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
slog.Warn("setup: pre-register primary in ha_nodes failed",
"fqdn", primaryFQDN, "error", err)
return
}
}
if err := h.PeerReloader(ctx); err != nil { if err := h.PeerReloader(ctx); err != nil {
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err) slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
return return