config.rs loads all runtime settings from an external sidecar.toml (path via $UOLINK_CONFIG), with env-var overrides (UOLINK_WEB_TOKEN, UOLINK_WEB_BIND, UOLINK_SHARD_BIND, UOLINK_DB_PATH). Nothing is compiled into the binary. On first run the file is generated with a random 24-byte auth token, so the sidecar is secured out of the box and the operator just copies the token to the website. An axum middleware rejects any request to a non-/health route that does not present the token, as Authorization: Bearer, X-Api-Key, or ?token= (the last so browser WebSocket clients, which cannot set handshake headers, can authenticate). The comparison is constant-time. An empty token disables auth and is only tolerated on a loopback bind; binding to 0.0.0.0 with no token logs a warning. Verified: /health open (200); /history 401 without a token, 401 with a wrong one, 200 with the right one via either Bearer or X-Api-Key; an authed shard query falls through to 503 when no shard is connected; WS rejected (401) with a bad ?token= and upgraded (101) with the right one. sidecar.toml is gitignored (holds the secret); sidecar.toml.example is committed as the reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.7 KiB
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 ../docs/PLAN.md §2.
Run
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>orX-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. It is generated randomly on first run; rotate it by editing sidecar.toml (or setting UOLINK_WEB_TOKEN) and restarting. An empty token disables auth and is only tolerated on a loopback bind — binding to 0.0.0.0 with no token logs a warning that the API is exposed. sidecar.toml is gitignored because it holds the secret.
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 intoShardEvent { kind, value }and forwards them; the write half drains an mpsc of command lines.ShardHandle::sendposts 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).AppStateholds thebroadcast::Sender<String>; each/wsclient 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 fromlink.ok),profiles(last-known character sheet, cached fromchar.profile). History and economy read here instead of the shard;pongis dropped as ephemeral chatter. DB file defaults touo-link.db(DB_PATHinmain.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 ../docs/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.