From 58e0f2879fd619ae04a9586e27c4b50131e19620 Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Thu, 13 Aug 2026 10:55:31 +1000 Subject: [PATCH] feat: serve the deployment CA from IMDS instead of baking it into images (#784) Image import shelled out to virt-customize to install the deployment CA into an imported image's trust store. The step was best-effort and skipped whenever virt-customize was absent, which is the default: nothing in provisioning installs guestfs-tools on a node. When it did run it mutated the image after its checksum had been verified, it could not handle Windows guests, and it tied CA rotation to re-importing every affected AMI. Nothing the stack launches depended on it. ECS container instances, EKS k3s servers and BootDirect microVMs all receive the gateway CA at launch via user-data or fw_cfg, and boot itself needs no CA because IMDS is plain HTTP. The only uncovered case is a customer instance whose own workload calls a Spinifex HTTPS endpoint, which needs a pull mechanism rather than an image mutation. The IMDS responder now serves the CA at /spinifex/ca.pem, outside the /latest tree so the AWS-compatible surface stays byte-identical to EC2's. The route is token-free because a CA certificate is public material and the console already serves it unauthenticated; the X-Forwarded-For SSRF guard still applies. The PEM is cached and re-read on modtime change, so rotating ca.pem on a node reaches guests without restarting vpcd. --- cmd/spinifex/cmd/admin.go | 65 ------------- cmd/spinifex/cmd/admin_cabake_test.go | 67 ------------- cmd/spinifex/cmd/service.go | 1 + docs/COMMANDS.md | 26 ++++- docs/install/install-multi-node/README.md | 10 ++ docs/install/install-source/README.md | 10 ++ spinifex/handlers/imds/cacert.go | 87 +++++++++++++++++ spinifex/handlers/imds/cacert_test.go | 110 ++++++++++++++++++++++ spinifex/handlers/imds/service.go | 6 +- spinifex/vpcd/vpcd.go | 4 + 10 files changed, 252 insertions(+), 134 deletions(-) delete mode 100644 cmd/spinifex/cmd/admin_cabake_test.go create mode 100644 spinifex/handlers/imds/cacert.go create mode 100644 spinifex/handlers/imds/cacert_test.go diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index ac19902fe..df4ff78ce 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -690,13 +690,6 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { defer nc.Close() node := appConfig.Nodes[appConfig.Node] - // Bake the deployment CA into the image trust store so a stock cloud image - // trusts the gateway from first boot. Best-effort: never fails the import. - // The CA path derives from the --spinifex-dir data root (its config/ symlink - // holds ca.pem), not NodeBaseDir(): node.BaseDir is unset in the import - // command, which would collapse the path to a relative config/ca.pem. - bakeCACertIntoImage(extractedImagePath, filepath.Join(baseDir, "config", "ca.pem")) - fmt.Println("Writing image to storage ...") provider := ebsprovider.NewNATSProvider(nc, imageImportTimeout) err = admin.ImportImage(context.Background(), provider, admin.ImportOpts{ @@ -758,64 +751,6 @@ func registerImportedAMISnapshot(store objectstore.ObjectStore, bucket string, a return nil } -// caBakeRunCommand installs the uploaded CA into the guest trust store across -// the distro families, then removes the staged copy. Each branch is gated on its -// updater existing so only the matching distro's branch writes anything (install -// -D creates the anchor dir on minimal images). Debian/Ubuntu use -// update-ca-certificates, RHEL/Rocky use update-ca-trust, and stock Alpine ships -// only ca-certificates-bundle (no updater, no anchor dir) so it falls back to -// appending the PEM to the static bundle. The install chain's exit code is -// preserved past the cleanup rm so a failure surfaces instead of a false success. -// The RHEL branch restorecon's only the trust-store paths it touched so the cert -// is correctly labelled without the image-wide --no-selinux-relabel (below) -// leaving an unlabelled bundle; restorecon is best-effort and never fails the bake. -const caBakeRunCommand = `( { command -v update-ca-certificates >/dev/null && install -D -m644 /tmp/spinifex-ca.pem /usr/local/share/ca-certificates/spinifex.crt && update-ca-certificates; } || ` + - `{ command -v update-ca-trust >/dev/null && install -D -m644 /tmp/spinifex-ca.pem /etc/pki/ca-trust/source/anchors/spinifex.crt && update-ca-trust && ` + - `{ command -v restorecon >/dev/null 2>&1 && restorecon -RF /etc/pki/ca-trust/source/anchors/spinifex.crt /etc/pki/ca-trust/extracted >/dev/null 2>&1; true; }; } || ` + - `{ cat /tmp/spinifex-ca.pem >> /etc/ssl/certs/ca-certificates.crt; } ); ` + - `rc=$?; rm -f /tmp/spinifex-ca.pem; exit $rc` - -// caBakeTimeout bounds the virt-customize run so a stalled libguestfs appliance -// cannot hang the import indefinitely; on timeout the bake degrades to a skip. -const caBakeTimeout = 5 * time.Minute - -// caBakeCmd builds the virt-customize invocation that uploads the deployment CA -// into the disk image at imagePath and installs it into the guest trust store. -// --no-selinux-relabel stops virt-customize flagging the image for a first-boot -// SELinux autorelabel: that relabel+reboot corrupts XFS roots (RHEL/Rocky) on the -// reboot, so the run-command relabels only the touched trust paths instead. -func caBakeCmd(ctx context.Context, imagePath, caCertPath string) *exec.Cmd { - return exec.CommandContext(ctx, "virt-customize", "-a", imagePath, - "--no-selinux-relabel", - "--upload", caCertPath+":/tmp/spinifex-ca.pem", - "--run-command", caBakeRunCommand) -} - -// caBakeRunner resolves virt-customize and runs the CA bake; overridable in tests. -var caBakeRunner = func(imagePath, caCertPath string) ([]byte, error) { - if _, err := exec.LookPath("virt-customize"); err != nil { - return nil, fmt.Errorf("virt-customize not found: %w", err) - } - ctx, cancel := context.WithTimeout(context.Background(), caBakeTimeout) - defer cancel() - return caBakeCmd(ctx, imagePath, caCertPath).CombinedOutput() -} - -// bakeCACertIntoImage uploads the deployment CA into the image's trust store via -// virt-customize so an imported stock image trusts the gateway from first boot. -// Best-effort: a missing CA, an absent virt-customize, or an image libguestfs -// cannot inspect logs and continues — image import never fails on the CA. -func bakeCACertIntoImage(imagePath, caCertPath string) { - if _, err := os.Stat(caCertPath); err != nil { - slog.Warn("CA bake skipped: deployment CA not found; imported image will not auto-trust the gateway", "ca", caCertPath, "err", err) - return - } - if out, err := caBakeRunner(imagePath, caCertPath); err != nil { - slog.Warn("CA bake skipped: virt-customize could not customize image; imported image will not auto-trust the gateway", "err", err, "output", string(out)) - return - } -} - func runVolumesOrphansCmd(_ *cobra.Command, _ []string) { appConfig, nc, err := loadConfigAndConnect() if err != nil { diff --git a/cmd/spinifex/cmd/admin_cabake_test.go b/cmd/spinifex/cmd/admin_cabake_test.go deleted file mode 100644 index 1fb3a4a9d..000000000 --- a/cmd/spinifex/cmd/admin_cabake_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCABakeCmd_Args(t *testing.T) { - cmd := caBakeCmd(context.Background(), "/img/disk.raw", "/data/config/ca.pem") - args := cmd.Args - - assert.Equal(t, "virt-customize", filepath.Base(args[0])) - assert.Contains(t, args, "-a") - assert.Contains(t, args, "/img/disk.raw") - assert.Contains(t, args, "--upload") - assert.Contains(t, args, "/data/config/ca.pem:/tmp/spinifex-ca.pem") - assert.Contains(t, args, "--run-command") - - // --no-selinux-relabel stops virt-customize forcing a first-boot autorelabel, - // whose relabel+reboot corrupts XFS roots on RHEL/Rocky. - assert.Contains(t, args, "--no-selinux-relabel") - - // The run-command covers Debian/Ubuntu (update-ca-certificates), RHEL/Rocky - // (update-ca-trust), and the stock-Alpine fallback that appends to the static - // ca-certificates bundle when no updater is present. The RHEL branch relabels - // only the touched trust paths so the cert is labelled without a full relabel. - joined := strings.Join(args, " ") - assert.Contains(t, joined, "update-ca-certificates") - assert.Contains(t, joined, "update-ca-trust") - assert.Contains(t, joined, "restorecon") - assert.Contains(t, joined, "/etc/ssl/certs/ca-certificates.crt") -} - -func TestBakeCACertIntoImage_SkipsWhenCAMissing(t *testing.T) { - called := false - orig := caBakeRunner - caBakeRunner = func(string, string) ([]byte, error) { called = true; return nil, nil } - defer func() { caBakeRunner = orig }() - - // A missing CA must not invoke virt-customize and must not panic. - bakeCACertIntoImage("/img/disk.raw", filepath.Join(t.TempDir(), "absent-ca.pem")) - assert.False(t, called, "runner must not be called when the CA is absent") -} - -func TestBakeCACertIntoImage_ContinuesOnRunnerError(t *testing.T) { - caPath := filepath.Join(t.TempDir(), "ca.pem") - require.NoError(t, os.WriteFile(caPath, []byte("ca"), 0600)) - - called := false - orig := caBakeRunner - caBakeRunner = func(string, string) ([]byte, error) { - called = true - return []byte("libguestfs: cannot inspect image"), errors.New("exit status 1") - } - defer func() { caBakeRunner = orig }() - - // An image libguestfs cannot inspect must skip-and-continue, never panic/fail. - bakeCACertIntoImage("/img/disk.raw", caPath) - assert.True(t, called, "runner must be invoked when the CA is present") -} diff --git a/cmd/spinifex/cmd/service.go b/cmd/spinifex/cmd/service.go index 9132fd78b..fe6cffa32 100644 --- a/cmd/spinifex/cmd/service.go +++ b/cmd/spinifex/cmd/service.go @@ -981,6 +981,7 @@ var vpcdStartCmd = &cobra.Command{ NatsHost: nodeConfig.NATS.Host, NatsToken: nodeConfig.NATS.ACL.Token, NatsCACert: nodeConfig.NATS.CACert, + CACert: nodeConfig.NATS.CACert, OVNNBAddr: nodeConfig.VPCD.OVNNBAddr, OVNSBAddr: nodeConfig.VPCD.OVNSBAddr, BaseDir: nodeConfig.BaseDir, diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a82f1ea04..62c97a246 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -98,7 +98,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t | Command | Flags | Description | |---------|-------|-------------| -| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. On every import a best-effort `virt-customize` step bakes the deployment CA (`/config/ca.pem`) into the image's trust store before the block copy, so in-guest SDK-over-TLS calls to Spinifex endpoints trust the gateway from first boot; an image libguestfs cannot customize is imported as-is (CA-free) and logs a skip. Because the CA is fixed into the image, rotating the cluster CA requires re-importing affected images. | +| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. | | `spx admin images list` | — | Lists available OS images that can be imported or downloaded | | `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. | | `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-/config.json` (the DescribeImages barrier) followed by the rest of `ami-/` and `snap-ami-/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. | @@ -671,6 +671,29 @@ curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ curl -i http://169.254.169.254/latest/meta-data/instance-id ``` +### IMDS — Deployment CA + +A guest that calls a Spinifex HTTPS endpoint with an AWS SDK needs the deployment CA in its trust +store. The CA is served from the IMDS responder at `/spinifex/ca.pem` — deliberately outside the +`/latest` tree, because the AWS-compatible surface stays byte-identical to EC2's and cloud-init must +never descend into a Spinifex-only path. + +The route is **token-free**: a CA certificate is public material (the console already serves it +unauthenticated at `/api/ca.pem`), and requiring the IMDSv2 handshake would push a token dance into +every bootstrap snippet. The `X-Forwarded-For` SSRF guard still applies, as on every other path. + +```yaml +#cloud-config +runcmd: + # Debian/Ubuntu + - curl -fsS http://169.254.169.254/spinifex/ca.pem -o /usr/local/share/ca-certificates/spinifex.crt + - update-ca-certificates +``` + +RHEL/Rocky write to `/etc/pki/ca-trust/source/anchors/spinifex.crt` and run `update-ca-trust`; stock +Alpine has no updater or anchor directory, so append the PEM to `/etc/ssl/certs/ca-certificates.crt`. +Rotating `ca.pem` on a node is served immediately — vpcd re-reads the file when it changes. + ### IMDS — Supported Paths | Path | Method | Source | Status | @@ -702,6 +725,7 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id | `/latest/meta-data/public-keys/0/` | GET | `openssh-key` (format list for index 0) | **DONE** | | `/latest/meta-data/public-keys/0/openssh-key` | GET | Launch SSH public key, live-fetched from the key store; 404 if the key was deleted, 500 on backend fault | **DONE** | | `/latest/user-data` | GET | `vm.UserData`; 404 if none | **DONE** | +| `/spinifex/ca.pem` | GET | Deployment CA PEM (`application/x-pem-file`); token-free, outside the AWS tree; 404 when no CA is configured | **DONE** | | `/latest/dynamic` | GET | Lists `instance-identity/` | **DONE** | | `/latest/dynamic/instance-identity` | GET | Lists `document` (signed forms listed when the signing key lands) | **DONE** | | `/latest/dynamic/instance-identity/document` | GET | Unsigned identity document from resolved ENI + instance facts | **DONE** | diff --git a/docs/install/install-multi-node/README.md b/docs/install/install-multi-node/README.md index 5767d1621..b41a48a85 100644 --- a/docs/install/install-multi-node/README.md +++ b/docs/install/install-multi-node/README.md @@ -312,11 +312,21 @@ sudo ss -tlnp | grep 6642 ### CA Certificate Not Trusted +On a node or any host running `spx`/`aws` against the cluster: + ```bash sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt sudo update-ca-certificates ``` +Inside a guest VM there is no `/etc/spinifex`; fetch the CA from IMDS instead: + +```bash +sudo curl -fsS http://169.254.169.254/spinifex/ca.pem \ + -o /usr/local/share/ca-certificates/spinifex-ca.crt +sudo update-ca-certificates +``` + ### Cross-Host VMs Cannot Communicate ```bash diff --git a/docs/install/install-source/README.md b/docs/install/install-source/README.md index c677b4c6c..4422c0867 100644 --- a/docs/install/install-source/README.md +++ b/docs/install/install-source/README.md @@ -85,7 +85,17 @@ export PATH=$PATH:/usr/local/go/bin ### CA Certificate Not Trusted +On a node or any host running `spx`/`aws` against the cluster: + ```bash sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt sudo update-ca-certificates ``` + +Inside a guest VM there is no `/etc/spinifex`; fetch the CA from IMDS instead: + +```bash +sudo curl -fsS http://169.254.169.254/spinifex/ca.pem \ + -o /usr/local/share/ca-certificates/spinifex-ca.crt +sudo update-ca-certificates +``` diff --git a/spinifex/handlers/imds/cacert.go b/spinifex/handlers/imds/cacert.go new file mode 100644 index 000000000..f1b716d48 --- /dev/null +++ b/spinifex/handlers/imds/cacert.go @@ -0,0 +1,87 @@ +package handlers_imds + +import ( + "errors" + "log/slog" + "net/http" + "os" + "sync" + "time" +) + +// pathSpinifexCACert serves the deployment CA. It sits outside the /latest tree +// because it is a Spinifex extension: the AWS-compatible metadata surface must +// stay byte-identical to EC2's, so no new key is added under /latest/meta-data. +const pathSpinifexCACert = "/spinifex/ca.pem" + +// caCertContentType matches the console's /api/ca.pem endpoint. +const caCertContentType = "application/x-pem-file" + +// caCertCache serves the deployment CA from disk, re-reading only when the file +// changes so a CA rotation is picked up without restarting vpcd. +type caCertCache struct { + path string + + mu sync.Mutex + pem []byte + modTime time.Time + size int64 +} + +func newCACertCache(path string) *caCertCache { + return &caCertCache{path: path} +} + +// load returns the current CA PEM, re-reading when modtime or size moved. +func (c *caCertCache) load() ([]byte, error) { + if c.path == "" { + return nil, errors.New("no CA certificate path configured") + } + fi, err := os.Stat(c.path) + if err != nil { + return nil, err + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.pem != nil && fi.ModTime().Equal(c.modTime) && fi.Size() == c.size { + return c.pem, nil + } + pem, err := os.ReadFile(c.path) + if err != nil { + return nil, err + } + c.pem, c.modTime, c.size = pem, fi.ModTime(), fi.Size() + return c.pem, nil +} + +// handleCACert serves the deployment CA so a guest can install it into its own +// trust store. Deliberately token-free: a CA certificate is public material, +// already downloadable unauthenticated from the console, and requiring an +// IMDSv2 token would push the PUT handshake into every bootstrap snippet. +// rejectForwarded still wraps the mux, so the SSRF guard is unchanged, and a +// relayed request would yield a public certificate and no credential. +func (s *IMDSServiceImpl) handleCACert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + eni := s.resolveCaller(r) + if eni == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + pem, err := s.caCert.load() + if err != nil { + slog.WarnContext(r.Context(), "IMDS: deployment CA unavailable", "instance_id", eni.instanceID, "err", err) + w.WriteHeader(http.StatusNotFound) + return + } + + slog.InfoContext(r.Context(), "IMDS: serving deployment CA", "instance_id", eni.instanceID, "private_ip", eni.privateIP) + w.Header().Set("Content-Type", caCertContentType) + _, _ = w.Write(pem) +} diff --git a/spinifex/handlers/imds/cacert_test.go b/spinifex/handlers/imds/cacert_test.go new file mode 100644 index 000000000..b7a2126e7 --- /dev/null +++ b/spinifex/handlers/imds/cacert_test.go @@ -0,0 +1,110 @@ +package handlers_imds + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testCAPEM = "-----BEGIN CERTIFICATE-----\ntestca\n-----END CERTIFICATE-----\n" + +// newCATestService builds a service whose CA cache points at a temp file holding +// pem, returning the handler and the file path so a test can rotate it. +func newCATestService(t *testing.T, pem string) (http.Handler, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "ca.pem") + if pem != "" { + require.NoError(t, os.WriteFile(path, []byte(pem), 0o600)) + } + svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{}) + svc.caCert = newCACertCache(path) + return withTapENI(svc.httpHandler(), testENI()), path +} + +// TestCACert_ServedWithoutToken is the load-bearing assertion for the route: +// the CA is public material, so a guest fetches it with a plain GET and no +// IMDSv2 token handshake. Every other metadata path 401s under those conditions. +func TestCACert_ServedWithoutToken(t *testing.T) { + h, _ := newCATestService(t, testCAPEM) + + rec := get(t, h, pathSpinifexCACert, "") + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, testCAPEM, rec.Body.String()) + assert.Equal(t, caCertContentType, rec.Header().Get("Content-Type")) +} + +// TestCACert_MissingFileIs404 pins the fail-soft behaviour: a deployment with no +// CA on disk serves a 404, never a 500, and vpcd still starts. +func TestCACert_MissingFileIs404(t *testing.T) { + h, _ := newCATestService(t, "") + + rec := get(t, h, pathSpinifexCACert, "") + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestCACert_RotationServedWithoutRestart covers the reason the cache keys on +// modtime+size: rotating the CA on disk must reach guests without restarting vpcd. +func TestCACert_RotationServedWithoutRestart(t *testing.T) { + h, path := newCATestService(t, testCAPEM) + + require.Equal(t, http.StatusOK, get(t, h, pathSpinifexCACert, "").Code) + + rotated := "-----BEGIN CERTIFICATE-----\nrotated\n-----END CERTIFICATE-----\n" + require.NoError(t, os.WriteFile(path, []byte(rotated), 0o600)) + require.NoError(t, os.Chtimes(path, time.Now().Add(time.Second), time.Now().Add(time.Second))) + + rec := get(t, h, pathSpinifexCACert, "") + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, rotated, rec.Body.String()) +} + +// TestCACert_ForwardedRejected confirms the token-free route did not opt out of +// the SSRF guard: rejectForwarded wraps the whole mux, this path included. +func TestCACert_ForwardedRejected(t *testing.T) { + h, _ := newCATestService(t, testCAPEM) + + req := httptest.NewRequest(http.MethodGet, "http://"+MetaDataServerIP+pathSpinifexCACert, nil) + req.RemoteAddr = testIP + ":50000" + req.Header.Set(hdrForwardedFor, "203.0.113.99") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Empty(t, rec.Body.String()) +} + +// TestCACert_RejectsNonGET keeps the route read-only. +func TestCACert_RejectsNonGET(t *testing.T) { + h, _ := newCATestService(t, testCAPEM) + + req := httptest.NewRequest(http.MethodPost, "http://"+MetaDataServerIP+pathSpinifexCACert, nil) + req.RemoteAddr = testIP + ":50000" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Equal(t, http.MethodGet, rec.Header().Get("Allow")) +} + +// TestCACert_AbsentFromAWSMetadataListing pins the placement decision: the route +// is a Spinifex extension outside /latest, so the AWS-compatible surface a guest +// crawls is byte-identical to EC2's and cloud-init never descends into it. +func TestCACert_AbsentFromAWSMetadataListing(t *testing.T) { + h, _ := newCATestService(t, testCAPEM) + token := issueToken(t, h) + + rec := get(t, h, pathMetaDataRoot, token) + + require.Equal(t, http.StatusOK, rec.Code) + assert.NotContains(t, rec.Body.String(), "spinifex") + assert.NotContains(t, rec.Body.String(), "ca.pem") +} diff --git a/spinifex/handlers/imds/service.go b/spinifex/handlers/imds/service.go index fd1750c7a..4f928a2dc 100644 --- a/spinifex/handlers/imds/service.go +++ b/spinifex/handlers/imds/service.go @@ -71,6 +71,7 @@ type IMDSServiceImpl struct { now func() time.Time baseDomain string internalDomain string + caCert *caCertCache } // NewIMDSServiceImpl wires the IMDS service. listTaps is injected to avoid a @@ -80,8 +81,9 @@ type IMDSServiceImpl struct { // publishes. resolverIPs are the WAN IPs of nodes running northstar: when // non-empty, each per-tap responder also serves the VPC DNS shim on // 169.254.169.253:53, relaying to northstar's unprivileged wildcard listener. +// caCertPath is the deployment CA served at /spinifex/ca.pem; empty 404s it. // ctx bounds the bucket opens only; each served request carries its own. -func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, expectedNodes int, listTaps listTapsFunc, baseDomain, internalDomain string, resolverIPs []string) (*IMDSServiceImpl, error) { +func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, expectedNodes int, listTaps listTapsFunc, baseDomain, internalDomain, caCertPath string, resolverIPs []string) (*IMDSServiceImpl, error) { if natsConn == nil { return nil, errors.New("nil NATS connection") } @@ -145,6 +147,7 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer now: time.Now, baseDomain: baseDomain, internalDomain: internalDomain, + caCert: newCACertCache(caCertPath), } // Each per-tap responder serves the shared mux, threading its tap's ENI // identity into every request via BaseContext. @@ -171,6 +174,7 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer func (s *IMDSServiceImpl) httpHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc(pathToken, s.handleToken) + mux.HandleFunc(pathSpinifexCACert, s.handleCACert) mux.HandleFunc("/", s.handleMetadata) return otelsetup.HTTPMiddleware("vpcd")(rejectForwarded(normalizeVersion(mux))) } diff --git a/spinifex/vpcd/vpcd.go b/spinifex/vpcd/vpcd.go index 1c1894cbd..374ab4403 100644 --- a/spinifex/vpcd/vpcd.go +++ b/spinifex/vpcd/vpcd.go @@ -97,6 +97,9 @@ type Config struct { NatsToken string // NatsCACert is the path to the CA certificate for NATS TLS. NatsCACert string + // CACert is the path to the deployment CA. IMDS serves it at + // /spinifex/ca.pem so a guest can install it into its own trust store. + CACert string // OVNNBAddr is the OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641"). OVNNBAddr string // OVNSBAddr is the OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642"), used for monitoring. @@ -522,6 +525,7 @@ func launchService(cfg *Config) error { listTaps, cfg.NorthstarBaseDomain, cfg.NorthstarInternalDomain, + cfg.CACert, cfg.ResolverNameservers, ) if err != nil {