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

93 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |
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 /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
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test
```