Runbook 15 — Dedicated Ansible control node¶
Goal: stand up a dedicated control-node VM (ctrl, AlmaLinux 9, MGMT
10.20.10.8) that runs all of Ansible — site.yml, update.yml, drift checks — and
holds the control-plane secrets (lab.kdbx + the vault password, with the vault
password TPM-sealed, never in plaintext). ctrl authenticates to the whole fleet with
a CA-signed control cert — not a raw key — so there is no per-host key to distribute.
YubiKey-gated CA signing lives on the admin laptop; ctrl and the admin laptop
coordinate through git.
Why dedicated: the control node holds the crown of the config plane (fleet access +
vault). Co-locating that with a general-purpose daily-driver (personal data, a browser, a
roaming laptop) is the worst custody spot for it. A single-purpose EL9 VM in MGMT is
common-managed, observable, Proxmox-backed-up, and destroy-and-rebuild like the rest
of the fleet — no snowflake.
Why a cert, not a raw key: the fleet already trusts the SSH user CA
(TrustedUserCAKeys), so a single CA-signed cert lets ctrl reach every host with nothing
registered per-host. A cert also expires and is KRL-revocable — a raw authorized_keys
entry is neither. So ctrl's fleet credential is a cert, and fleet_transport_pubkeys
carries only root@pve (the pre-cert provisioning bootstrap).
Why MGMT: the control node must reach every zone, and per the inter-zone policy only MGMT may reach all zones. A control node in SERVERS or DMZ could not reach the others.
When to run: once, early — the control node is the thing that runs runbook 16 and
everything after. The first apply is launched from your admin laptop
(where the YubiKey + repo live); then ctrl self-manages.
Facts to confirm: Proxmox VM storage name (local-zfs below); your admin machine has
the repo + lab.kdbx + vault access + the YubiKey to bootstrap from.
1. Provision the VM¶
# on the Proxmox host (pve), from the provision/ dir
./lab-provision.sh up ctrl # clones the AlmaLinux 9 template (vms.conf), starts it
# add a vTPM so the vault password can be sealed to it — a cold stop/start attaches the
# device (lab-provision.sh can't express it, like fileserver's PCI / tvheadend's USB):
qm stop 9128
qm set 9128 --tpmstate0 local-zfs:1,version=v2.0
qm start 9128
2. Bootstrap from your admin machine¶
ctrl is in all_linux + the [control] group, so the baseline + control role apply to
it like any host. From the admin laptop (your YubiKey cert reaches the fresh VM over the
provisioning path), repo root / ansible/:
ansible-playbook playbooks/site.yml -l ctrl
This runs common (SSH-CA trust, IPA enroll — MGMT can reach IPA, alloy, hardening,
dnf-automatic) and the control role (pinned ansible-core + versionlock, git,
keepassxc-cli, the /etc/lab secrets dir, and /etc/profile.d/lab-vault-cred.sh
exporting LAB_VAULT_CRED). After this, ctrl shows up in Grafana automatically.
3. Give ctrl its fleet credential + secrets (one-time, out of band)¶
These are credential-bearing and not in git. ctrl runs Ansible as the almalinux
user, so everything here lives under ~almalinux.
Root & sudo on ctrl.
almalinuxhas sudo on ctrl (NOPASSWD drop-in/etc/sudoers.d/10-almalinux-ctrl, codified by thecontrolrole,control_restore_almalinux_sudo). It's needed two ways: ctrl isansible_connection=local, so it self-manages viabecome; and it's the JIT ctrl-root path — an operator-signedadmin-ctrltask cert →ssh almalinux@ctrl→sudo -i(runbook 28). That sudo is gated upstream by which cert principals may reachalmalinux@ctrl(admin/admin-ctrlonly — the agent's standingadmin-mgmtcannot). Root SSH stays OFF (PermitRootLogin no, common hardening); root on ctrl is otherwise console-only (Proxmox console + the KeePass root password).Break-glass recovery if
almalinuxsudo is ever lost (which also breaks ctrl self-management): two routes. (a) Console — Proxmox console → root (KeePass pw, or single-userrd.break) → recreate/etc/sudoers.d/10-almalinux-ctrl. (b) Offline from pve (no ctrl password needed — used 2026-06-28):scripts/fix-ctrl-access.shrun on pve as root, launched detached (systemd-run --no-block --collect --unit=fix-ctrl bash /root/fix-ctrl-access.sh) — it graceful-stops ctrl, mounts its root partition offline (thevmpool-encpool is Tang-unlocked on pve), restores the sudoers drop-in +touch /.autorelabel, and restarts ctrl (always-restart trap). Launch detached because the operator/agent session may run on ctrl and dies when it stops. Thecontrolrole keeps the drop-in reproducible afterwards.
a. The control cert — ctrl's key signed by the user CA, with the zone + crown-jewel
principals so it reaches every host as almalinux:
# on ctrl: generate the control key. Initially no passphrase — it is protected by living on
# the TPM-backed, single-purpose box and by the cert's expiry/revocability. Once tang1 exists
# (runbook 24) you can additionally Tang-gate it for a network-bound fail-closed kill-switch
# on fleet reach — see §3d below.
ssh-keygen -t ed25519 -N '' -C ctrl@<instance> -f /home/almalinux/.ssh/id_ctrl
# sign it on the admin laptop (YubiKey): scripts/yubikey-tool.sh -> U (Sign a USER cert), answering:
# user key to sign : id_ctrl.pub (copy it over first)
# principals : admin-mgmt,admin-servers,admin-dmz,admin-freeipa,admin-workstations
# identity/key-id : ctrl-control
# validity : +52w
# then copy the minted id_ctrl-cert.pub to ctrl:/home/almalinux/.ssh/
# (admin-workstations lets the host-CA autosign reach the admin-laptop/desktop/gpu-host boxes, which authorize
# it -> root via the workstation role; see group_vars/all/principals.yml.)
Point ctrl's ssh at the key + cert (~almalinux/.ssh/config):
Host 10.20.* *.${domain}
IdentityFile ~/.ssh/id_ctrl
CertificateFile ~/.ssh/id_ctrl-cert.pub
IdentitiesOnly yes
The principals are the routine zone + crown-jewel set (admin-mgmt,admin-servers,
admin-dmz,admin-freeipa) plus admin-workstations — they reach every host (including
freeipa) as almalinux without using the break-glass admin principal.
admin-workstations extends that reach to the workstation boxes (admin-laptop/desktop/gpu-host) so
the host-CA autosign (runbook 26) can deploy their host certs; they authorize it via the
workstation role (workstation_ssh_principals). Rotate this cert on the host-cert
cadence (§5).
b. The repo + KeePass DB:
git clone http://10.20.30.30:3000/<org>/myos.git /opt/myos # needs read access (below)
sudo chown -R almalinux:almalinux /opt/myos # see §4a.1
# lab.kdbx is gitignored — copy it in out of band:
# scp lab.kdbx ctrl:/opt/myos/ansible/group_vars/all/lab.kdbx
/opt/myosread credential (so ctrl cangit pullto self-manage). Mint a read-only Forgejo token and store it foralmalinux(runtime state, NOT in git; recreate after a rebuild). Mint on the forgejo host (no password needed):sudo podman exec -u git forgejo forgejo admin user generate-access-token --username operator --token-name ctrl-opt-myos-ro --scopes read:repository, then on ctrl asalmalinux:umask 077; printf 'http://operator:<TOKEN>@10.20.30.30:3000\n' > ~/.git-credentials; chmod 600 ~/.git-credentials(origin is the internal forgejo over HTTP;credential.helper store). The API token-create endpoint needs basic auth (a token can't mint a token), which is why the CLI is used. Set up 2026-06-28.
c. Seal the vault passphrase to this host's TPM (no plaintext at rest). Get the
passphrase from KeePass (secrets.sh av, or the Ansible Vault entry in lab.kdbx),
then on ctrl:
umask 077
printf '%s' 'THE_VAULT_PASSPHRASE' \
| systemd-creds encrypt --name=lab-vault --with-key=tpm2 - /etc/lab/vault.cred
# verify (prints nothing on success):
systemd-creds decrypt --name=lab-vault /etc/lab/vault.cred - >/dev/null && echo OK
vault-pass.sh reads $LAB_VAULT_CRED (set by the control role) and decrypts this at run
time — bound to ctrl's TPM, useless if copied off the box.
d. (After runbook 24) Tang-gate the control key — network-bound fail-closed kill-switch. The control key reaches the whole fleet; binding it to tang1 means "no tang1 → no fleet reach" (an operational off-switch for automation god-mode, and the key is no longer plaintext at rest). The vmpool encryption already protects it at rest; this adds the runtime gate. Per the [[tang-clevis-backup-password-policy]], the passphrase is the KeePass break-glass.
# on ctrl (tang1 up). Generate a passphrase, store it in KeePass (lab.kdbx -> 'ctrl control
# SSH key'), and clevis-tang-encrypt it next to the key; then add it to id_ctrl:
PASS=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32) # -> KeePass (break-glass)
printf '%s' "$PASS" | clevis encrypt tang '{"url":"http://10.20.10.11"}' -y \
> ~/.ssh/id_ctrl.pass.jwe
ssh-keygen -p -f ~/.ssh/id_ctrl -P '' -N "$PASS" # encrypt key at rest (pubkey/cert unchanged)
sudo install -m755 /opt/myos/scripts/load-control-key.sh /usr/local/bin/load-control-key
eval "$(load-control-key)" # tang1 up -> agent gets key+cert; tang1 down -> FAIL-CLOSED
ansible all_linux -m ping
ssh-add ~/.ssh/id_ctrl and type the KeePass passphrase.
(Tang gates the load; a key already in the agent stays until ssh-add -D/reboot.)
4. Cut over — run from ctrl¶
Open a fresh almalinux login shell on ctrl (so LAB_VAULT_CRED is exported), then:
cd /opt/myos/ansible
ansible all_linux -m ansible.builtin.ping # the control cert reaches the whole fleet
ansible-playbook --check playbooks/site.yml # vault resolves via TPM; no errors
4a. Bootstrap-completion notes (do these, or you'll rediscover them)¶
- Own the repo as
almalinux. A clone done as root leaves/opt/myos(incl..git,group_vars/all/vault.yml,lab.kdbx)root:root, soalmalinuxcan'tgitit (dubious ownership) or read the vault-encryptedvault.yml. Fix once:sudo chown -R almalinux:almalinux /opt/myos. - Run deploys as the
almalinuxlogin shell — NOTsudo. The vault resolves via$LAB_VAULT_CRED(TPM), exported by/etc/profile.d/lab-vault-cred.sh;sudostrips it, sovault-pass.shfails.ctrlneeds no root for Ansible — it connects out. (§6's timer setsEnvironment=for the same reason.) - The control cert must carry the zone + crown-jewel principals.
admin-mgmt,admin-servers,admin-dmz,admin-freeipais what reaches all hosts (incl. freeipa) asalmalinux. Reserve the break-glassadminprincipal for emergencies — it is not the routine control credential. - Host-key trust
ctrl→fleet.ctrlneeds the@cert-authorityline in/etc/ssh/ssh_known_hosts(from thecommonrole) and the fleet needs signed host certs, orctrlprompts (TOFU) on first connect to each host.
5. CA signing stays on the admin laptop (the split)¶
The YubiKey lives on the admin laptop, so anything that signs with the user CA (grants access) runs there; the control node does the rest. The seam is git.
Exception — the host CA is now online on
ctrl(runbook 26). Routine host-cert signing is automated here: an online host-CA key, Clevis+Tang-sealed totang1(/etc/lab/host-ca.jwe), is decrypted per-signature byhost-ca-autosign.timer, which renews short-lived host certs (gather → sign →--tags sshcadeploy), ntfys every sign, and fails closed iftang1is unreachable (so a stolen/off-LANctrlcan't sign). Only the user CA stays YubiKey-gated on the admin laptop. One-time bootstrap (generate + seal + KeePass/offline escrow — the raw key is deliberately not in the auto-vault) is in runbook 26; thecontrolrole wires the timer + the JWE perms + the ntfy token.
yubikey-tool.sh(provision a token, sign user/host certs — includingctrl's own control cert) → the admin laptop (needs the YubiKey).push-krl.sh(revoke + push the KRL) → ctrl. Revoking edits the KRL with the CA public key only — no YubiKey — so this runs entirely on the control node.rotate-host-certs.shis split intogather→sign→deployso the YubiKey step is isolated from fleet/vault access (thegatherphase writes a manifest thatsignconsumes, so the laptop needs neither inventory nor the vault):Rollctrl> scripts/rotate-host-certs.sh gather t1_canary # refresh pubkeys + manifest ctrl> git add ... && git commit && git push admin-laptop> git pull && scripts/rotate-host-certs.sh sign t1_canary A # PIN + touch per host admin-laptop> git add ansible/roles/common/files/host_certs/*-cert.pub && git commit && git push ctrl> git pull && scripts/rotate-host-certs.sh deploy t1_canary # push + verifyctrl's own control cert in the same ceremony (re-signid_ctrl.pubwith the §3a principals) — it expires ~yearly, and thecommonrole's cert-expiry metric warns before it lapses.
6. Scheduled drift detection (built 2026-07-16)¶
The always-on control node alerts on configuration drift. scripts/lab-drift-check.sh +
the control-role lab-drift-check.timer (weekly, Sun 05:30) run a read-only
ansible-playbook playbooks/site.yml --check --diff and push a transition-only ntfy
summary of hosts that drifted from code (changed>0 under --check). It never applies
anything.
Design (the noise-control that makes it usable):
- Curated skips. The FreeIPA ipa_access play is command-driven register→gate chains that
error under --check, so it's skipped via --skip-tags ipa_access (the play is tagged).
Other check-mode-incompatible tasks (firewalld install-then-enable, register-gated command
tasks) surface as failed/unreachable per host and are reported as caveats, never as
drift. Scope is --limit all_linux (workstations + edges have their own playbooks).
- Transition-only (like obs-deadman): re-alerts only when the drifted/unevaluated host
set changes — persistent drift doesn't spam. A first clean run is silent; a "cleared"
message only follows a run that had drift.
- The run log is sensitive, and last-run-only. --diff renders template content — including
secret-bearing templates (e.g. the authelia config) — so last-run.log is written 0600 in a
0700 state dir and truncated every run (matching its name). It never accumulates, and it
is not readable by other local users. Treat it as secret material: don't copy it off ctrl, paste
it, or widen its mode. (Hardened 2026-07-18 — it was previously 0644 and appended forever.)
- The unit sets LAB_VAULT_CRED= (TPM vault; timers don't source /etc/profile.d) and runs
eval "$(scripts/load-control-key.sh)" first for DMZ reach (fail-closed if tang1 is down →
those hosts show unreachable, a caveat).
Ships DISABLED (drift_check_enabled: false). Enable after a manual trial run tunes the skip
set:
# On ctrl, as almalinux (never sudo — it strips LAB_VAULT_CRED):
cd /opt/myos/ansible && eval "$(scripts/load-control-key.sh)"
ansible-playbook playbooks/site.yml --check --diff --skip-tags ipa_access -l all_linux | tail -40
# Review what reports `changed` — reconcile real drift (apply or commit), and if a role is
# inherently noisy in --check, add it to drift_check_skip_tags. Then set drift_check_enabled: true.
Validation¶
- [ ]
qm config 9128 | grep tpmstateshows a v2.0 vTPM. - [ ]
ansible-playbook playbooks/site.yml -l ctrlis idempotent (2nd run = no changes). - [ ] On
ctrl:ansible --versionshows the pinnedansible-core;dnf versionlock listincludes it; no community bundle installed. - [ ] On
ctrl:systemd-creds decrypt --name=lab-vault /etc/lab/vault.cred -succeeds; a freshalmalinuxshell hasLAB_VAULT_CREDset;scripts/vault-pass.shprints the passphrase. - [ ] From
ctrl(asalmalinux):ansible all_linux -m ansible.builtin.pingsucceeds via the control cert, and a fullansible-playbook --check playbooks/site.ymlruns clean. - [ ]
ssh-keygen -L -f ~almalinux/.ssh/id_ctrl-cert.pubshows principalsadmin-mgmt,admin-servers,admin-dmz,admin-freeipaand a ~52w validity. - [ ] The sealed cred fails to decrypt if copied to another host (TPM-bound) — confirms no plaintext/portable secret.
- [ ] CA signing (incl. re-signing
ctrl's control cert) works from the admin laptop.