Zero-touch provisioning infrastructure for Mozilla RelOps CI workers (macOS today, eventually all hardware).
GCP-native, workload-identity-style secret delivery via SCEP-issued per-host client certs and mTLS at the load balancer. The same primitive β a DEP-enrolled host that holds an SCEP client cert can fetch its role's secrets from a brokered Secret Manager β covers every operator-driven flow:
| π | first-time provisioning of brand-new hardware out of DEP enrollment |
|---|---|
| π | re-provisioning an existing host via EACS (Erase All Content and Settings β what that actually does, button-press β re-enroll) |
| πͺͺ | re-keying a host whose cert was compromised or whose role changed |
| β³ | routine cert rotation β SCEP renews on its own, no operator action |
No laptop step. No shared secret copied out of a password manager. No SSH
session to type vault.yaml into. Every secret read is logged with the
requesting cert's serial number β "who pulled what, when" is a one-liner
in Cloud Audit Logs.
Status: Zero-touch and live for the m4 pools (2026-07). One flow, driven by the
orchestrator/ CLI (reprovision): quarantine β drain β EACS wipe β DEP re-enroll β
SimpleMDM profiles + signed bootstrap PKG β SCEP cert in keychain β auto-mint SecureToken
- escrow Bootstrap Token β bootstrap PKG fetches
vault.yamlβ puppet β worker re-registered in Taskcluster β auto-unquarantine. Proven end-to-end across the m4 staging pool (macmini-m4-111β¦115) and verified reprovision-ready on the m4 production pool.
Why the SecureToken mint needs an on-network runner. DEP skips Setup Assistant, so the
managed admin holds no SecureToken until a PAM (password) login β and Apple only grants the
first SecureToken through an interactive login session, which in this context can only be
driven over an on-host SSH session. That single Apple constraint is why the driver has to
run inside the datacenter. The runner performs that login automatically (reprovision mint
/ scripts/mint-securetoken.sh), so it is not a manual per-host step β it's the reason
the runner is on-network rather than a Cloud Run job.
One-click from Hangar. The whole reprovision is a button in the
hangar fleet dashboard (individual host or a
whole pool). Hangar (Cloud Run) can't reach MDC1, so it only queues a job; a
Puppet-managed on-network runner (macmini-m4-81, ronin_puppet role
gecko_t_osx_1500_m4_reprovision_runner β the orchestrator under a LaunchDaemon) claims it
over mTLS, outbound-only, runs reprovision, and streams every phase back into a live
view. Nothing dials into the datacenter. The operator never needs a terminal.
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β host (m4 Mac Mini, fresh out of DEP) β
β β
β 1. DEP enroll (Setup Assistant skipped) β β
β managed admin (fixed DEP password) β
β 2. Runner auto-mints the first SecureToken β
β over an on-host ssh login; BST escrowed β
β 3. SCEP profile β mdmclient β keypair + β
β cert in System keychain β
β 4. Signed bootstrap PKG (managed install): β
β CURL_SSL_BACKEND=securetransport \ β
β curl --cert "<CN>" https://forge/secret/X β
β (TLS handshake signs via OS network stack, β
β which holds the keychain sign ACL) β
βββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β mTLS
βΌ
βββββββββββββββββββββββ GCP project: relops-bootstrap βββββββββββββββββββββββ
β β
β π‘οΈ HTTPS LB β
β β’ Cloud Armor (source-CIDR allowlist, MDC1 NAT) β
β β’ Trust Config: step-ca root + intermediate (validates client cert) β
β β’ Server TLS Policy: ALLOW_INVALID_OR_MISSING (so /scep/* still works) β
β β’ Forwards X-Client-Cert-{Present,Chain-Verified,Leaf,Serial,SPIFFE} β
β β’ Live at https://forge.relops.mozilla.com (Google-managed cert) β
β β
β β β β
β βΌ βΌ β
β πͺͺ step-ca π vault-broker β
β GCE VM (zonal NEG) Cloud Run (serverless NEG) β
β SCEP issuer per puppet role β’ Reads X-Client-Cert-Leaf β
β Cert SPIFFE URI = β’ URL-decodes SPIFFE URI from SAN β
β spiffe://relops.mozilla/ β’ Checks role matches URL path β
β host/<CN>/role/<role> β’ Reads vault-<role> secret β
β β’ Per-cert-serial rate limit β
β β’ Logs to Cloud Audit β
β β
β π Secret Manager β
β vault-<role> per puppet role β
β (mirrors the 1P "RelOps" vault) β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
macOS keychain ACL gates SecKeyCreateSignature to a hard-coded list of
network-stack helpers (configd, nehelper, NEIKEv2Provider). A
Developer-ID-signed binary running as root in a LaunchDaemon β let alone our
bootstrap shell β gets errSecInteractionNotAllowed when trying to sign with
a keychain-stored key. We proved this end of every plausible workaround
(AllowAllAppsAccess=true, KeyIsExtractable=true, SecItemExport, ACME
via the data-protection keychain). The keychain genuinely will not let our
code use the SCEP key directly.
But the keychain ACL does allow signing from the OS network stack β which is
exactly the path curl takes when invoked with CURL_SSL_BACKEND=securetransport.
The TLS handshake's CertificateVerify is signed inside SecureTransport
(via the helpers in the allowlist), not in our process. Our code never
touches the private key. No PKCS12 extraction. No key on disk. The cert
never leaves the keychain.
The LB validates the cert chain via its Trust Config (step-ca root +
intermediate) and forwards parsed cert info to the broker as request
headers. The broker reads X-Client-Cert-Leaf and parses the SPIFFE URI
out of the SAN β we don't trust the LB's built-in SPIFFE parser because it
silently drops URI SANs with URL-encoded chars (e.g. Mac%20mini).
.
βββ terraform/ ποΈ GCP infra
β βββ main.tf, variables.tf, outputs.tf
β βββ vault_broker.tf β Cloud Run service + IAM
β βββ step_ca.tf, network.tf β step-ca GCE VM + VPC + firewalls
β βββ lb.tf β HTTPS LB + Cloud Armor + custom request headers
β βββ mtls.tf β Trust Config + Server TLS Policy
β βββ trust/{root,intermediate}.pem β step-ca PEMs the Trust Config points at
β βββ iam.tf β scoped IAM bindings (least privilege)
β βββ secrets.tf β per-puppet-role secret containers
β βββ artifact_registry.tf β broker image registry
β
βββ broker/ π FastAPI vault-broker service
β βββ app/ β reads X-Client-Cert-* headers, parses leaf,
β β URL-decodes SPIFFE, role-binds, fetches secret
β βββ tests/ β pytest happy-path + adversarial cases
β βββ Dockerfile, pyproject.toml
β
βββ orchestrator/ π§° Operator CLI (`reprovision`)
β βββ ... TC (Hawk) + SimpleMDM clients + workflow steps (quarantineβwipeβmintβescrowβwait)
β see orchestrator/README.md for the pipeline + golden paths
β
βββ pkg/ π¦ Signed bootstrap PKG builder (current delivery)
β βββ build.sh β builds + Developer-ID-signs the component pkg (43AQ936H96)
β βββ payload/ β /usr/local/sbin/m4-bootstrap.sh + entrypoint LaunchDaemon
β βββ scripts/postinstall β kicks the entrypoint on install
β
β (provisioner/ β the old Cloud Scheduler β Cloud Run cron reconciler that fired
β SimpleMDM script-jobs β was DECOMMISSIONED 2026-07. It is superseded by the signed
β PKG (delivery at DEP convergence) + the Hangar on-network runner (orchestration).
β Removed from the repo and torn down in GCP; see git history if you need the guard logic.)
β
βββ mdm/ π± SimpleMDM artifacts
β βββ scep-relops.mobileconfig.template
β βββ com.mozilla.relops.bootstrap.plist β optional periodic refresh daemon
β
βββ scripts/ π οΈ Operator helpers + worker bootstrap
β βββ bootstrap-step-ca.sh β idempotent step-ca init
β βββ render-scep-mobileconfig.sh β render SCEP profile for SimpleMDM upload
β βββ simplemdm-m4-no-sip-bootstrap.sh β β bootstrap script β now shipped as a signed PKG (managed install)
β βββ mint-securetoken.sh β automate the SecureToken mint (+ BST escrow) over ssh
β βββ simplemdm-vault-fetch-snippet.sh β just the fetch block, for splicing
β βββ relops-bootstrap.sh β LaunchDaemon variant for periodic refresh
β βββ install-on-worker.sh β operator-laptop installer
β βββ integration-test-broker.py β end-to-end test via real step-ca cert
β βββ INSTALL-on-worker.md β install guide + threat-model table
β βββ fetch-vault-mtls.swift β historical URLSession variant (dead end)
β
βββ docs/ π rendered walkthrough (index.html + deep-dive.html)
β + reprovision-architecture.html (one-page flow diagram)
β + eacs.html (what EACS actually does, button-press β re-enroll)
βββ .github/workflows/test.yml β
CI: pytest + terraform fmt/validate
βββ cloudbuild.yaml π build + push + deploy broker on commit
One-time operator steps. After this, provisioning a worker is driven by the
orchestrator/ CLI (reprovision) β see orchestrator/README.md
for the fresh vs reprovision golden paths. The bootstrap ships as a signed PKG.
gcloud projects create relops-bootstrap --name="RelOps Bootstrap"
gcloud config set project relops-bootstrap
gcloud billing projects link relops-bootstrap --billing-account="$BILLING_ID"
gcloud storage buckets create gs://relops-bootstrap-tfstate \
--location=us-central1 --uniform-bucket-level-access --public-access-prevention
gcloud storage buckets update gs://relops-bootstrap-tfstate --versioningcd terraform
cp terraform.tfvars.example terraform.tfvars
# fill in trusted_source_cidrs (MDC1 worker network) and broker_hostname
terraform init
terraform applyπ€ Outputs you'll need next:
| Output | Used for |
|---|---|
vault_broker_url |
Cloud Run URL of the broker (debug only β production traffic via LB) |
step_ca_ip |
static external IP of the step-ca VM |
broker_lb_ip |
static external IP of the LB (point DNS here) |
artifact_registry_repo_url |
where to docker push the broker image |
secret_ids |
Secret Manager containers waiting for content |
See scripts/README.md β scripts/bootstrap-step-ca.sh
is the idempotent operator-side bootstrap. Per-role SCEP provisioners get
added afterward with step ca provisioner add scep-<role> --type=SCEP ....
gcloud builds submit broker \
--tag us-central1-docker.pkg.dev/relops-bootstrap/vault-broker/vault-broker:latest \
--project relops-bootstrap
gcloud run deploy vault-broker \
--image us-central1-docker.pkg.dev/relops-bootstrap/vault-broker/vault-broker:latest \
--region us-central1 --project relops-bootstrap(Or push to main and let Cloud Build do it via cloudbuild.yaml.)
For each puppet role, mirror the 1Password entry into Secret Manager:
# Team 1Password vault is named "RelOps" (matches orchestrator/config.py); adjust
# the item/field to your per-role vault.yaml entry.
op read "op://RelOps/Puppet Vault Auth - gecko-t-osx-1500-m4/notesPlain" \
| gcloud secrets versions add vault-gecko_t_osx_1500_m4 --data-file=-scripts/render-scep-mobileconfig.sh produces a .mobileconfig with the
step-ca root embedded + SCEP enrollment payload. Upload to SimpleMDM as a
Custom Configuration Profile, assign to the device group whose hosts you
want to provision via the broker.
scripts/simplemdm-m4-no-sip-bootstrap.sh is packaged as a signed PKG (managed
install) assigned to the device group, so it lands during DEP convergence and runs on its
own β no SimpleMDM script-job, no GCP trigger. It:
- Waits for admin to hold a SecureToken β the runner mints the first one over an on-host ssh login (DEP skips Setup Assistant, so admin has no token until a PAM login, and Apple only grants the first SecureToken interactively β a script/PKG can't). BST escrow happens alongside the mint.
- Discovers the SCEP identity in the keychain by issuer DN
- Fetches the role-scoped
vault.yamlfrom the broker via SecureTransport curl - Clones ronin_puppet, sets up ssh-to-localhost, installs the m4-bootstrap-driver
LaunchDaemon (which loops
run-puppet.shuntil the safari semaphores fire)
The mint + BST escrow are driven by reprovision mint / reprovision escrow-bst (the
standalone scripts/mint-securetoken.sh), which the on-network runner runs automatically as
part of the pipeline β no manual per-host login.
| Layer | Mechanism | Protects against |
|---|---|---|
| Cloud Run ingress | INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER (live value: internal-and-cloud-load-balancing) |
direct-to-broker traffic that skips the LB (spoofed cert headers). Load-bearing β the broker trusts the LB's chain verdict, so this is what makes header trust safe |
| HTTPS LB | Cloud Armor source-CIDR allowlist (default-deny; allow 63.245.209.101/32) |
off-network attackers β blocked before TLS terminates, on both the broker and step-ca backends |
| HTTPS LB | mTLS via Trust Config (step-ca root + intermediate) | requests without a valid step-ca-issued cert (chain validated at the LB) |
| HTTPS LB | LB overwrites the X-Client-Cert-* header names it injects |
forged auth headers β an inbound client cannot set X-Client-Cert-Chain-Verified: true itself for the names the LB manages |
| Broker app | Requires LB-set X-Client-Cert-Present + -Chain-Verified = true, else 401 (does not re-verify the chain) |
unauthenticated fetches |
| Broker app | Role from cert SPIFFE URI must exactly match URL path role, else 403 | role escalation / cross-host secret theft |
| Broker app | Per-cert-serial rate limit, else 429 (in-memory, per-instance β a speed bump, not a global quota) | brute-force enumeration from a single instance |
| Broker IAM | Per-secret binding to vault-* + step-ca-root-cert only (no project-wide grant) |
lateral movement if the broker is compromised |
| Cloud Audit | Every Secret Manager read logged with cert serial (app log + platform audit log) | after-the-fact incident response |
| Cert lifecycle | 24h cert lifetime (step-ca default; not explicitly pinned in ca.json) |
long-lived compromised credentials |
| Cert private key | macOS System keychain ACL-restricted to network-stack helpers (KeyIsExtractable=false) |
key exfiltration by non-OS code |
Trust boundary (state this plainly in a review): the broker does no cryptography of its own beyond parsing the leaf to read the role β it trusts the LB's
X-Client-Cert-Chain-Verifiedheader. That is safe only because (a) the LB overwrites those header names, and (b) Cloud Run ingress blocks any request that did not transit the LB. Relaxing the ingress setting would turn the header trust into an auth bypass. SeeSECURITY.mdfor the command that proves each of these.
| URL | What it is |
|---|---|
https://forge.relops.mozilla.com |
Single HTTPS LB (Google-managed server cert). URL map: /scep/* (and a now-unused /acme/*) β step-ca backend; everything else (incl. /secret/{role}) β vault-broker backend. The LB requests a client cert on all paths (ALLOW_INVALID_OR_MISSING_CLIENT_CERT) so /scep/* works cert-less; /secret/* authentication is enforced in the broker, not the LB |
| Cloud Run direct URL | Not reachable from off-LB traffic β ingress is internal-and-cloud-load-balancing, so a direct request is refused at the platform layer (it does not reach the broker) rather than returning a 401 |
Vault fetch status (m4 role): proven end-to-end. The validating call from a real EACS'd m4 returns the full role-scoped vault.yaml with the broker's strict checks all green.
- πͺͺ Per-role expansion β verified live state (2026-07):
- Live (SCEP provisioner + populated
vault-*secret):gecko_t_osx_1500_m4(scep-no-sip),gecko_t_osx_1500_m4_staging(scep-osx-1500-m4-staging), andgecko_t_osx_1500_m4_no_sip. - Scaffolded, not functional: the six
gecko_t_linux_*roles have step-ca SCEP provisioners but theirvault-*secrets are empty (0 versions) β enrollment would succeed but the fetch has nothing to return. Not usable until the secrets are populated (and the Linux operator-side flow is re-validated β see the Linux notes indocs/). - Not started:
gecko_t_osx_1400_r8,gecko_t_osx_1015β empty secret containers, no provisioner yet. - Each remaining role is mechanical: new provisioner with a template hardcoding the role in
the SPIFFE URI + a "Dev - SCEP - " SimpleMDM profile + a populated Secret Manager
secret.
scripts/bootstrap-step-ca.shcodifies all eight live SCEP provisioners (2 macOS + 6 Linux), so the CA is reproducible from code; the abandonedacme-no-sipACME provisioner has been pruned from the live CA.
- Live (SCEP provisioner + populated
- π Cloud Build trigger β
cloudbuild.yamlexists, OAuth-flow UI got stuck during initial wire-up; switch to a PAT-based webhook trigger or retry the GitHub App connection. Manualgcloud builds submitworks. - π Widen
trusted_source_cidrsbeyond63.245.209.101/32if other Mozilla NAT IPs need access (other datacenters, VPN egress, etc.). - β Bootstrap via signed PKG β DONE (2026-07): the bootstrap is a signed PKG (managed install) that lands at DEP convergence instead of a triggered script-job.
- β
Hangar-driven, one-click reprovision β DONE (2026-07): a button in the
hangar dashboard queues a job that a
Puppet-managed on-network runner claims over mTLS and runs.
reprovisionruntime-fetches its SimpleMDM / TC / admin creds (Secret Manager / 1Password / hostvault.yaml) β no per-session pasting. - π Runner cert renewal β the on-network runner uses a step-ca client cert; step-ca isn't
reachable from MDC1 yet, so it's refreshed manually. Expose step-ca to MDC1 (like the forge
LB) to enable
step ca renew --daemonauto-rotation. TCquarantine-workertoken still needs periodic re-issue.
hangar β the RelOps fleet
dashboard. Same GCP folder, same terraform patterns, same FastAPI stack. This
repo borrows hangar's layout conventions.