Files
felhom.eu/documentation/runbooks/secrets.md
T
admin eba522f06e
gates / gates (push) Successful in 5m44s
secrets: the 12 remaining reused logins as a checklist; repo stays public (operator rulings)
Operator ruled 2026-10-09, after seeing the measurement:
  (a) he rotates the remaining services himself -- CC's scope stopped at the
      Felhom boundary;
  (b) felhom.eu STAYS anonymously readable for now, because making it private
      breaks the website git-sync and the installer tag fetch, which both
      clone with NO credentials (R-110).

The standing consequence of (b): no secret may ever enter this repo again,
which manifest_bearer_gate.py now enforces with no exemption. If (b) is ever
reversed, the 32 <!-- source --> comments served on /adatkezeles must be
stripped in the same change, because they are a map of the repo.

secrets.md now carries the exact checklist -- namespace / secret / key, 12
rows, RE-MEASURED after the Felhom rotation by hashing against the value git
history still serves, which also confirms the three CC rotated are absent
from it.

Two things recorded with it, both learned the hard way the same day:
  - a DATABASE password is not changed by editing the Secret. bookstack-db
    root-password is read at first init and then lives in the engine, exactly
    like umami's POSTGRES_PASSWORD did.
  - check the app can still RESTART before trusting the rotation: umami ran
    124 days at 512Mi and was OOMKilled on every restart attempt.

And the urgency, measured rather than assumed: these are not LAN-only.
nextcloud / paperless / bookstack / qbittorrent .dooplex.hu all resolve in
PUBLIC DNS to the public address and answer HTTPS with a login page. No login
was attempted; reachability is the point.
2026-10-09 13:37:28 +02:00

16 KiB

Runbook — out-of-band secrets (felhom-system)

Model: out-of-band kubectl + this runbook (DECISION model A). Secret values are never committed to git. The manifests in manifests/ carry only placeholders + comments that point here. The live values live in the operator's out-of-band store (password manager / escrow) and in the cluster as Secrets created imperatively with the commands below.

Corrected 2026-10-09 (R-923): the password manager is Vaultwarden, which runs on DooPlex — it is not out of band for a loss of DooPlex. Every Secret below is also in DooPlex's nightly Secrets export, which since 2026-10-09 rides the encrypted off-site copy; the keys that open it are on the break-glass sheet (break-glass-sheet.md, total-loss-of-dooplex.md).

The felhom ArgoCD app syncs the entire manifests/ dir, so any Secret committed there is re-applied on every sync. Secrets that must stay out of git are therefore created as objects that are not present in manifests/ (ArgoCD does not manage them), e.g. Secret/resend-api below.


Resend API key — Secret/resend-api

What uses it (live consumers):

Consumer How it reads the key
hub (Deployment/hub) env RESEND_API_KEY ← secretKeyRef: resend-api/RESEND_API_KEY; the hub binary's RESEND_API_KEY env override fills notifications.resend_api_key (the committed ConfigMap field is an empty placeholder).
contact-mailer (Deployment/contact-mailer) env RESEND_API_KEY ← secretKeyRef: resend-api/RESEND_API_KEY.
healthchecks (not currently deployed) when deployed, wire EMAIL_HOST_PASSWORD to secretKeyRef: resend-api/RESEND_API_KEY; do not inline the value into felhom.secret.yaml.
Gmail "Send mail as" (info@ / admin@felhom.eu) external — the key is the SMTP password; updated manually in Gmail settings, not in the cluster.

Where the value lives out-of-band: the operator's password manager, entry "Felhom Resend API key (app-relay + alerts)". Send-scoped. Never written to a file on a host, the guest, or any commit.

Create / rotate the Secret

The key is available as the RESEND_API env var in kisfenyo's shell on the build host (192.168.0.180). It is exported from ~/.bashrc, so it is present in an interactive shell (bash -ic) but not in a plain non-interactive ssh host 'cmd'.

Create (or update) the Secret without ever echoing the value — render the Secret YAML in the (non-sudo) interactive shell where $RESEND_API is set, and pipe it into sudo kubectl apply:

