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:
@@ -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.153"
|
var version = "1.1.154"
|
||||||
|
|
||||||
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.153"
|
var version = "1.1.154"
|
||||||
|
|
||||||
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.153"
|
var version = "1.1.154"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -292,29 +292,64 @@ func localSystemLoad() any {
|
|||||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||||
|
|
||||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
|
||||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
// 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) {
|
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()
|
token, exp, err := h.Tokens.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
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()
|
caCert, _, err := h.TLSStore.LoadCA()
|
||||||
caFP := ""
|
caFP := ""
|
||||||
if err == nil && caCert != nil {
|
if err == nil && caCert != nil {
|
||||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
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{
|
response.OK(c, gin.H{
|
||||||
"token": token,
|
"token": token,
|
||||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||||
"ca_fingerprint": caFP,
|
"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
|
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||||
|
|||||||
@@ -615,11 +615,11 @@
|
|||||||
"step1Title": "1 — EdgeGuard auf dem neuen Host installieren",
|
"step1Title": "1 — EdgeGuard auf dem neuen Host installieren",
|
||||||
"step1Desc": "Als root auf dem neuen Host ausführen (Debian 13, amd64):",
|
"step1Desc": "Als root auf dem neuen Host ausführen (Debian 13, amd64):",
|
||||||
"step2Title": "2 — Join-Token hier generieren (auf diesem Primary-Node)",
|
"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",
|
"step3Title": "3 — Auf dem neuen Node ausführen",
|
||||||
"step3NoToken": "Zuerst oben auf \"Join-Token erzeugen\" klicken — der vollständige Befehl erscheint dann hier.",
|
"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",
|
"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",
|
"apiUnreachable": "Cluster-API nicht erreichbar",
|
||||||
"tokenLabel": "Token",
|
"tokenLabel": "Token",
|
||||||
"caFingerprintLabel": "CA-Fingerabdruck",
|
"caFingerprintLabel": "CA-Fingerabdruck",
|
||||||
@@ -670,7 +670,11 @@
|
|||||||
"joining": "joining",
|
"joining": "joining",
|
||||||
"leaving": "leaving",
|
"leaving": "leaving",
|
||||||
"unknown": "unbekannt"
|
"unknown": "unbekannt"
|
||||||
}
|
},
|
||||||
|
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>: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": {
|
"ssl": {
|
||||||
"title": "SSL-Zertifikate",
|
"title": "SSL-Zertifikate",
|
||||||
|
|||||||
@@ -615,11 +615,11 @@
|
|||||||
"step1Title": "1 — Install EdgeGuard on the new host",
|
"step1Title": "1 — Install EdgeGuard on the new host",
|
||||||
"step1Desc": "Run as root on the new host (Debian 13, amd64):",
|
"step1Desc": "Run as root on the new host (Debian 13, amd64):",
|
||||||
"step2Title": "2 — Generate a join token here (on this primary node)",
|
"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",
|
"step3Title": "3 — Run on the new node",
|
||||||
"step3NoToken": "Click \"Generate join token\" above — the full command will appear here.",
|
"step3NoToken": "Click \"Generate join token\" above — the full command will appear here.",
|
||||||
"step4Title": "4 — Restart edgeguard-api on the new node",
|
"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",
|
"apiUnreachable": "Cluster API not reachable",
|
||||||
"tokenLabel": "Token",
|
"tokenLabel": "Token",
|
||||||
"caFingerprintLabel": "CA fingerprint",
|
"caFingerprintLabel": "CA fingerprint",
|
||||||
@@ -670,7 +670,11 @@
|
|||||||
"joining": "joining",
|
"joining": "joining",
|
||||||
"leaving": "leaving",
|
"leaving": "leaving",
|
||||||
"unknown": "unknown"
|
"unknown": "unknown"
|
||||||
}
|
},
|
||||||
|
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>: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": {
|
"ssl": {
|
||||||
"title": "SSL certificates",
|
"title": "SSL certificates",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ interface JoinTokenResponse {
|
|||||||
token: string
|
token: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
ca_fingerprint: string
|
ca_fingerprint: string
|
||||||
|
node_fqdn?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CertInfo {
|
interface CertInfo {
|
||||||
@@ -260,9 +261,10 @@ export default function ClusterPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [joinToken, setJoinToken] = useState<JoinTokenResponse | null>(null)
|
const [joinToken, setJoinToken] = useState<JoinTokenResponse | null>(null)
|
||||||
|
const [nodeFqdnInput, setNodeFqdnInput] = useState('')
|
||||||
const generateToken = useMutation({
|
const generateToken = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (nodeFqdn: string) => {
|
||||||
const r = await apiClient.post('/cluster/join-tokens')
|
const r = await apiClient.post('/cluster/join-tokens', { node_fqdn: nodeFqdn })
|
||||||
return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null
|
return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null
|
||||||
},
|
},
|
||||||
onSuccess: (tok) => { setJoinToken(tok) },
|
onSuccess: (tok) => { setJoinToken(tok) },
|
||||||
@@ -270,9 +272,6 @@ export default function ClusterPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
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<HANode> = [
|
const peerColumns: ColumnsType<HANode> = [
|
||||||
{
|
{
|
||||||
@@ -416,17 +415,34 @@ export default function ClusterPage() {
|
|||||||
|
|
||||||
<StepRow n={2} title={t('cluster.step2Title')}>
|
<StepRow n={2} title={t('cluster.step2Title')}>
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step2Desc')}</Paragraph>
|
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step2Desc')}</Paragraph>
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
{!joinToken ? (
|
||||||
<Button
|
<Space.Compact style={{ width: '100%', maxWidth: 480 }}>
|
||||||
type="primary"
|
<Input
|
||||||
icon={<KeyOutlined />}
|
placeholder="eg2.example.com"
|
||||||
loading={generateToken.isPending}
|
value={nodeFqdnInput}
|
||||||
disabled={isViewer || (isError && !data)}
|
onChange={(e) => setNodeFqdnInput(e.target.value)}
|
||||||
onClick={() => generateToken.mutate()}
|
onPressEnter={() => {
|
||||||
>
|
if (nodeFqdnInput.trim() && !isViewer) generateToken.mutate(nodeFqdnInput.trim())
|
||||||
{t('cluster.generateJoinToken')}
|
}}
|
||||||
|
disabled={isViewer}
|
||||||
|
/>
|
||||||
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
loading={generateToken.isPending}
|
||||||
|
disabled={isViewer || !nodeFqdnInput.trim() || (isError && !data)}
|
||||||
|
onClick={() => generateToken.mutate(nodeFqdnInput.trim())}
|
||||||
|
>
|
||||||
|
{t('cluster.generateJoinToken')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space.Compact>
|
||||||
|
) : (
|
||||||
|
<Button size="small" onClick={() => { setJoinToken(null); setNodeFqdnInput('') }}>
|
||||||
|
{t('cluster.generateNewToken')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
)}
|
||||||
</StepRow>
|
</StepRow>
|
||||||
|
|
||||||
<StepRow n={3} title={t('cluster.step3Title')}>
|
<StepRow n={3} title={t('cluster.step3Title')}>
|
||||||
@@ -440,7 +456,24 @@ export default function ClusterPage() {
|
|||||||
expires: new Date(joinToken.expires_at).toLocaleString(),
|
expires: new Date(joinToken.expires_at).toLocaleString(),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
{joinToken.node_fqdn && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={t('cluster.setupWizardHint')}
|
||||||
|
description={
|
||||||
|
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{'https://' + joinToken.node_fqdn + ':3443/setup'}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Descriptions size="small" bordered column={1}>
|
<Descriptions size="small" bordered column={1}>
|
||||||
|
{joinToken.node_fqdn && (
|
||||||
|
<Descriptions.Item label={t('cluster.newNodeFqdnLabel')}>
|
||||||
|
<Text style={{ fontFamily: 'monospace' }}>{joinToken.node_fqdn}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
<Descriptions.Item label={t('cluster.tokenLabel')}>
|
<Descriptions.Item label={t('cluster.tokenLabel')}>
|
||||||
<Text style={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all' }}>
|
<Text style={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all' }}>
|
||||||
{joinToken.token}
|
{joinToken.token}
|
||||||
@@ -452,12 +485,9 @@ export default function ClusterPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
)}
|
)}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<div>
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
{t('cluster.step3SetupDesc', { primaryFqdn })}
|
||||||
{t('cluster.joinCmdLabel')}
|
</Paragraph>
|
||||||
</Text>
|
|
||||||
<CopyCode value={joinCmd} />
|
|
||||||
</div>
|
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">{t('cluster.step3NoToken')}</Text>
|
<Text type="secondary">{t('cluster.step3NoToken')}</Text>
|
||||||
@@ -465,10 +495,8 @@ export default function ClusterPage() {
|
|||||||
</StepRow>
|
</StepRow>
|
||||||
|
|
||||||
<StepRowLast n={4} title={t('cluster.step4Title')}>
|
<StepRowLast n={4} title={t('cluster.step4Title')}>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step4Desc')}</Paragraph>
|
||||||
<CopyCode value="sudo systemctl restart edgeguard-api" />
|
<CopyCode value="sudo systemctl restart edgeguard-api" />
|
||||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
|
||||||
{t('cluster.step4Desc')}
|
|
||||||
</Paragraph>
|
|
||||||
</StepRowLast>
|
</StepRowLast>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user