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]>
221 lines
13 KiB
YAML
221 lines
13 KiB
YAML
---
|
|
# Crafty Controller pod = TWO containers:
|
|
# 1. crafty — the panel + every Minecraft server it creates (Java + Bedrock) run as
|
|
# SUBPROCESSES inside THIS container. Serves its web UI over HTTPS (self-signed)
|
|
# on :8443. Never exposed directly — only the sidecar and the game-port LB reach it.
|
|
# 2. oauth2-proxy — a per-pod REVERSE-PROXY (Option B, platform/oauth2-proxy/README.md). The shared
|
|
# Gateway routes crafty.home.keithjackson.dev -> this sidecar (:4180); the sidecar
|
|
# authenticates the browser against Keycloak (homelab realm, group platform-admins)
|
|
# and only THEN proxies to the co-located Crafty UI at https://127.0.0.1:8443. This
|
|
# is how Crafty's UI (which has NO native OIDC) is Keycloak-gated without ext_authz
|
|
# on the shared Gateway (impossible on Cilium 1.16.5 — see the oauth2-proxy README).
|
|
#
|
|
# Rendered by its OWN non-gating post-platform Flux Kustomization (clusters/base/crafty.yaml,
|
|
# dependsOn platform-config, wait:false) — a leaf UI must NEVER sit in the gating `platform` tier
|
|
# (DECISIONS-LOG: "Headlamp gating stall").
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: crafty
|
|
namespace: crafty
|
|
labels:
|
|
app.kubernetes.io/name: crafty
|
|
spec:
|
|
replicas: 1
|
|
# Crafty owns a single RWO Longhorn volume with live SQLite + running server subprocesses; a second
|
|
# replica would double-mount/corrupt it. Recreate (not RollingUpdate) so the old pod releases the RWO
|
|
# PVC before the new one attaches.
|
|
strategy:
|
|
type: Recreate
|
|
revisionHistoryLimit: 3
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: crafty
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: crafty
|
|
spec:
|
|
automountServiceAccountToken: false
|
|
# ── IDENTITY / STORAGE PERMISSIONS (the seerr fsGroup lesson, applied) ───────────────────────
|
|
# The Crafty image runs as the non-root user `crafty` (UID 1000) whose PRIMARY GROUP is root
|
|
# (GID 0): its Dockerfile does `useradd -g root -M crafty` + `chown -R crafty:root /crafty` and
|
|
# makes /crafty group-writable — the OpenShift/K8s "arbitrary-UID, root-group-perms" pattern the
|
|
# Crafty docs call out. A fresh Longhorn PVC is root:root and NOT group-writable, so the crafty
|
|
# process (GID 0) could not create /crafty/servers/* etc. -> CrashLoop (same failure seerr hit as
|
|
# UID 1000 on a root:root PVC). fsGroup=0 makes the kubelet group-own the mounted volume to GID 0,
|
|
# mark it group-writable + SGID, and add GID 0 as a supplemental group, so the crafty user can
|
|
# write — Longhorn's CSIDriver fsGroupPolicy: ReadWriteOnceWithFSType applies fsGroup to this RWO
|
|
# filesystem volume. runAsUser/runAsGroup pin the image's own crafty:root identity; runAsNonRoot
|
|
# asserts it. (HUMAN runtime check at bring-up: confirm the pod writes the DB and starts a server;
|
|
# if the image's crafty UID is not 1000, adjust runAsUser — see README.)
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 1000
|
|
runAsGroup: 0
|
|
fsGroup: 0
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
containers:
|
|
# ─────────────────────────────── Crafty panel + MC servers ──────────────────────────────────
|
|
- name: crafty
|
|
# registry.gitlab.com/crafty-controller/crafty-4 — official image. Pinned to the current
|
|
# stable release (4.10.8, 2026-07-29; tags are bare semver, NO `v` prefix). Renovate maintains.
|
|
image: registry.gitlab.com/crafty-controller/crafty-4:4.10.8
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: TZ
|
|
value: "America/Chicago"
|
|
ports:
|
|
- { name: https-ui, containerPort: 8443, protocol: TCP }
|
|
# Starter game ports the operator assigns to servers created in the panel. Declared here for
|
|
# documentation + to back the game-port LoadBalancer (service-l4.yaml). Add more as needed.
|
|
- { name: mc-java-0, containerPort: 25565, protocol: TCP }
|
|
- { name: mc-java-1, containerPort: 25566, protocol: TCP }
|
|
- { name: mc-java-2, containerPort: 25567, protocol: TCP }
|
|
- { name: mc-bedr-0, containerPort: 19132, protocol: UDP }
|
|
- { name: mc-bedr-1, containerPort: 19133, protocol: UDP }
|
|
- { name: mc-bedr-2, containerPort: 19134, protocol: UDP }
|
|
resources:
|
|
requests:
|
|
cpu: 250m
|
|
# Raised 1Gi -> 3Gi when the first Minecraft server ("homelab", Paper 26.2) was created:
|
|
# that JVM runs with -Xms2000M -Xmx2000M, so the container's steady-state floor is now the
|
|
# panel (~100Mi) PLUS a permanently-committed 2 GiB heap plus JVM overhead. Measured
|
|
# cgroup memory.current with the server idle-but-running: 2.86Gi. A 1Gi request understated
|
|
# that by ~3x, which both misleads the scheduler and makes this Burstable pod an early
|
|
# eviction candidate under node memory pressure (kubelet ranks by usage-over-request).
|
|
memory: 3Gi
|
|
# Minecraft JVMs are memory-hungry; cap generously for a starter set of small servers.
|
|
# Headroom check for the 4Gi cap: 2 GiB heap + ~0.5 GiB JVM non-heap (metaspace, code cache,
|
|
# GC structures, thread stacks, mmap'd region files) + ~0.1 GiB panel = ~2.9Gi observed,
|
|
# leaving ~1.2Gi. A 3 GiB heap would have landed near 4Gi and risked an OOMKill, which is
|
|
# why the server was sized at a 2 GiB heap rather than larger. Raise BOTH this limit and the
|
|
# server's own memory in Crafty together if a bigger heap is ever wanted.
|
|
limits:
|
|
memory: 4Gi
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
# readOnlyRootFilesystem intentionally UNSET: Crafty's launcher writes runtime state under
|
|
# /crafty (app cache, per-server working dirs) beyond the mounted subPaths; hardening it
|
|
# needs writable emptyDirs and is a deferred, runtime-verified follow-up (as headlamp notes).
|
|
volumeMounts:
|
|
# ONE Longhorn PVC mounted five times by subPath — exactly the dirs Crafty's docker-compose
|
|
# maps. Everything else under /crafty is read-only image content.
|
|
- { name: crafty-data, mountPath: /crafty/servers, subPath: servers }
|
|
- { name: crafty-data, mountPath: /crafty/backups, subPath: backups }
|
|
- { name: crafty-data, mountPath: /crafty/logs, subPath: logs }
|
|
- { name: crafty-data, mountPath: /crafty/import, subPath: import }
|
|
- { name: crafty-data, mountPath: /crafty/app/config, subPath: config }
|
|
# Crafty inits its SQLite DB + Tornado server on first boot (can take ~30-60s). TCP-probe the
|
|
# HTTPS UI port (an HTTPS GET would fight the self-signed cert). Generous startup budget.
|
|
startupProbe:
|
|
tcpSocket:
|
|
port: https-ui
|
|
periodSeconds: 10
|
|
failureThreshold: 30
|
|
livenessProbe:
|
|
tcpSocket:
|
|
port: https-ui
|
|
periodSeconds: 20
|
|
failureThreshold: 6
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: https-ui
|
|
periodSeconds: 15
|
|
# ─────────────────────────── oauth2-proxy reverse-proxy sidecar ─────────────────────────────
|
|
- name: oauth2-proxy
|
|
# quay.io/oauth2-proxy/oauth2-proxy — same authenticator family as platform/oauth2-proxy, but
|
|
# here in per-app REVERSE-PROXY mode (--upstream is the co-located Crafty UI, not static://202).
|
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
|
imagePullPolicy: IfNotPresent
|
|
args:
|
|
- --http-address=0.0.0.0:4180
|
|
- --reverse-proxy=true
|
|
- --provider=oidc
|
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
|
# Gate the co-located Crafty UI. Crafty serves HTTPS with a SELF-SIGNED cert, so skip verify
|
|
# on this in-pod (127.0.0.1) hop; the browser-facing TLS is the trusted wildcard at the Gateway.
|
|
- --upstream=https://127.0.0.1:8443
|
|
- --ssl-upstream-insecure-skip-verify=true
|
|
- --email-domain=*
|
|
- --scope=openid email profile
|
|
- --oidc-groups-claim=groups
|
|
# R-IDP-06: a valid Keycloak session is NOT enough — the user must ALSO be in platform-admins,
|
|
# else /oauth2 returns 403. (Matches the platform oauth2-proxy floor.)
|
|
- --allowed-group=platform-admins
|
|
# Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button.
|
|
- --skip-provider-button=true
|
|
- --redirect-url=https://crafty.${BASE_DOMAIN}/oauth2/callback
|
|
# ── COOKIE ISOLATION (bug fix 2026-09-29) ────────────────────────────────────────────
|
|
# This sidecar and the SHARED forward-auth oauth2-proxy (platform/oauth2-proxy) are
|
|
# INDEPENDENT instances holding DIFFERENT cookie-secrets (verified in-cluster: the two
|
|
# `cookie-secret` values differ, so neither can decrypt the other's cookie). Both used
|
|
# to emit oauth2-proxy's DEFAULT cookie names (`_oauth2_proxy` and `_oauth2_proxy_csrf`)
|
|
# scoped to `.${BASE_DOMAIN}`, so the browser kept ONE pair of cookies for BOTH proxies:
|
|
# whichever authenticated last overwrote the other's session, and the loser then logged
|
|
# "No valid authentication in request" and bounced to Keycloak -- which, thanks to the
|
|
# live Keycloak SSO session, silently redirected back and overwrote the cookie again.
|
|
# That mutual eviction presents EXACTLY as the reported symptom: "I signed in to
|
|
# Keycloak but I am still not signed in to the app."
|
|
# Two independent fixes, both applied here (only this side changes -- renaming the
|
|
# SHARED proxy's cookie would sign every other gated app out):
|
|
# 1. --cookie-name: a unique name, so the two sessions cannot alias at all.
|
|
# 2. --cookie-domain: HOST-scoped. A per-app reverse proxy has no reason to broadcast
|
|
# its session to every other host in the zone; the shared proxy keeps the wide
|
|
# scope because cross-host forward-auth is precisely its job.
|
|
# --whitelist-domain deliberately stays zone-wide: it governs permitted post-login
|
|
# REDIRECT targets, not cookie scope.
|
|
- --cookie-name=_oauth2_proxy_crafty
|
|
- --cookie-domain=crafty.${BASE_DOMAIN}
|
|
- --whitelist-domain=.${BASE_DOMAIN}
|
|
- --cookie-secure=true
|
|
- --pass-access-token=true
|
|
- --set-xauthrequest=true
|
|
env:
|
|
# client-id / client-secret MUST match the Keycloak `crafty` client
|
|
# (keycloak-realm-clients secret, crafty-client-secret). cookie-secret is a random 32-byte
|
|
# value. All three from the SOPS-encrypted crafty-oauth2-proxy Secret, by name only (R-IDP-04).
|
|
- name: OAUTH2_PROXY_CLIENT_ID
|
|
valueFrom:
|
|
secretKeyRef: { name: crafty-oauth2-proxy, key: client-id }
|
|
- name: OAUTH2_PROXY_CLIENT_SECRET
|
|
valueFrom:
|
|
secretKeyRef: { name: crafty-oauth2-proxy, key: client-secret }
|
|
- name: OAUTH2_PROXY_COOKIE_SECRET
|
|
valueFrom:
|
|
secretKeyRef: { name: crafty-oauth2-proxy, key: cookie-secret }
|
|
ports:
|
|
- { name: http, containerPort: 4180, protocol: TCP }
|
|
resources:
|
|
requests:
|
|
cpu: 25m
|
|
memory: 64Mi
|
|
limits:
|
|
memory: 128Mi
|
|
securityContext:
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /ping
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 15
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /ping
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 15
|
|
volumes:
|
|
- name: crafty-data
|
|
persistentVolumeClaim:
|
|
claimName: crafty-data
|