Skip to content

Enrollment and mTLS

The runner boots with no secret in the image. It builds its own identity at boot, proves who it is to the control-plane, and receives a short-lived mTLS certificate. It’s the only way a cattle fleet gets identity without scattering keys around.

Boot is a state machine: liveness → config → secrets → enroll → connect → ready. The enroll step does this:

  1. Generates an EC P-256 keypair. The private key exists in memory only, as PKCS#8, and is never written to disk.
  2. Generates a nonce and builds a CSR with it in the challengePassword field, which is the proof of possession.
  3. Gathers platform attestation, per RUNNER_ATTESTATION_MODE.
  4. POSTs to /api/enroll with { version, attestation, csr, challengeNonce }.
  5. The control-plane validates the attestation, signs the CSR, and returns a certificate with the tenant’s SPIFFE id inside: spiffe://rootpilot/tenant/<slug>/runner.
  6. The runner opens the WSS tunnel with mTLS using that certificate.

The tenant comes from the certificate, not from configuration

Section titled “The tenant comes from the certificate, not from configuration”

This is worth insisting on, because it’s the guarantee holding up everything else: the attestation material determines the tenant, not an environment variable. Enrollment returns a certificate with the SPIFFE id inside, and that’s what the control-plane reads on every call.

RUNNER_TENANT, which shows up in the fleet scripts, picks no tenant at all: it picks the vault folder to read credentials from. Getting that slug wrong will not connect you to the wrong organization.

The certificate is valid for at most 1 hour. RUNNER_CERT_RENEWAL=on is the default.

Renewal fires at 2/3 of the certificate’s lifetime, not near expiry. The reason is operational: renewing right at notAfter would turn any transient network instability into identity loss. Renewing at 2/3 leaves a third of the lifetime as a retry window.

Retries back off with a 5-minute ceiling and ±25% jitter. Without the jitter, the entire fleet would hit the control-plane in the same second.

Renewal is not a RUNNER_ATTESTATION_MODE. Renewing isn’t a configuration choice: it’s what the runner does when it already has a certificate.

It signs the new CSR’s nonce with the current private key and sends the current certificate along. The control-plane validates the chain against the CA, checks the signature with the certificate’s public key, and extracts the tenant from the certificate, never from the request body. Reading it from the body would turn renewal into a way to switch tenants.

The configured mode still applies to the initial enrollment, and to the case where renewal fails through to expiry.

The control-plane grants a 5-minute grace for a just-expired certificate in the renewal attestation. Adding 1 minute of clock-skew margin, identity is considered lost 6 minutes past expiry.

After that, the control-plane answers presented cert expired beyond the renewal grace window, and it will answer that forever. The runner has no way back: the renewal attestation proves possession with the certificate that died.

The behavior is then to exit the process, so the supervisor brings up a fresh runner that enrolls from scratch. Without that exit, the process would become a zombie: up, serving nothing, invisible to any probe.

There is a second identity path, and it is development only: supplying a ready-made certificate through RUNNER_CERT_PEM + RUNNER_KEY_PEM (+ RUNNER_CA_PEM). When the first two are present, the runner skips enrollment entirely.

Terminal window
npm run dev:certs # mints CA + tunnel cert + client cert into dev-certs/, 30 days

The log announces the shortcut:

using provisioned cert (dev/test shortcut) {spiffeId: "spiffe://rootpilot/tenant/demo/runner"}

With a provisioned certificate, renewal does not apply: there’s no enrollment to repeat.

The schema requires one of the two paths, and fails fast when both are missing:

either provide RUNNER_CERT_PEM+RUNNER_KEY_PEM (dev shortcut) or RUNNER_ENROLL_URL (enrollment)