feat(cluster): Fix "joining" status + cross-node auth federation

1. preRegisterJoiner now runs SYNCHRONOUSLY before IssueCert responds,
   so nftables @peer_ipv4 is updated before the joiner calls autoRegister.
   Previously it was a goroutine → race → autoRegister failed → "joining"
   forever.

2. Stable node ID for pre-registered placeholder (prenode-{fqdn}) instead
   of time-based ID — re-joins are now idempotent.

3. AgentRegisterPeer sets status="online" immediately (peer proved it is
   online by connecting via mTLS) and deletes the prenode-{fqdn} placeholder.

4. autoRegister retries 3× with 2s delay in case of transient nftables lag.

5. Auth federation: cluster nodes forward failed logins to the primary via
   mTLS /agent/auth/check so users can log in on any node with primary
   credentials (no PG replication needed).
   - SystemHandler.AgentAuthCheck: new endpoint on :8443
   - AuthHandler.checkWithPrimary: mTLS call to primary when local auth fails
   - AuthHandler.WithClusterTLS: inject cluster TLS store
   - startAgentListener now uses the wired systemHdl with Users repo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 18:04:34 +02:00
parent 6bb1c5c6d3
commit 8d83de6b0f
8 changed files with 187 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import (
"encoding/pem"
"fmt"
"log/slog"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -392,11 +393,13 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
return
}
// Pre-register the joining node so its IP lands in @peer_ipv4
// immediately — otherwise port 8443 stays blocked and auto-register
// via mTLS can never succeed (chicken-and-egg).
// Pre-register the joining node SYNCHRONOUSLY before returning the
// cert so that nftables @peer_ipv4 already contains the joiner's IP
// by the time they call autoRegister on port 8443. A goroutine here
// caused a race: cert returned → joiner calls autoRegister → nftables
// not updated yet → connection refused → status stays "joining".
if h.Store != nil && h.PeerReloader != nil {
go h.preRegisterJoiner(clientIP, req.CSR)
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
}
response.OK(c, issueCertResponse{
@@ -409,15 +412,16 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
// triggers a firewall reload so @peer_ipv4 contains the new IP before
// the peer tries to call /agent/cluster/peers on port 8443.
func (h *ClusterHandler) preRegisterJoiner(clientIP, csrPEM string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// Uses a stable deterministic ID so re-joins are idempotent.
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
fqdn := cnFromCSR(csrPEM)
if fqdn == "" {
fqdn = "joining-" + clientIP
}
nodeID := fmt.Sprintf("pre-%x", time.Now().UnixNano())
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
n := models.HANode{
ID: nodeID,
@@ -574,7 +578,7 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
FQDN: req.FQDN,
APIURL: req.APIURL,
Role: "peer",
Status: "joining",
Status: "online", // peer IS online — it just connected via mTLS
}
if req.PublicIP != "" {
v := req.PublicIP
@@ -598,6 +602,13 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
return
}
// Clean up the prenode-{fqdn} placeholder that preRegisterJoiner
// created during cert issuance — the real row just took its place.
placeholderID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(req.FQDN, ".", "-"))
if placeholderID != req.ID {
_ = h.Store.Delete(c.Request.Context(), placeholderID)
}
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
// effort: Fehler loggen, Response weiter durchreichen — der Peer
// hat seine Identity erfolgreich registriert, Operator kann manuell