feat(sidecar): protocol 5 — three routes, and the one timeout worth explaining #5
@@ -90,9 +90,22 @@ use tracing_subscriber::EnvFilter;
|
|||||||
/// envelope: `cmd` and `reqId` are written over whatever the caller sent, and a command that would
|
/// 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.
|
/// 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
|
/// `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.
|
/// mirror, §11 configuration; this constant is one of its four declaration sites.
|
||||||
pub const PROTOCOL_VERSION: u32 = 4;
|
pub const PROTOCOL_VERSION: u32 = 5;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let args = match cli::parse(std::env::args().skip(1)) {
|
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,
|
// 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.
|
// exactly as it forwards a link code.
|
||||||
.route("/permissions/sync", post(perm_sync))
|
.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));
|
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||||
|
|
||||||
let app = Router::new()
|
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)
|
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.
|
/// Maps an RPC outcome onto a status code.
|
||||||
///
|
///
|
||||||
/// The two failures are deliberately different codes. `NoPlugin` is `503`: the game is down and the
|
/// 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
|
/// 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`.
|
/// 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]
|
#[test]
|
||||||
fn the_command_cap_is_the_game_links_line_cap() {
|
fn the_command_cap_is_the_game_links_line_cap() {
|
||||||
assert_eq!(MAX_COMMAND_BYTES, 1024 * 1024);
|
assert_eq!(MAX_COMMAND_BYTES, 1024 * 1024);
|
||||||
|
|||||||
Reference in New Issue
Block a user