# on 192.168.0.180, as kisfenyo:
bash -ic 'kubectl create secret generic resend-api -n felhom-system \
    --from-literal=RESEND_API_KEY="$RESEND_API" \
    --dry-run=client -o yaml' | sudo kubectl apply -f -

Then roll the consumers so they pick up the new value:

sudo kubectl -n felhom-system rollout restart deploy/hub deploy/contact-mailer
sudo kubectl -n felhom-system rollout status  deploy/hub deploy/contact-mailer --timeout=120s

Verify a consumer can send

  • contact-mailer: submit the website contact form at https://felhom.eu (or, if DEBUG=true, curl -X POST https://felhom.eu/api/debug/test) → mail arrives at info@felhom.eu.
  • hub: trigger a notification (or the dispatcher test path) → mail arrives.
  • Gmail: send a test from info@felhom.eu → delivers.

Rotating away from a compromised key (ordered — load-bearing)

  1. Create the new send-scoped key in Resend; store it out-of-band; set RESEND_API on host 180.
  2. Run the create-Secret + rollout above with the new value.
  3. Update Gmail "Send mail as" SMTP password to the new key.
  4. Verify every consumer sends on the new key (above) — do this before step 5.
  5. Only then: delete the old key in Resend. Re-verify one consumer still sends afterwards.

Never delete the old key before step 4 passes — a premature delete breaks live mail.


Operator/global bearer key — Secret/report-api

The hub API's global bearer (api.report_api_key) — the operator's own key (e.g. felhom-ops … -hub-key), distinct from the per-customer/per-host keys the hub generates itself. It was COMMITTED in manifests/hub.yaml until v0.53.0 (flagged in the 0.81/0.113 and 0.85/0.120 publish runbooks, incl. a Phase-D screenshot exposure); the manifest now carries a secretKeyRef and scripts/manifest_bearer_gate.py blocks reintroduction. The git-history copy stays alive until the value is ROTATED — de-git alone kills nothing.

What uses it (live consumers of the GLOBAL key):

Consumer How it reads the key
hub (Deployment/hub) env REPORT_API_KEY ← secretKeyRef: report-api/REPORT_API_KEY (v0.53.0 env override fills api.report_api_key; the ConfigMap field is an empty placeholder). Not optional: — a missing Secret fails Ready by design.
Operator tooling (felhom-ops keys upload -hub-key …, runbook curl probes in break-glass.md / offsite-endpoint.md) typed per-invocation from the out-of-band store — nothing machine-persisted.
felhom-controller repo controller.yaml.example carried the LITERAL as example text (never a live consumer) — scrubbed 2026-07-13.

Per-customer (customer_configs.api_key) and per-host (hosts.api_key) keys are hub-generated and unaffected by a global-key rotation — no customer box breaks.

Where the value lives out-of-band: the operator's password manager, entry "Felhom hub global bearer (report_api_key)".

Create the Secret (pre-deploy for v0.53.0 — same value, no rotation yet)

Create it with the CURRENT value before syncing the v0.53.0 manifest (the pod refuses to start without it). Render on the build host without echoing the value (file-to-file, the operator-present rule):

# on 192.168.0.180, as kisfenyo — put the current key in a 0600 temp file first (no echo):
kubectl create secret generic report-api -n felhom-system \
    --from-file=REPORT_API_KEY=/dev/stdin < /path/to/keyfile \
    --dry-run=client -o yaml | sudo kubectl apply -f -
shred -u /path/to/keyfile

