Onboard Crafty Controller (Minecraft panel) with Keycloak-gated web UI

Add Crafty Controller 4 (registry.gitlab.com/crafty-controller/crafty-4:4.10.8)
as a platform component: a web panel that runs Java + Bedrock Minecraft servers as
subprocesses in one pod + a 20Gi Longhorn PVC (mounted 5x by subPath).

Auth = Option B (per-app oauth2-proxy reverse-proxy sidecar), since Cilium 1.16.5
cannot do ext_authz on the shared Gateway:
- oauth2-proxy v7.8.1 sidecar (:4180) reverse-proxies to Crafty's self-signed HTTPS
  UI (127.0.0.1:8443), gated to Keycloak homelab realm group platform-admins.
- Confidential `crafty` client provisioned live via kcadm + captured in the realm
  import; client secret + cookie secret via SOPS/age (mirrored in realm-clients).

- Web UI: HTTPRoute crafty.home.keithjackson.dev -> Service :80 -> sidecar :4180.
- Game ports: Cilium L2 LoadBalancer on VIP 192.168.1.55 (next free after .53/.54),
  starter set TCP 25565-25567 (Java) + UDP 19132-19134 (Bedrock), NOT auth-gated.
- fsGroup:0 / runAsGroup:0 / runAsUser:1000 for the image's crafty:root identity on
  the root:root Longhorn PVC (the seerr fsGroup lesson).
- Own non-gating Flux tier clusters/base/crafty.yaml (dependsOn platform-config,
  wait:false) + sops-decryption patch in clusters/dev — a leaf UI must never gate
  the platform.
- Explicit default-deny NetworkPolicy + CiliumNetworkPolicies (platform namespace,
  not app-namespace, so Kyverno does not auto-generate the baseline).

