From afe2c951b9d651785fea592bf9b72a9a6179248a Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 29 May 2026 17:08:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(setup):=20Cluster-Join=20komplett=20in=20G?= =?UTF-8?q?UI=20=E2=80=94=20kein=20CLI=20mehr=20n=C3=B6tig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 4 +- cmd/edgeguard-ctl/cluster_join.go | 261 +----------------- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/setup.go | 62 ++++- internal/services/clusterjoin/join.go | 254 +++++++++++++++++ management-ui/src/i18n/locales/de/common.json | 12 +- management-ui/src/i18n/locales/en/common.json | 12 +- management-ui/src/pages/Setup/index.tsx | 147 ++++++---- 10 files changed, 437 insertions(+), 321 deletions(-) create mode 100644 internal/services/clusterjoin/join.go diff --git a/VERSION b/VERSION index 683ec8c..4f706ff 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.148 +1.1.149 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 2b5cc52..01c1391 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.148" +var version = "1.1.149" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -111,7 +111,7 @@ func main() { requireAuth := handlers.RequireAuth(signer) - setupHdl := handlers.NewSetupHandler(setupStore) + setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version) setupHdl.Register(v1) // systemHdl exists früh damit sowohl der frühe (DB-pool nicht diff --git a/cmd/edgeguard-ctl/cluster_join.go b/cmd/edgeguard-ctl/cluster_join.go index 2a6ffec..bafa9d6 100644 --- a/cmd/edgeguard-ctl/cluster_join.go +++ b/cmd/edgeguard-ctl/cluster_join.go @@ -1,43 +1,14 @@ package main import ( - "bytes" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" "flag" "fmt" - "io" - "net" - "net/http" - "net/url" "os" - "strings" - "time" "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 --token -// [--insecure] -// [--cn ] -// -// --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 { fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError) 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 --token <…>") return 2 } - primary := fs.Arg(0) if *tokenFlag == "" { fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required") 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 if commonName == "" { h, _ := os.Hostname() commonName = h } - if commonName == "" { - commonName = "edgeguard-node" - } - endpoint, err := normalizePrimaryURL(primary) - if err != nil { + if err := clusterjoin.Join(clusterjoin.Request{ + 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) return 1 } - // SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host- - // 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). - } - + primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0)) 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(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir) 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") return 0 } - -// autoRegister: POST mTLS an :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 /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 -} diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index cddb3e6..93ab115 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.148" +var version = "1.1.149" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 2d46279..f3202af 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.148" +var version = "1.1.149" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 18e7a07..ace0375 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -1,10 +1,13 @@ package handlers import ( + "strings" + "github.com/gin-gonic/gin" "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/clusterjoin" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) @@ -12,9 +15,10 @@ import ( // are mounted before SetupGate so they remain reachable while the API // is in setup mode. type SetupHandler struct { - Store *setup.Store - Audit *audit.Repo - NodeID string + Store *setup.Store + Audit *audit.Repo + NodeID string + Version string } func NewSetupHandler(store *setup.Store) *SetupHandler { @@ -30,11 +34,18 @@ func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler { 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) { g := rg.Group("/setup") g.GET("/status", h.Status) g.POST("/complete", h.Complete) g.POST("/complete-node", h.CompleteAsNode) + g.POST("/join-cluster", h.JoinCluster) } // RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup @@ -130,3 +141,48 @@ func (h *SetupHandler) CompleteAsNode(c *gin.Context) { "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, + }) +} diff --git a/internal/services/clusterjoin/join.go b/internal/services/clusterjoin/join.go new file mode 100644 index 0000000..228adc7 --- /dev/null +++ b/internal/services/clusterjoin/join.go @@ -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 +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index e2f0863..545509d 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -327,7 +327,7 @@ "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.", "nodeSubmit": "Als Cluster-Knoten einrichten", - "nodeSuccessTitle": "Knoten konfiguriert", + "nodeSuccessTitle": "Erfolgreich beigetreten!", "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.", "nodeStep1Title": "Join-Token auf dem Primary generieren", @@ -336,7 +336,15 @@ "nodeStep2Desc": "Ersetze mit dem FQDN deines Primarys und mit dem Token aus Schritt 1:", "nodeStep3Title": "API neu starten", "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": { "title": "Dashboard", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index c199427..b087d16 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -327,7 +327,7 @@ "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.", "nodeSubmit": "Configure as cluster node", - "nodeSuccessTitle": "Node configured", + "nodeSuccessTitle": "Successfully joined!", "nodeJoinTitle": "Next: join the cluster", "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", @@ -336,7 +336,15 @@ "nodeStep2Desc": "Replace with your primary's FQDN and with the token from step 1:", "nodeStep3Title": "Restart the API", "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": { "title": "Dashboard", diff --git a/management-ui/src/pages/Setup/index.tsx b/management-ui/src/pages/Setup/index.tsx index 9b5dc2c..0fb7bb9 100644 --- a/management-ui/src/pages/Setup/index.tsx +++ b/management-ui/src/pages/Setup/index.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { Alert, Button, Card, Form, Input, Space, Tag, Typography, message } from 'antd' -import { ArrowLeftOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons' +import { Alert, Button, Card, Checkbox, Form, Input, Space, Typography, message } from 'antd' +import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -19,9 +19,12 @@ interface SetupValues { license_key?: string } -interface NodeValues { +interface JoinValues { fqdn: 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,}$/ @@ -44,10 +47,11 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { const { t } = useTranslation() const navigate = useNavigate() const [mode, setMode] = useState(null) - const [nodeDone, setNodeDone] = useState(false) - const [nodeFqdn, setNodeFqdn] = useState('') + const [joinDone, setJoinDone] = useState(false) + const [loading, setLoading] = useState(false) const onFinish = async (vals: SetupValues) => { + setLoading(true) try { const normalised: SetupValues = { ...vals, @@ -61,25 +65,30 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { } catch (e: unknown) { const err = e as { message?: string } message.error(err.message ?? t('common.error')) + } finally { + setLoading(false) } } - const onFinishNode = async (vals: NodeValues) => { + const onJoin = async (vals: JoinValues) => { + setLoading(true) try { - const normalised: NodeValues = { + await apiClient.post('/setup/join-cluster', { fqdn: vals.fqdn.trim().toLowerCase(), acme_email: vals.acme_email.trim().toLowerCase(), - } - await apiClient.post('/setup/complete-node', normalised) - setNodeFqdn(normalised.fqdn) - setNodeDone(true) + primary_fqdn: vals.primary_fqdn.trim().toLowerCase(), + token: vals.token.trim(), + insecure: vals.insecure ?? false, + }) + setJoinDone(true) } catch (e: unknown) { const err = e as { message?: string } message.error(err.message ?? t('common.error')) + } finally { + setLoading(false) } } - const joinCmd = `sudo edgeguard-ctl cluster-join --token ` const restartCmd = `sudo systemctl restart edgeguard-api` return ( @@ -129,12 +138,12 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { {/* ── Standalone form ── */} {mode === 'standalone' && ( <> - - + + {t('setup.title')} - + {t('setup.intro')} @@ -196,7 +205,7 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { - @@ -204,28 +213,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { )} - {/* ── Cluster node form ── */} - {mode === 'node' && !nodeDone && ( + {/* ── Cluster node join form ── */} + {mode === 'node' && !joinDone && ( <> - - + + {t('setup.nodeTitle')} - + {t('setup.nodeIntro')} -
+ + + + + + + + + + + {t('setup.joinInsecure')} + + - @@ -256,39 +294,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) { )} - {/* ── Cluster node success ── */} - {mode === 'node' && nodeDone && ( - -
- {t('setup.nodeSuccessTitle')} - {t('setup.nodeJoinTitle')} - - {t('setup.nodeJoinDesc', { fqdn: nodeFqdn })} - -
+ {/* ── Join success ── */} + {mode === 'node' && joinDone && ( + + + +
+ {t('setup.nodeSuccessTitle')} + {t('setup.nodeSuccessDesc')} +
+
-
- 1. {t('setup.nodeStep1Title')} - - {t('setup.nodeStep1Desc')} - -
- -
- 2. {t('setup.nodeStep2Title')} - - {t('setup.nodeStep2Desc')} - - -
- -
- 3. {t('setup.nodeStep3Title')} - - {t('setup.nodeStep3Desc')} - - -
+ + {t('setup.nodeRestartDesc')} + +
+ } + />