Files
Rust-Link/sidecar/src/main.rs
wtclaude 4adc9bccf2
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m24s
feat(sidecar): protocol 6 — first-party clans, and no new code
Protocol 6 adds one board (`clans`) and five clan events. The sidecar
files frames by `type`, so none of them needs an arm here. That is
protocol 2's promise kept. The bump exists for the other declaration
sites: a website that reads clans must not pair with a plugin that
never sends them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:14:18 -05:00

198 lines
8.9 KiB
Rust

//! 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;
}
}