diff --git a/VERSION b/VERSION index a10b76e..258b768 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.153 +1.1.154 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index dd8f51a..474618d 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.153" +var version = "1.1.154" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 7828e55..7007e4d 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.153" +var version = "1.1.154" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 7e1a1d8..715a3f7 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.153" +var version = "1.1.154" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index d9aef58..297cef0 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -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- diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 545509d..a26f50d 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -615,11 +615,11 @@ "step1Title": "1 — EdgeGuard auf dem neuen Host installieren", "step1Desc": "Als root auf dem neuen Host ausführen (Debian 13, amd64):", "step2Title": "2 — Join-Token hier generieren (auf diesem Primary-Node)", - "step2Desc": "Erzeugt einen einmaligen HMAC-Token, der 30 Minuten gültig ist. Der Token autorisiert den neuen Node ein signiertes Cluster-TLS-Zertifikat zu erhalten.", + "step2Desc": "Trage zuerst den FQDN des neuen Knotens ein. Er wird damit vorregistriert und Port 8443 wird für den Join geöffnet.", "step3Title": "3 — Auf dem neuen Node ausführen", "step3NoToken": "Zuerst oben auf \"Join-Token erzeugen\" klicken — der vollständige Befehl erscheint dann hier.", "step4Title": "4 — edgeguard-api auf dem neuen Node neu starten", - "step4Desc": "Der Dienst lädt das neue Cluster-TLS-Zertifikat und beginnt Heartbeats zu senden. Der Node erscheint innerhalb von 30 Sekunden in der Peers-Tabelle.", + "step4Desc": "Nachdem der Setup-Wizard auf dem neuen Knoten abgeschlossen ist, edgeguard-api dort neu starten um die Cluster-Zertifikate zu laden:", "apiUnreachable": "Cluster-API nicht erreichbar", "tokenLabel": "Token", "caFingerprintLabel": "CA-Fingerabdruck", @@ -670,7 +670,11 @@ "joining": "joining", "leaving": "leaving", "unknown": "unbekannt" - } + }, + "step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.", + "generateNewToken": "Neuen Token generieren", + "setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen", + "newNodeFqdnLabel": "FQDN des neuen Knotens" }, "ssl": { "title": "SSL-Zertifikate", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index b087d16..e44fd6e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -615,11 +615,11 @@ "step1Title": "1 — Install EdgeGuard on the new host", "step1Desc": "Run as root on the new host (Debian 13, amd64):", "step2Title": "2 — Generate a join token here (on this primary node)", - "step2Desc": "Creates a one-time HMAC token valid for 30 minutes. The token authorises the new node to receive a signed cluster TLS certificate.", + "step2Desc": "Enter the FQDN of the new node first. This pre-registers it and opens port 8443 for the join.", "step3Title": "3 — Run on the new node", "step3NoToken": "Click \"Generate join token\" above — the full command will appear here.", "step4Title": "4 — Restart edgeguard-api on the new node", - "step4Desc": "The service loads the new cluster TLS certificate and starts sending heartbeats. The node appears in the Peers table within 30 seconds.", + "step4Desc": "After the setup wizard completes on the new node, restart edgeguard-api there to load the cluster certificates:", "apiUnreachable": "Cluster API not reachable", "tokenLabel": "Token", "caFingerprintLabel": "CA fingerprint", @@ -670,7 +670,11 @@ "joining": "joining", "leaving": "leaving", "unknown": "unknown" - } + }, + "step3SetupDesc": "Open the setup wizard on the new node (https://:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.", + "generateNewToken": "Generate new token", + "setupWizardHint": "Open the setup wizard on the new node", + "newNodeFqdnLabel": "New node FQDN" }, "ssl": { "title": "SSL certificates", diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx index e439d10..95b7812 100644 --- a/management-ui/src/pages/Cluster/index.tsx +++ b/management-ui/src/pages/Cluster/index.tsx @@ -67,6 +67,7 @@ interface JoinTokenResponse { token: string expires_at: string ca_fingerprint: string + node_fqdn?: string } interface CertInfo { @@ -260,9 +261,10 @@ export default function ClusterPage() { }) const [joinToken, setJoinToken] = useState(null) + const [nodeFqdnInput, setNodeFqdnInput] = useState('') const generateToken = useMutation({ - mutationFn: async () => { - const r = await apiClient.post('/cluster/join-tokens') + mutationFn: async (nodeFqdn: string) => { + const r = await apiClient.post('/cluster/join-tokens', { node_fqdn: nodeFqdn }) return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null }, onSuccess: (tok) => { setJoinToken(tok) }, @@ -270,9 +272,6 @@ export default function ClusterPage() { }) const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname - const joinCmd = joinToken - ? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}` - : '' const peerColumns: ColumnsType = [ { @@ -416,17 +415,34 @@ export default function ClusterPage() { {t('cluster.step2Desc')} - - + + + ) : ( + - + )} @@ -440,7 +456,24 @@ export default function ClusterPage() { expires: new Date(joinToken.expires_at).toLocaleString(), })} /> + {joinToken.node_fqdn && ( + + {'https://' + joinToken.node_fqdn + ':3443/setup'} + + } + /> + )} + {joinToken.node_fqdn && ( + + {joinToken.node_fqdn} + + )} {joinToken.token} @@ -452,12 +485,9 @@ export default function ClusterPage() { )} -
- - {t('cluster.joinCmdLabel')} - - -
+ + {t('cluster.step3SetupDesc', { primaryFqdn })} + ) : ( {t('cluster.step3NoToken')} @@ -465,10 +495,8 @@ export default function ClusterPage() {
+ {t('cluster.step4Desc')} - - {t('cluster.step4Desc')} -