All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s
The sidecar now files a frame by its `type` and never by its `kind`. That is the dumb-forwarder property made structural: `event` is appended to history, `snapshot` replaces the board of its kind, `reply` is routed by `reqId`, `control` is broadcast and kept nowhere. Ten new event kinds are no change here at all, which is the whole point when the thing that grows fastest is the catalogue. A frame whose `type` this build does not know is dropped and counted, never guessed at. Defaulting an absent one to `event` would file a BOARD as history — the presence board appended a few thousand times, which nothing reports. The count is on `/health` as `untyped_frames`, because the failure it diagnoses (a plugin and a sidecar on different protocol versions, which the game link has no handshake to catch) otherwise presents as a website showing nothing while the game is plainly up. It caught exactly that within three seconds of first running, against a protocol 1 plugin still live on a retired rig. `boards` generalises protocol 1's single `server_state` row, and a database made by protocol 1 is migrated in place: the two indexed columns are added by a guarded `ALTER`, and the old board is carried across. Without that carry-over an upgraded sidecar answers `204` until the game next connects, and the website reads that as "never heard from" — losing a server it has rendered for weeks at the exact moment somebody upgraded the bridge. `GET /feed` is the ingest cursor: oldest first, strictly after an id, with `lastId` and `more`. It is a separate route rather than a flag on `/events` because one route with two orderings serves the other one to every caller that forgets the parameter — and for the ingesting caller that means advancing its cursor past rows it never read. Omitting `since` asks where the END is; `since=0` is the other question entirely, and the two must not be separated by whether somebody typed a parameter. `[store].retain_days` (default 14) prunes events hourly. Boards are never pruned: history grows and the present does not, and a pruned board is a server that has never connected. The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test gates phases 1 and 3 have both been running by hand — a guard nothing invokes is a guard whose state nobody knows. 44 tests pass, clippy clean at `-D warnings`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
117 lines
6.4 KiB
Markdown
117 lines
6.4 KiB
Markdown
# rust-link-sidecar
|
||
|
||
Configuration reference and endpoint list. For what this component *is*, see the
|
||
[repo README](../README.md).
|
||
|
||
## 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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
cargo fmt --all -- --check
|
||
cargo clippy --all-targets -- -D warnings
|
||
cargo test
|
||
```
|