feat(admin): IPsec peer cert generator signed by cluster CA

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.
This commit is contained in:
joshsiv-mulga
2026-05-22 15:55:54 +10:00
parent d229a57d3a
commit ce88e26836
2 changed files with 278 additions and 0 deletions
+161
View File
@@ -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})
}
+117
View File
@@ -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)
}