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
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:
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user