Files
docs/rust-link/INSTALL_RIG.md
wtclaude 11b8965aa7 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
2026-09-22 20:35:46 -05:00

136 lines
6.4 KiB
Markdown

# 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.