16 Commits

Author SHA1 Message Date
703245455b Merge pull request 'feat(sidecar): protocol 13, the egg with RunicNPC and the banner, edge → main (runicnpc 9e step 3, merge 2 of 3)' (#23) from edge into main
All checks were successful
Release sidecar / release (push) Successful in 14m22s
Reviewed-on: #23
2026-10-09 07:20:59 +00:00
f895eaa961 Merge pull request 'feat(egg): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)' (#22) from feat/9e-popup-banner into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m12s
Reviewed-on: #22
2026-10-09 06:29:39 +00:00
4eee1fbe17 feat(egg): write the recommended PopupNotifications banner (runicnpc 9e, D327, D328)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m43s
The install writes PopupNotifications.json into oxide/config or
carbon/configs as a banner across the top of the screen, by the installer's
rule: only where the file is missing or every setting is still the plugin's
own default (the Version aside). Anything else is kept whole. The plugin's
Version is written or kept, because PopupNotifications resets a config
without one. A failed write is said in the console and does not fail the
install: it is a look, not the bridge.

Tested in ghcr.io/ptero-eggs/installers:debian: missing, the rig's defaults,
defaults from a later release (version kept), four kinds of change, not
JSON, an existing banner, and an unmakeable directory. egg/build.sh builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-09 01:24:27 -05:00
13a6d33dfd Merge pull request 'feat: forward RunicNPC's profiles and placements; the egg installs RunicNPC (runicnpc stage 4)' (#21) from feat/runicnpc-stage4 into edge
Reviewed-on: #21
2026-09-30 19:52:29 +00:00
10221b7e20 feat(egg): install RunicNPC when the bundle carries it (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m53s
The install script fetches RunicNPC's tarball with the rest, checks it against
the bundle's sha256, its API against the bundle and its file against its own
manifest, and places RunicNPC.cs before the bridge. Its data directory is left
for RunicNPC to make (runicnpc PLAN.md §1.5). rust-link/bundle.json names it.
Walked against a mock Gitea with and without RunicNPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 08:11:34 -05:00
3b9a986e3a feat(sidecar): forward RunicNPC's profiles and placements (runicnpc stage 4)
GET and POST /npc/profiles, GET /npc/placements and POST /npc/placement:
four thin forwards of the plugin's npc.* commands, stamped like every other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:21:53 -05:00
6cfb236125 Merge pull request 'feat(sidecar): POST /permissions/inventory forwards the plugin's store read' (#20) from feat/perm-inventory into edge
Reviewed-on: #20
2026-09-28 16:38:43 +00:00
3662363caf feat(sidecar): POST /permissions/inventory forwards the plugin's store read
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m40s
Protocol 13, the permission manager (PLAN_REDESIGNS section 1): one more
opaque forward. The body is {snapshotId?, page?}; paging is the plugin's
and the website's, and no page is kept here.

Refs RunicGateway/Module-Rust#21

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-28 06:58:43 -05:00
4498baffe4 Merge pull request 'feat(egg): install every plugin file the release lists, helpers included (D182)' (#19) from feat/protocol-13-egg-helpers into edge
Reviewed-on: #19
2026-09-27 06:11:57 +00:00
fe7b2822c5 feat(egg): install every plugin file the release lists, helpers included (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m58s
Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
the bridge (docs/modules/rust/PLAN_FIXES.md D181, D182), installed by default.
The egg copied only RunicGateway.cs out of the tarball.

It now takes every .cs the plugin manifest lists in `files`, checks each
against its own sha256 before anything is placed (the bridge's own checksum
was never checked by the egg before), and refuses a name that is not a plain
<Name>.cs. A release older than helpers lists only the bridge and installs
exactly what it did before. Exercised in an Alpine shell against both, a
tampered helper, a missing one, `../evil.cs` and `evil.dll`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 21:25:35 -05:00
0179ca52ec Merge pull request 'fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)' (#18) from fix/protocol-13-config-reload into edge
Reviewed-on: #18
2026-09-26 22:29:27 +00:00
522187ddd1 fix(sidecar): protocol 13 — a configuration write no longer waits for its reload (F9)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m48s
The plugin now answers config.write once the files are on disk and reports
the reload as a config.outcome event, which this process files and feeds by
its type like any other event. The reload window no longer has to fit
inside REPLY_TIMEOUT, so CONFIG_RELOAD_WINDOW and the test that asserted
the pairing are gone, and the 504 body stops pointing at a rollback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:08:37 -05:00
e9b6778b78 Merge pull request 'fix(egg): the launcher drops Carbon's preloader for itself, not just the sidecar' (#17) from fix/launcher-carbon-preload into main
All checks were successful
Release sidecar / release (push) Successful in 5m6s
Reviewed-on: #17
2026-09-26 14:53:33 +00:00
af0e6be9f5 fix(egg): the launcher drops Carbon's preloader for itself, not just the sidecar
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m0s
Carbon's entrypoint puts LD_PRELOAD=libdoorstop.so in front of the whole
startup string, and with Carbon's DOORSTOP_* environment that preloader
breaks the launcher shell's own command substitution: the sidecar's
--print-config output went straight to the console and the capture came
back empty. The first Carbon server built from the egg printed its config
JSON, token included, unframed, then "server id 'main', sidecar URL
http://192.168.0.12: (listening on )" and never the once-only banner.
`env -u LD_PRELOAD` on the sidecar alone was not enough: the shell doing
the capturing had the preload too.

The launcher now re-execs itself once without LD_PRELOAD, keeps it aside,
and gives it back to the game in run_game, the only process that needs it.

Reproduced and fixed under Carbon's real environment (environment.sh +
libdoorstop.so): the released launcher dumps the JSON; this one prints the
banner and "server id 'egg-carbon', sidecar URL http://192.168.0.12:21019",
and the game still gets LD_PRELOAD and DOORSTOP_ENABLED. The four
no-preload cases in the game image are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 04:28:05 -05:00
6c46d9388a Merge pull request 'fix(egg): name the egg runicgateway-rust-autowipe' (#16) from fix/egg-name into main
All checks were successful
Release sidecar / release (push) Successful in 5m19s
Reviewed-on: #16
2026-09-26 08:45:32 +00:00
08ad7b93ce fix(egg): name the egg runicgateway-rust-autowipe
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m30s
The org lead's name for it in the panel. Pterodactyl takes an egg's name
from the imported JSON, so it is set here rather than renamed by hand
after every import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:31:56 -05:00
5 changed files with 249 additions and 59 deletions

View File

@@ -5,7 +5,7 @@
"update_url": null "update_url": null
}, },
"exported_at": "2026-09-26T00:00:00+00:00", "exported_at": "2026-09-26T00:00:00+00:00",
"name": "Rust (Runic Gateway)", "name": "runicgateway-rust-autowipe",
"author": "ci@whitlocktech.com", "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.", "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, "features": null,

View File

@@ -88,6 +88,69 @@ if [ $WORLD_SEED == "0" ]; then
fi fi
## ── The Runic Gateway bridge ───────────────────────────────────────────────── ## ── The Runic Gateway bridge ─────────────────────────────────────────────────
# PopupNotifications' recommended look (docs/runicnpc/PLAN.md D327): a banner
# across the top of the screen. PopupNotifications is optional and the operator
# installs it (D141), so its config is theirs: the banner goes in only where the
# file is missing or is still exactly the plugin's own defaults, every setting
# equal and the Version aside (D328). Anything else is kept whole. The Version
# is written too: PopupNotifications resets a config without one to defaults.
rg_popup() { # <the framework's config directory>
local file="$1/PopupNotifications.json"
local banner='{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.15,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.87,
"Width (0.0 - 1.0)": 0.7,
"Height (0.0 - 1.0)": 0.07,
"Space between notification (0.0 - 1.0)": 0.005
},
"UI Options": {
"Show close button": false,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.8,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "robotocondensed-bold.ttf",
"Font size": 18
},
"Version": { "Major": 0, "Minor": 2, "Patch": 1 }
}'
# PopupNotifications 0.2.1's GetBaseConfig(): a small grey box at the right.
local defaults='{
"Notification duration (in seconds)": 8,
"Maximum notifications shown at any time": 6,
"UI Positioning": {
"Position of the left side of notification (0.0 - 1.0)": 0.8,
"Position of the bottom of noticiation (0.0 - 1.0)": 0.78,
"Width (0.0 - 1.0)": 0.19,
"Height (0.0 - 1.0)": 0.1,
"Space between notification (0.0 - 1.0)": 0.01
},
"UI Options": {
"Show close button": true,
"Panel color (hex)": "#2b2b2b",
"Panel transparency (0.0 - 1.0)": 0.5,
"Close button color (hex)": "#d85540",
"Close button transparency (0.0 - 1.0)": 0.5,
"Font": "droidsansmono.ttf",
"Font size": 12
}
}'
if [ ! -e "${file}" ]; then
mkdir -p "$1" || return 1
jq -n "${banner}" > "${file}" || return 1
echo "Runic Gateway: wrote the recommended PopupNotifications banner to ${file}"
elif jq -e --argjson d "${defaults}" 'type == "object" and del(.Version) == $d' "${file}" >/dev/null 2>&1; then
jq --argjson b "${banner}" '$b + { Version: (.Version // $b.Version) }' "${file}" > "${file}.rg" \
&& mv "${file}.rg" "${file}" || { rm -f "${file}.rg"; return 1; }
echo "Runic Gateway: ${file} was PopupNotifications' defaults; wrote the recommended banner"
else
echo "Runic Gateway: kept ${file} (not PopupNotifications' defaults)"
fi
}
# After the wipe, so REMOVE_FILES can never delete the plugin this just placed. # After the wipe, so REMOVE_FILES can never delete the plugin this just placed.
rg_install() { rg_install() {
set -euo pipefail set -euo pipefail
@@ -95,10 +158,10 @@ rg_install() {
# just the variables an egg declares, and this one is not declared. # 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 api="${RUNICGATEWAY_BUNDLE_API:-https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents/v2/rust}"
local work=/tmp/runicgateway local work=/tmp/runicgateway
local plugins local plugins config
case "${FRAMEWORK:-vanilla}" in case "${FRAMEWORK:-vanilla}" in
oxide) plugins=/mnt/server/oxide/plugins ;; oxide) plugins=/mnt/server/oxide/plugins; config=/mnt/server/oxide/config ;;
carbon) plugins=/mnt/server/carbon/plugins ;; carbon) plugins=/mnt/server/carbon/plugins; config=/mnt/server/carbon/configs ;;
*) *)
# Not a failure (§34.4): failing would leave the operator without a game # 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 # server over a bridge they may not want yet. The startup skips the
@@ -138,6 +201,21 @@ rg_install() {
fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar fetch '.sidecar.assets["linux-x86_64"]' rust-link-sidecar
fetch '.sidecar.launcher' with-sidecar.sh fetch '.sidecar.launcher' with-sidecar.sh
fetch '.payload.asset' plugin.tar.gz fetch '.payload.asset' plugin.tar.gz
# RunicNPC (docs/runicnpc/PLAN.md D224), when the bundle carries it: a third
# file beside the bridge, checked the same way before anything is placed.
local npc=""
if jq -e '.npc != null' "${work}/bundle.json" >/dev/null; then
npc="$(jq -r '.npc.tag' "${work}/bundle.json")"
fetch '.npc.asset' runicnpc.tar.gz
tar -xzf "${work}/runicnpc.tar.gz" -C "${work}"
local npc_manifest="${work}/runicnpc/manifest.json"
[ -f "${npc_manifest}" ] || { echo "Runic Gateway: the RunicNPC tarball has no manifest.json"; return 1; }
[ "$(jq -r '.api' "${npc_manifest}")" = "$(jq -r '.npc.api' "${work}/bundle.json")" ] \
|| { echo "Runic Gateway: RunicNPC answers API $(jq -r '.api' "${npc_manifest}"), the bundle says $(jq -r '.npc.api' "${work}/bundle.json") - refusing it"; return 1; }
[ -f "${work}/runicnpc/RunicNPC.cs" ] || { echo "Runic Gateway: the RunicNPC tarball has no RunicNPC.cs"; return 1; }
echo "$(jq -r '.files["RunicNPC.cs"]' "${npc_manifest}") ${work}/runicnpc/RunicNPC.cs" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: RunicNPC.cs does not match its manifest's sha256 - refusing it"; return 1; }
fi
tar -xzf "${work}/plugin.tar.gz" -C "${work}" tar -xzf "${work}/plugin.tar.gz" -C "${work}"
local manifest="${work}/runicgateway-rust-plugin/manifest.json" local manifest="${work}/runicgateway-rust-plugin/manifest.json"
@@ -148,13 +226,42 @@ rg_install() {
mkdir -p /mnt/server/rust-link "${plugins}" 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}/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 755 "${work}/with-sidecar.sh" /mnt/server/rust-link/with-sidecar.sh
install -m 644 "${work}/runicgateway-rust-plugin/RunicGateway.cs" "${plugins}/RunicGateway.cs" # Every .cs the release's manifest lists: the bridge, and the helpers shipped
# beside it (docs/modules/rust/PLAN_FIXES.md D182 - today RunicGatewayZones.cs,
# the ZoneManager helper), each checked against its own sha256 first. A name
# is written into the plugins directory, so only a plain <Name>.cs is taken.
local file sha
for file in $(jq -r '.files | keys[]' "${manifest}"); do
case "${file}" in
*[!A-Za-z0-9_.]* | .* | *..* | *[!s] ) echo "Runic Gateway: the plugin manifest lists ${file}, which is not a plugin file - refusing it"; return 1 ;;
esac
[ "${file%.cs}" != "${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file}, which is not a .cs file - refusing it"; return 1; }
[ -f "${work}/runicgateway-rust-plugin/${file}" ] || { echo "Runic Gateway: the plugin manifest lists ${file} but the tarball has none"; return 1; }
sha="$(jq -r --arg f "${file}" '.files[$f]' "${manifest}")"
echo "${sha} ${work}/runicgateway-rust-plugin/${file}" | sha256sum -c --quiet - \
|| { echo "Runic Gateway: ${file} does not match the plugin manifest's sha256 - refusing it"; return 1; }
done
[ -f "${work}/runicgateway-rust-plugin/RunicGateway.cs" ] || { echo "Runic Gateway: the plugin tarball has no RunicGateway.cs"; return 1; }
# RunicNPC before the bridge: the bridge reports it at hello. Its data directory
# is NOT made here - one made from outside the game is not writable by it
# (runicnpc PLAN.md §1.5); RunicNPC makes its own on first load.
if [ -n "${npc}" ]; then
install -m 644 "${work}/runicnpc/RunicNPC.cs" "${plugins}/RunicNPC.cs"
fi
for file in $(jq -r '.files | keys[]' "${manifest}"); do
install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}"
done
# A look, not the bridge: if it cannot be written, say so and finish the install.
if ! rg_popup "${config}"; then
echo "Runic Gateway: could not write the PopupNotifications banner to ${config}; the bridge is installed regardless"
fi
# What is installed, readable from the panel's file manager. # What is installed, readable from the panel's file manager.
jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ jq --arg framework "${FRAMEWORK}" --arg installed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ bundle, protocol, framework: $framework, installed: $installed, '{ bundle, protocol, framework: $framework, installed: $installed,
sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }' \ sidecar: { tag: .sidecar.tag }, plugin: { tag: .payload.tag, commit: .payload.commit } }
+ (if .npc == null then {} else { runicnpc: { tag: .npc.tag, api: .npc.api } } end)' \
"${work}/bundle.json" > /mnt/server/rust-link/bundle.json "${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: installed sidecar $(jq -r '.sidecar.tag' "${work}/bundle.json") and plugin $(jq -r '.payload.tag' "${work}/bundle.json")${npc:+ and RunicNPC ${npc}} (${FRAMEWORK})"
echo "Runic Gateway: add this server under Admin -> Rust -> Servers; the console prints its URL and, on first boot, its token." 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 # In a subshell so `set -e` inside cannot leak into the rest of this script, and

