Files
bootapi/docs/security.md
T
unkinben 8f356346eb
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Address PR review: PXE gate + callback, git-sync templates, distro catalog, k8s targets, http+https
Implements the six review comments on PR #1:

- Per-host PXE-enable gate: read NetBox pxe_enabled custom field; a known host
  with it false gets the safe local-boot script (Cobbler netboot_enabled). Add a
  token-guarded POST /provisioned/{ident} callback that clears pxe_enabled in
  NetBox, plus a %post snippet in the default kickstarts that calls it.
- Templates from a git repo: bootapi clones a templates repo and re-pulls every
  BOOTAPI_TEMPLATE_GIT_INTERVAL (default 3m), atomically swapping the template
  set (last-good kept on parse failure; embedded defaults are the startup
  fallback). Metrics for syncs/failures/generation.
- Distro catalog (catalog/*.yaml): NetBox host -> boot images/kickstart, so
  adding an OS is a YAML + template change. Ships almalinux + fedora entries
  (artifactapi remotes); debian/talos path documented.
- Boot images from the artifactapi almalinux/fedora remotes via the catalog.
- Bind resolvers, puppet server/CA and PUPPETCA_URL env file now target the k8s
  services (198.18.200.7; puppet(ca).k8s.syd1.au.unkin.net).
- Boot path served over plain HTTP (installers lack CA trust) with an optional
  parallel HTTPS listener; docs say do not 301 the boot endpoints.

New packages: internal/catalog, internal/gitsync. NetBox client gains a
pxe_enabled write (token needs that scope - noted in docs). `bootapi validate`
subcommand validates a template/catalog set for the templates-repo CI.

go build/vet clean, go test -race green, golangci-lint v2 clean, pre-commit clean.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-28 22:34:44 +10:00

4.1 KiB

Security: secrets in kickstarts

A kickstart is fetched over the network by an unauthenticated installer and can embed real secrets: the root password hash, SSH keys, bootstrap tokens, repo credentials. bootapi's rule is:

NetBox holds identity and topology, never secrets. Secrets are injected at render time from Vault/env.

What goes where

Value Where it lives How it reaches the template
hostname, domain, IPs, MACs, VLANs, gateway, platform, role NetBox internal/netboxmodel.Host
template selection knobs (provision_template, nameservers, gateway) NetBox custom fields .Custom / typed fields
root password hash Vault → BOOTAPI_ROOT_PASSWORD_HASH[_FILE] .RootPasswordHash
SSH authorized keys Vault → BOOTAPI_SSH_AUTHORIZED_KEYS .SSHAuthorizedKeys
provision token Vault → BOOTAPI_PROVISION_TOKEN[_FILE] .ProvisionToken
puppet CA/server names env (not secret) .PuppetServer / .PuppetCAServer

BOOTAPI_ROOT_PASSWORD_HASH_FILE and BOOTAPI_NETBOX_TOKEN_FILE let the values arrive as Vault-mounted files rather than env, which is the k8s norm (see deployment.md). If no root hash is configured, the default templates emit rootpw --lock rather than a blank/guessable password.

This matches the pre-bootapi setup, where the root hash was Cobbler's eyaml default_password_crypted injected into settings.yaml — an operator-managed secret, never in the NetBox/inventory layer.

Exposure notes

  • Kickstarts are served over HTTP to the installer, so treat any embedded secret as visible to anything on the provisioning VLAN. Keep bootapi's kickstart endpoint on the trusted PXE network, exactly as Cobbler's was.
  • The root password hash is in the rendered kickstart by necessity (Anaconda needs it). Prefer SSH-key login + a locked or strong-random root password, and rotate the hash in Vault as normal.
  • The puppet bootstrap uses no long-lived token: the host generates a CSR and the puppetmaster autosigns it based on source subnet + *.main.unkin.net (unchanged from Cobbler). So the kickstart carries no puppet secret.

The provisioned callback token

POST /provisioned/{ident} (which flips pxe_enabled off in NetBox) is guarded by BOOTAPI_PROVISION_TOKEN. The default kickstart %post calls it with that token in an Authorization: Bearer header, so the token is embedded in every rendered kickstart — treat it as a provisioning secret (same exposure class as the root hash: visible to anything on the provisioning VLAN). It only authorizes clearing a boot gate, not reading data. Rotate it in Vault as normal; empty disables the callback (fail closed). The call runs over plain HTTP by default because %post has no internal-CA trust yet; the token — not TLS — is what authenticates it.

NetBox write scope

bootapi performs exactly one NetBox write: PATCH /api/dcim/devices/{id}/ setting custom_fields.pxe_enabled=false from the provisioned callback. Its NetBox token therefore needs write on the device pxe_enabled custom field in addition to read on devices/interfaces/ip-addresses. Scope the bootapi NetBox role/permission to just that (a NetBox object-permission constrained to dcim.device with the pxe_enabled field) rather than granting broad write. This is a deliberate, minimal escalation from the read-only design; it is called out here and in the deployment doc so the token is provisioned with the right (and only the right) scope.

Follow-up: per-template Vault lookups

Today all render-time secrets are process-wide env/files (one root hash, one key set for the fleet), which covers the current estate. If per-host or per-role secrets are ever needed (e.g. a distinct bootstrap token per role), the seam is internal/render.dataFor: add a Vault kv fetch keyed by host/role there, behind an interface, the same way encapi's internal/distro resolver injects per-host params behind an interface. Tracked as a follow-up, not implemented, to avoid giving bootapi broad Vault read scope before it's needed.