// 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" "context" "crypto/tls" "crypto/x509" "encoding/json" "errors" "fmt" "io" "log/slog" "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 // Force overwrites existing peer cert material. The CLI keeps this // false (explicit rm required); the setup-wizard handler sets it // true because the bootstrap self-signed cert must be replaced. Force 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() && !req.Force { 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: retry a few times because the primary's nftables may // need a moment to reload even though preRegisterJoiner is now // synchronous on the primary side. var autoRegErr error for i := 0; i < 3; i++ { if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil { autoRegErr = nil break } else { autoRegErr = err if i < 2 { slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err) time.Sleep(2 * time.Second) } } } if autoRegErr != nil { slog.Warn("clusterjoin: autoRegister failed after all retries — primary will reconcile via identity pull", "error", autoRegErr) } // Certs are saved; primary will reconcile via /agent/cluster/identity pull // even if autoRegister failed. Not returning the error — join is // structurally complete (certs issued), only the ha_nodes update is pending. 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.NewRequestWithContext(context.Background(), 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 func() { _ = 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 } // PushSelfToPrimary sends this node's current identity + configHash to the // primary via mTLS. Exported for use by the API server's periodic push // goroutine so the primary's ha_nodes always reflects the secondary's actual // config_hash (not the stale join-time value). func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error { return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer") } // PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS, // /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node // beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der // Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat). func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error { if tlsDir == "" { tlsDir = clustertls.DefaultDir } return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role) } func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role 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() if role == "" { role = "peer" } body, _ := json.Marshal(map[string]string{ "id": nodeID, "name": hostname, "fqdn": commonName, "api_url": "https://" + commonName + ":3443", "version": version, "config_hash": configHash, "role": role, }) 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.NewRequestWithContext(context.Background(), 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 func() { _ = 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 }