View File

@@ -16,6 +16,29 @@
# the game's: nothing the bridge gets wrong — an unwritable log, a sidecar that will not start — may # 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`. # keep the server from booting. Each step reports its own failure and the script goes on to `exec`.
# Carbon's entrypoint puts LD_PRELOAD=libdoorstop.so in front of the WHOLE startup string, and with
# Carbon's DOORSTOP_* environment that preloader breaks this shell's own `$(...)`: the child's output
# goes straight to the console and the capture comes back empty. The phase 18 walk saw exactly that
# on the first Carbon server built from the egg — the config JSON, token included, dumped raw to the
# console, and an empty "server id 'main', sidecar URL http://<ip>:" line. Dropping LD_PRELOAD for
# the sidecar alone is not enough; the shell doing the capturing has it too. So the script runs
# once more without it, keeps it aside, and gives it back to the game — the one process that needs
# it — in `run_game` below.
if [ -n "${LD_PRELOAD-}" ] && [ -z "${RUNICGATEWAY_LAUNCHER_CLEAN-}" ]; then
exec env -u LD_PRELOAD RUNICGATEWAY_GAME_PRELOAD="$LD_PRELOAD" RUNICGATEWAY_LAUNCHER_CLEAN=1 \
sh "$0" "$@"
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.
run_game() {
if [ -n "${RUNICGATEWAY_GAME_PRELOAD-}" ]; then
export LD_PRELOAD="$RUNICGATEWAY_GAME_PRELOAD"
fi
unset RUNICGATEWAY_GAME_PRELOAD RUNICGATEWAY_LAUNCHER_CLEAN
exec "$@"
}
RL=/home/container/rust-link RL=/home/container/rust-link
mkdir -p "$RL" 2>/dev/null mkdir -p "$RL" 2>/dev/null
@@ -58,14 +81,14 @@ fi
SIDECAR="$RL/rust-link-sidecar" SIDECAR="$RL/rust-link-sidecar"
if [ ! -x "$SIDECAR" ]; then if [ ! -x "$SIDECAR" ]; then
echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it." echo "[rust-link] $SIDECAR is missing - reinstall the server to fetch the bridge. Starting the game without it."
exec "$@" run_game "$@"
fi fi
# Provision first, so the token can be shown. `--print-config` resolves the configuration exactly as # 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 # 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). # 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 # Tracing is off on that path, so stdout is only the document. LD_PRELOAD is already gone from this
# sidecar in both calls: Carbon's entrypoint puts its Mono preloader in front of the whole startup. # shell (see the top); `env -u` stays so a preload set some other way cannot reach the sidecar.
if CFG="$(env -u LD_PRELOAD "$SIDECAR" --print-config 2>&1)"; then 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. # 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"; } field() { printf '%s\n' "$CFG" | sed -n "s/^ *\"$1\": \"\([^\"]*\)\",\{0,1\}\$/\1/p" | sed -n "${2:-1}p"; }
@@ -98,6 +121,4 @@ else
echo "[rust-link] $RL is not writable - the game starts without the sidecar." echo "[rust-link] $RL is not writable - the game starts without the sidecar."
fi fi
# The game BECOMES this process: the panel console keeps its stdin and stdout, and stop still run_game "$@"
# stops the server, which takes the sidecar down with the container.
exec "$@"

