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

6.4 KiB

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 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:

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

#!/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 execs 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.