docs(modules): module-rust phase 1 as built — the transport, and three org-lead decisions

Adds docs/rust-link/, the canonical spec for the Rust bridge: PROTOCOL.md (the
game link and the website API) and INTEGRATION.md (standing it up by hand, and
which of the three components is wrong when it does not work). A new top-level
directory mirroring link/, which is decision D1 below — it keeps the uo/link
symmetry and keeps module docs separate from bridge docs.

Records phase 1 in PLAN.md as section 13. Both criteria met: a server.hello
produced by the live Rust rig travelled game → sidecar → module → the public
website API, killing the sidecar left the game untouched, and all five guards are
green on the module skeleton.

Three org-lead decisions this phase needed, none settled by section 2:

* D1 — the bridge docs live at docs/rust-link/.
* D2 — loopback is the ONLY trust boundary on the game link, no token, exactly
  as on the ServUO bridge. Argued the other way on the grounds that Rust servers
  are far more often on GSPs; overruled, and the consequence is now written down
  as the mistake rather than defended against.
* D3 — the plugin reads Oxide's own config file, so it lands inside the phase-7b
  config editor for free. The argument against — that editing Host/Port from the
  website could cut the link carrying the edit — becomes that phase's guard
  rather than a reason for a second config mechanism.

Section 11.3 is corrected in place: it read module.json's "extensions" array as
held against reality by the loader in the way "mounts" is. Only half true. The
loader checks that a named slot EXISTS and never that the module filled it —
checkDeclared covers "mounts" alone — and only ONE of R13's two slots can be
declared there at all, because site.footer.status is a CLIENT slot and naming it
fails the load outright.

Section 13.3 records five defects a running server found that no test could,
including a bootId that regenerated on every plugin load rather than every server
start — which would have asked core to reconcile its whole ledger on every
oxide.reload, for a world that never moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-15 19:55:05 -05:00
parent 80c7a9dcd1
commit bd83b34614
4 changed files with 717 additions and 1 deletions

204
rust-link/INTEGRATION.md Normal file
View File

