All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m24s
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
198 lines
8.9 KiB
Rust
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;
|
|
}
|
|
}
|