Rotation (supervised — operator GO required; ordered, load-bearing)

  1. Mint the new key into a 0600 file: openssl rand -hex 32 > keyfile (no terminal echo).
  2. Re-run the create-Secret pipe above with the new file; store the value out-of-band.
  3. sudo kubectl -n felhom-system rollout restart deploy/hub && sudo kubectl -n felhom-system rollout status deploy/hub --timeout=120s
  4. Verify before declaring the old key dead:
    • a customer box still reports (per-customer key — proves rotation touched nothing it shouldn't);
    • an operator call with the NEW key succeeds (e.g. an authed GET /api/v1/… probe);
    • the SAME call with the OLD key returns 401 — only now is the git-history copy dead.
  5. Update the password-manager entry; note the rotation date in the publish-runbook disposition.

Off-site password sealing key — Secret/offsite-secret-key (hub v0.127.0, decision 69, R-821)

What uses it: hub env OFFSITE_SECRET_KEY (64 hex characters = 32 bytes). It seals every Storage Box sub-account password in one_time_secrets.value; the hub's key registrar opens them to write box keys. Required — the pod does not start without it.

Create (once, before the first v0.127.0 sync) — the value never touches a file or the terminal:

sudo kubectl -n felhom-system create secret generic offsite-secret-key \
  --from-literal=OFFSITE_SECRET_KEY="$(openssl rand -hex 32)"

If it is lost: the sealed passwords cannot be opened. Nothing on the boxes breaks (their keys are installed); the registrar and the daily check fail with offsite_key_audit_failed. Recover per customer with the hub's Re-issue offsite credentials button (the provider resets the password; the hub seals the new one). Keep a copy in the operator's password manager if a Re-issue round is not acceptable.

Rotation: not built. A new key cannot open the old rows; rotate by setting the new key and pressing Re-issue for every off-site customer.


DooPlex PBS — the ep0 copy (decision 70, 2026-10-03)

Not k8s Secrets — PBS's own private files on DooPlex, never in git:

File Holds Created
/etc/proxmox-backup/remote.cfg (root:backup 0640) ep0 API token root@pam!dooplex-sync (base64, PBS format) from the token's one-time output, file → file
/etc/proxmox-backup/notifications-priv.cfg (root:root 0600) the Resend API key, as the SMTP password of target felhom-operator from $RESEND_API, file → file

Rotating the Resend key (§ above) must also rewrite notifications-priv.cfg, or the ep0-copy failure mails stop silently. Rotate the ep0 token with proxmox-backup-manager user generate-token on ep0 (delete the old) and rewrite remote.cfg. See runbooks/ep0-datastore-copy.md.


felhom.secret.yaml — ROTATED AND DE-GITTED 2026-10-09 (R-925)

What was wrong. manifests/felhom.secret.yaml committed three Secrets in plaintext, and gitea.dooplex.hu serves this repo to anyone on the internet with no login (measured from off-network: a real path returns the file, a nonsense path returns 404). Every committed value was still live — nothing had ever been rotated. Worst of all, gitea-creds was the Gitea admin account password (is_admin: true, /api/v1/admin/users answered 200), and the same string was reused as the admin password in 15 cluster Secrets across the homelab.

What was done, in this order (the order matters — de-gitting alone kills nothing, the history keeps the value):

  1. umami-config — new random APP_SECRET and POSTGRES_PASSWORD. The password was changed inside Postgres (ALTER USER umami WITH PASSWORD) as well as in the Secret; the env var alone would not have changed it, because it is only read when the database is first initialised.
  2. healthchecks-config — new random SECRET_KEY and SUPERUSER_PASSWORD. Nothing consumes them (there is no healthchecks Deployment), so this was free.
  3. gitea-creds — the hub no longer holds the admin password at all. It now holds a scoped Gitea access token (read:package + read:repository). Both scopes are required and were each verified against their real endpoint before the swap: GET /v2/admin/felhom-controller/tags/list (the registry version checker) and GET /admin/felhom-controller/raw/branch/main/controller/configs/controller.yaml.example (the template fetcher — a read:package-only token gets 403 here, which is how the missing scope was found).
  4. The Gitea admin password was changed to a new random value and gitea-system/gitea-admin updated. Verified: the new password returns 200, the old published one returns 401.

The values were written to a 0600 file on DooPlex (~/rotated-secrets-2026-10-09.txt) and are to be moved into the password manager and the file deleted. They were never echoed to a terminal.

The file is gone from git and .gitignore's *secret* rule now applies to it (it never did before: .gitignore is not consulted for a file git already tracks, which is why the rule looked broken). scripts/manifest_bearer_gate.py's KNOWN_BACKLOG carve-out was removed in the same commit, so a reintroduction now fails the gate.

Recreating these Secrets (they are no longer in manifests/)

ArgoCD does not manage them any more, exactly like Secret/resend-api above. The felhom app has automated.enabled: false and no prune, so removing the file does not delete them. To recreate one from the password manager, on 192.168.0.180 as kisfenyo, writing the value to a 0600 temp file first so it is never echoed:

# umami-config: APP_SECRET + POSTGRES_PASSWORD.
# If POSTGRES_PASSWORD changes, ALTER USER in the database too, or umami cannot connect:
#   kubectl exec -n felhom-system deploy/umami-db -- psql -U umami -d umami \
#     -c "ALTER USER umami WITH PASSWORD '<new>';"
sudo kubectl create secret generic umami-config -n felhom-system \
  --from-file=APP_SECRET=/path/app_secret --from-file=POSTGRES_PASSWORD=/path/pg_pw \
  --dry-run=client -o yaml | sudo kubectl apply -f -

# gitea-creds: username=admin, password = a SCOPED TOKEN (read:package + read:repository),
# never the admin account password.
sudo kubectl create secret generic gitea-creds -n felhom-system \
  --from-literal=username=admin --from-file=password=/path/token \
  --dry-run=client -o yaml | sudo kubectl apply -f -

# healthchecks-config: only needed if healthchecks is ever deployed; wire EMAIL_HOST_PASSWORD to
# secretKeyRef resend-api/RESEND_API_KEY rather than inlining it (see the Resend section above).

Still owed — the same password opens 12 more services (operator's, 2026-10-09 ruling)

Rotating Gitea does not touch these: each is its own login and each is still the exact string that was published. The operator chose to do these himself; CC's scope stopped at the Felhom boundary.

Measured 2026-10-09 after the Felhom rotation (hash comparison against the value still served from git history — the three CC rotated are confirmed absent from this list):

[ ] adventurelog-system   adventurelog-admin            password
[ ] bookstack-system      bookstack-db                  root-password      <-- DATABASE root, do first
[ ] calibre-system        calibre-auth                  password
[ ] fileshare-system      gokapi-app                    admin-password
[ ] homepage-system       homepage-secrets              calibreweb-pass
[ ] homepage-system       homepage-secrets              qbittorrent-pass
[ ] mediaserver-system    qbittorrent-admin             password
[ ] nextcloud-system      nextcloud                     nextcloud-password
[ ] paperless-system      paperless-admin               password
[ ] servarr-system        download-client-credentials   qbittorrent-password
[ ] servarr-system        servarr-credentials           password
[ ] tandoor-system        tandoor-admin                 password

These are not LAN-only. nextcloud, paperless, bookstack, tandoor, calibre, adventurelog, fileshare, plex, the whole servarr set and homepage all have *.dooplex.hu ingresses; spot-checked in public DNS — nextcloud/paperless/bookstack/qbittorrent .dooplex.hu all resolve to the public address and answer HTTPS with a login page. No login was attempted; reachability is the point.

Two traps when rotating these, both learned on the Felhom side the same day:

  1. A database password is not changed by editing the Secret. bookstack-db/root-password is read when the database is first initialised; afterwards it lives in the database. Change it in the engine and in the Secret, then restart — exactly as umami-config/POSTGRES_PASSWORD had to be.
  2. Check the app can still RESTART before you trust it. Patching a Secret does nothing until the pod restarts, and a pod that has run for months may not come back: umami ran 124 days at 512Mi but was OOMKilled on every restart attempt. Rotate when you can watch it.

Repository visibility — DECIDED 2026-10-09: stays public for now

The operator ruled that felhom.eu stays anonymously readable for the moment. Making it private breaks two live paths that clone it with no credentials: the website git-sync in manifests/webpage.yaml, and the installer fetched from the installer-v… tag by every new box (R-110). Both would have to be given credentials first. The standing consequence of that ruling: no secret may ever enter this repo again — which manifest_bearer_gate.py now enforces with no exemption. If the decision is ever reversed, the 32 <!-- source: … --> comments served on /adatkezeles must be stripped in the same change, because they are a map of the repo.