Files
docs/rust-link/INTEGRATION.md
wtclaude 30e72adfcf docs(rust-link): protocol 2 — the read path, and phase 3 as built
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
2026-09-16 08:39:27 -05:00

10 KiB

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.

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, overlay/oxide/plugins/
rust-link-sidecar Rust-Link
A Runic Gateway website with module-rust installed 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

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.

{
  "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:

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

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:

{ "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:

{"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:

{ "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 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.