Protocol 3.0 is feature-complete on `edge` -- world.ruleset, points.board and
vendor.listing / vendor.listing.remove all landed there while the sidecar kept
declaring 2, because a bump is an operator-visible hard break (409 on every
protected route via web.rs::gate, and the website closes the WS on the ws.hello
mismatch). Doing it per phase would have broken the site four times; this is the
one time it happens.
Nothing that existed in v2 changed shape, so the version constant and its doc
comment are the whole change here. The README's worked example moves with it --
it still claimed "currently 1", two bumps stale.
Verified against the release binary: /health reports "protocol": 3, every
response carries `X-UOLink-Version: 3`, an authenticated request declaring 2 is
refused 409 {"sidecar_protocol":3,"client_protocol":"2"}, and one declaring 3
gets 200 off /ruleset. cargo build --release + cargo clippy --all-targets clean.
Co-Authored-By: Claude <noreply@anthropic.com>
110 lines
8.0 KiB
Markdown
110 lines
8.0 KiB
Markdown
# uo-link sidecar
|
|
|
|
The Rust half of the bridge. It terminates the loopback link to the ServUO shard and (as it grows) exposes WebSocket + REST to the website.
|
|
|
|
```
|
|
website ──WS (live feed) / REST (queries)──► sidecar ──loopback TCP 127.0.0.1:7788──► shard
|
|
(this) newline-JSON, bidirectional
|
|
```
|
|
|
|
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
|
|
|
|
## Run
|
|
|
|
```bash
|
|
cargo run # info logging
|
|
RUST_LOG=debug cargo run # see every event, incl. pong heartbeats
|
|
```
|
|
|
|
On first run it writes `sidecar.toml` with a generated auth token and logs the path. Binds the shard listener (`127.0.0.1:7788`) and the web server (`127.0.0.1:8080`) from that file, then waits for the shard to connect.
|
|
|
|
## Configuration & auth
|
|
|
|
All runtime settings live in `sidecar.toml` (path overridable with `$UOLINK_CONFIG`) — **nothing is compiled into the binary**. See `sidecar.toml.example`. Environment variables override the file: `UOLINK_SHARD_BIND`, `UOLINK_WEB_BIND`, `UOLINK_WEB_TOKEN`, `UOLINK_DB_PATH`.
|
|
|
|
The website authenticates to the sidecar with a shared token, presented as:
|
|
|
|
- REST — `Authorization: Bearer <token>` or `X-Api-Key: <token>`
|
|
- WebSocket — `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake)
|
|
|
|
`/health` is the only unauthenticated route. The token is compared in constant time.
|
|
|
|
**Authentication is always on.** If `auth_token` is blank (fresh install, or someone cleared it), the sidecar generates one, writes it back to `sidecar.toml`, logs it, and continues:
|
|
|
|
```
|
|
No auth token configured.
|
|
Generated new token: cb998929b2201e44914dcf077bbf115583bfbe80dcf93073
|
|
Saved to sidecar.toml. Authentication is on.
|
|
```
|
|
|
|
So you can never accidentally run without auth. Rotate by editing the token and restarting. `sidecar.toml` is gitignored because it holds the secret.
|
|
|
|
## Protocol version
|
|
|
|
The wire protocol has a version (`PROTOCOL_VERSION`, currently **3**), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.
|
|
|
|
- Every response carries an `X-UOLink-Version: 3` header.
|
|
- `/health` and the WebSocket `ws.hello` include `"protocol": 3`.
|
|
- If a request sends `X-UOLink-Version` and it disagrees with the sidecar, the request is rejected **409 Conflict** with `{sidecar_protocol, client_protocol}` so the mismatch is obvious.
|
|
|
|
Bump `PROTOCOL_VERSION` in `main.rs` whenever an event or endpoint's shape changes.
|
|
|
|
## Health
|
|
|
|
`GET /health` (unauthenticated) returns an at-a-glance status for troubleshooting:
|
|
|
|
```json
|
|
{
|
|
"status": "ok", // "ok" when plugin connected and DB reachable, else "degraded"
|
|
"protocol": 1,
|
|
"plugin_connected": true, // is the shard link up?
|
|
"database": "ok",
|
|
"uptime": "3d 12h",
|
|
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard, null if none
|
|
}
|
|
```
|
|
|
|
## Status
|
|
|
|
| Piece | State |
|
|
|-------|-------|
|
|
| Shard link (`shard.rs`) | **done** — accepts the shard, reads events, sends commands, re-accepts on disconnect. Verified against the live shard: received `server.hello`, round-tripped a `ping`→`pong`, and reconnected after a sidecar restart. |
|
|
| WebSocket feed (`web.rs`) | **done** — `/ws` fans every shard event out to connected clients via a `broadcast`. Verified: a WS client received `ws.hello` then live `pong` events relayed from the shard. Live-only, no replay. |
|
|
| REST queries (`rpc.rs` + `web.rs`) | **done** — synchronous queries and commands, correlated to shard replies by id. Verified end-to-end against the live shard, success and error paths. |
|
|
| SQLite persistence (`store.rs`) | **done** — every live event persisted; history/economy served from the DB; profiles cached with shard-down fallback; link map. Verified: data survived a sidecar restart, and a cached profile served at 200 with the shard killed. |
|
|
|
|
**The sidecar is feature-complete.** All four pieces work end-to-end against the live shard.
|
|
|
|
The web server binds per `sidecar.toml` (default `127.0.0.1:8080`). All routes except `/health` require the auth token (see Configuration & auth above).
|
|
|
|
### Routes
|
|
|
|
| Method | Path | Shard command | Reply |
|
|
|--------|------|---------------|-------|
|
|
| GET | `/health` | — | `ok` |
|
|
| GET | `/ws` | — | live event feed (WebSocket) |
|
|
| GET | `/char/{account}/{slot}` | `char.request` | `char.profile` |
|
|
| GET | `/char/serial/{serial}` | `char.request` | `char.profile` |
|
|
| GET | `/roster/{account}` | `account.roster` | `account.roster` |
|
|
| GET | `/vendors/{account}` | `vendor.snapshot` | `vendor.snapshot` |
|
|
| POST | `/link/confirm` `{code, websiteUserId}` | `link.confirm` | `link.ok` / `link.error` |
|
|
| POST | `/towncrier` `{id, lines, durationSec}` | `towncrier.add` | `towncrier.ok` / `towncrier.error` |
|
|
| DELETE | `/towncrier/{id}` | `towncrier.remove` | `towncrier.ok` / `towncrier.error` |
|
|
| GET | `/link/{account}` | — (reads store) | `{account, websiteUserId}` or 404 |
|
|
| GET | `/history?kind=&limit=` | — (reads store) | `{events: [...]}` newest first |
|
|
| GET | `/economy?limit=` | — (reads store) | `{series: [...]}` supply snapshots |
|
|
|
|
A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request). No shard connected → 503; no reply within 10 s → 504. `GET /char/serial/{serial}` falls back to the cached profile when the shard is unreachable, so an already-viewed character still renders during an outage.
|
|
|
|
## Design
|
|
|
|
- **`shard.rs`** — `serve()` binds the listener and accepts shard connections in a loop. Each connection splits into read/write halves: the read half parses newline-JSON into `ShardEvent { kind, value }` and forwards them; the write half drains an mpsc of command lines. `ShardHandle::send` posts a command to whichever shard is currently connected, and **drops with a warning if none is** — a website query during a shard outage should fail fast and retry, not queue behind a reconnect. Live *events* that must survive an outage are buffered by the shard, not here.
|
|
- **`web.rs`** — the website-facing HTTP surface (axum). `AppState` holds the `broadcast::Sender<String>`; each `/ws` client subscribes and forwards every event as a text frame. A client that lags past the broadcast buffer is warned and kept live (it just misses events) rather than stalling the others. This side *may* be exposed beyond loopback — it is the gatekeeper, so add auth when you do.
|
|
- **`rpc.rs`** — request/reply correlation over the one shard socket. A REST call registers a pending entry under a correlation id, sends the command, and awaits the reply (10 s timeout). The event loop routes any incoming line whose id is pending back to the waiter; everything else flows on as a live event. Recognizes three correlation fields, matching what the plugin echoes: `reqId` (queries), `code` (link), `id` (town-crier).
|
|
- **`store.rs`** — SQLite (`sqlx`). Three tables: `events` (the full live stream, append-only), `links` (account ↔ website user, mirrored from `link.ok`), `profiles` (last-known character sheet, cached from `char.profile`). History and economy read here instead of the shard; `pong` is dropped as ephemeral chatter. DB file defaults to `uo-link.db` (`DB_PATH` in `main.rs`), gitignored.
|
|
- **`main.rs`** — wires it together: the shard event loop first tries to route each line as an RPC reply; if it isn't one, the line is a live event — logged, persisted, and broadcast to WS.
|
|
|
|
## Wire protocol
|
|
|
|
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|