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:
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user