Skip to content

36 — KeePass admin access (lab.kdbx + KeePassXC-Browser)

Goal: a low-churn, offline, no-lock-in way to hold every admin/GUI login for the lab and autofill them in the browser — using the KeePassXC/KDBX the lab already standardizes on (runbook 20), unlocked by its master password, with no new network service.

Related: 20 SSH CA · 43 the secrets store · 45 YubiKey operations · 47 the agent's presence-unlock for lab.kdbx.

The two-vault model

Two .kdbx files, by role — don't mix them:

  • lab.kdbx — the infra/break-glass vault (canonical on ctrl). Every ${domain} admin-GUI login + the local break-glass app admins + the Ansible Vault entry (whose password is the vault passphrase — scripts/secrets.sh / scripts/vault-pass.sh read it). Single copy, on ctrl at ansible/group_vars/all/lab.kdbx (gitignored). This is the operator's store — not the AI agent's; the agent never reads it. (One deliberate, operator-authorized exception 2026-07-10: the agent audited + populated the DB via keepassxc-cli — now installed on ctrl by the control role — using a RAM-staged, operator-typed unlock file: checklist entries merged in, the key escrows (host-CA, tang1 jwks, instance-material tar, vmpool raw key + supernas/home raw key — both ZFS keys dry-run-verified against the live datasets with zfs load-key -n) attached + round-trip- verified, unlock file shredded after. Recovered credentials: Radicale (vault_radicale_admin_password in files-dav.vault.yml); ntfyadmin was reset (bcrypt-only, unrecoverable — new password generated + stored; publish tokens and the phone's casey user unaffected); fmd is phone-held by design (server stores only a hash; reset = re-register the device). The stale Authentik entry was removed. The standing rule is unchanged.)
  • A personal .kdbx on your desktop — your everyday working logins. Local, opened directly in KeePassXC with the browser plugin. Separate from the lab.

Unlock — the master password (+ the presence-unlock convenience)

lab.kdbx is a password-only database: its composite master key is the single master password, escrowed offline in the safe (never only inside the DB it opens). There is no YubiKey factor baked into the DB — so nothing about opening it depends on which token you hold, and there is no per-token lockout to reason about.

  • On your desktop (this runbook's flow) you open lab.kdbx with the master password, exactly like any KDBX.
  • For the AI agent on ctrl, typing the master password is replaced by the presence-unlock in runbook 47: the operator's YubiKey on atomic decrypts a per-session copy of the master password (an age-plugin-fido2-hmac file, multi-recipient to both tokens → either key arms it) and ships it over SSH into a short-TTL tmpfs credential on ctrl. The DB itself is still password-only; rb47 only automates presenting that password while the operator is present.

History — the retired OTP challenge-response factor (2026-07-18). lab.kdbx used to AND-combine the master password with a YubiKey OTP slot-2 HMAC-SHA1 challenge-response secret (the same value programmed on both tokens for either-key redundancy). That factor was retired when the presence-unlock (rb47) landed: the DB was rekeyed to password-only so the agent's arm-kdbx path works, and the CR redundancy moved into rb47's multi-recipient master.age. Do not re-add a CR factor — rekeying the DB back to challenge-response would break the agent unlock. The OTP applet reference survives only as history in rb45 §3.5 and rb46.

Access lab.kdbx from your desktop — SSHFS + KeePassXC-Browser

lab.kdbx stays canonical on ctrl; your desktop KeePassXC opens it over an SSHFS mount (so there's no second copy and no web service to run). Both DBs — this and your personal one — live in the same KeePassXC, and the browser plugin autofills from either.

  1. Mount ctrl's secrets dir over WG-admin:
    mkdir -p ~/mnt/ctrl-secrets
    sshfs <operator>@ctrl.${domain}:/opt/myos/ansible/group_vars/all ~/mnt/ctrl-secrets \
      -o reconnect,ServerAliveInterval=15,idmap=user
    
    (Confirm the real location of lab.kdbx on ctrl — the canonical repo path is ansible/group_vars/all/lab.kdbx.)
  2. Open ~/mnt/ctrl-secrets/lab.kdbx in KeePassXC; unlock with the master password (password-only — no YubiKey factor on the DB; see the Unlock section and rb47).
  3. Enable browser integration: KeePassXC → Settings → Browser Integration → Enable (tick Chromium/Chrome). Install the KeePassXC-Browser extension → Connect. It autofills any entry whose URL field matches the page.
  4. Fail-closed: if WG drops, the mount dies and KeePassXC loses the DB — correct (the infra is unreachable off-WG anyway). reconnect re-mounts when WG returns.

Admin-GUI / URL checklist for lab.kdbx

Put the URL verbatim into each entry's URL field so the plugin matches it. Known local break-glass usernames noted; blanks are operator-set. All *.${domain} names are split-horizon (WG-admin / WG-user only — never public).

Tier-2 appliances — direct IP, WG-admin (no proxy):

Service URL Account
Proxmox VE https://10.20.10.5:8006 root@pam
OPNsense firewall https://10.20.10.1 root
UniFi Network controller https://10.20.10.101:8443 operator UniFi admin

mgmt-proxy plane — infra admin UIs (WG-admin):

| Grafana | https://mon.${domain} | local admin (+ Authelia OAuth) |

apps-proxy plane — Tier-2 user apps (WG/LAN):

Service URL Account
Home Assistant https://ha.${domain} owner account
Jellyfin https://jellyfin.${domain} jfadmin (local)
Forgejo — control-plane https://git.${domain} casey (local password = break-glass; also Authelia-SSO-linked)
Forgejo — control-plane (labadmin) https://git.${domain} labadmin (dedicated site-admin, 2026-08-01 — see Admin API tokens below)
ntfy https://ntfy.${domain} ntfyadmin
umami dashboard https://stats.${domain} umami admin
Seafile https://files.${domain} admin@${domain} (local break-glass; users via Authelia OIDC)
Radicale https://dav.${domain} htpasswd user (store the plaintext here; vault holds only the bcrypt hashes)
Invidious https://invidious.${domain} (no admin login)

origin/edge plane — public (Tier 0/1):

Service URL Account
Nextcloud https://cloud.${domain} nextcloud admin
FindMyDevice https://fmd.${domain} fmd admin
Forgejo — public https://git.${domain_alt} varadmin (local) + operator
SearXNG https://search.${domain} (no login)

Direct app-admin UIs — IP:port (WG/LAN):

| tvheadend | http://10.20.20.31:9981 | named local admin | | Nextcloud AIO mastercontainer | https://10.20.30.20:8080 | AIO admin |

Non-GUI (store as notes/keys, not autofill): the almalinux SSH-CA (cert, not a password), per-host root/console break-glass, the Ansible Vault entry (its password = the vault passphrase), and the ctrl control-SSH-key passphrase.

Admin API tokens (added 2026-08-01)

Break-glass API credentials, distinct from the web logins above: they are what the agent uses — only while the DB is armed (runbook 47) — to audit an instance or to mint the narrower, per-task tokens that daily work should actually run on. Full scope (all); each was verified admin at creation by calling an admin-only endpoint.

Entry Instance Account
Forgejo control-plane API token (labadmin) https://git.${domain} labadmin
Forgejo public API token (varadmin) https://git.${domain_alt} varadmin
Forgejo admin API token (operator) https://git.${domain} casey — the operator's own; superseded for agent use

Why labadmin exists. The control-plane instance had exactly one admin: the operator's personal, SSO-linked casey. Handing the agent an admin token for that account would attribute every admin API action to the operator, on the instance that holds the infra repo — and it re-creates the trap already documented in scripts/forgejo-merge-pr.sh (Forgejo will not let an author approve their own PR, so acting as the operator can make the review gate unsatisfiable). labadmin is a dedicated site-admin, mirroring varadmin on the public instance: attribution stays clean, and revoking agent admin never touches the operator's login. Its web password is escrowed alongside the token.

Codeberg — the one deliberate exception to the two-vault split.

Entry Platform Account
Codeberg API token (VARASYS org, via caseywitt) https://codeberg.org caseywitt (personal — on purpose)

codeberg.org is a tenant platform: there is no site-admin tier to isolate, and — the fact that governs everything else here — a Forgejo/Codeberg organisation cannot hold a token. Tokens are user-scoped only. VARASYS is an org (id 1047746), not a user, so "the VARASYS token" does not exist as a thing: the org is administered with a member's token, carrying that member's role.

That leaves two shapes, and the choice is a blast-radius judgement, not a rule:

  • A dedicated role account added to the org's Owners team — the textbook answer, and the right one when the human's account carries anything the org shouldn't reach.
  • The owner's own token — chosen here (2026-08-01). This Codeberg account holds only public repos, so the personal identity's extra blast radius is small, while a role account would add an idle second login that Codeberg's terms permit reaping after 365 days with no contributions. Codeberg's docs take no position on role accounts either way.

Treat this as scoped to its reasoning, not as precedent: it is the one personal credential deliberately held in lab.kdbx. If that account ever gains a private repo or an org that matters, the calculus flips to the role account.

A caution for anyone re-deriving this: GET /api/v1/users/<name> returns organisations too in Forgejo, so it will happily confirm an org is a "user" and send you looking for a token that cannot exist. Check GET /api/v1/orgs/<name> before concluding anything about an account's type.

⚠️ A lab.kdbx edit is not durable until it is pushed to lab-secrets. The canonical DB on ctrl is a replica: load-lab-secrets.sh installs lab.kdbx from the lab-secrets clone over both /opt/myos and the agent checkout on every boot and every control converge (runbook 43). An edit that is never committed+pushed survives on ctrl only until that clone is replaced — a rebuild loses it silently. This is the same trap as the zz-local overlay, and it had already bitten: the 2026-08-01 escrow found two entries (Forgejo admin API token (operator), escrowed 2026-07-30, and Jitsi Meet moderator (jitsiadmin)) living only in the ctrl copy, never pushed. Diff before you commit — git show HEAD:lab.kdbx > /tmp/c.kdbx then compare keepassxc-cli ls of both — so you publish the drift instead of overwriting it.

The lab-secrets PUSH key passphrase is now escrowed — entry lab-secrets push key passphrase (id_lab_secrets_push), added 2026-08-01. It had been missing: runbook 43 described ~${admin}/.ssh/id_lab_secrets_push as "passphrase-protected from lab.kdbx" while no such entry existed, so the passphrase lived only with the operator and was a single point of loss — losing it would have made lab-secrets permanently read-only, i.e. nothing could ever be escrowed again.

This does not weaken rb43's read/write split. That split exists so a compromised ctrl cannot rewrite secrets history: read is Tang-sealed and automatic, write must be operator-present. Presence is preserved — lab.kdbx is itself YubiKey-gated (runbook 47), so reaching the passphrase still requires an arm. What changed is the form of presence: typing a passphrase became touching a token. Consequence to hold onto: an armed lab.kdbx now grants WRITE to the secrets store, where before it granted only read — so arm-kdbx off when finished matters more than it used to.

Confirm the couple that vary per install — UniFi port (8443 vs 443) and whether you use DNS names vs IPs for pve/OPNsense — and set the URL field to exactly what the browser shows (that's what KeePassXC matches on).

Backups + why KDBX (no migration)

lab.kdbx rides the normal ctrl Proxmox/ZFS backup; also keep a copy in the safe. KDBX is the right long-term choice — an open, audited, single-file format with many independent implementations (KeePass, KeePassXC, KeePassDX, KeeWeb, keepassxc-cli), so zero vendor lock-in and no service to run. Only revisit if a concrete need appears that a file can't serve — team sharing + web-from-anywhere (→ Vaultwarden) — which a single operator on WG does not have. pass/gpg is lateral; age/sops is a complement for repo secrets (already covered by Ansible Vault), not a password-manager replacement.