//! rust-link sidecar. //! //! Terminates the loopback link to a Rust game server's Oxide bridge plugin and exposes a //! website-facing HTTP surface: a WebSocket live feed and REST queries backed by SQLite. //! //! # Why the game does not listen //! //! The plugin is the TCP *client*; this process owns the listener. A Rust server therefore opens //! no extra port, and the only component the website can reach is this one. That invariant is //! inherited wholesale from the ServUO bridge — the footing changed (Oxide hooks instead of shard //! source) and the shape did not. //! //! # One server, one sidecar //! //! This binary serves exactly one game server. A community running six servers runs six pairs, and //! `module-rust` holds six clients; core never learns there is more than one. Nothing here is //! multiplexed, and nothing here should become multiplexed — the `serverId` on every frame exists //! so the *module* can tell its own clients apart, not so this process can. //! //! # Layout //! //! `main` does argument handling and nothing else; the sidecar proper lives in [`app`], which is //! parameterised on `ready`/`shutdown` so that a future service wrapper (the installer's phase) //! can supply the host's own start and stop without restructuring anything. mod app; mod cli; mod config; mod game; mod rpc; mod store; mod web; use tracing_subscriber::EnvFilter; /// Wire-protocol version between the website and this sidecar, and between this sidecar and the /// bridge plugin. Bump it whenever a frame's shape changes, so a mismatched peer is detected /// immediately (`409` on HTTP, a refusal in the log on the game link) rather than mis-parsed. /// /// It is declared in **three** places and they must agree: here, in `Module-Rust`'s /// `module.json`, and in `Rust-Plugins`' `overlay.toml`. The installer refuses to pair a sidecar /// and an overlay that disagree, so a bump lands in the same change as the emitters it describes. /// /// # Protocol 1 — the transport /// /// Everything phase 1 defines, and deliberately nothing more: /// /// * **`server.hello`** — the one event. The plugin sends it on every successful connect, not /// once at server start: this process restarts independently of the game, so anything the /// sidecar needs up front has to be re-sent per connection. It carries the `bootId`, which is /// how a game restart is told apart from a sidecar reconnect — the distinction the event /// system's `reconcile` will later hang off. /// * **`ping` / `pong`** — the heartbeat. The sidecar asks, the plugin answers. A `pong` is /// ephemeral chatter and is never persisted; it only moves `last_event`. /// * **`server.status`** — the one request/reply verb, correlated by `reqId`. It exists in /// phase 1 so the correlation path is exercised by something before anything depends on it. /// /// Both directions are newline-delimited JSON over TCP. Outbound frames (plugin -> sidecar) carry /// `kind`; inbound frames (sidecar -> plugin) carry `cmd`. /// /// # Protocol 2 — the read path /// /// The catalogue: presence, deaths, chat, gathering, moderation and the wipe. Three things about /// it reach this file rather than only the plugin's: /// /// * **Every frame carries `type`** — `event`, `snapshot`, `reply` or `control` — and this /// process files on THAT, never on `kind`. It is what keeps the sidecar a dumb forwarder while /// the catalogue grows: ten new event kinds are no change here at all. /// * **Every frame carries `serverId` and `wipeId`**, and both are lifted into indexed columns /// (the one migration shape the store's header predicted). /// * **`GET /feed`** is the ingest cursor, oldest-first, separate from `/events` so that no /// caller can get the other ordering by forgetting a parameter. /// /// # 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. /// /// # 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. /// /// # Protocol 6 — first-party clans /// /// One board (`clans`) and five events (`clan.created`, `clan.disbanded`, `clan.member.added`, /// `clan.member.left`, `clan.member.kicked`), from which the website builds core's Teams (R5). /// /// **Nothing in this process changed except this number**, and that is protocol 2's promise /// kept: the board is filed by `type: "snapshot"` and the events by `type: "event"`, so the /// sidecar needs no arm for any of them. The bump exists for the other two declaration sites — /// a website that reads clans must not pair with a plugin that never sends them. /// /// One property of this process does bear on the board: [`game::MAX_INBOUND_LINE_BYTES`] /// discards a line over 1 MiB outright. The plugin bounds the board well inside it and says /// `truncated` when it had to stop, because a board that never arrived would read as a server /// with no clans. /// /// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the /// mirror, §11 configuration, §12 clans; this constant is one of its four declaration sites. pub const PROTOCOL_VERSION: u32 = 6; fn main() -> anyhow::Result<()> { let args = match cli::parse(std::env::args().skip(1)) { Ok(args) => args, Err(msg) => { eprintln!("rust-link-sidecar: {msg}\n\n{}", cli::USAGE); std::process::exit(2); } }; match args.mode { cli::Mode::Help => { print!("{}", cli::USAGE); return Ok(()); } cli::Mode::Version => { println!( "rust-link-sidecar {} (protocol {})", env!("CARGO_PKG_VERSION"), PROTOCOL_VERSION ); return Ok(()); } // Tracing stays uninitialized here on purpose: the subscriber writes to stdout, and on this // path stdout is the document. An installer parses it. cli::Mode::PrintConfig => { let loaded = config::Config::load(args.config.as_deref())?; println!("{:#}", config::describe(&loaded)); return Ok(()); } cli::Mode::Run => {} } init_console_tracing(); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; runtime.block_on(app::run(args.config.as_deref(), || {}, shutdown_signal())) } /// Logging for a foreground run: human-readable, on stdout. pub fn init_console_tracing() { tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .init(); } /// Resolves on Ctrl-C, and on `SIGTERM` where there is one. async fn shutdown_signal() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; let mut term = match signal(SignalKind::terminate()) { Ok(s) => s, Err(_) => { let _ = tokio::signal::ctrl_c().await; return; } }; tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = term.recv() => {} } } #[cfg(not(unix))] { let _ = tokio::signal::ctrl_c().await; } }