docs(crafty): Bedrock on 19132 RESOLVED — a player has joined; the wall was an empty allowlist #23

Merged
jakman85 merged 5 commits from fix/crafty-bedrock-19132 into main 2026-09-30 03:19:04 +00:00
2 changed files with 926 additions and 10 deletions

No files matched your search

+609
View File
@@ -5109,3 +5109,612 @@ next L2/L3 piece on top of unmerged branches would create a rebase tangle for no
cannot land any faster. The next piece is specified and ready to start once these are merged:
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
boundary landed in #13.
## 2026-09-29 — Bedrock "cannot connect on 19132": the platform delivered the packets, the server never opened the socket
**Branch:** `fix/crafty-bedrock-19132` → PR to `main`. **Read-only diagnosis; nothing was applied.**
**Gate:** see "Gate" below — quote it, do not round it up.
### The report, and what actually refuses
Reported as "users are unable to connect to Crafty Minecraft servers listening on UDP port 19132
(Bedrock, slot 0)". No client type, no error text, no history.
**Nothing was listening on UDP 19132.** A Bedrock server exists and its process is alive, but it is
not speaking RakNet, so it never binds a UDP port. The entire network path below it is correct and was
measured as such, including *counted packets that arrived and were allowed*.
The chain, each line a measured field with the command that produced it:
1. **The server exists and runs.** `kubectl -n crafty exec … -c crafty -- ps -ef`:
`crafty 46689 1 ./bedrock_server`, started 20:54, cwd
`/crafty/servers/c5837301-12b1-404e-b50e-70eafb37226b`. Crafty's DB (`select * from servers` via
`/crafty/.venv/bin/python`, read-only URI) row 2: `server_name='K3s-Bedrock'`,
`type='minecraft-bedrock'`, `server_port=19132`, `created='2026-09-29 20:51:53'`.
So hypothesis "the operator never created a Bedrock server" — the PM's prior favourite, and mine —
is **false**. It was created ~20 minutes before I looked.
2. **Nothing is bound to UDP 19132.** `cat /proc/net/udp` and `/proc/net/udp6` in the crafty
container: **no `4ABC` local port in either family**. IPv4 UDP held nothing but an ephemeral
(`BBBF`=48063); IPv6 UDP nothing but `1D7F`=7551. Meanwhile `/proc/net/tcp6` line 0 is
`…:4ABC … 0A` — port 19132 is bound, as **TCP**, LISTEN. (Java's 25565 = `63DD` and Crafty's
8443 = `20FB` are both there in `tcp6`, so the Java server is genuinely up.)
3. **Why: `transport=nethernet`.** `grep -v ^# server.properties` on the Bedrock server dir:
`server-port=19132`, `server-portv6=19133`, **`transport=nethernet`**, `enable-lan-visibility=true`.
The file's own shipped documentation, quoted verbatim: `# Allowed values: "raknet" or "nethernet"`,
and above `server-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 opens no RakNet UDP listener. Consistent with the pod's outbound `tcp` entries to
`150.171.110.177:443` / `.178:443` (Microsoft) and the churning ephemeral UDP sockets.
4. **Nobody set that; it is the vendor default.** `server.properties` mtime `Sep 29 20:52` equals the
`bedrock_server` binary's — untouched since extraction — and `grep -rn transport
/crafty/app/classes/minecraft/` returns **nothing**: Crafty has no code that reads or writes this
property. So a freshly-downloaded BDS of this build **never** listens on `19132/udp`, and no amount
of Crafty-side configuration changes that.
5. **The failure is observable from inside the pod, from localhost.**
`grep raknet /crafty/logs/commander.log`, every 5–30 s since 21:02:
`WARNING - app.classes.remote_stats.raknet_ping - Got timeout while issuing bedrock ping to
127.0.0.1:19132`. Crafty's only Bedrock health probe is `raknet_ping.py` (`ls
/crafty/app/classes/remote_stats/`), so the panel is asking the right question and getting silence
**over loopback**. That single line eliminates every network hypothesis at once.
6. **Real player traffic reached the pod and was permitted.** `cilium-dbg bpf policy get <crafty ep>`
on `cilium-wdh82` (k3s-prod-1):
`Allow Ingress reserved:world 19132/UDP … BYTES 27660 PACKETS 26`.
**26 packets of world traffic were allowed in on 19132/UDP.** The policy did not drop them; the
application had no socket to receive them. `reserved:remote-node 19132/UDP` is also an Allow (0
bytes — the packets landed on the node that both announces the VIP and runs the pod, so no second
hop), which re-confirms the 2026-09-14 finding that `cluster` subsumes `remote-node` here.
**Verdict: this is not a platform defect and not a manifest defect. It is the Bedrock Dedicated
Server's default `transport=nethernet`, plus a README that told the operator creating a server on
19132 was sufficient.** The repo-side defect is documentation, and that is what this branch changes.
### The platform path, measured sound (so the operator can rule it out for good)
| Claim | Measured |
|---|---|
| `crafty-l4-udp` really is `Cluster` | `kubectl get svc crafty-l4-udp -o yaml` → `spec.externalTrafficPolicy: Cluster` (the 2026-09-14 fix IS live, not just in git) |
| VIP allocated | `status.loadBalancer.ingress[0].ip: 192.168.2.22`, `ipMode: VIP`, condition `cilium.io/IPAMRequestSatisfied=True reason=satisfied` |
| Backends exist | EndpointSlice `crafty-l4-udp-68kws` → `10.0.0.130`, ports `19132,19133,19134` |
| Datapath frontend present | `cilium-dbg service list` on **both** k3s-prod-1 and k3s-prod-3: `192.168.2.22:19132/UDP LoadBalancer 1 => 10.0.0.130:19132/UDP (active)` — i.e. any node can serve it, which is exactly what `Cluster` is for |
| Policy admits it | datapath policy map, `Allow Ingress reserved:world 19132/UDP` (+ `host`, `remote-node`, `unmanaged`, `init`, `health`, `kube-apiserver`) |
| LAN DNS resolves | `nslookup crafty-mc.home.keithjackson.dev 127.0.0.1` in the pihole pod → `Address: 192.168.2.22` |
| Flux applied current `main` | Kustomization `crafty` `.status.lastAppliedRevision = main@sha1:20ea70d…` == GitRepository `flux-system` `.status.artifact.revision = main@sha1:20ea70d…` |
### A real second finding: the shared VIP's two leases sit on DIFFERENT nodes
`kubectl -n kube-system get lease … | grep l2announce`:
cilium-l2announce-crafty-crafty-l4-tcp k3s-prod-3
cilium-l2announce-crafty-crafty-l4-udp k3s-prod-1
cilium-l2announce-unifi-unifi-l4-tcp k3s-prod-2
cilium-l2announce-unifi-unifi-l4-udp k3s-prod-2
cilium-l2announce-pihole-pihole-dns-tcp k3s-prod-3
cilium-l2announce-pihole-pihole-dns-udp k3s-prod-3
Crafty is the **only** shared-VIP pair that is split: **two nodes ARP-announce `192.168.2.22`
simultaneously.** unifi and pihole happen to be co-located. This is the per-Service, backend-blind
election described in `service-l4.yaml` doing exactly what that comment says it does — and it is a
*coincidence* that the other two pairs agree, not a property.
**I am not calling this the bug, and I am not changing anything for it**, because under
`externalTrafficPolicy: Cluster` it is benign by construction: `cilium-dbg service list` shows the
full frontend set with an active backend on **both** announcing nodes (table above), so whichever
node's ARP reply a client caches, the packet is forwarded to the pod. The cost is ARP-cache churn on
LAN clients, not a black hole — and this is why the 2026-09-14 `Local`→`Cluster` flip was the right
fix rather than a lucky one. **No new L0 check is warranted:**
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node/check.sh` already forbids
`Local` on any announced Service tree-wide, which is precisely the invariant that makes a split
harmless. An additional "shared-VIP pairs must be co-located" check would assert a *runtime* lease
placement a static check cannot see, and Cilium offers no field to pin it.
### Hypotheses tested and discarded, so nobody re-runs them
- **Wrong port / IPv6-only bind (h2).** Partly right in shape, wrong in mechanism: `server-port=19132`
is correct and the process is not v6-only — it binds *no* UDP port at all. Measured, not assumed.
- **Shared-VIP ARP split (h3).** Real (above), not causal.
- **`Cluster` not actually live on the UDP Service (h4).** False; it is live, with datapath to match.
- **Loaded policy ≠ git policy (h5).** False; dumped the BPF map, 19132/UDP is an Allow and has
counted traffic.
- **SNAT collapsing RakNet sessions (h6).** **UNTESTED AND UNTESTABLE TODAY** — there is no RakNet
session to collapse. It remains a live theory for *after* the transport is fixed: Cilium is in SNAT
mode (no `loadBalancer.mode: dsr`), so every LAN player will reach the server as one address, and
RakNet keys sessions on source `ip:port`. Nothing was measured about masquerade port-reuse or UDP
conntrack here. Do not treat this paragraph as evidence of anything.
- **DNS (h8).** False; pihole answers `192.168.2.22` (table above).
- **Client-side realities (h7).** Not the *cause* of a dead socket, but they will be the next wall:
LAN/Friends discovery cannot work behind a VIP, and consoles cannot type a server address. Both are
now documented in `platform/crafty/README.md` rather than left for the next person to rediscover.
### What changed in this repo
`platform/crafty/README.md` only. **No manifest was touched** — the manifests are provably correct,
and adding ports or widening policy for this would have been manufacturing work to look productive.
Added: *"Bedrock on UDP 19132 — the transport trap"* (the measurement chain above, and the
`raknet_ping` warning as its signature), *"Bedrock realities that are NOT platform defects"* (no LAN
discovery, consoles, the `enable-lan-visibility` collision with slots 1–2, `server-portv6`). Corrected
three stale claims: "the operator has **not created any servers yet**" (two exist), the human test
that implied a started Bedrock server on 19132 is reachable, and the single-server *Runtime state*
section (now a two-row table).
### What I did NOT verify — read this before acting
- **That `transport=raknet` fixes it is REASONED, NOT OBSERVED.** It follows from the shipped property
docs (`Allowed values: "raknet" or "nethernet"`; `server-portv6` "ignored when
transport=nethernet") and from the absence of any UDP bind under `nethernet`. Editing
`server.properties` is a write to application state on the PVC, which is an operator action, so I
did not try it. **Nobody has seen a packet answered on 19132/udp on this cluster.**
- **No end-to-end LAN test.** I never sent a RakNet unconnected-ping from a LAN host to
`192.168.2.22:19132`. I did not need to: the same ping fails from `127.0.0.1` inside the pod.
- **Nothing was applied.** No `kubectl apply/edit/patch/scale`, no `flux reconcile`, no file written
inside the container. The running pod is still `crafty-64f7b6d78-pkpgp` (2/2, 15d, 0 restarts) on
the **pre-`fix/crafty-oauth2-proxy-signin`** spec — that PR is still unmerged.
- **Who those 26 packets came from is unknown.** Byte/packet counters prove arrival and permission,
not identity. I did not run a live `cilium monitor` or `tcpdump`, so "users were trying and being
ignored" is a strong inference, not a captured flow.
- **The Java server's reachability was not tested.** `25565` is bound (`tcp6 63DD LISTEN`) and its
datapath frontend is active on both nodes, but no client connected during this session. The TCP
lease being on k3s-prod-3 while the pod is on k3s-prod-1 is `Cluster` working as designed — asserted
from the service table, not from a connection.
- **The NetherNet side was not explored.** Whether this server is currently joinable *at all* through
Xbox/Friends signalling (its actual transport) is unknown and untested.
- **Bedrock slots 1 and 2 (19133/19134) have never had a server.** The `enable-lan-visibility`
collision I documented for them is read from the shipped comment, not provoked.
### Operator actions required (I cannot do any of these)
1. **Make the Bedrock server speak RakNet.** In Crafty: stop `K3s-Bedrock`, edit
`/crafty/servers/c5837301-12b1-404e-b50e-70eafb37226b/server.properties` →
`transport=raknet`, start it. Then confirm the socket exists before telling anyone it works:
`kubectl --context k3s-prod -n crafty exec <pod> -c crafty -- grep 4ABC /proc/net/udp /proc/net/udp6`
should now show a wildcard bind, and the `raknet_ping` timeout warnings in
`/crafty/logs/commander.log` should stop. **Both checks, not one.**
2. **Then tell the players how to connect.** Windows/mobile client → **Add Server** →
`crafty-mc.home.keithjackson.dev`, port **19132**. It will *not* appear in the LAN/Friends tab, and
a Switch/Xbox/PlayStation client cannot reach it at all. If the reported users are on consoles,
step 1 will not help them and the answer is "unsupported without a DNS redirect".
3. **Fix `auto_start` on the new server.** DB row `K3s-Bedrock` has `auto_start=0` **and**
`crash_detection=0` — the create-API trap already documented in this repo, live again.
`PATCH /api/v2/servers/c5837301-…` setting both true, **and read them back**. Until then the
Bedrock server does not survive a pod restart — and merging
`fix/crafty-oauth2-proxy-signin` restarts the pod (`strategy: Recreate`, RWO volume), stopping
*both* Minecraft servers.
4. **Optional, cosmetic:** to stop two nodes ARP-announcing `192.168.2.22`, delete
`lease/cilium-l2announce-crafty-crafty-l4-tcp` in `kube-system` and let it re-elect — a 1-in-3
chance of landing on k3s-prod-1. Not worth doing: under `Cluster` the split costs nothing but ARP
churn, and a redraw is equally random.
5. **After step 1, watch for the SNAT/RakNet question (h6 above)** — if a *second* simultaneous player
evicts the first, that is the SNAT theory becoming real and it needs its own measured
investigation (masquerade port reuse, UDP conntrack), not a guess.
## 2026-09-29 (later) — Bedrock IS reachable on 19132/udp. The `TRANSPORT TYPE ERROR` banner is advisory, and the last entry's "REASONED, NOT OBSERVED" call has now been OBSERVED — correct
**Do not read this as overturning the previous entry's recommendation. It confirms it.** What it
overturns is the conclusion the operator and I both drew from the server's *reaction* to it.
### The sequence
The entry above ended with operator action 1: stop `K3s-Bedrock`, set `transport=raknet`, start it —
explicitly flagged **"REASONED, NOT OBSERVED … Nobody has seen a packet answered on 19132/udp on this
cluster."** The operator did it. `/crafty/logs/audit.log`, measured:
21:32:32 issued command stop_server for server K3s-Bedrock
21:33:31 Edited file /crafty/servers/c5837301-…/server.properties
21:33:58 issued command start_server for server K3s-Bedrock
BDS then printed, at ERROR level, `================ TRANSPORT TYPE ERROR ===================` /
`Your current connection type is not set to NetherNet. In this release, NetherNet is the only supported
transport type.` / `Players will not be able to connect to your game without NetherNet.` That was
reported to me as *"the server rejected it"*, and I was briefed to treat the shipped property comment
(`Allowed values: "raknet" or "nethernet"`) as stale relative to the binary and to write up an
architecture finding: that BDS is NetherNet-only, can never bind `19132/udp`, and that the whole
`crafty-l4-udp` exposure is therefore inert.
**That brief was wrong, and so was the inference behind it. The server did not reject anything.**
### What is actually true (all OBSERVED, 2026-09-29 21:38–21:41)
- `./bedrock_server` pid **47006** is running, started 21:34 and still up — it printed that banner and
carried on. It did not exit and Crafty did not restart it.
- It **bound the RakNet sockets**: `/proc/net/udp` `00000000:4ABC` (19132/IPv4) and `/proc/net/udp6`
`…:4ABD` (19133/IPv6), both owned by that pid (`/proc/47006/fd/25`, `fd/26` → those inodes). The
**TCP** bind on `4ABC` that the last entry measured under nethernet is **gone**.
- Crafty's warning changed kind at that exact moment: last `Got timeout while issuing bedrock ping` at
**`21:34:04,306`**, first `Bedrock ping returned a string with too few fields` at **`21:34:09,854`**
(155 timeout lines, then 86 answered and counting). Per `raknet_ping.py`, `Got timeout` is emitted
*only* when `sock.recv` raises `TimeoutError`; `too few fields` is reachable *only after* a `0x1C`
Unconnected Pong arrives **and its magic validates**. The listener answers.
- **The end-to-end test the last entry admitted it had never run, run:** a RakNet Unconnected Ping from
the toolbox on the LAN (host network, off-cluster) to the prod VIP **`192.168.2.22:19132`** returns a
139-byte `0x1c` 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;
BDS **1.26.52**, protocol 2193, 0/10 players. `:19133` returns the **same** pong from the same
`server_guid` (that is `enable-lan-visibility=true`, now observed rather than read off a comment);
`:19134` times out, correctly. **A Bedrock player on this LAN can connect right now.**
- The vendor's own doc on the PVC, `bedrock_server_how_to.html` (shipped in the same archive as the
binary, same mtime 20:52), documents `transport` as *"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 still fully compiled in: `strings bedrock_server` yields `RakNetConnector`,
`createUniqueRakPeer`, `RakPeerInterface`, and the vendored build path
`/mnt/vss/_work/1/s/handheld/src-deps/raknet/raknet/RakNetSocket2.cpp`.
So `NetherNet is the only supported transport type` is a **support** statement, not a **capability**
one. Microsoft declines to support RakNet and ships it working, at ERROR log level, in 1.26.52.
### Why nethernet could never have worked here anyway — a finding the last entry missed
The same vendor doc explains the last entry's oddest measurement (19132 bound **TCP**): 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."* And this repo exposes **no TCP 19132**:
`crafty-l4-tcp` carries 25565-25567 only, and `crafty-allow-ingress` allows `19132 UDP` and not
`19132 TCP`. The signaling port was unreachable at the Service layer *and* the policy layer. The
server's own `packet-statistics.txt` for that 2293-second nethernet run agrees: `Total Received: 0, 0B`.
The 26 packets / 27660 bytes the last entry found on the `19132/UDP` policy map were players' RakNet
pings arriving at a port where nethernet had opened no UDP socket. Consistent, and now closed.
### Where the reasoning failed — the part worth keeping
The previous entry's reasoning was **sound and its recommendation was right**; it was correctly
labelled as unobserved, and the observation vindicated it. The failure was downstream, in reading the
server's response:
1. **An ERROR-level log line was read as a refusal.** It was a deprecation notice. Nothing checked
whether the process survived it or what it bound — and both were one command away.
2. **"The vendor comment is stale relative to the binary" was asserted from the binary's *complaint*,
never from the binary's *behaviour*.** The binary had two other testimonies on disk — the shipped
how-to, and its own symbol table — and both said `raknet` works. Neither was consulted.
3. The tiebreak between two contradicting vendor statements is never a third piece of prose. It is
`/proc/net/udp` and a pong on the wire. That is what settled it, in about four minutes.
This is CLAUDE.md §2b applied one level further in: don't infer platform state from the repo, **and
don't infer application state from the application's own error message.** Query the socket.
### Decision and recommendation
**Keep `transport=raknet`. No manifest change.** `service-l4.yaml`, `networkpolicy.yaml` and the
`containerPort`s were already shaped for exactly RakNet and are now proven end-to-end rather than
assumed. Ranked alternatives, all rejected for today and written up in the README:
1. **Keep `transport=raknet`** — zero manifest change, works now, vanilla parity, real Bedrock world.
2. **GeyserMC + Floodgate** on the existing `homelab` Paper server — also zero manifest change (Geyser
itself binds `19132/udp`, so the existing Service is already correct), held in reserve for the day
RakNet is genuinely removed. It **cannot run alongside** the live BDS: both want 19132. Cost is that
Bedrock players join the **Java** world. Geyser's current Bedrock-version support is
**external and unmeasured**.
3. **Pin/downgrade BDS** — unnecessary (the current build speaks RakNet) and fragile.
4. **PocketMine-MP / Nukkit** — no; loses vanilla parity and world format.
5. **Accept NetherNet** — worst option: it needs *more* manifest work than the one that already works
(TCP 19132 in `crafty-l4-tcp` **and** in `crafty-allow-ingress`, plus `server-udp-ports` pinned and
exposed), and WebRTC media behind a shared VIP and SNAT is unproven here.
The 19132-19134/UDP exposure **stays** and is **not** inert. That part of the brief is refuted.
### What changed in this repo
`platform/crafty/README.md` only — again **no manifest touched**, and this time that is a measured
conclusion rather than a cautious one.
- Replaced *"the transport trap"* with a two-trap section: nethernet hides the UDP socket, and then the
`TRANSPORT TYPE ERROR` banner frightens you into reverting the fix. Records the full evidence chain,
the vendor how-to quotes, the binary symbols, and the off-cluster pong.
- Documented that Crafty's perpetual `too few fields - missing values [None]` warning is the **success**
signature, not a failure: BDS 1.26.52's pong has 16 `;`-fields, Crafty 4.10.8's
`pingpong_string_fields` knows 14 and wants an empty terminator, gets `'0'`. Cosmetic. The failure
signature is the *other* message, `Got timeout`.
- Added the ranked options table with each option's manifest consequence, and an *"If RakNet is ever
really removed"* section naming Geyser+Floodgate as the migration with the exact PVC/panel steps.
- Upgraded the `enable-lan-visibility` note from "read off a comment" to OBSERVED, and **corrected** the
claim that `19133` is unreachable — it is reachable, on IPv4, and slot 1 genuinely is occupied.
- Corrected the **Game VIP**: the README stated `192.168.1.55` as fact. That is the base/dev value;
`clusters/prod/kustomization.yaml` patches `CRAFTY_L4_VIP` to `192.168.2.22`, which is the live
`EXTERNAL-IP` on both `crafty-l4-tcp` and `crafty-l4-udp`. Rewrote as the variable plus the measured
prod value.
- Noted that `transport=raknet` is PVC state Flux cannot restore, and that a recreated or reinstalled
Bedrock server silently regresses to the vendor default `nethernet`.
- Re-confirmed `K3s-Bedrock` `auto_start=0` / `crash_detection=0` straight out of `crafty.sqlite`.
### What I did NOT verify — read this before acting
- **No human Bedrock client has connected.** I proved the RakNet *ping/pong* handshake end-to-end from
off-cluster; I did not complete a login, which additionally requires Xbox Live auth
(`online-mode=true`) from the client. **UNVERIFIED that a player reaches the world**, and it is the
one test only a human with a Minecraft client can run.
- **The multi-player SNAT question from the last entry is still open and still untested.** Whether a
*second* simultaneous Bedrock player evicts the first (masquerade port reuse / UDP conntrack behind a
shared VIP) is unmeasured. One ping proves one flow.
- **Whether a console can reach it is still no** — unchanged and still not tested; consoles have no
server-address field.
- **Geyser is entirely unmeasured here.** Nothing was installed, downloaded, or run. Its Bedrock-version
support, its jar names and its config keys as written in the README are **web/documentation-sourced,
not observed on this cluster**. Verify before acting on that section.
- **Whether Crafty can pin a BDS version is UNVERIFIED** — I did not read its Bedrock installer.
- **A web search on this error was attempted and was low-quality** — results largely echoed the error
string back. Nothing in the README or this entry rests on it. The load-bearing evidence is all local:
`/proc/net/*`, `/proc/47006/fd/*`, `audit.log`, `commander.log`, `raknet_ping.py`, the shipped
`bedrock_server_how_to.html`, `strings bedrock_server`, and the pong.
- **Whether a future BDS removes RakNet for real is unknowable from here.** Treat the banner as a
deprecation notice with an unknown expiry.
- **Nothing was applied or written.** No `kubectl apply/edit/patch/scale/delete`, no `flux reconcile`,
no write anywhere inside the container — `server.properties` was read, never touched. The running pod
is still `crafty-64f7b6d78-pkpgp` (2/2, 15d, 0 restarts). All probes were reads plus outbound UDP
pings from the toolbox.
- **`fix/crafty-oauth2-proxy-signin` is still unmerged**, so the pod still runs the pre-that-PR spec.
### Gate and CI — rc 3, and why CI's green does not contradict that
Local, pinned toolbox, from the worktree (`ENGINE` shim + `DEV_KUBECONFIG=/dev/null`), diff base
`origin/main @ 20ea70d` → `HEAD @ d29a73c`:
| Stage | Result |
|---|---|
| `lint` | green |
| `l0` | green — 19 cases, 0 failures, 0 skips |
| `render-golden` | green — all 10 onboarded goldens reproduce |
| `l1` | green — 3 cases, 0 failures, 0 skips |
| `spec-immutability` | **green** (see below) |
| `gitea-settings` | **SKIPPED** — `SETTINGS_API_TOKEN` unset |
| `conformance` (L2/L3) | **SKIPPED** — `CLUSTER_AVAILABLE != true` |
**Exit code 3 — UNDETERMINED, not a PASS.** Nothing failed; nothing outside the green rows is
certified. Touched requirements `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` remain L2/L3 UNVERIFIED.
`spec-immutability` — normally *the* tolerated skip — was closed rather than accepted. It had skipped
**spuriously**: `dev.sh` resolves the spec repo at `$REPO_ROOT/../home-network-k3s`, which from a
*worktree* is the wrong directory; the repo exists beside the **main** checkout. Mounting it read-only
through the `ENGINE` shim turned the skip into a real green, so R-TEST-06 is genuinely verified here.
Worth folding into the CLAUDE.md §3 worktree workarounds: the documented shim should mount **two**
paths, the main `.git` *and* the sibling spec repo.
CI on `d29a73c`, statuses read by description and not by tick:
context='gate / gate (pull_request)' status='success' description='Successful in 1m35s'
context='gate / conformance (pull_request)' status='success' description='Has been skipped'
**Neither green means rc 0.** `gate / conformance` is `if: false` in `.gitea/workflows/gate.yml` — it
never runs and reports `success` with "Has been skipped", the exact trap CLAUDE.md §2 names. And
`gate / gate`, the single required context, runs `./ci/gate.sh || [ "$?" -eq 3 ]` (line 134): **CI
tolerates exit 3 by declared design**, so its green means "exited 0 *or* 3". It would report this
identical green for the rc 3 above. Enforcement itself was measured directly rather than assumed
(§2b): `branches/main` → `protected: true`, `user_can_push: false`, `required_approvals: 1`,
`enable_status_check: true`, `status_check_contexts: ['gate / gate (pull_request)']`;
`admin/actions/runners` → 1 runner, `incluster-dind`, `online`. **PR #23 is open, unmerged, and I did
not merge it.**
### Operator actions required
1. **Nothing to do about the transport.** `transport=raknet` is correct, live, and answering. **Do not
revert it because of the `TRANSPORT TYPE ERROR` banner, and do not chase Crafty's `too few fields`
warning** — both appear on a fully working server.
2. **Get a human to actually join**, from a Windows/mobile client: **Add Server** →
`crafty-mc.home.keithjackson.dev`, port **19132**. This is the only remaining unproven step. It will
never appear in the LAN/Friends tab; a console cannot reach it at all.
3. **Fix `auto_start` — still outstanding, and now more urgent.** `K3s-Bedrock` is still
`auto_start=0`, `crash_detection=0`. `PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`
setting both true **and read them back** (the create API silently drops them). Until then the
Bedrock server does not survive a pod restart — and merging `fix/crafty-oauth2-proxy-signin`
restarts the pod (`strategy: Recreate`, RWO volume), stopping **both** Minecraft servers **and**
leaving Bedrock down until someone starts it by hand.
4. **Set `enable-lan-visibility=false` on `K3s-Bedrock`** if a second Bedrock server is ever wanted.
It currently occupies **both** 19132 and 19133 (measured), so slot 1 is not actually free. LAN
discovery cannot work behind the VIP anyway, so nothing is lost.
5. **Re-check after any Bedrock server recreate or BDS update:** confirm `transport=raknet` survived,
then re-ping. The vendor default is `nethernet`, and the regression is silent.
6. **If a second simultaneous player evicts the first**, that is the SNAT/UDP-conntrack theory becoming
real; it needs its own measured investigation, not a guess.
## 2026-09-29 (later still) — "You are not invited": the join gate is an empty allowlist. RakNet is confirmed working *by the refusal itself*
A Bedrock client reported **"Unable to connect / You are not invited to play on this server"**. This was
put to me as evidence that NetherNet signalling is working, that the refusal might have come from
Microsoft rather than our server, and that BDS players therefore never traverse this repo's
`crafty-l4-udp` path. **Measured, all three of those are wrong.** The refusal is ours, it came back over
`19132/udp`, and it has a one-line cause.
### Measured 21:56 (read-only)
| Property / file | Value |
|---|---|
| `allow-list` | **`true`** (line 37 of `server.properties`) |
| `allowlist.json` | **`[]`** — 3 bytes, mtime **20:51**, never edited since the server was created |
| `online-mode` | `true` |
| `transport` | **`raknet`** (line 71); `server.properties` mtime **still 21:33:31** |
| UDP 19132 | bound — `/proc/net/udp` `00000000:4ABC`, pid 47006 |
| **TCP 19132** | **absent** — `grep 4ABC /proc/net/tcp /proc/net/tcp6` → no match |
| `bedrock_server` | pid **47006**, etime 21:58 — the *same* process since 21:34, never restarted |
| `K3s-Bedrock.log_path` | **`''`** (empty string) in `crafty.sqlite`; `Dedicated_Server.txt` is **0 bytes** |
### What that settles
1. **`transport` is still `raknet`. Nobody reverted it and the binary did not rewrite it.** The file's
mtime is unchanged from the operator's 21:33:31 edit, `audit.log` records no later edit, and pid
47006 has been the same process since 21:34. **So NetherNet is NOT in use and cannot be what served
this client.** The premise that "something is evidently set to nethernet" is refuted.
2. **NetherNet could not have served it even in principle:** nethernet signals over a **TCP** socket on
`server-port`, and there is **no TCP 19132 listener in the pod at all**. No signalling endpoint
exists, and none is exposed by any Service.
3. **Therefore the refusal came from our server, over RakNet, over `19132/udp`, through the VIP.** A
refusal authored by the server requires the client to have reached the server; the only listener is
the RakNet UDP socket. **The 19132-19134/UDP exposure is the opposite of inert — it is the path that
carried both the earlier pong and this refusal.** *(Observed: the listeners, the missing TCP socket,
`transport=raknet`, the process identity. Reasoned from those: that the refusal traversed that path.)*
4. **The cause is trivial and certain: `allow-list=true` with `allowlist.json == []` refuses every
player.** The shipped `bedrock_server_how_to.html` states it — *"If the `allow-list` property is
enabled … the server will only allow selected users to connect. To allow a user to connect you need
to know their Xbox Live Gamertag."* An empty array selects nobody. **This configuration cannot admit
anyone, so the refusal needed no other explanation.**
### The correlation I was asked for, and why it cannot be done from disk
I was asked to correlate the client error against the BDS server log and `commander.log` at 21:40–22:00,
on the basis that our log showing nothing would mean the refusal came from Microsoft. **That inference
does not hold here, because there is no BDS log on disk to be silent.** ``K3s-Bedrock``'s `log_path` is
the empty string and `Dedicated_Server.txt` is 0 bytes: **Crafty persists no Bedrock stdout anywhere on
the PVC** — the console is panel-UI-only. `commander.log` 21:39–21:56 holds nothing but `homelab`
crash-detection lines and `raknet_ping` chatter: no join, no gamertag, no XUID, no allowlist denial.
That silence is evidence about Crafty's logging, **not** about the refusal's origin. **UNVERIFIED and
only observable live in the panel console.** The point stands: absence of a log here is not absence of
an event.
I also did **not** capture the player's packet (no `tcpdump`, no `cilium monitor`), so the flow's
identity is inferred from the listener topology, not seen. What removes the doubt is that the
configuration is *unconditionally* refusing: whichever path a client takes, `allow-list=true` plus `[]`
denies it. The operator action is required either way.
### Correction to the earlier entry's loose end, now firmer
The previous entries left "who sent those 26 packets / 27660 bytes on the `19132/UDP` policy map"
UNVERIFIED, and it stays UNVERIFIED — but note it was measured *before* 21:34, i.e. during the nethernet
window when **no UDP listener existed**. Those packets are consistent with clients RakNet-pinging a port
nothing was listening on. They should **not** be attributed to a successful Bedrock session, and equally
should not now be re-attributed to this refused client, which arrived later.
### Ranking after this measurement — unchanged at the top
**Keep `transport=raknet`, add the players to the allowlist. Still zero manifest change.** The allowlist
refusal is orthogonal to transport: it would refuse identically under NetherNet. Switching to NetherNet
would *add* manifest work (TCP 19132 in `crafty-l4-tcp` **and** `crafty-allow-ingress`, plus
`server-udp-ports` pinned and exposed) and would admit **not one extra player**. "Accept NetherNet" does
**not** outrank the status quo on this evidence, because NetherNet is not what is working — RakNet is.
Geyser stays at #2, reserve-only.
### What changed in this repo
`platform/crafty/README.md` — added *"You are not invited to play on this server — the join gate is the
allowlist, not the network"*: the measured table, the proof that the refusal is ours and rode the UDP
path, the "BDS console is not on disk" caveat, the gamertag/XUID identity model, the three operator
routes (`allowlist add` in the console; edit-then-`allowlist reload`; or `allow-list=false`, discouraged
because the game port is deliberately un-gated so the allowlist is the *only* join control), and a note
that `allowlist.json` is PVC state that resets to `[]` on a server recreate. Updated the options table
and human test 4 to put the allowlist check before blaming the network. **No manifest touched.**
### Still UNVERIFIED
- **No successful join.** Reachability and an authorization refusal are both proven; a completed login is
not. Only a human with a client, after an allowlist entry, can close it.
- **The exact client-side string was not observed by me** — it is the operator's report, and its mapping
to the allowlist denial is **reasoned** from the vendor doc, not read out of a BDS log (there is none).
- **Whether the client used Add-Server-by-IP or an Xbox/Friends route is unknown**, so a *concurrent*
Xbox-side "not invited" cannot be fully excluded. It does not matter operationally: the allowlist must
be fixed regardless, and after that a retry distinguishes the two immediately.
- **The multi-player SNAT question** remains open and untested.
- **Nothing was written.** `allowlist.json`, `server.properties` and the DB were read only; no
`allowlist add` was issued, no console command sent, no `kubectl` mutation, no `flux reconcile`.
### Operator actions — revised, allowlist first
1. **Add each player's Xbox Gamertag to the allowlist.** Crafty → `K3s-Bedrock` → Console:
`allowlist add <Gamertag>` (quote if it contains a space). Takes effect immediately, no restart.
Verify with `allowlist list`. This is **the** fix for "You are not invited".
2. **Then retry the join** from a Windows/mobile client: **Add Server** →
`crafty-mc.home.keithjackson.dev`, port **19132**. If it now connects, everything in these three
entries is closed. If it still refuses, capture the **panel console** output during the attempt —
that is the only place BDS's own reason is visible.
3. **Do not set `allow-list=false`** as the fix. The game port is deliberately un-gated at the network
layer, so the allowlist is the only thing deciding who may join. (And console `allowlist off` does not
change `server.properties`, so it silently reverts on restart.)
4. **Leave `transport=raknet` alone**, and keep ignoring both the `TRANSPORT TYPE ERROR` banner and the
`too few fields` warning — the server is reachable, proven by pong and by this refusal.
5. **`auto_start=0` / `crash_detection=0` on `K3s-Bedrock` — still outstanding.** `PATCH
/api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`, both true, **read them back**.
6. **Treat `allowlist.json` and `transport=raknet` as backup-relevant PVC state.** A server recreate
resets the allowlist to `[]` and the transport to `nethernet` — both silently.
## 2026-09-29 (closing) — a player has joined. The chain is closed: `allow-list=true` + an empty allowlist was the wall, and nothing else was
The operator added one gamertag to the allowlist and the Bedrock client connected and joined. **The
three-entry investigation above is now closed by a working server**, with no manifest change, no
transport change, and no network change.
### What is now OBSERVED (moved out of UNVERIFIED)
- **A successful join.** *Operator-reported, 2026-09-29* — they added the player's gamertag and the
client connected and joined. **I did not watch the join**, and that distinction is kept deliberately;
this repo has been burned by collapsing it.
- **Corroborated directly on disk, though**, which is stronger than the report alone:
`allowlist.json` went `[]` → **one entry**, 75 bytes, and that entry's **`xuid` is populated**
(`keys=['ignoresPlayerLimit','name','xuid']`, mtime **22:03:17**). `audit.log` shows the operator wrote
only the name — `Edited file …/allowlist.json` at **22:02:13**. The vendor doc: `xuid` is *"Optional.
If it's not set then it will be populated when someone with a matching name **connects**."* So the XUID
was written **by the server, on connect**, ~64 s after the operator's edit. That is server-authored
evidence of an Xbox-Live-authenticated join. *(Observed: file state, both timestamps, the audit line.
Reasoned from the vendor doc: populated XUID ⇒ a connect happened.)*
- **The reasoned mapping is confirmed.** The client's *"You are not invited to play on this server"* was
mapped to the empty allowlist **by reasoning** from the vendor doc, because Crafty persists no BDS
stdout and there was no log to read it from. Adding **exactly one** gamertag and getting **exactly one**
successful join confirms that mapping. **`allow-list=true` + `allowlist.json == []` was the wall, and
nothing else was.**
- **`transport=raknet` survives a restart.** The fix involved one: `stop_server` 22:02:37 →
`start_server` 22:02:41, pid **47006 → 47875**. Afterwards `server.properties` mtime is **still
21:33:31** with `transport=raknet`, UDP `4ABC` is bound by the new pid, there is still **no TCP 4ABC**,
and `192.168.2.22:19132` answered a fresh pong at 22:06 (`MCPE;…;1.26.52;0;10;…`, new `server_guid`
matching the new pid, `players=0` — the player had left).
- **The route the operator used was file-edit + restart**, not the console `allowlist add` I put first:
`audit.log` edit 22:02:13 → stop → start. A restart is a valid substitute for `allowlist reload`.
Both routes now documented as proven; the console route is still preferred because it needs no restart.
### The recommendation is now PROVEN, not provisional
**Keep `transport=raknet` and enumerate players in the allowlist. Zero manifest change.** This is no
longer the best-reasoned option — it is the configuration a player has demonstrably joined over
`192.168.2.22:19132`. `service-l4.yaml`, `networkpolicy.yaml` and the `containerPort`s needed nothing.
Ranking otherwise unchanged: Geyser #2 (reserve, and it cannot share 19132), pinning BDS #3,
PocketMine/Nukkit #4, accept-NetherNet last — NetherNet is still **not in use**, would still need TCP
19132 exposed in two places, and would still admit not one extra player.
### Still open — NOT weakened by the success
- **`K3s-Bedrock` still has `auto_start=0` and `crash_detection=0`.** Re-measured from `crafty.sqlite` at
22:06, *after* the successful join and after the operator's 22:02:28 `modified the server` action —
which did **not** change them. **The operator now has a working server that will silently not come back
after any pod restart**, and merging `fix/crafty-oauth2-proxy-signin` **is** such a restart
(`strategy: Recreate`, RWO volume) and would stop **both** Minecraft servers. This matters *more* now,
not less: the failure mode has changed from "never worked" to "worked, then vanished".
- **The silent-reset exposure is unchanged, and now precisely characterised:**
a **restart** preserves `transport=raknet` and `allowlist.json` (observed above) but does not restart
the server itself (`auto_start=0`); a **server recreate or reinstall** resets `server.properties` to
`transport=nethernet` (nothing bound on 19132/udp) **and** `allowlist.json` to `[]` (everyone refused).
Neither reset announces itself, and the panel will show the server healthy and started. Both files are
PVC state Flux cannot restore. **Re-run the 5-step connect procedure after any recreate or BDS update.**
- **L2/L3 conformance is still uncertified.** `CLUSTER_AVAILABLE` is unset, so the gate's `conformance`
stage does not run; `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` remain UNVERIFIED in CI. A player joining
is not a conformance test.
- **Multi-player SNAT/UDP-conntrack is still untested.** Exactly **one** player has joined. Whether a
*second simultaneous* Bedrock player evicts the first behind the shared VIP and SNAT is unmeasured, and
a single successful session is precisely the evidence that does *not* settle it.
- **I still never saw BDS's own console output** — `log_path` is `''`, `Dedicated_Server.txt` is 0 bytes.
Anything BDS itself said about the join is visible only live in the panel.
- **Consoles still cannot reach this**, and LAN/Friends discovery still cannot work behind the VIP.
- **Geyser remains entirely unmeasured**; the pinnability of a BDS version remains UNVERIFIED.
### What changed in this repo
`platform/crafty/README.md` and `DECISIONS-LOG.md`. **No manifest touched, in any of the three passes.**
- Marked the join-gate section **RESOLVED and confirmed**, with the XUID-population evidence and the
operator-reported join labelled as exactly that.
- Promoted the decision heading and option-1 row from provisional to **PROVEN**, and recorded that the
operator's actual route was file-edit + restart (route 2), with route 1 still preferred.
- Rewrote Bedrock human-test 4 as a **5-step verified connect procedure**, each step with its distinct
failure signature, and stated plainly that the `TRANSPORT TYPE ERROR` banner and the `too few fields`
warning both appear on a fully working server — that pair misled two consecutive sessions.
- Sharpened the PVC warning to distinguish **restart** (preserves both files; server stays down because
`auto_start=0`) from **recreate/reinstall** (silently resets transport *and* allowlist).
### Operator actions — one is now urgent
1. **`PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`** setting `auto_start` **and**
`crash_detection` true, **and read them back**. Both are still `0`. This is the only thing standing
between the working server and it disappearing at the next pod restart — including the restart that
merging `fix/crafty-oauth2-proxy-signin` will cause.
2. **Leave `transport=raknet` and the allowlist alone.** Keep ignoring the `TRANSPORT TYPE ERROR` banner
and the `too few fields` warning.
3. **Add a gamertag per additional player** — console `allowlist add <Gamertag>` (no restart needed).
Do **not** switch to `allow-list=false`: the game port is un-gated by design, so the allowlist is the
only join control.
4. **After any Bedrock recreate or BDS update**, re-run the 5-step connect procedure — transport and
allowlist both reset silently.
5. **Before announcing this to more than one player**, note that only one simultaneous session has been
proven; if a second player evicts the first, that is the SNAT/conntrack question and needs its own
measured investigation.
+317 -10
View File
@@ -21,7 +21,7 @@ Cilium L2 LoadBalancer (game protocols authenticate players themselves).
| 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 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)
@@ -76,15 +76,16 @@ can't span namespaces). Both are age-encrypted to the repo `sops-age` recipient
## 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**
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 → 192.168.1.55`.
themselves. `external-dns` publishes `crafty-mc.home.keithjackson.dev → ${CRAFTY_L4_VIP}`.
### 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:
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
@@ -93,6 +94,274 @@ add capacity:
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`.
### "You are not invited to play on this server" — the join gate is the allowlist, not the network (MEASURED 2026-09-29 21:56; **RESOLVED and confirmed 22:03**)
A Bedrock client reported **"Unable to connect / You are not invited to play on this server"**. That is
an **authorization** refusal, and it is the expected, guaranteed behaviour of this server's current
configuration. Measured:
| Property / file | Value | Source |
|---|---|---|
| `allow-list` | **`true`** (line 37) | `server.properties` |
| `allowlist.json` | **`[]`** — 3 bytes, mtime 20:51, never edited since server create | `cat allowlist.json` |
| `online-mode` | `true` (line 31) | `server.properties` |
| `transport` | **`raknet`** (line 71), file mtime **still 21:33:31** | `server.properties` |
| TCP 19132 listener | **none** (`NO-TCP-4ABC`) | `grep 4ABC /proc/net/tcp /proc/net/tcp6` |
| UDP 19132 listener | present, `00000000:4ABC`, pid 47006 | `grep 4ABC /proc/net/udp` |
**`allow-list=true` with an empty `allowlist.json` refuses every player, unconditionally.** The shipped
`bedrock_server_how_to.html` states it: *"If the `allow-list` property is enabled in server.properties
then the server will only allow selected users to connect. To allow a user to connect you need to know
their Xbox Live **Gamertag**."* An empty list selects nobody. This is not a network problem, not a
transport problem, and not a platform problem — and no amount of Service or policy work will change it.
#### CONFIRMED: one gamertag added, and a player joined
**This is no longer a hypothesis. The fix was applied and it worked.** *(The join itself is
**operator-reported** — 2026-09-29, the operator added the player's gamertag and the Bedrock client
connected and joined. I did not watch it.)* Two independent things corroborate it **directly on disk**:
- `allowlist.json` went from `[]` to **one entry**, and that entry's **`xuid` is populated**
(`keys=['ignoresPlayerLimit','name','xuid']`, file 75 bytes, mtime **22:03:17**). Per the vendor doc,
`xuid` is *"Optional. If it's not set then it will be populated when someone with a matching name
**connects**."* The operator wrote only the name — `audit.log` records `Edited file
…/allowlist.json` at **22:02:13** — so **the XUID was written by the server, on connect, at 22:03:17**.
That is server-authored evidence of an Xbox-Live-authenticated join. *(Observed: the file state and
both timestamps. Reasoned from the vendor doc: that a populated XUID implies a connect.)*
- The RakNet path still answers after the restart this involved: a pong from `192.168.2.22:19132` at
22:06, `MCPE;…;1.26.52;0;10;…` (`players=0` — the player had left by then), new `server_guid` matching
the new pid.
**So the causal chain is closed: `allow-list=true` + `allowlist.json == []` was the wall, and nothing
else was.** The earlier mapping of the client's *"You are not invited"* string to the empty allowlist was
**reasoned** from the vendor doc (there is no BDS log on disk to read it from); adding exactly one
gamertag and getting exactly one successful join **confirms that mapping**. No transport change, no
manifest change, and no network change was involved in the fix.
> **Note the route the operator actually used:** they **edited `allowlist.json` directly** and then
> **restarted** the server (`audit.log`: edit 22:02:13 → `stop_server` 22:02:37 → `start_server`
> 22:02:41; pid 47006 → **47875**). That is route 2 below, and a restart is a valid substitute for
> `allowlist reload`. Route 1 (`allowlist add` in the console) avoids the restart and is still preferred.
**This is also positive evidence that the RakNet path works end to end.** A refusal authored by *our*
server required the client to reach our server and get an answer back. The only listener on 19132 is
the **RakNet UDP** socket — there is **no TCP 19132 listener at all**, and NetherNet requires a TCP
signalling socket on `server-port`. So the client cannot have used NetherNet against this server; it
arrived over `19132/udp`, through `crafty-l4-udp` and the VIP. **The 19132-19134/UDP exposure is not
inert — it is the path that carried both the pong and this refusal.** *(Observed: the listeners, the
absent TCP socket, `transport=raknet`. Reasoned from those: that the refusal traversed that path. Not
captured: the actual player flow — see the caveat below.)*
#### Caveat: BDS console output is NOT on disk, so a join attempt cannot be correlated from the filesystem
`K3s-Bedrock`'s `log_path` in `crafty.sqlite` is the **empty string**, and `Dedicated_Server.txt` is
**0 bytes**. Crafty does not persist a Bedrock server's stdout anywhere on the PVC — the console exists
only in the panel UI. `commander.log` between 21:39 and 21:56 contains nothing but `homelab`
crash-detection lines and the `raknet_ping` chatter: **no join, no gamertag, no XUID, no allowlist
denial.** That absence is **not** evidence about where the refusal came from; it is evidence that
Crafty logs no BDS stdout at all. **To see a rejected join you must watch the panel console live.**
UNVERIFIED, and only observable in the UI.
#### How a Bedrock player actually gets authorized here
Identity is an **Xbox Live Gamertag** (`online-mode=true` forces XBL auth, which is what gives BDS a
gamertag/XUID to check). `allowlist.json` is a JSON array; per the vendor doc the keys are `name` (the
gamertag), optional `xuid` (*"populated when someone with a matching name connects"*), and
`ignoresPlayerLimit`. So the operator adds **one entry per player, keyed on gamertag** — there is no
group, role, or Keycloak integration, and **none of this touches the oauth2-proxy/Keycloak gate**,
which protects only the web panel.
Three ways to do it, all **operator actions**. **Routes 1 and 2 are both proven** — route 2 is the one
that produced the confirmed join above:
1. **Preferred — the panel console.** In Crafty, open `K3s-Bedrock` → Console and send:
`allowlist add <Gamertag>` (quote it if it contains a space: `allowlist add "Example Name"`).
This writes `allowlist.json` **and** takes effect immediately, with no restart.
2. **Edit the file, then reload.** Write `/crafty/servers/c5837301-…/allowlist.json`, then send
`allowlist reload` in the console — *"After you've modified the file you need to run the command
`allowlist reload` to make sure that the server knows about your new change."* Shape:
```json
[
{ "ignoresPlayerLimit": false, "name": "FirstPlayerGamertag" },
{ "ignoresPlayerLimit": false, "name": "SecondPlayerGamertag" }
]
```
3. **Turn the allowlist off entirely** — `allow-list=false` in `server.properties` (restart), or
`allowlist off` in the console. Note the vendor's warning: `allowlist on|off` *"does not change the
value in the server.properties file"*, so a console `off` silently reverts on the next start.
**Not recommended:** the game port is deliberately un-gated at the network layer (see *Game-port L4
exposure*), so the allowlist is the **only** thing deciding who may join. Keep it `true` and enumerate
players.
> **`allowlist.json` is application state on the PVC**, exactly like `transport=raknet`. Neither is in
> git and **Flux can restore neither**. Be precise about what breaks them, because the two cases differ:
>
> - **A pod or server *restart* preserves both** — observed: the 22:02:48 restart (pid 47006 → 47875)
> kept `transport=raknet` (`server.properties` mtime unchanged at 21:33:31) and kept the allowlist
> entry. What a **pod** restart *does* break is that `K3s-Bedrock` has `auto_start=0`, so the Bedrock
> server simply **does not come back** until someone starts it by hand.
> - **A server *recreate* or reinstall silently resets both** — a fresh `server.properties` ships
> `transport=nethernet` (nothing bound on 19132/udp) and a fresh `allowlist.json` is `[]` (everyone
> refused). Neither change announces itself, and the server will look healthy and started in the panel
> while being unreachable *and* unjoinable.
>
> **Treat both files as backup-relevant, and re-run the 5-step connect procedure above after any Bedrock
> recreate or BDS update.**
#### Why this does not change the transport recommendation
`transport=raknet` is correct, and with the allowlist entry in place the combination is **proven
end-to-end by a player who has joined** — not merely the best-reasoned option. The allowlist refusal was
orthogonal to transport all along: it would have refused players identically under NetherNet. Switching
to NetherNet would *add* work (TCP 19132 in `crafty-l4-tcp` **and** in `crafty-allow-ingress`, plus
`server-udp-ports` pinned and exposed) and would admit **not one extra player**. The ranking below stands,
and option 1 is now settled rather than provisional.
#### Decision (PROVEN, not provisional): `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
`containerPort`s were already shaped for exactly RakNet, and that shape is now **proven by a player who
has connected and joined over `192.168.2.22:19132`** — not assumed, and not merely reasoned. The options
that would have required manifest work are all **not recommended**:
| Option | Manifest consequence | Verdict |
|---|---|---|
| **Keep `transport=raknet`** + allowlist entries (**this is what works**) | **none** — the existing Service, policy and ports were already correct | **Done; proven.** A player has joined over the VIP. Zero manifest change, vanilla parity, 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 — and note it is **not currently in use**: `transport=raknet` and there is no TCP 19132 listener. Strictly more manifest work than the option that already works, it admits no extra player (the allowlist is the gate), and WebRTC media 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
`containerPort`s 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
@@ -114,14 +383,48 @@ Gateway VIP). The sidecar→Crafty hop is pod-local (`127.0.0.1`) and not subjec
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.)
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. **Bedrock: the connect procedure (VERIFIED end-to-end 2026-09-29 — a player has joined).**
Creating and starting the server is **not sufficient**. Do these in order; each step has a distinct
failure signature:
1. **`transport=raknet`** in `server.properties`. The vendor default is `nethernet`, which opens
**no UDP listener at all**.
2. **A live UDP listener:** `grep 4ABC /proc/net/udp /proc/net/udp6` → a wildcard bind. Under
`nethernet` you instead see `4ABC` on **TCP**, which nothing here exposes.
3. **A pong from off-cluster:** a RakNet Unconnected Ping to `192.168.2.22:19132` returns a `0x1c`
packet whose MOTD string starts `MCPE;`. This proves the VIP, Service and policy path.
4. **An allowlist entry for the player's Xbox Gamertag.** `allow-list=true` with
`allowlist.json == []` refuses **every** player with *"You are not invited to play on this
server"* while the server is otherwise perfectly reachable. In the panel console:
`allowlist add <Gamertag>`.
5. **Add Server** by hand in a Windows/mobile client: `crafty-mc.home.keithjackson.dev`, port
`19132`. It will never appear in the LAN/Friends tab, and a console cannot reach it at all.
**Two log messages appear on a fully working server and must be ignored:** BDS's
`================ TRANSPORT TYPE ERROR ===================` banner (advisory — see *Trap 2*), and
Crafty's perpetual `Bedrock ping returned a string with too few fields`. Neither indicates a fault;
chasing them is what cost two sessions. The **real** failure signatures are `Got timeout while
issuing bedrock ping` (no listener) and *"You are not invited"* (allowlist).
## Runtime state: the servers themselves are NOT in git
As of 2026-09-14 one Java server exists — **"homelab"**, Paper 26.2, TCP 25565, 2 GiB heap, autostart
on, UUID `13c7b85d-00bb-4a58-8c86-2433ca47891c`. It was created through Crafty's v2 API, and **Mojang's
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.
@@ -132,6 +435,10 @@ application state on the `crafty-data` Longhorn PVC, in two places, and **Flux c
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