mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
fix: enable TLS on cluster manager API (#113)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user