fix: admin init --force preserves keys, credentials and CA (#536)

This commit is contained in:
Julian Sommer
2026-07-14 14:46:12 +10:00
committed by GitHub
parent 2b44de27bc
commit 744cb1929d
5 changed files with 410 additions and 152 deletions
+151 -108
View File
@@ -1267,66 +1267,91 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Created config directory: %s\n", configDir)
// Generate system credentials (for service-to-service auth in config files)
accessKey, err := admin.GenerateAWSAccessKey()
// Identity and crypto material is load-or-generate: a fresh install mints a
// new identity bundle, but a --force re-init preserves the existing one so
// data sealed under it (NATS KV secrets, sealed fragments, encrypted volumes)
// stays decryptable.
masterKey, masterKeyExisted, err := ensureMasterKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err)
os.Exit(1)
}
secretKey, err := admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing IAM master key: %v\n", err)
os.Exit(1)
}
accountID := admin.SystemAccountID()
// Generate IAM master key (AES-256, used to encrypt secrets in NATS KV)
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
if err := writeSystemCredentials(configDir, accessKey, secretKey); err != nil {
fmt.Fprintf(os.Stderr, "Error writing system credentials: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf(" System creds: %s\n", filepath.Join(configDir, "system-credentials.json"))
// Predastore encryption key is per-node and never transmitted; generate
// it locally now so the service has it on first start.
var accessKey, secretKey, adminAccessKey, adminSecretKey string
if masterKeyExisted {
// Preserve path: reuse the existing identity. The system credentials must
// match what seeded the NATS KV `system` secret, so load them rather than
// mint new ones. The admin credentials are not recovered: bootstrap.json is
// consumed and deleted by awsgw after first boot, and the operator's copy
// already lives in ~/.aws/credentials. Leaving them empty makes
// finalizeNodeSetup refresh only ~/.aws/config (endpoint/CA for a changed
// bind IP), not the credentials.
accessKey, secretKey, err = loadSystemCredentials(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading preserved system credentials: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Preserved existing identity (master key, credentials and CA unchanged)")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
} else {
// Fresh install: mint system + admin credentials and seed the bootstrap files.
accessKey, err = admin.GenerateAWSAccessKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating access key: %v\n", err)
os.Exit(1)
}
secretKey, err = admin.GenerateAWSSecretKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating secret key: %v\n", err)
os.Exit(1)
}
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
if err := writeSystemCredentials(configDir, accessKey, secretKey); err != nil {
fmt.Fprintf(os.Stderr, "Error writing system credentials: %v\n", err)
os.Exit(1)
}
adminAccessKey = bootstrapResult.AdminAccessKey
adminSecretKey = bootstrapResult.AdminSecretKey
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf(" System creds: %s\n", filepath.Join(configDir, "system-credentials.json"))
}
// Predastore encryption key is per-node and never transmitted; load-or-generate
// so the service has it on first start and a re-init keeps sealed fragments.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating predastore encryption key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Println("\n🔐 Predastore encryption key ready (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)
// Viperblock at-rest encryption key is cluster-wide; load-or-generate so a
// re-init keeps the existing key (and its sealed volumes). The bytes feed the
// multi-node leader's key distribution below.
viperblockKey, viperblockKeyPath, err := ensureViperblockEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating viperblock encryption key: %v\n", err)
fmt.Fprintf(os.Stderr, "Error preparing viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key")
fmt.Println("\n🔐 Viperblock at-rest encryption key ready")
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)
fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID())
fmt.Printf(" AWS Profile: spinifex\n")
if !masterKeyExisted {
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", adminAccessKey)
fmt.Printf(" Secret Key: %s\n", adminSecretKey)
fmt.Printf(" Account: %s (%s)\n", admin.DefaultAccountName(), admin.DefaultAccountID())
fmt.Printf(" AWS Profile: spinifex\n")
}
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSInternalSuffix)
@@ -1392,7 +1417,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
networkConfig.BootstrapSubnetCidr = handlers_ec2_vpc.DefaultSubnetCidr
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey,
masterKey, viperblockKey, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, advertiseIP, clusterBind, email,
port, nodes, formationTimeoutStr, tokenTTLStr, services, networkConfig)
return
@@ -1544,7 +1570,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
finalizeNodeSetup(spxRoot, certPath, bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, bindIP)
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
// Write node.conf so spx admin banner works on source installs (not just ISO).
nodeHostname, _ := os.Hostname()
@@ -1575,7 +1601,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// runAdminInitMultiNode handles the multi-node formation path for admin init.
// It starts a formation server, registers this node, waits for all nodes to join,
// then generates configs with complete cluster topology.
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, natsToken, clusterName,
func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string,
masterKey, viperblockKey []byte, natsToken, clusterName,
configDir, spxRoot, certPath, region, az, node, bindIP, advertiseIP, clusterBind, email string,
port, expectedNodes int, formationTimeoutStr, tokenTTLStr string, services []string, networkConfig *formation.NetworkConfig) {
formationTimeout, err := time.ParseDuration(formationTimeoutStr)
@@ -1602,47 +1629,11 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
// Generate IAM master key for the cluster
masterKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
os.Exit(1)
}
bootstrapDir := filepath.Join(spxRoot, "awsgw")
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Predastore encryption key is per-node and never distributed via the
// formation server. Generate only the leader's own key here; each
// joiner generates its own during `spx admin join`.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating predastore encryption key: %v\n", err)
os.Exit(1)
}
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)
// Identity, predastore and viperblock keys were prepared load-or-generate by
// the caller: reuse them here so a re-init never rotates cluster crypto and
// the leader distributes the same viperblock key to joiners. The path is
// deterministic and already written on disk.
viperblockKeyPath := filepath.Join(configDir, "viperblock", "encryption.key")
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
@@ -1663,8 +1654,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
NatsToken: natsToken,
ClusterName: clusterName,
Region: region,
AdminAccessKey: bootstrapResult.AdminAccessKey,
AdminSecretKey: bootstrapResult.AdminSecretKey,
AdminAccessKey: adminAccessKey,
AdminSecretKey: adminSecretKey,
}
fs := formation.NewFormationServer(expectedNodes, creds, string(caCertData), string(caKeyData), networkConfig, joinToken, tokenTTL)
@@ -1801,7 +1792,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
finalizeNodeSetup(spxRoot, certPath, bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, bindIP)
finalizeNodeSetup(spxRoot, certPath, adminAccessKey, adminSecretKey, region, bindIP)
// Keep formation server running briefly so joining nodes can fetch complete status
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
@@ -2625,6 +2616,48 @@ type writeBootstrapResult struct {
AdminSecretKey string
}
// ensureMasterKey load-or-generates the IAM master key at <configDir>/master.key.
// It returns the key bytes and whether the key already existed on disk. On a
// --force re-init the existing key is preserved: rotating it would orphan every
// IAM secret in NATS KV (e.g. the ECR signing key) that was encrypted under it.
func ensureMasterKey(configDir string) (key []byte, existed bool, err error) {
keyPath := filepath.Join(configDir, "master.key")
if admin.FileExists(keyPath) {
key, err = handlers_iam.LoadMasterKey(keyPath)
if err != nil {
return nil, false, fmt.Errorf("load master key: %w", err)
}
return key, true, nil
}
key, err = handlers_iam.GenerateMasterKey()
if err != nil {
return nil, false, fmt.Errorf("generate master key: %w", err)
}
return key, false, nil
}
// loadSystemCredentials reads the preserved system access/secret key from
// <configDir>/system-credentials.json. On a re-init the configs must render the
// same system credentials that seeded the NATS KV `system` secret, else SigV4
// auth between services fails.
func loadSystemCredentials(configDir string) (accessKey, secretKey string, err error) {
data, err := os.ReadFile(filepath.Join(configDir, "system-credentials.json"))
if err != nil {
return "", "", fmt.Errorf("read system credentials: %w", err)
}
var creds struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return "", "", fmt.Errorf("parse system credentials: %w", err)
}
if creds.AccessKeyID == "" || creds.SecretAccessKey == "" {
return "", "", fmt.Errorf("system credentials missing access/secret key")
}
return creds.AccessKeyID, creds.SecretAccessKey, nil
}
// writeBootstrapFiles generates new admin credentials and writes the bootstrap
// files (master.key to configDir, bootstrap.json to bootstrapDir).
// Used by init flows (single and multi-node).
@@ -2646,47 +2679,57 @@ func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, acces
}, nil
}
// writePredastoreEncryptionKey generates a fresh 32-byte AES-256 master key
// for this node's predastore data dir and writes it to
// <configDir>/predastore/encryption.key with mode 0600. The key is per-node:
// every node generates its own at init/join time and never transmits it,
// because predastore only opens fragments on the node that sealed them.
// writePredastoreEncryptionKey load-or-generates this node's per-node predastore
// key at <configDir>/predastore/encryption.key (mode 0600). The key is per-node:
// every node generates its own at init/join time and never transmits it, because
// predastore only opens fragments on the node that sealed them. An existing key
// is preserved so a --force re-init does not orphan already-sealed fragments.
func writePredastoreEncryptionKey(configDir string) (string, error) {
predastoreDir := filepath.Join(configDir, "predastore")
if err := os.MkdirAll(predastoreDir, 0750); err != nil {
return "", fmt.Errorf("create predastore config dir: %w", err)
}
keyPath := filepath.Join(predastoreDir, "encryption.key")
if admin.FileExists(keyPath) {
return keyPath, nil
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate predastore encryption key: %w", err)
}
keyPath := filepath.Join(predastoreDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save predastore encryption key: %w", err)
}
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) {
// ensureViperblockEncryptionKey load-or-generates the cluster-wide viperblock
// at-rest key at <configDir>/viperblock/encryption.key (mode 0600) and returns
// its bytes and path. Unlike the predastore key it is shared: the leader loads
// these bytes to distribute the same key to joiners via the formation server, so
// a volume sealed on any node can be opened on any other. An existing key is
// preserved so a --force re-init does not orphan already-encrypted volumes.
func ensureViperblockEncryptionKey(configDir string) ([]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)
return nil, "", fmt.Errorf("create viperblock config dir: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if admin.FileExists(keyPath) {
key, err := handlers_iam.LoadMasterKey(keyPath)
if err != nil {
return nil, "", fmt.Errorf("load viperblock encryption key: %w", err)
}
return key, keyPath, nil
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate viperblock encryption key: %w", err)
return nil, "", 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 nil, "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
return key, keyPath, nil
}
// saveViperblockEncryptionKey writes an already-generated 32-byte viperblock
+176 -17
View File
@@ -11,6 +11,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/formation"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/utils"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
@@ -255,23 +256,6 @@ 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
@@ -292,6 +276,181 @@ func TestSaveViperblockEncryptionKey_RoundTrip(t *testing.T) {
assert.Equal(t, key, got, "shared key must be written verbatim")
}
// A re-init must not rotate the per-node predastore key: rotating it would orphan
// every fragment already sealed under the old key. The helper preserves an
// existing key byte-for-byte.
func TestWritePredastoreEncryptionKey_PreservesExisting(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
orig, err := os.ReadFile(keyPath)
require.NoError(t, err)
keyPath2, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, keyPath, keyPath2)
after, err := os.ReadFile(keyPath2)
require.NoError(t, err)
assert.Equal(t, orig, after, "existing predastore key must be preserved on re-init")
}
// ensureViperblockEncryptionKey generates a shared 32-byte 0600 key on a fresh
// dir and returns bytes that match the on-disk file; a second call preserves it
// so a re-init keeps every encrypted volume readable.
func TestEnsureViperblockEncryptionKey(t *testing.T) {
configDir := t.TempDir()
key, keyPath, err := ensureViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
assert.Len(t, key, 32, "viperblock key must be 32 bytes")
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")
onDisk, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, key, onDisk, "returned bytes must match the file")
key2, _, err := ensureViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, key, key2, "existing viperblock key must be preserved on re-init")
}
// A present-but-corrupt viperblock key must fail loud rather than regenerate,
// which would orphan every already-encrypted volume in the cluster.
func TestEnsureViperblockEncryptionKey_CorruptFailsLoud(t *testing.T) {
configDir := t.TempDir()
keyDir := filepath.Join(configDir, "viperblock")
require.NoError(t, os.MkdirAll(keyDir, 0750))
keyPath := filepath.Join(keyDir, "encryption.key")
require.NoError(t, os.WriteFile(keyPath, []byte("bad"), 0600))
_, _, err := ensureViperblockEncryptionKey(configDir)
require.Error(t, err, "a truncated viperblock key must error, not regenerate")
after, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, []byte("bad"), after, "corrupt viperblock key must not be overwritten")
}
// ensureMasterKey generates a fresh in-memory key without touching disk (the
// bootstrap writer persists it), and preserves an existing on-disk key so a
// re-init never rotates the key that encrypts IAM secrets in NATS KV.
func TestEnsureMasterKey(t *testing.T) {
t.Run("FreshGeneratesWithoutWriting", func(t *testing.T) {
configDir := t.TempDir()
key, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.False(t, existed, "no master.key present yet")
assert.Len(t, key, 32)
assert.NoFileExists(t, filepath.Join(configDir, "master.key"),
"ensureMasterKey must not persist on the fresh path")
})
t.Run("PreservesExisting", func(t *testing.T) {
configDir := t.TempDir()
seed := make([]byte, 32)
for i := range seed {
seed[i] = byte(200 - i)
}
require.NoError(t, handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), seed))
key, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.True(t, existed, "existing master.key must be detected")
assert.Equal(t, seed, key, "existing master key must be loaded verbatim")
})
// A present-but-corrupt master.key must fail loud, never be silently
// regenerated: rotating it would orphan every IAM secret in NATS KV.
t.Run("CorruptFailsLoud", func(t *testing.T) {
configDir := t.TempDir()
keyPath := filepath.Join(configDir, "master.key")
require.NoError(t, os.WriteFile(keyPath, []byte("too-short"), 0600))
_, _, err := ensureMasterKey(configDir)
require.Error(t, err, "a truncated master.key must error, not regenerate")
after, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, []byte("too-short"), after, "corrupt master key must not be overwritten")
})
}
// The identity bundle is preserved holistically on re-init: the load helpers
// recover the exact system credentials and never rewrite bootstrap.json. Admin
// credentials are not recovered from disk — awsgw consumes and deletes
// bootstrap.json after first boot, so the preserve path must not read it back.
func TestPreservedIdentityBundle(t *testing.T) {
configDir := t.TempDir()
bootstrapDir := filepath.Join(configDir, "awsgw")
masterKey, err := handlers_iam.GenerateMasterKey()
require.NoError(t, err)
const (
sysAccess = "AKIASYSTEM0000000000"
sysSecret = "system-secret-value"
adminAccess = "AKIAADMIN00000000000"
adminSecret = "admin-secret-value"
accountID = "123456789012"
)
require.NoError(t, writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKey,
sysAccess, sysSecret, accountID, adminAccess, adminSecret))
require.NoError(t, writeSystemCredentials(configDir, sysAccess, sysSecret))
bootstrapPath := filepath.Join(bootstrapDir, "bootstrap.json")
bootstrapBefore, err := os.ReadFile(bootstrapPath)
require.NoError(t, err)
// master.key round-trips and is flagged as pre-existing.
loadedKey, existed, err := ensureMasterKey(configDir)
require.NoError(t, err)
assert.True(t, existed)
assert.Equal(t, masterKey, loadedKey)
// System credentials come back verbatim (they must match the NATS KV seed).
gotSysAccess, gotSysSecret, err := loadSystemCredentials(configDir)
require.NoError(t, err)
assert.Equal(t, sysAccess, gotSysAccess)
assert.Equal(t, sysSecret, gotSysSecret)
// The preserve helpers must not rewrite the seed file.
bootstrapAfter, err := os.ReadFile(bootstrapPath)
require.NoError(t, err)
assert.Equal(t, bootstrapBefore, bootstrapAfter, "bootstrap.json must not be rewritten on re-init")
}
// loadSystemCredentials surfaces a clear error when the file is absent rather
// than returning empty credentials that would silently break SigV4 auth.
func TestLoadSystemCredentials_Missing(t *testing.T) {
_, _, err := loadSystemCredentials(t.TempDir())
require.Error(t, err)
}
// A present-but-empty or malformed system-credentials.json must error rather
// than yield blank credentials that would silently break SigV4 between services.
func TestLoadSystemCredentials_Invalid(t *testing.T) {
t.Run("EmptyValues", func(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "system-credentials.json"),
[]byte(`{"access_key_id":"","secret_access_key":""}`), 0600))
_, _, err := loadSystemCredentials(configDir)
require.Error(t, err)
})
t.Run("MalformedJSON", func(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "system-credentials.json"),
[]byte("not json"), 0600))
_, _, err := loadSystemCredentials(configDir)
require.Error(t, err)
})
}
// 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.
+1 -1
View File
@@ -54,7 +54,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--no-external` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--no-external` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/`. `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
### Version
+31 -23
View File
@@ -185,20 +185,19 @@ func AWSGWServiceDNSNames(region, suffix string) []string {
}
}
// GenerateCertificatesIfNeeded prepares the TLS material for a node. The CA is
// preserved across re-inits — it anchors trust for already-joined nodes, IPsec
// peer certs, and CA-baked AMIs — so it is regenerated only when absent, never on
// force. The CA-signed server cert is cheap to reissue, so force refreshes it to
// pick up a changed bind IP / SANs while keeping the CA (and all trust) intact.
func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string, awsRegion, internalSuffix string) (caCertPath string) {
caCertPath = filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
serverCertPath := filepath.Join(configDir, "server.pem")
serverKeyPath := filepath.Join(configDir, "server.key")
// Check if we need to generate certificates
needsGeneration := force ||
!FileExists(caCertPath) || !FileExists(caKeyPath) ||
!FileExists(serverCertPath) || !FileExists(serverKeyPath)
if needsGeneration {
fmt.Println("\n🔐 Generating Certificate Authority and SSL certificates...")
if !FileExists(caCertPath) || !FileExists(caKeyPath) {
fmt.Println("\n🔐 Generating Certificate Authority...")
if err := GenerateCACert(caCertPath, caKeyPath); err != nil {
fmt.Fprintf(os.Stderr, "Error generating CA certificate: %v\n", err)
os.Exit(1)
@@ -207,15 +206,6 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string, a
fmt.Printf(" CA Certificate: %s\n", caCertPath)
fmt.Printf(" CA Key: %s\n", caKeyPath)
extraDNS := AWSGWServiceDNSNames(awsRegion, internalSuffix)
if err := GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, []string{bindIP}, extraDNS); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Server certificate generated (signed by CA):\n")
fmt.Printf(" Certificate: %s\n", serverCertPath)
fmt.Printf(" Key: %s\n", serverKeyPath)
// Print manual instructions only when not root (root gets auto-install)
if os.Getuid() != 0 {
fmt.Println("\n📋 To trust the Spinifex CA system-wide (recommended):")
@@ -224,7 +214,20 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string, a
fmt.Println("\n This allows AWS CLI and other tools to trust Spinifex services automatically.")
}
} else {
fmt.Println("\n✅ CA and SSL certificates already exist")
fmt.Println("\n✅ Certificate Authority already exists (preserved)")
}
if force || !FileExists(serverCertPath) || !FileExists(serverKeyPath) {
extraDNS := AWSGWServiceDNSNames(awsRegion, internalSuffix)
if err := GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, []string{bindIP}, extraDNS); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Server certificate generated (signed by CA):\n")
fmt.Printf(" Certificate: %s\n", serverCertPath)
fmt.Printf(" Key: %s\n", serverKeyPath)
} else {
fmt.Println("✅ Server certificate already exists")
}
return caCertPath
@@ -786,11 +789,16 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
profileName := "spinifex"
if err := UpdateAWSINIFile(credPath, profileName, map[string]string{
"aws_access_key_id": accessKey,
"aws_secret_access_key": secretKey,
}); err != nil {
return err
// Empty credentials mean a --force re-init preserving the existing identity:
// the admin secret is not recoverable from disk and is unchanged, so leave the
// credentials file as-is and refresh only the config (endpoint/CA/region).
if accessKey != "" && secretKey != "" {
if err := UpdateAWSINIFile(credPath, profileName, map[string]string{
"aws_access_key_id": accessKey,
"aws_secret_access_key": secretKey,
}); err != nil {
return err
}
}
configSection := profileName
+51 -3
View File
@@ -277,6 +277,32 @@ func TestSetupAWSCredentials_FallsBackToLocalhostForWildcard(t *testing.T) {
assert.Contains(t, string(configData), "https://localhost:9999")
}
// On a --force re-init the preserve path passes empty admin credentials: the
// existing ~/.aws/credentials must be left intact while ~/.aws/config is still
// refreshed (endpoint/CA for a changed bind IP).
func TestSetupAWSCredentials_EmptyCredsRefreshesConfigOnly(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
awsDir := filepath.Join(dir, ".aws")
require.NoError(t, os.MkdirAll(awsDir, 0700))
require.NoError(t, UpdateAWSINIFile(filepath.Join(awsDir, "credentials"), "spinifex", map[string]string{
"aws_access_key_id": "PRESERVED_KEY",
"aws_secret_access_key": "PRESERVED_SECRET",
}))
err := SetupAWSCredentials("", "", "ap-southeast-2", "/new/ca.pem", "10.11.12.5")
require.NoError(t, err)
credData, _ := os.ReadFile(filepath.Join(awsDir, "credentials"))
assert.Contains(t, string(credData), "PRESERVED_KEY", "existing admin credentials must be preserved on re-init")
assert.Contains(t, string(credData), "PRESERVED_SECRET")
configData, _ := os.ReadFile(filepath.Join(awsDir, "config"))
assert.Contains(t, string(configData), "https://10.11.12.5:9999", "config endpoint must be refreshed")
assert.Contains(t, string(configData), "/new/ca.pem")
}
// --- Certificate generation ---
func TestGenerateCACert_CreatesValidCA(t *testing.T) {
@@ -617,12 +643,34 @@ func TestGenerateCertificatesIfNeeded(t *testing.T) {
assert.Equal(t, origModTime, caInfo2.ModTime())
})
t.Run("ForceRegenerates", func(t *testing.T) {
// --force must preserve the CA (trust anchor for joined nodes / baked AMIs)
// and only re-sign the server cert, which stays verifiable against that CA.
t.Run("ForcePreservesCARegeneratesServerCert", func(t *testing.T) {
origCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem"))
origCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key"))
origServer, _ := os.ReadFile(filepath.Join(dir, "server.pem"))
GenerateCertificatesIfNeeded(dir, true, "10.9.8.7", "us-east-1", "spinifex.internal")
GenerateCertificatesIfNeeded(dir, true, "", "us-east-1", "spinifex.internal")
newCA, _ := os.ReadFile(filepath.Join(dir, "ca.pem"))
assert.NotEqual(t, origCA, newCA)
newCAKey, _ := os.ReadFile(filepath.Join(dir, "ca.key"))
newServer, _ := os.ReadFile(filepath.Join(dir, "server.pem"))
assert.Equal(t, origCA, newCA, "CA cert must be preserved on --force")
assert.Equal(t, origCAKey, newCAKey, "CA key must be preserved on --force")
assert.NotEqual(t, origServer, newServer, "server cert must be re-signed on --force")
// The re-signed server cert must verify against the preserved CA.
caBlock, _ := pem.Decode(newCA)
caCert, err := x509.ParseCertificate(caBlock.Bytes)
require.NoError(t, err)
pool := x509.NewCertPool()
pool.AddCert(caCert)
srvBlock, _ := pem.Decode(newServer)
srvCert, err := x509.ParseCertificate(srvBlock.Bytes)
require.NoError(t, err)
_, err = srvCert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}})
assert.NoError(t, err, "re-signed server cert must verify against preserved CA")
})
}