Files
home-network-k3s-impl/runbooks/kustomization.yaml
T
jakman85andClaude Opus 5 ea0c0632d2 fix(crafty): the Keycloak gate works -- Crafty's own login was the wall
Reported: "crafty does not sign in after signing into keycloak, and there is
no way to set the service up without credentials."

MEASURED, not inferred. The sidecar log settles which layer refused:

  GET /                        -> 302 to Keycloak
  [AuthSuccess] ... groups:[platform-admins]        <- oauth2-proxy is FINE
  GET /oauth2/callback         -> 302
  GET /login?next=/panel/dashboard -> 200           <- Crafty's OWN login page
  POST /api/v2/auth/login/     -> 401               <- Crafty refused

So the Keycloak gate was never broken. Crafty 4 has no OIDC and cannot consume
the sidecar's identity headers, so it shows its own local login to an already
authenticated user. That second factor is intended. What was NOT intended is
that nothing documented where its password comes from, which is exactly why
setup was impossible: Crafty generates it ONCE on first boot (users table
empty -> DatabaseBuilder.default_settings()) into /crafty/app/config/
default-creds.txt, mode 0600, on the PVC's `config` subPath. Never in git;
a fresh PVC generates a new one. The README asserted the double login was
"expected, not a bug" and then left the reader with no way forward.

Verified in-cluster that the existing credential is still usable, so the
service can be set up without touching the DB: users.last_update equals
users.created for `admin` (09/05/2026, 08:10:04), i.e. the password has never
been changed, and admin's last_login is 09/14 from 127.0.0.1 -- the API login
that created the "homelab" server. default-creds.txt is therefore live.

