Files
Rust-Link/sidecar/README.md
wtclaude b3b66b1cc2
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m53s
feat(sidecar): a Windows service, the egg and its launcher, and the first release workflow (phase 18)
Module-rust phase 18, step 4 of docs/modules/rust/PLAN.md §34.2.7.

The Windows service (D149, §34.2.5): src/windows.rs, ported from link's fix
for error 1053. The same exe tries the SCM handshake and falls through to a
console run on 1063; it reports Running only once the listener and store are
up, and logs to a daily file beside its config. One binary serves every
RunicGatewayRust-<id> instance, because the SCM ignores the dispatcher's name
for an own-process service.

An empty environment variable now counts as unset. A Pterodactyl egg exports
every variable it declares, so a blank RUSTLINK_WEB_TOKEN arrived as "" and
overrode the saved token, and a new one was generated and persisted on every
boot. That breaks D152, which this change makes true.

The egg (R20, R22, D151, D152, §34.2.6), in egg/:
- install.sh is egg 18's script with two changes. A wipe guard moves
  rust-link/ to /tmp around `rm -rf ${REMOVE_FILES}`. The bridge block then
  fetches a schema-2 Rust bundle (pinnable by RUNICGATEWAY_BUNDLE), checks
  every asset's sha256 and the plugin's protocol before placing anything, and
  places the plugin by FRAMEWORK. Vanilla installs nothing and does not fail.
- with-sidecar.sh is the launcher. It unsets blank variables, builds the web
  bind from RUSTLINK_WEB_PORT, and runs --print-config so that a newly
  generated token is printed once. It prints the URL and server id for the
  admin page, then execs the game. It no longer uses `set -e`: nothing the
  bridge gets wrong may keep the game from booting.
- The startup's launcher prefix is conditional, so a server with no bridge
  boots exactly as egg 18 does.
- build.sh assembles egg-rust-runicgateway.json. PR Checks runs it.

The release (D145, §34.2.1) reuses servuo-plugins' engine. It publishes the
static musl Linux binary, the Windows exe, the launcher, the egg and
SHA256SUMS, and dispatches the installer's bundle.yml. PR Checks gains a
clippy run for the Windows target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:14:57 -05:00

129 lines
7.5 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. **An
empty or blank variable counts as unset**: a Pterodactyl egg exports every variable it declares, so a
field left blank arrives as `VAR=""`, and honouring that would erase the saved token on every boot.
| 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.
## As a Windows service
The same `.exe` runs from a shell or under the Service Control Manager. It tries the SCM handshake
first and falls through to an ordinary console run when a human started it, so there is no
`--service` flag to forget. Under the SCM it reports `Running` only once the game listener and the
store are up, turns a stop request into a clean shutdown, and logs to a daily-rolled
`rust-link-sidecar.YYYY-MM-DD.log` beside its config (a service has no console). One service per
game server, each with its own `--config`; the installer names them `RunicGatewayRust-<server id>`.
## 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 |
| every later route | plugin (RPC) | One thin forward per command, from `POST /link/confirm` (protocol 3) to `POST /titles` (protocol 12). Each is listed beside its protocol in `src/web.rs`, and its body in `docs/rust-link/PROTOCOL.md` |
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
```