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);