Files
Rust-Link/egg/with-sidecar.sh
wtclaude 3f5ed059b8
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
fix(sidecar): refuse a plugin that names another server (D155)
`[game].server_id` was a cross-check that WARNED and kept the plugin's id.
The phase 18 walk showed what that costs: a second server's plugin,
parked in this listener's backlog by a plugin bug (Rust-Plugins, D154),
was accepted the moment the first server's plugin reloaded, and the
website showed server "alpha" with beta's hostname and wipe.

Now, with `server_id` set, the connection is closed on the first frame
that names another server, BEFORE that frame reaches the store or the
feed, and both ids are logged at ERROR. The command channel is installed
only once a frame has named this server, so no website command (a grant,
a world write) can reach a plugin about to be refused, and /health reports
the plugin connected only from then. Blank `server_id`: nothing checked,
as before.

The egg's launcher now hands the sidecar the plugin config's ServerId once
that file exists. The plugin reads RUSTLINK_SERVER_ID only at its first
config write (D150); without this, a variable edited after the first boot
would be refused instead of changing nothing, as INSTALL.md promises.

Walked: a plugin aimed at another server's sidecar is refused with an
ERROR naming both ids, and the site keeps the right server's identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:19:50 -05:00

104 lines
5.5 KiB
Bash
Executable File

#!/bin/sh
# with-sidecar.sh — start the rust-link sidecar beside a Rust server, then become the server.
#
# The egg's startup is this script followed by the game's own command line:
#
# ./rust-link/with-sidecar.sh ./RustDedicated -batchmode …
#
# It ships in Rust-Link's release rather than inside the egg, so a fix here reaches a server at its
# next reinstall without anybody re-importing the egg (docs/modules/rust/PLAN.md §34.2.6, §34.4).
# The shape is docs/rust-link/INSTALL_RIG.md's, proven on the rigs; see that file for why each line
# that looks optional is not.
#
# POSIX sh and no jq: the game image (ghcr.io/pterodactyl/games:rust) has grep and sed, not jq.
#
# Deliberately NOT `set -e`. Every step before the last line is the bridge's, and the last line is
# the game's: nothing the bridge gets wrong — an unwritable log, a sidecar that will not start — may
# keep the server from booting. Each step reports its own failure and the script goes on to `exec`.
RL=/home/container/rust-link
mkdir -p "$RL" 2>/dev/null
export RUSTLINK_CONFIG="$RL/sidecar.toml"
# Fixed, and never a panel variable: the install script moves this directory aside around its own
# `rm -rf ${REMOVE_FILES}`, which is what keeps a wipe from reaching the store (§34.1).
export RUSTLINK_DB_PATH="$RL/rust-link.db"
# An EMPTY panel variable is exported as `VAR=""`. The sidecar treats that as unset, and so does
# this script, so a blank field always means "the default" (or, for the token, "the saved one").
for v in RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS RUSTLINK_WEB_PORT RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND; do
eval "val=\${$v-}"
if [ -z "$val" ]; then unset "$v"; fi
done
# The server id follows the PLUGIN's config once it exists. The plugin reads RUSTLINK_SERVER_ID only
# when it writes its first config; after that the file is canonical and the website locks it (D150).
# The sidecar refuses a plugin that names another server (D155), so a variable edited after the
# first boot would otherwise take the bridge down instead of changing nothing. Stock framework paths,
# the same ones the installer writes. `sed`, because the game image has no jq.
for f in /home/container/oxide/config/RunicGateway.json /home/container/carbon/configs/RunicGateway.json; do
[ -f "$f" ] || continue
ID="$(sed -n 's/^[[:space:]]*"ServerId":[[:space:]]*"\([^"]*\)".*$/\1/p' "$f" | head -n 1)"
if [ -n "$ID" ]; then
if [ -n "${RUSTLINK_SERVER_ID-}" ] && [ "$RUSTLINK_SERVER_ID" != "$ID" ]; then
echo "[rust-link] RUSTLINK_SERVER_ID is '$RUSTLINK_SERVER_ID' but the plugin's config names '$ID'; using '$ID', the id the website knows. The variable is read once, at the first boot."
fi
export RUSTLINK_SERVER_ID="$ID"
fi
break
done
# The website-facing bind. Pterodactyl tells a container nothing about its extra allocations, so
# the port is typed into the egg, and it must be one of this server's allocations — a port that is
# not fails as a bind the website never reaches, which the lines below make visible (§34.1).
if [ -n "${RUSTLINK_WEB_PORT-}" ]; then
export RUSTLINK_WEB_BIND="0.0.0.0:${RUSTLINK_WEB_PORT}"
fi
SIDECAR="$RL/rust-link-sidecar"
if [ ! -x "$SIDECAR" ]; then
echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it."
exec "$@"
fi
# Provision first, so the token can be shown. `--print-config` resolves the configuration exactly as
# a start does, writing sidecar.toml with a generated token when there is none; the JSON it prints
# says whether it generated one on THIS call, which is what makes "print once" true (D152).
# Tracing is off on that path, so stdout is only the document. LD_PRELOAD is dropped for the
# sidecar in both calls: Carbon's entrypoint puts its Mono preloader in front of the whole startup.
if CFG="$(env -u LD_PRELOAD "$SIDECAR" --print-config 2>&1)"; then
# The Nth `"key": "value"` line of the pretty-printed JSON. `bind` appears twice — game, then web.
field() { printf '%s\n' "$CFG" | sed -n "s/^ *\"$1\": \"\([^\"]*\)\",\{0,1\}\$/\1/p" | sed -n "${2:-1}p"; }
WEB_BIND="$(field bind 2)"
SERVER_ID="$(field server_id)"
if printf '%s\n' "$CFG" | grep -q '"token_generated": true'; then
TOKEN="$(field auth_token)"
echo "[rust-link] ================================================================"
echo "[rust-link] A new sidecar token was generated. It is shown ONCE, here:"
echo "[rust-link] ${TOKEN}"
echo "[rust-link] It is kept in rust-link/sidecar.toml; read it there if you lose it."
echo "[rust-link] ================================================================"
fi
PORT="${WEB_BIND##*:}"
HOST="${SERVER_IP-}"
case "$HOST" in ""|0.0.0.0) HOST="<this node's address>" ;; esac
echo "[rust-link] Admin -> Rust -> Servers: server id '${SERVER_ID:-main}', sidecar URL http://${HOST}:${PORT} (listening on ${WEB_BIND})"
else
echo "[rust-link] the sidecar could not read its configuration:"
printf '%s\n' "$CFG" | sed 's/^/[rust-link] /'
fi
# A background job's redirection fails in the child, invisibly, so writability is asked first. A
# directory the log cannot be written to is one the store and the token cannot be written to either.
if touch "$RL/sidecar.log" 2>/dev/null; then
env -u LD_PRELOAD "$SIDECAR" >> "$RL/sidecar.log" 2>&1 &
else
echo "[rust-link] $RL is not writable - the game starts without the sidecar."
fi
# The game BECOMES this process: the panel console keeps its stdin and stdout, and stop still
# stops the server, which takes the sidecar down with the container.
exec "$@"