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
136 lines
6.4 KiB
Markdown
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.
|