Reported as "users cannot connect to Bedrock on UDP 19132". Measured read-only on prod; the platform delivered the packets and the server never opened the socket. Server `K3s-Bedrock` (c5837301-..., type=minecraft-bedrock, server_port=19132) exists and `./bedrock_server` is alive, but /proc/net/udp and /proc/net/udp6 in the crafty container hold NO socket on 19132 (0x4ABC) in either family -- port 19132 is bound as TCP (/proc/net/tcp6 ...:4ABC state 0A). Cause, from the server's own server.properties: transport=nethernet, the value Microsoft's BDS archive ships. Its inline docs: `Allowed values: "raknet" or "nethernet"`, and against server-portv6, `This property is ignored when transport=nethernet`. NetherNet is the WebRTC/Xbox-signalled transport and opens no RakNet UDP listener. Crafty does not manage the property (no `transport` anywhere under /crafty/app/classes/) and its only Bedrock probe is raknet_ping.py, hence commander.log spamming "Got timeout while issuing bedrock ping to 127.0.0.1:19132" -- over loopback, which indicts the server, not the network. The path below it is sound and was measured as such: crafty-l4-udp live as externalTrafficPolicy: Cluster with the VIP in status.loadBalancer, a non-empty EndpointSlice, `cilium-dbg service list` showing 192.168.2.22:19132/UDP => 10.0.0.130:19132/UDP (active) on BOTH announcing nodes, the datapath policy map carrying `Allow Ingress reserved:world 19132/UDP` with 26 packets / 27660 bytes already counted through it, pihole answering crafty-mc -> 192.168.2.22, and Flux at main@sha1:20ea70d == the GitRepository artifact. So this is not a platform or manifest defect. No manifest is touched: adding ports or widening policy for this would be manufacturing work. The repo-side defect is documentation -- the README told the operator that creating a server on 19132 was sufficient. README: add "Bedrock on UDP 19132 -- the transport trap" (the measurement chain, and the raknet_ping warning as its signature) and "Bedrock realities that are NOT platform defects" (no LAN/Friends discovery behind a VIP, consoles cannot enter an address, enable-lan-visibility collides with slots 1-2, server-portv6 is not exposed). Correct three stale claims: "the operator has not created any servers yet" (two exist), the human test implying a started Bedrock server is reachable, and the single-server Runtime state section (now a two-row table recording K3s-Bedrock's auto_start=0 / crash_detection=0 -- the create-API trap, live again, so it will not survive a pod restart). Also measured, reported, and deliberately NOT changed: the shared VIP's two leases sit on different nodes (crafty-l4-tcp on k3s-prod-3, crafty-l4-udp on k3s-prod-1), so two nodes ARP-announce 192.168.2.22. Benign under `Cluster` -- both nodes carry the full active frontend set -- and the existing L0 check l2-announced-vips-serve-from-any-node already forbids the `Local` that would make it fatal. A "shared VIPs must co-locate" check would assert runtime lease placement a static check cannot see. Read-only throughout: no apply/edit/patch, no flux reconcile, no write inside the container. Operator actions (set transport=raknet and verify the UDP bind; PATCH auto_start; tell players to Add Server by hand) are in the DECISIONS-LOG entry, along with what is UNVERIFIED -- chiefly that `raknet` fixes it is reasoned from the shipped docs, not observed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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). 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, NOTapp-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
craftyUID is ever not 1000, adjustrunAsUser(the launcher's non-root path runs directly as the current UID, so any non-root UID with GID 0 works, but 1000 matchesgetpwuidcleanly).
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 (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)
As of 2026-09-29 two servers exist (see Runtime state below): Java homelab on 25565 and
Bedrock K3s-Bedrock on 19132. The starter set covers 3 Java + 3 Bedrock slots. To add capacity:
- In the Crafty UI, create the server and bind it to a NEW port (Java TCP or Bedrock UDP).
- Add that port to the matching Service in
service-l4.yaml(one{ name, port, targetPort, protocol }line — K8s Services enumerate ports, no ranges). - Add it to the game-port ingress rule in
networkpolicy.yaml(crafty-allow-ingress). - (Optional) add a
containerPortindeployment.yamlfor documentation. - Commit → Flux reconciles.
Bedrock on UDP 19132 — the transport trap (MEASURED on prod 2026-09-29)
A Bedrock server that Crafty created, started, and reports as running can still have nothing bound
to 19132/udp, with every layer in this repo provably correct. This is the first thing to check
when "Bedrock players cannot connect" — before the Service, the policy, the lease or the VIP.
Measured on prod 2026-09-29 against server K3s-Bedrock (c5837301-12b1-404e-b50e-70eafb37226b,
DB type=minecraft-bedrock, server_port=19132, process ./bedrock_server alive):
/proc/net/udpand/proc/net/udp6inside the crafty container held no socket on 19132 (0x4ABC) at all — only ephemeral UDP sockets. Port 19132 was bound as TCP (/proc/net/tcp6,…:4ABC, state0A/LISTEN), not UDP.- Cause, from the server's own
server.properties:transport=nethernet— the value Microsoft's BDS archive ships by default. Its own inline docs:# Allowed values: "raknet" or "nethernet", and againstserver-portv6:# This property is ignored when transport=nethernet and a dual-stack socket will be opened on server-port instead. NetherNet is the WebRTC/Xbox-signalled transport; it never opens a RakNet UDP listener, so nothing binds19132/udphoweverserver-portis set. - Crafty does not manage this property —
transportdoes not appear anywhere under/crafty/app/classes/— and Crafty's only Bedrock reachability probe israknet_ping.py, so the panel spamsWARNING app.classes.remote_stats.raknet_ping - Got timeout while issuing bedrock ping to 127.0.0.1:19132every 5–30 s against a server it started successfully. That warning is the signature of this trap, and it comes from127.0.0.1— i.e. it indicts the server, not the LAN. - The platform path was verified sound at the same time:
crafty-l4-udplive asexternalTrafficPolicy: Clusterwithstatus.loadBalancer.ingress[0].ip= the VIP, a non-empty EndpointSlice,cilium-dbg service listshowing<VIP>:19132/UDP → 10.0.0.130:19132/UDP (active)on every node, and the crafty endpoint's datapath policy map carryingAllow Ingress reserved:world 19132/UDPwith 26 packets / 27660 bytes already counted through it. Real player packets reached the pod and were allowed; the application had no socket to take them.
Operator fix — application state on the PVC, not git and not Flux: stop the server in Crafty, set
transport=raknet in /crafty/servers/<uuid>/server.properties, start it, and confirm a UDP
listener on 19132 exists (grep 4ABC /proc/net/udp /proc/net/udp6, or the raknet_ping warnings
stopping) before suspecting the network. (That raknet restores the UDP bind is read from the
shipped property documentation, not yet observed on this cluster — see the DECISIONS-LOG entry of
2026-09-29.)
Bedrock realities that are NOT platform defects but present identically
- LAN / Friends-tab discovery can never work here. Bedrock discovery is a UDP broadcast on 19132
inside one L2 segment; this server sits behind a LoadBalancer VIP (and SNAT), not on the LAN, so it
will never appear in a client's Friends/LAN list. Players must use Add Server and type the
address and port by hand:
crafty-mc.home.keithjackson.dev(or the VIP) with port 19132.external-dnspublishes that name from the -tcp Service, so it resolves to the shared VIP and is correct for Bedrock too — only the port differs from Java's 25565. - Consoles cannot reach this at all. Switch/Xbox/PlayStation Bedrock clients have no
arbitrary-server-address field; only Windows/mobile/Fire do. Reaching it from a console needs a DNS
redirect of a featured-server hostname, which is out of scope for
service-l4.yaml. enable-lan-visibility=true(the shipped default) collides with Bedrock slots 1 and 2. Undertransport=raknetit makes a server bind the default ports 19132/19133 even whenserver-portis set to something else — the shipped comment says exactly that. So a second Bedrock server put on 19133 or 19134 will fight the first for 19132. Setenable-lan-visibility=falseon every Bedrock server after the first; LAN discovery cannot work here anyway (above).server-portv6is not exposed and is not meant to be.service-l4.yamlis IPv4 single-stack, so the defaultserver-portv6=19133is unreachable from the LAN. Harmless — but do not read19133inserver.propertiesas "slot 1 is in use".
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 buildofplatform/crafty,clusters/base,clusters/dev— clean.
Human tests (post-deploy)
- Browser OIDC round-trip: incognito →
https://crafty.home.keithjackson.dev→ expect a 302 tohttps://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). - Pod health / fsGroup:
kubectl -n crafty get podReady 2/2; Crafty writes its DB under/crafty/app/configand/crafty/serverswithout EACCES. - Server-create + game reach (Java): in the Crafty UI create a Java server bound to
25565; start it; connect a Minecraft client tocrafty-mc.home.keithjackson.dev:25565(or192.168.1.55:25565) from the LAN and confirm the world loads. - Server-create + game reach (Bedrock): creating and starting the server is not sufficient —
check
transport=raknetand a live UDP listener on 19132 first (see the transport trap above), then Add Server by hand in a Windows/mobile client atcrafty-mc.home.keithjackson.devport19132. It will never show up in the LAN/Friends tab, and a console cannot reach it.
Runtime state: the servers themselves are NOT in git
As of 2026-09-29 two servers exist (both measured live, select * from servers):
| Name | Type | Port | UUID | auto_start |
|---|---|---|---|---|
homelab |
minecraft-java (Paper 26.2, 2 GiB heap) |
TCP 25565 | 13c7b85d-00bb-4a58-8c86-2433ca47891c |
1 |
K3s-Bedrock |
minecraft-bedrock |
UDP 19132 | c5837301-12b1-404e-b50e-70eafb37226b |
0 ⚠ |
K3s-Bedrock was created 2026-09-29 20:51 and has auto_start=0 and crash_detection=0 — the
create-API trap documented at the bottom of this file, live again. It will not come back after a pod
restart, and merging any change to deployment.yaml restarts the pod (strategy: Recreate). PATCH
both to true and read them back.
homelab was created through Crafty's v2 API, and Mojang's
EULA (https://aka.ms/MinecraftEULA) was accepted on the operator's behalf to let it start; see the
DECISIONS-LOG entry of that date.
Everything in this directory describes only the capacity to run Minecraft servers. The servers are
application state on the crafty-data Longhorn PVC, in two places, and Flux cannot recreate either:
/crafty/app/config/db/(configsubPath) — Crafty's SQLite row: name, port, jar, launch command, heap sizes,auto_start/crash_detection./crafty/servers/<uuid>/(serverssubPath) —paper.jar,eula.txt,server.properties, and the generated world. The jar is re-downloadable; the world is not.
So a rebuild from git alone yields a healthy but empty Crafty with nothing listening on 25565.
Recreating a server by hand means: create it in the UI or API, accept the EULA again, and PATCH
auto_start to true — see the next note. The world starts over. Treat this PVC as backup-relevant.
Trap: the create API silently ignores autostart
POST /api/v2/servers accepts and validates autostart and crashdetection, but never stores them —
the persisted columns are auto_start and crash_detection. After a create that asked for true,
both read back false. Set them afterwards with PATCH /api/v2/servers/<uuid> and read them back to
confirm, or the server will not come up after a pod restart.