10 Commits
v0.1.0 ... edge

Author SHA1 Message Date
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
bc1919ce38 Merge pull request 'fix(sidecar): refuse a plugin that names another server (D155)' (#15) from fix/refuse-foreign-plugin into main
All checks were successful
Release sidecar / release (push) Successful in 5m12s
Reviewed-on: #15
2026-09-26 06:27:15 +00:00
3f5ed059b8 fix(sidecar): refuse a plugin that names another server (D155)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4m1s
`[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
8 changed files with 217 additions and 83 deletions

View File

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

View File

@@ -148,7 +148,25 @@ rg_install() {
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"
# 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; }
for file in $(jq -r '.files | keys[]' "${manifest}"); do
install -m 644 "${work}/runicgateway-rust-plugin/${file}" "${plugins}/${file}"
done
# 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,

View File

@@ -16,6 +16,29 @@
# 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`.
# 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
mkdir -p "$RL" 2>/dev/null
@@ -31,6 +54,23 @@ for v in RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS RUSTLINK_WEB
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).
@@ -41,14 +81,14 @@ 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 "$@"
run_game "$@"
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.
# Tracing is off on that path, so stdout is only the document. LD_PRELOAD is already gone from this
# 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
# 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"; }
@@ -81,6 +121,4 @@ 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 "$@"
run_game "$@"

View File

