diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 88878eb..5443f6f 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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. **R19–R21 (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. **R19–R22 (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_.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