feat(cluster): pull-basierte Peer-Registrierung als autoRegister-Fallback
Wenn autoRegister (Push utm-2→utm-1) fehlschlägt, bleibt der Peer dauerhaft im 'joining'-Status. Neuer Self-Healing-Mechanismus: - GET /agent/cluster/identity auf dem Agent-Listener: gibt die eigene ha_nodes-Row zurück (echte Node-ID, FQDN, Version) - ClusterHandler.Status() startet für jeden 'joining'/'pending'-Peer einen Background-Reconcile via Aggregator: holt Identity, upsertet mit echter ID (public_ip aus Placeholder übernommen für nftables), löscht Placeholder - autoRegister-Fehler werden jetzt als Warn-Log sichtbar statt silent verworfen Damit reicht ein Cluster-Page-Aufruf nach Update beider Nodes um den Status von 'joining' auf 'online' zu bringen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.160"
|
var version = "1.1.161"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.160"
|
var version = "1.1.161"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.160"
|
var version = "1.1.161"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -138,6 +138,27 @@ func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
|||||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/agent/cluster")
|
g := rg.Group("/agent/cluster")
|
||||||
g.POST("/peers", h.AgentRegisterPeer)
|
g.POST("/peers", h.AgentRegisterPeer)
|
||||||
|
g.GET("/identity", h.AgentIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||||
|
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
|
||||||
|
// fehlgeschlagen ist — Pull-Fallback.
|
||||||
|
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
|
||||||
|
if h.Store == nil || h.LocalID == "" {
|
||||||
|
response.NotFound(c, simpleError("node not registered"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
|
||||||
|
if err != nil {
|
||||||
|
if err == cluster.ErrNotFound {
|
||||||
|
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||||
@@ -212,6 +233,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
|||||||
if len(out.Peers) > 0 {
|
if len(out.Peers) > 0 {
|
||||||
out.Mode = "cluster"
|
out.Mode = "cluster"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull-Reconcile für joining-Peers: wenn ein Peer via Aggregator
|
||||||
|
// erreichbar ist aber noch mit Placeholder-ID in ha_nodes steht,
|
||||||
|
// holen wir seine echte Identity aktiv ab. Best-effort goroutine —
|
||||||
|
// blockiert die Status-Response nicht.
|
||||||
|
if h.Aggregator != nil && h.Store != nil {
|
||||||
|
var joining []models.HANode
|
||||||
|
for _, p := range out.Peers {
|
||||||
|
if p.Status == "joining" || p.Status == "pending" {
|
||||||
|
joining = append(joining, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(joining) > 0 {
|
||||||
|
go h.reconcileJoiningPeers(joining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
||||||
// lokaler → Banner-Trigger im UI.
|
// lokaler → Banner-Trigger im UI.
|
||||||
if localHash != nil && *localHash != "" {
|
if localHash != nil && *localHash != "" {
|
||||||
@@ -458,6 +496,62 @@ func cnFromCSR(csrPEM string) string {
|
|||||||
return csr.Subject.CommonName
|
return csr.Subject.CommonName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
|
||||||
|
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
|
||||||
|
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
|
||||||
|
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
|
||||||
|
// eines temporären Netzwerkproblems fehlgeschlagen ist.
|
||||||
|
//
|
||||||
|
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
|
||||||
|
// damit @peer_ipv4 (nftables) korrekt bleibt.
|
||||||
|
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
|
||||||
|
changed := false
|
||||||
|
for i, res := range results {
|
||||||
|
if !res.OK || len(res.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var identity models.HANode
|
||||||
|
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placeholder := placeholders[i]
|
||||||
|
if identity.ID == placeholder.ID {
|
||||||
|
continue // ID bereits korrekt
|
||||||
|
}
|
||||||
|
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
|
||||||
|
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
|
||||||
|
n := identity
|
||||||
|
n.Status = "online"
|
||||||
|
if n.PublicIP == nil {
|
||||||
|
n.PublicIP = placeholder.PublicIP
|
||||||
|
}
|
||||||
|
if n.InternalIP == nil {
|
||||||
|
n.InternalIP = placeholder.InternalIP
|
||||||
|
}
|
||||||
|
out, err := h.Store.UpsertSelf(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cluster: reconcile joining peer: upsert failed",
|
||||||
|
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
|
||||||
|
changed = true
|
||||||
|
slog.Info("cluster: joining peer reconciled via identity pull",
|
||||||
|
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
|
||||||
|
}
|
||||||
|
if changed && h.PeerReloader != nil {
|
||||||
|
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.PeerReloader(rctx); err != nil {
|
||||||
|
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||||
|
|
||||||
type simpleError string
|
type simpleError string
|
||||||
|
|||||||
@@ -126,14 +126,26 @@ func Join(req Request) error {
|
|||||||
// Auto-register: retry a few times because the primary's nftables may
|
// Auto-register: retry a few times because the primary's nftables may
|
||||||
// need a moment to reload even though preRegisterJoiner is now
|
// need a moment to reload even though preRegisterJoiner is now
|
||||||
// synchronous on the primary side.
|
// synchronous on the primary side.
|
||||||
|
var autoRegErr error
|
||||||
for i := 0; i < 3; i++ {
|
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); err == nil {
|
||||||
|
autoRegErr = nil
|
||||||
break
|
break
|
||||||
} else if i < 2 {
|
} else {
|
||||||
slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err)
|
autoRegErr = err
|
||||||
time.Sleep(2 * time.Second)
|
if i < 2 {
|
||||||
|
slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if autoRegErr != nil {
|
||||||
|
slog.Warn("clusterjoin: autoRegister failed after all retries — primary will reconcile via identity pull",
|
||||||
|
"error", autoRegErr)
|
||||||
|
}
|
||||||
|
// Certs are saved; primary will reconcile via /agent/cluster/identity pull
|
||||||
|
// even if autoRegister failed. Not returning the error — join is
|
||||||
|
// structurally complete (certs issued), only the ha_nodes update is pending.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user