@@ -87,7 +87,8 @@ where
// Game link: events in, commands out.
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<game::GameEvent>();
let (handle, _bound) = game::serve(&cfg.game.bind, event_tx).await?;
let (handle, _bound) =
game::serve(&cfg.game.bind, cfg.game.server_id.clone(), event_tx).await?;
// Live feed: every game event fans out to all connected website WebSocket clients.
let (bcast_tx, _) = broadcast::channel::<String>(1024);
@@ -145,7 +146,6 @@ where
// waiting caller and stop. Everything else is filed by its `type`, and by its `type` alone:
// that is what keeps this process a dumb forwarder while the catalogue grows (PROTOCOL.md
// §8.1). Ten new event kinds are no change here.
let configured_server_id = cfg.game.server_id.clone();
let feed_tx = bcast_tx.clone();
let route_rpc = rpc.clone();
let event_store = store.clone();
@@ -194,7 +194,6 @@ where
// `type` exists to prevent, and it is invisible until somebody wonders why the
// presence board has four thousand rows.
if ev.kind == store::SERVER_BOARD {
check_server_id(&configured_server_id, &ev.value);
info!(kind = %ev.kind, "{}", line);
} else {
tracing::debug!(kind = %ev.kind, "board {}", line);
@@ -260,24 +259,6 @@ where
Ok(())
}
/// The plugin's `serverId` is the authority; `[game].server_id` is a cross-check. A disagreement is
/// almost always two game servers pointed at one sidecar by a copied config, which is silent in
/// every other design and produces one server's history under another's name.
fn check_server_id(configured: &str, hello: &serde_json::Value) {
if configured.is_empty() {
return;
}
let announced = hello.get("serverId").and_then(|v| v.as_str()).unwrap_or("");
if !announced.is_empty() && announced != configured {
warn!(
configured,
announced,
"the connected plugin announces a different serverId than this sidecar is configured \
for; keeping the plugin's. Two servers sharing one sidecar is the usual cause."
);
}
}
/// A frame's own timestamp, falling back to ours. The plugin stamps `t` at the moment the world was
/// read, which is earlier than the moment we saw the line and is the one worth keeping.
fn frame_t(value: &serde_json::Value) -> i64 {

View File

@@ -17,9 +17,11 @@
//! R8 makes the platform multi-server: one sidecar per game server, and every row the module
//! stores carries the server it came from. The plugin declares its own `serverId` in `server.hello`
//! and that is the authority. This setting is a **cross-check**, not a second source of truth: when
//! both are set and they disagree, the sidecar logs the disagreement loudly and keeps the plugin's.
//! Two servers pointed at one sidecar by a copy-pasted config is the mistake this catches, and it
//! is silent in every other design.
//! both are set and they disagree, the sidecar **refuses the plugin** — it closes the connection
//! before the frame is filed, and logs both ids at ERROR (D155). Two servers dialling one sidecar is
//! the mistake this catches, and it is silent in every other design. It was a warning that kept the
//! plugin's id until the phase 18 walk showed what that costs: one server's history filed under
//! another's name, visible on the website. Left blank, nothing is checked.
use std::env;
use std::fs;

View File

@@ -23,7 +23,7 @@ use serde_json::Value;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tracing::{info, warn};
use tracing::{error, info, warn};
/// The longest line the sidecar will accept from the plugin, in bytes.
///
@@ -181,8 +181,12 @@ impl GameHandle {
/// The **bound** address is returned alongside the handle rather than assumed to be the one asked
/// for: `127.0.0.1:0` is a legitimate thing to configure (and what the tests use), and a log line
/// echoing the request rather than the result is the kind that is wrong exactly when it matters.
///
/// `server_id` is `[game].server_id`. When it is set, a plugin that names another server is
/// refused — see [`foreign_server_id`].
pub async fn serve(
addr: &str,
server_id: String,
event_tx: mpsc::UnboundedSender<GameEvent>,
) -> std::io::Result<(GameHandle, std::net::SocketAddr)> {
let listener = TcpListener::bind(addr).await?;
@@ -197,7 +201,9 @@ pub async fn serve(
match listener.accept().await {
Ok((stream, peer)) => {
info!(%peer, "plugin connected");
if let Err(e) = handle_connection(stream, &event_tx, &accept_handle).await {
if let Err(e) =
handle_connection(stream, &server_id, &event_tx, &accept_handle).await
{
warn!(error = %e, "plugin connection ended");
} else {
info!("plugin disconnected");
@@ -224,17 +230,44 @@ pub async fn serve(
Ok((handle, bound))
}
/// The server a frame names, when it is not the one this sidecar is configured for.
///
/// `None` means the frame may pass: no `server_id` is configured, or the frame names none, or it
/// names this one. The plugin stamps `serverId` on every frame, so the first line of a connection
/// is enough to tell — and the check runs before that line reaches the store or the feed, because
/// by the time it is filed, one server's history is already under another's name (D155). That is
/// what the phase 18 walk saw when this was a warning: a second server's plugin, parked in this
/// listener's backlog, was accepted the moment the first one reloaded, and the website showed the
/// first server with the second's hostname and wipe.
fn foreign_server_id<'a>(configured: &str, frame: &'a Value) -> Option<&'a str> {
if configured.is_empty() {
return None;
}
match frame.get("serverId").and_then(|v| v.as_str()) {
Some(announced) if !announced.is_empty() && announced != configured => Some(announced),
_ => None,
}
}
async fn handle_connection(
stream: tokio::net::TcpStream,
server_id: &str,
event_tx: &mpsc::UnboundedSender<GameEvent>,
handle: &GameHandle,
) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let (read_half, mut write_half) = stream.into_split();
// Install the outbound command channel for this connection.
// The outbound command channel for this connection. With a `server_id` configured it is
// installed only once a frame has named this server: until then the peer could be a plugin
// about to be refused, and a command sent in that window — a permission grant, a world write —
// would land on the wrong server. `/health` reads the same handle, so it reports the plugin
// connected only from that moment too.
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<String>();
handle.set(Some(cmd_tx)).await;
let mut pending_tx = Some(cmd_tx);
if server_id.is_empty() {
handle.set(pending_tx.take()).await;
}
let mut reader = BufReader::new(read_half);
let mut lines = LineReader::default();
@@ -258,6 +291,24 @@ async fn handle_connection(
if !trimmed.is_empty() {
match serde_json::from_str::<Value>(trimmed) {
Ok(value) => {
if let Some(announced) = foreign_server_id(server_id, &value) {
error!(
configured = server_id,
announced,
"refusing a plugin that names another server: this \
sidecar is '{server_id}', the plugin says \
'{announced}'. Two servers are dialling one game \
port — check each plugin config's Port against its \
sidecar's [game].bind"
);
return Ok(());
}
if pending_tx.is_some()
&& value.get("serverId").and_then(|v| v.as_str())
== Some(server_id)
{
handle.set(pending_tx.take()).await;
}
let kind = value
.get("kind")
.and_then(|k| k.as_str())
@@ -396,7 +447,7 @@ mod tests {
use tokio::io::AsyncReadExt;
let (tx, mut rx) = mpsc::unbounded_channel();
let (handle, addr) = serve("127.0.0.1:0", tx).await.unwrap();
let (handle, addr) = serve("127.0.0.1:0", String::new(), tx).await.unwrap();
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
client
@@ -415,4 +466,67 @@ mod tests {
let n = client.read(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"{\"cmd\":\"ping\"}\n");
}
#[test]
fn foreign_server_id_names_only_a_disagreement() {
let beta = serde_json::json!({ "kind": "server.hello", "serverId": "beta" });
let alpha = serde_json::json!({ "kind": "server.hello", "serverId": "alpha" });
let anonymous = serde_json::json!({ "kind": "pong" });
let blank = serde_json::json!({ "kind": "pong", "serverId": "" });
assert_eq!(foreign_server_id("alpha", &beta), Some("beta"));
assert_eq!(foreign_server_id("alpha", &alpha), None);
assert_eq!(foreign_server_id("alpha", &anonymous), None);
assert_eq!(foreign_server_id("alpha", &blank), None);
// Nothing configured: nothing to check against.
assert_eq!(foreign_server_id("", &beta), None);
}
/// D155, over a real socket: the plugin that names another server is closed on its first
/// frame, the frame is never forwarded, and no command could have reached it meanwhile.
#[tokio::test]
async fn a_plugin_naming_another_server_is_refused_before_anything_is_filed() {
use tokio::io::AsyncReadExt;
let (tx, mut rx) = mpsc::unbounded_channel();
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
// Accepted, but not yet vetted: no command may go to it.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!handle.is_connected().await);
client
.write_all(b"{\"kind\":\"server.hello\",\"type\":\"board\",\"serverId\":\"beta\"}\n")
.await
.unwrap();
let mut buf = [0u8; 16];
let n = tokio::time::timeout(std::time::Duration::from_secs(2), client.read(&mut buf))
.await
.expect("the sidecar closes a refused plugin")
.unwrap_or(0);
assert_eq!(n, 0);
// The only thing forwarded is the listener's own link.down, never beta's hello.
let ev = rx.recv().await.unwrap();
assert_eq!(ev.kind, "link.down");
assert!(!handle.is_connected().await);
}
#[tokio::test]
async fn the_command_channel_opens_on_the_first_frame_naming_this_server() {
let (tx, mut rx) = mpsc::unbounded_channel();
let (handle, addr) = serve("127.0.0.1:0", "alpha".to_string(), tx).await.unwrap();
let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
client
.write_all(b"{\"kind\":\"server.hello\",\"serverId\":\"alpha\"}\n")
.await
.unwrap();
let ev = rx.recv().await.unwrap();
assert_eq!(ev.value["serverId"], "alpha");
assert!(handle.is_connected().await);
}
}

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
/// not.
///
/// Two things about that reach this process. The write is the only route here that causes a write
/// on the game host, and it is the only one whose reply routinely spends seconds rather than
/// milliseconds — the plugin holds the correlation open across a reload and, at worst, across a
/// rollback as well. `web::CONFIG_RELOAD_WINDOW` is that budget, mirrored from the plugin, and a
/// test asserts the pairing rather than trusting it.
/// The write is the only route here that causes a write on the game host. Since protocol 13 its
/// reply comes back as soon as the files are on disk, marked `pending`; how the reload went follows
/// later as a `config.outcome` event, which this process files and feeds like any other.
///
/// # Protocol 6 — first-party clans
///
@@ -165,10 +163,19 @@ use tracing_subscriber::EnvFilter;
/// 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.
///
/// # 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.
///
/// `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,
/// §16 the rewards, §17 the map, §18 the optional mods; this constant is one of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 12;
/// §16 the rewards, §17 the map, §18 the optional mods, §19 the walk's fixes; this constant is one
/// of its four declaration sites.
pub const PROTOCOL_VERSION: u32 = 13;
fn main() -> anyhow::Result<()> {
let args = match cli::parse(std::env::args().skip(1)) {

View File

@@ -102,9 +102,8 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// an operator edited over SSH and the website then overwrote.
.route("/config/files", get(config_files))
.route("/config/file", get(config_file))
// The only route on this sidecar that causes a WRITE on the game host, and the only one
// whose reply can take most of the RPC budget: the plugin holds it open across a reload
// and, at worst, across a rollback as well. See `CONFIG_RELOAD_WINDOW`.
// The only route on this sidecar that causes a WRITE on the game host. It answers once the
// files are on disk; the reload's outcome follows as a `config.outcome` event (protocol 13).
.route("/config/write", post(config_write))
// 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
@@ -694,19 +693,14 @@ async fn config_file(State(st): State<AppState>, Query(q): Query<ConfigPathQuery
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.
///
/// 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
/// 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
@@ -750,16 +744,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;
if matches!(result, Err(RpcError::Timeout)) {
// A bare `504` on a route that writes reads as "did my change land, and is the plugin
// still up?" — and unlike every other timeout on this sidecar, that question has a good
// answer. The plugin writes a whole set or restores a whole set, never half of either, so
// a re-read settles it; and a write that is still in flight is most likely inside the
// rollback this window pays for.
// A bare `504` on a route that writes reads as "did my change land?" — and unlike every
// other timeout on this sidecar, that question has a good answer. The plugin writes a
// whole set or none of it, and restores a whole set or none of it, so a re-read settles
// it. Since protocol 13 the reply no longer waits for a reload, so this is a slow host or
// a busy main thread, not a rollback in progress.
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({
"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",
})),
)
@@ -1037,25 +1030,6 @@ mod tests {
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
/// everything else the caller sent, unread.
#[test]