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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user