The specification the other three repositories are held against, plus the phase record. `PROTOCOL.md` §8 is the new contract. Its centre is one field: every frame now carries `type` — `event`, `snapshot`, `reply`, `control` — and the sidecar files on that and nothing else. That is the dumb-forwarder property made structural rather than intended: ten new event kinds are zero change in Rust-Link, and only a version adding an indexed column touches it at all. Also in §8: the fifteen-kind catalogue and what each frame carries; `wipeId` derived by the plugin, which REVERSES §3.2's "deriving one is the website's job" and says why; boards re-sent on connect and on a cadence; the aggregate rule (a hook that can fire more than once a second per player is a counter, not an event); the void rule that stops a read-path hook vetoing a death or a login; and `GET /feed`, a cursor route separate from `/events` because one route with two orderings serves the wrong one to every caller that forgets the parameter. §8.5 is the part to read twice. The classification of a kind as public or staff is NOT on the wire, deliberately: a boundary declared by the sender is one a compromised or out-of-date game host can widen, so the module holds a default-deny allowlist and this table is what its test holds it against. §8.8 corrects a catalogue rather than a defect: PLAN.md §10 sources `rust.login.denied` from `CanUserLogin`, and that hook fires on every attempt — the only way to learn of a denial from it is to be the denier. A denial is the absence of an approval, and protocol 2 emits both facts so phase 10 can pair them. `PLAYER_WALK.md` is new, and it exists because half this catalogue cannot fire without somebody holding a mouse. Ten steps, what each one should produce, and what counts as a pass — written so the walk can be run without watching the output live, and so the answer afterwards is readable as a transcript. PLAN.md §16 is phase 3 as built: the four decisions, the two defects only a server that BOOTED with the plugin could find (a wipe id that was null for every real session, and a two-second main-thread stall on unload), what was proven and how, and — stated plainly rather than implied — the three measurements still queued on the org lead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
239 lines
10 KiB
Markdown
239 lines
10 KiB
Markdown
# 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.0 `untyped_frames` on `/health` is not zero
|
|
|
|
**The plugin and the sidecar are on different protocol versions.** The game link has no handshake
|
|
to catch that at connect time (`PROTOCOL.md` §2), so it shows up here instead: the sidecar files a
|
|
frame by its `type`, a frame from the wrong version does not carry one it recognises, and it is
|
|
dropped and counted rather than guessed at.
|
|
|
|
The symptom without this counter is the confusing one — a game server plainly up, a sidecar plainly
|
|
healthy, and a website showing nothing. Check the plugin's `rg.link` (it prints its protocol) against
|
|
the sidecar's `/health` (which prints its own) and upgrade whichever is behind.
|
|
|
|
### 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.
|
|
|
|
---
|
|
|
|
## 4.1 What the bridge sends, and how much of it is kept
|
|
|
|
From protocol 2 the plugin sends the read path: connects and disconnects, deaths, chat, gathering,
|
|
bans and reports, and the wipe. Two things about the volume are worth knowing before you size
|
|
anything.
|
|
|
|
**Gathering and NPC kills are counted, not forwarded.** `OnDispenserGather` fires on every swing at
|
|
a tree; sending one frame per swing would make the bridge the most expensive thing on the server. The
|
|
plugin keeps a per-player tally and flushes it once a minute as a single `player.tally` frame. So the
|
|
leaderboard is exact and the wire is quiet.
|
|
|
|
**The sidecar's history is bounded; the website's is not.** `[store].retain_days` (default 14) is
|
|
how long the sidecar keeps raw events. The permanent record — per-wipe totals that survive a wipe —
|
|
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
|
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
|
|
|
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
|
|
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
|
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
|
`REMOVE_FILES`.
|
|
|
|
---
|
|
|
|
## 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.
|