Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
303 lines
15 KiB
Markdown
303 lines
15 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, 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.
|