View File

@@ -101,11 +101,9 @@ use tracing_subscriber::EnvFilter;
/// for the reload to announce itself, and **puts the old files back automatically** if it does /// for the reload to announce itself, and **puts the old files back automatically** if it does
/// not. /// not.
/// ///
/// Two things about that reach this process. The write is the only route here that causes a write /// The write is the only route here that causes a write on the game host. Since protocol 13 its
/// on the game host, and it is the only one whose reply routinely spends seconds rather than /// reply comes back as soon as the files are on disk, marked `pending`; how the reload went follows
/// milliseconds — the plugin holds the correlation open across a reload and, at worst, across a /// later as a `config.outcome` event, which this process files and feeds like any other.
/// rollback as well. `web::CONFIG_RELOAD_WINDOW` is that budget, mirrored from the plugin, and a
/// test asserts the pairing rather than trusting it.
/// ///
/// # Protocol 6 — first-party clans /// # Protocol 6 — first-party clans
/// ///
@@ -165,10 +163,23 @@ use tracing_subscriber::EnvFilter;
/// keeps it, and positions are asked for while somebody is looking and never touch the database /// keeps it, and positions are asked for while somebody is looking and never touch the database
/// (D111). Which layer a viewer may see is decided on the website, never here. /// (D111). Which layer a viewer may see is decided on the website, never here.
/// ///
/// # Protocol 13 — the first player walk's fixes
///
/// Opened by the configuration save (the module's PLAN_FIXES.md F9, D177): `POST /config/write`
/// answers `pending` at once instead of holding the RPC across a reload, and the outcome arrives
/// as a `config.outcome` event. Nothing new to route — an event is filed and fed by its `type`,
/// whatever its kind — so what changes here is what no longer has to fit: the reload window that
/// had to sit inside [`rpc::REPLY_TIMEOUT`] is gone.
///
/// The permission manager's redesign (the module's PLAN_REDESIGNS.md §1, D160) adds one route,
/// `POST /permissions/inventory`: the plugin's whole store, in pages it sizes to the game link's
/// line cap. Forwarded like every other object; the pages are never kept here.
///
/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the
/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs, /// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs,
/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites. /// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one
pub const PROTOCOL_VERSION: u32 = 12; /// of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 13;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) { let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -95,6 +95,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it, // plugin will do with any of it. It puts `cmd` and `reqId` on the object and forwards it,
// exactly as it forwards a link code. // exactly as it forwards a link code.
.route("/permissions/sync", post(perm_sync)) .route("/permissions/sync", post(perm_sync))
// Protocol 13 (D160): the whole store, owners included, in pages. The body is
// `{snapshotId?, page?}` and is forwarded as it is; paging is the plugin's and the
// website's business, never this process's, which keeps no copy of any page.
.route("/permissions/inventory", post(perm_inventory))
// Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All // Protocol 5 (R18): the plugin's own view of the game host's configuration tree. All
// three are correlated round trips and all three fail when the game is down, because // three are correlated round trips and all three fail when the game is down, because
// "what is on that host's disk" has no stale answer worth giving — and, unlike a board, // "what is on that host's disk" has no stale answer worth giving — and, unlike a board,
@@ -102,9 +106,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// an operator edited over SSH and the website then overwrote. // an operator edited over SSH and the website then overwrote.
.route("/config/files", get(config_files)) .route("/config/files", get(config_files))
.route("/config/file", get(config_file)) .route("/config/file", get(config_file))
// The only route on this sidecar that causes a WRITE on the game host, and the only one // The only route on this sidecar that causes a WRITE on the game host. It answers once the
// whose reply can take most of the RPC budget: the plugin holds it open across a reload // files are on disk; the reload's outcome follows as a `config.outcome` event (protocol 13).
// and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`.
.route("/config/write", post(config_write)) .route("/config/write", post(config_write))
// Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back. // Protocol 8 (the module's PLAN.md §27): an event borrowing a value and giving it back.
// Three correlated round trips, and the sidecar knows nothing about any of them — not // Three correlated round trips, and the sidecar knows nothing about any of them — not
@@ -139,6 +142,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows // held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows
// what a title is. // what a title is.
.route("/titles", post(titles)) .route("/titles", post(titles))
// Protocol 13, RunicNPC (runicnpc PLAN.md stage 4): the site's NPC profiles and the
// server's placements. RunicNPC is called by the plugin, never by this process, and nothing
// here knows a profile from a placement. Four more thin forwards.
.route("/npc/profiles", get(npc_profiles).post(npc_profiles_set))
.route("/npc/placements", get(npc_placements))
.route("/npc/placement", post(npc_placement))
.route_layer(middleware::from_fn_with_state(state.clone(), gate)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -436,6 +445,13 @@ async fn perm_sync(State(st): State<AppState>, Json(body): Json<Value>) -> Respo
respond(st.rpc.call(&st.game, command, &req_id).await) respond(st.rpc.call(&st.game, command, &req_id).await)
} }
/// One page of the plugin's permission inventory (protocol 13). Page 0 without a `snapshotId`
/// starts a fresh read; any other page names the snapshot page 0 answered with. A `perm.error`
/// (`busy`, `stale`, `too-large`) is an answer, and arrives as a `200` like the sync's.
async fn perm_inventory(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "perm.inventory", "an inventory request").await
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ConfigPathQuery { struct ConfigPathQuery {
path: String, path: String,
@@ -575,6 +591,32 @@ async fn titles(State(st): State<AppState>, Json(body): Json<Value>) -> Response
forward_object(&st, body, "titles.set", "a title set").await forward_object(&st, body, "titles.set", "a title set").await
} }
/// The server's RunicNPC profiles as RunicNPC holds them: whether a site manages them, each profile,
/// and those refused. The website reads this before its first push, to adopt them (D244). Live.
async fn npc_profiles(State(st): State<AppState>) -> Response {
let req_id = st.rpc.next_req_id();
let command = json!({ "cmd": "npc.profiles", "reqId": req_id });
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// Replace the whole profile set (a push). `npc.ok` lists the profiles RunicNPC refused.
async fn npc_profiles_set(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "npc.profiles.set", "a profile push").await
}
/// Every placement, the routes one may walk and the cost warning. Live: an admin's `/rnpc` in game
/// changes it at any moment.
async fn npc_placements(State(st): State<AppState>) -> Response {
let req_id = st.rpc.next_req_id();
let command = json!({ "cmd": "npc.placements", "reqId": req_id });
respond(st.rpc.call(&st.game, command, &req_id).await)
}
/// One change to one placement, by `op`: add, set, remove, rename or respawn.
async fn npc_placement(State(st): State<AppState>, Json(body): Json<Value>) -> Response {
forward_object(&st, body, "npc.placement", "a placement change").await
}
/// What this map is, where its picture comes from, and its monuments (protocol 11, stage one). /// What this map is, where its picture comes from, and its monuments (protocol 11, stage one).
/// Live, and never cached here: a wipe changes the answer, and the module compares its key and /// Live, and never cached here: a wipe changes the answer, and the module compares its key and
/// hash against what it holds to decide whether to fetch at all. /// hash against what it holds to decide whether to fetch at all.
@@ -694,19 +736,14 @@ async fn config_file(State(st): State<AppState>, Query(q): Query<ConfigPathQuery
respond(st.rpc.call(&st.game, command, &req_id).await) respond(st.rpc.call(&st.game, command, &req_id).await)
} }
/// The window the plugin gives a reload to announce itself, mirrored here from
/// `ConfigReloadWindowSeconds` in `overlay/oxide/plugins/RunicGateway.cs`.
///
/// It is duplicated rather than negotiated because the two numbers are a *pairing*, like the
/// protocol version: the plugin owns the behaviour and this end owns the budget it has to fit in.
/// The test below is what keeps them honest — a worst-case write is two windows, and a
/// [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) that does not cover both would abandon the caller
/// precisely when a rollback had just saved them, leaving the website to report a timeout over a
/// server that is perfectly healthy.
pub const CONFIG_RELOAD_WINDOW: Duration = Duration::from_secs(4);
/// Replace a set of configuration files and reload whatever owns them. /// Replace a set of configuration files and reload whatever owns them.
/// ///
/// Since protocol 13 the reply does not wait for the reload. The plugin answers once the files are
/// on disk (`pending: true`, a `writeId`, and the `ceilingMs` it will wait), and reports the reload
/// as a `config.outcome` event when it settles. Protocol 12 held this RPC across the reload, which
/// forced the plugin's window under [`REPLY_TIMEOUT`](crate::rpc::REPLY_TIMEOUT) — and a cold
/// compile outlasted it and had a valid edit rolled back (the module's PLAN_FIXES.md F9).
///
/// Opaque body, exactly as `/permissions/sync`: `cmd` and `reqId` are written over whatever the /// Opaque body, exactly as `/permissions/sync`: `cmd` and `reqId` are written over whatever the
/// caller sent, and nothing else about the object is read here. What the website sends is whole /// caller sent, and nothing else about the object is read here. What the website sends is whole
/// file *text* rather than a key and a value (D35), so there is nothing on this hop that could /// file *text* rather than a key and a value (D35), so there is nothing on this hop that could
@@ -750,16 +787,15 @@ async fn config_write(State(st): State<AppState>, Json(body): Json<Value>) -> Re
let result = st.rpc.call(&st.game, command, &req_id).await; let result = st.rpc.call(&st.game, command, &req_id).await;
if matches!(result, Err(RpcError::Timeout)) { if matches!(result, Err(RpcError::Timeout)) {
// A bare `504` on a route that writes reads as "did my change land, and is the plugin // A bare `504` on a route that writes reads as "did my change land?" — and unlike every
// still up?" — and unlike every other timeout on this sidecar, that question has a good // other timeout on this sidecar, that question has a good answer. The plugin writes a
// answer. The plugin writes a whole set or restores a whole set, never half of either, so // whole set or none of it, and restores a whole set or none of it, so a re-read settles
// a re-read settles it; and a write that is still in flight is most likely inside the // it. Since protocol 13 the reply no longer waits for a reload, so this is a slow host or
// rollback this window pays for. // a busy main thread, not a rollback in progress.
return ( return (
StatusCode::GATEWAY_TIMEOUT, StatusCode::GATEWAY_TIMEOUT,
Json(json!({ Json(json!({
"error": "the plugin did not report within the write budget", "error": "the plugin did not report within the write budget",
"reloadWindowSeconds": CONFIG_RELOAD_WINDOW.as_secs(),
"hint": "re-read the files: the plugin writes the whole set or restores it", "hint": "re-read the files: the plugin writes the whole set or restores it",
})), })),
) )
@@ -1037,25 +1073,6 @@ mod tests {
assert_eq!(command["grants"], json!([])); assert_eq!(command["grants"], json!([]));
} }
/// A command larger than the game link's own line cap is refused here, where the caller learns
/// why. Forwarded, it would be discarded by both ends without a word and present as a `504`.
/// The pairing in `CONFIG_RELOAD_WINDOW`'s own words, asserted rather than trusted.
///
/// The worst path through one configuration write is two windows — wait for the edited
/// plugin's reload, give up, restore the files, reload again — plus the write, the reads and
/// a line each way. If that does not fit inside the RPC timeout, the caller is abandoned at
/// exactly the moment the rollback saved it, and the website reports a timeout over a server
/// that is healthy and has the old config back.
#[test]
fn a_rollback_fits_inside_the_rpc_budget() {
let worst = CONFIG_RELOAD_WINDOW * 2;
assert!(
worst + Duration::from_secs(1) <= crate::rpc::REPLY_TIMEOUT,
"two reload windows ({worst:?}) plus a second of slack must fit in {:?}",
crate::rpc::REPLY_TIMEOUT
);
}
/// Protocol 8's routes cannot choose their own command or correlation id either, and they keep /// Protocol 8's routes cannot choose their own command or correlation id either, and they keep
/// everything else the caller sent, unread. /// everything else the caller sent, unread.
#[test] #[test]
@@ -1107,6 +1124,40 @@ mod tests {
assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none());
} }
/// RunicNPC's writes are stamped like every other forward: a placement change cannot become a
/// profile push, or anything else, by naming one.
#[test]
fn a_runicnpc_command_cannot_choose_its_own_command() {
let body = json!({ "cmd": "npc.profiles.set", "reqId": "theirs", "op": "remove", "id": "bandit-1" });
let stamped = stamp(body, "npc.placement", "r-9").expect("an object is stamped");
assert_eq!(stamped["cmd"], "npc.placement");
assert_eq!(stamped["reqId"], "r-9");
assert_eq!(stamped["op"], "remove");
assert_eq!(stamped["id"], "bandit-1");
let body = json!({ "cmd": "npc.placement", "profiles": { "warden": { "health": 250 } } });
let stamped = stamp(body, "npc.profiles.set", "r-10").expect("an object is stamped");
assert_eq!(stamped["cmd"], "npc.profiles.set");
assert_eq!(stamped["profiles"]["warden"]["health"], 250);
assert!(stamp(json!("warden"), "npc.profiles.set", "r-11").is_none());
}
/// Protocol 13: an inventory page request keeps its paging fields and cannot become a sync.
#[test]
fn an_inventory_request_cannot_choose_its_own_command() {
let body =
json!({ "cmd": "perm.sync", "reqId": "theirs", "snapshotId": "abc123", "page": 2 });
let stamped = stamp(body, "perm.inventory", "r-7").expect("an object is stamped");
assert_eq!(stamped["cmd"], "perm.inventory");
assert_eq!(stamped["reqId"], "r-7");
assert_eq!(stamped["snapshotId"], "abc123");
assert_eq!(stamped["page"], 2);
assert!(stamp(json!([0]), "perm.inventory", "r-8").is_none());
}
/// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's /// Protocol 12: a title set is forwarded whole. The markup inside each `text` is the module's
/// and the plugin's business, so it must arrive byte for byte as the website composed it. /// and the plugin's business, so it must arrive byte for byte as the website composed it.
#[test] #[test]