feat(sidecar): protocol 5 — three routes, and the one timeout worth explaining
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 08:54:08 -05:00
parent c005f5e12f
commit 9532b7b26c
2 changed files with 164 additions and 2 deletions

View File

@@ -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)) {

View File

@@ -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<AppState>, Json(body): Json<Value>) -> 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<AppState>) -> 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<AppState>, Query(q): Query<ConfigPathQuery>) -> 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<AppState>, Json(body): Json<Value>) -> 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);