diff --git a/clusters/base/crafty.yaml b/clusters/base/crafty.yaml new file mode 100644 index 0000000..728e6fa --- /dev/null +++ b/clusters/base/crafty.yaml @@ -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. diff --git a/clusters/base/kustomization.yaml b/clusters/base/kustomization.yaml index e23664b..df4ee11 100644 --- a/clusters/base/kustomization.yaml +++ b/clusters/base/kustomization.yaml @@ -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) diff --git a/clusters/dev/kustomization.yaml b/clusters/dev/kustomization.yaml index ec227fc..74a172c 100644 --- a/clusters/dev/kustomization.yaml +++ b/clusters/dev/kustomization.yaml @@ -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 diff --git a/platform/crafty/README.md b/platform/crafty/README.md new file mode 100644 index 0000000..f01c9ac --- /dev/null +++ b/platform/crafty/README.md @@ -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.) diff --git a/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml b/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml new file mode 100644 index 0000000..b6500b6 --- /dev/null +++ b/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml @@ -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 diff --git a/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml.example b/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml.example new file mode 100644 index 0000000..6ce59d0 --- /dev/null +++ b/platform/crafty/crafty-oauth2-proxy-credentials.sops.yaml.example @@ -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 diff --git a/platform/crafty/deployment.yaml b/platform/crafty/deployment.yaml new file mode 100644 index 0000000..1566849 --- /dev/null +++ b/platform/crafty/deployment.yaml @@ -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 diff --git a/platform/crafty/httproute.yaml b/platform/crafty/httproute.yaml new file mode 100644 index 0000000..6324339 --- /dev/null +++ b/platform/crafty/httproute.yaml @@ -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 diff --git a/platform/crafty/kustomization.yaml b/platform/crafty/kustomization.yaml new file mode 100644 index 0000000..9ca23ae --- /dev/null +++ b/platform/crafty/kustomization.yaml @@ -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 diff --git a/platform/crafty/namespace.yaml b/platform/crafty/namespace.yaml new file mode 100644 index 0000000..acd512f --- /dev/null +++ b/platform/crafty/namespace.yaml @@ -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" diff --git a/platform/crafty/networkpolicy.yaml b/platform/crafty/networkpolicy.yaml new file mode 100644 index 0000000..5389889 --- /dev/null +++ b/platform/crafty/networkpolicy.yaml @@ -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 diff --git a/platform/crafty/pvc.yaml b/platform/crafty/pvc.yaml new file mode 100644 index 0000000..8b7cdb3 --- /dev/null +++ b/platform/crafty/pvc.yaml @@ -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 diff --git a/platform/crafty/service-l4.yaml b/platform/crafty/service-l4.yaml new file mode 100644 index 0000000..3159fc8 --- /dev/null +++ b/platform/crafty/service-l4.yaml @@ -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 } diff --git a/platform/crafty/service.yaml b/platform/crafty/service.yaml new file mode 100644 index 0000000..1672920 --- /dev/null +++ b/platform/crafty/service.yaml @@ -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 diff --git a/platform/keycloak/keycloak-realm-clients.sops.yaml b/platform/keycloak/keycloak-realm-clients.sops.yaml index 5bd23f9..6342c1d 100644 --- a/platform/keycloak/keycloak-realm-clients.sops.yaml +++ b/platform/keycloak/keycloak-realm-clients.sops.yaml @@ -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 diff --git a/platform/keycloak/keycloak-realm-clients.sops.yaml.example b/platform/keycloak/keycloak-realm-clients.sops.yaml.example index b0f40a9..5bdd6d3 100644 --- a/platform/keycloak/keycloak-realm-clients.sops.yaml.example +++ b/platform/keycloak/keycloak-realm-clients.sops.yaml.example @@ -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 diff --git a/platform/keycloak/keycloak-realm-import.yaml b/platform/keycloak/keycloak-realm-import.yaml index 5c5bad4..2080a70 100644 --- a/platform/keycloak/keycloak-realm-import.yaml +++ b/platform/keycloak/keycloak-realm-import.yaml @@ -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