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
This commit is contained in:
92
sidecar/README.md
Normal file
92
sidecar/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 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 1–1000 |
|
||||
| `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
|
||||
```
|
||||
Reference in New Issue
Block a user