feat(setup): Cluster-Join komplett in GUI — kein CLI mehr nötig
POST /setup/join-cluster macht alles was bisher edgeguard-ctl cluster-join tat: CSR generieren, Certs vom Primary holen, schreiben, auto-registrieren, Setup als Cluster-Node markieren. Setup-Wizard Node-Modus fragt jetzt direkt Primary-FQDN + Join-Token ab. Nach Submit: Erfolgsmeldung + einziger verbleibender Schritt (systemctl restart). Neue interne Bibliothek: internal/services/clusterjoin — wird von Handler und CLI (edgeguard-ctl cluster-join) gleichermaßen genutzt, keine Duplizierung. 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.148"
|
var version = "1.1.149"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -111,7 +111,7 @@ func main() {
|
|||||||
|
|
||||||
requireAuth := handlers.RequireAuth(signer)
|
requireAuth := handlers.RequireAuth(signer)
|
||||||
|
|
||||||
setupHdl := handlers.NewSetupHandler(setupStore)
|
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
|
||||||
setupHdl.Register(v1)
|
setupHdl.Register(v1)
|
||||||
|
|
||||||
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
|
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
|
||||||
|
|||||||
@@ -1,43 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdClusterJoin: provisioniert auf diesem Node das Cluster-Cert-
|
|
||||||
// Material durch einen Aufruf an /api/v1/cluster/issue-cert beim
|
|
||||||
// Primary.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <token>
|
|
||||||
// [--insecure]
|
|
||||||
// [--cn <fqdn>]
|
|
||||||
//
|
|
||||||
// --insecure: TLS-Verify überspringen (für Bootstrap wenn der
|
|
||||||
// Primary mit self-signed Cert läuft und die CA noch
|
|
||||||
// nicht woanders verteilt ist — der Cert-Issue-Flow
|
|
||||||
// selbst läuft über HMAC-Token, nicht über TLS-Trust).
|
|
||||||
// --cn: Subject-CN für unseren CSR. Default: os.Hostname().
|
|
||||||
//
|
|
||||||
// Output: schreibt ca.crt + peer.{crt,key} nach /var/lib/edgeguard/
|
|
||||||
// cluster-tls/. Falls Cert-Material schon vorhanden, abort mit
|
|
||||||
// hint auf manuellen rm — wir wollen nicht aus Versehen einen
|
|
||||||
// laufenden Cluster-Node von seiner identity bringen.
|
|
||||||
func cmdClusterJoin(args []string) int {
|
func cmdClusterJoin(args []string) int {
|
||||||
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
|
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
|
||||||
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
|
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
|
||||||
@@ -52,93 +23,32 @@ func cmdClusterJoin(args []string) int {
|
|||||||
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
|
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
primary := fs.Arg(0)
|
|
||||||
if *tokenFlag == "" {
|
if *tokenFlag == "" {
|
||||||
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
|
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
store := clustertls.New(*clusterTLSDir)
|
|
||||||
if store.HasPeer() {
|
|
||||||
fmt.Fprintf(os.Stderr,
|
|
||||||
"edgeguard-ctl cluster-join: peer cert already present under %s — "+
|
|
||||||
"refuse to overwrite. Run 'rm -rf %s' first if this is intentional.\n",
|
|
||||||
*clusterTLSDir, *clusterTLSDir)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
commonName := *cn
|
commonName := *cn
|
||||||
if commonName == "" {
|
if commonName == "" {
|
||||||
h, _ := os.Hostname()
|
h, _ := os.Hostname()
|
||||||
commonName = h
|
commonName = h
|
||||||
}
|
}
|
||||||
if commonName == "" {
|
|
||||||
commonName = "edgeguard-node"
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint, err := normalizePrimaryURL(primary)
|
if err := clusterjoin.Join(clusterjoin.Request{
|
||||||
if err != nil {
|
PrimaryFQDN: fs.Arg(0),
|
||||||
|
Token: *tokenFlag,
|
||||||
|
CommonName: commonName,
|
||||||
|
Insecure: *insecure,
|
||||||
|
TLSDir: *clusterTLSDir,
|
||||||
|
Version: version,
|
||||||
|
}); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
|
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host-
|
primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0))
|
||||||
// Argument eine IP war, damit der lokale Agent-Listener auch
|
|
||||||
// gegen IP gechecked werden kann.
|
|
||||||
dnsNames := []string{commonName}
|
|
||||||
var ips []net.IP
|
|
||||||
if ip := net.ParseIP(commonName); ip != nil {
|
|
||||||
ips = append(ips, ip)
|
|
||||||
// Wenn CN eine IP ist, lassen wir DNSNames leer — RFC 6125
|
|
||||||
// erlaubt nicht beides als-ob-DNS.
|
|
||||||
dnsNames = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(commonName, dnsNames, ips)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: gen CSR: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
caCertPEM, peerCertPEM, err := postIssueCert(endpoint, *tokenFlag, csrPEM, *insecure)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: issue-cert: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(*clusterTLSDir, 0o700); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: mkdir %s: %v\n", *clusterTLSDir, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
// Schreiben in stabiler Reihenfolge: erst CA (wird vom Peer-Cert-
|
|
||||||
// Verify gebraucht), dann peer.{crt,key}.
|
|
||||||
for _, w := range []struct {
|
|
||||||
name string
|
|
||||||
mode os.FileMode
|
|
||||||
data string
|
|
||||||
}{
|
|
||||||
{"ca.crt", 0o644, caCertPEM},
|
|
||||||
{"peer.crt", 0o644, peerCertPEM},
|
|
||||||
{"peer.key", 0o600, keyPEM},
|
|
||||||
} {
|
|
||||||
path := *clusterTLSDir + "/" + w.name
|
|
||||||
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: write %s: %v\n", path, err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3.5: Auto-Register beim Primary. Nutzt das frisch erhaltene
|
|
||||||
// Peer-Cert via mTLS, damit der Primary uns in ha_nodes mit
|
|
||||||
// status='joining' anlegt + sein peer_ipv4-Set updated.
|
|
||||||
if err := autoRegister(endpoint, *clusterTLSDir, commonName); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr,
|
|
||||||
"edgeguard-ctl cluster-join: auto-register failed (Cert-Material liegt aber schon — kannst manuell nachholen): %v\n", err)
|
|
||||||
// Wir geben hier NICHT-NULL zurück — der Cert-Issue war ja
|
|
||||||
// erfolgreich. Der Operator kann manuell registrieren oder
|
|
||||||
// es funktioniert beim Service-Start (Phase 3.2 Heartbeat).
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Cluster-Join erfolgreich.\n")
|
fmt.Printf("Cluster-Join erfolgreich.\n")
|
||||||
fmt.Printf(" Primary: %s\n", endpoint)
|
fmt.Printf(" Primary: %s\n", primary)
|
||||||
fmt.Printf(" CN: %s\n", commonName)
|
fmt.Printf(" CN: %s\n", commonName)
|
||||||
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
||||||
fmt.Printf("\nNächste Schritte:\n")
|
fmt.Printf("\nNächste Schritte:\n")
|
||||||
@@ -147,150 +57,3 @@ func cmdClusterJoin(args []string) int {
|
|||||||
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
|
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoRegister: POST mTLS an <primary-host>:8443/agent/cluster/peers.
|
|
||||||
// Note: der mTLS-Agent-Port :8443 ist anders als der Public-Port
|
|
||||||
// (3443). Wir leiten den Host aus der primary-URL ab und ersetzen
|
|
||||||
// den Port.
|
|
||||||
func autoRegister(primary, tlsDir, commonName string) error {
|
|
||||||
// Primary-URL parse + Port-Override
|
|
||||||
u, err := url.Parse(primary)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.Host = u.Hostname() + ":8443"
|
|
||||||
u.Path = "/agent/cluster/peers"
|
|
||||||
|
|
||||||
// Local node-id + body bauen. node-id liegt in /var/lib/edgeguard/
|
|
||||||
// node-id (vom Heartbeat-Subsystem persistiert); wir lesen direkt
|
|
||||||
// statt cluster.EnsureNodeID() um den DB-Abhängigkeit-Pfad nicht
|
|
||||||
// zu öffnen.
|
|
||||||
nodeID, _ := os.ReadFile("/var/lib/edgeguard/node-id")
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"id": strings.TrimSpace(string(nodeID)),
|
|
||||||
"name": hostname,
|
|
||||||
"fqdn": commonName,
|
|
||||||
"api_url": "https://" + commonName + ":3443",
|
|
||||||
"version": version,
|
|
||||||
})
|
|
||||||
|
|
||||||
// mTLS-Client mit gerade frisch geschriebenem Material.
|
|
||||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("load peer cert: %w", err)
|
|
||||||
}
|
|
||||||
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read ca: %w", err)
|
|
||||||
}
|
|
||||||
pool := x509.NewCertPool()
|
|
||||||
if !pool.AppendCertsFromPEM(caPEM) {
|
|
||||||
return errors.New("invalid ca.crt")
|
|
||||||
}
|
|
||||||
|
|
||||||
tr := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
Certificates: []tls.Certificate{pair},
|
|
||||||
RootCAs: pool,
|
|
||||||
MinVersion: tls.VersionTLS13,
|
|
||||||
// Hostname-Verify: wir checken gegen den CN/SAN des
|
|
||||||
// Primary-Cert. Wenn der Primary-Cert das nicht hat
|
|
||||||
// (Self-Signed for IP only), kann der join trotzdem
|
|
||||||
// erfolgreich sein wenn das CA-Cert validiert.
|
|
||||||
ServerName: u.Hostname(),
|
|
||||||
},
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
ResponseHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizePrimaryURL: nimmt "fqdn", "host:port" oder "https://host:port"
|
|
||||||
// und liefert immer "https://host:port" zurück. Default-Port 3443 (das
|
|
||||||
// ist der Mgmt-UI-Listener; /cluster/issue-cert läuft dort).
|
|
||||||
func normalizePrimaryURL(in string) (string, error) {
|
|
||||||
in = strings.TrimSpace(in)
|
|
||||||
if in == "" {
|
|
||||||
return "", errors.New("empty primary fqdn/url")
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
|
|
||||||
in = "https://" + in
|
|
||||||
}
|
|
||||||
u, err := url.Parse(in)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if u.Hostname() == "" {
|
|
||||||
return "", errors.New("primary URL has no host")
|
|
||||||
}
|
|
||||||
if u.Port() == "" {
|
|
||||||
u.Host = u.Hostname() + ":3443"
|
|
||||||
}
|
|
||||||
u.Path = ""
|
|
||||||
u.RawQuery = ""
|
|
||||||
u.Fragment = ""
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// postIssueCert: POSTet {token, csr} an <primary>/api/v1/cluster/issue-cert.
|
|
||||||
// `insecure` skippt TLS-Verify damit der Bootstrap auch wenn der Primary
|
|
||||||
// mit self-signed Cert hört durchgeht — die Sicherheit hängt am HMAC-
|
|
||||||
// gesigneten Token, nicht am TLS-Layer.
|
|
||||||
func postIssueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
|
||||||
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
|
||||||
req, err := http.NewRequest(http.MethodPost,
|
|
||||||
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
tr := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
ResponseHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
||||||
}
|
|
||||||
var env struct {
|
|
||||||
Data struct {
|
|
||||||
CACert string `json:"ca_cert"`
|
|
||||||
PeerCert string `json:"peer_cert"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &env); err != nil {
|
|
||||||
return "", "", fmt.Errorf("decode response: %w", err)
|
|
||||||
}
|
|
||||||
if env.Error != "" {
|
|
||||||
return "", "", fmt.Errorf("server: %s", env.Error)
|
|
||||||
}
|
|
||||||
if env.Data.CACert == "" || env.Data.PeerCert == "" {
|
|
||||||
return "", "", errors.New("response missing ca_cert or peer_cert")
|
|
||||||
}
|
|
||||||
return env.Data.CACert, env.Data.PeerCert, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.148"
|
var version = "1.1.149"
|
||||||
|
|
||||||
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.148"
|
var version = "1.1.149"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ type SetupHandler struct {
|
|||||||
Store *setup.Store
|
Store *setup.Store
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
NodeID string
|
NodeID string
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||||
@@ -30,11 +34,18 @@ func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithVersion macht die laufende Version für auto-register verfügbar.
|
||||||
|
func (h *SetupHandler) WithVersion(v string) *SetupHandler {
|
||||||
|
h.Version = v
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/setup")
|
g := rg.Group("/setup")
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
g.POST("/complete", h.Complete)
|
g.POST("/complete", h.Complete)
|
||||||
g.POST("/complete-node", h.CompleteAsNode)
|
g.POST("/complete-node", h.CompleteAsNode)
|
||||||
|
g.POST("/join-cluster", h.JoinCluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||||
@@ -130,3 +141,48 @@ func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
|
|||||||
"fqdn": st.FQDN,
|
"fqdn": st.FQDN,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JoinCluster performs the full cluster-join flow from the setup wizard:
|
||||||
|
// fetches certs from the primary, writes them to disk, then marks setup
|
||||||
|
// as completed. No CLI required.
|
||||||
|
func (h *SetupHandler) JoinCluster(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
FQDN string `json:"fqdn" binding:"required"`
|
||||||
|
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||||
|
PrimaryFQDN string `json:"primary_fqdn" binding:"required"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := strings.ToLower(strings.TrimSpace(body.FQDN))
|
||||||
|
|
||||||
|
if err := clusterjoin.Join(clusterjoin.Request{
|
||||||
|
PrimaryFQDN: body.PrimaryFQDN,
|
||||||
|
Token: strings.TrimSpace(body.Token),
|
||||||
|
CommonName: fqdn,
|
||||||
|
Insecure: body.Insecure,
|
||||||
|
Version: h.Version,
|
||||||
|
NodeID: h.NodeID,
|
||||||
|
}); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
|
||||||
|
FQDN: fqdn,
|
||||||
|
ACMEEmail: body.ACMEEmail,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"completed": st.Completed,
|
||||||
|
"is_cluster_node": st.IsClusterNode,
|
||||||
|
"fqdn": st.FQDN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
254
internal/services/clusterjoin/join.go
Normal file
254
internal/services/clusterjoin/join.go
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// Package clusterjoin encapsulates the cluster-join flow so it can be
|
||||||
|
// driven from both the edgeguard-ctl CLI and the setup-wizard HTTP
|
||||||
|
// handler without duplicating the HTTP + cert logic.
|
||||||
|
package clusterjoin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request describes what the joining node needs.
|
||||||
|
type Request struct {
|
||||||
|
// PrimaryFQDN is the FQDN (or https://…:port URL) of the primary.
|
||||||
|
// NormalizePrimaryURL() is applied automatically.
|
||||||
|
PrimaryFQDN string
|
||||||
|
|
||||||
|
// Token is the eg-join-v1.… value generated on the primary.
|
||||||
|
Token string
|
||||||
|
|
||||||
|
// CommonName becomes the Subject-CN of the peer cert and the
|
||||||
|
// lookup key in ha_nodes. Usually the FQDN of this node.
|
||||||
|
CommonName string
|
||||||
|
|
||||||
|
// Insecure skips TLS verify when contacting the primary. Safe
|
||||||
|
// because security depends on the HMAC-signed token, not TLS trust.
|
||||||
|
Insecure bool
|
||||||
|
|
||||||
|
// TLSDir is where ca.crt + peer.{crt,key} are written.
|
||||||
|
// Zero value → clustertls.DefaultDir.
|
||||||
|
TLSDir string
|
||||||
|
|
||||||
|
// Version + NodeID are reported to the primary during auto-register.
|
||||||
|
// Both are optional metadata — join succeeds even if empty.
|
||||||
|
Version string
|
||||||
|
NodeID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join performs the full cluster-join:
|
||||||
|
// 1. Generates a local Ed25519 key + CSR.
|
||||||
|
// 2. POSTs to primary /api/v1/cluster/issue-cert to get ca.crt + peer.crt.
|
||||||
|
// 3. Writes ca.crt, peer.crt, peer.key to TLSDir.
|
||||||
|
// 4. Calls auto-register on the primary via mTLS (best-effort).
|
||||||
|
func Join(req Request) error {
|
||||||
|
if req.PrimaryFQDN == "" {
|
||||||
|
return errors.New("primary_fqdn required")
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
return errors.New("token required")
|
||||||
|
}
|
||||||
|
if req.CommonName == "" {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
req.CommonName = h
|
||||||
|
}
|
||||||
|
if req.CommonName == "" {
|
||||||
|
req.CommonName = "edgeguard-node"
|
||||||
|
}
|
||||||
|
tlsDir := req.TLSDir
|
||||||
|
if tlsDir == "" {
|
||||||
|
tlsDir = clustertls.DefaultDir
|
||||||
|
}
|
||||||
|
|
||||||
|
store := clustertls.New(tlsDir)
|
||||||
|
if store.HasPeer() {
|
||||||
|
return fmt.Errorf("peer cert already present under %s — remove it first if this is intentional", tlsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, err := NormalizePrimaryURL(req.PrimaryFQDN)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsNames := []string{req.CommonName}
|
||||||
|
var ips []net.IP
|
||||||
|
if ip := net.ParseIP(req.CommonName); ip != nil {
|
||||||
|
ips = append(ips, ip)
|
||||||
|
dnsNames = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(req.CommonName, dnsNames, ips)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate CSR: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPEM, peerCertPEM, err := issueCert(primary, req.Token, csrPEM, req.Insecure)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("issue-cert: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(tlsDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", tlsDir, err)
|
||||||
|
}
|
||||||
|
for _, w := range []struct {
|
||||||
|
name string
|
||||||
|
mode os.FileMode
|
||||||
|
data string
|
||||||
|
}{
|
||||||
|
{"ca.crt", 0o644, caCertPEM},
|
||||||
|
{"peer.crt", 0o644, peerCertPEM},
|
||||||
|
{"peer.key", 0o600, keyPEM},
|
||||||
|
} {
|
||||||
|
path := tlsDir + "/" + w.name
|
||||||
|
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-register: best-effort — cert material is already written.
|
||||||
|
_ = autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePrimaryURL accepts "fqdn", "host:port" or "https://host:port"
|
||||||
|
// and always returns "https://host:port". Default port is 3443.
|
||||||
|
func NormalizePrimaryURL(in string) (string, error) {
|
||||||
|
in = strings.TrimSpace(in)
|
||||||
|
if in == "" {
|
||||||
|
return "", errors.New("empty primary fqdn/url")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
|
||||||
|
in = "https://" + in
|
||||||
|
}
|
||||||
|
u, err := url.Parse(in)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if u.Hostname() == "" {
|
||||||
|
return "", errors.New("primary URL has no host")
|
||||||
|
}
|
||||||
|
if u.Port() == "" {
|
||||||
|
u.Host = u.Hostname() + ":3443"
|
||||||
|
}
|
||||||
|
u.Path = ""
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
||||||
|
req, err := http.NewRequest(http.MethodPost,
|
||||||
|
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12}, //nolint:gosec
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
PeerCert string `json:"peer_cert"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return "", "", fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if env.Error != "" {
|
||||||
|
return "", "", fmt.Errorf("server: %s", env.Error)
|
||||||
|
}
|
||||||
|
if env.Data.CACert == "" || env.Data.PeerCert == "" {
|
||||||
|
return "", "", errors.New("response missing ca_cert or peer_cert")
|
||||||
|
}
|
||||||
|
return env.Data.CACert, env.Data.PeerCert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoRegister(primary, tlsDir, commonName, version, nodeID string) error {
|
||||||
|
u, err := url.Parse(primary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Host = u.Hostname() + ":8443"
|
||||||
|
u.Path = "/agent/cluster/peers"
|
||||||
|
|
||||||
|
if nodeID == "" {
|
||||||
|
raw, _ := os.ReadFile("/var/lib/edgeguard/node-id")
|
||||||
|
nodeID = strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"id": nodeID,
|
||||||
|
"name": hostname,
|
||||||
|
"fqdn": commonName,
|
||||||
|
"api_url": "https://" + commonName + ":3443",
|
||||||
|
"version": version,
|
||||||
|
})
|
||||||
|
|
||||||
|
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load peer cert: %w", err)
|
||||||
|
}
|
||||||
|
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read ca: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caPEM) {
|
||||||
|
return errors.New("invalid ca.crt")
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{pair},
|
||||||
|
RootCAs: pool,
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
ServerName: u.Hostname(),
|
||||||
|
},
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
"nodePreflightTitle": "Vor dem Fortfahren",
|
"nodePreflightTitle": "Vor dem Fortfahren",
|
||||||
"nodePreflightDesc": "Stelle sicher, dass EdgeGuard auf dem Primary bereits läuft und du dort einen Join-Token erstellen kannst (Cluster-Seite). Du benötigst ihn im nächsten Schritt.",
|
"nodePreflightDesc": "Stelle sicher, dass EdgeGuard auf dem Primary bereits läuft und du dort einen Join-Token erstellen kannst (Cluster-Seite). Du benötigst ihn im nächsten Schritt.",
|
||||||
"nodeSubmit": "Als Cluster-Knoten einrichten",
|
"nodeSubmit": "Als Cluster-Knoten einrichten",
|
||||||
"nodeSuccessTitle": "Knoten konfiguriert",
|
"nodeSuccessTitle": "Erfolgreich beigetreten!",
|
||||||
"nodeJoinTitle": "Nächster Schritt: Cluster beitreten",
|
"nodeJoinTitle": "Nächster Schritt: Cluster beitreten",
|
||||||
"nodeJoinDesc": "Dieser Server ({{fqdn}}) ist konfiguriert. Führe jetzt den Join-Befehl aus, um ihn mit dem Primary zu verbinden.",
|
"nodeJoinDesc": "Dieser Server ({{fqdn}}) ist konfiguriert. Führe jetzt den Join-Befehl aus, um ihn mit dem Primary zu verbinden.",
|
||||||
"nodeStep1Title": "Join-Token auf dem Primary generieren",
|
"nodeStep1Title": "Join-Token auf dem Primary generieren",
|
||||||
@@ -336,7 +336,15 @@
|
|||||||
"nodeStep2Desc": "Ersetze <primary-fqdn> mit dem FQDN deines Primarys und <token> mit dem Token aus Schritt 1:",
|
"nodeStep2Desc": "Ersetze <primary-fqdn> mit dem FQDN deines Primarys und <token> mit dem Token aus Schritt 1:",
|
||||||
"nodeStep3Title": "API neu starten",
|
"nodeStep3Title": "API neu starten",
|
||||||
"nodeStep3Desc": "Nach erfolgreichem Join-Befehl API neu starten, damit die replizierte Konfiguration übernommen wird:",
|
"nodeStep3Desc": "Nach erfolgreichem Join-Befehl API neu starten, damit die replizierte Konfiguration übernommen wird:",
|
||||||
"nodeLoginNote": "Der Login erfolgt mit den Admin-Zugangsdaten des Primarys. Das funktioniert sobald die Datenbankreplikation aufgebaut wurde (der Join-Befehl richtet das ein)."
|
"nodeLoginNote": "Der Login erfolgt mit den Admin-Zugangsdaten des Primarys. Das funktioniert sobald die Datenbankreplikation aufgebaut wurde (der Join-Befehl richtet das ein).",
|
||||||
|
"primaryFqdn": "Primary-FQDN",
|
||||||
|
"primaryFqdnHint": "FQDN des bestehenden Primary-EdgeGuard-Knotens (z.B. eg1.example.com).",
|
||||||
|
"joinToken": "Join-Token",
|
||||||
|
"joinTokenHint": "Auf dem Primary generieren: Cluster-Seite → Knoten hinzufügen → Join-Token generieren.",
|
||||||
|
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
|
||||||
|
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
|
||||||
|
"nodeRestartTitle": "Neustart erforderlich",
|
||||||
|
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -327,7 +327,7 @@
|
|||||||
"nodePreflightTitle": "Before you continue",
|
"nodePreflightTitle": "Before you continue",
|
||||||
"nodePreflightDesc": "Make sure EdgeGuard is already running on your primary node and you can generate a join token there (Cluster page). You will need it in the next step.",
|
"nodePreflightDesc": "Make sure EdgeGuard is already running on your primary node and you can generate a join token there (Cluster page). You will need it in the next step.",
|
||||||
"nodeSubmit": "Configure as cluster node",
|
"nodeSubmit": "Configure as cluster node",
|
||||||
"nodeSuccessTitle": "Node configured",
|
"nodeSuccessTitle": "Successfully joined!",
|
||||||
"nodeJoinTitle": "Next: join the cluster",
|
"nodeJoinTitle": "Next: join the cluster",
|
||||||
"nodeJoinDesc": "This box ({{fqdn}}) is configured. Now run the join command below to connect it to the primary.",
|
"nodeJoinDesc": "This box ({{fqdn}}) is configured. Now run the join command below to connect it to the primary.",
|
||||||
"nodeStep1Title": "Generate a join token on the primary",
|
"nodeStep1Title": "Generate a join token on the primary",
|
||||||
@@ -336,7 +336,15 @@
|
|||||||
"nodeStep2Desc": "Replace <primary-fqdn> with your primary's FQDN and <token> with the token from step 1:",
|
"nodeStep2Desc": "Replace <primary-fqdn> with your primary's FQDN and <token> with the token from step 1:",
|
||||||
"nodeStep3Title": "Restart the API",
|
"nodeStep3Title": "Restart the API",
|
||||||
"nodeStep3Desc": "After the join command completes, restart the API to pick up the replicated configuration:",
|
"nodeStep3Desc": "After the join command completes, restart the API to pick up the replicated configuration:",
|
||||||
"nodeLoginNote": "Login uses the admin credentials from the primary. This will work once database replication is established (the join command sets this up)."
|
"nodeLoginNote": "Login uses the admin credentials from the primary. This will work once database replication is established (the join command sets this up).",
|
||||||
|
"primaryFqdn": "Primary FQDN",
|
||||||
|
"primaryFqdnHint": "FQDN of the existing primary EdgeGuard node (e.g. eg1.example.com).",
|
||||||
|
"joinToken": "Join token",
|
||||||
|
"joinTokenHint": "Generated on the primary: Cluster page → Add node → Generate join token.",
|
||||||
|
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
|
||||||
|
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
|
||||||
|
"nodeRestartTitle": "Restart required",
|
||||||
|
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Alert, Button, Card, Form, Input, Space, Tag, Typography, message } from 'antd'
|
import { Alert, Button, Card, Checkbox, Form, Input, Space, Typography, message } from 'antd'
|
||||||
import { ArrowLeftOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -19,9 +19,12 @@ interface SetupValues {
|
|||||||
license_key?: string
|
license_key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeValues {
|
interface JoinValues {
|
||||||
fqdn: string
|
fqdn: string
|
||||||
acme_email: string
|
acme_email: string
|
||||||
|
primary_fqdn: string
|
||||||
|
token: string
|
||||||
|
insecure?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||||
@@ -44,10 +47,11 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [mode, setMode] = useState<Mode | null>(null)
|
const [mode, setMode] = useState<Mode | null>(null)
|
||||||
const [nodeDone, setNodeDone] = useState(false)
|
const [joinDone, setJoinDone] = useState(false)
|
||||||
const [nodeFqdn, setNodeFqdn] = useState('')
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const onFinish = async (vals: SetupValues) => {
|
const onFinish = async (vals: SetupValues) => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const normalised: SetupValues = {
|
const normalised: SetupValues = {
|
||||||
...vals,
|
...vals,
|
||||||
@@ -61,25 +65,30 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string }
|
const err = e as { message?: string }
|
||||||
message.error(err.message ?? t('common.error'))
|
message.error(err.message ?? t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFinishNode = async (vals: NodeValues) => {
|
const onJoin = async (vals: JoinValues) => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const normalised: NodeValues = {
|
await apiClient.post('/setup/join-cluster', {
|
||||||
fqdn: vals.fqdn.trim().toLowerCase(),
|
fqdn: vals.fqdn.trim().toLowerCase(),
|
||||||
acme_email: vals.acme_email.trim().toLowerCase(),
|
acme_email: vals.acme_email.trim().toLowerCase(),
|
||||||
}
|
primary_fqdn: vals.primary_fqdn.trim().toLowerCase(),
|
||||||
await apiClient.post('/setup/complete-node', normalised)
|
token: vals.token.trim(),
|
||||||
setNodeFqdn(normalised.fqdn)
|
insecure: vals.insecure ?? false,
|
||||||
setNodeDone(true)
|
})
|
||||||
|
setJoinDone(true)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string }
|
const err = e as { message?: string }
|
||||||
message.error(err.message ?? t('common.error'))
|
message.error(err.message ?? t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinCmd = `sudo edgeguard-ctl cluster-join <primary-fqdn> --token <token>`
|
|
||||||
const restartCmd = `sudo systemctl restart edgeguard-api`
|
const restartCmd = `sudo systemctl restart edgeguard-api`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,12 +138,12 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
{/* ── Standalone form ── */}
|
{/* ── Standalone form ── */}
|
||||||
{mode === 'standalone' && (
|
{mode === 'standalone' && (
|
||||||
<>
|
<>
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
||||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
||||||
{t('setup.back')}
|
{t('setup.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
|
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
{t('setup.intro')}
|
{t('setup.intro')}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -196,7 +205,7 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
{t('setup.submit')}
|
{t('setup.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -204,28 +213,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Cluster node form ── */}
|
{/* ── Cluster node join form ── */}
|
||||||
{mode === 'node' && !nodeDone && (
|
{mode === 'node' && !joinDone && (
|
||||||
<>
|
<>
|
||||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
||||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
||||||
{t('setup.back')}
|
{t('setup.back')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.nodeTitle')}</Typography.Title>
|
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.nodeTitle')}</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
{t('setup.nodeIntro')}
|
{t('setup.nodeIntro')}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
message={t('setup.nodePreflightTitle')}
|
message={t('setup.nodePreflightTitle')}
|
||||||
description={t('setup.nodePreflightDesc')}
|
description={t('setup.nodePreflightDesc')}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form layout="vertical" onFinish={onFinishNode}>
|
<Form layout="vertical" onFinish={onJoin}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('setup.fqdn')}
|
label={t('setup.fqdn')}
|
||||||
name="fqdn"
|
name="fqdn"
|
||||||
@@ -247,8 +256,37 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
<Input placeholder="ops@example.com" />
|
<Input placeholder="ops@example.com" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('setup.primaryFqdn')}
|
||||||
|
name="primary_fqdn"
|
||||||
|
extra={t('setup.primaryFqdnHint')}
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
{ pattern: FQDN_RE, message: t('setup.fqdnInvalid') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="eg1.example.com" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('setup.joinToken')}
|
||||||
|
name="token"
|
||||||
|
extra={t('setup.joinTokenHint')}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
placeholder="eg-join-v1.eyJ…"
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="insecure" valuePropName="checked">
|
||||||
|
<Checkbox>{t('setup.joinInsecure')}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||||
{t('setup.nodeSubmit')}
|
{t('setup.nodeSubmit')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -256,39 +294,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Cluster node success ── */}
|
{/* ── Join success ── */}
|
||||||
{mode === 'node' && nodeDone && (
|
{mode === 'node' && joinDone && (
|
||||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ fontSize: 32, color: '#52c41a' }} />
|
||||||
<div>
|
<div>
|
||||||
<Tag color="success" style={{ marginBottom: 8 }}>{t('setup.nodeSuccessTitle')}</Tag>
|
<Typography.Title level={4} style={{ margin: 0 }}>{t('setup.nodeSuccessTitle')}</Typography.Title>
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>{t('setup.nodeJoinTitle')}</Typography.Title>
|
<Typography.Text type="secondary">{t('setup.nodeSuccessDesc')}</Typography.Text>
|
||||||
<Typography.Paragraph type="secondary">
|
|
||||||
{t('setup.nodeJoinDesc', { fqdn: nodeFqdn })}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
<div>
|
<Alert
|
||||||
<Typography.Text strong>1. {t('setup.nodeStep1Title')}</Typography.Text>
|
type="warning"
|
||||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0' }}>
|
showIcon
|
||||||
{t('setup.nodeStep1Desc')}
|
message={t('setup.nodeRestartTitle')}
|
||||||
</Typography.Paragraph>
|
description={
|
||||||
</div>
|
<Space direction="vertical" size={8} style={{ width: '100%', marginTop: 8 }}>
|
||||||
|
<Typography.Text>{t('setup.nodeRestartDesc')}</Typography.Text>
|
||||||
<div>
|
|
||||||
<Typography.Text strong>2. {t('setup.nodeStep2Title')}</Typography.Text>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
|
|
||||||
{t('setup.nodeStep2Desc')}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<CopyCode value={joinCmd} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Typography.Text strong>3. {t('setup.nodeStep3Title')}</Typography.Text>
|
|
||||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
|
|
||||||
{t('setup.nodeStep3Desc')}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
<CopyCode value={restartCmd} />
|
<CopyCode value={restartCmd} />
|
||||||
</div>
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
Reference in New Issue
Block a user