feat(admin): wire IPsec peer cert into admin init + join paths

Add --ipsec bool flag (default true) on adminInitCmd. Single-node init
and the multi-node leader path both emit an IPsec peer cert after the
existing CA+server-cert generation. The multi-node formation server
propagates the IPSecEnabled flag to joining nodes via NetworkConfig;
admin join honours it and emits its own peer cert during the same
cert-issuance step that already produces the server cert.

spinifex.toml template gains an unconditional [network] block carrying
ipsec_enabled so the value is visible to operators on every install
regardless of whether external networking is configured. The daemon
reads it via cluster config (NetworkConfig.IPSecEnabled).

Bead mulga-siv-125.3.5.
This commit is contained in:
joshsiv-mulga
2026-05-22 16:29:00 +10:00
parent ce88e26836
commit c74c3aa0a2
5 changed files with 103 additions and 20 deletions
+51 -19
View File
@@ -300,6 +300,7 @@ func init() {
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
// Flags for admin join
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
@@ -844,6 +845,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
@@ -1085,6 +1087,16 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
if err := admin.GenerateIPSecPeerCertIfEnabled(ipsecEnabled, configDir, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
if ipsecEnabled {
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Install CA certificate into system trust store
installCACertificate(filepath.Join(configDir, "ca.pem"))
@@ -1106,29 +1118,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
isMultiNode := nodes >= 2
if isMultiNode {
// Build cluster-wide network config for propagation to joining nodes
var networkConfig *formation.NetworkConfig
// Build cluster-wide network config for propagation to joining nodes.
// Always emit so the IPSecEnabled flag reaches joiners even when
// external networking is disabled.
networkConfig := &formation.NetworkConfig{
IPSecEnabled: ipsecEnabled,
}
if externalMode != "" {
bootstrapVpcId := utils.GenerateResourceID("vpc")
bootstrapSubnetId := utils.GenerateResourceID("subnet")
bootstrapIgwId := utils.GenerateResourceID("igw")
networkConfig = &formation.NetworkConfig{
ExternalMode: externalMode,
PoolName: "wan",
PoolSource: externalSource,
PoolStart: poolStart,
PoolEnd: poolEnd,
PoolGateway: externalGateway,
PoolGatewayIP: gatewayIP,
PoolPrefixLen: externalPrefixLen,
PoolDNSServers: dnsServers,
BootstrapAccountId: admin.DefaultAccountID(),
BootstrapVpcId: bootstrapVpcId,
BootstrapSubnetId: bootstrapSubnetId,
BootstrapIgwId: bootstrapIgwId,
BootstrapCidr: handlers_ec2_vpc.DefaultVPCCidr,
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
}
networkConfig.ExternalMode = externalMode
networkConfig.PoolName = "wan"
networkConfig.PoolSource = externalSource
networkConfig.PoolStart = poolStart
networkConfig.PoolEnd = poolEnd
networkConfig.PoolGateway = externalGateway
networkConfig.PoolGatewayIP = gatewayIP
networkConfig.PoolPrefixLen = externalPrefixLen
networkConfig.PoolDNSServers = dnsServers
networkConfig.BootstrapAccountId = admin.DefaultAccountID()
networkConfig.BootstrapVpcId = bootstrapVpcId
networkConfig.BootstrapSubnetId = bootstrapSubnetId
networkConfig.BootstrapIgwId = bootstrapIgwId
networkConfig.BootstrapCidr = handlers_ec2_vpc.DefaultVPCCidr
networkConfig.BootstrapSubnetCidr = handlers_ec2_vpc.DefaultSubnetCidr
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
@@ -1246,6 +1260,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
}
// Print external networking summary
@@ -1837,6 +1852,22 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Server certificate generated with bind IP: %s\n\n", bindIP)
// Match the leader's intra-AZ IPsec posture: NetworkConfig.IPSecEnabled is
// authoritative. When the formation response omits NetworkConfig entirely
// (no current code path; defensive guard against future regressions) fall
// back to the AWS-parity default of ON.
ipsecEnabled := true
if statusResp.NetworkConfig != nil {
ipsecEnabled = statusResp.NetworkConfig.IPSecEnabled
}
if err := admin.GenerateIPSecPeerCertIfEnabled(ipsecEnabled, configDir, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
if ipsecEnabled {
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Build cluster topology from formation data
clusterRoutes := formation.BuildClusterRoutes(statusResp.Nodes)
predastoreNodes := formation.BuildPredastoreNodes(statusResp.Nodes)
@@ -2246,6 +2277,7 @@ func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region
// applyNetworkConfig copies cluster-wide network settings from a formation
// NetworkConfig into ConfigSettings and auto-detects the local WAN interface.
func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkConfig) {
settings.IPSecEnabled = nc.IPSecEnabled
settings.ExternalMode = nc.ExternalMode
settings.PoolName = nc.PoolName
settings.PoolSource = nc.PoolSource
+2 -1
View File
@@ -7,9 +7,10 @@ node = "{{.Node}}"
[operator]
email = "{{.OperatorEmail}}"
{{- end}}
{{- if .ExternalMode}}
[network]
ipsec_enabled = {{.IPSecEnabled}}
{{- if .ExternalMode}}
external_mode = "{{.ExternalMode}}"
[[network.external_pools]]
+5
View File
@@ -102,6 +102,11 @@ type ConfigSettings struct {
// GPUPassthrough enables VFIO GPU passthrough in the daemon config.
// Sets gpu_passthrough = true under [nodes.<node>.daemon].
GPUPassthrough bool
// IPSecEnabled toggles cluster-wide OVN native IPsec on intra-AZ Geneve
// (default true). Surfaced under [network] in spinifex.toml so the value
// is visible/auditable; the daemon reads it via cluster config.
IPSecEnabled bool
}
// PredastoreNodeConfig describes a single Predastore node for multi-node config generation.
+12
View File
@@ -48,6 +48,18 @@ func IPSecCertPaths(configDir string) (certPath, keyPath string) {
// SANs discovered from the local machine's interfaces are not added here —
// IPSec peer identity should be the explicit cluster identity only, not
// every interface address, to keep peer-identity matching strict.
// GenerateIPSecPeerCertIfEnabled is a no-op when enabled is false. Used by
// admin init and admin join so the call site reads as a single guarded line
// keyed off the cluster's network.ipsec_enabled flag.
func GenerateIPSecPeerCertIfEnabled(enabled bool, configDir, hostname, nodeIP string) error {
if !enabled {
return nil
}
caCertPath := filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
return GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP)
}
func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP string) error {
if hostname == "" {
return fmt.Errorf("ipsec peer cert: hostname required")
+33
View File
@@ -109,6 +109,39 @@ func TestGenerateIPSecPeerCert(t *testing.T) {
})
}
func TestGenerateIPSecPeerCertIfEnabled(t *testing.T) {
t.Parallel()
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")
caKeyPath := filepath.Join(caDir, "ca.key")
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
t.Run("disabled is a no-op", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, GenerateIPSecPeerCertIfEnabled(false, dir, "node-x", "10.0.0.9"))
certPath, _ := IPSecCertPaths(dir)
_, err := os.Stat(certPath)
assert.True(t, os.IsNotExist(err), "no cert must be written when ipsec disabled")
})
t.Run("enabled generates cert using configDir CA", func(t *testing.T) {
t.Parallel()
// Use a configDir that already has ca.pem + ca.key beside it (the
// canonical layout under /etc/spinifex/).
dir := t.TempDir()
require.NoError(t, GenerateCACert(filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca.key")))
require.NoError(t, GenerateIPSecPeerCertIfEnabled(true, dir, "node-y", "10.0.0.10"))
certPath, keyPath := IPSecCertPaths(dir)
_, err := os.Stat(certPath)
require.NoError(t, err)
_, err = os.Stat(keyPath)
require.NoError(t, err)
})
}
func TestIPSecCertPaths(t *testing.T) {
t.Parallel()
certPath, keyPath := IPSecCertPaths("/etc/spinifex")