|
|
|
|
@@ -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 <primary-fqdn-or-url> --token <token>
|
|
|
|
|
// [--insecure]
|
|
|
|
|
// [--cn <fqdn>]
|
|
|
|
|
//
|
|
|
|
|
// --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 <primary-fqdn-or-url> --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 <primary-host>: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 <primary>/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
|
|
|
|
|
}
|
|
|
|
|
|