diff --git a/VERSION b/VERSION index 69d5b37..1af304e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.96 \ No newline at end of file +1.2.97 \ No newline at end of file diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 8cf7441..e2c563a 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -194,6 +194,12 @@ func main() { } // runSecondaryConfigRender wird weiter unten gestartet sobald // clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync). + } else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" { + // Primary/Founder (kein joined Secondary): self (role=primary) an + // alle Peers pushen, damit deren lokale ha_nodes den Primary frisch + // hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary + // als offline. No-op solange keine Peers existieren (Single-Node). + go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version) } // Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem @@ -863,6 +869,51 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio } } +// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene +// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu +// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen +// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig +// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie +// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange +// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log). +func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) { + const tick = 30 * time.Second + t := time.NewTicker(tick) + defer t.Stop() + push := func() { + pCtx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + peers, err := store.List(pCtx) + if err != nil { + slog.Warn("cluster: peer-push list failed", "error", err) + return + } + hash, _ := cluster.ComputeConfigHash(pCtx, pool) + for i := range peers { + p := peers[i] + if p.ID == nodeID { + continue // nicht an sich selbst pushen + } + target := p.APIURL + if target == "" { + target = "https://" + p.FQDN + } + if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { + slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err) + } + } + } + push() // immediate push on API startup + for { + select { + case <-ctx.Done(): + return + case <-t.C: + push() + } + } +} + func randomEphemeralSecret() []byte { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 2679595..3b24169 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -866,6 +866,7 @@ type registerPeerRequest struct { MgmtIP string `json:"mgmt_ip"` // optional Version string `json:"version"` ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config + Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary } // AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen. @@ -904,12 +905,21 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { // Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite. // Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was // wir brauchen.) + // Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine + // Rolle → "peer". Der Primary-Push sendet "primary", damit die vom + // Secondary ausgelieferte UI den Primary korrekt als primary zeigt. + // Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte + // Rollenerkennung läuft über pg_publication). + role := strings.TrimSpace(req.Role) + if role == "" { + role = "peer" + } n := models.HANode{ ID: req.ID, Name: req.Name, FQDN: req.FQDN, APIURL: req.APIURL, - Role: "peer", + Role: role, Status: "online", // peer IS online — it just connected via mTLS } if req.PublicIP != "" { @@ -965,7 +975,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) { }() } - slog.Info("cluster: peer registered via mTLS", + // Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis), + // sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush) + // würden sonst das Log fluten. + logFn := slog.Debug + if ipChanged { + logFn = slog.Info + } + logFn("cluster: peer registered via mTLS", "id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status, "client_cn", cn, "remote", c.ClientIP()) response.OK(c, out) diff --git a/internal/services/clusterjoin/join.go b/internal/services/clusterjoin/join.go index b9027c1..2f3e6f1 100644 --- a/internal/services/clusterjoin/join.go +++ b/internal/services/clusterjoin/join.go @@ -128,7 +128,7 @@ func Join(req Request) error { // synchronous on the primary side. var autoRegErr error for i := 0; i < 3; i++ { - if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil { + if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil { autoRegErr = nil break } else { @@ -222,13 +222,21 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri // goroutine so the primary's ha_nodes always reflects the secondary's actual // config_hash (not the stale join-time value). func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error { + return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer") +} + +// PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS, +// /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node +// beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der +// Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat). +func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error { if tlsDir == "" { tlsDir = clustertls.DefaultDir } - return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash) + return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role) } -func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error { +func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role string) error { u, err := url.Parse(primary) if err != nil { return err @@ -241,6 +249,9 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin nodeID = strings.TrimSpace(string(raw)) } hostname, _ := os.Hostname() + if role == "" { + role = "peer" + } body, _ := json.Marshal(map[string]string{ "id": nodeID, "name": hostname, @@ -248,6 +259,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin "api_url": "https://" + commonName + ":3443", "version": version, "config_hash": configHash, + "role": role, }) pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")