From 904b45ebaa5ec53a01b6e5aedd4462cee006dd0b Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Mon, 25 May 2026 21:01:03 +1000 Subject: [PATCH] fix(ipsec): symlink cluster CA into charon's default cacerts dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ''`, 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 --- spinifex/admin/ipsec.go | 38 ++++++++++++++++++++++++++++++++++++ spinifex/admin/ipsec_test.go | 11 +++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/spinifex/admin/ipsec.go b/spinifex/admin/ipsec.go index ad330872e..26e1aae5b 100644 --- a/spinifex/admin/ipsec.go +++ b/spinifex/admin/ipsec.go @@ -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 ''` 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 } diff --git a/spinifex/admin/ipsec_test.go b/spinifex/admin/ipsec_test.go index c3da091b5..4855b1d51 100644 --- a/spinifex/admin/ipsec_test.go +++ b/spinifex/admin/ipsec_test.go @@ -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")