feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m53s

Module-rust phase 18, step 4 of docs/modules/rust/PLAN.md §34.2.7.

The Windows service (D149, §34.2.5): src/windows.rs, ported from link's fix
for error 1053. The same exe tries the SCM handshake and falls through to a
console run on 1063; it reports Running only once the listener and store are
up, and logs to a daily file beside its config. One binary serves every
RunicGatewayRust-<id> instance, because the SCM ignores the dispatcher's name
for an own-process service.

An empty environment variable now counts as unset. A Pterodactyl egg exports
every variable it declares, so a blank RUSTLINK_WEB_TOKEN arrived as "" and
overrode the saved token, and a new one was generated and persisted on every
boot. That breaks D152, which this change makes true.

The egg (R20, R22, D151, D152, §34.2.6), in egg/:
- install.sh is egg 18's script with two changes. A wipe guard moves
  rust-link/ to /tmp around `rm -rf ${REMOVE_FILES}`. The bridge block then
  fetches a schema-2 Rust bundle (pinnable by RUNICGATEWAY_BUNDLE), checks
  every asset's sha256 and the plugin's protocol before placing anything, and
  places the plugin by FRAMEWORK. Vanilla installs nothing and does not fail.
- with-sidecar.sh is the launcher. It unsets blank variables, builds the web
  bind from RUSTLINK_WEB_PORT, and runs --print-config so that a newly
  generated token is printed once. It prints the URL and server id for the
  admin page, then execs the game. It no longer uses `set -e`: nothing the
  bridge gets wrong may keep the game from booting.
- The startup's launcher prefix is conditional, so a server with no bridge
  boots exactly as egg 18 does.
- build.sh assembles egg-rust-runicgateway.json. PR Checks runs it.

The release (D145, §34.2.1) reuses servuo-plugins' engine. It publishes the
static musl Linux binary, the Windows exe, the launcher, the egg and
SHA256SUMS, and dispatches the installer's bundle.yml. PR Checks gains a
clippy run for the Windows target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 23:14:57 -05:00
parent 6aa6cdcf97
commit b3b66b1cc2
13 changed files with 1559 additions and 16 deletions

31
egg/build.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Assemble the importable egg: egg.json with install.sh inserted as its install script.
#
# egg/build.sh [OUT] default OUT: dist/egg-rust-runicgateway.json
#
# The install script is kept as a real file so it can be read, diffed and shellchecked; an install
# script edited inside a JSON string is one nobody reviews. PR Checks runs this, so a broken egg
# fails a pull request, and release.yml runs it to produce the asset a panel admin imports.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${1:-dist/egg-rust-runicgateway.json}"
mkdir -p "$(dirname "$OUT")"
bash -n "$HERE/install.sh"
sh -n "$HERE/with-sidecar.sh"
jq --rawfile script "$HERE/install.sh" \
'.scripts.installation.script = $script' "$HERE/egg.json" > "$OUT"
# The shape Pterodactyl's importer requires, and the variables this egg exists to add.
jq -e '
.meta.version == "PTDL_v2"
and (.scripts.installation.script | startswith("#!/bin/bash"))
and (.startup | startswith("$( [ -x ./rust-link/with-sidecar.sh ]"))
and ([.variables[].env_variable] as $v
| ["RUSTLINK_SERVER_ID","RUSTLINK_WEB_PORT","RUSTLINK_WEB_TOKEN","RUNICGATEWAY_BUNDLE",
"RUSTLINK_RETAIN_DAYS","FRAMEWORK","REGEN_SERVER","REMOVE_FILES"]
| all(. as $k | $v | index($k)))
' "$OUT" >/dev/null || { echo "egg/build.sh: $OUT is missing something the egg must have" >&2; exit 1; }
echo "egg: $OUT"

272
egg/egg.json Normal file
View File

