# 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: RUSTLINK_SERVER_ID= \ RUSTLINK_WEB_TOKEN= ./rust-link/with-sidecar.sh ``` 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://:` 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.