Protocol 4 (`PROTOCOL.md` §10), phase 7 as built (`PLAN.md` §20), what an operator needs to know about it (`INTEGRATION.md`), and the in-game leg as a walk to run (`PLAYER_WALK.md`). **The spec.** One verb carrying the whole desired set, diffed by the plugin against the live store; a report whose two interesting fields are the ways a push looks like it worked and did not (`unresolved`, `pending`); drift as a report rather than an action; and the permission hooks as a live SIGNAL rather than the record — a hook that stops firing costs latency, not correctness. **The finding the design turns on, written where it belongs.** A name in the store that is not in the desired set is either something the site retired or something a human granted, and those have opposite correct answers. The store records who granted a permission nowhere, so only the website can tell them apart — which is why it keeps a ledger of what it pushed, and why revoking a hand edit needed a table of its own. **§10.5 is a rule generalising.** "A wedged sidecar must never stall the game" becomes "nothing the far side sends may cost the main thread unbounded work", because `perm.sync` is the first command whose work is not bounded by its own shape. Three bounds, each on the side that can say something useful when it is hit. **§20.7 says plainly what is not proven**: the acceptance line needs a second, non-admin Steam account on the rig, and nothing in the plugin has been compiled. The walk doc carries the seven steps, including the two things to confirm on Carbon rather than assume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
13 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: falsewith an otherwise healthy sidecar — the bridge is fine and the game is not talking to it. Check the plugin is loaded (oxide.plugins) andrg.linkon 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; reconnectingand retries with backoff, buffering into a bounded queue that drops its oldest entries rather than growing. The game does not stall, andEmitnever 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 wrongHostorPortlooks 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.
From protocol 3 your players can link their Steam account. In game they type /link and the
server answers them privately with a six-character code; on the website they type that code in
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
website owns the record, and /unlink in game asks it to let go.
Two things an operator should know about it:
- The code is never in a frame. It reaches the player and nobody else, which is what makes typing
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
account.link.requested, a staff-visible note that somebody asked. - A Steam account can belong to one website account at a time, across your whole fleet. A code
from any of your servers links for all of them. If somebody links the wrong account the site
refuses to move it — the player runs
/unlinkin game, or staff release it from the user's page in the admin panel.
From protocol 4 the website owns your permissions. Groups and grants are written in
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
plugin you already run honours them — Kits, ZoneManager, anything that calls UserHasPermission.
Nothing is required of those plugins and nothing is configured twice.
Four things an operator should know about it, because each looks like something else from the game side:
- A wipe does not lose them. The site re-pushes the whole set when the server comes back. If your
wipe script clears
oxide/data/, the permissions the site authored are back within a minute of the server being up; ones granted at the console are not, because nothing remembers those. - Granting at the console still works, and the website notices. A hand edit is reported as drift on that screen and is never undone on its own — an operator is offered two answers to it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console grant during an incident must survive the next sync.
- A permission no loaded plugin has registered cannot be granted. Oxide's own API silently does nothing for an unknown name, so the site checks first and reports the name as unresolved instead of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
- A player who has never connected to that server can hold a grant but cannot be in a group. The store has no record of them to put in a group yet; the site says which memberships are waiting and they land on that player's first connection.
rg.perms at the server console prints what the last sync did, which is the fastest way to tell
"that permission does not exist here" from "that player has never been seen here".
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.