# 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://: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-` | 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.