Files
Rust-Link/sidecar/README.md
wtclaude e2a58f3455 feat(sidecar): protocol 1 — the transport
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
2026-09-15 19:52:55 -05:00

4.7 KiB
Raw Blame History

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].bind is loopback, and there is no token on that link. The plugin and the sidecar share a host; 127.0.0.1 is the authentication. Binding it to a routable address puts an unauthenticated command channel on the network.
  • A relative [store].path resolves against the directory holding sidecar.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 11000
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