fix(ipsec): symlink cluster CA into charon's default cacerts dir

ovs-monitor-ipsec writes per-conn `cacert=/etc/spinifex/ca.pem` references
in the strongSwan config, but strongSwan 6.0 only loads CAs declared in
standalone `ca` sections or files placed in /etc/ipsec.d/cacerts at
startup. Without this, charon authenticates the local side fine but
rejects every inbound peer cert as `no trusted RSA public key found for
'<peer>'`, AUTH_FAILED, and no xfrm SAs negotiate.

Do the symlink in admin.GenerateIPSecPeerCert (right after writing the
peer cert) — setup-ovn.sh runs before `spx admin init/join` creates
/etc/spinifex/ca.pem, so the wiring can't live there. Use a stage-and-
rename pattern so concurrent test invocations don't race on the symlink.

/etc/ipsec.d/cacerts is on charon's default AppArmor allowlist so no
profile change needed.

Refs: mulga-siv-136
This commit is contained in:
joshsiv-mulga
2026-05-25 21:01:03 +10:00
parent f523932179
commit 904b45ebaa
2 changed files with 47 additions and 2 deletions
+38
View File
@@ -136,6 +136,44 @@ func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP st
return fmt.Errorf("ipsec peer cert: write key: %w", err)
}
// Symlink the cluster CA into charon's default cacerts scan dir.
// ovs-monitor-ipsec writes per-conn `cacert=` references, but
// strongSwan 6.0 only loads CAs declared in standalone `ca` sections
// or files placed in /etc/ipsec.d/cacerts at startup. Without this,
// charon authenticates the local side OK but rejects every inbound
// peer cert as `no trusted RSA public key found for '<peer>'` and
// no SAs negotiate. The dir is on charon's default AppArmor allowlist
// so no profile change is needed (unlike /etc/spinifex/).
if err := installCAIntoCharonTrustStore(caCertPath); err != nil {
return fmt.Errorf("ipsec peer cert: install CA into charon trust store: %w", err)
}
return nil
}
// charonCATrustDir is the strongSwan default cacerts scan dir. Overridden
// in tests so they don't pollute the real /etc/ipsec.d/cacerts.
var charonCATrustDir = "/etc/ipsec.d/cacerts"
const charonCATrustLink = "spinifex-ca.pem"
func installCAIntoCharonTrustStore(caCertPath string) error {
if err := os.MkdirAll(charonCATrustDir, 0750); err != nil {
return fmt.Errorf("mkdir %s: %w", charonCATrustDir, err)
}
link := filepath.Join(charonCATrustDir, charonCATrustLink)
tmp, err := os.MkdirTemp(charonCATrustDir, ".cainstall-")
if err != nil {
return fmt.Errorf("tmpdir: %w", err)
}
defer os.RemoveAll(tmp)
staging := filepath.Join(tmp, charonCATrustLink)
if err := os.Symlink(caCertPath, staging); err != nil {
return fmt.Errorf("symlink %s -> %s: %w", staging, caCertPath, err)
}
if err := os.Rename(staging, link); err != nil {
return fmt.Errorf("rename %s -> %s: %w", staging, link, err)
}
return nil
}
+9 -2
View File
@@ -15,7 +15,12 @@ import (
// TestGenerateIPSecPeerCert shares a CA across subtests so the 4096-bit
// CA-key generation runs once (~0.7s).
func TestGenerateIPSecPeerCert(t *testing.T) {
t.Parallel()
// Redirect the charon trust-store symlink at a temp dir so the test
// doesn't try to write to /etc/ipsec.d/cacerts on the host. Restored
// in cleanup. Cannot use t.Parallel() with this var override.
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
t.Cleanup(func() { charonCATrustDir = origTrustDir })
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")
@@ -110,7 +115,9 @@ func TestGenerateIPSecPeerCert(t *testing.T) {
}
func TestGenerateIPSecPeerCertIfEnabled(t *testing.T) {
t.Parallel()
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
t.Cleanup(func() { charonCATrustDir = origTrustDir })
caDir := t.TempDir()
caCertPath := filepath.Join(caDir, "ca.pem")