feat(cluster): FQDN-First Token-Flow — Node vor dem Join vorregistrieren

Cluster-Seite: Schritt 2 fragt jetzt den FQDN des neuen Knotens bevor
der Token generiert wird. Der Knoten wird sofort in ha_nodes (status=pending)
eingetragen. Nach Token-Generierung wird direkt die Setup-Wizard-URL des
neuen Knotens angezeigt (https://<fqdn>:3443/setup).

Backend: POST /cluster/join-tokens nimmt jetzt node_fqdn entgegen,
pre-registriert via preRegisterByFQDN(). Die IP wird beim issue-cert
nachgetragen (preRegisterJoiner).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 17:29:58 +02:00
parent fd69081336
commit 5ace250879
8 changed files with 110 additions and 39 deletions

View File

@@ -292,29 +292,64 @@ func localSystemLoad() any {
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
// kann. Token wird NUR EINMAL zurückgegeben.
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
var req struct {
NodeFQDN string `json:"node_fqdn"`
}
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
_ = c.ShouldBindJSON(&req)
token, exp, err := h.Tokens.Generate()
if err != nil {
response.Internal(c, err)
return
}
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
// kann.
caCert, _, err := h.TLSStore.LoadCA()
caFP := ""
if err == nil && caCert != nil {
caFP = jointoken.CAFingerprint16(caCert.Raw)
}
// Pre-register: wenn ein FQDN übergeben wurde, schon jetzt in
// ha_nodes anlegen (status='pending') + Firewall-Reload. So ist
// der Node bekannt bevor er überhaupt antwortet, und @peer_ipv4
// wird beim issue-cert (wo wir die IP haben) nur noch updaten.
if req.NodeFQDN != "" && h.Store != nil {
go h.preRegisterByFQDN(req.NodeFQDN)
}
response.OK(c, gin.H{
"token": token,
"expires_at": exp.UTC().Format(time.RFC3339),
"ca_fingerprint": caFP,
"node_fqdn": req.NodeFQDN,
})
}
// preRegisterByFQDN legt einen ha_nodes-Eintrag mit status='pending' an
// bevor der Joiner überhaupt die Verbindung aufbaut. Idempotent dank
// ON CONFLICT. Kein Firewall-Reload hier — die IP ist noch unbekannt;
// das erledigt preRegisterJoiner wenn die issue-cert-Anfrage eintrifft.
func (h *ClusterHandler) preRegisterByFQDN(fqdn string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
nodeID := fmt.Sprintf("pre-%x", time.Now().UnixNano())
n := models.HANode{
ID: nodeID,
Name: fqdn,
FQDN: fqdn,
APIURL: "https://" + fqdn + ":3443",
Role: "peer",
Status: "pending",
}
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
slog.Warn("cluster: pre-register by FQDN failed", "fqdn", fqdn, "error", err)
}
}
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-