Files
Rust-Link/sidecar
wtclaude fd6efd9a2c
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m30s
feat(sidecar): protocol 3 — the first route on this bridge that is not a GET
`POST /link/confirm` forwards a one-time link code to the plugin and hands back
what it says. Everything before it was the website reading what the game had
already told us; this is the website asking the game a question only the game can
answer.

**It is still a forwarder and holds no authority of its own.** It does not mint
codes, does not store them, does not know what a website user is, and cannot tell
a good code from a bad one. Putting the code table here would give the sidecar a
credential and an opinion, and D2 and the bridge principles say it has neither.

**A refused code is a 200.** `link.ok` and `link.error` are both answers, and the
website has to tell "that code is wrong" from "the game never replied" to say the
right thing to a player. The two transport failures keep the codes `respond`
already gives them: 503 when the game is down, 504 when it is up and silent.

`usable_code` is split out and tested because its two rejections are easy to get
subtly wrong. It trims BEFORE it measures: a player pasting a code out of game
chat brings whitespace with it, a field of nothing but spaces is empty rather
than four characters long, and the length bound belongs on the trimmed value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-17 07:36:45 -05:00
..
2026-09-15 19:52:25 -05:00

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
[store].retain_days RUSTLINK_RETAIN_DAYS 14 Days of event history to keep. 0 keeps everything

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 /boards store Every board, keyed by kind. 200 with an empty object when there are none
GET /events?kind=&wipe=&limit= store Newest first; limit clamped to 11000. For a human
GET /feed?since=&limit= store Oldest first, from a cursor. For a consumer that must not miss a row. Omitting since asks where the end is
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.

The protocol

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.

This process files a frame by its type, and never by its kind — which is what keeps it a dumb forwarder while the catalogue grows. Ten new event kinds are no change here at all.

type Kept Broadcast Example
event appended to the history yes player.death
snapshot replaces the board of that kind yes players.online
reply no no server.status, routed by reqId
control no yes pong, link.down

A frame with no type this build knows is dropped and counted, never guessed at, and the count is on /health as untyped_frames. The game link has no version handshake, so a plugin and a sidecar on different protocol versions show up there and nowhere else.

Frame Direction Purpose
server.hello plugin → sidecar A board. Sent on every connect, not once at server start — this process restarts independently of the game. Carries serverId, bootId and wipeId
players.online plugin → sidecar The other board: who is connected, re-sent on connect and every 60s
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
the read path plugin → sidecar Presence, deaths, chat, tallies, moderation, the wipe — the catalogue is PROTOCOL.md §8.4

bootId is how a game restart is told apart from a sidecar reconnect — the distinction the event system's reconcile hangs off later. wipeId is how a wipe splits the history instead of ending it; the plugin derives it, and every frame carries it.

History is bounded, boards are not. [store].retain_days (default 14) prunes events hourly; a board is one row per kind holding what is true now, and pruning it would make a server the site has rendered for weeks look like one that has never connected. The permanent record is the website's.

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