feat(sidecar): protocol 4 — two routes, and no opinion about either #4
@@ -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)) {
|
||||
|
||||
@@ -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<AppState>) -> 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<AppState>, Json(body): Json<Value>) -> 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");
|
||||
|
||||
Reference in New Issue
Block a user