Seed Host Secrets from the Vault
mise run seed-secrets is human-run on gilbert (biometric 1Password) and
seats vault secrets as host files the nix config references by path. The apply
path itself — nixos-rebuild / darwin-rebuild — reads no vault: it
references these files by path, which is the property that lets host config
merge-apply like ArgoCD. The per-host secrets.json manifest is the
declared-state owner (see declared-state-files); the per-host flake
invariant keeps it in lockstep with the config (see add-a-flake-invariant).
When to run it
- A new host file the nix config references is declared in the manifest.
- Rotation of one item —
--only <path>— without touching the rest. - After a vault value changes (a runner registration token, a service account credential).
Commands
mise run seed-secrets ringtail # apply: read the vault (biometric), write, restart units
mise run seed-secrets ringtail --check # vault-free drift gate: verify ownership/mode, scan for stray files
mise run seed-secrets ringtail --dry-run # report what an apply would do; write nothing
mise run seed-secrets ringtail --only /etc/forgejo-runner/priv.env--check needs no vault and no TTY, so it can run unattended — the host
systemd timer on ringtail (seed-secrets-check.timer) runs it as root in
place via --check --local (no ssh), once at boot and then every Sunday
05:30, writing an ok + timestamp textfile metric that Grafana alerts on
(SeedSecretsCheckFailed / SeedSecretsCheckStale, see
runbook-seed-secrets-check).
Anything a check cannot inspect is UNKNOWN, and UNKNOWN is a failure.
Every remote command runs as root through sudo -n over ssh, so the ssh
user needs passwordless sudo on the host (ringtail’s eblume has it).
Vault values are written newline-stripped, the same bytes ansible’s
pre_tasks wrote.
A write that finds the entry’s parent directory missing (the first darwin
seat: indri’s identity dir exists only once the nix-darwin generation that
creates it has applied) creates the parent as the entry’s owner/group,
mode 0700,
and reads the parent back (owner, 0700) before the file read-back; a
pre-existing parent is never touched. --check reports a missing parent
explicitly rather than as a generic cannot stat.
Generated entries (like /etc/k3s/token) mint once and are never rotated.
Minting asks for confirmation and restarts nothing. An absent generated file
on a live host is almost always a lost file, not a fresh host: for the k3s
token, restore it from /var/lib/rancher/k3s/server/token. A new value would
not match the token the cluster’s bootstrap data is encrypted with.
Adding a new entry
Add the entry to nixos/ringtail/secrets.json (or
darwin/indri/secrets.json) — path, kind (op or generated), op:// ref,
owner/group/mode, and the unit(s) to restart — and to the nix config that
references it. The flake invariant fails the flake-check build if the
manifest declares a path nothing references, or if one of the reference
sites it knows about uses an undeclared path. On ringtail those are
services.k3s.tokenFile, the gitea-actions-runner instances’ tokenFile,
and /etc/agents/op-token. It does not scan every option, so a new kind of
reference site (an EnvironmentFile, a credentialsFile) must be added to
referenced in invariants.nix in the same PR. Then run
mise run seed-secrets ringtail --dry-run, and apply from gilbert.
Failure modes
UNKNOWNis a failure. A check that cannot see a file is red, not green — a partial picture of a secrets boundary invites a second surprise.- Tailscale SSH on indri returns exit 0 for everything (eblume/blumeops#1379). Every write is read back (owner, group, mode, sha256 of what was written); a read-back that disagrees is a hard failure.
- Stray files are reported, never deleted. A scratch file is more likely
than a leaked secret, and deletion stays a human act. A failed write can
leave
<path>.seedtmpholding the value.--checkreports it as a stray; delete it by hand. - indri’s restarts are launchd labels. A darwin manifest’s
restartentries are LaunchAgent labels, kicked withlaunchctl kickstart -k gui/<uid>/<label>in the entry owner’s gui domain (the transport runs as root, so the uid comes from the entry’s declared owner) and confirmed by parsinglaunchctl printoutput — exit codes lie (eblume/blumeops#1379). eblume/blumeops#1528. - Biometric required. Apply refuses to run without a TTY, so CI can never run this — the vault credential is the thing the pattern removes from the apply path.
See also
- eblume/blumeops#1524 — the mechanism issue