mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
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:
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user