docs(modules): phase 8 as built, and the rig that put the sidecar where the design says it lives #261
@@ -1429,6 +1429,88 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||||
and C (phases 8 and 11), and the map is leg D.
|
and C (phases 8 and 11), and the map is leg D.
|
||||||
|
|
||||||
|
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||||
|
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||||
|
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||||
|
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||||
|
built and its three decisions. The contract is normative there; this entry records what the app
|
||||||
|
does about it.
|
||||||
|
|
||||||
|
#### It is `CharactersScreen` for a different game, deliberately
|
||||||
|
|
||||||
|
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||||
|
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||||
|
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||||
|
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||||
|
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||||
|
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||||
|
module's data would be hard-wired into a core screen).
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||||
|
|
||||||
|
#### The gate is `rust`, and `PLAYER` means staff too
|
||||||
|
|
||||||
|
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||||
|
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||||
|
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||||
|
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game as well.
|
||||||
|
|
||||||
|
#### Two reads, and neither blocks the other
|
||||||
|
|
||||||
|
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||||
|
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||||
|
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||||
|
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||||
|
|
||||||
|
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||||
|
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||||
|
implementation of that rule is a second thing to keep true.
|
||||||
|
|
||||||
|
#### The refusals stay four pieces of advice
|
||||||
|
|
||||||
|
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||||
|
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||||
|
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||||
|
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||||
|
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||||
|
told otherwise goes back to the same unreachable server for another one.
|
||||||
|
|
||||||
|
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||||
|
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||||
|
app has one string for the status, written to be true of all three.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||||
|
where the backend was talking to a real game server rather than a stand-in.
|
||||||
|
|
||||||
|
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||||
|
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||||
|
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||||
|
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||||
|
|
||||||
|
#### The walk found one thing the suite did not
|
||||||
|
|
||||||
|
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||||
|
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||||
|
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||||
|
conversation starts, and the module's own schema says so in a comment.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||||
|
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||||
|
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||||
|
you read what you hold, not where you decide what somebody else holds.
|
||||||
|
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||||
|
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||||
|
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||||
|
somewhere to land.
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||||
|
|||||||
@@ -986,7 +986,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
||||||
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
|
||||||
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
| 7b | **Mod configuration from the site** (R18). ✅ **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
|
||||||
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
|
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | A player links from the app |
|
||||||
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
|
||||||
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
|
||||||
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
||||||
@@ -3206,6 +3206,107 @@ down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
|
|||||||
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
|
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
|
||||||
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
|
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
|
||||||
|
|
||||||
|
## 22. Phase 8 as built — the player's own half, on two screens, 2026-09-22
|
||||||
|
|
||||||
|
**Walked end to end against a live rig, and the rig is why this phase reads differently from the
|
||||||
|
three before it.** The sidecar now runs inside the game container, so for the first time since phase
|
||||||
|
5 the website talked to a real plugin on a real Oxide server rather than to a stand-in — including
|
||||||
|
the permission push, which landed a group membership in Oxide's own store and reported
|
||||||
|
`unresolved` for a name nothing had registered.
|
||||||
|
|
||||||
|
Two repositories and this document: [`Module-Rust#10`][mr] (the website half, into `edge`) and
|
||||||
|
[`Android-app#48`][aa] (M15, into `edge`). Core needed no change — the third Rust phase running.
|
||||||
|
|
||||||
|
### 22.0 The three decisions this phase needed
|
||||||
|
|
||||||
|
| | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D39** | **The app's permission half is player-facing and read-only, and the website grows the route for it.** Phase 7's whole surface is `requireRole('admin')`, and the app has no admin user-detail screen to port it into; porting the authoring page to a phone was considered and rejected (it writes into a running game from a device that is easy to mis-tap, and M14 already excluded the Rust admin surface as configuration the app consumes rather than edits). So the phase adds one self-scoped read instead — and renders it on the **website** as well, so the app never has a surface the website lacks. |
|
||||||
|
| **D40** | **It mirrors `module-uo`'s player surface, not a shape of its own.** One drawer row under the player group, with the code card at the top of the screen it leads to — exactly `CharactersScreen`. A tab under one server was rejected because a link is fleet-wide, and a section inside core's own Account screen was rejected because the app has no slot mechanism and the module's data would be hard-wired into a core screen. |
|
||||||
|
| **D41** | **The row hangs on `rust`, matching `module-uo`'s single `shard`.** The module declares a surface word per feature and `identity` is one of them, but D16's rule stands: a capability answers *is the module there*, and core flattens every module's capabilities into one list. Requiring `rust` **and** `identity` was considered — it would refuse the screen on a build predating phase 6 — and rejected as a second meaning for a word that has one. |
|
||||||
|
|
||||||
|
### 22.1 What the player is told, and what they are not
|
||||||
|
|
||||||
|
`GET /player/rust/permissions` is a different shape from the admin read rather than a filtered one,
|
||||||
|
and the three differences are the phase:
|
||||||
|
|
||||||
|
1. **The scope is resolved on the server.** A client handed `scope: "*"` would have to know what the
|
||||||
|
fleet is to say anything, and then `inScope` exists twice. Each entry arrives carrying the
|
||||||
|
servers it reaches, each already marked.
|
||||||
|
2. **`live` is the pushed ledger, never the authored row.** Phase 7 is careful never to record a
|
||||||
|
push that silently did nothing, so *waiting* here means waiting — and the alternative is the site
|
||||||
|
claiming to have given something it has not.
|
||||||
|
3. **Nothing says why it is waiting.** An offline server, a permission no loaded plugin registered
|
||||||
|
and a store that has never seen the account are one state on this screen. Telling them apart is
|
||||||
|
an operator's diagnosis and an inventory of what is installed on a host.
|
||||||
|
|
||||||
|
**An entitlement that reaches nobody still lists**, and both surfaces say so: authored against the
|
||||||
|
website account, it exists before a Steam id does. Hiding it until one turns up is the defect the
|
||||||
|
admin user page shipped in phase 7 (§20.5) — the same mistake, one tier along, caught before it
|
||||||
|
shipped this time because the rule was already written down.
|
||||||
|
|
||||||
|
**The honest limit, named rather than designed around:** a *rank* can be live while every permission
|
||||||
|
it carries resolves nowhere. The rig proved it — `vip` was created in Oxide's store and the
|
||||||
|
membership landed, while `kits.vip` came back `unresolved` because Kits is not installed there. The
|
||||||
|
player is told the rank reached the game, which is true and is what the site gave them; whether a
|
||||||
|
server's plugins understand the names inside it is on the admin screen, where the warning already
|
||||||
|
is.
|
||||||
|
|
||||||
|
### 22.2 The refusals, on a phone
|
||||||
|
|
||||||
|
The app's convention since M1 is that a refusal is chosen by **status** and rendered from a string
|
||||||
|
resource — the website's sentence is never displayed, because the app is localized and the website
|
||||||
|
is not. That convention holds here and it costs something worth writing down: the module
|
||||||
|
distinguishes *three* 503s (a server it could not reach, a fleet that is all down, a site with no
|
||||||
|
servers configured at all) by sentence, and the app has one string for the status. It is written to
|
||||||
|
be true of all three, and it does **not** say "get a new code" — a player told that would go back to
|
||||||
|
the same unreachable server for another one.
|
||||||
|
|
||||||
|
The four that do differ by status keep four different pieces of advice: 400 a spent code, 409 a
|
||||||
|
Steam account another website account holds, 429 the limiter, 503 a server that could not be
|
||||||
|
reached.
|
||||||
|
|
||||||
|
### 22.3 What the walk proved, and on what
|
||||||
|
|
||||||
|
- **The website half** in a browser, against the live rig: the section rendering with zero linked
|
||||||
|
accounts, a rank marked *has it* and a grant marked *waiting* on the same screen, the note that
|
||||||
|
explains why, and the refusal path.
|
||||||
|
- **The app half** on an emulator against the same core: the row absent when signed out and absent
|
||||||
|
on a UO site, present for a signed-in player; both reads; a refused code rendering **beside the
|
||||||
|
button**; the marks; and the release.
|
||||||
|
- **The push itself against a real Oxide store**, which is new. `perm.sync` created the group,
|
||||||
|
applied the membership, and reported `unresolved: ["kits.vip", "zonemanager.admin"]` — the second
|
||||||
|
of those a genuine finding about the rig rather than the code: ZoneManager registers
|
||||||
|
`zonemanager.ignoreflag.*` and friends, and **not** `zonemanager.admin`. A permission name that
|
||||||
|
looks obvious is still a name some plugin has to have registered.
|
||||||
|
|
||||||
|
**The acceptance line needs a person in game.** Everything above used a link row written directly
|
||||||
|
into the rig's database, because a `/link` code reaches a player and nobody else. The three minutes
|
||||||
|
that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
|
||||||
|
|
||||||
|
### 22.4 Smaller things worth keeping
|
||||||
|
|
||||||
|
- **`MenuAccess.PLAYER` is `isPlayer || isStaff`**, and that is right here: `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game too. The app's own gating test now
|
||||||
|
asks *which* module a row on a module path declares, not merely whether it declares one — a
|
||||||
|
second game under `player/` is a new way for that test to have been passing for the wrong reason.
|
||||||
|
- **The website's pill carries the word, not only the dot.** A filled circle beside a hollow one is
|
||||||
|
the whole difference between "you have this in game" and "you do not yet". Found by looking at it.
|
||||||
|
- **The app's row now says when and where an account was linked**, which the website's always did.
|
||||||
|
Which server minted the code is not part of the identity — a link is fleet-wide — but it is where
|
||||||
|
a support conversation starts.
|
||||||
|
- **`/player/rust` has no app deep link**, deliberately: `module-uo`'s player screens have none
|
||||||
|
either, and the app's web-path table is the *public* nav's. Phase 10 is where it will matter, when
|
||||||
|
a notification about an entitlement wants somewhere to land.
|
||||||
|
- **A rig timestamp written by a different connection is five hours out.** The `DATETIME` columns
|
||||||
|
here are naive, and they round-trip correctly only through the connection that wrote them —
|
||||||
|
inserting a stand-in row with `docker exec mariadb` made the site read it as the future. Not a
|
||||||
|
product defect; a rig recipe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||||
|
|||||||
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||||
|
|
||||||
|
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||||
|
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||||
|
|
||||||
|
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||||
|
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||||
|
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||||
|
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||||
|
|
||||||
|
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||||
|
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||||
|
each other with nothing configured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What goes on the volume
|
||||||
|
|
||||||
|
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||||
|
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||||
|
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||||
|
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||||
|
|
||||||
|
Building the binary needs no Rust toolchain on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||||
|
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||||
|
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||||
|
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The launcher
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
RL=/home/container/rust-link
|
||||||
|
mkdir -p "$RL"
|
||||||
|
|
||||||
|
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||||
|
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||||
|
export RUSTLINK_DB_PATH
|
||||||
|
|
||||||
|
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||||
|
eval "val=\${$v-}"
|
||||||
|
if [ -n "$val" ]; then export "$v"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||||
|
|
||||||
|
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||||
|
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||||
|
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||||
|
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||||
|
left unsettled, and this is the answer.
|
||||||
|
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||||
|
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||||
|
container. A `wait` here instead would leave the panel talking to a shell.
|
||||||
|
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||||
|
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## The startup command
|
||||||
|
|
||||||
|
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||||
|
the way egg variables will supply them in phase 18 (R22):
|
||||||
|
|
||||||
|
```
|
||||||
|
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||||
|
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||||
|
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||||
|
|
||||||
|
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||||
|
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||||
|
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||||
|
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||||
|
containing it.
|
||||||
|
|
||||||
|
## Wiring the website to it
|
||||||
|
|
||||||
|
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||||
|
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||||
|
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||||
|
`plugin_connected: true` and the protocol version.
|
||||||
|
|
||||||
|
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||||
|
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
## What it proved, first time
|
||||||
|
|
||||||
|
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||||
|
|
||||||
|
```
|
||||||
|
web server listening addr=0.0.0.0:21004
|
||||||
|
plugin connected peer=127.0.0.1:51148
|
||||||
|
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||||
|
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||||
|
answered `plugin_connected: true`.
|
||||||
|
|
||||||
|
## Still open for phase 18
|
||||||
|
|
||||||
|
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||||
|
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||||
|
what R22's variable block is for.
|
||||||
|
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||||
|
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||||
|
node, so this is a walk to run, not a claim to repeat.
|
||||||
|
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||||
|
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||||
|
of this, and still the reason a rig can differ from itself between two runs.
|
||||||
@@ -185,8 +185,13 @@ live Oxide rig**, where `rg.config` answers
|
|||||||
|
|
||||||
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
|
||||||
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
in the running game.** It needs the sidecar and the game server on **one host**, because the game
|
||||||
link is loopback by design (D2) — on the Pterodactyl rigs that is phase 18's egg, and on a
|
link is loopback by design (D2).
|
||||||
development machine it is a firewall rule for the port the plugin dials.
|
|
||||||
|
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||||
|
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||||
|
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||||
|
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||||
|
other, which is the whole point of putting them in one container.
|
||||||
|
|
||||||
| # | Do this | You should see |
|
| # | Do this | You should see |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -215,3 +220,23 @@ this phase has two specific things to confirm rather than assume:
|
|||||||
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
**What counts as a pass:** a setting typed on the website changes what the running game does; a
|
||||||
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
|
||||||
the save did not touch differs by a single byte.
|
the save did not touch differs by a single byte.
|
||||||
|
|
||||||
|
## The account walk on a phone (phase 8, Android leg B)
|
||||||
|
|
||||||
|
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||||
|
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||||
|
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||||
|
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||||
|
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||||
|
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||||
|
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||||
|
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||||
|
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||||
|
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||||
|
|
||||||
|
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||||
|
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||||
|
|||||||
Reference in New Issue
Block a user