Bisher pushte nur der Secondary seine Liveness an den Primary (runPrimaryPush). Der Primary pushte nichts → in der lokalen ha_nodes des Secondary fror die Primary-Row nach dem Boot ein → die vom Secondary ausgelieferte UI zeigte den Primary als offline. Neu: runPeerPush auf dem Primary/Founder pusht alle 30s self (role=primary) an jeden Peer via mTLS (/agent/cluster/peers). PushSelfToPeer(role) generalisiert PushSelfToPrimary; registerPeerRequest+AgentRegisterPeer akzeptieren ein role-Feld (default 'peer' → joining-Peer-Verhalten unverändert). Peer-Register-Log bei Routine-Pushes auf Debug (Info nur bei neuem Peer/IP-Wechsel) gegen 30s-Spam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
306 lines
9.1 KiB
Go
306 lines
9.1 KiB
Go
// 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"
|
|
"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.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
|
|
}
|
|
|
|
// 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.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
|
|
}
|