All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m7s
entity.destroyed gains the cupboard's authorised list and covers doors, walls and the cupboard. The sidecar stores and serves events as they arrive, so only the number moves. The bump is what stops a website that alerts on `authorized` from pairing with a protocol-6 plugin that never sends it, which would read every raid as a base with no cupboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
210 lines
9.6 KiB
Rust
210 lines
9.6 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.
|
|
///
|
|
/// # Protocol 7 — a raid frame that names who lives there
|
|
///
|
|
/// `entity.destroyed` widens to doors, external walls and the tool cupboard, and gains the
|
|
/// cupboard's `buildingId` and its `authorized` list, which is what lets the website send the
|
|
/// raid alert to the people whose base it was (the module's PLAN.md §25).
|
|
///
|
|
/// **Again nothing here changed but this number.** The frame is an `event`, stored and served as
|
|
/// it arrived. The bump exists because a website that alerts on `authorized` must not pair with a
|
|
/// plugin that never sends it — against protocol 6 it would read every raid as a base with no
|
|
/// cupboard, and alert nobody while looking healthy.
|
|
///
|
|
/// `docs/rust-link/PROTOCOL.md` is the specification — §8 the read path, §9 identity, §10 the
|
|
/// mirror, §11 configuration, §12 clans, §13 the raid frame; this constant is one of its four
|
|
/// declaration sites.
|
|
pub const PROTOCOL_VERSION: u32 = 7;
|
|
|
|
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;
|
|
}
|
|
}
|