`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
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].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 /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 1–1000. 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