@@ -0,0 +1,204 @@
# rust-link — standing the bridge up
**Operator- and developer-facing.** How to get a Rust server, a sidecar and a website talking, and
how to tell which of the three is wrong when they are not. The contract itself is
[`PROTOCOL.md`](PROTOCOL.md).
There is no installer support for Rust yet — that is a later phase — so everything here is done by
hand. When the installer gains `--game rust`, this page becomes the fallback path rather than the
only one.
---
## 1. What you need
| Piece | Where it comes from |
|---|---|
| A Rust dedicated server with **Oxide** | umod.org |
| `RunicGateway.cs` | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins), `overlay/oxide/plugins/` |
| `rust-link-sidecar` | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link) |
| A Runic Gateway website with `module-rust` installed | [Module-Rust](https://gitea.whitlocktech.com/RunicGateway/Module-Rust) |
**One game server, one sidecar, on that server's own host.** Six servers means six of the first two
pairs and six rows in the website's admin panel.
The module also expects four third-party Oxide plugins to be present for the features that follow
the bridge itself — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`, all from k1lly0u on
umod.org. The bridge works without them; the features that read them do not.
---
## 2. The order that works
Sidecar first, then plugin, then website. Any order eventually converges — the plugin retries for
ever and the website polls — but this one gives you a readable log at each step instead of three
components all reporting that something else is missing.
### 2.1 The sidecar
```bash
rust-link-sidecar --print-config
```
This resolves the configuration exactly as a normal start would: it writes `sidecar.toml` if it is
missing, generates and saves an auth token if there is none, and prints the whole thing as JSON —
**including the token in clear text**, which is the point. Keep that token; the website needs it and
there is no second way to read it back.
```json
{
"component": "rust-link-sidecar",
"protocol": 1,
"config_path": "/etc/runicgateway/rust-main.toml",
"game": { "bind": "127.0.0.1:7799", "server_id": "" },
"web": { "bind": "127.0.0.1:8090", "auth_token": "…", "ws_path": "/ws" },
"store": { "path": "/var/lib/runicgateway/rust-link.db" }
}
```
Then start it. On a host running more than one game server, give each sidecar its own
`--config`, its own ports and its own database file.
**`[game].bind` stays on loopback.** There is no token on the game link — the plugin and the sidecar
share a host and `127.0.0.1` *is* the authentication. Moving that bind to a routable address puts an
unauthenticated command channel on the network.
**`[web].bind` is the one you may need to move**, because the website is usually on another host.
Behind TLS and a firewall: the token is the only thing guarding it.
Check it:
```bash
curl http://127.0.0.1:8090/health
{"status":"degraded","protocol":1,"plugin_connected":false,"database":"ok","uptime":"0m","last_event":null}
```
`degraded` with `plugin_connected: false` is exactly right at this point — nothing is connected yet.
### 2.2 The plugin
```bash
cp RunicGateway.cs /path/to/rust/oxide/plugins/
```
Oxide compiles and loads it on the write. Watch `oxide/logs/`:
```
[Info] RunicGateway was compiled successfully in 2295ms
[Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
[Info] [Runic Gateway] connected to 127.0.0.1:7799
```
The first load also writes `oxide/config/RunicGateway.json`. Set `ServerId` before you go further:
```json
{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main" }
```
**`ServerId` is this server's identity as the website knows it, and it is permanent.** It is not
derived from the hostname on purpose — an operator renames a server for a season, and the site must
not lose its history for it. Changing it later orphans everything recorded under the old one.
Now `/health` should read:
```json
{"status":"ok","protocol":1,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"…"}
```
If it does not, ask the game server:
```
rg.link
protocol=1 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
connects=1 writeErrors=0 bootId=boot-20260915T194502Z
```
### 2.3 The website
**Admin → Rust → add a server.** Four values:
| Field | Value |
|---|---|
| Id | the slug every URL carries. Match `ServerId` in the plugin config |
| Name | what visitors see |
| Sidecar base URL | `http://<sidecar host>:8090` |
| Sidecar token | the `auth_token` from `--print-config` |
**The token is write-only.** It is stored encrypted and never returned to any client; the panel
reports only whether one is set. A save that leaves the field blank keeps the stored one — so
renaming a server does not mean re-pasting a credential.
Then press **Test**, which probes the sidecar and reports what came back:
```json
{ "ok": true, "status": "ok",
"sidecar": { "status": "ok", "protocol": 1, "plugin_connected": true, } }
```
Within a poll interval the server appears at `/rust/servers`.
---
## 3. When it does not work
A wrong URL, a wrong token and a mismatched protocol version all present as *"the site says my
server is offline"*. The **Test** button is what separates them, and its `status` is the whole
diagnosis:
| `status` | What is wrong | Where to look |
|---|---|---|
| `ok` | nothing | — |
| `no-token` | the admin form was saved without one | Admin → Rust |
| `unauthorized` | the token does not match | `--print-config` on the sidecar host |
| `protocol-mismatch` | the sidecar and the module speak different versions | upgrade one of them; the body names both numbers |
| `timeout` | the sidecar answered too slowly, or not at all | the sidecar's own log |
| `transport-error` | nothing is listening at that address | the base URL, the firewall, whether the sidecar is running |
| `http-<code>` | something answered, and it was not a sidecar | usually a reverse proxy in front of the wrong thing |
Two failures that look alike and are not:
- **`plugin_connected: false` with an otherwise healthy sidecar** — the bridge is fine and the game
is not talking to it. Check the plugin is loaded (`oxide.plugins`) and `rg.link` on the game
server.
- **The server is listed but reads `stale`** — something reported once and has not since. The row
says what was true when it was written; nothing has written it since. Either the poll is failing
(the website's log) or the sidecar stopped (its own).
### 3.1 The failures that are supposed to happen
Three things look like breakage and are the design:
- **Killing the sidecar does not disturb the game.** The plugin logs `sidecar link lost;
reconnecting` and retries with backoff, buffering into a bounded queue that drops its oldest
entries rather than growing. The game does not stall, and `Emit` never touches a socket.
- **Starting the plugin before the sidecar logs one line and then goes quiet.** `cannot reach the
sidecar: … — retrying quietly until it answers`, printed once per load rather than every few
seconds. A wrong `Host` or `Port` looks exactly like this, which is why it is printed at all.
- **The website renders with every game server off.** The server list, the player counts and the
last-reported times all come from stored state. A page that 500s because a socket is closed would
be a module that made the site's availability depend on the game's.
---
## 4. Running more than one server
Each pair is fully independent: its own ports, its own `sidecar.toml`, its own database file, its
own token, its own row on the website.
Set `[game].server_id` in each `sidecar.toml` to match that server's plugin config. It is a
**cross-check**, not a second source of truth — the plugin's announcement wins — and it exists to
catch exactly one mistake: two game servers pointed at one sidecar by a copied config, which is
silent in every other design and produces one server's history under another's name. When it fires
you get a warning naming both ids.
---
## 5. Upgrading
The four declaration sites in [`PROTOCOL.md` §2](PROTOCOL.md#2-versioning) must agree. In practice
that means upgrading the sidecar and the plugin **together**, because the game link has no version
check of its own and a mismatched plugin mis-parses rather than refusing.
The website is the forgiving half: it sends its version on every request and a sidecar that
disagrees answers `409` with both numbers, so a module ahead of or behind its sidecar reports a
named fault rather than misbehaving.