make -C ci gate green; kustomize build of platform/crafty, clusters/base,
clusters/dev clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-28 15:39:51 -05:00
1 parent a207103c7b
commit 0437871807
17 files changed
+779 -2

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
---
# Flux Kustomization: Crafty Controller — reconciles ./platform/crafty.
#
# POST-PLATFORM TIER (dependsOn platform-config). Crafty is a LEAF Minecraft-management UI that depends
# on Keycloak (the oauth2-proxy sidecar authenticates against it) + the trusted wildcard TLS cert +
# split-horizon DNS + the Gateway API CRDs + the Cilium L2 LoadBalancer pool (game-port VIP) — all
# brought up by `platform` (controllers) and `platform-config` (Keycloak realm/CRs, the wildcard
# Certificate, cilium L2 pool + announcement, Gateway). It therefore MUST NOT live inside the gating
# `platform` tier: a UI whose pod can crashloop (image pull, DB init, sidecar OIDC discovery during a
# cold start) would keep `platform` not-Ready, and while `platform` is not-Ready NOTHING downstream
# reconciles — the exact incident behind the headlamp/oauth2-proxy/homepage moves (DECISIONS-LOG:
# "Headlamp gating stall"). Same lesson, same fix: a leaf reconciles in its OWN post-platform tier.
#
# NON-GATING (wait:false): nothing in the graph dependsOn crafty, and its pod can be long-not-Ready
# during a cold start (Longhorn attach + Crafty DB init) while it waits on Keycloak/DNS/TLS.
# wait:false guarantees its health can NEVER gate anything, even if a future resource is (mistakenly)
# pointed dependsOn it.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: crafty
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
prune: true
wait: false
dependsOn:
- name: platform-config # Keycloak realm + wildcard TLS + split-horizon VIP + Gateway + L2 pool first
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/crafty
# cluster-vars retained for parity with the other tiers / future overlay knobs (crafty references no
# ${VARS} today).
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
# SOPS decryption for crafty-oauth2-proxy-credentials.sops.yaml is layered per-environment by the
# overlay (clusters/dev patches THIS Kustomization by name), identical to headlamp/oauth2-proxy.
+4
View File
@@ -30,4 +30,8 @@ resources:
# A leaf UI at home.keithjackson.dev; same non-gating rationale as headlamp/oauth2-proxy (a UI must
# never gate the platform chain). Carries no SOPS secret, so clusters/dev adds no decryption patch.
- homepage.yaml # Flux Kustomization -> ./platform/homepage (dependsOn platform-config)
# Crafty Controller — Minecraft management UI, web UI Keycloak-gated by a per-pod oauth2-proxy
# reverse-proxy sidecar (Option B). POST-platform tier (dependsOn platform-config, wait:false); same
# non-gating leaf-UI rationale as headlamp/homepage/oauth2-proxy (a UI must never gate the platform).
- crafty.yaml # Flux Kustomization -> ./platform/crafty (dependsOn platform-config)
- cluster-vars.yaml # overlay knob surface (base defaults)
+14
View File
@@ -91,6 +91,20 @@ patches:
provider: sops
secretRef:
name: sops-age
# crafty has its own post-platform Flux Kustomization (clusters/base/crafty.yaml). It carries
# crafty-oauth2-proxy-credentials.sops.yaml, so it needs its own SOPS decryption stanza (same as
# headlamp/oauth2-proxy). The `sops-age` Secret is created out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: crafty
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
- patch: |-
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
+119
View File
@@ -0,0 +1,119 @@
# platform/crafty
**Crafty Controller 4** — a web panel that creates and manages multiple Minecraft servers (Java +
Bedrock) as **subprocesses inside its own container**. Its web UI is **Keycloak-gated** via a per-pod
**oauth2-proxy reverse-proxy sidecar** (the decided **Option B**, see
[`../oauth2-proxy/README.md`](../oauth2-proxy/README.md)). Game ports are exposed **un-gated** on a
Cilium L2 LoadBalancer (game protocols authenticate players themselves).
- **Owner:** WS-APP (auth wiring shared with WS-SEC)
- **Flux tier:** own non-gating `clusters/base/crafty.yaml` (`dependsOn platform-config`, `wait:false`)
- **Namespace:** `crafty` (PLATFORM namespace — `gateway-access=shared`, NOT `app-namespace=true`)
## Deploy summary
| Thing | Value |
|---|---|
| Image | `registry.gitlab.com/crafty-controller/crafty-4:4.10.8` (current stable; bare-semver tags, no `v`) |
| Web UI | HTTPS **self-signed** on `:8443` (reached only via the sidecar / same-pod) |
| Sidecar | `quay.io/oauth2-proxy/oauth2-proxy:v7.8.1`, reverse-proxy on `:4180` |
| Data | ONE Longhorn RWO PVC `crafty-data` (20Gi), mounted 5× by subPath |
| PVC subPaths | `servers`→`/crafty/servers`, `backups`→`/crafty/backups`, `logs`→`/crafty/logs`, `import`→`/crafty/import`, `config`→`/crafty/app/config` |
| Identity | `runAsUser:1000 runAsGroup:0 fsGroup:0 runAsNonRoot:true` (see below) |
| Web host | `crafty.home.keithjackson.dev` → shared Gateway → `crafty` Service :80 → sidecar :4180 → Crafty :8443 |
| Game VIP | **192.168.1.55** (Cilium L2 LoadBalancer; `.53`=Gateway, `.54`=unifi, `.55`=crafty) |
| Game ports | starter set: **TCP 25565-25567** (Java), **UDP 19132-19134** (Bedrock) |
### fsGroup / user decision (the seerr lesson, applied)
Crafty's Dockerfile does `useradd -g root -M crafty` (UID **1000**, primary group **root/GID 0**),
`chown -R crafty:root /crafty`, and makes `/crafty` group-writable — the OpenShift/K8s
"arbitrary-UID, root-group-perms" pattern. A fresh Longhorn PVC is `root:root` and not group-writable,
so the non-root crafty process could not create `/crafty/servers/*` etc. and would CrashLoop (exactly
seerr's failure at UID 1000 on a `root:root` PVC). Fix: **`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;
**`runAsGroup: 0`** + **`runAsUser: 1000`** pin the image's own `crafty:root` identity. Longhorn's
`CSIDriver fsGroupPolicy: ReadWriteOnceWithFSType` applies fsGroup to this RWO filesystem volume.
> **HUMAN runtime check:** confirm the pod writes its SQLite DB and can start a server. If the image's
> `crafty` UID is ever not 1000, adjust `runAsUser` (the launcher's non-root path runs directly as the
> current UID, so any non-root UID with GID 0 works, but 1000 matches `getpwuid` cleanly).
## Auth = Option B (per-app oauth2-proxy reverse-proxy)
Cilium 1.16.5 cannot inject `ext_authz` into the shared Gateway's machine-generated Envoy config, and
native Gateway-API ExternalAuth ships in no released Cilium yet (see `../oauth2-proxy/README.md`). So
Crafty's UI is gated by a **dedicated oauth2-proxy sidecar in the Crafty pod** (`--reverse-proxy`,
`--upstream=https://127.0.0.1:8443`, `--ssl-upstream-insecure-skip-verify=true`,
`--allowed-groups=platform-admins`, `--skip-provider-button`, cookie/redirect for
`crafty.home.keithjackson.dev`). The `crafty` Service targets the sidecar, never Crafty's `:8443`, so
the gate is **in the data path** (fail-closed: no valid `platform-admins` session ⇒ no upstream).
> **Note:** Crafty ALSO has its own local admin login (it has no OIDC). After oauth2-proxy admits the
> user, they still see Crafty's own login page — a second, defense-in-depth factor. The operator logs
> in with Crafty's local admin credentials (set on first boot). This is expected, not a bug.
### Keycloak client
Confidential auth-code client **`crafty`** in the `homelab` realm — redirect
`https://crafty.home.keithjackson.dev/oauth2/callback`, `webOrigins` the same host, a `groups`
membership mapper (bare names). Provisioned **live via kcadm** at authoring time (2026-08-28) and
captured as the durable GitOps source of truth in
[`../keycloak/keycloak-realm-import.yaml`](../keycloak/keycloak-realm-import.yaml) (secret injected via
the `CRAFTY_CLIENT_SECRET` placeholder).
### Secret locations (SOPS/age)
| Secret | File | Key |
|---|---|---|
| oauth2-proxy client id/secret + cookie secret | `crafty-oauth2-proxy-credentials.sops.yaml` (this dir) | `client-id`, `client-secret`, `cookie-secret` |
| Keycloak client secret (mirror) | `../keycloak/keycloak-realm-clients.sops.yaml` | `crafty-client-secret` |
`client-secret` here **must equal** `crafty-client-secret` there (cross-namespace mirror — a Secret
can't span namespaces). Both are age-encrypted to the repo `sops-age` recipient and Flux-decryptable
(verified: both decrypt and the two values match).
## Game-port L4 exposure
The Gateway is L7-HTTPS-only; raw TCP/UDP game ports can't ride it. `service-l4.yaml` exposes them as
two `type=LoadBalancer` Services (TCP + UDP) sharing the pinned VIP **192.168.1.55**
(`lbipam.cilium.io/sharing-key: crafty`), ARP-announced by the existing `default-l2` policy. This path
is **NOT auth-gated** — Minecraft Java (Mojang/Microsoft) and Bedrock (Xbox Live) authenticate players
themselves. `external-dns` publishes `crafty-mc.home.keithjackson.dev → 192.168.1.55`.
### Adding more game ports (when the operator creates servers)
The operator has **not created any servers yet**; the starter set covers 3 Java + 3 Bedrock slots. To
add capacity:
1. In the Crafty UI, create the server and bind it to a NEW port (Java TCP or Bedrock UDP).
2. Add that port to the matching Service in `service-l4.yaml` (one
`{ name, port, targetPort, protocol }` line — K8s Services enumerate ports, no ranges).
3. Add it to the game-port ingress rule in `networkpolicy.yaml` (`crafty-allow-ingress`).
4. (Optional) add a `containerPort` in `deployment.yaml` for documentation.
5. Commit → Flux reconciles.
## Policies
`namespace.yaml` sets only `gateway-access=shared` (platform namespace, like headlamp/homepage), so
Kyverno does not auto-generate the app baseline. `networkpolicy.yaml` ships it explicitly (defense in
depth): default-deny (DNS-only egress) + Cilium allows — Gateway→sidecar `:4180`, world/LAN→game
ports, host→probe ports, and egress `world` (server-jar downloads + the OIDC path to Keycloak via the
Gateway VIP). The sidecar→Crafty hop is pod-local (`127.0.0.1`) and not subject to network policy.
## Verification
- `make -C ci gate` — green (hand-authored platform manifests; render-golden unaffected).
- `kustomize build` of `platform/crafty`, `clusters/base`, `clusters/dev` — clean.
### Human tests (post-deploy)
1. **Browser OIDC round-trip:** incognito → `https://crafty.home.keithjackson.dev` → expect a 302 to
`https://keycloak.home.keithjackson.dev/realms/homelab/...` (NOT the Crafty UI). Log in as a
**platform-admins** user (MFA/OTP enforced) → land on Crafty's own login → sign in with Crafty's
local admin. A **non-platform-admins** user → **403** (authenticated but not authorized).
2. **Pod health / fsGroup:** `kubectl -n crafty get pod` Ready 2/2; Crafty writes its DB under
`/crafty/app/config` and `/crafty/servers` without EACCES.
3. **Server-create + game reach:** in the Crafty UI create a Java server bound to `25565`; start it;
connect a Minecraft client to `crafty-mc.home.keithjackson.dev:25565` (or `192.168.1.55:25565`)
from the LAN and confirm the world loads. (Bedrock: a server on `19132/udp` reached at the VIP.)
@@ -0,0 +1,30 @@
apiVersion: v1
kind: Secret
metadata:
name: crafty-oauth2-proxy
namespace: crafty
type: Opaque
stringData:
client-id: ENC[AES256_GCM,data:86ALvhXE,iv:S+guxBop+DkVX+SxxXObtzt5MYKVXo4NRksGPqwfUhA=,tag:Gllc0vKia1X1PFBF663apw==,type:str]
client-secret: ENC[AES256_GCM,data:0g/6FjJGHc8ndxkb9fJGvAmDWRVb7DhkC7hYubbQuncXjmo/9F33E/qMw4Zie/XOWL0hrb+OpdXETa4xH3XQeA==,iv:Wa2TPUfwBlxIntVz0cHbarGdK7SpL2KL24i1N1YYeWQ=,tag:AMEu0YI3O+4hWSJm4hsHaA==,type:str]
cookie-secret: ENC[AES256_GCM,data:FXHwQtpz1dGTV9gkzgx92Dh8yl3MbYZB/Q5Txfd9q7dbxDyKcmsOlxhAGuk=,iv:wVwcgbLEayUpDzMbTwMkT+jg3jVb/ztoqbXaKL41cVA=,tag:Q1Yl8PrkNBFg3x7OjYmCCQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPa2I3ZU9zQUs3bnQ0dmJx
U3o0YXoyRWdaWmRrK2tBL0JRZjU1QTNCczA4CkI5RVZJRWFrWkJyL2ZJNU9EZWRP
ZjloR3VkUjFEeE93c2lsKzJSaTVTMFkKLS0tIGhyREVqWVZSanNnOHBLak9IdTQv
S0l3WFdPMFNzT1VoaVc2QnBIQXJDVGcKkjpqUkJa7TPYfSrjOxBB3lsffEHy5Thm
KuIMdXH5oiaCwORyjyPsFg/h3NOCSgzc0dolbQzoSynYb3vztlwmdQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-28T20:36:54Z"
mac: ENC[AES256_GCM,data:zfv1JaZrz2eyQF6SKrTi6YfmGXpKK8miRa5RHiTsq0FPFfFP6Xc7n47RH1tZj5CRgf86wKZB3H7m6jlxZ3mdpshqK3B41sONHC3TGLdJUJt8bw/a5GbyVg+xs41aLcldylq4zrDGXq/aj9c1/lfbtd1xxIpWaNiuvvSK6y21Tmc=,iv:hF5nskThNA/uY0tvYFMwtWrPhDSUqoVX8Y10TgdIDJA=,tag:IxoUv7kbrouxXLY23cElDg==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -0,0 +1,37 @@
# =============================================================================
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize and never
# auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
#
# WHAT THIS IS: the OIDC client + cookie secrets for the per-app oauth2-proxy SIDECAR that gates the
# Crafty web UI (deployment.yaml, container `oauth2-proxy`). The sidecar reads these three keys as
# OAUTH2_PROXY_CLIENT_ID / OAUTH2_PROXY_CLIENT_SECRET / OAUTH2_PROXY_COOKIE_SECRET (env, by name only;
# R-IDP-04). SOPS-encrypted; keys stay cleartext for reviewable diffs.
#
# The `client-secret` value MUST equal the `crafty-client-secret` placed in
# platform/keycloak/keycloak-realm-clients.sops.yaml (which the realm import injects into the Keycloak
# `crafty` client) — same cross-namespace mirror pattern as grafana/gitea/headlamp (a Secret cannot be
# shared across namespaces, so the one value is stored in two encrypted files).
#
# HAND-OFF STEPS (operator):
# (a) client-id is fixed: `crafty` (matches the Keycloak client).
# (b) client-secret: use the SAME value generated for keycloak-realm-clients crafty-client-secret
# openssl rand -hex 32
# (c) cookie-secret: a fresh random 32-byte value, url-safe base64:
# openssl rand -base64 32 | tr -- '+/' '-_'
# (d) Copy to: platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml
# (e) Fill values; encrypt:
# sops -e -i platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml
# (f) It is already listed under `resources:` in platform/crafty/kustomization.yaml.
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: crafty-oauth2-proxy
namespace: crafty
type: Opaque
stringData:
client-id: crafty
# MUST match keycloak-realm-clients crafty-client-secret.
client-secret: REPLACE-ME
# Random 32-byte url-safe-base64 cookie signing secret (oauth2-proxy).
cookie-secret: REPLACE-ME
+189
View File
@@ -0,0 +1,189 @@
---
# 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
memory: 1Gi
# Minecraft JVMs are memory-hungry; cap generously for a starter set of small servers.
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.home.keithjackson.dev/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-groups=platform-admins
# Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button.
- --skip-provider-button=true
- --redirect-url=https://crafty.home.keithjackson.dev/oauth2/callback
- --cookie-domain=.home.keithjackson.dev
- --whitelist-domain=.home.keithjackson.dev
- --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
+36
View File
@@ -0,0 +1,36 @@
---
# North-south ingress for the Crafty web UI at crafty.home.keithjackson.dev. Attaches to the shared
# Gateway (docs/09); TLS terminated at the Gateway by the *.home.keithjackson.dev wildcard cert (this
# host is covered). Same-namespace backend, so no ReferenceGrant; the parentRef is permitted because
# the crafty namespace carries gateway-access=shared. CR of the Gateway API CRDs — the CRDs are
# registered by `platform`, which is Ready before this tier's platform-config dependency completes.
#
# auth-mode annotation records that this route is gated by a per-app oauth2-proxy REVERSE-PROXY sidecar
# (Option B), NOT by Gateway ext_authz forward-auth (impossible on Cilium 1.16.5) and NOT native OIDC
# (Crafty has none). The backendRef is the `crafty` Service, which fronts the sidecar (:4180) — so the
# gate is IN the data path; the route needs no ext_authz filter of its own.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: crafty
namespace: crafty
labels:
app.kubernetes.io/name: crafty
annotations:
homelab.keithjackson.dev/auth-mode: oauth2-proxy
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- crafty.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: crafty
port: 80
+25
View File
@@ -0,0 +1,25 @@
---
# Crafty Controller — Minecraft management panel, web UI Keycloak-gated by a per-pod oauth2-proxy
# reverse-proxy sidecar (Option B). Rendered by its OWN post-platform Flux Kustomization
# (clusters/base/crafty.yaml, dependsOn platform-config, wait:false) — NOT by the gating `platform`
# tier: a leaf UI whose pod can be legitimately unhealthy must never gate the platform chain
# (DECISIONS-LOG: "Headlamp gating stall"). Same non-gating pattern as headlamp/homepage/oauth2-proxy.
#
# The Gateway API CRs (httproute) + LoadBalancer Services travel WITH the component here: the CRDs they
# need (gateway.networking.k8s.io, cilium.io) are registered by `platform`, and `platform` is Ready
# before this tier's platform-config dependency completes, so ordering holds.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- pvc.yaml
- deployment.yaml
- service.yaml
# oauth2-proxy sidecar client + cookie secrets — SOPS-encrypted, decrypted per-env by the crafty
# Kustomization (clusters/dev patches it by name). Referenced by name only (R-IDP-04).
- crafty-oauth2-proxy-credentials.sops.yaml
# default-deny baseline + Cilium ingress(gateway/game)/egress(world) allows.
- networkpolicy.yaml
# web-UI ingress (crafty.home -> oauth2-proxy sidecar) + game-port L4 LoadBalancer (VIP .55).
- httproute.yaml
- service-l4.yaml
+21
View File
@@ -0,0 +1,21 @@
---
# Crafty Controller — Minecraft server management panel (Java + Bedrock). Its web UI is served at
# crafty.home.keithjackson.dev via the shared Gateway, GATED by a per-pod oauth2-proxy sidecar in
# reverse-proxy mode (Keycloak `homelab` realm, group `platform-admins`) — the decided Option-B auth
# pattern (platform/oauth2-proxy/README.md: Cilium 1.16.5 cannot do ext_authz on the shared Gateway).
#
# gateway-access=shared opts this namespace in to attach HTTPRoutes to the shared Gateway (the
# *.home.keithjackson.dev wildcard cert already covers crafty.home). The Gateway routes to the
# oauth2-proxy sidecar (:4180), never directly to Crafty's UI.
#
# NOTE: intentionally NOT homelab.keithjackson.dev/app-namespace=true. Crafty is a PLATFORM admin tool
# (like Headlamp in `headlamp` / Homepage in `homepage`), so Kyverno does NOT generate the app
# default-deny baseline here and the app-scoped auth-mode / egress-class / host-namespace policies do
# not apply (all Kyverno policies match namespaceSelector app-namespace=true). We still ship an
# explicit default-deny NetworkPolicy + CiliumNetworkPolicies (networkpolicy.yaml) as defense in depth.
apiVersion: v1
kind: Namespace
metadata:
name: crafty
labels:
gateway-access: "shared"
+98
View File
@@ -0,0 +1,98 @@
---
# Default-deny baseline (R-NET-01): deny all ingress + egress, allow only DNS. `crafty` is a PLATFORM
# namespace so Kyverno does NOT auto-generate this (its policies match app-namespace=true) — we ship it
# explicitly (defense in depth), same shape as the Kyverno-generated app baseline and as
# headlamp/homepage. Cilium enforces the UNION of this NetworkPolicy and the CiliumNetworkPolicies
# below (allow if ANY policy allows), so those add the Gateway/game/world paths on top of deny-all.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: crafty
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# INGRESS allows. The crafty pod runs BOTH containers (oauth2-proxy sidecar :4180 + Crafty :8443 + the
# MC game-port subprocesses), all selected by app.kubernetes.io/name=crafty.
# - Gateway (Cilium `ingress` entity) -> oauth2-proxy sidecar :4180 ONLY. The Gateway can reach the
# GATE, never Crafty's :8443 directly (there is no Service for :8443 either).
# - LAN players + admins (`world` = off-cluster to Cilium) -> the raw game ports. NOT auth-gated:
# Minecraft protocols authenticate the player. `host`/`cluster` cover node-local health probes and
# any in-cluster caller.
# - `host` (the kubelet) -> :8443 and :4180 for the liveness/readiness probes.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: crafty-allow-ingress
namespace: crafty
labels:
app.kubernetes.io/name: crafty
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: crafty
ingress:
# Gateway -> the oauth2-proxy gate. Kubelet (host) probes the gate too.
- fromEntities:
- ingress
- host
toPorts:
- ports:
- { port: "4180", protocol: TCP }
# LAN/world players -> raw game ports (starter set). Add ports here when new servers are created.
- fromEntities:
- world
- cluster
- host
toPorts:
- ports:
- { port: "25565", protocol: TCP } # Java slot 0
- { port: "25566", protocol: TCP } # Java slot 1
- { port: "25567", protocol: TCP } # Java slot 2
- { port: "19132", protocol: UDP } # Bedrock slot 0
- { port: "19133", protocol: UDP } # Bedrock slot 1
- { port: "19134", protocol: UDP } # Bedrock slot 2
# Kubelet (host) -> Crafty's HTTPS UI port for its TCP health probe.
- fromEntities:
- host
toPorts:
- ports:
- { port: "8443", protocol: TCP }
---
# EGRESS allows (DNS already permitted by the default-deny baseline above).
# - Crafty must reach the INTERNET to download Minecraft server jars (Mojang/PaperMC/Purpur/Fabric,
# Bedrock binaries) when the operator creates/updates a server.
# - The oauth2-proxy sidecar must reach Keycloak at keycloak.home.keithjackson.dev, which coredns
# split-horizon resolves to the Gateway VIP (an L2-announced LoadBalancer IP Cilium classifies as
# `world`) — so `world` egress covers both the jar downloads AND the OIDC path (same as headlamp).
# - The sidecar -> Crafty hop is pod-local (127.0.0.1:8443) and is NOT subject to network policy.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: crafty-allow-egress
namespace: crafty
labels:
app.kubernetes.io/name: crafty
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: crafty
egress:
- toEntities:
- world
+25
View File
@@ -0,0 +1,25 @@
---
# Crafty data PVC (R-STATE-01/02) — the SINGLE stateful volume behind Crafty Controller. Crafty runs
# every Minecraft server it creates as a SUBPROCESS inside its own container, so all server worlds,
# backups, imports, logs and Crafty's own SQLite config/DB live under /crafty and must persist across
# pod restarts. This one Longhorn RWO volume is mounted five times by subPath (deployment.yaml) to the
# exact directories Crafty's docker-compose maps (/crafty/servers, /crafty/backups, /crafty/logs,
# /crafty/import, /crafty/app/config).
#
# Sized 20Gi for a starter set of small worlds + rolling backups; expand via the Longhorn
# allowVolumeExpansion storage class when real worlds grow. Replicated on the Longhorn 3-replica tier
# (docs/04). fsGroup/root-group ownership so the non-root `crafty` user can write — see deployment.yaml.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: crafty-data
namespace: crafty
labels:
app.kubernetes.io/name: crafty
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 20Gi
+72
View File
@@ -0,0 +1,72 @@
---
# ============================================================================================
# GAME-PORT L4 EXPOSURE — why the shared HTTPS Gateway is NOT enough (same shape as apps/vms/unifi).
# ============================================================================================
# The Minecraft servers Crafty creates listen on RAW TCP (Java) and UDP (Bedrock) game ports inside
# the crafty pod's network namespace. The Gateway API `shared` Gateway is an L7 HTTPS terminator and
# CANNOT express raw TCP on arbitrary ports or UDP at all. So the game ports are exposed as
# type=LoadBalancer Services whose VIP is pulled from the CiliumLoadBalancerIPPool `default-pool`
# (192.168.1.53-.60) and ARP-announced on the flat LAN by the existing CiliumL2AnnouncementPolicy
# `default-l2` (platform-config/cilium/*) — the SAME machinery the Gateway VIP and unifi already use.
# No new Cilium config is needed; these Services just consume the pool.
#
# THIS PATH IS NOT AUTH-GATED — and that is correct: Minecraft's Java (Mojang/Microsoft auth) and
# Bedrock (Xbox Live) protocols authenticate the PLAYER themselves. Only the Crafty WEB UI (admin
# control plane) is Keycloak-gated (via the oauth2-proxy sidecar); the game ports carry player traffic
# and must reach the server subprocesses directly.
#
# VIP: 192.168.1.55 — the next free address in default-pool (.53 = shared Gateway, .54 = unifi;
# verified free live 2026-08-28). PINNED via lbipam.cilium.io/ips so players have a stable target.
# ONE VIP, TWO SERVICES: a LoadBalancer Service is single-protocol-friendly, so TCP and UDP split into
# two Services sharing the IP via lbipam.cilium.io/sharing-key (same pattern as unifi).
#
# STARTER PORT SET (the operator has not created any servers yet): TCP 25565-25567 (three Java slots)
# + UDP 19132-19134 (three Bedrock slots). To add capacity: create the server in Crafty bound to a NEW
# port, then add that port to the matching Service below (and a containerPort in deployment.yaml for
# documentation) and to the game-port ingress CiliumNetworkPolicy (networkpolicy.yaml). K8s Services
# enumerate ports individually (no ranges), so add one `{ name, port, targetPort, protocol }` line each.
#
# externalTrafficPolicy: Local preserves the LAN client source IP and pins the VIP to the node running
# the crafty pod (also what L2 announcement does). external-dns (source: service) publishes the A
# record from the -tcp Service.
apiVersion: v1
kind: Service
metadata:
name: crafty-l4-tcp
namespace: crafty
labels:
app.kubernetes.io/name: crafty
annotations:
lbipam.cilium.io/sharing-key: "crafty"
lbipam.cilium.io/ips: "192.168.1.55"
# external-dns publishes A crafty-mc.home.keithjackson.dev -> 192.168.1.55 (the Java server target).
external-dns.alpha.kubernetes.io/hostname: "crafty-mc.home.keithjackson.dev"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app.kubernetes.io/name: crafty
ports:
- { name: mc-java-0, port: 25565, targetPort: 25565, protocol: TCP }
- { name: mc-java-1, port: 25566, targetPort: 25566, protocol: TCP }
- { name: mc-java-2, port: 25567, targetPort: 25567, protocol: TCP }
---
apiVersion: v1
kind: Service
metadata:
name: crafty-l4-udp
namespace: crafty
labels:
app.kubernetes.io/name: crafty
annotations:
lbipam.cilium.io/sharing-key: "crafty" # same key + same IP => shares the VIP with -tcp
lbipam.cilium.io/ips: "192.168.1.55"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app.kubernetes.io/name: crafty
ports:
- { name: mc-bedr-0, port: 19132, targetPort: 19132, protocol: UDP }
- { name: mc-bedr-1, port: 19133, targetPort: 19133, protocol: UDP }
- { name: mc-bedr-2, port: 19134, targetPort: 19134, protocol: UDP }
+23
View File
@@ -0,0 +1,23 @@
---
# ClusterIP fronting the oauth2-proxy SIDECAR (NOT Crafty directly). The shared Gateway routes
# crafty.home.keithjackson.dev to this Service (httproute.yaml); Service :80 -> sidecar :4180, which
# authenticates against Keycloak and only then proxies to the co-located Crafty UI (127.0.0.1:8443).
# There is deliberately NO Service targeting Crafty's :8443 — the UI is reachable ONLY through the
# gate. (Game traffic reaches the MC server subprocesses via the separate L4 LoadBalancer, which is
# NOT auth-gated because game protocols authenticate themselves — service-l4.yaml.)
apiVersion: v1
kind: Service
metadata:
name: crafty
namespace: crafty
labels:
app.kubernetes.io/name: crafty
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: crafty
ports:
- name: http
port: 80
targetPort: http # oauth2-proxy sidecar :4180
protocol: TCP
@@ -11,6 +11,7 @@ stringData:
nextcloud-client-secret: ENC[AES256_GCM,data:Uh3+EqIr88CNNt3KfDYP8lbNIhcB9iQrxJvbaxek40hD+JYAJmUP31WQypyDY3BiSvkjE6urr9PJmYoDgz7tkw==,iv:Tg4WAgyydIUBB1mqdOZPt0HsCkWSGNPvbI4WtFKj9V8=,tag:1LgZYdDPVEM+9UROanBllw==,type:str]
semaphore-client-secret: ENC[AES256_GCM,data:BX5lXhFNX6vQh67G2+XM2vt9wymhL8Psx+NqQJneFup7gdZRjhk8paEU5dOfugZNwooieara7UFyAFm5+1BJFg==,iv:kmmz0ZiCZgsSowns3hjDTQzoD2lPCr9Oz1iS5WgbhEE=,tag:BbesQX6lxAhaGiqPqMQ9vA==,type:str]
headlamp-client-secret: ENC[AES256_GCM,data:hlQ5m6FeqwOqX9HTT8jC6NZmhQH0kMgUveMZ2Cfen1zLLxWfYXXC1QMBWSqrchY2EpyIr7vNPbFjbqffwiSDHQ==,iv:WS1F3gkWn18STl4c38AlCe7TU6jvZpl/eULf2TnjIZ4=,tag:n2BnQYrm4gZbLeemDBykjA==,type:str]
crafty-client-secret: ENC[AES256_GCM,data:j9HAqIVlr+y8xZ6RvOvtQrVBv/mTLK89KYu1oQMkeSMsjyZdwZlLwW+Q7oiO1hqpyEG7ht5z3x6dJegN4LaxQQ==,iv:mnG5boz0M2A6hDdLKBaneUPH75Ih1RhM2R2d77VG7Vs=,tag:TGQKb7+4lnmhS3DZm6Wz1A==,type:str]
sops:
kms: []
gcp_kms: []
@@ -26,8 +27,8 @@ sops:
YXJGUUJiTnFndFhDZDU1bmxXTENCbmsKuhb3RQ3LZmuTPAXEck8b6iuZ5vxLb9fV
+fQELmU4kOTbZrPSxy+kpttLG5Cy5d6X2ieZXBf6opzpLTUhlBOkHA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-28T16:39:25Z"
mac: ENC[AES256_GCM,data:HRQ1SISIr9DrfCz96vEDGd/vP0o6pkVy5iMAUS+310oWAWhTxeXFvROJAhfdL1Pq8GzgFnA8s/Z6mSwr2E/D3Sjp679dYrZJeuKqt7A/TFiIsTt2yka+xpH9uCtoX8XpQDXFgq2bpAeOYoUVIahHCm9/P6mRT+D0tOkUNCcgOPE=,iv:WNR1+aVE/Ru3UmNiTRwDVN3KshIJvCdlvo3TIJJQiLA=,tag:J9r9x2yeRhiTFCraW0XH9g==,type:str]
lastmodified: "2026-08-28T20:36:54Z"
mac: ENC[AES256_GCM,data:hXqmcUYihXpprhm0Sn9CxbxuJR12WZtMKRPi+320KjU3ZEOa2Bz0wqzMQFhM2fLa4GttBG49YbdDHhqk1z/hqtHgxJoeiIi9qQOrpKgRC5oI42x6CTS7uhyIeh0y8h1UlQWaBbJ6Z+ig4OQ/1W9PCFkxL9klt6tbnQlWGiIbSB4=,iv:rY+1gerz/mXgvXPH4HpRRd0Nus0kHV8xC6G4WZDEXyA=,tag:gofnyk3rx+RFmpRxoiF8GA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -38,3 +38,5 @@ stringData:
grafana-client-secret: REPLACE-ME
oauth2-proxy-client-secret: REPLACE-ME
headlamp-client-secret: REPLACE-ME
# Mirror into platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml (key: client-secret).
crafty-client-secret: REPLACE-ME
@@ -39,6 +39,10 @@ spec:
secret:
name: keycloak-realm-clients
key: headlamp-client-secret
CRAFTY_CLIENT_SECRET:
secret:
name: keycloak-realm-clients
key: crafty-client-secret
realm:
realm: homelab
enabled: true
@@ -321,6 +325,41 @@ spec:
"included.client.audience": kubernetes
"id.token.claim": "true"
"access.token.claim": "true"
# crafty — the Minecraft management panel (platform/crafty). Crafty's web UI has NO native OIDC,
# so it is gated by a PER-APP oauth2-proxy REVERSE-PROXY sidecar (Option B; platform/crafty +
# platform/oauth2-proxy/README.md) — this is that sidecar's confidential auth-code client, modeled
# on the grafana/oauth2-proxy clients. Redirect URI is the oauth2-proxy callback on Crafty's host.
# The `groups` claim (bare names) lets the sidecar enforce --allowed-groups=platform-admins
# (R-IDP-06). Secret injected from the SOPS keycloak-realm-clients Secret via CRAFTY_CLIENT_SECRET
# (R-IDP-04); the SAME value is mirrored in platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml
# (cross-namespace mirror, like grafana/gitea/headlamp). Provisioned live via kcadm at authoring
# time (2026-08-28) and captured here as the durable GitOps source of truth.
- clientId: crafty
name: Crafty Controller (oauth2-proxy)
protocol: openid-connect
enabled: true
publicClient: false
standardFlowEnabled: true
directAccessGrantsEnabled: false
secret: "$(env:CRAFTY_CLIENT_SECRET)"
redirectUris:
- https://crafty.home.keithjackson.dev/oauth2/callback
webOrigins:
- https://crafty.home.keithjackson.dev
defaultClientScopes:
- email
- profile
- roles
protocolMappers:
- name: groups
protocol: openid-connect
protocolMapper: oidc-group-membership-mapper
config:
"full.path": "false"
"id.token.claim": "true"
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the
# platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP.
# Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails