From ce88e26836d14e8c44d56c8a65303f323409a600 Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Fri, 22 May 2026 15:55:54 +1000 Subject: [PATCH] feat(admin): IPsec peer cert generator signed by cluster CA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GenerateIPSecPeerCert + IPSecCertPaths in spinifex/admin so admin init (and the future admin add-node path) can issue per-node IKEv2 peer credentials signed by the existing cluster CA. The cert carries id-kp-ipsecIKE (RFC 4945) so strongSwan accepts it as an IKEv2 peer credential when ovs-monitor-ipsec materialises tunnel configs. Cluster CA is reused directly — no intermediate strongSwan PKI today; when intermediate CAs land that becomes a separate workstream. The cert/key pair lives under {configDir}/ipsec/peer.{pem,key}; the daemon points OVS Open_vSwitch.other_config (certificate, private_key, ca_cert) at these paths when network.ipsec_enabled is true. --- spinifex/admin/ipsec.go | 161 +++++++++++++++++++++++++++++++++++ spinifex/admin/ipsec_test.go | 117 +++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 spinifex/admin/ipsec.go create mode 100644 spinifex/admin/ipsec_test.go diff --git a/spinifex/admin/ipsec.go b/spinifex/admin/ipsec.go new file mode 100644 index 000000000..9ca5df1bc --- /dev/null +++ b/spinifex/admin/ipsec.go @@ -0,0 +1,161 @@ +package admin + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// IPSec peer-cert layout under configDir. ovs-monitor-ipsec consumes these +// paths via Open_vSwitch.other_config (certificate, private_key, ca_cert) +// set by the daemon at startup when network.ipsec_enabled is true. +const ( + ipsecDirName = "ipsec" + ipsecCertFileName = "peer.pem" + ipsecKeyFileName = "peer.key" + ipsecCACertSymlink = "ca.pem" +) + +// id-kp-ipsecIKE per RFC 4945 §5.1.3.12. strongSwan accepts certs carrying +// this EKU as IKEv2 peer credentials; ovs-monitor-ipsec's auto-generated +// strongSwan config consumes them as-is. +var oidExtKeyUsageIPSecIKE = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 17} + +// IPSecCertPaths returns the canonical paths for the per-node IPsec peer +// credential pair under configDir. Both paths live in +// {configDir}/ipsec/. The CA trust anchor is the existing cluster CA in +// configDir/ca.pem; ovs-monitor-ipsec is pointed at that path directly. +func IPSecCertPaths(configDir string) (certPath, keyPath string) { + dir := filepath.Join(configDir, ipsecDirName) + return filepath.Join(dir, ipsecCertFileName), filepath.Join(dir, ipsecKeyFileName) +} + +// GenerateIPSecPeerCert issues an IKEv2 peer certificate for this node, +// signed by the existing cluster CA. The cert carries the IPSec-IKE +// extended key usage so strongSwan accepts it as a peer credential. +// +// hostname is the node's primary hostname (used as Common Name + DNS SAN). +// nodeIP is the node's primary cluster IP (added as an IP SAN). Additional +// 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. +func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP string) error { + if hostname == "" { + return fmt.Errorf("ipsec peer cert: hostname required") + } + if nodeIP == "" { + return fmt.Errorf("ipsec peer cert: nodeIP required") + } + parsedIP := net.ParseIP(nodeIP) + if parsedIP == nil { + return fmt.Errorf("ipsec peer cert: invalid nodeIP %q", nodeIP) + } + + caCert, caKey, err := loadCAKeyPair(caCertPath, caKeyPath) + if err != nil { + return err + } + + peerKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("ipsec peer cert: generate key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return fmt.Errorf("ipsec peer cert: serial: %w", err) + } + + notBefore := time.Now() + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: hostname, + Organization: []string{"Spinifex Platform"}, + }, + NotBefore: notBefore, + NotAfter: notBefore.Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + UnknownExtKeyUsage: []asn1.ObjectIdentifier{oidExtKeyUsageIPSecIKE}, + BasicConstraintsValid: true, + DNSNames: []string{hostname}, + IPAddresses: []net.IP{parsedIP}, + } + + der, err := x509.CreateCertificate(rand.Reader, &template, caCert, &peerKey.PublicKey, caKey) + if err != nil { + return fmt.Errorf("ipsec peer cert: create certificate: %w", err) + } + + dir := filepath.Join(configDir, ipsecDirName) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("ipsec peer cert: mkdir %s: %w", dir, err) + } + + certPath, keyPath := IPSecCertPaths(configDir) + if err := writePEMFile(certPath, "CERTIFICATE", der, 0644); err != nil { + return fmt.Errorf("ipsec peer cert: write cert: %w", err) + } + + keyBytes, err := x509.MarshalPKCS8PrivateKey(peerKey) + if err != nil { + return fmt.Errorf("ipsec peer cert: marshal key: %w", err) + } + if err := writePEMFile(keyPath, "PRIVATE KEY", keyBytes, 0600); err != nil { + return fmt.Errorf("ipsec peer cert: write key: %w", err) + } + + return nil +} + +func loadCAKeyPair(caCertPath, caKeyPath string) (*x509.Certificate, *rsa.PrivateKey, error) { + certPEM, err := os.ReadFile(caCertPath) + if err != nil { + return nil, nil, fmt.Errorf("read CA cert %s: %w", caCertPath, err) + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, nil, fmt.Errorf("decode CA cert PEM at %s", caCertPath) + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse CA cert: %w", err) + } + + keyPEM, err := os.ReadFile(caKeyPath) + if err != nil { + return nil, nil, fmt.Errorf("read CA key %s: %w", caKeyPath, err) + } + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, nil, fmt.Errorf("decode CA key PEM at %s", caKeyPath) + } + caKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parse CA key: %w", err) + } + rsaKey, ok := caKey.(*rsa.PrivateKey) + if !ok { + return nil, nil, fmt.Errorf("CA key is not RSA") + } + return caCert, rsaKey, nil +} + +func writePEMFile(path, blockType string, der []byte, mode os.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer f.Close() + return pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}) +} diff --git a/spinifex/admin/ipsec_test.go b/spinifex/admin/ipsec_test.go new file mode 100644 index 000000000..248321bd4 --- /dev/null +++ b/spinifex/admin/ipsec_test.go @@ -0,0 +1,117 @@ +package admin + +import ( + "crypto/x509" + "encoding/pem" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGenerateIPSecPeerCert shares a CA across subtests so the 4096-bit +// CA-key generation runs once (~0.7s). +func TestGenerateIPSecPeerCert(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("happy path", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "10.0.0.5")) + + certPath, keyPath := IPSecCertPaths(dir) + certPEM, err := os.ReadFile(certPath) + require.NoError(t, err) + block, _ := pem.Decode(certPEM) + require.NotNil(t, block) + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + + assert.Equal(t, "node-1", cert.Subject.CommonName) + assert.Equal(t, []string{"node-1"}, cert.DNSNames) + require.Len(t, cert.IPAddresses, 1) + assert.True(t, cert.IPAddresses[0].Equal(net.ParseIP("10.0.0.5"))) + + // IPSec EKU must be present so strongSwan accepts the cert as an IKEv2 peer. + var hasIPSecIKE bool + for _, oid := range cert.UnknownExtKeyUsage { + if oid.Equal(oidExtKeyUsageIPSecIKE) { + hasIPSecIKE = true + break + } + } + assert.True(t, hasIPSecIKE, "cert missing id-kp-ipsecIKE EKU") + + caCertPEM, _ := os.ReadFile(caCertPath) + caBlock, _ := pem.Decode(caCertPEM) + caCert, _ := x509.ParseCertificate(caBlock.Bytes) + pool := x509.NewCertPool() + pool.AddCert(caCert) + _, err = cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}}) + assert.NoError(t, err, "cert must verify against cluster CA") + + info, err := os.Stat(keyPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "key must be 0600") + }) + + t.Run("idempotent overwrite", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-2", "10.0.0.6")) + require.NoError(t, GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-2", "10.0.0.7")) + + certPath, _ := IPSecCertPaths(dir) + certPEM, _ := os.ReadFile(certPath) + block, _ := pem.Decode(certPEM) + cert, _ := x509.ParseCertificate(block.Bytes) + assert.True(t, cert.IPAddresses[0].Equal(net.ParseIP("10.0.0.7")), "second call should rotate the SAN IP") + }) + + t.Run("rejects missing hostname", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "", "10.0.0.5") + require.Error(t, err) + assert.Contains(t, err.Error(), "hostname required") + }) + + t.Run("rejects missing nodeIP", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "nodeIP required") + }) + + t.Run("rejects invalid nodeIP", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := GenerateIPSecPeerCert(dir, caCertPath, caKeyPath, "node-1", "not-an-ip") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid nodeIP") + }) + + t.Run("missing CA file", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := GenerateIPSecPeerCert(dir, filepath.Join(dir, "ghost-ca.pem"), caKeyPath, "node-1", "10.0.0.5") + require.Error(t, err) + assert.Contains(t, err.Error(), "read CA cert") + }) +} + +func TestIPSecCertPaths(t *testing.T) { + t.Parallel() + certPath, keyPath := IPSecCertPaths("/etc/spinifex") + assert.Equal(t, "/etc/spinifex/ipsec/peer.pem", certPath) + assert.Equal(t, "/etc/spinifex/ipsec/peer.key", keyPath) +}