RESOLVED — a player has joined. Two findings, both now closed.
1. transport=raknet works. BDS 1.26.52's TRANSPORT TYPE ERROR banner is advisory, not a
refusal — the server printed it and bound the RakNet sockets anyway.
2. The wall was an empty allowlist.allow-list=true + allowlist.json == [] refused every player.
The operator added one Xbox Gamertag and the client connected and joined.(The join is operator-reported — I did not watch it.)
Fix shipped: zero manifest change.service-l4.yaml, networkpolicy.yaml and the containerPorts
were already correct; nothing in git needed to change in any of the three passes.
How the join is corroborated on disk (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 — 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.)
This confirms the reasoning step. The mapping of "You are not invited to play on this server" to the
empty allowlist was reasoned from the vendor doc, because Crafty persists no BDS stdout and there was
no log to read it from. One gamertag added, one successful join: allow-list=true + [] was the wall,
and nothing else was.
Also newly observed: 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 (players=0 — the player had left). And the
operator's actual route was file-edit + restart (route 2), not the console allowlist add I listed
first; both are proven, console still preferred since it needs no restart.
The original two findings
1. transport=raknet works. BDS 1.26.52's TRANSPORT TYPE ERROR banner is advisory, not a
refusal — the server printed it and bound the RakNet sockets anyway. UDP 19132 is bound and answering,
proven end-to-end from off-cluster.
2. The live blocker is authorization, not the network. A client reporting "You are not invited to
play on this server" is allow-list=true + allowlist.json == []. An empty allowlist with the
allowlist enabled refuses every player, unconditionally. Fix: add each player's Xbox Gamertag.
The allowlist finding, measured 21:56 (read-only)
Property / file
Value
allow-list
true (server.properties line 37)
allowlist.json
[] — 3 bytes, mtime 20:51, never edited since server create
online-mode
true
transport
raknet (line 71); file mtime still 21:33:31
UDP 19132
bound — /proc/net/udp00000000:4ABC, pid 47006
TCP 19132
absent — no 4ABC in /proc/net/tcp or tcp6
bedrock_server
pid 47006, same process since 21:34, never restarted
K3s-Bedrock.log_path
'' in crafty.sqlite; Dedicated_Server.txt0 bytes
This refutes three inferences that were put to me, and the refutations matter:
"NetherNet signalling is evidently working." No. transport is still raknet — mtime unchanged
from the operator's 21:33:31 edit, audit.log shows no later edit, same pid throughout. And NetherNet
signals over TCP on server-port, of which there is no listener at all. NetherNet is not in
use and could not have served this client.
"The refusal may have come from Microsoft, not our server." A server-authored refusal required
reaching the server, and the only listener on 19132 is the RakNet UDP socket. (Observed: the
listeners, the absent TCP socket, transport=raknet. Reasoned from those: the refusal rode that path.
Not captured: the player's actual flow — no tcpdump/cilium monitor.) What removes the doubt is
that the config is unconditionally refusing: whichever path a client takes, true + [] denies it.
"BDS players never traverse crafty-l4-udp, so the exposure is inert." The opposite. That path
carried both the pong and this refusal. The 19132-19134/UDP exposure is live and correct.
The requested log correlation cannot be done from disk.K3s-Bedrock's log_path is the empty
string and Dedicated_Server.txt is 0 bytes — Crafty persists no BDS stdout anywhere; the console
is panel-UI-only. commander.log 21:39–21:56 holds only homelab crash-detection and raknet_ping
chatter: no join, no gamertag, no XUID, no denial. That silence is evidence about Crafty's logging, not
about the refusal's origin. To see BDS's own reason you must watch the panel console live.
The join model (how a player actually gets authorized)
Identity is an Xbox Live Gamertag (online-mode=true forces XBL auth). allowlist.json entries are {name, xuid?, ignoresPlayerLimit} — xuid is "populated when someone with a matching name connects".
One entry per player; no groups, no roles, no Keycloak involvement (that gate protects only the web
panel). Operator routes, in order of preference: console allowlist add <Gamertag> (immediate, no
restart, verify with allowlist list); or edit the file then allowlist reload; or allow-list=false — discouraged, because 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).
allowlist.json is PVC state — not in git, Flux cannot restore it, and a server recreate resets it
to []. Same class as transport=raknet. Both are backup-relevant.
Finding 1 detail: the transport banner is advisory
This PR started as "BDS is NetherNet-only, so crafty-l4-udp is inert." That premise is refuted.
The previous commit's recommendation (transport=raknet) was correct — honestly labelled REASONED, NOT
OBSERVED, and now OBSERVED. What was wrong was reading an ERROR-level log line as a refusal without
checking what the process did next.
Evidence chain (all OBSERVED unless tagged)
#
Measurement
Command / source
1
./bedrock_server pid 47006 still running 5 min after printing the banner
ps -eo pid,etime,args in the crafty container
2
UDP 19132 + 19133 bound: /proc/net/udp00000000:4ABC, /proc/net/udp6…:4ABD. The earlier TCP bind on 4ABC is gone
Crafty's warning changed kind: last Got timeout21:34:04,306, first too few fields21:34:09,854 (155 → 86 lines)
/crafty/logs/commander.log
5
Got timeout fires only on sock.recv → TimeoutError; too few fields is reachable only after a 0x1C Unconnected Pong arrives with valid magic
app/classes/remote_stats/raknet_ping.py, read
6
End-to-end from off-cluster: RakNet Unconnected Ping → 192.168.2.22:19132 returns a 139-byte 0x1c pong, magic valid, ~12 ms: MCPE;Dedicated Server;2193;1.26.52;0;10;…;Survival;1;19132;19133;0;0;0;
hand-rolled RakNet ping from the toolbox on the host network
7
Vendor doc on the PVC documents raknet as supported: "the server listens directly on server-port (IPv4) and server-portv6 (IPv6), and clients connect to that address and port"
bedrock_server_how_to.html, shipped in the same archive
8
RakNet still fully compiled in: RakNetConnector, createUniqueRakPeer, RakPeerInterface, …/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.
Why NetherNet could never have worked here — the finding the last pass missed
The same vendor doc explains the previous pass's oddest measurement (19132 bound TCP): nethernet "listens on a single dual-stack TCP socket on server-port for an HTTP-based signaling
handshake." And this repo exposes no TCP 19132 — crafty-l4-tcp is 25565-25567 only, and crafty-allow-ingress allows 19132 UDP and not 19132 TCP. Unreachable at the Service layer and
the policy layer. The server's own packet-statistics.txt for that run agrees: Total Received: 0, 0B
over 2293 s. The 26 packets the last pass found on the 19132/UDP policy map were players' RakNet
pings hitting a port where nethernet had opened no UDP socket. Consistent, and now closed.
Recommendation, ranked — with the manifest consequence of each
Rank
Option
Manifest consequence
Verdict
1
Keep transport=raknet
none — Service, policy and containerPorts were already correct, now proven end-to-end
Take this. Works today, vanilla parity, real Bedrock world.
2
GeyserMC + Floodgate on the homelab Paper server
none (Geyser also binds 19132/udp) but it collides with the live BDS — one must move
Reserve. Only if RakNet is really removed. Cost: Bedrock players join the Java world. Geyser's version support is web/unmeasured.
3
Pin/downgrade BDS
none
Unnecessary — the current build speaks RakNet. Whether Crafty can pin a version is UNVERIFIED.
4
PocketMine-MP / Nukkit
none
No — loses vanilla parity and world format.
5
Accept NetherNet
19132-19134/UDP goes inert; would need TCP 19132 added to crafty-l4-tcpandcrafty-allow-ingress, plus server-udp-ports pinned and exposed
No — strictly more work than the option that already works, and WebRTC media behind a shared VIP + SNAT is unproven here.
The 19132-19134/UDP exposure stays and is not inert. The allowlist refusal is orthogonal to
transport — it would refuse players identically under NetherNet. So "accept NetherNet" does not
outrank the status quo: NetherNet is not what is working, RakNet is, and switching would add manifest
work while admitting not one extra player.
Changes — platform/crafty/README.md and DECISIONS-LOG.md only. No manifest touched.
Rewrote "the transport trap" as two traps: nethernet hides the UDP socket, then the ERROR
banner frightens you into reverting the fix. Full evidence chain, vendor quotes, binary symbols, pong.
Documented that Crafty's perpetual too few fields - missing values [None] is the success
signature: 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.
Added the ranked options table and an "If RakNet is ever really removed" migration section.
enable-lan-visibility upgraded to OBSERVED and a previous claim corrected: 19133 is not
unreachable — it answers with the same server_guid, so Bedrock slot 1 is genuinely occupied.
Game VIP corrected: the README asserted 192.168.1.55, which is the base/dev value. clusters/prod/kustomization.yaml patches CRAFTY_L4_VIP to 192.168.2.22 — the live EXTERNAL-IP
on both L4 Services.
Noted transport=raknet is PVC state Flux cannot restore, and silently regresses to the vendor
default on a Bedrock server recreate or reinstall.
DECISIONS-LOG.md: appended a new entry. The prior entry is not edited or deleted — it
reasoned correctly and flagged its own uncertainty honestly; the new entry records that the
observation vindicated it and that the misread was of the server's reaction.
Gate: rc 3 — UNDETERMINED. NOT green. Not a PASS.
ENGINE=<shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'
from the worktree; diff base origin/main @ 20ea70d → HEAD @ c59a4b7.
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 — R-TEST-06 genuinely verified (see below)
gitea-settings
SKIPPED — SETTINGS_API_TOKEN unset
conformance (L2/L3)
SKIPPED — CLUSTER_AVAILABLE != true
rc 3, because two stages did not run. Nothing failed; nothing beyond the green rows is certified.
Touched requirements R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01 are L2/L3 UNVERIFIED.
spec-immutability initially skipped spuriously: dev.sh looks for the spec repo at $REPO_ROOT/../home-network-k3s, which from a worktree is the wrong directory — it exists beside the main checkout. Mounting it RO through the ENGINE shim turned that skip into a real green, so
the one normally-tolerated skip is closed here rather than accepted.
Separately, and not from the gate, the enforcement seam was measured directly (CLAUDE.md §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. A green tick here still proves only the L0/L1 offline subset
— and a skipped Gitea job reports success with "Has been skipped", so read the description.
What CI reported on d29a73c (the prior head) (measured, descriptions read — not just the tick)
context='gate / gate (pull_request)' status='success' description='Successful in 1m35s'
context='gate / conformance (pull_request)' status='success' description='Has been skipped'
Combined commit status: state: success. Neither tick means the gate reached rc 0.
gate / conformance is if: false in .gitea/workflows/gate.yml — it never runs and reports success with "Has been skipped". This is the exact trap CLAUDE.md §2 names.
gate / gate — the one required context — genuinely ran (1m35s), but the workflow invokes ./ci/gate.sh || [ "$?" -eq 3 ] (line 134). CI tolerates exit 3 by declared design. So its
green means "gate.sh exited 0 or 3". My local run exited 3, and CI would report exactly
this same green for it. The tick is not evidence of rc 0 and must not be read as one.
Still open — NOT weakened by the success
auto_start=0 / crash_detection=0 are still 0 (re-measured 22:06, after the join). See
operator action 1 — this is now the urgent item.
The silent-reset exposure, now precisely characterised: a restart preserves transport=raknet
and allowlist.json (observed) but leaves the server down because auto_start=0; a server recreate
or reinstall silently resets server.properties to transport=nethernet (nothing bound on
19132/udp) andallowlist.json to [] (everyone refused). Neither announces itself, and the panel
shows the server healthy. Both files are PVC state Flux cannot restore.
L2/L3 conformance is still uncertified — R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01 remain UNVERIFIED
in CI. A player joining is not a conformance test.
Consoles still cannot reach this; LAN/Friends discovery still cannot work behind the VIP.
Geyser remains entirely unmeasured; BDS version pinnability remains UNVERIFIED.
UNVERIFIED — read before acting
The join itself is operator-reported, not watched by me. Corroborated on disk by the
server-written XUID (above), which is why it is listed as evidence rather than as unverified — but the
distinction is kept deliberately.
Only ONE simultaneous session is proven. Whether a second concurrent Bedrock player evicts the
first (masquerade port reuse / UDP conntrack behind the shared VIP) is untested — 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.
The multi-player SNAT question is still open. Whether a second simultaneous player evicts the
first (masquerade port reuse / UDP conntrack behind a shared VIP) is untested. One ping is one flow.
Geyser is entirely unmeasured here — nothing installed or run. Its version support, jar names and
config keys in the README are documentation-sourced.
Whether Crafty can pin a BDS version — UNVERIFIED, its installer was not read.
Whether a future BDS really removes RakNet — unknowable from here. The banner is a deprecation
notice with an unknown expiry.
A web search on the error string was attempted and was low quality (results echoed the error
back). Nothing here rests on it — all load-bearing evidence is local: /proc/net/*, /proc/47006/fd/*, audit.log, commander.log, raknet_ping.py, the shipped how-to, strings bedrock_server, and the pong.
Read-only, as briefed
No kubectl apply/edit/patch/delete/scale/rollout, no flux reconcile, no write anywhere inside the
container — server.properties was read, never touched. Pod is still crafty-64f7b6d78-pkpgp
(2/2, 15d, 0 restarts). All probes were reads plus outbound UDP pings from the toolbox. Not merged.
Operator actions
URGENT — PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b setting auto_start andcrash_detection true, and read them back. Both are still 0, re-measured at 22:06 after the successful join and after the operator's 22:02:28 "modified the server" action, which did
not change them. This is the only thing between the working server and it silently vanishing at the
next pod restart — and merging fix/crafty-oauth2-proxy-signin is such a restart
(strategy: Recreate, RWO volume), which would stop both Minecraft servers. The failure mode is
now worse than before: "worked, then vanished" rather than "never worked".
Add a gamertag per additional player — console allowlist add <Gamertag> (no restart needed),
verify with allowlist list.
Do not set allow-list=false — the game port is un-gated by design, so the allowlist is the only
join control (and console allowlist off does not persist to server.properties).
Leave transport=raknet alone.Do not revert it because of the TRANSPORT TYPE ERROR banner,
and do not chase the too few fields warning — both appear on a fully working server.
Before announcing to more than one player: only one simultaneous session is proven. If a
second player evicts the first, that is the SNAT/UDP-conntrack question and needs its own measured
investigation.
(superseded by 1) auto_startPATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b
setting auto_start and crash_detection true, and read them back (the create API drops them).
Until then Bedrock does not survive a pod restart — and merging fix/crafty-oauth2-proxy-signin
restarts the pod (strategy: Recreate), stopping both servers.
Set enable-lan-visibility=false on K3s-Bedrock if a second Bedrock server is ever wanted —
it currently occupies both 19132 and 19133 (measured).
Re-check after any Bedrock recreate or BDS update — re-run the 5-step connect procedure; the allowlist resets to [] and: confirm transport=raknet survived, then
re-ping. The vendor default is nethernet and the regression is silent.
Optional: wire SETTINGS_API_TOKEN (infra/gitea/set-ci-token.sh) to close the gitea-settings
skip in future gate runs.
## RESOLVED — a player has joined. Two findings, both now closed.
**1. `transport=raknet` works.** BDS 1.26.52's `TRANSPORT TYPE ERROR` banner is **advisory, not a
refusal** — the server printed it and bound the RakNet sockets anyway.
**2. The wall was an empty allowlist.** `allow-list=true` + `allowlist.json == []` refused every player.
The operator added one Xbox Gamertag and **the client connected and joined.** *(The join is
**operator-reported** — I did not watch it.)*
**Fix shipped: zero manifest change.** `service-l4.yaml`, `networkpolicy.yaml` and the `containerPort`s
were already correct; nothing in git needed to change in any of the three passes.
### How the join is corroborated on disk (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 — 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.)*
**This confirms the reasoning step.** The mapping of *"You are not invited to play on this server"* to the
empty allowlist was **reasoned** from the vendor doc, because Crafty persists no BDS stdout and there was
no log to read it from. One gamertag added, one successful join: **`allow-list=true` + `[]` was the wall,
and nothing else was.**
Also newly observed: **`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 (`players=0` — the player had left). And the
operator's actual route was **file-edit + restart** (route 2), not the console `allowlist add` I listed
first; both are proven, console still preferred since it needs no restart.
## The original two findings
**1. `transport=raknet` works.** BDS 1.26.52's `TRANSPORT TYPE ERROR` banner is **advisory, not a
refusal** — the server printed it and bound the RakNet sockets anyway. UDP 19132 is bound and answering,
proven end-to-end from off-cluster.
**2. The live blocker is authorization, not the network.** A client reporting *"You are not invited to
play on this server"* is `allow-list=true` + `allowlist.json == []`. **An empty allowlist with the
allowlist enabled refuses every player, unconditionally.** Fix: add each player's Xbox **Gamertag**.
### The allowlist finding, measured 21:56 (read-only)
| Property / file | Value |
|---|---|
| `allow-list` | **`true`** (`server.properties` line 37) |
| `allowlist.json` | **`[]`** — 3 bytes, mtime 20:51, never edited since server create |
| `online-mode` | `true` |
| `transport` | **`raknet`** (line 71); file mtime **still 21:33:31** |
| UDP 19132 | bound — `/proc/net/udp` `00000000:4ABC`, pid 47006 |
| **TCP 19132** | **absent** — no `4ABC` in `/proc/net/tcp` or `tcp6` |
| `bedrock_server` | pid **47006**, same process since 21:34, never restarted |
| `K3s-Bedrock.log_path` | **`''`** in `crafty.sqlite`; `Dedicated_Server.txt` **0 bytes** |
This refutes three inferences that were put to me, and the refutations matter:
- **"NetherNet signalling is evidently working."** No. `transport` is still `raknet` — mtime unchanged
from the operator's 21:33:31 edit, `audit.log` shows no later edit, same pid throughout. And NetherNet
signals over **TCP** on `server-port`, of which there is **no listener at all**. NetherNet is not in
use and could not have served this client.
- **"The refusal may have come from Microsoft, not our server."** A server-authored refusal required
reaching the server, and the only listener on 19132 is the **RakNet UDP** socket. *(Observed: the
listeners, the absent TCP socket, `transport=raknet`. Reasoned from those: the refusal rode that path.
Not captured: the player's actual flow — no `tcpdump`/`cilium monitor`.)* What removes the doubt is
that the config is *unconditionally* refusing: whichever path a client takes, `true` + `[]` denies it.
- **"BDS players never traverse `crafty-l4-udp`, so the exposure is inert."** The opposite. That path
carried both the pong and this refusal. **The 19132-19134/UDP exposure is live and correct.**
**The requested log correlation cannot be done from disk.** `K3s-Bedrock`'s `log_path` is the **empty
string** and `Dedicated_Server.txt` is 0 bytes — **Crafty persists no BDS stdout anywhere**; the console
is panel-UI-only. `commander.log` 21:39–21:56 holds only `homelab` crash-detection and `raknet_ping`
chatter: no join, no gamertag, no XUID, no denial. **That silence is evidence about Crafty's logging, not
about the refusal's origin.** To see BDS's own reason you must watch the panel console live.
### The join model (how a player actually gets authorized)
Identity is an **Xbox Live Gamertag** (`online-mode=true` forces XBL auth). `allowlist.json` entries are
`{name, xuid?, ignoresPlayerLimit}` — `xuid` is *"populated when someone with a matching name connects"*.
One entry per player; no groups, no roles, **no Keycloak involvement** (that gate protects only the web
panel). Operator routes, in order of preference: console `allowlist add <Gamertag>` (immediate, no
restart, verify with `allowlist list`); or edit the file then `allowlist reload`; or `allow-list=false` —
**discouraged**, because 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).
`allowlist.json` is **PVC state** — not in git, Flux cannot restore it, and a server recreate resets it
to `[]`. Same class as `transport=raknet`. Both are backup-relevant.
---
## Finding 1 detail: the transport banner is advisory
This PR started as "BDS is NetherNet-only, so `crafty-l4-udp` is inert." **That premise is refuted.**
The previous commit's recommendation (`transport=raknet`) was correct — honestly labelled *REASONED, NOT
OBSERVED*, and now **OBSERVED**. What was wrong was reading an ERROR-level log line as a refusal without
checking what the process did next.
### Evidence chain (all OBSERVED unless tagged)
| # | Measurement | Command / source |
|---|---|---|
| 1 | `./bedrock_server` pid 47006 **still running** 5 min after printing the banner | `ps -eo pid,etime,args` in the `crafty` container |
| 2 | **UDP 19132 + 19133 bound**: `/proc/net/udp` `00000000:4ABC`, `/proc/net/udp6` `…:4ABD`. The earlier **TCP** bind on `4ABC` is **gone** | `grep 4ABC /proc/net/udp /proc/net/udp6 /proc/net/tcp*` |
| 3 | Both sockets owned by that pid | `/proc/47006/fd/25`, `fd/26` → those socket inodes |
| 4 | Crafty's warning **changed kind**: last `Got timeout` `21:34:04,306`, first `too few fields` `21:34:09,854` (155 → 86 lines) | `/crafty/logs/commander.log` |
| 5 | `Got timeout` fires only on `sock.recv` → `TimeoutError`; `too few fields` is reachable **only after a 0x1C Unconnected Pong arrives with valid magic** | `app/classes/remote_stats/raknet_ping.py`, read |
| 6 | **End-to-end from off-cluster**: RakNet Unconnected Ping → `192.168.2.22:19132` returns a 139-byte `0x1c` pong, magic valid, ~12 ms: `MCPE;Dedicated Server;2193;1.26.52;0;10;…;Survival;1;19132;19133;0;0;0;` | hand-rolled RakNet ping from the toolbox on the host network |
| 7 | Vendor doc **on the PVC** documents `raknet` as supported: *"the server listens directly on `server-port` (IPv4) and `server-portv6` (IPv6), and clients connect to that address and port"* | `bedrock_server_how_to.html`, shipped in the same archive |
| 8 | RakNet still fully compiled in: `RakNetConnector`, `createUniqueRakPeer`, `RakPeerInterface`, `…/src-deps/raknet/raknet/RakNetSocket2.cpp` | `strings bedrock_server` |
| 9 | Operator timeline: stop 21:32:32 → edit `server.properties` 21:33:31 → start 21:33:58 | `/crafty/logs/audit.log` |
| 10 | `K3s-Bedrock` **still** `auto_start=0`, `crash_detection=0` | `crafty.sqlite`, opened read-only |
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.
### Why NetherNet could never have worked here — the finding the last pass missed
The same vendor doc explains the previous pass's oddest measurement (19132 bound **TCP**): nethernet
*"listens on a single dual-stack **TCP** socket on `server-port` for an HTTP-based signaling
handshake."* And this repo exposes **no TCP 19132** — `crafty-l4-tcp` is 25565-25567 only, and
`crafty-allow-ingress` allows `19132 UDP` and not `19132 TCP`. Unreachable at the Service layer *and*
the policy layer. The server's own `packet-statistics.txt` for that run agrees: `Total Received: 0, 0B`
over 2293 s. The 26 packets the last pass found on the `19132/UDP` policy map were players' RakNet
pings hitting a port where nethernet had opened no UDP socket. Consistent, and now closed.
### Recommendation, ranked — with the manifest consequence of each
| Rank | Option | Manifest consequence | Verdict |
|---|---|---|---|
| **1** | **Keep `transport=raknet`** | **none** — Service, policy and `containerPort`s were already correct, now proven end-to-end | **Take this.** Works today, vanilla parity, real Bedrock world. |
| 2 | GeyserMC + Floodgate on the `homelab` Paper server | **none** (Geyser also binds `19132/udp`) but it **collides** with the live BDS — one must move | Reserve. Only if RakNet is really removed. Cost: Bedrock players join the **Java** world. Geyser's version support is **web/unmeasured**. |
| 3 | Pin/downgrade BDS | none | Unnecessary — the *current* build speaks RakNet. Whether Crafty can pin a version is **UNVERIFIED**. |
| 4 | PocketMine-MP / Nukkit | none | No — loses vanilla parity and world format. |
| 5 | Accept NetherNet | 19132-19134/UDP goes inert; would need **TCP** 19132 added to `crafty-l4-tcp` **and** `crafty-allow-ingress`, plus `server-udp-ports` pinned and exposed | No — strictly *more* work than the option that already works, and WebRTC media behind a shared VIP + SNAT is unproven here. |
**The 19132-19134/UDP exposure stays and is not inert.** The allowlist refusal is **orthogonal to
transport** — it would refuse players identically under NetherNet. So "accept NetherNet" does **not**
outrank the status quo: NetherNet is not what is working, RakNet is, and switching would *add* manifest
work while admitting **not one extra player**.
### Changes — `platform/crafty/README.md` and `DECISIONS-LOG.md` only. No manifest touched.
- Rewrote *"the transport trap"* as **two** traps: nethernet hides the UDP socket, then the ERROR
banner frightens you into reverting the fix. Full evidence chain, vendor quotes, binary symbols, pong.
- Documented that Crafty's perpetual `too few fields - missing values [None]` is the **success**
signature: 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.
- Added the ranked options table and an *"If RakNet is ever really removed"* migration section.
- `enable-lan-visibility` upgraded to **OBSERVED** and a previous claim **corrected**: `19133` is *not*
unreachable — it answers with the same `server_guid`, so Bedrock slot 1 is genuinely occupied.
- **Game VIP corrected**: the README asserted `192.168.1.55`, which is the base/dev value.
`clusters/prod/kustomization.yaml` patches `CRAFTY_L4_VIP` to `192.168.2.22` — the live `EXTERNAL-IP`
on both L4 Services.
- Noted `transport=raknet` is PVC state Flux cannot restore, and silently regresses to the vendor
default on a Bedrock server recreate or reinstall.
- `DECISIONS-LOG.md`: **appended** a new entry. The prior entry is **not** edited or deleted — it
reasoned correctly and flagged its own uncertainty honestly; the new entry records that the
observation vindicated it and that the misread was of the *server's reaction*.
### Gate: rc 3 — UNDETERMINED. NOT green. Not a PASS.
`ENGINE=<shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`
from the worktree; diff base `origin/main @ 20ea70d` → `HEAD @ c59a4b7`.
| 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** — R-TEST-06 genuinely verified (see below) |
| `gitea-settings` | **SKIPPED** — `SETTINGS_API_TOKEN` unset |
| `conformance` (L2/L3) | **SKIPPED** — `CLUSTER_AVAILABLE != true` |
**rc 3, because two stages did not run.** Nothing failed; nothing beyond the green rows is certified.
Touched requirements `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` are **L2/L3 UNVERIFIED**.
`spec-immutability` initially skipped spuriously: `dev.sh` looks for the spec repo at
`$REPO_ROOT/../home-network-k3s`, which from a *worktree* is the wrong directory — it exists beside the
**main** checkout. Mounting it RO through the `ENGINE` shim turned that skip into a real **green**, so
the one normally-tolerated skip is closed here rather than accepted.
Separately, and not from the gate, the enforcement seam was measured directly (CLAUDE.md §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`. **A green tick here still proves only the L0/L1 offline subset**
— and a skipped Gitea job reports `success` with "Has been skipped", so read the description.
### What CI reported on `d29a73c` (the prior head) (measured, descriptions read — not just the tick)
context='gate / gate (pull_request)' status='success' description='Successful in 1m35s'
context='gate / conformance (pull_request)' status='success' description='Has been skipped'
Combined commit status: `state: success`. **Neither tick means the gate reached rc 0.**
- `gate / conformance` is `if: false` in `.gitea/workflows/gate.yml` — it **never runs** and reports
`success` with *"Has been skipped"*. This is the exact trap CLAUDE.md §2 names.
- `gate / gate` — the one required context — genuinely ran (1m35s), but the workflow invokes
`./ci/gate.sh || [ "$?" -eq 3 ]` (line 134). **CI tolerates exit 3 by declared design.** So its
green means *"gate.sh exited 0 **or** 3"*. My local run exited **3**, and CI would report exactly
this same green for it. The tick is not evidence of rc 0 and must not be read as one.
### Still open — NOT weakened by the success
- **`auto_start=0` / `crash_detection=0` are still `0`** (re-measured 22:06, after the join). See
operator action 1 — this is now the urgent item.
- **The silent-reset exposure, now precisely characterised:** a **restart** preserves `transport=raknet`
and `allowlist.json` (observed) but leaves the server down because `auto_start=0`; a **server recreate
or reinstall** silently resets `server.properties` to `transport=nethernet` (nothing bound on
19132/udp) **and** `allowlist.json` to `[]` (everyone refused). Neither announces itself, and the panel
shows the server healthy. Both files are PVC state Flux cannot restore.
- **L2/L3 conformance is still uncertified** — `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` remain UNVERIFIED
in CI. A player joining is not a conformance test.
- **Consoles still cannot reach this**; LAN/Friends discovery still cannot work behind the VIP.
- **Geyser remains entirely unmeasured**; BDS version pinnability remains UNVERIFIED.
### UNVERIFIED — read before acting
- **The join itself is operator-reported, not watched by me.** Corroborated on disk by the
server-written XUID (above), which is why it is listed as evidence rather than as unverified — but the
distinction is kept deliberately.
- **Only ONE simultaneous session is proven.** Whether a *second* concurrent Bedrock player evicts the
first (masquerade port reuse / UDP conntrack behind the shared VIP) is **untested** — 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.
- **The multi-player SNAT question is still open.** Whether a *second* simultaneous player evicts the
first (masquerade port reuse / UDP conntrack behind a shared VIP) is untested. One ping is one flow.
- **Geyser is entirely unmeasured here** — nothing installed or run. Its version support, jar names and
config keys in the README are documentation-sourced.
- **Whether Crafty can pin a BDS version** — UNVERIFIED, its installer was not read.
- **Whether a future BDS really removes RakNet** — unknowable from here. The banner is a deprecation
notice with an unknown expiry.
- A web search on the error string was attempted and was **low quality** (results echoed the error
back). **Nothing here rests on it** — all load-bearing evidence is local: `/proc/net/*`,
`/proc/47006/fd/*`, `audit.log`, `commander.log`, `raknet_ping.py`, the shipped how-to,
`strings bedrock_server`, and the pong.
### Read-only, as briefed
No `kubectl apply/edit/patch/delete/scale/rollout`, no `flux reconcile`, **no write anywhere inside the
container** — `server.properties` was read, never touched. Pod is still `crafty-64f7b6d78-pkpgp`
(2/2, 15d, 0 restarts). All probes were reads plus outbound UDP pings from the toolbox. **Not merged.**
### Operator actions
1. **URGENT — `PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`** setting `auto_start`
**and** `crash_detection` true, **and read them back**. Both are **still `0`**, re-measured at 22:06
*after* the successful join and after the operator's 22:02:28 "modified the server" action, which did
not change them. This is the only thing between the working server and it silently vanishing at the
next pod restart — and **merging `fix/crafty-oauth2-proxy-signin` is such a restart**
(`strategy: Recreate`, RWO volume), which would stop **both** Minecraft servers. The failure mode is
now worse than before: "worked, then vanished" rather than "never worked".
2. **Add a gamertag per additional player** — console `allowlist add <Gamertag>` (no restart needed),
verify with `allowlist list`.
3. **Do not set `allow-list=false`** — the game port is un-gated by design, so the allowlist is the only
join control (and console `allowlist off` does not persist to `server.properties`).
4. **Leave `transport=raknet` alone.** **Do not revert it because of the `TRANSPORT TYPE ERROR` banner,
and do not chase the `too few fields` warning** — both appear on a fully working server.
5. **Before announcing to more than one player:** only **one** simultaneous session is proven. If a
second player evicts the first, that is the SNAT/UDP-conntrack question and needs its own measured
investigation.
6. **(superseded by 1) `auto_start`** `PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`
setting `auto_start` and `crash_detection` true, **and read them back** (the create API drops them).
Until then Bedrock does not survive a pod restart — and merging `fix/crafty-oauth2-proxy-signin`
restarts the pod (`strategy: Recreate`), stopping **both** servers.
7. **Set `enable-lan-visibility=false`** on `K3s-Bedrock` if a second Bedrock server is ever wanted —
it currently occupies **both** 19132 and 19133 (measured).
8. **Re-check after any Bedrock recreate or BDS update** — re-run the 5-step connect procedure; the allowlist resets to `[]` and: confirm `transport=raknet` survived, then
re-ping. The vendor default is `nethernet` and the regression is silent.
9. Optional: wire `SETTINGS_API_TOKEN` (`infra/gitea/set-ci-token.sh`) to close the `gitea-settings`
skip in future gate runs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reported as "users cannot connect to Bedrock on UDP 19132". Measured read-only
on prod; the platform delivered the packets and the server never opened the
socket.
Server `K3s-Bedrock` (c5837301-..., type=minecraft-bedrock, server_port=19132)
exists and `./bedrock_server` is alive, but /proc/net/udp and /proc/net/udp6 in
the crafty container hold NO socket on 19132 (0x4ABC) in either family -- port
19132 is bound as TCP (/proc/net/tcp6 ...:4ABC state 0A). Cause, from the
server's own server.properties: transport=nethernet, the value Microsoft's BDS
archive ships. Its inline docs: `Allowed values: "raknet" or "nethernet"`, and
against server-portv6, `This property is ignored when transport=nethernet`. NetherNet is the
WebRTC/Xbox-signalled transport and opens no RakNet UDP listener. Crafty does
not manage the property (no `transport` anywhere under /crafty/app/classes/) and
its only Bedrock probe is raknet_ping.py, hence commander.log spamming
"Got timeout while issuing bedrock ping to 127.0.0.1:19132" -- over loopback,
which indicts the server, not the network.
The path below it is sound and was measured as such: crafty-l4-udp live as
externalTrafficPolicy: Cluster with the VIP in status.loadBalancer, a non-empty
EndpointSlice, `cilium-dbg service list` showing 192.168.2.22:19132/UDP =>
10.0.0.130:19132/UDP (active) on BOTH announcing nodes, the datapath policy map
carrying `Allow Ingress reserved:world 19132/UDP` with 26 packets / 27660 bytes
already counted through it, pihole answering crafty-mc -> 192.168.2.22, and Flux
at main@sha1:20ea70d == the GitRepository artifact.
So this is not a platform or manifest defect. No manifest is touched: adding
ports or widening policy for this would be manufacturing work. The repo-side
defect is documentation -- the README told the operator that creating a server
on 19132 was sufficient.
README: add "Bedrock on UDP 19132 -- the transport trap" (the measurement chain,
and the raknet_ping warning as its signature) and "Bedrock realities that are NOT
platform defects" (no LAN/Friends discovery behind a VIP, consoles cannot enter
an address, enable-lan-visibility collides with slots 1-2, server-portv6 is not
exposed). Correct three stale claims: "the operator has not created any servers
yet" (two exist), the human test implying a started Bedrock server is reachable,
and the single-server Runtime state section (now a two-row table recording
K3s-Bedrock's auto_start=0 / crash_detection=0 -- the create-API trap, live
again, so it will not survive a pod restart).
Also measured, reported, and deliberately NOT changed: the shared VIP's two
leases sit on different nodes (crafty-l4-tcp on k3s-prod-3, crafty-l4-udp on
k3s-prod-1), so two nodes ARP-announce 192.168.2.22. Benign under `Cluster` --
both nodes carry the full active frontend set -- and the existing L0 check
l2-announced-vips-serve-from-any-node already forbids the `Local` that would make
it fatal. A "shared VIPs must co-locate" check would assert runtime lease
placement a static check cannot see.
Read-only throughout: no apply/edit/patch, no flux reconcile, no write inside
the container. Operator actions (set transport=raknet and verify the UDP bind;
PATCH auto_start; tell players to Add Server by hand) are in the DECISIONS-LOG
entry, along with what is UNVERIFIED -- chiefly that `raknet` fixes it is
reasoned from the shipped docs, not observed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
gitea-admin
changed title from docs(crafty): Bedrock 19132 refuses nothing — BDS ships transport=nethernet to docs(crafty): Bedrock 19132 IS bound and answering — the TRANSPORT TYPE ERROR banner is advisory2026-09-30 02:51:40 +00:00
The handoff entry asserted conclusions but did not carry its own gate numbers.
Adds them, per CLAUDE.md section 7:
lint / l0 / render-golden / l1 / spec-immutability : green
gitea-settings : SKIPPED (SETTINGS_API_TOKEN unset)
conformance L2/L3: SKIPPED (CLUSTER_AVAILABLE != true)
=> exit 3, UNDETERMINED. Not a PASS.
Also records two things future sessions will otherwise re-learn the hard way:
1. spec-immutability skipped SPURIOUSLY from the worktree -- dev.sh looks for the
spec repo at $REPO_ROOT/../home-network-k3s, which from a worktree is the wrong
directory; it exists beside the MAIN checkout. Mounting it RO through the ENGINE
shim turned the one tolerated skip into a real green, so R-TEST-06 is genuinely
verified. The CLAUDE.md section 3 shim should mount two paths, not one.
2. CI's green tick does NOT mean rc 0. gate/conformance is `if: false` and reports
success with "Has been skipped"; gate/gate runs
`./ci/gate.sh || [ "$?" -eq 3 ]`, so CI tolerates exit 3 by declared design and
would report the same green for the rc 3 above.
Branch protection and the runner were measured against the API, not assumed:
protected=true, user_can_push=false, required_approvals=1, context
'gate / gate (pull_request)', runner incluster-dind online. PR #23 left unmerged.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
New operator observation: a Bedrock client got "Unable to connect / You are not
invited to play on this server". That was put to me as evidence that NetherNet
signalling is working, that the refusal may have come from Microsoft rather than
our server, and that BDS players therefore never traverse crafty-l4-udp.
Measured read-only at 21:56, all three are wrong.
allow-list=true (server.properties line 37)
allowlist.json == [] (3 bytes, mtime 20:51, never edited since create)
online-mode=true
transport=raknet (line 71; file mtime STILL 21:33:31)
UDP 19132 bound (/proc/net/udp 00000000:4ABC, pid 47006)
TCP 19132 ABSENT (no 4ABC in /proc/net/tcp or tcp6)
bedrock_server pid 47006, same process since 21:34, never restarted
1. transport is still raknet -- nobody reverted it, the binary did not rewrite it
(mtime unchanged, audit.log records no later edit, same pid). NetherNet is NOT
in use and cannot be what served this client.
2. NetherNet could not have served it in principle: it signals over TCP on
server-port, and there is no TCP 19132 listener in the pod at all.
3. So the refusal came from OUR server, over RakNet, over 19132/udp, through the
VIP. The 19132-19134/UDP exposure is the opposite of inert -- it carried both
the earlier pong and this refusal.
4. Cause is certain and trivial: allow-list=true with an empty allowlist.json
refuses every player. The shipped how_to says so, and it keys on Xbox Gamertag.
The requested log correlation cannot be done from disk: K3s-Bedrock's log_path in
crafty.sqlite is the empty string and Dedicated_Server.txt is 0 bytes -- Crafty
persists no BDS stdout at all, the console is panel-UI-only. commander.log
21:39-21:56 holds only homelab crash-detection and raknet_ping lines. That silence
is evidence about Crafty's logging, not about the refusal's origin.
Adds a README section on the join authorization model: the measured table, the
proof the refusal is ours, the "console is not on disk" caveat, gamertag/XUID
identity, and three operator routes (console `allowlist add`, edit-then-`allowlist
reload`, or allow-list=false -- discouraged, since the game port is deliberately
un-gated so the allowlist is the only join control). Notes allowlist.json is PVC
state that resets to [] on a server recreate. Options table and human test 4 now
check the allowlist before blaming the network.
Ranking unchanged at the top: keep transport=raknet, add gamertags. Still zero
manifest change. NetherNet would ADD work (TCP 19132 in crafty-l4-tcp and in
crafty-allow-ingress, plus server-udp-ports) and admit not one extra player.
Read-only throughout: allowlist.json, server.properties and the DB were read; no
console command issued, no kubectl mutation, no flux reconcile.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin
changed title from docs(crafty): Bedrock 19132 IS bound and answering — the TRANSPORT TYPE ERROR banner is advisory to docs(crafty): Bedrock 19132 is reachable — the wall is an empty allowlist, not the transport2026-09-30 03:01:32 +00:00
Operator added one gamertag and the Bedrock client connected and joined. The
three-pass investigation closes with a working server, no manifest change, no
transport change, no network change.
Moved out of UNVERIFIED:
* A successful join -- OPERATOR-REPORTED (I did not watch it; the distinction is
kept deliberately). Corroborated directly on disk, which is stronger than the
report alone: allowlist.json went [] -> one entry, 75 bytes, and that entry's
xuid is POPULATED (mtime 22:03:17). audit.log shows the operator wrote only the
name (Edited file .../allowlist.json, 22:02:13). Vendor doc: xuid "will be
populated when someone with a matching name connects". So the server wrote the
XUID on connect, ~64s after the edit -- server-authored evidence of an
XBL-authenticated join.
* The reasoned mapping of "You are not invited" to the empty allowlist is
CONFIRMED: one gamertag added, one successful join. allow-list=true with
allowlist.json == [] was the wall, and nothing else was.
* transport=raknet survives a restart: the fix involved one (pid 47006 -> 47875);
afterwards server.properties mtime is still 21:33:31 with transport=raknet, UDP
4ABC bound, still NO TCP 4ABC, and a fresh pong from 192.168.2.22:19132 at
22:06.
* The operator's actual route was file-edit + restart (route 2), not the console
`allowlist add` I listed first. Both proven; console still preferred (no restart).
README: join-gate section marked RESOLVED; decision heading and option 1 promoted
from provisional to PROVEN; Bedrock human-test 4 rewritten as a 5-step verified
connect procedure, each step with its distinct failure signature, stating 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.
NOT weakened, and sharpened where possible:
* auto_start=0 / crash_detection=0 STILL 0, re-measured at 22:06 after the join
and after the operator's 22:02:28 "modified the server" action, which did not
change them. The failure mode is now worse, not better: "worked, then vanished".
Merging fix/crafty-oauth2-proxy-signin is such a restart.
* Silent-reset now precisely characterised: a RESTART preserves both files
(observed) but leaves the server down (auto_start=0); a server RECREATE or
reinstall silently resets server.properties to transport=nethernet AND
allowlist.json to [].
* L2/L3 conformance still uncertified; multi-player SNAT still untested (exactly
one player has joined -- that is precisely the evidence that does not settle it);
BDS console output still never seen (log_path is '').
No manifest touched, in any of the three passes. Read-only throughout.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
gitea-admin
changed title from docs(crafty): Bedrock 19132 is reachable — the wall is an empty allowlist, not the transport to docs(crafty): Bedrock on 19132 RESOLVED — a player has joined; the wall was an empty allowlist2026-09-30 03:11:14 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
RESOLVED — a player has joined. Two findings, both now closed.
1.
transport=raknetworks. BDS 1.26.52'sTRANSPORT TYPE ERRORbanner is advisory, not arefusal — the server printed it and bound the RakNet sockets anyway.
2. The wall was an empty allowlist.
allow-list=true+allowlist.json == []refused every player.The operator added one Xbox Gamertag and the client connected and joined. (The join is
operator-reported — I did not watch it.)
Fix shipped: zero manifest change.
service-l4.yaml,networkpolicy.yamland thecontainerPortswere already correct; nothing in git needed to change in any of the three passes.
How the join is corroborated on disk (stronger than the report alone)
allowlist.jsonwent[]→ one entry, 75 bytes, and that entry'sxuidis populated(
keys=['ignoresPlayerLimit','name','xuid'], mtime 22:03:17).audit.logshows the operator wroteonly the name —
Edited file …/allowlist.jsonat 22:02:13. The vendor doc:xuidis "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 — 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.)
This confirms the reasoning step. The mapping of "You are not invited to play on this server" to the
empty allowlist was reasoned from the vendor doc, because Crafty persists no BDS stdout and there was
no log to read it from. One gamertag added, one successful join:
allow-list=true+[]was the wall,and nothing else was.
Also newly observed:
transport=raknetsurvives a restart. The fix involved one (stop_server22:02:37 →
start_server22:02:41, pid 47006 → 47875); afterwardsserver.propertiesmtime is still21:33:31 with
transport=raknet, UDP4ABCis bound by the new pid, there is still no TCP 4ABC,and
192.168.2.22:19132answered a fresh pong at 22:06 (players=0— the player had left). And theoperator's actual route was file-edit + restart (route 2), not the console
allowlist addI listedfirst; both are proven, console still preferred since it needs no restart.
The original two findings
1.
transport=raknetworks. BDS 1.26.52'sTRANSPORT TYPE ERRORbanner is advisory, not arefusal — the server printed it and bound the RakNet sockets anyway. UDP 19132 is bound and answering,
proven end-to-end from off-cluster.
2. The live blocker is authorization, not the network. A client reporting "You are not invited to
play on this server" is
allow-list=true+allowlist.json == []. An empty allowlist with theallowlist enabled refuses every player, unconditionally. Fix: add each player's Xbox Gamertag.
The allowlist finding, measured 21:56 (read-only)
allow-listtrue(server.propertiesline 37)allowlist.json[]— 3 bytes, mtime 20:51, never edited since server createonline-modetruetransportraknet(line 71); file mtime still 21:33:31/proc/net/udp00000000:4ABC, pid 470064ABCin/proc/net/tcportcp6bedrock_serverK3s-Bedrock.log_path''incrafty.sqlite;Dedicated_Server.txt0 bytesThis refutes three inferences that were put to me, and the refutations matter:
transportis stillraknet— mtime unchangedfrom the operator's 21:33:31 edit,
audit.logshows no later edit, same pid throughout. And NetherNetsignals over TCP on
server-port, of which there is no listener at all. NetherNet is not inuse and could not have served this client.
reaching the server, and the only listener on 19132 is the RakNet UDP socket. (Observed: the
listeners, the absent TCP socket,
transport=raknet. Reasoned from those: the refusal rode that path.Not captured: the player's actual flow — no
tcpdump/cilium monitor.) What removes the doubt isthat the config is unconditionally refusing: whichever path a client takes,
true+[]denies it.crafty-l4-udp, so the exposure is inert." The opposite. That pathcarried both the pong and this refusal. The 19132-19134/UDP exposure is live and correct.
The requested log correlation cannot be done from disk.
K3s-Bedrock'slog_pathis the emptystring and
Dedicated_Server.txtis 0 bytes — Crafty persists no BDS stdout anywhere; the consoleis panel-UI-only.
commander.log21:39–21:56 holds onlyhomelabcrash-detection andraknet_pingchatter: no join, no gamertag, no XUID, no denial. That silence is evidence about Crafty's logging, not
about the refusal's origin. To see BDS's own reason you must watch the panel console live.
The join model (how a player actually gets authorized)
Identity is an Xbox Live Gamertag (
online-mode=trueforces XBL auth).allowlist.jsonentries are{name, xuid?, ignoresPlayerLimit}—xuidis "populated when someone with a matching name connects".One entry per player; no groups, no roles, no Keycloak involvement (that gate protects only the web
panel). Operator routes, in order of preference: console
allowlist add <Gamertag>(immediate, norestart, verify with
allowlist list); or edit the file thenallowlist reload; orallow-list=false—discouraged, because 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 offdoes not changeserver.properties,so it silently reverts on restart).
allowlist.jsonis PVC state — not in git, Flux cannot restore it, and a server recreate resets itto
[]. Same class astransport=raknet. Both are backup-relevant.Finding 1 detail: the transport banner is advisory
This PR started as "BDS is NetherNet-only, so
crafty-l4-udpis inert." That premise is refuted.The previous commit's recommendation (
transport=raknet) was correct — honestly labelled REASONED, NOTOBSERVED, and now OBSERVED. What was wrong was reading an ERROR-level log line as a refusal without
checking what the process did next.
Evidence chain (all OBSERVED unless tagged)
./bedrock_serverpid 47006 still running 5 min after printing the bannerps -eo pid,etime,argsin thecraftycontainer/proc/net/udp00000000:4ABC,/proc/net/udp6…:4ABD. The earlier TCP bind on4ABCis gonegrep 4ABC /proc/net/udp /proc/net/udp6 /proc/net/tcp*/proc/47006/fd/25,fd/26→ those socket inodesGot timeout21:34:04,306, firsttoo few fields21:34:09,854(155 → 86 lines)/crafty/logs/commander.logGot timeoutfires only onsock.recv→TimeoutError;too few fieldsis reachable only after a 0x1C Unconnected Pong arrives with valid magicapp/classes/remote_stats/raknet_ping.py, read192.168.2.22:19132returns a 139-byte0x1cpong, magic valid, ~12 ms:MCPE;Dedicated Server;2193;1.26.52;0;10;…;Survival;1;19132;19133;0;0;0;raknetas supported: "the server listens directly onserver-port(IPv4) andserver-portv6(IPv6), and clients connect to that address and port"bedrock_server_how_to.html, shipped in the same archiveRakNetConnector,createUniqueRakPeer,RakPeerInterface,…/src-deps/raknet/raknet/RakNetSocket2.cppstrings bedrock_serverserver.properties21:33:31 → start 21:33:58/crafty/logs/audit.logK3s-Bedrockstillauto_start=0,crash_detection=0crafty.sqlite, opened read-onlySo "NetherNet is the only supported transport type" is a support statement, not a capability
one: Microsoft declines to support RakNet and ships it working.
Why NetherNet could never have worked here — the finding the last pass missed
The same vendor doc explains the previous pass's oddest measurement (19132 bound TCP): nethernet
"listens on a single dual-stack TCP socket on
server-portfor an HTTP-based signalinghandshake." And this repo exposes no TCP 19132 —
crafty-l4-tcpis 25565-25567 only, andcrafty-allow-ingressallows19132 UDPand not19132 TCP. Unreachable at the Service layer andthe policy layer. The server's own
packet-statistics.txtfor that run agrees:Total Received: 0, 0Bover 2293 s. The 26 packets the last pass found on the
19132/UDPpolicy map were players' RakNetpings hitting a port where nethernet had opened no UDP socket. Consistent, and now closed.
Recommendation, ranked — with the manifest consequence of each
transport=raknetcontainerPorts were already correct, now proven end-to-endhomelabPaper server19132/udp) but it collides with the live BDS — one must movecrafty-l4-tcpandcrafty-allow-ingress, plusserver-udp-portspinned and exposedThe 19132-19134/UDP exposure stays and is not inert. The allowlist refusal is orthogonal to
transport — it would refuse players identically under NetherNet. So "accept NetherNet" does not
outrank the status quo: NetherNet is not what is working, RakNet is, and switching would add manifest
work while admitting not one extra player.
Changes —
platform/crafty/README.mdandDECISIONS-LOG.mdonly. No manifest touched.banner frightens you into reverting the fix. Full evidence chain, vendor quotes, binary symbols, pong.
too few fields - missing values [None]is the successsignature: BDS 1.26.52's pong has 16
;-fields, Crafty 4.10.8'spingpong_string_fieldsknows 14 andwants an empty terminator, gets
'0'. Cosmetic. The failure signature is the other message.enable-lan-visibilityupgraded to OBSERVED and a previous claim corrected:19133is notunreachable — it answers with the same
server_guid, so Bedrock slot 1 is genuinely occupied.192.168.1.55, which is the base/dev value.clusters/prod/kustomization.yamlpatchesCRAFTY_L4_VIPto192.168.2.22— the liveEXTERNAL-IPon both L4 Services.
transport=raknetis PVC state Flux cannot restore, and silently regresses to the vendordefault on a Bedrock server recreate or reinstall.
DECISIONS-LOG.md: appended a new entry. The prior entry is not edited or deleted — itreasoned correctly and flagged its own uncertainty honestly; the new entry records that the
observation vindicated it and that the misread was of the server's reaction.
Gate: rc 3 — UNDETERMINED. NOT green. Not a PASS.
ENGINE=<shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'from the worktree; diff base
origin/main @ 20ea70d→HEAD @ c59a4b7.lintl0render-goldenl1spec-immutabilitygitea-settingsSETTINGS_API_TOKENunsetconformance(L2/L3)CLUSTER_AVAILABLE != truerc 3, because two stages did not run. Nothing failed; nothing beyond the green rows is certified.
Touched requirements
R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01are L2/L3 UNVERIFIED.spec-immutabilityinitially skipped spuriously:dev.shlooks for the spec repo at$REPO_ROOT/../home-network-k3s, which from a worktree is the wrong directory — it exists beside themain checkout. Mounting it RO through the
ENGINEshim turned that skip into a real green, sothe one normally-tolerated skip is closed here rather than accepted.
Separately, and not from the gate, the enforcement seam was measured directly (CLAUDE.md §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. A green tick here still proves only the L0/L1 offline subset— and a skipped Gitea job reports
successwith "Has been skipped", so read the description.What CI reported on
d29a73c(the prior head) (measured, descriptions read — not just the tick)Combined commit status:
state: success. Neither tick means the gate reached rc 0.gate / conformanceisif: falsein.gitea/workflows/gate.yml— it never runs and reportssuccesswith "Has been skipped". This is the exact trap CLAUDE.md §2 names.gate / gate— the one required context — genuinely ran (1m35s), but the workflow invokes./ci/gate.sh || [ "$?" -eq 3 ](line 134). CI tolerates exit 3 by declared design. So itsgreen means "gate.sh exited 0 or 3". My local run exited 3, and CI would report exactly
this same green for it. The tick is not evidence of rc 0 and must not be read as one.
Still open — NOT weakened by the success
auto_start=0/crash_detection=0are still0(re-measured 22:06, after the join). Seeoperator action 1 — this is now the urgent item.
transport=raknetand
allowlist.json(observed) but leaves the server down becauseauto_start=0; a server recreateor reinstall silently resets
server.propertiestotransport=nethernet(nothing bound on19132/udp) and
allowlist.jsonto[](everyone refused). Neither announces itself, and the panelshows the server healthy. Both files are PVC state Flux cannot restore.
R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01remain UNVERIFIEDin CI. A player joining is not a conformance test.
UNVERIFIED — read before acting
server-written XUID (above), which is why it is listed as evidence rather than as unverified — but the
distinction is kept deliberately.
first (masquerade port reuse / UDP conntrack behind the shared VIP) is untested — and a single
successful session is precisely the evidence that does not settle it.
log_pathis'',Dedicated_Server.txtis 0 bytes.Anything BDS itself said about the join is visible only live in the panel.
first (masquerade port reuse / UDP conntrack behind a shared VIP) is untested. One ping is one flow.
config keys in the README are documentation-sourced.
notice with an unknown expiry.
back). Nothing here rests on it — all load-bearing evidence is local:
/proc/net/*,/proc/47006/fd/*,audit.log,commander.log,raknet_ping.py, the shipped how-to,strings bedrock_server, and the pong.Read-only, as briefed
No
kubectl apply/edit/patch/delete/scale/rollout, noflux reconcile, no write anywhere inside thecontainer —
server.propertieswas read, never touched. Pod is stillcrafty-64f7b6d78-pkpgp(2/2, 15d, 0 restarts). All probes were reads plus outbound UDP pings from the toolbox. Not merged.
Operator actions
PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226bsettingauto_startand
crash_detectiontrue, and read them back. Both are still0, re-measured at 22:06after the successful join and after the operator's 22:02:28 "modified the server" action, which did
not change them. This is the only thing between the working server and it silently vanishing at the
next pod restart — and merging
fix/crafty-oauth2-proxy-signinis such a restart(
strategy: Recreate, RWO volume), which would stop both Minecraft servers. The failure mode isnow worse than before: "worked, then vanished" rather than "never worked".
allowlist add <Gamertag>(no restart needed),verify with
allowlist list.allow-list=false— the game port is un-gated by design, so the allowlist is the onlyjoin control (and console
allowlist offdoes not persist toserver.properties).transport=raknetalone. Do not revert it because of theTRANSPORT TYPE ERRORbanner,and do not chase the
too few fieldswarning — both appear on a fully working server.second player evicts the first, that is the SNAT/UDP-conntrack question and needs its own measured
investigation.
auto_startPATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226bsetting
auto_startandcrash_detectiontrue, and read them back (the create API drops them).Until then Bedrock does not survive a pod restart — and merging
fix/crafty-oauth2-proxy-signinrestarts the pod (
strategy: Recreate), stopping both servers.enable-lan-visibility=falseonK3s-Bedrockif a second Bedrock server is ever wanted —it currently occupies both 19132 and 19133 (measured).
[]and: confirmtransport=raknetsurvived, thenre-ping. The vendor default is
nethernetand the regression is silent.SETTINGS_API_TOKEN(infra/gitea/set-ci-token.sh) to close thegitea-settingsskip in future gate runs.
🤖 Generated with Claude Code
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]>docs(crafty): Bedrock 19132 refuses nothing — BDS ships transport=nethernetto docs(crafty): Bedrock 19132 IS bound and answering — the TRANSPORT TYPE ERROR banner is advisorydocs(crafty): Bedrock 19132 IS bound and answering — the TRANSPORT TYPE ERROR banner is advisoryto docs(crafty): Bedrock 19132 is reachable — the wall is an empty allowlist, not the transportdocs(crafty): Bedrock 19132 is reachable — the wall is an empty allowlist, not the transportto docs(crafty): Bedrock on 19132 RESOLVED — a player has joined; the wall was an empty allowlist