docs(modules): module-rust supports Carbon too, ships a Pterodactyl egg, and the rigs move to the panel #253

Merged
whitlocktech merged 4 commits from docs/rust-carbon-and-pterodactyl into main 2026-09-16 02:54:25 +00:00
Showing only changes of commit 069e715b1b - Show all commits

View File

@@ -1,10 +1,10 @@
# `module-rust` — the plan
**Status:** phases 0 and 1 done, 2026-09-15. **Twenty-one decisions of record; one outstanding
request, no open questions.** Audited against the whole contract, not just the game-facing chapters
**Status:** phases 0 and 1 done, 2026-09-15. **Twenty-two decisions of record, no open questions.** Audited against the whole contract, not just the game-facing chapters
(§7); the event and engagement catalogues are §9 and §10; §11 is a second pass over `MODULE_API.md`
itself. **R19R21 (2026-09-15) added a second modding framework, a Pterodactyl egg, and moved the
rigs off the workstation** — see [`CARBON.md`](CARBON.md) for the framework reference.
itself. **R19R22 (2026-09-15) added a second modding framework, a Pterodactyl egg, moved the rigs off
the workstation, and put the sidecar's configuration in the egg** — see [`CARBON.md`](CARBON.md) for
the framework reference and §14 for the rig as built.
The [dry run](../rust-dryrun.md) designed this module on paper and deliberately did not build it.
This is the document that builds it. Where the two disagree, this one is later and wins — but the dry
@@ -810,39 +810,83 @@ one. So the deployment loop is three tiers, matched to what each is for:
| **Working-tree iteration** — an uncommitted `.cs` under test | A **client** API key (`ptlc_…`): `files/write`, then `command` to reload | The Pterodactyl analogue of `servuo-plugins/deploy.ps1`, and it carries the same caveat: **if something only works when the push script copies it, it does not ship** |
| **Bulk or binary** — sidecar builds, world files | SFTP on the node, port 2022 | Where the client API's per-file write is the wrong shape |
**The client key now exists beside the application one** — the token file holds both, as
`application:` and `user:` lines — but at the time of writing it authenticates and then sees **no
servers**, so tier 2 is not yet usable. §3 has the diagnosis. Until it is, iteration is
reinstall-only: correct, and slow enough that nobody would choose it twice. The push script itself
**Both keys exist and both were exercised on 2026-09-15** — the token file holds them as
`application:` and `user:` lines, and §14 records the rig they built together. The push script itself
lives in **`Rust-Plugins`**, mirroring where `deploy.ps1` lives for ServUO.
### R22 — the sidecar is configured from the egg's variables, not from a file the operator edits
**Decided 2026-09-15 (org lead).** What normally lives in `sidecar.toml` moves into the Rust egg's
variables, so an operator on Pterodactyl configures the sidecar in the panel alongside the game's own
settings rather than opening a file manager to edit TOML. One configuration surface, in the place
they are already looking.
**This is nearly free, because the sidecar already does it.** `rust-link`'s `config.rs` documents its
precedence as *environment overrides file overrides defaults* and already reads all five keys from
the environment: `RUSTLINK_GAME_BIND`, `RUSTLINK_SERVER_ID`, `RUSTLINK_WEB_BIND`,
`RUSTLINK_WEB_TOKEN`, `RUSTLINK_DB_PATH` (plus `RUSTLINK_CONFIG` for the file's own path).
Pterodactyl exposes every egg variable to the container as an environment variable, so the mapping is
one-to-one and **no second configuration mechanism is introduced** — the file stays canonical, the
environment overrides it, the egg sets the environment, and the installer (R4) keeps writing the file
exactly as it does now.
Which gives the two halves of the shard side two different config surfaces, deliberately:
| | Configured from | Mechanism |
|---|---|---|
| The **plugin** | the website, Admin → the R18 config editor | D3: it reads `oxide/config/RunicGateway.json`, so it is inside R18 for free |
| The **sidecar** | the panel, as egg variables | R22: `RUSTLINK_*` in the container environment |
That split is right rather than merely convenient. The plugin is configured by the thing it talks to;
the sidecar is configured by the thing that starts it, and on a panel the operator has no shell.
**Three things the variable set has to get right**, each of which is a way to hand somebody a footgun:
- **`RUSTLINK_GAME_BIND` is not operator-editable.** D2 makes loopback the authentication on the game
link; a panel field that accepts `0.0.0.0:7799` is a web form that puts an unauthenticated command
channel on the network. It is set by the egg and marked neither viewable nor editable — the same
posture R18 takes toward the plugin's own `Host`/`Port`, for the same reason.
- **`RUSTLINK_WEB_BIND` is derived from an allocation, not typed.** It has to match the port the panel
actually handed out, exactly as the egg already derives `QUERY_PORT` and `RCON_PORT`. A free-text
bind is a bind that silently does not match the allocation, and the failure is the website never
connecting with nothing in any log to say why.
- **`RUSTLINK_DB_PATH` must point somewhere `REMOVE_FILES` never sweeps.** Already named in R20 and
restated here because this is the decision that makes the path an operator-visible field: the wipe
list and the database path become two settings on the same screen, and they must not be able to
agree.
**The token is the one place the ergonomics are not automatic.** Today the sidecar generates a token
when it finds none and persists it to its config file, which is what makes it secure out of the box;
`--print-config` is how an operator reads it back. A panel variable cannot be filled in by the
program that generates it, so the choices are: ship an empty default and let the sidecar generate and
persist as it does now, with the operator reading it out of the panel's file manager once; or make
the operator paste one in. The existing precedence already supports both — a set variable wins, an
empty one falls through to generation — so this is a default to choose when the egg is built, not a
mechanism to design. **Whichever is chosen, note that a Pterodactyl variable is visible to anyone
with panel access to that server and appears in the container environment**, which is a different
exposure from a `0600` file and should be stated in the operator guide rather than discovered.
**Lands in phase 18 with the rest of R20's egg.**
## 3. Open questions
**None — but one thing is unresolved on the panel.** R21's iteration loop needs a Pterodactyl
**client** API key, and one now exists (`ptlc_…`, alongside the application key, both in
`RunicGateway/pterodactyl_claude_api_token`). It authenticates correctly —
`/api/client/account` returns user 1 `whitlocktech`, `root_admin: true`, the owner of every server on
the node — **and then lists zero servers.** `/api/client` is `total: 0` for the default listing and
for `type=admin` and `type=admin-all` alike, and every `/api/client/servers/{id}/…` route 404s for
both the short identifier and the full UUID.
**None.** Every question this section carried was closed on 2026-09-15, and so was the one open
*request*: the token file now holds both keys, and **both were exercised end to end on 2026-09-15**
(§14).
That is a panel-side condition, not a token-shape problem, and it is **not blocking the plan** — it
blocks tier 2 of R21's deployment loop, leaving reinstall and SFTP working. Diagnosis so far, so that
whoever picks it up does not repeat it:
One correction belongs here rather than being quietly dropped, because the shape of the mistake is
the reusable part. This section briefly recorded that the client key "authenticates and then lists
zero servers", and built a diagnosis on top of it — including a claim that *includes are broken on
this panel*, because `/api/application/servers?include=user` returned an empty list where the same
route without the include had returned six.
- The key is **not** mis-scoped: `/api/client/account` and `/api/client/permissions` both answer 200.
- The account is **not** missing ownership: `/api/application/servers` lists six servers, and server 7
carries `"user": 1`.
- **Includes are broken on this panel independently**, which is worth knowing before trusting a
diagnosis built on them: `/api/application/servers?include=user` returns an *empty* list where the
same route without the include returns six, and `/api/application/users/1?include=servers` reports
zero servers owned. Neither is evidence of anything about ownership.
**Both claims were wrong, and they were wrong the same way.** The servers were being deleted while
the probing was happening, so two reads minutes apart were reads of two different worlds. The empty
client list was correct. The empty include was correct. Nothing was broken.
The remaining candidates are a stale panel cache and the key's allowed-IP field. Worth noting that
`gitea.whitlocktech.com` was returning 502 and Cloudflare 530 during the same window, so "something
is wrong with that host right now" is a live hypothesis rather than a coincidence to dismiss.
Everything else in this section was closed on 2026-09-15.
The lesson is not "check twice"; it is that **a differential diagnosis across two API calls silently
assumes the state did not move between them**, and on a live panel somebody else is also holding the
controls. Once a server existed, every one of those calls answered correctly on the first try.
*Clans in the base set while the Team provider reads first-party* was confirmed as the intended
reading: complementary, not in conflict — the plugin is installed for alliances and clan chat, the
@@ -952,7 +996,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
| 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included |
| 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework |
| 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework |
| 19 | **Docs, kit feedback, cutover.** `docs/`; **`.profile`** (three repos were added); **`runicgateway.com`** (a second game is a headline change, and Pterodactyl is a hosting claim the site can now make); the Integration-kit question R2 raised; and whether the kit owes a reader anything about **supporting two mod frameworks at once** (R19) — a shape it has no chapter for either | docs + Integration-kit + .profile + runicgateway.com | `docs/` describes what shipped, the front door names the new repos, and R2's missing chapter is answered either way |
### Why the lease comes before the reward action
@@ -1708,6 +1752,199 @@ permission gate, because every plugin's check short-circuits without a `BasePlay
account bypasses most of them non-uniformly. A second, non-admin Steam account has to be arranged
before phase 7 — it is the one prerequisite this rig cannot satisfy on its own.
## 14. The Pterodactyl rig as built, 2026-09-15
R21's first server exists, made with the application key and driven with the client key. **Both
credentials work; neither can do the other's job.** What follows is what building it actually taught,
including one finding that changes R20's shape.
### 14.0 The rig
| | |
|---|---|
| Panel | `http://192.168.0.12` (no TLS — `https://` fails outright), node 1 `Main` |
| Server | `rust-oxide`, id **17**, identifier **`e6758c06`** |
| Egg | 18 `Rust Autowipe`, `ghcr.io/pterodactyl/games:rust` |
| `FRAMEWORK` | `oxide` |
| Limits | 8192 MB memory, 25600 MB disk — deliberately under half the node, so the Carbon rig fits beside it |
| Allocations | 21000 game (default), 21001 query, 21002 RCON, 21003 Rust+, **21004 held for the sidecar's `[web].bind`** |
| World | procedural, size 3000, seed 1234 |
| SFTP | `192.168.0.12:2022` |
The RCON password is a generated 24-byte token rather than the old rig's `letmein`, kept out of this
document and out of the repo. §6 named that shape as the thing nothing published should copy; this is
the first rig where it was not copied.
**A Rust server install is about 6 GB, not the ~20 GB this plan assumed** when it worried about node
capacity — measured at 5,894 MB with the game installed and the world generating. Two rigs are
comfortable on a 128 GB node, and the 25600 MB limit is generous rather than tight.
### 14.1 The two keys, and what each one is actually for
Confirmed by use rather than by reading:
| | Application (`ptla_`) | Client (`ptlc_`) |
|---|---|---|
| Create / configure a server, assign allocations | **yes** | no |
| List, read, power, console, **files** | no (`403`) | **yes** |
| Write or import an egg | **no**`/api/application/eggs` 404s; eggs are an admin-UI or `php artisan` operation | no |
So the full loop needs both, and **a published egg is a JSON file a human imports** — which is also
exactly how an operator will consume ours, so it is a constraint worth designing into rather than
around.
**File operations are refused during install** with `409 ServerStateConflictException`
*"this server has not yet completed its installation process"*. Anything that pushes files has to
wait for `is_installing: false`, not merely for the server to exist.
### 14.2 The correction: there was never a panel bug
An earlier pass through this section recorded that the client key "authenticates and then lists zero
servers", and reasoned from there to a second claim — that *includes are broken on this panel*,
because `/api/application/servers?include=user` returned an empty list where the same route without
the include had returned six.
**Both were wrong, and wrong the same way.** The servers were being deleted while the probing
happened, so two calls minutes apart read two different worlds. Once a server existed, every one of
those calls answered correctly on the first attempt — the client list, the single-server route, and
`include=user`.
The reusable part is not "check twice". It is that **a differential diagnosis across two API calls
silently assumes the state did not move between them**, and on a live panel somebody else is also
holding the controls.
### 14.2b The upload loop, proven with the real plugin
Not a hello-world: phase 1's actual `RunicGateway.cs` (27,642 bytes, 709 lines) was pushed straight
from the working tree with the client key, and it came back byte-identical on read.
Three things that worked and were not certain to:
- **`files/write` creates missing parents.** `/oxide/plugins/` did not exist — the framework is laid
down at boot (§14.3), and the server had never been started — and the write created the whole path.
- **A plugin placed before Oxide exists survives Oxide arriving.** The entrypoint's `unzip -o` over
`oxide/` left the file untouched, so the push does not have to wait for a first boot.
- **It compiled and loaded on Linux**, which no previous phase had ever established. Every prior
finding came from Windows and Mono:
```
02:19 [Info] RunicGateway was compiled successfully in 0ms
02:19 [Info] [Runic Gateway] protocol 1, serverId 'main', sidecar 127.0.0.1:7799
02:19 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
02:19 [Info] [Runic Gateway] cannot reach the sidecar: Connection refused - retrying quietly
```
That last line is phase 1's no-stall contract holding on a second platform: no sidecar exists on
this host yet, the plugin says so once and keeps the game running.
**Read the console without a websocket.** Pterodactyl streams console over a websocket, which is
awkward to drive from a script — but `wrapper.js` also writes `latest.log`, and Oxide writes
`oxide/logs/oxide_<date>.txt`. Both are plain reads through `files/contents`, which is how every log
line quoted in this section was obtained. Worth knowing before anyone writes a websocket client.
### 14.2c The tier-2 loop, end to end
R21's middle tier is the one that has to be pleasant to use, so it was run rather than described.
One pass: patch the working-tree source so the change is visible in the game console, push, reload
through the client API's `command` endpoint, read Oxide's log back, then restore.
```
patched source: True
push -> HTTP 204
oxide.reload -> HTTP 204
02:27 [Info] RunicGateway was compiled successfully in 3392ms
02:27 [Info] Unloaded plugin Runic Gateway v0.1.0 by RunicGateway
02:27 [Info] [Runic Gateway] protocol 1 [PTERODACTYL-PUSH-PROOF], serverId 'main', sidecar 127.0.0.1:7799
02:27 [Info] Loaded plugin Runic Gateway v0.1.0 by RunicGateway
restored source and re-pushed -> 204
```
**Roughly ten seconds from a saved edit to a reloaded plugin**, against a running server with a
generated world, without touching the panel UI. That is the loop `deploy.ps1` gives us for ServUO,
and it is the thing that makes the panel a workable rig rather than only a deployment target.
Four details worth carrying into the push script:
- **Reload is `POST /command`, not a file operation**, and it answers `204` whether or not the plugin
actually came back. The proof has to be read out of `oxide/logs/` afterwards — the same shape R18's
auto-rollback needs, and an early rehearsal of it.
- **The unload/load pair straddles the plugin's own `Init` log line.** `Unloaded` is printed, then the
new instance's startup line, then `Loaded`. A script that waits for `Loaded` before reading has
already passed the line it wanted.
- **Oxide's compiler idles out and restarts.** The boot compile was `0ms`; the reload compile was
`3392ms` because `Shutting down compiler because idle shutdown` had happened in between. A timeout
tuned against a warm compiler will be wrong on the first reload after a quiet period.
- **Restore the working tree and re-push it.** A test that leaves a marker in the source is a test
that ships a marker. Both were put back and verified byte-identical against the server copy.
### 14.3 The image installs the framework on **every boot**, and neither version is pinnable
`ghcr.io/pterodactyl/games:rust`'s entrypoint is where `FRAMEWORK` is consumed — **not** the egg's
install script, which knows nothing about it. On every single start, before the game runs, it:
- runs `steamcmd +app_update 258550` unless `AUTO_UPDATE=0`;
- for `carbon`, downloads
`CarbonCommunity/Carbon.Core/releases/download/**production_build**/Carbon.Linux.Release.tar.gz`;
- for `oxide`, downloads `OxideMod/Oxide.Rust/releases/**latest**/Oxide.Rust-linux.zip`.
**Both are moving targets, fetched fresh at every restart.** CARBON.md §8 predicted this for Carbon
from its rolling release tags; the egg makes it true of *Oxide as well*, because `latest` is the same
kind of promise. The consequence is sharper than "the rig may drift":
> **A restart is a framework upgrade.** Two runs of the same test on the same server, minutes apart,
> are not guaranteed to be running the same framework build — and nothing in the panel says so.
That reaches three places. **R4's `doctor`**: the weaker "current enough" claim is not Carbon-specific
after all; under the egg neither framework has a pinned version to check. **§6's wipe-cadence risk**:
the re-verify step is per *restart*, not per wipe. And **R20 itself**: if the egg is our deliverable,
whether it should pin the framework at all is a decision, not an oversight — the upstream egg's
answer is "always newest", which is right for an operator on wipe day and wrong for a test rig
trying to reproduce a finding.
### 14.4 **The trap that changes R20: the startup string is not a safe place to launch the sidecar**
R20 says the startup command becomes "a small wrapper that launches `rust-link-sidecar` and then
`RustDedicated`". The mechanism allows it and the ordering makes it wrong.
`wrapper.js` runs the startup string through `child_process.exec`, which is `/bin/sh -c` — so
`./rust-link-sidecar & ./RustDedicated …` is syntactically fine. **But for Carbon the entrypoint
prepends to the whole string:**
```bash
MODIFIED_STARTUP="LD_PRELOAD=$(pwd)/libdoorstop.so ${MODIFIED_STARTUP}"
```
So a startup beginning with our sidecar becomes:
```bash
LD_PRELOAD=…/libdoorstop.so ./rust-link-sidecar & ./RustDedicated …
```
**The preload lands on the sidecar and not on the game.** Carbon loads through Doorstop rather than
through a patched `Assembly-CSharp.dll`, so the result is a server that starts cleanly, reports no
error, and **is not modded** — no plugins, no hooks, and a bridge that connects to a game it can
never hear from. It is the exact silent-success failure §6 keeps cataloguing, and it would only ever
appear on the Carbon half.
Two further consequences of the same handoff:
- **`quit` SIGTERMs the shell, not the sidecar.** `wrapper.js` kills `gameProcess`, which is the `sh`
running the startup string; a backgrounded sidecar is not its child in the way that reaches. R20
already required "stop means stop the game" — this is the mechanism by which it would fail, and it
leaves an orphan holding port 21004 against the next start.
- **Doorstop also confirms R21's clean-install rule from a second direction.** Switching `FRAMEWORK`
on an existing install does not undo the other framework: Oxide's patched DLL stays on disk while
Carbon preloads over it. The migration argument was the soft reason for a fresh Carbon rig; this is
the hard one.
**So R20 needs a decision it did not know it needed:** the sidecar is launched by something other
than the startup string — our own image or entrypoint layered on the upstream one — or the startup
string is composed so that whatever the entrypoint prepends still lands on `RustDedicated`. The first
is more work and survives upstream changing its entrypoint; the second is free and depends on a line
in somebody else's repository. Raised rather than settled.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust