Files
home-network-k3s-impl/platform/crafty
jakman85andClaude Opus 5 d29a73cad8
gate / gate (pull_request) Successful in 1m35s
gate / conformance (pull_request) Has been skipped
docs(crafty): 19132/udp IS bound and answering -- the ERROR banner was advisory
The prior entry's operator fix (transport=raknet) was labelled REASONED, NOT
OBSERVED. The operator applied it, BDS 1.26.52 printed
"================ TRANSPORT TYPE ERROR ===================
 NetherNet is the only supported transport type", and that was read as a refusal.

It was not a refusal. The process kept running and bound the RakNet sockets:
/proc/net/udp 00000000:4ABC and /proc/net/udp6 ...:4ABD, both owned by
bedrock_server pid 47006 (fd/25, fd/26). The earlier TCP bind on 4ABC is gone.
Crafty's raknet_ping warning changed kind at 21:34 -- last "Got timeout"
21:34:04,306, first "too few fields" 21:34:09,854 -- and per raknet_ping.py the
latter is reachable only after a 0x1C Unconnected Pong arrives with valid magic.

Proven end-to-end from off-cluster: a RakNet Unconnected Ping to the prod VIP
192.168.2.22:19132 returns a 139-byte 0x1c pong in ~12ms:
  MCPE;Dedicated Server;2193;1.26.52;0;10;...;Bedrock level;Survival;1;19132;19133;0;0;0;

The vendor's own shipped bedrock_server_how_to.html documents raknet as a
supported value ("the server listens directly on server-port"), and RakNet is
still compiled in (RakNetConnector, createUniqueRakPeer, RakNetSocket2.cpp).
"Only supported transport type" is a support statement, not a capability one.

Also explains why nethernet could never have worked here: it signals over TCP on
server-port, and this repo exposes no TCP 19132 -- crafty-l4-tcp is 25565-25567
and crafty-allow-ingress allows 19132 UDP only. packet-statistics.txt for that
run: Total Received: 0, 0B over 2293s.

Decision: keep transport=raknet, NO manifest change. The 19132-19134/UDP
exposure is correct and live, not inert. Geyser+Floodgate documented as the
reserve migration if RakNet is ever really removed (also zero manifest change).

Other corrections: the "too few fields" warning is the success signature, not a
failure; enable-lan-visibility upgraded to OBSERVED (19133 answers with the same
server_guid, so slot 1 is NOT free -- previous text said it was unreachable);
Game VIP corrected from 192.168.1.55 (the base/dev value) to the live prod
192.168.2.22; noted transport=raknet is PVC state that silently regresses to
nethernet on a server recreate. K3s-Bedrock auto_start=0/crash_detection=0
re-confirmed from crafty.sqlite and still outstanding.

