From fd6efd9a2cf47e208227e64870a82c82bb2b2a03 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 17 Sep 2026 07:36:45 -0500 Subject: [PATCH 01/11] =?UTF-8?q?feat(sidecar):=20protocol=203=20=E2=80=94?= =?UTF-8?q?=20the=20first=20route=20on=20this=20bridge=20that=20is=20not?= =?UTF-8?q?=20a=20GET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /link/confirm` forwards a one-time link code to the plugin and hands back what it says. Everything before it was the website reading what the game had already told us; this is the website asking the game a question only the game can answer. **It is still a forwarder and holds no authority of its own.** It does not mint codes, does not store them, does not know what a website user is, and cannot tell a good code from a bad one. Putting the code table here would give the sidecar a credential and an opinion, and D2 and the bridge principles say it has neither. **A refused code is a 200.** `link.ok` and `link.error` are both answers, and the website has to tell "that code is wrong" from "the game never replied" to say the right thing to a player. The two transport failures keep the codes `respond` already gives them: 503 when the game is down, 504 when it is up and silent. `usable_code` is split out and tested because its two rejections are easy to get subtly wrong. It trims BEFORE it measures: a player pasting a code out of game chat brings whitespace with it, a field of nothing but spaces is empty rather than four characters long, and the length bound belongs on the trimmed value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- sidecar/src/main.rs | 2 +- sidecar/src/web.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index acd9163..c5be3f6 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -73,7 +73,7 @@ use tracing_subscriber::EnvFilter; /// /// `docs/rust-link/PROTOCOL.md` §8 is the specification; this constant is one of its four /// declaration sites. -pub const PROTOCOL_VERSION: u32 = 2; +pub const PROTOCOL_VERSION: u32 = 3; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 969919f..3e72abb 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -25,7 +25,7 @@ use axum::{ http::{HeaderValue, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; use serde::Deserialize; @@ -76,6 +76,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/feed", get(feed)) // Live: a correlated round trip to the plugin. Fails when the game is down, by design. .route("/status", get(status)) + // **The first route on this sidecar that is not a GET** (protocol 3). Everything before it + // was the website reading what the game had already said; this is the website asking the + // game a question only the game can answer. + // + // It is still a forwarder and holds no authority of its own: it does not mint codes, does + // not store them, does not know what a website user is, and cannot tell a good code from a + // bad one. It moves one string to the plugin and one reply back. + .route("/link/confirm", post(link_confirm)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -255,6 +263,62 @@ async fn status(State(st): State) -> Response { respond(st.rpc.call(&st.game, command, &req_id).await) } +// ---- link confirm ---- + +/// What the website sends to redeem a link code. +#[derive(serde::Deserialize)] +struct LinkConfirm { + code: String, +} + +/// Longer than any code the plugin mints, short enough that nothing else gets forwarded. +const MAX_CODE_LEN: usize = 32; + +/// Redeem a one-time link code the plugin minted for a player who ran `/link`. +/// +/// **The sidecar validates nothing here beyond the shape**, deliberately. Only the game server +/// holds the pending codes, and only it knows which Steam id a code belongs to — so the whole of +/// this function is "forward it, hand back what came out". Putting the code table here instead +/// would give the sidecar a credential and an opinion, and it is designed to have neither. +/// +/// The reply is whatever the plugin said: `link.ok` carrying a `steamId`, or `link.error` carrying +/// a reason. **Both are 200s.** A refused code is an answer, not a transport failure, and the +/// website needs to tell "that code is wrong" from "the game never replied" to say the right thing +/// to a player. The two transport failures keep their own codes through [`respond`] — `503` when +/// the game is down, `504` when it is up and silent. +async fn link_confirm(State(st): State, Json(body): Json) -> Response { + let code = match usable_code(&body.code) { + Some(c) => c, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "a link code is required"})), + ) + .into_response() + } + }; + + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "link.confirm", "reqId": req_id, "code": code }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// The submitted code, trimmed, or `None` when there is nothing worth forwarding. +/// +/// Split out so it can be tested without an [`AppState`], and because the two rejections are +/// easy to get subtly wrong. **Trim first, then measure**: a player pasting a code out of the +/// game chat brings whitespace with it, and a field of nothing but spaces is empty rather than +/// four characters long. The length bound is on the trimmed value for the same reason. +fn usable_code(raw: &str) -> Option<&str> { + let code = raw.trim(); + + if code.is_empty() || code.len() > MAX_CODE_LEN { + return None; + } + + Some(code) +} + /// Maps an RPC outcome onto a status code. /// /// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the @@ -466,6 +530,28 @@ mod tests { /// The two RPC failures must not collapse into one code: "the game is down" and "the game is up /// and slow" have different fixes, and the website's client branches on the status. + #[test] + fn a_submitted_code_is_trimmed_before_it_is_judged() { + // A player pastes out of game chat and brings whitespace with them. Trimming after the + // length check would forward the padding; checking emptiness before trimming would accept + // a field of spaces and send the plugin nothing to look up. + assert_eq!(usable_code(" ABC123 "), Some("ABC123")); + assert_eq!(usable_code("ABC123"), Some("ABC123")); + assert_eq!(usable_code(" "), None); + assert_eq!(usable_code(""), None); + } + + #[test] + fn a_code_longer_than_any_the_plugin_mints_is_refused_here() { + // The plugin's alphabet is six characters. A caller sending a megabyte does not have a + // code, and the game link should never carry the attempt. + let long = "A".repeat(MAX_CODE_LEN + 1); + assert_eq!(usable_code(&long), None); + + let at_bound = "A".repeat(MAX_CODE_LEN); + assert_eq!(usable_code(&at_bound), Some(at_bound.as_str())); + } + #[test] fn rpc_failures_map_to_distinct_codes() { assert_eq!( From 8f5440089c2e8b82c294138818c936792b7f3773 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 18:27:54 -0500 Subject: [PATCH 02/11] =?UTF-8?q?feat(sidecar):=20protocol=204=20=E2=80=94?= =?UTF-8?q?=20two=20routes,=20and=20no=20opinion=20about=20either?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /permissions/catalogue` and `POST /permissions/sync` (R2). The first pair that exists so the website can WRITE to the game, and the smallest change in this repository that a protocol bump has ever needed. That is the dumb-forwarder property paying for itself a second time: protocol 4 adds the largest command on the bridge and touches neither the store nor the feed. The sidecar does not know what a group is, which names are managed, or what the plugin will do with any of it. It puts an envelope on an object and forwards it. **The envelope is this side's.** `cmd` and `reqId` are inserted AFTER the caller's object is taken, so they overwrite anything a caller put there — no request can arrive claiming to be a different command, or aimed at a correlation id somebody else is waiting on. **A command larger than the game link's line cap is refused here**, with the limit in the body. Forwarded, it would be discarded silently by both ends (§3.1 — an over-long line is dropped, not buffered) and present to the caller as a `504`, which sends an operator to look at a game server that is working perfectly. Two tests, and both assert a refusal rather than a happy path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- sidecar/src/main.rs | 25 ++++++++-- sidecar/src/web.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index c5be3f6..fba16c5 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -71,9 +71,28 @@ use tracing_subscriber::EnvFilter; /// * **`GET /feed`** is the ingest cursor, oldest-first, separate from `/events` so that no /// caller can get the other ordering by forgetting a parameter. /// -/// `docs/rust-link/PROTOCOL.md` §8 is the specification; this constant is one of its four -/// declaration sites. -pub const PROTOCOL_VERSION: u32 = 3; +/// # Protocol 3 — identity +/// +/// `POST /link/confirm`, the first route here that is not a GET, and the first message on this +/// bridge the WEBSITE originates. It forwards a six-character code to the plugin and hands back +/// what the plugin said. The codes live in the game's memory and nowhere else: putting the table +/// here would give this process a credential and an opinion, and it is designed to have neither. +/// +/// # Protocol 4 — the permission mirror +/// +/// `GET /permissions/catalogue` and `POST /permissions/sync` (R2). The first command that WRITES +/// to the game: the website sends the whole permission set it authors for this server and the +/// plugin reconciles the store against it. +/// +/// Nothing about that shape is visible in this process beyond two routes, and that is the +/// dumb-forwarder property paying for itself a second time — protocol 4 adds the largest command +/// on the bridge and touches neither the store nor the feed. The one thing this side owns is the +/// envelope: `cmd` and `reqId` are written over whatever the caller sent, and a command that would +/// not fit on the game link is refused here rather than discarded silently at the other end. +/// +/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the +/// mirror; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 4; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 3e72abb..ddca450 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -84,6 +84,17 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // not store them, does not know what a website user is, and cannot tell a good code from a // bad one. It moves one string to the plugin and one reply back. .route("/link/confirm", post(link_confirm)) + // Protocol 4, and the first pair that exists so the website can WRITE to the game (R2). + // + // The catalogue is a live read of what this server's loaded plugins have registered — the + // option source the website's authoring form is built from, so a grant can only name a + // permission that will actually resolve. + .route("/permissions/catalogue", get(perm_catalogue)) + // The sync is the website's whole desired permission set for this server. This sidecar + // reads none of it: it does not know what a group is, which names are managed, or what the + // 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. + .route("/permissions/sync", post(perm_sync)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -319,6 +330,68 @@ fn usable_code(raw: &str) -> Option<&str> { Some(code) } +// ---- permissions (protocol 4) ---- + +/// The largest command this sidecar will put on the game link. +/// +/// Both ends discard an over-long line rather than buffering it (PROTOCOL.md §3.1), and a discarded +/// command is indistinguishable from a plugin that never answered: the caller waits out its whole +/// timeout and is told `504`, which sends an operator to look at the game server. Refusing here +/// costs one comparison and says which limit was actually hit. +/// +/// It is the game link's cap rather than a smaller number of our own, because the line this +/// function builds is the line that has to fit. +const MAX_COMMAND_BYTES: usize = 1024 * 1024; + +/// What this server's loaded plugins have registered, and the groups its store holds. +async fn perm_catalogue(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "perm.catalogue", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Forward the website's desired permission set to the plugin, and hand back its report. +/// +/// **The body is opaque here.** The sidecar defines no schema for a frame's contents — that is the +/// dumb-forwarder property this whole bridge is built on, and it is why protocol 4 adds a shape +/// this large without touching the store or the feed. What this function does own is the envelope: +/// `cmd` and `reqId` are inserted **after** the caller's object is taken, so they overwrite +/// anything a caller put there and no request can arrive claiming to be a different command. +async fn perm_sync(State(st): State, Json(body): Json) -> Response { + let mut command = match body { + Value::Object(map) => map, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "a permission set must be an object"})), + ) + .into_response() + } + }; + + let req_id = st.rpc.next_req_id(); + command.insert("cmd".into(), Value::String("perm.sync".into())); + command.insert("reqId".into(), Value::String(req_id.clone())); + + let command = Value::Object(command); + let encoded = command.to_string(); + + if encoded.len() > MAX_COMMAND_BYTES { + warn!(bytes = encoded.len(), "permission sync refused: too large"); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "error": "the permission set is larger than the game link will carry", + "bytes": encoded.len(), + "limit": MAX_COMMAND_BYTES, + })), + ) + .into_response(); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + /// Maps an RPC outcome onto a status code. /// /// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the @@ -565,6 +638,41 @@ mod tests { assert_eq!(respond(Ok(json!({"ok": true}))).status(), StatusCode::OK); } + /// The envelope is this side's, not the caller's. A website that sent `cmd` or `reqId` in its + /// own body must not be able to aim the forwarded line at a different command, or at a + /// correlation id somebody else is waiting on. + #[test] + fn a_synced_body_cannot_choose_its_own_command_or_correlation_id() { + let body = json!({ "cmd": "link.confirm", "reqId": "r-1", "grants": [] }); + + let mut command = match body { + Value::Object(map) => map, + _ => unreachable!(), + }; + + // The two lines `perm_sync` runs, in its order. + command.insert("cmd".into(), Value::String("perm.sync".into())); + command.insert("reqId".into(), Value::String("r-42".into())); + + assert_eq!(command["cmd"], json!("perm.sync")); + assert_eq!(command["reqId"], json!("r-42")); + // And everything the caller actually meant is still there, untouched and unread. + 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`. + #[test] + fn the_command_cap_is_the_game_links_line_cap() { + assert_eq!(MAX_COMMAND_BYTES, 1024 * 1024); + + let big = json!({ "note": "x".repeat(MAX_COMMAND_BYTES) }).to_string(); + assert!(big.len() > MAX_COMMAND_BYTES); + + let small = json!({ "grants": [] }).to_string(); + assert!(small.len() <= MAX_COMMAND_BYTES); + } + #[test] fn uptime_reads_as_a_human_would_write_it() { assert_eq!(format_uptime(Duration::from_secs(90)), "1m"); From 9532b7b26cf0453ae7fe66ef978d3bfa54f59e92 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 08:54:08 -0500 Subject: [PATCH 03/11] =?UTF-8?q?feat(sidecar):=20protocol=205=20=E2=80=94?= =?UTF-8?q?=20three=20routes,=20and=20the=20one=20timeout=20worth=20explai?= =?UTF-8?q?ning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /config/files`, `GET /config/file` and `POST /config/write` (R18). The sidecar keeps the property protocol 4 relied on: it defines no schema for any of it, stores none of it, and judges no path — only the process holding the configuration directory can decide whether a path resolves inside it, and a guard here would be a weaker second opinion with no way to check itself. Nothing reaches the store or the feed. A config this sidecar cached would be an edit an operator made over SSH that the website then silently overwrote, so all three routes fail when the game is down, like `/status`. The one thing added beyond forwarding is a better 504 on the write: the plugin writes a whole set or restores a whole set and never half of either, so the body says to re-read rather than guess, and names the reload window that is probably still running. `CONFIG_RELOAD_WINDOW` mirrors the plugin's, and a test asserts that two of them plus slack fit inside `REPLY_TIMEOUT` — a rollback that reports after its caller has gone is worse than no rollback. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- sidecar/src/main.rs | 17 ++++- sidecar/src/web.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index fba16c5..050d5fd 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -90,9 +90,22 @@ use tracing_subscriber::EnvFilter; /// envelope: `cmd` and `reqId` are written over whatever the caller sent, and a command that would /// not fit on the game link is refused here rather than discarded silently at the other end. /// +/// # Protocol 5 — configuration from the site +/// +/// `GET /config/files`, `GET /config/file` and `POST /config/write` (R18). An admin edits a +/// plugin's settings on the website; the plugin writes them, reloads whatever owns them, watches +/// 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. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the -/// mirror; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 4; +/// mirror, §11 configuration; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 5; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index ddca450..66f05b2 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -95,6 +95,17 @@ 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, // exactly as it forwards a link code. .route("/permissions/sync", post(perm_sync)) + // 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 + // "what is on that host's disk" has no stale answer worth giving — and, unlike a board, + // nothing here is ever filed in the store. A config this sidecar cached would be a config + // 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`. + .route("/config/write", post(config_write)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -392,6 +403,109 @@ async fn perm_sync(State(st): State, Json(body): Json) -> Respo respond(st.rpc.call(&st.game, command, &req_id).await) } +#[derive(Debug, Deserialize)] +struct ConfigPathQuery { + path: String, +} + +/// Every settings file on the game host, and every plugin loaded to reload one. +/// +/// The sidecar knows nothing about either: not where the tree is (the framework decides, and it is +/// `oxide/config` on one framework and `carbon/configs` on the other), not which files are +/// editable, not what a plugin is called. It asks and forwards. +async fn config_files(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "config.list", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// One settings file, as text, with the version a write must present back. +/// +/// **The path is forwarded verbatim and judged at the far end.** That is not laziness: the only +/// process that can decide whether a path resolves inside the configuration directory is the one +/// that has the directory and the filesystem. A guard here would be a second, weaker opinion in a +/// place with no way to check it, and the kind of guard that makes the real one feel optional. +async fn config_file(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "config.read", "reqId": req_id, "path": q.path }); + 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. +/// +/// 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 +/// misunderstand a number — which is the point of composing the bytes on the side that will be +/// blamed for them. +async fn config_write(State(st): State, Json(body): Json) -> Response { + let mut command = match body { + Value::Object(map) => map, + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "a configuration write must be an object"})), + ) + .into_response() + } + }; + + let req_id = st.rpc.next_req_id(); + command.insert("cmd".into(), Value::String("config.write".into())); + command.insert("reqId".into(), Value::String(req_id.clone())); + + let command = Value::Object(command); + let encoded = command.to_string(); + + if encoded.len() > MAX_COMMAND_BYTES { + warn!( + bytes = encoded.len(), + "configuration write refused: too large" + ); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "error": "the configuration write is larger than the game link will carry", + "bytes": encoded.len(), + "limit": MAX_COMMAND_BYTES, + })), + ) + .into_response(); + } + + 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. + 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", + })), + ) + .into_response(); + } + + respond(result) +} + /// Maps an RPC outcome onto a status code. /// /// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the @@ -662,6 +776,41 @@ mod tests { /// 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 + ); + } + + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. + #[test] + fn a_configuration_write_cannot_choose_its_own_command() { + let body = json!({ "cmd": "perm.sync", "reqId": "r-1", "files": [], "reload": "Kits" }); + + let mut command = match body { + Value::Object(map) => map, + _ => unreachable!(), + }; + + command.insert("cmd".into(), Value::String("config.write".into())); + command.insert("reqId".into(), Value::String("r-7".into())); + + assert_eq!(command["cmd"], json!("config.write")); + assert_eq!(command["reqId"], json!("r-7")); + assert_eq!(command["reload"], json!("Kits")); + } + #[test] fn the_command_cap_is_the_game_links_line_cap() { assert_eq!(MAX_COMMAND_BYTES, 1024 * 1024); From 4adc9bccf280e8df6433be6c08d8d2dfce8117c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:14:18 -0500 Subject: [PATCH 04/11] =?UTF-8?q?feat(sidecar):=20protocol=206=20=E2=80=94?= =?UTF-8?q?=20first-party=20clans,=20and=20no=20new=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 6 adds one board (`clans`) and five clan events. The sidecar files frames by `type`, so none of them needs an arm here. That is protocol 2's promise kept. The bump exists for the other declaration sites: a website that reads clans must not pair with a plugin that never sends them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 050d5fd..779b18c 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -103,9 +103,24 @@ use tracing_subscriber::EnvFilter; /// 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 +/// +/// One board (`clans`) and five events (`clan.created`, `clan.disbanded`, `clan.member.added`, +/// `clan.member.left`, `clan.member.kicked`), from which the website builds core's Teams (R5). +/// +/// **Nothing in this process changed except this number**, and that is protocol 2's promise +/// kept: the board is filed by `type: "snapshot"` and the events by `type: "event"`, so the +/// sidecar needs no arm for any of them. The bump exists for the other two declaration sites — +/// a website that reads clans must not pair with a plugin that never sends them. +/// +/// One property of this process does bear on the board: [`game::MAX_INBOUND_LINE_BYTES`] +/// discards a line over 1 MiB outright. The plugin bounds the board well inside it and says +/// `truncated` when it had to stop, because a board that never arrived would read as a server +/// with no clans. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the -/// mirror, §11 configuration; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 5; +/// mirror, §11 configuration, §12 clans; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 6; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { From cb0913388d7f94bbe1d69efbff9aa9cc0d16b9eb Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:52:56 -0500 Subject: [PATCH 05/11] =?UTF-8?q?feat(sidecar):=20protocol=207=20=E2=80=94?= =?UTF-8?q?=20the=20raid=20frame,=20and=20again=20no=20new=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entity.destroyed gains the cupboard's authorised list and covers doors, walls and the cupboard. The sidecar stores and serves events as they arrive, so only the number moves. The bump is what stops a website that alerts on `authorized` from pairing with a protocol-6 plugin that never sends it, which would read every raid as a base with no cupboard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 779b18c..b6a9eeb 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -118,9 +118,21 @@ use tracing_subscriber::EnvFilter; /// `truncated` when it had to stop, because a board that never arrived would read as a server /// with no clans. /// +/// # Protocol 7 — a raid frame that names who lives there +/// +/// `entity.destroyed` widens to doors, external walls and the tool cupboard, and gains the +/// cupboard's `buildingId` and its `authorized` list, which is what lets the website send the +/// raid alert to the people whose base it was (the module's PLAN.md §25). +/// +/// **Again nothing here changed but this number.** The frame is an `event`, stored and served as +/// it arrived. The bump exists because a website that alerts on `authorized` must not pair with a +/// plugin that never sends it — against protocol 6 it would read every raid as a base with no +/// cupboard, and alert nobody while looking healthy. +/// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the -/// mirror, §11 configuration, §12 clans; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 6; +/// mirror, §11 configuration, §12 clans, §13 the raid frame; this constant is one of its four +/// declaration sites. +pub const PROTOCOL_VERSION: u32 = 7; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { From d688dfca6639acaf7cf811dd040cdb7816bc75dd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 21:14:03 -0500 Subject: [PATCH 06/11] =?UTF-8?q?feat(sidecar):=20protocol=208=20=E2=80=94?= =?UTF-8?q?=20three=20lease=20forwards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /lease, POST /lease, POST /lease/release, each a correlated round trip with cmd and reqId stamped over the caller's. lease.expired is an ordinary event. The sidecar learns nothing about keys, bounds or deadlines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 15 ++++-- sidecar/src/web.rs | 113 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b6a9eeb..813a9bc 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -129,10 +129,19 @@ use tracing_subscriber::EnvFilter; /// plugin that never sends it — against protocol 6 it would read every raid as a base with no /// cupboard, and alert nobody while looking healthy. /// +/// # Protocol 8 — the leases +/// +/// `GET /lease`, `POST /lease` and `POST /lease/release`: an event borrowing a value and giving +/// it back (the module's PLAN.md §27). Three correlated round trips and one event, +/// `lease.expired`, which is filed like every other event. The allowlist, the bounds, the +/// seven-day ceiling and the deadline timer all live in the plugin; the ledger lives on the +/// website. This process moves lines between them and learns nothing about either, which is why +/// the whole protocol is three thin forwards here. +/// /// `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; this constant is one of its four -/// declaration sites. -pub const PROTOCOL_VERSION: u32 = 7; +/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases; this constant is one +/// of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 8; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 66f05b2..f29c9cf 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -106,6 +106,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // 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`. .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 + // which keys exist, not their bounds, not what a deadline is. The plugin holds the + // allowlist, the ceiling and the timer; the website holds the ledger. This moves lines. + .route("/lease", get(lease_list).post(lease_apply)) + .route("/lease/release", post(lease_release)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -408,6 +414,92 @@ struct ConfigPathQuery { path: String, } +/// What `GET /lease` may narrow to. Both optional, both forwarded as they are. +#[derive(Debug, Deserialize)] +struct LeaseQuery { + key: Option, + target: Option, +} + +/// Everything this server lends, what it holds now, and every hold in force (protocol 8). +/// +/// A live round trip, never a cached board, for the reason `/permissions/catalogue` is one: the +/// website asks this immediately before taking a lease, to record the baseline it will later give +/// back, and a baseline that was a minute stale would be restored over whatever happened in that +/// minute. +async fn lease_list(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let mut command = json!({ "cmd": "lease.list", "reqId": req_id }); + + if let Some(key) = q.key { + command["key"] = Value::String(key); + } + if let Some(target) = q.target { + command["target"] = Value::String(target); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Take a value for a while. The plugin answers `lease.ok` or `lease.error` with a reason. +async fn lease_apply(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "lease.apply", "a lease").await +} + +/// Give a value back. **A drifted value is a `200` carrying `lease.drifted`**, not an error: the +/// plugin did exactly what it was asked — it compared, and declined to overwrite somebody's +/// deliberate change — and the website records that as its own outcome. +async fn lease_release(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "lease.release", "a lease release").await +} + +/// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything +/// the caller put there. `None` when the body is not an object. +fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { + let mut map = match body { + Value::Object(map) => map, + _ => return None, + }; + + map.insert("cmd".into(), Value::String(cmd.into())); + map.insert("reqId".into(), Value::String(req_id.into())); + Some(Value::Object(map)) +} + +/// The opaque-object forward the lease routes share: stamp the envelope, refuse what the game +/// link cannot carry, and hand back whatever the plugin answered. +async fn forward_object(st: &AppState, body: Value, cmd: &str, what: &str) -> Response { + let req_id = st.rpc.next_req_id(); + + let command = match stamp(body, cmd, &req_id) { + Some(command) => command, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("{what} must be an object") })), + ) + .into_response() + } + }; + + let encoded = command.to_string(); + + if encoded.len() > MAX_COMMAND_BYTES { + warn!(bytes = encoded.len(), cmd, "command refused: too large"); + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "error": format!("{what} is larger than the game link will carry"), + "bytes": encoded.len(), + "limit": MAX_COMMAND_BYTES, + })), + ) + .into_response(); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + /// Every settings file on the game host, and every plugin loaded to reload one. /// /// The sidecar knows nothing about either: not where the tree is (the framework decides, and it is @@ -793,6 +885,27 @@ mod tests { ); } + /// Protocol 8's routes cannot choose their own command or correlation id either, and they keep + /// everything else the caller sent, unread. + #[test] + fn a_lease_cannot_choose_its_own_command() { + let body = + json!({ "cmd": "config.write", "reqId": "r-1", "key": "decay.scale", "value": "0" }); + + let command = stamp(body, "lease.apply", "r-9").expect("an object is stamped"); + + assert_eq!(command["cmd"], json!("lease.apply")); + assert_eq!(command["reqId"], json!("r-9")); + assert_eq!(command["key"], json!("decay.scale")); + assert_eq!(command["value"], json!("0")); + } + + #[test] + fn a_lease_that_is_not_an_object_is_not_stamped() { + assert!(stamp(json!(["decay.scale"]), "lease.apply", "r-1").is_none()); + assert!(stamp(json!("decay.scale"), "lease.release", "r-1").is_none()); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() { From a1a6177921eed7ddbb324dbaaf9916657aa6d6ee Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 00:42:54 -0500 Subject: [PATCH 07/11] =?UTF-8?q?feat(sidecar):=20protocol=209=20=E2=80=94?= =?UTF-8?q?=20five=20world=20forwards=20(phase=2013a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /world/monuments, GET /world/owned, POST /world/zone, POST /world/place and POST /world/revert, each a correlated round trip with cmd and reqId stamped over the caller's. The allowlist, bounds, monument vocabulary and the registry of what each run owns live in the plugin (the module's PLAN.md §28); this process moves lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 13 ++++++--- sidecar/src/web.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 813a9bc..baf49f2 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -138,10 +138,17 @@ use tracing_subscriber::EnvFilter; /// website. This process moves lines between them and learns nothing about either, which is why /// the whole protocol is three thin forwards here. /// +/// # Protocol 9 — the world verbs +/// +/// `GET /world/monuments`, `GET /world/owned`, `POST /world/zone`, `POST /world/place` and +/// `POST /world/revert`: what an event makes in the world — a zone, crates, NPCs — and gives back +/// (the module's PLAN.md §28). Five more thin forwards. The allowlist, the bounds, the monument +/// vocabulary and the registry of what each run owns all live in the plugin. +/// /// `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; this constant is one -/// of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 8; +/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs; +/// this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 9; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index f29c9cf..477190c 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -112,6 +112,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // allowlist, the ceiling and the timer; the website holds the ledger. This moves lines. .route("/lease", get(lease_list).post(lease_apply)) .route("/lease/release", post(lease_release)) + // Protocol 9 (§28): what an event makes in the world and gives back. Five more thin + // forwards. The allowlist, the bounds, the monument vocabulary and the registry of what + // each run owns are all the plugin's; nothing here knows a crate from a zone. + .route("/world/monuments", get(world_monuments)) + .route("/world/owned", get(world_owned)) + .route("/world/zone", post(world_zone)) + .route("/world/place", post(world_place)) + .route("/world/revert", post(world_revert)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -453,6 +461,53 @@ async fn lease_release(State(st): State, Json(body): Json) -> R forward_object(&st, body, "lease.release", "a lease release").await } +/// This wipe's monuments and the plugin's placeable allowlist, for the authoring form. Live, like +/// every catalogue here: a map changes at every wipe, and a cached list would offer monuments that +/// no longer exist. +async fn world_monuments(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "world.monuments", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// What `GET /world/owned` may narrow to: one run, or every run when absent. +#[derive(Debug, Deserialize)] +struct WorldOwnedQuery { + #[serde(rename = "runId")] + run_id: Option, +} + +/// What the world still holds of what events made. The plugin LOOKS for each thing — a restart +/// is not proof a crate is gone — so this is the website's reconcile answer, and it is never +/// cached. +async fn world_owned(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let mut command = json!({ "cmd": "world.owned", "reqId": req_id }); + + if let Some(run_id) = q.run_id { + command["runId"] = Value::String(run_id); + } + + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Open a zone a run owns. `world.ok` or `world.error` with a reason. +async fn world_zone(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.zone", "a zone").await +} + +/// Place crates or NPCs a run owns. A repeated idempotency key is answered with the first call's +/// ids by the plugin; this process does not know one call from another. +async fn world_place(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.place", "a placement").await +} + +/// Give back what a run owns. **Something already gone is a `200`**, listed as `gone`, because +/// reverting a thing a player looted is a success, not a failure. +async fn world_revert(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "world.revert", "a revert").await +} + /// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything /// the caller put there. `None` when the body is not an object. fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { @@ -906,6 +961,17 @@ mod tests { assert!(stamp(json!("decay.scale"), "lease.release", "r-1").is_none()); } + #[test] + fn a_world_command_cannot_choose_its_own_command() { + let body = json!({ "cmd": "world.revert", "reqId": "theirs", "runId": "7", "prefab": "crate.elite" }); + let stamped = stamp(body, "world.place", "r-1").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "world.place"); + assert_eq!(stamped["reqId"], "r-1"); + assert_eq!(stamped["runId"], "7"); + assert_eq!(stamped["prefab"], "crate.elite"); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() { From c80994ce87292c20b164d5cedb02a157555fcd7e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 06:37:38 -0500 Subject: [PATCH 08/11] =?UTF-8?q?feat(sidecar):=20protocol=2010=20?= =?UTF-8?q?=E2=80=94=20five=20reward=20forwards=20(phase=2013b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /tally/open, GET /tally/snapshot, POST /tally/close, GET /kits and POST /chat: who took part in a run, the kits a reward can name, and one line in the server's chat (the module's PLAN.md §29). Thin forwards like protocol 9's; the tally, the kit catalogue and the chat memory all live in the plugin. perm.sync's new `credits` field passes through untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 14 +++++++-- sidecar/src/web.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index baf49f2..3dd927a 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -145,10 +145,18 @@ use tracing_subscriber::EnvFilter; /// (the module's PLAN.md §28). Five more thin forwards. The allowlist, the bounds, the monument /// vocabulary and the registry of what each run owns all live in the plugin. /// +/// # Protocol 10 — the rewards +/// +/// `POST /tally/open`, `GET /tally/snapshot`, `POST /tally/close`, `GET /kits` and `POST /chat`: +/// who took part in a run, the kits a reward can name, and one line in the server's chat (the +/// module's PLAN.md §29). Five more thin forwards. `perm.sync` also gains a `credits` field, which +/// passes through untouched like the rest of that body. The tally, the kit catalogue and the chat +/// memory all live in the plugin. +/// /// `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; -/// this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 9; +/// mirror, §11 configuration, §12 clans, §13 the raid frame, §14 the leases, §15 the world verbs, +/// §16 the rewards; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 10; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 477190c..acd8c9d 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -120,6 +120,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/world/zone", post(world_zone)) .route("/world/place", post(world_place)) .route("/world/revert", post(world_revert)) + // Protocol 10 (§29): who took part, what they may redeem, and a line in chat. The tally + // is counted and held by the plugin, the kit catalogue is Kits' own, and whether a chat + // line was already said is the plugin's memory. These move lines, like everything above. + .route("/tally/open", post(tally_open)) + .route("/tally/snapshot", get(tally_snapshot)) + .route("/tally/close", post(tally_close)) + .route("/kits", get(kits)) + .route("/chat", post(chat)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -508,6 +516,48 @@ async fn world_revert(State(st): State, Json(body): Json) -> Re forward_object(&st, body, "world.revert", "a revert").await } +/// Start counting who takes part in a run. `tally.ok` or `tally.error` with a reason. +async fn tally_open(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "tally.open", "a tally").await +} + +/// What `GET /tally/snapshot` reads: one run's tally. +#[derive(Debug, Deserialize)] +struct TallySnapshotQuery { + #[serde(rename = "runId")] + run_id: String, +} + +/// Who has taken part in a run so far, with the plugin's score for each. Live and never cached: +/// a reward is granted from this answer, and a stale one would leave out whoever joined last. +async fn tally_snapshot( + State(st): State, + Query(q): Query, +) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "tally.snapshot", "reqId": req_id, "runId": q.run_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Stop counting and forget the tally. **A tally already gone is a `200`**, like a world revert. +async fn tally_close(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "tally.close", "a tally close").await +} + +/// The kits this server's Kits plugin has, with what each is gated by. Live, for the authoring +/// form, and refused by the plugin with a reason when Kits is not loaded. +async fn kits(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "kits.list", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Say one line in the server's chat. A repeated key is answered `ok` by the plugin and not said +/// again, so a retried step never repeats itself. +async fn chat(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "chat.say", "a chat line").await +} + /// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything /// the caller put there. `None` when the body is not an object. fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { @@ -972,6 +1022,25 @@ mod tests { assert_eq!(stamped["prefab"], "crate.elite"); } + /// Protocol 10's forwards are stamped the same way: a chat line cannot become a permission + /// sync by naming one. + #[test] + fn a_protocol_10_command_cannot_choose_its_own_command() { + let body = json!({ "cmd": "perm.sync", "reqId": "theirs", "key": "k-1", "message": "hi" }); + let stamped = stamp(body, "chat.say", "r-2").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "chat.say"); + assert_eq!(stamped["reqId"], "r-2"); + assert_eq!(stamped["message"], "hi"); + + let body = json!({ "cmd": "tally.close", "runId": "9", "score": "both" }); + let stamped = stamp(body, "tally.open", "r-3").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "tally.open"); + assert_eq!(stamped["score"], "both"); + assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() { From 9e2a83d7c942fdfcbed82d83f2f89c283754f7f1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 00:11:19 -0500 Subject: [PATCH 09/11] =?UTF-8?q?feat(sidecar):=20protocol=2011=20?= =?UTF-8?q?=E2=80=94=20four=20map=20forwards=20(phase=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /map, GET /map/chunk, POST /map/render and GET /map/live, stamped and forwarded like the reward routes (PLAN.md §30.2). Nothing is stored: the picture passes through in 512 KiB slices and positions are never filed (D111). A test asserts a base64 slice plus its envelope fits the game link's 1 MiB line cap on the way back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/src/main.rs | 12 +++++-- sidecar/src/web.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 3dd927a..6572502 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -153,10 +153,18 @@ use tracing_subscriber::EnvFilter; /// passes through untouched like the rest of that body. The tally, the kit catalogue and the chat /// memory all live in the plugin. /// +/// # Protocol 11 — the map +/// +/// `GET /map`, `GET /map/chunk`, `POST /map/render` and `GET /map/live`: the picture of this +/// wipe's map, in slices, and everything that moves on it (the module's PLAN.md §30). Four more +/// thin forwards. **Nothing here is stored**: the picture passes through to the website, which +/// 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. +/// /// `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; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 10; +/// §16 the rewards, §17 the map; this constant is one of its four declaration sites. +pub const PROTOCOL_VERSION: u32 = 11; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index acd8c9d..12b027c 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -128,6 +128,13 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/tally/close", post(tally_close)) .route("/kits", get(kits)) .route("/chat", post(chat)) + // Protocol 11 (§30): the map. The picture passes through in slices and is never kept + // here, and positions are asked for while somebody is looking and never filed (D111). The + // plugin decides what exists and what is stale; this moves lines, like everything above. + .route("/map", get(map_info)) + .route("/map/chunk", get(map_chunk)) + .route("/map/render", post(map_render)) + .route("/map/live", get(map_live)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -558,6 +565,55 @@ async fn chat(State(st): State, Json(body): Json) -> Response { forward_object(&st, body, "chat.say", "a chat line").await } +/// 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 +/// hash against what it holds to decide whether to fetch at all. +async fn map_info(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "map.info", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// What `GET /map/chunk` reads: which picture, and which slice of it. +#[derive(Debug, Deserialize)] +struct MapChunkQuery { + #[serde(rename = "mapKey")] + map_key: String, + sha256: String, + n: u32, +} + +/// One slice of the picture, base64 (stage two). The plugin refuses `stale` when the key or hash +/// has moved since the caller asked `/map`, so a fetch that straddles a wipe cannot splice two +/// maps — which is also why the two are forwarded as the caller sent them and never filled in. +async fn map_chunk(State(st): State, Query(q): Query) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ + "cmd": "map.fetch", + "reqId": req_id, + "mapKey": q.map_key, + "sha256": q.sha256, + "chunk": q.n, + }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + +/// Ask the game to draw its own map when there is no cached picture (D109). **Answered at once +/// and done on a later frame**: the render stalls the game for seconds, longer than this +/// sidecar's reply budget on any large map, so the module polls `/map` for the result instead of +/// waiting here. +async fn map_render(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "map.render", "a render").await +} + +/// Everything that moves on the map, every layer at once. Which layers a viewer may see is the +/// website's decision (§8.5), so nothing here filters, and nothing here keeps the answer. +async fn map_live(State(st): State) -> Response { + let req_id = st.rpc.next_req_id(); + let command = json!({ "cmd": "map.live", "reqId": req_id }); + respond(st.rpc.call(&st.game, command, &req_id).await) +} + /// Put `cmd` and `reqId` on a caller's object, **after** it is taken, so they overwrite anything /// the caller put there. `None` when the body is not an object. fn stamp(body: Value, cmd: &str, req_id: &str) -> Option { @@ -1041,6 +1097,31 @@ mod tests { assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); } + /// Protocol 11's one opaque forward is stamped like the rest: a render cannot become a world + /// placement by naming one. + #[test] + fn a_render_cannot_choose_its_own_command() { + let body = json!({ "cmd": "world.place", "reqId": "theirs", "actor": "admin" }); + let stamped = stamp(body, "map.render", "r-5").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "map.render"); + assert_eq!(stamped["reqId"], "r-5"); + assert_eq!(stamped["actor"], "admin"); + } + + /// A map slice must fit the game link's line cap on the way BACK, or the plugin's reply is + /// discarded by this process's reader and the caller sees a `504` for a picture that exists. + /// The plugin slices at 512 KiB; base64 grows that by a third, and the frame around it is + /// small. + #[test] + fn a_map_slice_fits_the_game_links_line_cap() { + let slice: usize = 512 * 1024; + let base64 = slice.div_ceil(3) * 4; + let envelope = 1024; + + assert!(base64 + envelope <= crate::game::MAX_INBOUND_LINE_BYTES); + } + /// The same envelope rule as `perm_sync`, on the route that writes to a filesystem. #[test] fn a_configuration_write_cannot_choose_its_own_command() { From 46ba35d89a974290ab03ebe83aeecbc227aeff93 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 17:48:05 -0500 Subject: [PATCH 10/11] =?UTF-8?q?feat(sidecar):=20protocol=2012=20?= =?UTF-8?q?=E2=80=94=20POST=20/titles=20(phase=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One more thin forward: the chat titles each player has earned, as a whole set the plugin swaps in and BetterChat reads on the chat path. perm.sync's BetterChat styles and chat.say's delivery and format pass through untouched like the rest of their bodies. PROTOCOL_VERSION moves to 12. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- sidecar/README.md | 1 + sidecar/src/main.rs | 4 ++-- sidecar/src/web.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/sidecar/README.md b/sidecar/README.md index dac655e..7fcc47a 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -57,6 +57,7 @@ still authenticate). Every response carries `X-RustLink-Version`. | `GET /feed?since=&limit=` | store | **Oldest first, from a cursor.** For a consumer that must not miss a row. Omitting `since` asks where the end is | | `GET /status` | plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply | | `GET /ws` | broadcast | The live feed. Sends `ws.hello` on connect | +| every later route | plugin (RPC) | One thin forward per command, from `POST /link/confirm` (protocol 3) to `POST /titles` (protocol 12). Each is listed beside its protocol in `src/web.rs`, and its body in `docs/rust-link/PROTOCOL.md` | The split is the point: the store-backed reads answer while the game is off, which is what lets the website render a server list during a wipe or a restart. `/status` is the one route that fails when diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 6572502..b61a2e6 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -163,8 +163,8 @@ use tracing_subscriber::EnvFilter; /// /// `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; this constant is one of its four declaration sites. -pub const PROTOCOL_VERSION: u32 = 11; +/// §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; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 12b027c..6a38d1f 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -135,6 +135,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/map/chunk", get(map_chunk)) .route("/map/render", post(map_render)) .route("/map/live", get(map_live)) + // Protocol 12 (§33): the chat titles a player has earned. A whole set, replaced each time, + // held by the plugin in memory and read by BetterChat on the chat path. Nothing here knows + // what a title is. + .route("/titles", post(titles)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -565,6 +569,12 @@ async fn chat(State(st): State, Json(body): Json) -> Response { forward_object(&st, body, "chat.say", "a chat line").await } +/// Replace the chat titles this server's plugin holds (protocol 12). The set is whole, so a retry +/// is harmless, and the plugin answers whether BetterChat is there to show them. +async fn titles(State(st): State, Json(body): Json) -> Response { + forward_object(&st, body, "titles.set", "a title set").await +} + /// 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 /// hash against what it holds to decide whether to fetch at all. @@ -1097,6 +1107,24 @@ mod tests { assert!(stamp(json!(["9"]), "tally.close", "r-4").is_none()); } + /// 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. + #[test] + fn a_protocol_12_title_set_passes_through_untouched() { + let body = json!({ + "cmd": "chat.say", + "setId": "s-1", + "titles": [{ "steamId": "76561198000000001", "text": "[#ff8800]Top Killer[/#]" }], + }); + let stamped = stamp(body, "titles.set", "r-5").expect("an object is stamped"); + + assert_eq!(stamped["cmd"], "titles.set"); + assert_eq!(stamped["reqId"], "r-5"); + assert_eq!(stamped["setId"], "s-1"); + assert_eq!(stamped["titles"][0]["text"], "[#ff8800]Top Killer[/#]"); + assert!(stamp(json!("titles"), "titles.set", "r-6").is_none()); + } + /// Protocol 11's one opaque forward is stamped like the rest: a render cannot become a world /// placement by naming one. #[test] From b3b66b1cc23b592d055a9365039c045c6705ed14 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 23:14:57 -0500 Subject: [PATCH 11/11] feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/pr-checks.yml | 20 +- .gitea/workflows/release.yml | 517 +++++++++++++++++++++++++++++++++ README.md | 21 ++ egg/build.sh | 31 ++ egg/egg.json | 272 +++++++++++++++++ egg/install.sh | 173 +++++++++++ egg/with-sidecar.sh | 86 ++++++ sidecar/Cargo.lock | 95 ++++++ sidecar/Cargo.toml | 5 + sidecar/README.md | 13 +- sidecar/src/config.rs | 56 +++- sidecar/src/main.rs | 27 +- sidecar/src/windows.rs | 259 +++++++++++++++++ 13 files changed, 1559 insertions(+), 16 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100755 egg/build.sh create mode 100644 egg/egg.json create mode 100755 egg/install.sh create mode 100755 egg/with-sidecar.sh create mode 100644 sidecar/src/windows.rs diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index ce89ea4..f6721f0 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -57,7 +57,7 @@ jobs: SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" $SUDO apt-get update $SUDO apt-get install -y --no-install-recommends \ - build-essential curl ca-certificates git + build-essential curl ca-certificates git jq gcc-mingw-w64-x86-64 if ! command -v cargo >/dev/null 2>&1; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ @@ -66,6 +66,7 @@ jobs: echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" export PATH="${HOME}/.cargo/bin:${PATH}" rustup component add rustfmt clippy + rustup target add x86_64-pc-windows-gnu cargo --version && cargo fmt --version && cargo clippy --version # Keyed on Cargo.lock: dependency builds are reused until a dep actually @@ -97,3 +98,20 @@ jobs: - name: cargo test run: cargo test --locked + # The Windows service entry point (src/windows.rs) compiles only for + # Windows, so the Linux clippy above never sees it. Linted against the + # release's own target here, so a service-only fault fails a PR instead + # of waiting for a release to show up (docs/modules/rust/PLAN.md §34.2.5). + # MinGW is the C compiler for the bundled SQLite, as in release.yml. + - name: cargo clippy (Windows target) + env: + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + run: cargo clippy --locked --target x86_64-pc-windows-gnu --all-targets -- -D warnings + + # The egg the release publishes, assembled exactly as release.yml does: + # the install script and the launcher parse, and the egg carries the + # shape the panel's importer needs and every variable it exists to add. + - name: Build the egg + working-directory: . + run: bash egg/build.sh /tmp/egg-rust-runicgateway.json diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9eeecb7 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,517 @@ +# Automated build + release for the rust-link sidecar, its launcher and the egg. +# +# Trigger: every push to `main` (i.e. every merged PR — in practice the +# edge→main cutover), and by hand. +# +# Why this exists: the Runic Gateway installer (`--game rust`) and the +# Pterodactyl egg both install the sidecar from a release, never from git. Until +# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.1, D145) this repository +# had never released. +# +# Flow — the same two halves as link's and servuo-plugins' release.yml: +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── RUST ADAPTER (the only repo-specific part) ─────────────────────┐ +# │ consumes: the version │ +# │ produces: rust-link-sidecar-linux-x86_64 (static, musl) │ +# │ rust-link-sidecar-windows-x86_64.exe │ +# │ with-sidecar.sh, egg-rust-runicgateway.json │ +# │ SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# The engine is servuo-plugins', the most complete copy: tag-only (no bump +# commit, so `main` is never pushed to), secrets preflighted before anything is +# tagged, an existing tag checked against the release API rather than trusted, +# every tag swept for a missing release, and 5xx retried. +# +# ── The adapter, and how it differs from link's ───────────────────────────── +# +# * LINUX IS STATIC (musl). The same binary runs on a host under systemd and +# inside the egg's game container, whose image is not ours and whose glibc is +# not a contract (docs/rust-link/INSTALL_RIG.md). +# * NO linux-aarch64. RustDedicated has no arm64 build, so there is no host to +# run it on (D149). +# * WINDOWS IS A SERVICE. src/windows.rs speaks the SCM handshake; without it the +# installer's service would die with error 1053, as link's once did (§34.2.5). +# * THE LAUNCHER AND THE EGG ship here. The launcher is fetched by the egg at +# install time, so a fix to it reaches a server at its next reinstall with no +# re-import (§34.4). The egg is assembled by egg/build.sh from egg/egg.json and +# egg/install.sh. +# +# The version is never the protocol. PROTOCOL_VERSION lives in +# sidecar/src/main.rs and moves only when a message shape does. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first +# release takes what the engine derives) +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Link): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the +# tag and create the release. The final step also dispatches +# RunicGateway/installer's bundle workflow, so the token +# ideally has write there too — without it the step warns +# and that repo's nightly cron picks the release up instead. +# REGISTRY_USER — the Gitea username that token belongs to. + +name: Release sidecar + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-sidecar + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/Rust-Link + # The engine's changelog heading. + ARTIFACT: rust-link-sidecar + WORKDIR: sidecar + BIN: rust-link-sidecar + LINUX_TARGET: x86_64-unknown-linux-musl + WINDOWS_TARGET: x86_64-pc-windows-gnu + SEED_VERSION: "0.1.0" + INSTALLER_REPO: RunicGateway/installer + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out full history (need tags + commit log for the bump) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── RELEASE ENGINE: decide the next version + changelog ────────────── + - name: Plan the release (version + changelog) + id: plan + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" + + BUMP=none + if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi + if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi + + bump() { # -> bumped + IFS=. read -r MA MI PA <<< "$1" + case "$2" in + major) echo "$((MA+1)).0.0" ;; + minor) echo "${MA}.$((MI+1)).0" ;; + patch) echo "${MA}.${MI}.$((PA+1))" ;; + esac + } + + RELEASE=true + if [ -z "$LAST_TAG" ]; then + VERSION="$SEED_VERSION" # first release: seed + elif [ "$BUMP" = none ]; then + RELEASE=false # no feat/fix/breaking since last tag + VERSION="${LAST_TAG#v}" + else + VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" + fi + + # An existing tag is NOT automatically "nothing to do". A tag with no + # release behind it means a previous run tagged and then died before + # publishing — which is exactly what happened on servuo-plugins' first + # run, when missing REGISTRY_* secrets took the release API call to 401 + # after the tag had already been pushed. Standing down on the tag alone + # would make that state permanent: every later run would see the tag, + # set RELEASE=false, and the release would never appear. So distinguish + # the two cases and finish the job the earlier run started. + # Note this OVERRIDES the RELEASE=false decided just above. With the tag + # already in place there are no releasable commits after it, so the + # normal path stands down — which is precisely why the stuck state + # could never clear itself. Recovery has to be able to say "yes, + # publish" for a version the bump logic considers already done. + REUSE_TAG=false + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" + if [ "$REL_HTTP" = "200" ]; then + echo "Tag v${VERSION} already has a release — nothing to do." + RELEASE=false + elif [ "$REL_HTTP" = "404" ]; then + echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing." + REUSE_TAG=true + RELEASE=true + else + # Anything else (000 from a network failure, 401/403 from a bad + # token) is not evidence of absence. Guessing "no release" here + # would re-publish over a good one, so refuse instead. + echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess." + exit 1 + fi + fi + + # ── Orphan sweep ──────────────────────────────────────────────── + # + # The check above is VERSION-SCOPED: it only ever asks about the one + # version this run computed. That is enough to recover an orphan on + # the very next run, and useless afterwards — once any releasable + # commit lands, the next run computes a NEW version, never looks at + # the old tag again, and the orphan becomes permanent and silent. + # + # servuo-plugins v0.1.0 is the proof: the commit that ADDED the + # recovery above was itself a `fix:`, so it bumped to v0.1.1 and the + # run that introduced the recovery stepped straight past the tag it + # was written to rescue. + # + # So every v* tag is checked, and anything missing a release is + # WARNED about. Deliberately not recovered: publishing an old version + # would mean building today's tree and shipping it under a tag whose + # tree it is not, which is worse than the inconsistency it fixes. + # A human decides whether to recover or drop it. + # + # Never fails the run. A sweep that can break a good release is a + # sweep someone will delete. + ORPHANS="" + for T in $(git tag -l 'v*' --sort=-v:refname); do + T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)" + [ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}" + done + if [ -n "${ORPHANS}" ]; then + echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either." + fi + + # Changelog range. A recovery run has nothing after the tag, so + # summarize what the tag itself contains rather than emitting an empty + # list: the range that produced it, i.e. previous-tag..this-tag. + if [ "$REUSE_TAG" = true ]; then + PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi + SINCE="$PREV_TAG" + else + CL_RANGE="$RANGE" + SINCE="$LAST_TAG" + fi + CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)" + + { + echo "## ${ARTIFACT} v${VERSION}" + echo + FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)" + [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } + [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } + echo "### All changes" + if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi + echo "$CL_SUBJECTS" | sed 's/^/- /' + } > dist/CHANGELOG.md + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-}" + + # ── Credential preflight ───────────────────────────────────────────── + # Runs BEFORE anything is built or pushed, and only when this run intends + # to publish, so a docs:/chore:-only merge stays green on a repo that has + # no secrets. + # + # This exists because of how servuo-plugins' first run failed. REGISTRY_USER and + # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway: + # actions/checkout leaves an `http..extraheader` credential in the + # local git config, so `git remote set-url` to a URL with empty + # credentials still authenticated through that leftover header. The + # release API call had no such fallback and returned 401 — so the run + # tagged the repo and then failed, which is the worst of both outcomes. + # Checking the secrets up front turns that into an immediate, legible + # failure instead of a half-published release. + - name: Verify release credentials are configured + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + MISSING="" + [ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER" + [ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN" + if [ -n "$MISSING" ]; then + echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release." + exit 1 + fi + echo "Release credentials present." + + - name: Install jq + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + command -v jq >/dev/null 2>&1 && exit 0 + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends jq + + # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── + # musl-tools gives the cc crate a musl-gcc for the bundled SQLite that + # sqlx's sqlite feature compiles from C; mingw does the same for Windows. + # A Rust-only cross build fails at the first .c file. + - name: Install Rust toolchain, targets, and their C compilers + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + build-essential musl-tools gcc-mingw-w64-x86-64 file \ + curl ca-certificates git jq + + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable + fi + echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" + export PATH="${HOME}/.cargo/bin:${PATH}" + rustup component add rustfmt + rustup target add "${LINUX_TARGET}" + rustup target add "${WINDOWS_TARGET}" + + - name: Set the crate version to match the release + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + # Replace only the [package] version (the first `version = "..."`), so + # `--version` on a released binary names its release. Not committed: + # the tag is the version. + sed -i -E "0,/^version = \"[^\"]+\"/s//version = \"${VERSION}\"/" "${WORKDIR}/Cargo.toml" + grep -m1 '^version' "${WORKDIR}/Cargo.toml" + # Re-sync this crate's own entry in Cargo.lock, or every `--locked` + # step below fails. Dependency pins are untouched. + cargo update --manifest-path "${WORKDIR}/Cargo.toml" --workspace + + # ── RUST ADAPTER: gates ────────────────────────────────────────────── + - name: cargo fmt --check + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo fmt --check + + - name: cargo test + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo test --locked + + # ── RUST ADAPTER: build both targets ───────────────────────────────── + - name: cargo build --release (Linux, static musl) + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + run: cargo build --release --locked --target "${LINUX_TARGET}" + + - name: cargo build --release (Windows, cross via MinGW) + if: ${{ steps.plan.outputs.release == 'true' }} + working-directory: sidecar + env: + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + run: cargo build --release --locked --target "${WINDOWS_TARGET}" + + # ── RUST ADAPTER: package artifacts (+ checksums) ──────────────────── + - name: Package artifacts and SHA256SUMS + id: package + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + cp "${WORKDIR}/target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64" + cp "${WORKDIR}/target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe" + install -m 755 egg/with-sidecar.sh dist/with-sidecar.sh + bash egg/build.sh dist/egg-rust-runicgateway.json + + # A dynamically linked "static" binary fails only inside the game + # container, on somebody else's glibc. Refuse it here. + if file "dist/${BIN}-linux-x86_64" | grep -q 'dynamically linked'; then + echo "::error::the Linux sidecar is dynamically linked; the egg needs it static (musl)"; exit 1 + fi + + ASSETS="${BIN}-linux-x86_64 ${BIN}-windows-x86_64.exe with-sidecar.sh egg-rust-runicgateway.json" + # Every artifact must appear here: the installer, the egg and the + # bundle CI verify against these sums, and `sha256sum -c` passes + # silently over a file this list does not mention. + ( cd dist && sha256sum ${ASSETS} > SHA256SUMS ) + echo "assets=${ASSETS} SHA256SUMS" >> "$GITHUB_OUTPUT" + ls -l dist && echo "----" && cat dist/SHA256SUMS + + # ── RELEASE ENGINE: tag ────────────────────────────────────────────── + # Tag only — no bump commit, so `main` is never pushed to (see header). + - name: Push the release tag + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + # Secrets can arrive with a trailing newline (depending on how they were + # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot + # be parsed"). Strip line breaks before building the URL. + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git config user.name "rust-link-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + + # The tag may already exist when we are finishing a run that died after + # tagging (see the plan step). `git tag` on an existing name fails under + # `set -e`, and pushing an identical existing tag is a harmless no-op — + # so create it only if it is new, then push either way. A push that + # fails here means the remote tag points somewhere else, which SHOULD + # stop the run. + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists — reusing it." + else + git tag "${TAG}" + fi + git push origin "${TAG}" + + # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + - name: Create Gitea release and upload assets + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + BODY="$(cat dist/CHANGELOG.md)" + # Same newline hygiene as the tag step: a stray CR/LF in the token would + # corrupt the Authorization header. + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + + PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" + + # installer#22's release run failed exactly here: it landed one second + # after the tag push and Gitea answered 500, having not finished + # processing the pushed tag. Re-running published the same artifacts + # untouched, so it was a race, not a bad request — but the tag sat + # orphaned until a human noticed. + # + # Two things made that worse than it needed to be. + # + # 1. `curl -sSf` prints NO response body on an error status, so all the + # log carried was "curl: (22) ... error: 500" and the cause had to be + # inferred from timestamps. Capture the body and print it. + # 2. Nothing retried, so a transient 5xx became a permanent orphan. + # + # 4xx is deliberately NOT retried: a bad token or a malformed body does + # not improve by being sent again, and retrying only turns a clear + # failure into a slow one. + REL_ID="" + for attempt in 1 2 3 4 5; do + HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" || echo 000)" + + if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then + REL_ID="$(jq -r '.id' /tmp/rel.json)" + break + fi + + echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}" + echo "--- response body ---" + cat /tmp/rel.json || true + echo + echo "---------------------" + + case "$HTTP" in + 4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;; + esac + + if [ "$attempt" = 5 ]; then + echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release." + echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it." + exit 1 + fi + sleep $(( attempt * 5 )) + done + + if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then + echo "::error::Release created but no id came back; refusing to upload assets blind." + exit 1 + fi + echo "Created release ${TAG} (id=${REL_ID})" + + for f in ${{ steps.package.outputs.assets }}; do + # Same treatment. An upload that fails quietly leaves a release whose + # SHA256SUMS does not cover every artifact it advertises, which is + # worse than no release at all -- that file is the trust anchor. + HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@dist/${f}" || echo 000)" + if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then + echo "::error::uploading ${f} returned HTTP ${HTTP}" + cat /tmp/asset.json || true + exit 1 + fi + echo " uploaded ${f}" + done + + # ── Recompose the installer's bundle manifest ──────────────────────── + # Neither the installer nor the egg resolves "latest" at run time — both + # install the exact sidecar named by a published bundle + # (docs/modules/rust/PLAN.md §34.2.2). A sidecar release that nobody + # recomposes around is therefore a release no operator will ever be + # offered. This tells the installer repo to + # rebuild that manifest now rather than leaving the new version invisible + # until its nightly cron. + # + # That job reads PROTOCOL_VERSION from sidecar/src/main.rs at this tag + # and checks it against the released plugin's declared protocol before + # publishing anything (gate 1). + # + # DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint + # returns no run handle, so there is nothing to poll: a waiting step would + # have to guess which run is its own and hold a runner idle to do it. + # + # A failure here is a WARNING, never a failure of this job. The release is + # already published and correct by this point, and failing the run would + # misreport that. The installer's nightly cron recomposes from whatever the + # latest releases actually are, so a dropped dispatch costs latency, not + # correctness. + - name: Ask the installer repo to recompose its bundle + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"ref":"main"}' \ + "https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)" + case "$HTTP" in + 20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;; + 403|404) + echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;; + *) + echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;; + esac diff --git a/README.md b/README.md index f3d4194..d7f5b39 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,27 @@ as JSON, which is how an installer reads the token back without scraping a log. See [`sidecar/README.md`](sidecar/README.md) for the configuration reference and the endpoint list. +## Releases, the launcher and the egg + +Every merge to `main` carrying a `feat`, `fix` or `perf` commit cuts a release +(`.gitea/workflows/release.yml`): + +| Asset | What it is | +|---|---| +| `rust-link-sidecar-linux-x86_64` | Static (musl): one binary for a systemd host and for the egg's game container | +| `rust-link-sidecar-windows-x86_64.exe` | Runs as a console program or as a Windows service | +| `with-sidecar.sh` | The egg's launcher: starts the sidecar, prints the URL (and a new token, once), then `exec`s the game | +| `egg-rust-runicgateway.json` | The Pterodactyl egg, for a panel admin to import | +| `SHA256SUMS` | The trust anchor for all of the above | + +There is no `linux-aarch64`: RustDedicated has no arm64 build. A release then asks the installer +repo to recompose its Rust bundle, which is what the installer (`--game rust`) and the egg install +from. + +[`egg/`](egg/) holds the egg's sources: `egg.json`, `install.sh` (egg 18 "Rust Autowipe"'s script +with a wipe guard around its `rm -rf ${REMOVE_FILES}` and the bridge fetched from a bundle) and +`with-sidecar.sh`. `bash egg/build.sh` assembles them, as PR Checks and the release do. + ## The protocol is a contract The loopback JSON protocol (plugin ↔ sidecar) and this sidecar's HTTP/WS API (sidecar ↔ website) diff --git a/egg/build.sh b/egg/build.sh new file mode 100755 index 0000000..c6ca966 --- /dev/null +++ b/egg/build.sh @@ -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" diff --git a/egg/egg.json b/egg/egg.json new file mode 100644 index 0000000..85f4d9c --- /dev/null +++ b/egg/egg.json @@ -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" + } + ] +} diff --git a/egg/install.sh b/egg/install.sh new file mode 100755 index 0000000..ad8eee0 --- /dev/null +++ b/egg/install.sh @@ -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() { # + 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 "-----------------------------------------" diff --git a/egg/with-sidecar.sh b/egg/with-sidecar.sh new file mode 100755 index 0000000..2dacc52 --- /dev/null +++ b/egg/with-sidecar.sh @@ -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="" ;; 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 "$@" diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 5629d0f..970fca7 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -233,6 +233,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -275,6 +284,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -912,6 +927,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -1039,6 +1060,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1165,7 +1192,9 @@ dependencies = [ "tokio", "toml", "tracing", + "tracing-appender", "tracing-subscriber", + "windows-service", ] [[package]] @@ -1573,6 +1602,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.119" @@ -1661,6 +1696,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1808,6 +1873,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.20", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -2018,6 +2096,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "windows-core" version = "0.62.2" @@ -2068,6 +2152,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "windows-strings" version = "0.5.1" diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 8ce5685..3c5ef98 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -18,5 +18,10 @@ toml = "0.8" getrandom = "0.2" chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +# The SCM handshake (src/windows.rs). Windows only: systemd supervises a console program as-is. +[target.'cfg(windows)'.dependencies] +windows-service = "0.8" +tracing-appender = "0.2" + [profile.release] opt-level = 2 diff --git a/sidecar/README.md b/sidecar/README.md index 7fcc47a..2376e41 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -6,7 +6,9 @@ Configuration reference and endpoint list. For what this component *is*, see the ## Configuration `sidecar.toml`, resolved in this order: `--config `, else `$RUSTLINK_CONFIG`, else -`./sidecar.toml`. Environment variables override the file; the file overrides the defaults. +`./sidecar.toml`. Environment variables override the file; the file overrides the defaults. **An +empty or blank variable counts as unset**: a Pterodactyl egg exports every variable it declares, so a +field left blank arrives as `VAR=""`, and honouring that would erase the saved token on every boot. | Key | Env | Default | What it is | |---|---|---|---| @@ -42,6 +44,15 @@ Resolves the configuration exactly as a normal start would — writing the file token if they are missing — and prints it as JSON on stdout, **including the token in clear text**. That is the supported way for an installer to obtain it; the alternative is scraping a log. +## As a Windows service + +The same `.exe` runs from a shell or under the Service Control Manager. It tries the SCM handshake +first and falls through to an ordinary console run when a human started it, so there is no +`--service` flag to forget. Under the SCM it reports `Running` only once the game listener and the +store are up, turns a stop request into a clean shutdown, and logs to a daily-rolled +`rust-link-sidecar.YYYY-MM-DD.log` beside its config (a service has no console). One service per +game server, each with its own `--config`; the installer names them `RunicGatewayRust-`. + ## Endpoints Everything except `/health` requires the token, as `Authorization: Bearer `, `X-Api-Key: `, diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs index d5e66f5..37cabeb 100644 --- a/sidecar/src/config.rs +++ b/sidecar/src/config.rs @@ -187,22 +187,36 @@ impl Config { /// Environment overrides, so a deployment can set secrets without editing the file. fn apply_env(&mut self) { - if let Ok(v) = env::var("RUSTLINK_GAME_BIND") { + self.apply_env_from(|key| env::var(key).ok()); + } + + /// [`Self::apply_env`] against any lookup, so the rules below are testable without mutating the + /// process environment (which the test harness shares across threads). + /// + /// **An empty or blank value is the same as an unset one.** A Pterodactyl egg exports every + /// variable it declares, so an operator who leaves `RUSTLINK_WEB_TOKEN` blank arrives here as + /// `RUSTLINK_WEB_TOKEN=""`. Honouring that as an override would blank the token saved in + /// `sidecar.toml` on every boot, and a fresh one would be generated and persisted each time: + /// the website's copy would go stale at every restart (PLAN.md §34, D152). No variable here has + /// a meaningful empty value — an empty bind or database path can only fail later, less clearly. + fn apply_env_from(&mut self, get: impl Fn(&str) -> Option) { + let get = |key: &str| get(key).filter(|v| !v.trim().is_empty()); + if let Some(v) = get("RUSTLINK_GAME_BIND") { self.game.bind = v; } - if let Ok(v) = env::var("RUSTLINK_SERVER_ID") { + if let Some(v) = get("RUSTLINK_SERVER_ID") { self.game.server_id = v; } - if let Ok(v) = env::var("RUSTLINK_WEB_BIND") { + if let Some(v) = get("RUSTLINK_WEB_BIND") { self.web.bind = v; } - if let Ok(v) = env::var("RUSTLINK_WEB_TOKEN") { + if let Some(v) = get("RUSTLINK_WEB_TOKEN") { self.web.auth_token = v; } - if let Ok(v) = env::var("RUSTLINK_DB_PATH") { + if let Some(v) = get("RUSTLINK_DB_PATH") { self.store.path = v; } - if let Ok(v) = env::var("RUSTLINK_RETAIN_DAYS") { + if let Some(v) = get("RUSTLINK_RETAIN_DAYS") { // A malformed value is ignored rather than fatal: this reaches the process as a panel // variable somebody typed (R22), and refusing to start over a stray character would // take the bridge down for a setting that has a perfectly good default. @@ -449,4 +463,34 @@ mod tests { assert_eq!(cfg.store.path, default_db_path()); assert_eq!(cfg.game.server_id, ""); } + + /// The egg's case (D152): a blank panel variable must not erase the token already saved in the + /// file, or a new one would be generated on every boot. + #[test] + fn an_empty_variable_does_not_override_the_file() { + let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap(); + cfg.apply_env_from(|key| match key { + "RUSTLINK_WEB_TOKEN" => Some(String::new()), + "RUSTLINK_WEB_BIND" => Some(" ".into()), + "RUSTLINK_SERVER_ID" => Some(String::new()), + _ => None, + }); + assert_eq!(cfg.web.auth_token, "saved-token"); + assert_eq!(cfg.web.bind, default_web_bind()); + assert_eq!(cfg.game.server_id, ""); + } + + #[test] + fn a_set_variable_still_overrides_the_file() { + let mut cfg: Config = toml::from_str(&default_file("saved-token")).unwrap(); + cfg.apply_env_from(|key| match key { + "RUSTLINK_WEB_TOKEN" => Some("from-env".into()), + "RUSTLINK_WEB_BIND" => Some("0.0.0.0:21009".into()), + "RUSTLINK_SERVER_ID" => Some("alpha".into()), + _ => None, + }); + assert_eq!(cfg.web.auth_token, "from-env"); + assert_eq!(cfg.web.bind, "0.0.0.0:21009"); + assert_eq!(cfg.game.server_id, "alpha"); + } } diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b61a2e6..b1e392d 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -20,8 +20,10 @@ //! # Layout //! //! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is -//! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase) -//! can supply the host's own start and stop without restructuring anything. +//! parameterised on `ready`/`shutdown` so a service wrapper can supply the host's own start and +//! stop. On Windows that wrapper is [`windows`] — the SCM handshake, without which a registered +//! service dies with error 1053 (PLAN.md §34.2.5). On Linux there is none: systemd supervises a +//! console program as it is, and stops it with `SIGTERM`, which [`shutdown_signal`] already hears. mod app; mod cli; @@ -30,6 +32,8 @@ mod game; mod rpc; mod store; mod web; +#[cfg(windows)] +mod windows; use tracing_subscriber::EnvFilter; @@ -198,13 +202,20 @@ fn main() -> anyhow::Result<()> { cli::Mode::Run => {} } - init_console_tracing(); + // The SCM's way in. It falls through to a console run when a human started the process. + #[cfg(windows)] + return windows::run(args.config.as_deref()); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; + #[cfg(not(windows))] + { + init_console_tracing(); - runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal())) + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal())) + } } /// Logging for a foreground run: human-readable, on stdout. @@ -217,7 +228,7 @@ pub fn init_console_tracing() { } /// Resolves on Ctrl-C, and on `SIGTERM` where there is one. -async fn shutdown_signal() { +pub(crate) async fn shutdown_signal() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; diff --git a/sidecar/src/windows.rs b/sidecar/src/windows.rs new file mode 100644 index 0000000..f27d37b --- /dev/null +++ b/sidecar/src/windows.rs @@ -0,0 +1,259 @@ +//! Windows startup and shutdown: the SCM handshake. +//! +//! Ported from `link`'s `windows.rs`, which learned it the hard way (docs/modules/rust/PLAN.md +//! §34.2.5, D149). The Windows Service Control Manager cannot supervise an arbitrary console +//! program. A binary registered with `sc.exe create` has ~30 seconds to call +//! `StartServiceCtrlDispatcher` and connect back to the SCM; one that never does is killed with +//! **error 1053, "the service did not respond to the start request in a timely fashion"** — even +//! though the process itself started perfectly and is sitting there serving traffic. That is the +//! entire reason this module exists. +//! +//! ## One binary, two ways in +//! +//! The dispatcher is tried first and *failing is expected*: when the process was started from a +//! shell rather than by the SCM, the connect fails with `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` +//! (1063), and that — and only that — falls through to a normal foreground run. So +//! `rust-link-sidecar.exe --config ...` stays an ordinary console app you can Ctrl-C, `cargo run` +//! still works, and the same binary can be registered as a service with no `--service` flag for an +//! operator to forget. Any other dispatcher error is a real failure and is reported. +//! +//! ## One binary, many services +//! +//! A Rust host runs one sidecar per game server (R8), so the installer registers one service per +//! instance — `RunicGatewayRust-` (D148) — all pointing at this one executable with a +//! different `--config`. That works without this module knowing the instance's name: for an +//! **own-process** service the SCM ignores the name handed to the dispatcher and to the control +//! handler, because the process can only ever host the one service it was started as. +//! +//! ## Logging goes to a file, because a service has no stdout +//! +//! Under the SCM there is no console attached, so the normal stdout subscriber writes into the +//! void. In service mode the sidecar logs to a daily-rolled file next to its config instead +//! (`rust-link-sidecar.YYYY-MM-DD.log`, seven kept). Instances keep their configs apart, so their +//! logs are apart too. A service whose start fails leaves a reason behind rather than only an SCM +//! error code. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::sync::Notify; +use tracing_subscriber::EnvFilter; +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; +use windows_service::{define_windows_service, service_dispatcher}; + +/// The prefix of every instance's service name (`RunicGatewayRust-`, installed by +/// `installer/src/service.rs`). Passed to the dispatcher and the control handler, which ignore it +/// for an own-process service — see the module docs. It is a literal on both sides; the two repos +/// are released independently and share no crate. +pub const SERVICE_NAME_PREFIX: &str = "RunicGatewayRust"; + +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; + +/// `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` — "this process was not started by the SCM", which is +/// the normal answer when a human runs the binary. +const ERROR_FAILED_SERVICE_CONTROLLER_CONNECT: i32 = 1063; + +/// `service_main` is called through an `extern "system"` trampoline and so can capture nothing. +/// The parsed `--config` is handed over here instead of being re-parsed, so the service and a +/// console run resolve their configuration through exactly the same code path. +static CONFIG_PATH: OnceLock> = OnceLock::new(); + +pub fn run(config_path: Option<&str>) -> anyhow::Result<()> { + let _ = CONFIG_PATH.set(config_path.map(str::to_string)); + + match service_dispatcher::start(SERVICE_NAME_PREFIX, ffi_service_main) { + Ok(()) => Ok(()), + // Not started by the SCM: this is a foreground run, which is not an error. + Err(windows_service::Error::Winapi(e)) + if e.raw_os_error() == Some(ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) => + { + console_run(config_path) + } + Err(e) => Err(anyhow::Error::new(e) + .context("could not connect to the Windows service control manager")), + } +} + +/// A normal foreground run: stdout logging, Ctrl-C to stop. Exactly what `main` does elsewhere. +fn console_run(config_path: Option<&str>) -> anyhow::Result<()> { + crate::init_console_tracing(); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(crate::app::run( + config_path, + || {}, + crate::shutdown_signal(), + )) +} + +define_windows_service!(ffi_service_main, service_main); + +fn service_main(_arguments: Vec) { + // Arguments are deliberately ignored: for an own-process service the `binPath=` arguments + // arrive on the process command line and have already been parsed in `main`. What lands here + // is whatever was typed after `sc start`, which nothing in this deployment uses. + if let Err(e) = serve() { + // Nowhere left to report to but the log: the status handle is gone or was never obtained. + tracing::error!(error = %e, "service exited with an error"); + } +} + +fn serve() -> anyhow::Result<()> { + let config_path = CONFIG_PATH.get().cloned().flatten(); + // Held for the life of the service: dropping the guard stops the background log writer. + let _log_guard = init_service_tracing(config_path.as_deref()); + + // The SCM calls the control handler on its own thread, so the stop signal crosses a thread + // boundary into the async world. `notify_one` stores a permit if nothing is waiting yet, so a + // stop that arrives during startup is not lost. + let stop = Arc::new(Notify::new()); + let handler_stop = stop.clone(); + let status_handle = + service_control_handler::register(SERVICE_NAME_PREFIX, move |control| match control { + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + ServiceControl::Stop | ServiceControl::Shutdown => { + handler_stop.notify_one(); + ServiceControlHandlerResult::NoError + } + _ => ServiceControlHandlerResult::NotImplemented, + })?; + + // Registering the handler is the handshake 1053 was about. Everything after this point gets to + // take as long as it credibly needs, as long as the state keeps being reported. + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::StartPending, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::from_secs(30), + process_id: None, + })?; + + let ready_handle = status_handle; + let result = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(crate::app::run( + config_path.as_deref(), + // Reported only once the game listener is bound and the store is open, so a bad config + // or a taken port fails the *start* instead of flapping Running → Stopped a moment later. + move || { + let _ = ready_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + }); + }, + async move { stop.notified().await }, + )); + + // A failed run must leave a nonzero SERVICE_EXIT_CODE behind: `sc query` reporting STOPPED with + // exit code 0 is what made `link`'s original failure look like a clean stop. + let exit_code = match &result { + Ok(()) => ServiceExitCode::Win32(0), + Err(e) => { + tracing::error!(error = %e, "sidecar failed"); + ServiceExitCode::ServiceSpecific(1) + } + }; + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code, + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + result +} + +/// Where the service writes its log: beside the config it was pointed at, which is the directory +/// the installer already provisions and grants the service account write access to. +fn log_dir(config_path: Option<&str>) -> PathBuf { + if let Some(parent) = config_path + .map(PathBuf::from) + .as_deref() + .and_then(|p| p.parent()) + .filter(|p| !p.as_os_str().is_empty()) + { + return parent.to_path_buf(); + } + match std::env::var_os("ProgramData") { + Some(program_data) => PathBuf::from(program_data) + .join("RunicGateway") + .join("rust"), + None => std::env::temp_dir(), + } +} + +/// Returns `None` if the log file could not be opened — a service that cannot write a log is still +/// a service worth running, and the SCM start must not fail over it. +fn init_service_tracing( + config_path: Option<&str>, +) -> Option { + let appender = tracing_appender::rolling::Builder::new() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("rust-link-sidecar") + .filename_suffix("log") + .max_log_files(7) + .build(log_dir(config_path)) + .ok()?; + + let (writer, guard) = tracing_appender::non_blocking(appender); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_ansi(false) // a log file is not a terminal + .with_writer(writer) + .init(); + Some(guard) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_dir_follows_the_config_file() { + assert_eq!( + log_dir(Some(r"C:\ProgramData\RunicGateway\rust\alpha.toml")), + PathBuf::from(r"C:\ProgramData\RunicGateway\rust") + ); + } + + #[test] + fn a_bare_filename_does_not_become_the_filesystem_root() { + // `--config sidecar.toml` has a parent of "", which as a path means the root of the current + // drive — somewhere a service account cannot write. Fall back instead. + let dir = log_dir(Some("sidecar.toml")); + assert_ne!(dir, PathBuf::from("")); + assert!(dir.is_absolute(), "{}", dir.display()); + } + + #[test] + fn no_config_falls_back_to_program_data() { + let dir = log_dir(None); + assert!(dir.is_absolute(), "{}", dir.display()); + } + + #[test] + fn service_name_prefix_matches_the_installer() { + // installer/src/service.rs names each instance `RunicGatewayRust-`. + assert_eq!(SERVICE_NAME_PREFIX, "RunicGatewayRust"); + } +}