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" ) // 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.…)") insecure := fs.Bool("insecure", false, "skip TLS verification on the primary (bootstrap mode)") cn := fs.String("cn", "", "subject common name (default: hostname)") clusterTLSDir := fs.String("tls-dir", clustertls.DefaultDir, "where to write ca.crt + peer.{crt,key}") fs.SetOutput(os.Stderr) if err := fs.Parse(args); err != nil { return 2 } if fs.NArg() < 1 { 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 { 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). } fmt.Printf("Cluster-Join erfolgreich.\n") fmt.Printf(" Primary: %s\n", endpoint) fmt.Printf(" CN: %s\n", commonName) fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir) fmt.Printf("\nNächste Schritte:\n") fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n") fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n") 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 }