Read-only throughout: no kubectl mutation, no flux reconcile, no write inside
the container. server.properties was read, never touched.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 21:46:57 -05:00
..

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, 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 ${CRAFTY_L4_VIP} (Cilium L2 LoadBalancer) — prod 192.168.2.22 (live EXTERNAL-IP, measured 2026-09-29); base/dev 192.168.1.55
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 (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 ${CRAFTY_L4_VIP} (prod 192.168.2.22) (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 → ${CRAFTY_L4_VIP}.

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:

  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.

Bedrock on UDP 19132 — the transport trap, and the ERROR banner that is NOT a refusal (MEASURED on prod 2026-09-29)

Two traps in sequence. The first hides the UDP socket; the second frightens you into undoing the fix. As of 2026-09-29 21:34 this server is reachable on 19132/udp and answering, measured from off-cluster. Read both traps before changing anything.

Trap 1 — transport=nethernet (the shipped default) opens no RakNet UDP listener

Measured 20:54–21:32 against K3s-Bedrock (c5837301-12b1-404e-b50e-70eafb37226b, DB type=minecraft-bedrock, server_port=19132, process ./bedrock_server alive):

  • /proc/net/udp and /proc/net/udp6 held no socket on 19132 (0x4ABC) at all. Port 19132 was bound as TCP (/proc/net/tcp6, …:4ABC, state 0A/LISTEN), not UDP.
  • That is nethernet working as designed, and the shipped bedrock_server_how_to.html — the vendor's own doc, sitting on the PVC beside the binary — says exactly why: nethernet "listens on a single dual-stack TCP socket on server-port for an HTTP-based signaling handshake, then negotiates a UDP connection with each client that carries the gameplay traffic."
  • So nethernet could never have worked here anyway, on two independent counts: service-l4.yaml exposes 19132 only in the UDP Service (crafty-l4-tcp carries 25565-25567 and nothing else), and crafty-allow-ingress allows 19132 UDP and not 19132 TCP. The signaling port was unreachable at both the Service and the policy layer. The server's own packet-statistics.txt for that run agrees: Total Received: 0, 0B over 2293 s.
  • Crafty does not manage this property (transport appears nowhere under /crafty/app/classes/) and its only Bedrock probe is raknet_ping.py, so the panel logged WARNING app.classes.remote_stats.raknet_ping - Got timeout while issuing bedrock ping to 127.0.0.1:19132 every ~5 s — 155 such lines — against a server it had started successfully.

Trap 2 — under transport=raknet this build prints a scary ERROR banner and then works anyway

The operator set transport=raknet at 21:33:31 (/crafty/logs/audit.log: Edited file /crafty/servers/c5837301-…/server.properties) and started the server at 21:33:58. BDS 1.26.52 printed, at ERROR level:

[ERROR] ================ TRANSPORT TYPE ERROR  ===================
[ERROR] Your current connection type is not set to NetherNet. In this release, NetherNet is the only
        supported transport type.
[ERROR] Players will not be able to connect to your game without NetherNet.
[ERROR] To switch, set 'transport=nethernet' in server.properties.

That banner is advisory, not fatal. Do not revert transport on seeing it. Every claim it makes about connectivity was false on this cluster within five seconds of it being printed:

  • The process kept running (./bedrock_server, pid 47006) and bound the RakNet sockets: /proc/net/udp 00000000:4ABC (19132, IPv4) and /proc/net/udp6 …:4ABD (19133, IPv6), both owned by bedrock_server — /proc/47006/fd/25 and fd/26 → those socket inodes. The earlier TCP bind on 4ABC is gone.

  • Crafty's warning changed kind at that instant: last Got timeout at 21:34:04,306, first Bedrock ping returned a string with too few fields at 21:34:09,854. That distinction is load-bearing — read raknet_ping.py: Got timeout is emitted only when sock.recv raises TimeoutError (nothing came back at all), whereas too few fields is reachable only after a 0x1C Unconnected Pong has arrived and its magic has validated. The server is answering pings.

  • End-to-end, from off-cluster: a RakNet Unconnected Ping sent from the LAN to the prod VIP 192.168.2.22:19132 returns a 139-byte 0x1c Unconnected Pong in ~12 ms, magic valid:

    MCPE;Dedicated Server;2193;1.26.52;0;10;11676629557768398479;Bedrock level;Survival;1;19132;19133;0;0;0;
    

    Edition MCPE, protocol 2193, version 1.26.52, 0/10 players. Players can reach this server now, through the VIP, the Service, and the CiliumNetworkPolicy, with no manifest change.

  • The vendor doc on disk contradicts the banner and matches the observed behaviour. transport is documented as "Allowed values: raknet, nethernet", and: "raknet — The traditional Bedrock UDP transport. The server listens directly on server-port (IPv4) and server-portv6 (IPv6), and clients connect to that address and port." RakNet is also still fully compiled into the binary — strings bedrock_server yields RakNetConnector, createUniqueRakPeer, RakPeerInterface and the vendored build path …/src-deps/raknet/raknet/RakNetSocket2.cpp.

So "NetherNet is the only supported transport type" is a support statement, not a capability statement: Microsoft declines to support RakNet and ships it working. Treat the banner as a deprecation notice with an unknown expiry — a future BDS may genuinely remove RakNet, and that is the day this exposure needs GeyserMC (see If RakNet is ever really removed below).

The shipped-comment trap, stated plainly. server.properties lists raknet as allowed and the binary logs an ERROR saying it is not supported. Both are the vendor, and they disagree. The tiebreak is neither prose: it is /proc/net/udp and a pong on the wire. Whenever this file says a Bedrock server is or is not reachable, it is because someone pinged it — not because a comment said so.

The steady-state too few fields warning is benign and is now the expected signature

Crafty will log Bedrock ping returned a string with too few fields - missing values [None] every ~5 s forever. That means the ping SUCCEEDED. BDS 1.26.52's pong carries 16 ;-separated fields; Crafty 4.10.8's pingpong_string_fields table knows 14 and expects the last to be an empty terminator. It instead finds '0', so last_enumeration stops at index 12 and the parser reports the one None terminator entry as missing. Every field that matters (edition, MOTD, protocol, version name, player count, max players, uuid, game mode, both ports) parsed correctly. Do not chase this warning. The failure signature is the other message, Got timeout.

Decision: transport=raknet stays; the 19132-19134/UDP exposure is correct and live

No manifest change is needed or wanted. service-l4.yaml, networkpolicy.yaml and the containerPorts were already shaped for exactly RakNet, and that shape is now proven end-to-end rather than assumed. The three options that would have required manifest work are all not recommended today:

Option Manifest consequence Verdict
Keep transport=raknet (recommended) none — the existing Service, policy and ports are already correct and now proven Take this. Zero change, works today, vanilla parity, Bedrock clients join a real Bedrock world.
GeyserMC + Floodgate on the homelab Paper server none either (Geyser also binds 19132/udp), but it would collide with the live BDS on 19132 — one of them must move Hold in reserve. Only worth it if RakNet is really removed, or if you want one shared world. Cost: Bedrock players join the Java world, not a Bedrock world; Java-only mechanics and some content differ; it is another moving part to patch.
Pin/downgrade BDS to a pre-nethernet release none Unnecessary — the current build already speaks RakNet. Also fragile: Microsoft removes old BDS downloads, and Crafty's Bedrock installer tracks latest. UNVERIFIED whether Crafty can pin a version at all.
A third-party server (PocketMine-MP, Nukkit) none No. Loses vanilla parity and the world format; a large change to solve a problem that is not occurring.
Accept NetherNet 19132-19134/UDP becomes inert; nethernet would instead need TCP 19132 added to crafty-l4-tcp and to crafty-allow-ingress, plus server-udp-ports pinned and exposed for the negotiated media path No. Strictly more manifest work than the option that already works, and the WebRTC media path behind a shared VIP and SNAT is unproven here.

If RakNet is ever really removed

Then BDS opens no UDP listener and this exposure has nothing to bind to — an architecture problem, not a config one. The migration is GeyserMC + Floodgate as a plugin on the existing homelab Paper server, because Geyser speaks RakNet on 19132/udp itself: the VIP, the Service, the policy and the containerPorts all stay byte-for-byte as they are, and Bedrock clients keep connecting to crafty-mc.home.keithjackson.dev:19132. What changes is the world: Bedrock players land in the Java world via Floodgate (no Xbox-authenticated Bedrock account needed on the Java side), the Bedrock server is retired, and K3s-Bedrock stops the day Geyser starts — they cannot share 19132. Installing it is a PVC write plus a Paper restart, i.e. an operator action: drop Geyser-Spigot.jar and floodgate-spigot.jar into /crafty/servers/13c7b85d-…/plugins/ and restart homelab, then set Geyser's bedrock.port: 19132 and clone-remote-port: false in its generated config.yml. Geyser's own Bedrock-version support is external and unmeasured here — check that it supports the Bedrock client version in use before committing.

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-dns publishes 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 — now OBSERVED, not merely documented. Under transport=raknet it makes a server bind the default ports 19132 and 19133 even when server-port/server-portv6 say otherwise; the shipped bedrock_server_how_to.html says so, and on 2026-09-29 a RakNet ping to 192.168.2.22:19133 returned the same pong, from the same server_guid, as :19132 — one server occupying two of the three Bedrock slots. :19134 correctly timed out. So a second Bedrock server put on 19133 will fight the first. Set enable-lan-visibility=false on every Bedrock server after the first; LAN discovery cannot work here anyway (above).
  • server-portv6 is the IPv6 listener and is not exposed; 19133 is nonetheless in use. service-l4.yaml is IPv4 single-stack, so the IPv6 socket …:4ABD that BDS opens for server-portv6=19133 is unreachable from the LAN. But because enable-lan-visibility=true (above) the server also answers on IPv4 19133, which the UDP Service does expose — measured. So 19133 in server.properties genuinely does mean "slot 1 is taken", contrary to what this file previously said.

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 (Java): 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.2.22:25565) from the LAN and confirm the world loads.
  4. Server-create + game reach (Bedrock): creating and starting the server is not sufficient. Check, in this order (see the transport trap above): transport=raknet in server.properties; a live UDP listener (grep 4ABC /proc/net/udp /proc/net/udp6 → a wildcard bind); and a real pong from off-cluster. Ignore the TRANSPORT TYPE ERROR banner in the console and ignore Crafty's too few fields warning — both appear on a fully working server. The authoritative test is a RakNet Unconnected Ping to 192.168.2.22:19132 returning a 0x1c packet whose MOTD string starts MCPE;. Then Add Server by hand in a Windows/mobile client at crafty-mc.home.keithjackson.dev port 19132. 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 still has auto_start=0 and crash_detection=0 — re-measured 2026-09-29 21:41 straight out of crafty.sqlite (read-only), so the create-API trap documented at the bottom of this file is still live and still unfixed. 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/ (config subPath) — Crafty's SQLite row: name, port, jar, launch command, heap sizes, auto_start / crash_detection.
  • /crafty/servers/<uuid>/ (servers subPath) — paper.jar, eula.txt, server.properties, and the generated world. The jar is re-downloadable; the world is not. transport=raknet lives here too. It is the one setting that makes Bedrock reachable at all (see the transport trap), it is not in git, Flux cannot restore it, and a freshly created or reinstalled Bedrock server gets the vendor default transport=nethernet back — i.e. it silently regresses to "nothing bound on 19132/udp". Re-apply and re-ping after any Bedrock server recreate or BDS update.

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.