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
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/netbox → model.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.