The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin dials into, and serves the website a WebSocket feed plus store-backed reads. Protocol 1 is deliberately three frames — server.hello, ping/pong, and one correlated server.status — because phase 1's job is to get every seam working at once with almost nothing in them. What is load-bearing rather than incidental: * The plugin is the TCP client and this process owns the listener, so a Rust server opens no extra port. Loopback is the trust boundary on that link and there is no token on it; the website-facing surface is the opposite, with auth always on and a token generated and persisted on first start. * Inbound lines are capped at 1 MiB from the start rather than after the first large frame arrives. An over-long line is discarded and the connection stays up: one malformed frame is not a reason to drop a link live events flow over. * Store-backed reads answer while the game is off, which is what lets a website render a server list during a wipe. /status is the one route that fails when the game is down, and /server answers 204 rather than a null when the game has never connected -- those are different answers and a client that cannot tell them apart renders a server that does not exist. * The two RPC failures get distinct codes. 503 means the game is down; 504 means it is up and did not answer. Different fixes. * rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core classifies a budget overrun as retryable unconditionally, so an action whose budgetMs does not exceed it can never report retry:false. One defect found while building, which no unit test would have caught: a four-connection SQLite pool over :memory: hands out four separate empty databases, because an in-memory database is per connection. It presents as 'no such table' from a random subset of queries. The pool is now capped at one connection for an in-memory path, which is the only coherent reading of :memory: and is what makes it usable at all. Exercised end to end against a live Rust server: a server.hello travelled game -> sidecar -> module -> the public website API, and killing this process left the game untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
4.7 KiB
rust-link-sidecar
Configuration reference and endpoint list. For what this component is, see the repo README.
Configuration
sidecar.toml, resolved in this order: --config <PATH>, else $RUSTLINK_CONFIG, else
./sidecar.toml. Environment variables override the file; the file overrides the defaults.
| Key | Env | Default | What it is |
|---|---|---|---|
[game].bind |
RUSTLINK_GAME_BIND |
127.0.0.1:7799 |
Where the Oxide plugin dials in |
[game].server_id |
RUSTLINK_SERVER_ID |
(empty) | Optional cross-check against the plugin's own serverId |
[web].bind |
RUSTLINK_WEB_BIND |
127.0.0.1:8090 |
Where the website reaches this sidecar |
[web].auth_token |
RUSTLINK_WEB_TOKEN |
(generated) | The shared secret the website presents |
[store].path |
RUSTLINK_DB_PATH |
rust-link.db |
SQLite file |
Two things about those defaults are load-bearing:
[game].bindis loopback, and there is no token on that link. The plugin and the sidecar share a host;127.0.0.1is the authentication. Binding it to a routable address puts an unauthenticated command channel on the network.- A relative
[store].pathresolves against the directory holdingsidecar.toml, not the working directory. A service manager's working directory must not decide where the database lands — on Windows that can be%SystemRoot%\System32, or a silently redirected VirtualStore copy.
[game].server_id is a cross-check, not a second source of truth. The plugin announces its own
serverId and that is the authority; when both are set and they disagree, the sidecar logs the
disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied
config is the mistake this catches, and it is silent in every other design.
Reading the token back
rust-link-sidecar --print-config
Resolves the configuration exactly as a normal start would — writing the file and generating the token if they are missing — and prints it as JSON on stdout, including the token in clear text. That is the supported way for an installer to obtain it; the alternative is scraping a log.
Endpoints
Everything except /health requires the token, as Authorization: Bearer <t>, X-Api-Key: <t>,
or ?token=<t> (the last so browser WebSocket clients, which cannot set handshake headers, can
still authenticate). Every response carries X-RustLink-Version.
| Route | Backed by | Notes |
|---|---|---|
GET /health |
— | Unauthenticated, so monitoring can reach it |
GET /server |
store | The last server.hello. 204 when the game has never connected |
GET /events?kind=&limit= |
store | Newest first; limit clamped to 1–1000 |
GET /status |
plugin (RPC) | A live round trip. 503 with no plugin, 504 on no reply |
GET /ws |
broadcast | The live feed. Sends ws.hello on connect |
The split is the point: the store-backed reads answer while the game is off, which is what lets the
website render a server list during a wipe or a restart. /status is the one route that fails when
the game is down, because "what is it doing right now" has no stale answer worth giving.
/server answers 204, not 200 with a null, when the game has never connected. "We have never
heard from this server" and "this server reports nothing" are different answers, and a client that
cannot tell them apart renders a server that does not exist.
Protocol 1
Newline-delimited JSON over TCP, both directions. Outbound frames (plugin → sidecar) carry kind;
inbound frames (sidecar → plugin) carry cmd. Lines are capped at 1 MiB; an over-long line is
discarded and the connection stays up.
| Frame | Direction | Purpose |
|---|---|---|
server.hello |
plugin → sidecar | Sent on every connect, not once at server start — this process restarts independently of the game. Carries serverId and bootId |
ping / pong |
sidecar → plugin → sidecar | The heartbeat, every 30s. A pong is never persisted; it only moves last_event |
server.status |
sidecar → plugin → sidecar | The one request/reply verb, correlated by reqId |
bootId is how a game restart is told apart from a sidecar reconnect — the distinction the event
system's reconcile hangs off later.
The RPC reply timeout (rpc::REPLY_TIMEOUT, 10s) is a ceiling every later command budget sits
under. Core classifies a budget overrun as retryable unconditionally, because it cannot ask the
game while the action is still awaiting a socket. An action whose budgetMs exceeds this can never
report retry: false.
Checks
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test