@@ -0,0 +1,272 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2026-09-26T00:00:00+00:00",
"name": "Rust (Runic Gateway)",
"author": "ci@whitlocktech.com",
"description": "Egg 18 \"Rust Autowipe\" with the Runic Gateway bridge: the rust-link sidecar runs beside the game and the plugin is placed for Oxide or Carbon, both from a published, checksum-verified bundle at install time. FRAMEWORK=vanilla installs no bridge. Built from RunicGateway/Rust-Link egg/; see docs/rust-link/INSTALL.md.",
"features": null,
"docker_images": {
"ghcr.io/pterodactyl/games:rust": "ghcr.io/pterodactyl/games:rust"
},
"file_denylist": [],
"startup": "$( [ -x ./rust-link/with-sidecar.sh ] && printf %s ./rust-link/with-sidecar.sh ) \"./RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.ip 0.0.0.0 +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +app.port {{APP_PORT}} +server.saveinterval {{SAVEINTERVAL}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"$( if [ -f seed.txt ] && [[ ${WORLD_SEED} == \"0\" ]]; then printf %s $(cat seed.txt); else printf %s ${WORLD_SEED}; fi )\\\"\"|| printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}\"",
"config": {
"files": "{}",
"startup": "{\n \"done\": \"Server startup complete\"\n}",
"logs": "{}",
"stop": "quit"
},
"scripts": {
"installation": {
"script": "@@ egg/install.sh, inserted by egg/build.sh @@",
"container": "ghcr.io/ptero-eggs/installers:debian",
"entrypoint": "bash"
}
},
"variables": [
{
"name": "SRCDS_APPID",
"description": "",
"env_variable": "SRCDS_APPID",
"default_value": "258550",
"user_viewable": false,
"user_editable": false,
"rules": "required|string|max:20",
"field_type": "text"
},
{
"name": "Max Players",
"description": "The maximum amount of players allowed in the server at once.",
"env_variable": "MAX_PLAYERS",
"default_value": "40",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Server Name",
"description": "The name of your server in the public server list.",
"env_variable": "HOSTNAME",
"default_value": "A Rust Server",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:40",
"field_type": "text"
},
{
"name": "Level",
"description": "The world file for Rust to use.",
"env_variable": "LEVEL",
"default_value": "Procedural Map",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:20",
"field_type": "text"
},
{
"name": "Description",
"description": "The description under your server title. Commonly used for rules & info. Use \\n for newlines.",
"env_variable": "DESCRIPTION",
"default_value": "Powered by Pterodactyl",
"user_viewable": true,
"user_editable": true,
"rules": "required|string",
"field_type": "text"
},
{
"name": "URL",
"description": "The URL for your server. This is what comes up when clicking the \"Visit Website\" button.",
"env_variable": "SERVER_URL",
"default_value": "http://pterodactyl.io",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "World Size",
"description": "The world size for a procedural map.",
"env_variable": "WORLD_SIZE",
"default_value": "3000",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "World Seed",
"description": "The seed for a procedural map.",
"env_variable": "WORLD_SEED",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
},
{
"name": "Server Image",
"description": "The header image for the top of your server listing.",
"env_variable": "SERVER_IMG",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "RCON Port",
"description": "Port for RCON connections.",
"env_variable": "RCON_PORT",
"default_value": "28016",
"user_viewable": true,
"user_editable": false,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "RCON Password",
"description": "RCON access password.",
"env_variable": "RCON_PASS",
"default_value": "CHANGEME",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:/^[\\w.-]*$/|max:64",
"field_type": "text"
},
{
"name": "Save Interval",
"description": "Sets the server’s auto-save interval in seconds.",
"env_variable": "SAVEINTERVAL",
"default_value": "60",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Additional Arguments",
"description": "Add additional startup parameters to the server.",
"env_variable": "ADDITIONAL_ARGS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
},
{
"name": "Regen Server",
"description": "If the server should have its files removed and regenerate the server seed on reinstall.",
"env_variable": "REGEN_SERVER",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "required|boolean",
"field_type": "text"
},
{
"name": "Files to remove",
"description": "A space-separated list of files to remove when regenerating the server on reinstall.",
"env_variable": "REMOVE_FILES",
"default_value": "server/rust/player.deaths.*.db server/rust/player.identities.*.db server/rust/player.states.*.db server/rust/player.tokens.db proceduralmap.*.*.*.map server/rust/proceduralmap.*.*.*.sav oxide/data/Kits_Data.json oxide/data/NTeleportationHome.json oxide/data/ServerRewards/player_data.json oxide/data/PTTracker/playtime_data.json",
"user_viewable": true,
"user_editable": true,
"rules": "required|string",
"field_type": "text"
},
{
"name": "QUERY PORT",
"description": "Port for QUERY connections.",
"env_variable": "QUERY_PORT",
"default_value": "28017",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "APP PORT",
"description": "Port for Rust+ applications. -1 to disable.",
"env_variable": "APP_PORT",
"default_value": "28082",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
},
{
"name": "Custom Map URL",
"description": "Overwrites the map with the one from the direct download URL. Invalid URLs will cause the server to crash.",
"env_variable": "MAP_URL",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "Modding Framework",
"description": "The modding framework to be used: carbon, oxide, vanilla.\nDefaults to \"vanilla\" for a non-modded server installation.",
"env_variable": "FRAMEWORK",
"default_value": "vanilla",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:vanilla,carbon,oxide",
"field_type": "text"
},
{
"name": "Runic Gateway: server id",
"description": "This server's id on the website: lowercase letters, digits and '-', up to 64. It is read ONCE, when the bridge plugin writes its first config; after that the config file holds it and the website locks it, so changing this later changes nothing. Type the same id at Admin -> Rust -> Servers.",
"env_variable": "RUSTLINK_SERVER_ID",
"default_value": "main",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|regex:/^[a-z0-9][a-z0-9-]{0,63}$/",
"field_type": "text"
},
{
"name": "Runic Gateway: sidecar port",
"description": "The port the website reaches this server's bridge on. It MUST be one of this server's allocations: the panel does not tell the server which ports it holds, and a port that is not allocated binds but is never reachable. The console prints the URL on every boot.",
"env_variable": "RUSTLINK_WEB_PORT",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer|between:1024,65535",
"field_type": "text"
},
{
"name": "Runic Gateway: sidecar token",
"description": "Leave blank: the bridge generates a token on its first boot, keeps it in rust-link/sidecar.toml and prints it to the console once. Set it only to choose your own. Anyone who can see this server's startup variables in the panel can read a token typed here.",
"env_variable": "RUSTLINK_WEB_TOKEN",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:128",
"field_type": "text"
},
{
"name": "Runic Gateway: bundle",
"description": "Pin the bridge to a published bundle (e.g. 2026.09.27). Blank takes the current one. Either way it is fetched only when the server is (re)installed, never on a restart, so a restart cannot change the protocol under your website.",
"env_variable": "RUNICGATEWAY_BUNDLE",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|regex:/^\\d{4}\\.\\d{2}\\.\\d{2}(\\.\\d+)?$/",
"field_type": "text"
},
{
"name": "Runic Gateway: history days",
"description": "How many days of raw events the bridge keeps before rolling them up. Blank keeps its default.",
"env_variable": "RUSTLINK_RETAIN_DAYS",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|integer|min:0",
"field_type": "text"
}
]
}

173
egg/install.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/bin/bash
# Rust + the Runic Gateway bridge — the egg's install script.
#
# Egg 18 "Rust Autowipe"'s script, unchanged down to its wipe, plus two things
# (docs/modules/rust/PLAN.md §34.2.6):
#
# 1. THE WIPE GUARD. `rm -rf ${REMOVE_FILES}` runs with rust-link/ moved out of
# the server root, so no list an operator types — wildcards included — can
# reach the bridge's store or its token.
# 2. THE BRIDGE. The sidecar, its launcher and the plugin, from a published
# bundle, each checked against the bundle's sha256 before anything is
# placed. RUNICGATEWAY_BUNDLE pins a bundle; blank takes the current one
# (D151). This runs at install and reinstall only — never at boot, so a
# restart cannot change the protocol under a website that has not moved.
#
# Server Files: /mnt/server
# Image to install with is 'ghcr.io/ptero-eggs/installers:debian' (jq, curl,
# sha256sum and tar; no python3).
##
#
# Variables
# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.
# WINDOWS_INSTALL - if it's a windows server you want to install set to 1
# SRCDS_APPID - steam app id found here - https://developer.valvesoftware.com/wiki/Dedicated_Servers_List
# SRCDS_BETAID - beta branch of a steam app. Leave blank to install normal branch
# SRCDS_BETAPASS - password for a beta branch should one be required during private or closed testing phases.. Leave blank for no password.
# INSTALL_FLAGS - Any additional SteamCMD flags to pass during install.. Keep in mind that steamcmd auto update process in the docker image might overwrite or ignore these when it performs update on server boot.
# AUTO_UPDATE - Adding this variable to the egg allows disabling or enabling automated updates on boot. Boolean value. 0 to disable and 1 to enable.
#
##
## just in case someone removed the defaults.
if [[ "${STEAM_USER}" == "" ]] || [[ "${STEAM_PASS}" == "" ]]; then
echo -e "steam user is not set.\n"
echo -e "Using anonymous user.\n"
STEAM_USER=anonymous
STEAM_PASS=""
STEAM_AUTH=""
else
echo -e "user set to ${STEAM_USER}"
fi
## download and install steamcmd
cd /tmp
mkdir -p /mnt/server/steamcmd
curl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
tar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd
mkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing
cd /mnt/server/steamcmd
# SteamCMD fails otherwise for some reason, even running as root.
# This is changed at the end of the install process anyways.
chown -R root:root /mnt
export HOME=/mnt/server
## install game using steamcmd
./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ "${WINDOWS_INSTALL}" == "1" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} $( [[ -z ${SRCDS_BETAID} ]] || printf %s "-beta ${SRCDS_BETAID}" ) $( [[ -z ${SRCDS_BETAPASS} ]] || printf %s "-betapassword ${SRCDS_BETAPASS}" ) ${INSTALL_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6
## set up 32 bit libraries
mkdir -p /mnt/server/.steam/sdk32
cp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so
## set up 64 bit libraries
mkdir -p /mnt/server/.steam/sdk64
cp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so
## ── The wipe, with rust-link/ held outside the server root ─────────────────
# The store keeps all-time rollups across wipes (R12) and the token is what the
# website holds; a swept store is the failure that looks like success. Moved to
# /tmp — outside /mnt/server, so no path in REMOVE_FILES can name it — and put
# back straight after, before anything else can fail.
if [ "${REGEN_SERVER}" == "1" ]; then
cd /mnt/server/
RG_KEEP=/tmp/runicgateway-rust-link.keep
rm -rf "${RG_KEEP}"
if [ -d rust-link ]; then mv rust-link "${RG_KEEP}"; fi
rm -rf ${REMOVE_FILES}
if [ -d "${RG_KEEP}" ]; then rm -rf rust-link; mv "${RG_KEEP}" rust-link; fi
fi
if [ $WORLD_SEED == "0" ]; then
if [ ! -f /mnt/server/seed.txt ]; then
rm -sf /mnt/server/seed.txt
fi
cat /dev/urandom | tr -dc '1-9' | fold -w 5 | head -n 1 > /mnt/server/seed.txt
fi
## ── The Runic Gateway bridge ─────────────────────────────────────────────────
# After the wipe, so REMOVE_FILES can never delete the plugin this just placed.
rg_install() {
set -euo pipefail
# Overridable only for testing against a mock: the panel passes a container
# just the variables an egg declares, and this one is not declared.
local api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}"
local work=/tmp/runicgateway
local plugins
case "${FRAMEWORK:-vanilla}" in
oxide) plugins=/mnt/server/oxide/plugins ;;
carbon) plugins=/mnt/server/carbon/plugins ;;
*)
# Not a failure (§34.4): failing would leave the operator without a game
# server over a bridge they may not want yet. The startup skips the
# launcher when it is absent, so the server boots exactly as egg 18's.
echo "Runic Gateway: FRAMEWORK=${FRAMEWORK:-vanilla} - the bridge needs Oxide or Carbon, so nothing of it was installed."
return 0
;;
esac
rm -rf "${work}"; mkdir -p "${work}"
local doc="current.json"
if [ -n "${RUNICGATEWAY_BUNDLE:-}" ]; then doc="bundle-${RUNICGATEWAY_BUNDLE}.json"; fi
echo "Runic Gateway: resolving bundle ${doc}"
# The contents API, not /raw/: raw reads are CDN-cached for hours, which would
# hand a reinstall right after a release the bundle from before it.
curl -fsSL "${api}/${doc}?ref=bundles" | jq -r '.content' | base64 -d > "${work}/bundle.json" \
|| { echo "Runic Gateway: could not fetch ${doc} - is RUNICGATEWAY_BUNDLE a published bundle?"; return 1; }
jq -e '.schema == 2 and .game == "rust"' "${work}/bundle.json" >/dev/null \
|| { echo "Runic Gateway: ${doc} is not a schema-2 Rust bundle"; return 1; }
local tag protocol
tag="$(jq -r '.bundle' "${work}/bundle.json")"
protocol="$(jq -r '.protocol' "${work}/bundle.json")"
echo "Runic Gateway: bundle ${tag}, protocol ${protocol}"
# Everything is fetched and checked BEFORE anything is placed: a half-updated
# pair is a sidecar and a plugin speaking two protocols.
fetch() { # <jq path to an asset> <local name>
local name url sha
name="$(jq -r "$1.name" "${work}/bundle.json")"
url="$(jq -r "$1.url" "${work}/bundle.json")"
sha="$(jq -r "$1.sha256" "${work}/bundle.json")"
curl -fsSL -o "${work}/$2" "${url}" || { echo "Runic Gateway: could not download ${name}"; return 1; }
echo "${sha} ${work}/$2" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: ${name} does not match the bundle's sha256 - refusing it"; return 1; }
}
fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar
fetch '.sidecar.launcher' with-sidecar.sh
fetch '.payload.asset' plugin.tar.gz
tar -xzf "${work}/plugin.tar.gz" -C "${work}"
local manifest="${work}/runicgateway-rust-plugin/manifest.json"
[ -f "${manifest}" ] || { echo "Runic Gateway: the plugin tarball has no manifest.json"; return 1; }
[ "$(jq -r '.protocol' "${manifest}")" = "${protocol}" ] \
|| { echo "Runic Gateway: the plugin declares protocol $(jq -r '.protocol' "${manifest}"), the bundle ${protocol} - refusing the pair"; return 1; }
mkdir -p /mnt/server/rust-link "${plugins}"
install -m 755 "${work}/rust-link-sidecar" /mnt/server/rust-link/rust-link-sidecar
install -m 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh
install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs"
# What is installed, readable from the panel's file manager.
jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ bundle, protocol, framework: $framework, installed: $installed,
sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }' \
"${work}/bundle.json" > /mnt/server/rust-link/bundle.json
echo "Runic Gateway: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json") (${FRAMEWORK})"
echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token."
}
# In a subshell so `set -e` inside cannot leak into the rest of this script, and
# so a failure fails the install with its reason rather than leaving a half pair.
# NOT `( rg_install ) || …`: a subshell in a condition runs with errexit OFF, and
# an unchecked failed step inside it would sail on to "installed".
( rg_install )
if [ $? -ne 0 ]; then
echo "Runic Gateway: the bridge was NOT installed (see above)."
exit 1
fi
## install end
echo "-----------------------------------------"
echo "Installation completed..."
echo "-----------------------------------------"

86
egg/with-sidecar.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/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 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 "$@"