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
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