ALSO FIXED -- a real, separate defect found while proving the above, which
produces the reported symptom intermittently and would have been blamed on
Keycloak forever: this sidecar and the SHARED forward-auth oauth2-proxy both
emitted oauth2-proxy's DEFAULT cookie names (_oauth2_proxy,
_oauth2_proxy_csrf) scoped to .home.keithjackson.dev, while holding DIFFERENT
cookie-secrets (compared the two live Secrets: they differ, so neither can
decrypt the other's cookie). One cookie pair, two proxies: whichever
authenticated last clobbered the other's session; the loser logged "No valid
authentication in request", bounced to Keycloak, and -- SSO session still live
-- was redirected straight back, clobbering it again. Fixed on this side only
(renaming the shared proxy's cookie would sign out every other gated app):
a unique --cookie-name plus a host-scoped --cookie-domain, since a per-app
reverse proxy has no reason to broadcast its session zone-wide.
--whitelist-domain stays zone-wide: it governs redirect targets, not cookies.

Two traps measured rather than assumed, both now documented:
  - The 3-strikes cooldown CANNOT fire behind this sidecar. Crafty records
    attempts under get_remote_ip() (honours X-Forwarded-For -- the live
    tracker key is the chain "192.168.2.132, 10.0.0.123") but checks the
    cooldown under request.remote_ip, which is always 127.0.0.1 here. The
    halves key differently, so get_recent_attempts() always reads empty.
    Good (no lockout risk) and bad (that throttle is not a control we have).
  - Crafty's `set_passwd` console command is unreachable in k8s: it calls
    getpass.getpass() and needs a TTY; the container runs `main.py -d -i`.
    Recovery has to go through the DB, and argon2 is installed ONLY in
    /crafty/.venv/bin/python -- the system python3 lacks it.

Deliberately NOT done: an initContainer seeding default.json from a SOPS
Secret to make a rebuilt Crafty's password deterministic. Crafty does support
that hook (find_default_password reads app/config/default.json, min 8 chars),
but the image entrypoint pulls its default config ONLY when app/config is
EMPTY -- so writing a file there pre-boot suppresses the whole config
bootstrap. Buying reproducibility means co-owning image-internal bootstrap
logic that a 4.11 bump could silently change, to replace a one-command
retrieval. Recorded in DECISIONS-LOG as considered and rejected.

New runbook runbooks/crafty-admin-credential.md: tell the two auth layers
apart from the sidecar log, retrieve and rotate the bootstrap credential,
and reset a lost one via argon2 -- generating the password in-pod into a 0600
file rather than passing it in, because dev.sh does not forward stdin (a
`kubectl exec -i` through it hangs; verified). Registered in the configMapGenerator
so the URL actually resolves, not just the file exists.

Gate: toolbox ./ci/gate.sh -- lint/l0/render-golden/l1 green, spec-immutability
green. rc 3 overall: gitea-settings SKIPPED (no SETTINGS_API_TOKEN) and L2/L3
NOT RUN (CLUSTER_AVAILABLE unset). No runtime browser round-trip performed --
the sign-in fix is UNVERIFIED end-to-end and needs the human test in the README.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 22:48:18 -05:00

97 lines
5.2 KiB
YAML

---
# Runbooks tier — reconciled by its OWN non-gating post-platform Flux Kustomization
# (clusters/base/runbooks.yaml, dependsOn platform-config, wait:false). Same placement and rationale as
# platform/homepage: a leaf web server must never sit in the gating `platform` tier, where a UI's
# unhealth once stalled the whole chain (DECISIONS-LOG: "Headlamp gating stall").
#
# WHY THE MANIFESTS LIVE HERE, ALONGSIDE THE DOCS, RATHER THAN IN platform/runbooks/.
# The markdown is the source of truth and the task is to serve it verbatim, which means the ConfigMap must
# be GENERATED from these files — there must not be a second hand-maintained copy of 34 documents that can
# drift from the originals. `configMapGenerator` does that, and its `files:` paths are resolved relative to
# THIS directory. A `platform/runbooks/kustomization.yaml` would therefore need `../../runbooks/<name>.md`,
# which depends on kustomize load restrictions being relaxed — true for Flux's kustomize-controller,
# refused by a plain `kustomize build`, and so a divergence between what the gate can verify and what the
# cluster does. Making this directory the Flux path removes the question entirely: no `../`, identical
# behaviour locally and in-cluster, one copy of every document. The serving manifests are tucked under
# `serving/` so `runbooks/*.md` stays exactly the flat set of pages the URLs name.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: runbooks
resources:
- serving/namespace.yaml
- serving/nginx-config.yaml
- serving/deployment.yaml
- serving/service.yaml
# default-deny baseline + Cilium gateway-ingress allow (DNS-only egress from the baseline).
- serving/networkpolicy.yaml
- serving/httproute.yaml # apex `/runbooks` prefix (binds the Gateway `https-apex` listener)
configMapGenerator:
# ONE ConfigMap holding every runbook, keyed by the EXACT name the alert annotations use.
#
# THE KEYS ARE EXTENSIONLESS ON PURPOSE. The annotations are `https://${BASE_DOMAIN}/runbooks/<name>`
# with NO `.md`, and this repo must not change them (they are spread across four alerts.yaml files that
# other work is editing). `<key>=<file>` maps `node-down.md` on disk to the key `node-down`, the volume
# mount turns each key into a file under the nginx root, and `/runbooks/node-down` becomes a plain static
# lookup — no rewrite rule, nothing to get subtly wrong at 3am. Drop the `<key>=` and every key would
# carry `.md`, so every alert link would 404 while this file still looked correct.
#
# The generated name carries kustomize's content hash, so editing any runbook rolls the server
# automatically and a stale projection is impossible.
#
# ADDING A RUNBOOK: add the file AND its line here. The L0 check
# conformance/tests/l0-static/alert-runbooks-resolvable asserts both halves, because a file in git with
# no generator entry is reviewed, merged, apparently done — and still a 404 in the cluster.
- name: runbooks-content
options:
labels:
app.kubernetes.io/name: runbooks
files:
# The index, served at /runbooks/ (nginx `try_files /runbooks/README`).
- README=README.md
- backup-failure=backup-failure.md
- backup-missed=backup-missed.md
- backup-partial=backup-partial.md
- cdi-import-stuck=cdi-import-stuck.md
- cert-expiry=cert-expiry.md
- cert-issuance=cert-issuance.md
- cnpg-archiving=cnpg-archiving.md
- cnpg-backup-missed=cnpg-backup-missed.md
- cnpg-exporter=cnpg-exporter.md
- cnpg-primary-spread=cnpg-primary-spread.md
- cnpg-wal-growth=cnpg-wal-growth.md
# Operator procedure, not alert-linked (Crafty's local login is a second factor behind
# the Keycloak gate and its bootstrap password lives only on the PVC).
- crafty-admin-credential=crafty-admin-credential.md
- denied-flows=denied-flows.md
- flux-reconcile=flux-reconcile.md
- flux-suspended=flux-suspended.md
- image-automation=image-automation.md
- image-policy-stalled=image-policy-stalled.md
- keycloak-client-secrets=keycloak-client-secrets.md
- keycloak-db=keycloak-db.md
- keycloak-down=keycloak-down.md
- kubevirt-control-plane=kubevirt-control-plane.md
- kubevirt-crashloop=kubevirt-crashloop.md
- kubevirt-launch-failed=kubevirt-launch-failed.md
- longhorn-capacity=longhorn-capacity.md
- longhorn-degraded=longhorn-degraded.md
- longhorn-faulted=longhorn-faulted.md
- monitoring-capacity=monitoring-capacity.md
- node-cpu=node-cpu.md
- node-disk=node-disk.md
- node-down=node-down.md
- node-io=node-io.md
- node-memory=node-memory.md
- node-memory-swap=node-memory-swap.md
# Operator procedure, not an alert page (no alert links to it — the
# alert-runbooks-resolvable check treats that as advisory). It is served
# because the plex.tv claim window is short and is not a thing to go looking
# for in git: /runbooks/plex-claim has to resolve the moment it is needed.
- plex-claim=plex-claim.md
- pvc-capacity=pvc-capacity.md
- targets-down=targets-down.md
- update-rollback=update-rollback.md
- vpn-down=vpn-down.md
- vpn-egress-stale=vpn-egress-stale.md