docs(modules): phase 8 as built, and the rig that finally put the sidecar where the design says it lives

Three documents:

* `modules/rust/PLAN.md` §22 — the phase as built. Three org-lead decisions
  (D39-D41), what a player is told and what they are not, the refusals on a
  phone, and an honest limit the rig found: a rank can be live while every
  permission it carries resolves nowhere.
* `rust-link/INSTALL_RIG.md` — new. The sidecar runs INSIDE the game
  container now, which is the shape R20 says the egg ships and which retires
  the firewall wall phases 6, 7 and 7b each stopped at. A container's
  127.0.0.1 is genuinely private, so a stock plugin config and a stock
  sidecar find each other with nothing configured at all. Three things in
  the launcher are load-bearing and each is written down with the failure it
  prevents.
* `rust-link/PLAYER_WALK.md` — the account walk on a phone, and a correction:
  7b's "it needs a firewall rule on a development machine" is no longer true.

`android/PLAN.md` gains M15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 20:35:46 -05:00
parent 15d64b28fe
commit 11b8965aa7
4 changed files with 346 additions and 3 deletions

135
rust-link/INSTALL_RIG.md Normal file
View File

@@ -0,0 +1,135 @@
# The sidecar inside the game container — the Pterodactyl rig recipe
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
is not a phase: it is how the rigs are wired from now on, and it is the shape
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
each other with nothing configured at all.
---
## What goes on the volume
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
| File | What it is |
|---|---|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
| `with-sidecar.sh` | The launcher below, `chmod 755` |
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
Building the binary needs no Rust toolchain on the host:
```bash
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
rustup target add x86_64-unknown-linux-musl
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
```
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
call:** a two-entry `files` array applied only the first, silently, on this panel.
---
## The launcher
```sh
#!/bin/sh
set -e
RL=/home/container/rust-link
mkdir -p "$RL"
export RUSTLINK_CONFIG="$RL/sidecar.toml"
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
export RUSTLINK_DB_PATH
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
eval "val=\${$v-}"
if [ -n "$val" ]; then export "$v"; fi
done
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
exec "$@"
```
Three things in it are load-bearing, and each is a thing that went wrong first:
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
inherits it from this script's environment and still boots modded — which is the composition R20
left unsettled, and this is the answer.
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
container. A `wait` here instead would leave the panel talking to a shell.
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
nothing.
## The startup command
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
the way egg variables will supply them in phase 18 (R22):
```
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
```
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
that survives that, which is why the launcher takes the game command as arguments rather than
containing it.
## Wiring the website to it
The sidecar's web API is on the **second allocation**, so from the site it is
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
`plugin_connected: true` and the protocol version.
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
the container.
## What it proved, first time
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
```
web server listening addr=0.0.0.0:21004
plugin connected peer=127.0.0.1:51148
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
```
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
answered `plugin_connected: true`.
## Still open for phase 18
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
what R22's variable block is for.
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
node, so this is a walk to run, not a claim to repeat.
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
of this, and still the reason a rig can differ from itself between two runs.

View File

@@ -185,8 +185,13 @@ live Oxide rig**, where `rg.config` answers
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
in the running game.** It needs the sidecar and the game server on **one host**, because the game
link is loopback by design (D2) — on the Pterodactyl rigs that is phase 18's egg, and on a
development machine it is a firewall rule for the port the plugin dials.
link is loopback by design (D2).
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
other, which is the whole point of putting them in one container.
| # | Do this | You should see |
|---|---|---|
@@ -215,3 +220,23 @@ this phase has two specific things to confirm rather than assume:
**What counts as a pass:** a setting typed on the website changes what the running game does; a
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
the save did not touch differs by a single byte.
## The account walk on a phone (phase 8, Android leg B)
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
same three minutes as the identity walk above, done on the phone instead of in a browser.
| # | Do this | You should see |
|---|---|---|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
**What counts as a pass:** a player links an account from the phone without touching a browser, and
the screen never claims an entitlement is in the game when the site has not confirmed it there.