fix: enable TLS on cluster manager API (#113)

This commit is contained in:
Julian Sommer
2026-04-07 10:56:16 +10:00
committed by GitHub
parent 5598e523d5
commit 99f9949bdd
9 changed files with 184 additions and 12 deletions
+3 -3
View File
@@ -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):
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 ""
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+31 -4
View File
@@ -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)
}
+144
View File
@@ -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()
}
+1
View File
@@ -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{