Merge branch 'dev' of github.com:mulgadc/spinifex into dev

This commit is contained in:
tomnewton-mulga
2026-06-15 10:29:11 +10:00
10 changed files with 257 additions and 18 deletions
+98 -1
View File
@@ -1127,6 +1127,17 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide; on a single-node init
// there are no joiners, so generate it locally and enable encryption by
// default for all volumes created on this install.
viperblockKeyPath, err := writeViperblockEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey)
@@ -1314,6 +1325,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
EncryptionKeyFile: viperblockKeyPath,
}
// Print external networking summary
@@ -1422,6 +1435,22 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide and shared: the leader
// generates it once and distributes it to joiners via the formation server
// so a volume sealed on any node can be opened on any other.
viperblockKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating viperblock encryption key: %v\n", err)
os.Exit(1)
}
viperblockKeyPath, err := saveViperblockEncryptionKey(configDir, viperblockKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error saving viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key (cluster-wide, shared with joiners)")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
if err != nil {
@@ -1450,6 +1479,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
// Include master key in formation server for distribution to joining nodes
fs.SetMasterKey(base64.StdEncoding.EncodeToString(masterKey))
// Distribute the cluster-wide viperblock encryption key to joiners
fs.SetViperblockKey(base64.StdEncoding.EncodeToString(viperblockKey))
// Register self (init node) as the first node
selfNode := formation.NodeInfo{
Name: node,
@@ -1557,6 +1589,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
// Init node runs ovn-central locally
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
@@ -1902,6 +1936,28 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Predastore encryption key generated: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide: receive it from the
// leader rather than generating one, so this node can open volumes sealed
// elsewhere. Lenient on absence — an older leader that predates key
// distribution returns nothing; fall back to cleartext rather than fail
// the join.
var viperblockKeyPath string
if statusResp.ViperblockKey == "" {
fmt.Println("⚠️ Leader did not provide a viperblock encryption key; at-rest encryption disabled on this node")
} else {
viperblockKeyBytes, err := base64.StdEncoding.DecodeString(statusResp.ViperblockKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error decoding viperblock encryption key: %v\n", err)
os.Exit(1)
}
viperblockKeyPath, err = saveViperblockEncryptionKey(configDir, viperblockKeyBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error saving viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Viperblock encryption key received from leader: %s\n", viperblockKeyPath)
}
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
@@ -1993,6 +2049,8 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
// Joining nodes connect to the init node's OVN NB/SB DB
OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP),
OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP),
@@ -2278,6 +2336,7 @@ func runCertRenew(cmd *cobra.Command, _ []string) {
type configDirs struct {
AWSGW string
Predastore string
Viperblock string
NATS string
Spinifex string
}
@@ -2287,10 +2346,11 @@ func createConfigSubdirs(configDir string) (configDirs, error) {
dirs := configDirs{
AWSGW: filepath.Join(configDir, "awsgw"),
Predastore: filepath.Join(configDir, "predastore"),
Viperblock: filepath.Join(configDir, "viperblock"),
NATS: filepath.Join(configDir, "nats"),
Spinifex: filepath.Join(configDir, "spinifex"),
}
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.NATS, dirs.Spinifex} {
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.Viperblock, dirs.NATS, dirs.Spinifex} {
if err := os.MkdirAll(dir, 0700); err != nil {
return configDirs{}, fmt.Errorf("create directory %s: %w", dir, err)
}
@@ -2412,6 +2472,43 @@ func writePredastoreEncryptionKey(configDir string) (string, error) {
return keyPath, nil
}
// writeViperblockEncryptionKey generates a fresh 32-byte AES-256 master key for
// viperblock at-rest encryption and writes it to
// <configDir>/viperblock/encryption.key with mode 0600. Unlike the predastore
// key, this key is cluster-wide and shared: the leader generates it once at init
// and distributes it to joiners via the formation server, so a volume sealed on
// any node can be opened on any other. Loaded at startup via masterkey.LoadShared.
func writeViperblockEncryptionKey(configDir string) (string, error) {
viperblockDir := filepath.Join(configDir, "viperblock")
if err := os.MkdirAll(viperblockDir, 0750); err != nil {
return "", fmt.Errorf("create viperblock config dir: %w", err)
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate viperblock encryption key: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
}
// saveViperblockEncryptionKey writes an already-generated 32-byte viperblock
// master key (received from the formation leader) to
// <configDir>/viperblock/encryption.key with mode 0600.
func saveViperblockEncryptionKey(configDir string, key []byte) (string, error) {
viperblockDir := filepath.Join(configDir, "viperblock")
if err := os.MkdirAll(viperblockDir, 0750); err != nil {
return "", fmt.Errorf("create viperblock config dir: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
}
// writeSystemCredentials writes the system access key to a plaintext JSON file.
// The daemon reads this at startup to inject credentials into ALB VM cloud-init
// for SigV4-authenticated communication with the AWS gateway.
+68
View File
@@ -252,6 +252,74 @@ func TestWritePredastoreEncryptionKey(t *testing.T) {
assert.NotEqual(t, key1, key2, "per-node keys must differ")
}
// The viperblock key gates at-rest encryption for every volume on a fresh
// install. masterkey.LoadShared rejects anything that isn't exactly 32 bytes at
// 0640-or-stricter, so a regression in size/mode would silently fall back to
// cleartext (empty path) or fail service startup.
func TestWriteViperblockEncryptionKey(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writeViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, int64(32), info.Size(), "viperblock master key must be exactly 32 bytes")
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "viperblock master key must be mode 0600")
}
// Joiners persist the leader's shared key via saveViperblockEncryptionKey
// against a configDir with no viperblock/ subdir yet. The bytes round-trip
// verbatim — the whole cluster shares one key, so any mutation would orphan
// volumes sealed on other nodes.
func TestSaveViperblockEncryptionKey_RoundTrip(t *testing.T) {
configDir := t.TempDir()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
keyPath, err := saveViperblockEncryptionKey(configDir, key)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
got, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, key, got, "shared key must be written verbatim")
}
// A fresh install must render encryption_key_file so viperblockd enables
// at-rest encryption; an empty field (upgrade / legacy) must omit the line
// entirely so the volume stays in cleartext legacy mode.
func TestSpinifexTomlTemplate_EncryptionKeyFile(t *testing.T) {
base := admin.ConfigSettings{
Node: "node1", Az: "ap-southeast-2a", Port: "4432", Region: "ap-southeast-2",
BindIP: "0.0.0.0", AccessKey: "AKIATEST", SecretKey: "SECRET",
AccountID: "123456789012", NatsToken: "token",
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
}
dir := t.TempDir()
withKey := base
withKey.ConfigDir = dir
withKey.EncryptionKeyFile = "/etc/spinifex/viperblock/encryption.key"
path := filepath.Join(dir, "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, withKey))
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), `encryption_key_file = "/etc/spinifex/viperblock/encryption.key"`)
dir2 := t.TempDir()
noKey := base
noKey.ConfigDir = dir2
path2 := filepath.Join(dir2, "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path2, spinifexTomlTemplate, noKey))
data2, err := os.ReadFile(path2)
require.NoError(t, err)
assert.NotContains(t, string(data2), "encryption_key_file", "blank key must omit the field")
}
// Joiners run `spx admin join` against a configDir that doesn't yet contain
// a predastore/ subdir. The helper must create it (predastore subdir is
// owned by spinifex-storage in prod, but the helper just needs the dir to
+3
View File
@@ -93,6 +93,9 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
{{- if .EncryptionKeyFile}}
encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
+8
View File
@@ -416,6 +416,14 @@ TMPEOF
$SUDO chown "spinifex-gw:$SPINIFEX_GROUP" /etc/spinifex/awsgw
$SUDO chmod 0750 /etc/spinifex/awsgw
# Viperblock's at-rest encryption key dir. 0750 group-traversable; the key
# itself is set to root:spinifex 0640 by SetServiceOwnership because both
# viperblockd (spinifex-viperblock) and the awsgw handlers (spinifex-gw)
# load it.
$SUDO mkdir -p /etc/spinifex/viperblock
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /etc/spinifex/viperblock
$SUDO chmod 0750 /etc/spinifex/viperblock
# Per-service data directories
$SUDO mkdir -p /var/lib/spinifex/nats
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
+13 -5
View File
@@ -104,6 +104,12 @@ type ConfigSettings struct {
// IPSecEnabled toggles cluster-wide OVN native IPsec on intra-AZ Geneve.
// Written under [network] in spinifex.toml; daemon reads it via cluster config.
IPSecEnabled bool
// EncryptionKeyFile is the path to the cluster-wide viperblock at-rest
// encryption key, rendered into [nodes.X.viperblock].encryption_key_file.
// Empty means no key was provisioned and volumes are written cleartext
// (legacy mode); the template omits the field entirely in that case.
EncryptionKeyFile string
}
// PredastoreNodeConfig describes a single Predastore node for multi-node config generation.
@@ -308,6 +314,7 @@ func SetServiceOwnership() {
"/var/lib/spinifex/nats": "spinifex-nats",
"/etc/spinifex/predastore": "spinifex-storage",
"/var/lib/spinifex/predastore": "spinifex-storage",
"/etc/spinifex/viperblock": "spinifex-viperblock",
"/var/lib/spinifex/spinifex": "spinifex-daemon",
"/var/lib/spinifex/viperblock": "spinifex-viperblock",
"/var/lib/spinifex/vpcd": "spinifex-vpcd",
@@ -342,11 +349,12 @@ func SetServiceOwnership() {
// bootstrap.json lives in the awsgw data dir (not /etc/spinifex),
// so /etc/spinifex stays at 0750 (no group-write needed).
for path, mode := range map[string]os.FileMode{
"/etc/spinifex/spinifex.toml": 0640,
"/etc/spinifex/master.key": 0640,
"/etc/spinifex/server.pem": 0644,
"/etc/spinifex/server.key": 0640,
"/etc/spinifex/ca.pem": 0644,
"/etc/spinifex/spinifex.toml": 0640,
"/etc/spinifex/master.key": 0640,
"/etc/spinifex/viperblock/encryption.key": 0640,
"/etc/spinifex/server.pem": 0644,
"/etc/spinifex/server.key": 0640,
"/etc/spinifex/ca.pem": 0644,
} {
if _, err := os.Stat(path); err != nil {
continue
+2 -2
View File
@@ -318,7 +318,7 @@ func readAMIConfig(store objectstore.ObjectStore, bucket, imageID string) (viper
}
var state viperblock.VBState
if err := json.Unmarshal(body, &state); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &state); err != nil {
return viperblock.AMIMetadata{}, fmt.Errorf("%w: %s: %v", handlers_ec2_image.ErrCorruptAMIConfig, key, err)
}
return state.VolumeConfig.AMIMetadata, nil
@@ -350,7 +350,7 @@ func readVolumeConfig(store objectstore.ObjectStore, bucket, volumeID string) (*
}
var w volumeConfigWrapper
if err := json.Unmarshal(body, &w); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &w); err != nil {
return nil, fmt.Errorf("%w: %s: %v", errCorruptVolumeConfig, key, err)
}
return &w.VolumeConfig, nil
+13
View File
@@ -55,6 +55,7 @@ type StatusResponse struct {
CACert string `json:"ca_cert,omitempty"`
CAKey string `json:"ca_key,omitempty"`
MasterKey string `json:"master_key,omitempty"`
ViperblockKey string `json:"viperblock_key,omitempty"`
NetworkConfig *NetworkConfig `json:"network_config,omitempty"`
}
@@ -110,6 +111,7 @@ type FormationServer struct {
caCert string
caKey string
masterKey string
viperblockKey string
networkConfig *NetworkConfig
joinToken string
tokenExpiry time.Time
@@ -218,6 +220,16 @@ func (fs *FormationServer) SetMasterKey(key string) {
fs.masterKey = key
}
// SetViperblockKey sets the base64-encoded cluster-wide viperblock at-rest
// encryption key for distribution to joining nodes. Unlike the per-node
// predastore key, this key is shared so a volume sealed on one node can be
// opened on any other.
func (fs *FormationServer) SetViperblockKey(key string) {
fs.mu.Lock()
defer fs.mu.Unlock()
fs.viperblockKey = key
}
// Start launches the HTTPS server on the given address (e.g. "10.0.0.1:4432")
// using the cluster CA certificate and key for TLS.
func (fs *FormationServer) Start(bindAddr string) error {
@@ -360,6 +372,7 @@ func (fs *FormationServer) handleStatus(w http.ResponseWriter, r *http.Request)
resp.CACert = fs.caCert
resp.CAKey = fs.caKey
resp.MasterKey = fs.masterKey
resp.ViperblockKey = fs.viperblockKey
resp.NetworkConfig = fs.networkConfig
}
+25
View File
@@ -346,6 +346,31 @@ func TestStatusEndpointMasterKey(t *testing.T) {
assert.Equal(t, "dGVzdC1tYXN0ZXIta2V5LWJhc2U2NC1lbmNvZGVk", sr.MasterKey)
}
func TestStatusEndpointViperblockKey(t *testing.T) {
t.Parallel()
fs := NewFormationServer(2, testCreds(), "ca-cert", "ca-key", nil, testToken, testTokenTTL)
fs.SetViperblockKey("dmlwZXJibG9jay1zaGFyZWQta2V5LWJhc2U2NA==")
ts := testServer(t, fs)
defer ts.Close()
// Before completion: shared key must not leak
resp := authGet(t, ts.URL+"/formation/status", testToken)
var sr StatusResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
resp.Body.Close()
assert.Empty(t, sr.ViperblockKey)
require.NoError(t, fs.RegisterNode(testNode("node1", "10.0.0.1")))
require.NoError(t, fs.RegisterNode(testNode("node2", "10.0.0.2")))
// After completion: joiners receive the cluster-wide key
resp = authGet(t, ts.URL+"/formation/status", testToken)
defer resp.Body.Close()
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sr))
assert.True(t, sr.Complete)
assert.Equal(t, "dmlwZXJibG9jay1zaGFyZWQta2V5LWJhc2U2NA==", sr.ViperblockKey)
}
func TestHealthEndpoint_NoAuth(t *testing.T) {
t.Parallel()
fs := NewFormationServer(1, testCreds(), "", "", nil, testToken, testTokenTTL)
+20 -7
View File
@@ -170,11 +170,13 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
continue
}
// Parse the viperblock config which contains VolumeConfig with AMIMetadata
// Parse the viperblock config which contains VolumeConfig with AMIMetadata.
// StateBody unwraps the at-rest encryption envelope (the metadata ships
// authenticated-but-plaintext); it is a no-op for unencrypted volumes.
var vbConfig struct {
VolumeConfig viperblock.VolumeConfig `json:"VolumeConfig"`
}
if err := json.Unmarshal(body, &vbConfig); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &vbConfig); err != nil {
slog.Debug("Failed to unmarshal config", "key", configKey, "err", err)
continue
}
@@ -580,8 +582,12 @@ func (s *ImageServiceImpl) getVolumeConfig(volumeID string) (*viperblock.VolumeC
}
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
return nil, err
}
var vbState viperblock.VBState
if err := json.NewDecoder(result.Body).Decode(&vbState); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &vbState); err != nil {
return nil, err
}
return &vbState.VolumeConfig, nil
@@ -616,10 +622,13 @@ func (s *ImageServiceImpl) amiNameExists(name string) (bool, error) {
return false, fmt.Errorf("amiNameExists: failed to read %s: %w", configKey, err)
}
var vbState viperblock.VBState
decodeErr := json.NewDecoder(result.Body).Decode(&vbState)
body, readErr := io.ReadAll(result.Body)
_ = result.Body.Close()
if decodeErr != nil {
if readErr != nil {
return false, fmt.Errorf("amiNameExists: failed to read %s: %w", configKey, readErr)
}
var vbState viperblock.VBState
if decodeErr := json.Unmarshal(viperblock.StateBody(body), &vbState); decodeErr != nil {
return false, fmt.Errorf("amiNameExists: failed to decode %s: %w", configKey, decodeErr)
}
@@ -649,8 +658,12 @@ func (s *ImageServiceImpl) GetAMIConfig(imageID string) (viperblock.AMIMetadata,
}
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
return viperblock.AMIMetadata{}, fmt.Errorf("%w: %s: %v", ErrCorruptAMIConfig, configKey, err)
}
var vbState viperblock.VBState
if err := json.NewDecoder(result.Body).Decode(&vbState); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &vbState); err != nil {
return viperblock.AMIMetadata{}, fmt.Errorf("%w: %s: %v", ErrCorruptAMIConfig, configKey, err)
}
return vbState.VolumeConfig.AMIMetadata, nil
+7 -3
View File
@@ -1000,6 +1000,9 @@ func (s *VolumeServiceImpl) getVolumeConfigAndEncryption(volumeID string) (*vipe
if err != nil {
return nil, false, fmt.Errorf("failed to read config body: %w", err)
}
// Unwrap the at-rest encryption envelope (authenticated-but-plaintext
// metadata); no-op for unencrypted volumes.
body = viperblock.StateBody(body)
// Try full VBState first (matches mergeVolumeConfig's careful decode
// pattern). A populated BlockSize is the marker that the blob is a full
@@ -1062,10 +1065,11 @@ func (s *VolumeServiceImpl) mergeVolumeConfig(configKey string, cfg *viperblock.
return nil, fmt.Errorf("failed to read existing config: %w", err)
}
// Use Decoder (not Unmarshal): a trailing AES-GCM tag must not cause a parse error
// that would silently route to the wrapper-only path and brick the volume.
// StateBody unwraps the at-rest encryption envelope so the guard below sees
// the real VBState. Without it the wrapper decodes to a zero-valued state
// (BlockSize==0), routing to the wrapper-only path and bricking the volume.
var state viperblock.VBState
if decodeErr := json.NewDecoder(bytes.NewReader(body)).Decode(&state); decodeErr != nil || state.BlockSize == 0 {
if decodeErr := json.Unmarshal(viperblock.StateBody(body), &state); decodeErr != nil || state.BlockSize == 0 {
// Not a full VBState (new volume or wrapper-only) -- write wrapper
return json.Marshal(volumeConfigWrapper{VolumeConfig: *cfg})
}