INTEGRATION.md predated the installer: it said there was no installer support for Rust, showed protocol-1 samples throughout, listed the uMod Clans plugin as expected (D47 says it is deliberately not used) and PopupNotifications as needed (optional, D141), and never mentioned Carbon or RunicNPC. It now points at INSTALL.md for setting a server up, takes its pieces from one bundle, and shows protocol 14's samples, including /health's untyped_frames and --print-config's current shape. INSTALL.md's component table and by-hand step named two of the plugin's helpers; the release ships three. RunicGatewayRaids.cs (D204) is now named in both. Found while writing the runicgateway.com Rust docs (its PLAN.md §16, phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
360 lines
18 KiB
Markdown
360 lines
18 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).
|
|
|
|
**To set a server up, use [`INSTALL.md`](INSTALL.md)**: the installer (`--game rust`), the
|
|
Pterodactyl egg, or its own by-hand path. This page walks the same pieces one at a time, by hand, and
|
|
is mostly useful for understanding them and for diagnosing a link that does not come up.
|
|
|
|
---
|
|
|
|
## 1. What you need
|
|
|
|
| Piece | Where it comes from |
|
|
|---|---|
|
|
| A Rust dedicated server with **Oxide** or **Carbon** | umod.org, or carbonmod.gg |
|
|
| `RunicGateway.cs`, and its helpers | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins), `overlay/oxide/plugins/` |
|
|
| `RunicNPC.cs` — **required** since RunicNPC's stage 9 (D310) | [runicnpc-rust](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust) |
|
|
| `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) |
|
|
|
|
Take the plugin, RunicNPC and the sidecar from one bundle (`v2/rust/current.json` on the installer's
|
|
`bundles` branch), never "the latest of each": the bundle is what CI checked speaks one protocol.
|
|
|
|
**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 bridge needs two third-party plugins from uMod for the features that follow it, **Kits** and
|
|
**ZoneManager**; it works without them, and the features that read them do not. **RunicNPC will not
|
|
load without Kits.** PopupNotifications is optional (D141), and so is everything
|
|
[`OPERATING.md`](../modules/rust/OPERATING.md) covers. The uMod **Clans** plugin is deliberately not
|
|
used (D47): a server's clans come from Rust's own clan system.
|
|
|
|
---
|
|
|
|
## 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",
|
|
"version": "0.3.0",
|
|
"protocol": 14,
|
|
"config_path": "/etc/runicgateway/rust-main.toml",
|
|
"config_created": true,
|
|
"token_generated": true,
|
|
"game": { "bind": "127.0.0.1:7799", "server_id": "" },
|
|
"web": { "bind": "127.0.0.1:8090", "ws_path": "/ws", "auth_required": true, "auth_token": "…" },
|
|
"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":14,"plugin_connected":false,"database":"ok","uptime":"0m","last_event":null,"untyped_frames":0}
|
|
```
|
|
|
|
`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/` (Carbon: `carbon/plugins/` and
|
|
`carbon/logs/Carbon.Core.log`):
|
|
|
|
```
|
|
[Info] RunicGateway was compiled successfully in 2295ms
|
|
[Info] [Runic Gateway] protocol 14, 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` (Carbon: `carbon/configs/`). Set
|
|
`ServerId` before you go further — or, better, create the file holding just `ServerId` and `Port`
|
|
before the first load, as the installer does. The plugin fills in every other key:
|
|
|
|
```json
|
|
{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "main", "EventsEnabled": false, … }
|
|
```
|
|
|
|
**`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":14,"plugin_connected":true,"database":"ok","uptime":"2m","last_event":"…","untyped_frames":0}
|
|
```
|
|
|
|
If it does not, ask the game server:
|
|
|
|
```
|
|
rg.link
|
|
protocol=14 serverId=main connected=True depth=0 sent=3 dropped=0 received=2
|
|
connects=1 writeErrors=0 bootId=boot-20260915T194502Z wipeId=w-20260915T195817Z
|
|
```
|
|
|
|
### 2.3 The website
|
|
|
|
**Admin → Rust servers** (`/admin/rust/servers`) **→ 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 the sidecar**, which probes the sidecar and reports what came back — *The sidecar
|
|
answered (protocol 14).* on the page, from this answer:
|
|
|
|
```json
|
|
{ "ok": true, "status": "ok",
|
|
"sidecar": { "status": "ok", "protocol": 14, "plugin_connected": true, … } }
|
|
```
|
|
|
|
Within a poll interval the server appears at `/rust`.
|
|
|
|
### 2.4 The recommended PopupNotifications look (optional)
|
|
|
|
PopupNotifications is optional (D141). Where it is installed, news, events and zone messages can
|
|
arrive as popups, and its stock look is a small grey box at the right of the screen, sized for one
|
|
short line. The look we recommend is a **banner across the top of the screen**: no close button,
|
|
Roboto Condensed Bold at 18 (chosen in RunicNPC's 9d player session, D327). Paste this over
|
|
`oxide/config/PopupNotifications.json` (Carbon: `carbon/configs/PopupNotifications.json`), then
|
|
`oxide.reload PopupNotifications` (Carbon: `c.reload PopupNotifications`):
|
|
|
|
```json
|
|
{
|
|
"Notification duration (in seconds)": 8,
|
|
"Maximum notifications shown at any time": 6,
|
|
"UI Positioning": {
|
|
"Position of the left side of notification (0.0 - 1.0)": 0.15,
|
|
"Position of the bottom of noticiation (0.0 - 1.0)": 0.87,
|
|
"Width (0.0 - 1.0)": 0.7,
|
|
"Height (0.0 - 1.0)": 0.07,
|
|
"Space between notification (0.0 - 1.0)": 0.005
|
|
},
|
|
"UI Options": {
|
|
"Show close button": false,
|
|
"Panel color (hex)": "#2b2b2b",
|
|
"Panel transparency (0.0 - 1.0)": 0.8,
|
|
"Close button color (hex)": "#d85540",
|
|
"Close button transparency (0.0 - 1.0)": 0.5,
|
|
"Font": "robotocondensed-bold.ttf",
|
|
"Font size": 18
|
|
},
|
|
"Version": {
|
|
"Major": 0,
|
|
"Minor": 2,
|
|
"Patch": 1
|
|
}
|
|
}
|
|
```
|
|
|
|
- **Keep the `Version` block.** PopupNotifications resets a config with no version (or one below
|
|
0.2.0) to its defaults when it loads, which would undo the banner.
|
|
- The key `noticiation` is PopupNotifications' own spelling. Leave it as it is.
|
|
- **The installer and the Pterodactyl egg write this for you**, but only where the file is missing
|
|
or is still exactly the plugin's own defaults (D328). A config with any setting changed is kept
|
|
whole, so a look you chose is never replaced. See [`INSTALL.md`](INSTALL.md).
|
|
|
|
---
|
|
|
|
## 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, and it exists to catch exactly one mistake: two game
|
|
servers dialling one sidecar, which is silent in every other design and produces one server's
|
|
history under another's name. When it fires, the sidecar refuses the plugin that names the other
|
|
server, before anything it sent is stored, and logs an `ERROR` naming both ids; that plugin retries
|
|
every few seconds until its `Port` is corrected. The installer and the egg set it for you.
|
|
|
|
---
|
|
|
|
## 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 `/unlink` in 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".
|
|
|
|
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
|
|
config. It reads the configuration directory your framework actually uses — `oxide/config` or
|
|
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
|
|
works for whatever you have installed. Four things worth knowing before you use it:
|
|
|
|
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
|
|
seconds, the old file is **restored automatically** and the site shows you the log line that says
|
|
why. A typo costs you a few seconds, not a plugin.
|
|
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
|
|
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
|
|
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
|
|
next save anyway.
|
|
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
|
|
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
|
|
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
|
|
assumption.
|
|
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
|
|
website would cut the link carrying the change, or strand every row the site holds for this
|
|
server. Edit them on the host; everything else in that file is editable from the site.
|
|
|
|
`rg.config` at the server console prints which directory the site is reading and what the last write
|
|
from it did.
|
|
|
|
**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.
|