WHY .gitignore "was not working": it was working. git never consults
.gitignore for a file it ALREADY TRACKS. The rule `*secret*` matched fine --
proved by dropping an untracked copy in and watching check-ignore name
`.gitignore:3:*secret*`. The file had been tracked since feea0606, which is
ironically the commit that de-gitted the Resend key.
WHAT THE EXPOSED VALUE ACTUALLY WAS. Not an analytics password: the GITEA
ADMIN ACCOUNT PASSWORD (is_admin true; /api/v1/admin/users answered 200), in
a repo gitea.dooplex.hu serves anonymously to the internet. That is push
access to every repo -- including the one whose website/ is git-synced live
and whose scripts/ is published by tag to every new box installer (R-110).
Re-ranked P2 -> P1 on that measurement; my first ranking had only measured
the analytics blast radius.
Every committed value was still live. Nothing had ever been rotated.
ROTATED (values never echoed; written to a 0600 file on DooPlex):
umami-config APP_SECRET + POSTGRES_PASSWORD. The password was
changed INSIDE postgres (ALTER USER) as well as in the
Secret -- the env var is only read at first init, so
patching the Secret alone would have changed nothing.
healthchecks-config SECRET_KEY + SUPERUSER_PASSWORD (nothing consumes them,
there is no healthchecks Deployment).
gitea-creds no longer holds the admin password at all: a SCOPED
token (read:package + read:repository).
gitea admin new random password; gitea-system/gitea-admin updated.
VERIFIED, not assumed:
- new admin password -> 200, OLD PUBLISHED PASSWORD -> 401 (the leak is dead)
- umami: a real beacon returns 200 (so the app authenticates to postgres and
writes) while a bogus site id still returns 400 (so the 200 means something)
- hub: "Registry version check: latest = 0.304.0" AND "Template fetched
(5881 bytes)", no auth failures
- BOTH token scopes are load-bearing, and the second was found by breaking
it: a package-only token made the hub log "Template fetch: unexpected
status 403", because the template fetcher reads a raw file out of the
felhom-controller repo, not the registry.
AN INCIDENT CAUSED BY THE FIX, recorded because it is the useful part: the
rollout restart needed to pick up the new umami secret put umami into
CrashLoopBackOff and took stats.felhom.eu down (503) for ~4 minutes. Not the
rotation -- at memory 512Mi that pod runs for months but CANNOT RESTART:
startup (Prisma + Next.js) peaks over the limit and is OOMKilled (exit 137).
Raised to 1Gi IN THE MANIFEST, not just live, per .claude/rules/manifests.md
("never bare kubectl set -- the next sync reverts it and the fix silently
disappears").
THE GATE: KNOWN_BACKLOG is removed from manifest_bearer_gate.py, as its own
comment instructed. Red-proofed with a decoy: exit 1 with it, exit 0 without.
An exemption kept this visible for three months and changed nothing.
WHAT REMAINS (operator, and it is bigger than what was fixed): the same
password is still the admin password in ~12 other namespaces -- nextcloud,
paperless, bookstack (a DATABASE ROOT password), tandoor, calibre,
adventurelog, gokapi, qbittorrent, servarr, homepage. Rotating Gitea does not
touch them. Also owed: a kisfenyo Gitea token sits in plaintext in the local
homelab-manifests remote URL and was printed to a session transcript during
this investigation, so it should be replaced regardless (R-580's shape).
NOT a finding: homelab-manifests is private (404 anonymously) and does not
contain the password; ArgoCD's repo credential is a separate token and was
untouched by the rotation.
13 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
felhomArgoCD app syncs the entiremanifests/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 inmanifests/(ArgoCD does not manage them), e.g.Secret/resend-apibelow.
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 atinfo@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)
- Create the new send-scoped key in Resend; store it out-of-band; set
RESEND_APIon host 180. - Run the create-Secret + rollout above with the new value.
- Update Gmail "Send mail as" SMTP password to the new key.
- Verify every consumer sends on the new key (above) — do this before step 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)
- Mint the new key into a 0600 file:
openssl rand -hex 32 > keyfile(no terminal echo). - Re-run the create-Secret pipe above with the new file; store the value out-of-band.
sudo kubectl -n felhom-system rollout restart deploy/hub && sudo kubectl -n felhom-system rollout status deploy/hub --timeout=120s- 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.
- 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):
umami-config— new randomAPP_SECRETandPOSTGRES_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.healthchecks-config— new randomSECRET_KEYandSUPERUSER_PASSWORD. Nothing consumes them (there is no healthchecks Deployment), so this was free.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) andGET /admin/felhom-controller/raw/branch/main/controller/configs/controller.yaml.example(the template fetcher — aread:package-only token gets 403 here, which is how the missing scope was found).- The Gitea
adminpassword was changed to a new random value andgitea-system/gitea-adminupdated. 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, and NOT done here (they are outside this repo): the same password is still the admin
password in ~12 other namespaces — nextcloud, paperless, bookstack (a database root
password), tandoor, calibre, adventurelog, gokapi, qbittorrent, servarr, homepage.
Rotating Gitea does not touch those; each is its own login and each is still the published string.