diff --git a/docs/INSTALL_ORIGINAL.md b/docs/INSTALL_ORIGINAL.md index 9353408fc..856de4497 100644 --- a/docs/INSTALL_ORIGINAL.md +++ b/docs/INSTALL_ORIGINAL.md @@ -940,9 +940,9 @@ All nodes should show `Ready` status. Use `./bin/spx get vms` to see running ins Check individual daemon health endpoints: ```bash -curl -s http://$SPINIFEX_NODE1:4432/health -curl -s http://$SPINIFEX_NODE2:4432/health -curl -s http://$SPINIFEX_NODE3:4432/health +curl -sk https://$SPINIFEX_NODE1:4432/health +curl -sk https://$SPINIFEX_NODE2:4432/health +curl -sk https://$SPINIFEX_NODE3:4432/health ``` Check NATS cluster routing (from any node): diff --git a/docs/install-multi-node/README.md b/docs/install-multi-node/README.md index 749700b74..7075dd268 100644 --- a/docs/install-multi-node/README.md +++ b/docs/install-multi-node/README.md @@ -152,7 +152,7 @@ Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import a The init command must still be running when join executes. If init exited, re-run with `--force`. ```bash -curl -s http://$SPINIFEX_NODE1:4432/health +curl -sk https://$SPINIFEX_NODE1:4432/health ``` ### OVN Chassis Not Registering diff --git a/scripts/create-multi-node.sh b/scripts/create-multi-node.sh index 96b7e98f9..ab0a45b45 100755 --- a/scripts/create-multi-node.sh +++ b/scripts/create-multi-node.sh @@ -92,7 +92,7 @@ sleep 5 echo "Daemon health:" for IP in 127.0.0.1 127.0.0.2 127.0.0.3; do - echo " $IP: $(curl -s http://$IP:4432/health | head -c 100)" + echo " $IP: $(curl -sk https://$IP:4432/health | head -c 100)" done echo "" diff --git a/scripts/iac/configure-cluster.sh b/scripts/iac/configure-cluster.sh index d6a3d3362..095e25451 100755 --- a/scripts/iac/configure-cluster.sh +++ b/scripts/iac/configure-cluster.sh @@ -176,7 +176,7 @@ for i in $(seq 0 $((NODE_COUNT - 1))); do printf " node%d (%s): " "$((i + 1))" "$ip" attempts=0 while true; do - health=$(curl -s --connect-timeout 3 "http://$ip:4432/health" 2>/dev/null || echo "") + health=$(curl -sk --connect-timeout 3 "https://$ip:4432/health" 2>/dev/null || echo "") if [ -n "$health" ]; then echo "healthy" break diff --git a/scripts/iac/spinifex-test.sh b/scripts/iac/spinifex-test.sh index 18b4432f0..da39a28e3 100755 --- a/scripts/iac/spinifex-test.sh +++ b/scripts/iac/spinifex-test.sh @@ -185,7 +185,7 @@ cmd_status() { printf " %-20s mgmt=%-16s data=%-16s " "$name" "$ip" "$data_ip" local health - if health=$(curl -s --connect-timeout 3 "http://$ip:4432/health" 2>/dev/null); then + if health=$(curl -sk --connect-timeout 3 "https://$ip:4432/health" 2>/dev/null); then echo "health=$health" else echo "health=unreachable" diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index 9dc6336ab..b8da4a7f9 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -457,7 +457,7 @@ if is_multinode; then attempts=0 max_attempts=5 while [ $attempts -lt $max_attempts ]; do - if curl -s --connect-timeout 3 "http://$peer/health" > /dev/null 2>&1; then + if curl -sk --connect-timeout 3 "https://$peer/health" > /dev/null 2>&1; then echo " $peer: healthy" break fi diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 81c8bf044..e0948fc35 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/sha256" + "crypto/tls" "encoding/hex" "encoding/json" "errors" @@ -1544,21 +1545,47 @@ func (d *Daemon) ClusterManager() error { } }) - // Start HTTP server in goroutine — use a channel to propagate bind errors - // back to the caller so Start() fails visibly instead of silently. + // Load TLS certificate (C-5: serve over HTTPS instead of plaintext HTTP) + // Resolve relative cert paths against config directory (cert lives alongside spinifex.toml). + // For binary installs, systemd sets absolute paths via env vars; for dev, the config + // stores relative paths like "config/server.pem" which need resolution. + tlsCert := d.config.Daemon.TLSCert + tlsKey := d.config.Daemon.TLSKey + if tlsCert == "" || tlsKey == "" { + return fmt.Errorf("cluster manager TLS not configured: set daemon.tlscert and daemon.tlskey in config") + } + if d.configPath != "" { + configDir := filepath.Dir(d.configPath) + if !filepath.IsAbs(tlsCert) { + tlsCert = filepath.Join(configDir, filepath.Base(tlsCert)) + } + if !filepath.IsAbs(tlsKey) { + tlsKey = filepath.Join(configDir, filepath.Base(tlsKey)) + } + } + cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) + if err != nil { + return fmt.Errorf("cluster manager load TLS cert: %w", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + d.clusterServer = &http.Server{ Addr: daemonHost, Handler: r, ReadHeaderTimeout: 10 * time.Second, + TLSConfig: tlsConfig, } - ln, err := net.Listen("tcp", daemonHost) + ln, err := tls.Listen("tcp", daemonHost, tlsConfig) if err != nil { return fmt.Errorf("cluster manager listen on %s: %w", daemonHost, err) } go func() { - slog.Info("Starting cluster manager", "host", daemonHost) + slog.Info("Starting cluster manager (TLS)", "host", daemonHost) if err := d.clusterServer.Serve(ln); err != nil && err != http.ErrServerClosed { slog.Error("Cluster manager failed", "error", err) } diff --git a/spinifex/daemon/daemon_test.go b/spinifex/daemon/daemon_test.go index 223cb92ec..180c61b2f 100644 --- a/spinifex/daemon/daemon_test.go +++ b/spinifex/daemon/daemon_test.go @@ -1,9 +1,19 @@ package daemon import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" "fmt" + "math/big" "net" + "net/http" "os" "path/filepath" "runtime" @@ -4026,3 +4036,137 @@ func TestBuildDrives_CloudInitVolume(t *testing.T) { assert.Equal(t, "cdrom", d.Media) assert.Equal(t, "cloudinit", d.ID) } + +// --- ClusterManager TLS --- + +func TestClusterManager_TLSServesHTTPS(t *testing.T) { + tmpDir := t.TempDir() + + // Generate self-signed cert for testing + certPEM, keyPEM := generateTestCert(t) + certPath := filepath.Join(tmpDir, "server.pem") + keyPath := filepath.Join(tmpDir, "server.key") + require.NoError(t, os.WriteFile(certPath, certPEM, 0600)) + require.NoError(t, os.WriteFile(keyPath, keyPEM, 0600)) + + // Find a free port + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + ln.Close() + + cfg := &config.ClusterConfig{ + Node: "node-1", + Nodes: map[string]config.Config{ + "node-1": { + BaseDir: tmpDir, + Daemon: config.DaemonConfig{ + Host: addr, + TLSCert: certPath, + TLSKey: keyPath, + }, + }, + }, + } + + daemon, err := NewDaemon(cfg) + require.NoError(t, err) + daemon.configPath = filepath.Join(tmpDir, "spinifex.toml") + + err = daemon.ClusterManager() + require.NoError(t, err) + defer daemon.clusterServer.Close() + + // Plain HTTP should fail + _, err = net.DialTimeout("tcp", addr, time.Second) + require.NoError(t, err) // TCP connects, but HTTP won't work + + // HTTPS with InsecureSkipVerify should succeed + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + Timeout: 2 * time.Second, + } + resp, err := client.Get(fmt.Sprintf("https://%s/health", addr)) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestClusterManager_EmptyCertReturnsError(t *testing.T) { + cfg := &config.ClusterConfig{ + Node: "node-1", + Nodes: map[string]config.Config{ + "node-1": { + Daemon: config.DaemonConfig{ + Host: "127.0.0.1:0", + }, + }, + }, + } + + daemon, err := NewDaemon(cfg) + require.NoError(t, err) + + err = daemon.ClusterManager() + require.Error(t, err) + assert.Contains(t, err.Error(), "TLS not configured") +} + +func TestClusterManager_InvalidCertReturnsError(t *testing.T) { + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "bad.pem") + keyPath := filepath.Join(tmpDir, "bad.key") + require.NoError(t, os.WriteFile(certPath, []byte("not a cert"), 0600)) + require.NoError(t, os.WriteFile(keyPath, []byte("not a key"), 0600)) + + cfg := &config.ClusterConfig{ + Node: "node-1", + Nodes: map[string]config.Config{ + "node-1": { + Daemon: config.DaemonConfig{ + Host: "127.0.0.1:0", + TLSCert: certPath, + TLSKey: keyPath, + }, + }, + }, + } + + daemon, err := NewDaemon(cfg) + require.NoError(t, err) + + err = daemon.ClusterManager() + require.Error(t, err) + assert.Contains(t, err.Error(), "cluster manager load TLS cert") +} + +// generateTestCert creates a self-signed certificate for testing. +func generateTestCert(t *testing.T) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certBuf := &bytes.Buffer{} + require.NoError(t, pem.Encode(certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyBuf := &bytes.Buffer{} + require.NoError(t, pem.Encode(keyBuf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + return certBuf.Bytes(), keyBuf.Bytes() +} diff --git a/spinifex/formation/formation.go b/spinifex/formation/formation.go index acb3b8ab9..124820a14 100644 --- a/spinifex/formation/formation.go +++ b/spinifex/formation/formation.go @@ -239,6 +239,7 @@ func (fs *FormationServer) Shutdown(ctx context.Context) error { } func (fs *FormationServer) handleJoin(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // 1 MB limit var req JoinRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, JoinResponse{