Compare commits
1 Commits
main
...
docs/insta
| Author | SHA1 | Date | |
|---|---|---|---|
| d0363cd62d |
@@ -14,16 +14,11 @@ installer/ docs for the installer that deploys a shard's bridge components
|
||||
ci/ cross-cutting CI/quality notes
|
||||
```
|
||||
|
||||
**Setting up a shard?** [`installer/INSTALL.md`](installer/INSTALL.md) is the operator guide, and
|
||||
the installer is the supported path: one binary deploys the plugin overlay, installs the uo-link
|
||||
sidecar as a service, and hands you the values the website needs.
|
||||
|
||||
### `website/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
|
||||
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
|
||||
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
|
||||
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
|
||||
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
|
||||
| [SPAWN_ATLAS.md](website/SPAWN_ATLAS.md) | The bestiary / spawn atlas: what the shard contains, parsed from its own ServUO tree |
|
||||
@@ -59,7 +54,7 @@ sidecar as a service, and hands you the values the website needs.
|
||||
### `installer/`
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [INSTALL.md](installer/INSTALL.md) | **Start here to set up a shard** — the installer deploys the plugin overlay and the uo-link sidecar, registers the service, and connects it to the website. Appendix A is the same thing by hand, still supported |
|
||||
| [INSTALL.md](installer/INSTALL.md) | **Operator guide** — installing Runic Gateway on a ServUO shard, connecting it to the website, and diagnosing it. Includes the by-hand path, which works today |
|
||||
| [PLAN.md](installer/PLAN.md) | Installer design of record — phases, locked decisions, the bundle/compat-matrix model |
|
||||
|
||||
## Provenance
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
Operator guide for the **Runic Gateway installer** — the tool that takes a working ServUO
|
||||
installation and connects it to a Runic Gateway website.
|
||||
|
||||
> **The installer is the supported way to set this up.** Download one binary, run `install`, paste
|
||||
> four values into your website. It deploys the plugin overlay, installs the uo-link sidecar and
|
||||
> registers it as a service, and gives you [`doctor`, `update` and `uninstall`](#7-day-two)
|
||||
> afterwards. Start at [§1](#1-download-and-verify).
|
||||
> **Status: the installer binary is not released yet.**
|
||||
>
|
||||
> [Appendix A](#appendix-a--installing-by-hand) is the same deployment done by hand. It is
|
||||
> **supported, not deprecated** — use it on a host that cannot run the binary, when you want to
|
||||
> place things yourself, or when you are developing on the bridge and installing from a working
|
||||
> tree rather than a release. It is also the reference for what the installer does under the hood.
|
||||
> Everything it installs *is* released and published — the sidecar, the plugin overlay, and the
|
||||
> [bundle manifest](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json)
|
||||
> that names the checked combination of the two. This guide is the operator-facing contract those
|
||||
> phases build to, and it is written first on purpose: it is the specification of what the run
|
||||
> looks like, what it asks, where it writes, and what it prints.
|
||||
>
|
||||
> **You can install today without it** — [Appendix A](#appendix-a--installing-by-hand) is the same
|
||||
> deployment done by hand, with the commands verified against the current releases. When the binary
|
||||
> ships, Appendix A stays as the reference for what it does under the hood.
|
||||
>
|
||||
> Design of record: [PLAN.md](PLAN.md).
|
||||
|
||||
@@ -25,7 +27,7 @@ Three things, on the machine that runs your shard:
|
||||
|---|---|---|
|
||||
| 1 | **The plugin overlay** — C# source that ServUO compiles at boot, copied into your server tree | [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) release tarball |
|
||||
| 2 | **The uo-link sidecar** — a small Rust service that the shard dials out to, and that your website reads from | [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) release binary |
|
||||
| 3 | **A record of what it did** — `install.json`, plus cached copies of the patches and of every file the patch tier edited | Written by the installer |
|
||||
| 3 | **A record of what it did** — `install.json`, plus a cached copy of any patches it applied | Written by the installer |
|
||||
|
||||
```
|
||||
ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
|
||||
@@ -51,7 +53,7 @@ Only the sidecar is exposed, and only to your website.
|
||||
| Requirement | Detail |
|
||||
|---|---|
|
||||
| A working ServUO install | It must currently boot and compile scripts cleanly. The installer deploys onto a healthy shard; it does not repair a broken one. |
|
||||
| ServUO **57.4** *(patch tier only)* | **57.4 is the only supported version.** The base install works on any reasonably current ServUO. The patch tier is written and tested against stock 57.4; on any other version it is **unsupported and untested** — you can still choose to run it, behind an explicit opt-in, and it applies only where the exact lines it patches are unchanged. See [§4](#4-the-patch-tier-optional). |
|
||||
| ServUO **57.4** *(patch tier only)* | The base install works on any reasonably current ServUO. The patch tier is verified against stock 57.4 only, and is skipped with a warning on anything else. |
|
||||
| ServUO **stopped** | `ServUO.exe` holds a lock on `Scripts.dll` and writes `Saves/` on exit. The installer refuses to deploy under a running shard. |
|
||||
| Administrator / root | It writes into system directories and registers a service. |
|
||||
| Outbound HTTPS | To `gitea.whitlocktech.com`, to fetch the bundle and the two artifacts. Nothing inbound is needed, and no Gitea account or git client is required. |
|
||||
@@ -73,16 +75,10 @@ Download the installer for your OS, plus `SHA256SUMS`, from the
|
||||
|
||||
```
|
||||
runicgateway-installer-linux-x86_64
|
||||
runicgateway-installer-linux-aarch64
|
||||
runicgateway-installer-windows-x86_64.exe
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
`linux-aarch64` is for arm64 hosts — Ampere/Graviton instances, Pi-class boxes. `uname -m` says
|
||||
`aarch64` on those and `x86_64` otherwise. There is no macOS build and no Windows-on-arm build: the
|
||||
shard dials the sidecar out on loopback, so the two have to share a host, and no ServUO host is
|
||||
either of those.
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
@@ -144,9 +140,8 @@ one file); `doctor`, `update` and `uninstall` are run from it later. Examples be
|
||||
1. **Your ServUO root** — detected if the installer is run from inside it or from an obvious
|
||||
sibling, otherwise prompted. A directory qualifies only if it contains `ServUO.exe`, `Scripts/`
|
||||
and `Config/`.
|
||||
2. **Whether to apply the patch tier** — off unless you say yes. On a ServUO that is not 57.4 the
|
||||
prompt defaults to **no** and carries an unsupported-version warning you have to answer past.
|
||||
See [§4](#4-the-patch-tier-optional).
|
||||
2. **Whether to apply the patch tier** — off unless you say yes, and not offered at all if your
|
||||
ServUO is not 57.4. See [§4](#4-the-patch-tier-optional).
|
||||
3. **The hostname your website should use to reach this machine** — used only to compose the two
|
||||
URLs it prints at the end. The sidecar's bind address is frequently `127.0.0.1` or `0.0.0.0`,
|
||||
neither of which is something to hand to a website.
|
||||
@@ -170,19 +165,16 @@ Overlay sync
|
||||
ADD Config/Bridge.cfg
|
||||
ADD Scripts/Custom/Bridge/*.cs (22 files)
|
||||
CHANGE Scripts/Scripts.csproj
|
||||
deployed. add=23 change=1 unchanged=0 kept=0
|
||||
deployed. add=23 change=1 unchanged=0
|
||||
|
||||
Patch tier not selected
|
||||
Patch tier skipped (not selected)
|
||||
Without it: no vendor.sale events, no in-game moderation audit forwarding.
|
||||
|
||||
uo-link sidecar
|
||||
binary /usr/bin/runicgateway-link install
|
||||
✓ sidecar binary verified sha256 27d491ef…
|
||||
config /etc/runicgateway/sidecar.toml created
|
||||
database /var/lib/runicgateway/uo-link.db
|
||||
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
|
||||
service runicgateway-link.service active, enabled
|
||||
running as runicgateway
|
||||
uo-link
|
||||
binary /usr/bin/runicgateway-link
|
||||
config /etc/runicgateway/sidecar.toml (created)
|
||||
database /var/lib/runicgateway/uo-link.db
|
||||
service runicgateway-link.service enabled, running
|
||||
|
||||
Recorded /etc/runicgateway/install.json
|
||||
|
||||
@@ -209,18 +201,11 @@ reports "unchanged" and writes nothing.
|
||||
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write nothing. |
|
||||
| `--servuo <path>` | `install`, `doctor`, `update` | Name the ServUO root instead of detecting or prompting. |
|
||||
| `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
|
||||
| `--patches` / `--no-patches` | `install`, `update` | Decide the patch tier non-interactively. `--patches` never loosens the region check: patches whose target lines are not stock are reported for you to apply by hand, not forced. On `update` it is what takes up a feature the shard does not already have. |
|
||||
| `--patches-unsupported-servuo` | `install` | Required *in addition to* `--patches` to run the patch tier on a ServUO that is not 57.4. Unsupported and untested — see [§4](#4-the-patch-tier-optional). Ignored on 57.4. |
|
||||
| `--patches` / `--no-patches` | `install` | Decide the patch tier non-interactively. `--patches` still refuses on a non-57.4 tree. |
|
||||
| `--host <name>` | `install` | The hostname to print in the website URLs. |
|
||||
| `--site-url <url>` | `install` | Your site's base URL, for the Admin → Shard link. |
|
||||
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. **On `uninstall` it means yes** — that prompt defaults to no, and typing `uninstall --yes` is not an accident. |
|
||||
| `--no-backup` | `install`, `update` | Do not copy the files this run is about to overwrite. They are otherwise saved under the state directory — see [§7](#7-day-two). |
|
||||
| `--purge` | `uninstall` | Also delete `sidecar.toml`, `uo-link.db`, the cached patch set and every backup, all of which are otherwise kept. |
|
||||
|
||||
Exit codes are `0` success, `1` the run failed, `2` the arguments were unusable. Two commands also
|
||||
use `1` for a run that *completed* and found something wrong, so they can be read from a script:
|
||||
`doctor` when any check failed, and `uninstall` when a step could not be carried out (everything
|
||||
else still was).
|
||||
| `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. |
|
||||
| `--purge` | `uninstall` | Also delete `sidecar.toml` and `uo-link.db`, which are otherwise kept. |
|
||||
|
||||
---
|
||||
|
||||
@@ -233,9 +218,7 @@ else still was).
|
||||
| `/usr/bin/runicgateway-link` | The sidecar binary |
|
||||
| `/etc/runicgateway/sidecar.toml` | Sidecar config, including the auth token |
|
||||
| `/etc/runicgateway/install.json` | What the installer deployed: versions, commit, per-file hashes, applied patches, timestamps |
|
||||
| `/etc/runicgateway/patches/` | Copies of the patches the tier evaluated, so `uninstall` can print the exact hunks long after the release tarball is gone, and a refused one is still on hand to apply yourself |
|
||||
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
|
||||
| `/etc/runicgateway/backups/<timestamp>/` | Copies of the files a run replaced, with a `manifest.json` naming each. Newest three kept; skip with `--no-backup` |
|
||||
| `/etc/runicgateway/patches/` | Copies of any patches applied, so `uninstall` can print the exact hunks long after the release tarball is gone |
|
||||
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) |
|
||||
| `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
|
||||
|
||||
@@ -247,11 +230,8 @@ else still was).
|
||||
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
|
||||
| `%ProgramData%\RunicGateway\install.json` | As above |
|
||||
| `%ProgramData%\RunicGateway\patches\` | As above |
|
||||
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
|
||||
| `%ProgramData%\RunicGateway\backups\<timestamp>\` | As above |
|
||||
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
|
||||
| `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` | The service's log. A Windows service has no console to write to, so it logs here instead; rolled daily, seven kept. A foreground run still logs to stdout as usual |
|
||||
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink`. Needs a sidecar **v1.2.0 or newer** — see [Troubleshooting](#troubleshooting) on error 1053 |
|
||||
| Service `RunicGatewayLink` | Automatic start, restart on failure |
|
||||
|
||||
**Inside your ServUO tree** (added by the overlay sync — 24 files):
|
||||
|
||||
@@ -261,30 +241,11 @@ Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
|
||||
Scripts/Scripts.csproj OVERWRITES a stock file (see below)
|
||||
```
|
||||
|
||||
**Both service definitions pin the config path**, because the sidecar's own default is relative to
|
||||
its working directory — and a service manager's working directory is not somewhere you want a
|
||||
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under
|
||||
Both service definitions pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own
|
||||
defaults are relative to its working directory, and a service manager's working directory is not
|
||||
somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under
|
||||
`C:\Program Files\`, a silently redirected VirtualStore copy.
|
||||
|
||||
How the *database* path is pinned differs by platform, and that is deliberate:
|
||||
|
||||
| | Config | Database |
|
||||
|---|---|---|
|
||||
| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming |
|
||||
| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` |
|
||||
|
||||
The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no
|
||||
per-service one — which every process on the host inherits and which outlives an uninstall.
|
||||
|
||||
**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows
|
||||
gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of
|
||||
registering the service and which has no password. Neither runs as root or `LocalSystem`.
|
||||
|
||||
**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location
|
||||
protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by
|
||||
inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service
|
||||
user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows.
|
||||
|
||||
> **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the
|
||||
> plugin would sit in the tree and never compile — and ServUO would not tell you, because it
|
||||
> ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
|
||||
@@ -311,87 +272,15 @@ How the installer handles it:
|
||||
|
||||
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
|
||||
degraded one.
|
||||
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per
|
||||
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming.
|
||||
- **A modified file is not automatically a refusal.** These patches touch three small regions of
|
||||
three large files. If you have edited `Logging.cs` somewhere else entirely, the installer says so
|
||||
and still applies the patch — it checks whether *the lines the patch edits* are still stock, not
|
||||
whether the whole file is. It applies only where the surrounding lines match the patch exactly and
|
||||
appear exactly once; anything less and it stops and hands you the hunk to apply by hand. It never
|
||||
force-fits a patch by loosening the match.
|
||||
- **Dry-run first, always.** Every patch is checked (`git apply --check`) before anything is
|
||||
applied, and reported per patch. Most real shards are hand-modified; a patch that does not apply
|
||||
is expected, not alarming.
|
||||
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together
|
||||
or not at all — and within a patch, if one hunk cannot be placed safely, none are. A patch that
|
||||
could have been placed but was held back by its sibling says exactly that; it is never reported as
|
||||
applied.
|
||||
- **It does not need `git`, and does not use it.** The matching and the writing are the installer's
|
||||
own, which is why it can place a patch on a shard where `git apply` refuses — the shipped patches
|
||||
and their target files do not all use the same line endings, and that alone defeats `git apply`.
|
||||
Nothing outside a patched region is touched, down to the byte, and inserted lines take your file's
|
||||
own line ending.
|
||||
- **Your ServUO version is reported, not decisive** — but see the warning below before running this
|
||||
on anything other than 57.4.
|
||||
or not at all.
|
||||
- **Skipped entirely on a ServUO that is not 57.4**, with a warning. Unverified diffs are never
|
||||
applied to an unknown tree.
|
||||
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print
|
||||
the exact hunks to revert — along with how each was applied, since a patch placed into a file you
|
||||
had already modified is one to look at more carefully when reverting. Patches that were *not*
|
||||
applied are cached too, because that is the copy the run tells you to apply by hand.
|
||||
- **A copy of every file it edits is kept, exactly as it was beforehand**, under
|
||||
`patches/originals/` in the installer's own directory — not in your ServUO tree. It is written
|
||||
before the first edit and never overwritten, so however many times you re-run `install`, it stays
|
||||
the version from before the tier ever touched the file. That is what lets you verify a revert
|
||||
rather than reconstruct one.
|
||||
- **Re-running is safe.** A patch already in place is recognised and left alone, and the record
|
||||
keeps the way it originally landed rather than relabelling it.
|
||||
|
||||
### ⚠ On any ServUO that is not 57.4: unsupported, untested, no guarantees
|
||||
|
||||
> **Runic Gateway is designed, built and tested against stock ServUO 57.4.** That is the only
|
||||
> supported version.
|
||||
>
|
||||
> On any other version — a newer release, an older one, or a fork — the patch tier is
|
||||
> **UNSUPPORTED, UNTESTED, and NOT GUARANTEED TO WORK.** You may run it. If you do, you are on your
|
||||
> own: it is not covered by support, and a bad outcome may not show up until your shard is live,
|
||||
> because ServUO's script build reports success even when it failed and quietly keeps running the
|
||||
> previous `Scripts.dll`.
|
||||
>
|
||||
> The installer will still refuse to place a patch anywhere the exact lines it edits have changed —
|
||||
> but matching text is not the same as matching behaviour. A hunk can land correctly and still be
|
||||
> wrong for a tree that has diverged around it.
|
||||
>
|
||||
> **Back up your ServUO tree first, and verify your shard boots and compiles afterwards.**
|
||||
|
||||
Because of that, on a non-57.4 tree the tier is off by default and takes a deliberate yes:
|
||||
|
||||
- the interactive prompt defaults to **no** and prints the warning above;
|
||||
- `--patches` on its own is **not** enough — an unattended run must also pass
|
||||
`--patches-unsupported-servuo`;
|
||||
- the choice is recorded, and `doctor` keeps showing an unsupported-version row for the life of the
|
||||
install — so whoever looks after this shard next can see it without being told.
|
||||
|
||||
A run where the tier is selected on a shard that has been worked on looks like this:
|
||||
|
||||
```
|
||||
Patch tier 2 of 3 applied
|
||||
✓ playervendor-sale-eventsink Server/EventSink.cs
|
||||
stock file — applied at line 171, 1521, 1771, 2416
|
||||
✓ playervendor-sale-gump Scripts/Gumps/PlayerVendorGumps.cs
|
||||
file modified, patched region stock — applied at line 95
|
||||
✗ commandlogging-event Scripts/Commands/Logging.cs
|
||||
patched region has been modified (hunk 1) — not applied
|
||||
apply this by hand, then re-run install to record it:
|
||||
/etc/runicgateway/patches/commandlogging-event.patch
|
||||
|
||||
⚠ Server/EventSink.cs — a CORE ServUO file was patched. Rebuild the solution:
|
||||
dotnet build ServUO.sln
|
||||
A shard restart is not enough; ServUO's dynamic script build does not rebuild the core, and it
|
||||
will not tell you so.
|
||||
|
||||
Not applied, so you do not get: no in-game moderation audit forwarding.
|
||||
Everything else works. Apply the hunks by hand if you want them, then re-run install to record it.
|
||||
```
|
||||
|
||||
The line numbers are where each hunk was actually found in *your* file, not where it sits in stock
|
||||
ServUO — they differ as soon as anything above the region has been edited, and yours is the one to
|
||||
go to.
|
||||
the exact hunks to revert.
|
||||
|
||||
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game
|
||||
moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
|
||||
@@ -436,20 +325,68 @@ Saving restarts the site's ingest client, so the change takes effect immediately
|
||||
AES-GCM encrypted at rest and **never returned to any client** — losing it means reading it back
|
||||
from `sidecar.toml` on the shard host, not from the website.
|
||||
|
||||
If the sidecar sits behind a reverse proxy, paste your **public** `https://` and `wss://` URLs
|
||||
instead of the two the installer printed — it composes those from the sidecar's own bind address,
|
||||
which knows nothing about what fronts it. Everything else on the page is unchanged.
|
||||
|
||||
### If your website is on a different machine
|
||||
|
||||
The sidecar binds `127.0.0.1:8080` by default, which is reachable only from the shard host. If your
|
||||
website runs elsewhere, you must widen the bind — and then narrow the access:
|
||||
website runs elsewhere, the recommended arrangement is a **TLS reverse proxy in front of the
|
||||
sidecar** — this is a supported deployment and the one Runic Gateway itself runs, on a real domain
|
||||
name.
|
||||
|
||||
1. Set `[web] bind` in `sidecar.toml` to `0.0.0.0:8080` (or a specific LAN address) and restart the
|
||||
service.
|
||||
2. **Firewall port 8080 to your website's address only.** The auth token is always on, but it
|
||||
travels as a plain bearer token — the sidecar speaks HTTP, not HTTPS.
|
||||
3. If the two hosts are not on a trusted network, put the sidecar behind a TLS reverse proxy or a
|
||||
VPN/WireGuard link, and give the website the proxied `https://` / `wss://` URLs.
|
||||
**Leave `[web] bind` on `127.0.0.1:8080`** and let the proxy be the only thing that talks to it.
|
||||
Widening the bind and firewalling the port is the alternative, not the default (see below).
|
||||
|
||||
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts
|
||||
*inbound commands* to the game, and being loopback-only is what makes that safe.
|
||||
Give the website the **proxied** URLs — `https://link.example.com` and
|
||||
`wss://link.example.com/ws` — in place of the `http://` / `ws://` pair the installer prints. Those
|
||||
values are the sidecar's own view of itself; the proxy is what the outside world sees.
|
||||
|
||||
What the proxy must do:
|
||||
|
||||
| Requirement | Why |
|
||||
|---|---|
|
||||
| **Forward the WebSocket upgrade** (`Upgrade` / `Connection` headers, HTTP/1.1 to the upstream) | `/ws` is the live event feed. Without it the site's REST calls work and events never arrive — a confusing half-working state. |
|
||||
| **Pass request headers through unmodified** | Auth is `Authorization: Bearer` (or `X-Api-Key`), and the website sends `X-UOLink-Version`. A proxy that strips unknown headers turns into a `401`, and a stripped version header just silently skips the mismatch check. |
|
||||
| **Do not buffer the WS connection, and allow long-lived ones** | The feed is idle between events. The sidecar sends a WebSocket **Ping every 30 s**, so a read timeout of 60 s or more is safe as it stands — but a proxy that buffers responses will hold events instead of streaming them. |
|
||||
| **Do not log query strings** | The sidecar also accepts `?token=…` (for clients that cannot set headers). If anything in your stack uses that form, a default access-log format writes your auth token to disk on every request. |
|
||||
|
||||
Nothing needs `X-Forwarded-For`: the sidecar never uses the client's IP for authorization, and the
|
||||
browser IP that account provisioning cares about is supplied by the website in the request body.
|
||||
|
||||
An nginx server block that satisfies all of the above:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name link.example.com;
|
||||
|
||||
# your certificate directives here
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade; # "upgrade" for WS, "" otherwise
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(with the usual `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` at `http`
|
||||
level). Caddy and Traefik handle WebSocket upgrades automatically and need no equivalent stanza.
|
||||
|
||||
**Without a proxy**, on a trusted network only: set `[web] bind` to `0.0.0.0:8080` or a specific LAN
|
||||
address, restart the service, and **firewall the port to your website's address**. The auth token is
|
||||
always required, but the sidecar speaks HTTP — on that path the token and every event cross the
|
||||
network in the clear. Do not do this over the public internet.
|
||||
|
||||
The `[shard] bind` line is a different matter entirely: leave it on `127.0.0.1:7788` and never proxy
|
||||
it. That socket accepts *inbound commands* to the game, and being loopback-only is what makes that
|
||||
safe.
|
||||
|
||||
---
|
||||
|
||||
@@ -509,43 +446,21 @@ The command that makes this supportable. Run it before asking anyone for help
|
||||
first thing a maintainer will want.
|
||||
|
||||
```
|
||||
✓ Install record /etc/runicgateway/install.json (bundle 2026.08.04, installer 1.0.0, …)
|
||||
✓ ServUO found /opt/ServUO (57.4)
|
||||
✓ Overlay in sync 24 files, all hashes match install.json
|
||||
⚠ Patch tier 1 applied — moderation-audit (region-match)
|
||||
✓ uo-link installed uo-link-sidecar 1.1.0 (protocol 3)
|
||||
config /etc/runicgateway/sidecar.toml database /var/lib/runicgateway/uo-link.db
|
||||
✓ Service runicgateway-link.service active, enabled as runicgateway
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok, up 6h, database ok
|
||||
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
|
||||
✓ uo-link installed 1.1.0
|
||||
✓ Service running, enabled
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok
|
||||
✓ Protocol sidecar 3 = overlay manifest 3
|
||||
✗ Shard connected no — the shard is running (pid 8123) but has not dialed in
|
||||
✓ Bundle 2026.08.04 — up to date
|
||||
✓ Backups 2026-08-04T09:12:44Z — 3 file(s) replaced by update to bundle 2026.08.04
|
||||
3 kept in /etc/runicgateway/backups
|
||||
✗ Shard connected no shard has dialed in since boot
|
||||
```
|
||||
|
||||
Rows come from asking the installed sidecar (`--version`, `--print-config`) rather than from reading
|
||||
`install.json`, so `doctor` reports what the binary would actually do — including which config and
|
||||
database file the *service* resolves — rather than what the installer believes it was told. The
|
||||
overlay row compares live file hashes against `install.json`, which is how it tells "you edited a
|
||||
deployed file" from "the file is gone"; the bundle row is what tells you the overlay upstream has
|
||||
moved on. Each patched file is re-checked against the cached copy of its patch, so a core upgrade or
|
||||
a restored backup that quietly removed the tier's edits is caught here — nothing else would notice.
|
||||
|
||||
It writes nothing at all, and it is safe to run while the shard is up; that is in fact the only
|
||||
state in which the last row can be `✓`.
|
||||
|
||||
**Reading the marks:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `✓` | as it should be |
|
||||
| `⚠` | worth knowing, not broken — a stopped shard, a service you never registered, an unpatched tier, or no route to Gitea to check for a newer bundle |
|
||||
| `✗` | broken. `doctor` exits `1` if any row is `✗`, so it can be run from a monitoring script; a `⚠` never causes that |
|
||||
|
||||
The distinction on the last row is worth spelling out: **shard not running** is a `⚠` (start it),
|
||||
while **shard running and not dialed in** is a `✗` — that is the silent failure this whole guide
|
||||
warns about, where ServUO reports a clean boot over a script build that failed.
|
||||
Three of those rows come from asking the installed sidecar (`--version`, `--print-config`) rather
|
||||
than from reading `install.json`, so `doctor` reports what the binary would actually do — including
|
||||
which config and database file the *service* resolves — rather than what the installer believes it
|
||||
was told. The overlay row compares live file hashes against both `install.json` and the release
|
||||
manifest, which is how it tells "you edited a deployed file" from "the overlay moved on".
|
||||
|
||||
### `runicgateway update`
|
||||
|
||||
@@ -559,31 +474,6 @@ together — never to two independently-latest artifacts that may disagree.
|
||||
Your `sidecar.toml`, your `Bridge.cfg` edits and your database are not touched. `Bridge.cfg` is
|
||||
overwritten only if you have not changed it; a modified copy is reported, not clobbered.
|
||||
|
||||
**Anything it does overwrite is copied first.** Every `.cs` file the overlay owns is replaced
|
||||
unconditionally — that is deliberate, they are code — so if you have edited one, the run saves your
|
||||
copy under `backups/<timestamp>/` in the state directory before writing, alongside `sidecar.toml`
|
||||
and any stock ServUO file the patch tier is about to touch. Each backup carries a `manifest.json`
|
||||
saying where every file came from. The newest three are kept; `--no-backup` skips taking one.
|
||||
|
||||
Putting a file back is yours to do — the installer will not restore an old file over a newer
|
||||
release, because it cannot know what has changed since. A run that overwrites nothing takes no
|
||||
backup, so a no-op `update` leaves nothing behind.
|
||||
|
||||
It updates the ServUO tree `install.json` names — not a tree it detects — and it needs the shard
|
||||
stopped, exactly as `install` does. There is nothing to update on a host that was never installed;
|
||||
it says so rather than performing a first install under a verb that promises to preserve.
|
||||
|
||||
**Your auth token is not reprinted.** It has not changed and your website already has it. The one
|
||||
thing an update can change that the site must be told about is the **protocol version**, and it says
|
||||
so plainly when that happens — a stale number in Admin → Shard is answered with `409` and looks
|
||||
exactly like your shard going offline.
|
||||
|
||||
**The patch tier under `update`:** features you already have are re-checked against the new release
|
||||
(normally nothing to do), without asking you again — you consented when they were installed, and
|
||||
that includes a shard where the tier ran unsupported. Features you never took are **named, not
|
||||
applied**; run `update --patches` (or `install --patches`) to take one up. A shard that declined the
|
||||
tier stays unpatched through every update.
|
||||
|
||||
### `runicgateway uninstall`
|
||||
|
||||
Removes what it exclusively owns, and **prints** everything else. The installer cannot know what you
|
||||
@@ -592,19 +482,12 @@ your work.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Removed** | The sidecar binary, its service entry, `install.json` |
|
||||
| **Kept** | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and every backup an upgrade took (`--purge` drops all of them) |
|
||||
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete — with any file you have edited since deployment flagged, so you do not delete your own work by mistake |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert — with how each landed, since one placed into a file you had already modified is worth a closer look. The pre-patch copy kept under `patches/originals/` is there to diff against. |
|
||||
| **Removed** | The sidecar binary, its service entry, `install.json`, the cached patch set |
|
||||
| **Kept** | `sidecar.toml` and `uo-link.db` — config and history survive (`--purge` drops them) |
|
||||
| **Printed, not done** | Every overlay file deployed into your ServUO tree, by path, for you to delete |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert |
|
||||
|
||||
It lists all of that **before** asking, and the prompt defaults to **no**. `--yes` proceeds, which is
|
||||
what an unattended uninstall needs; nothing else about the command is destructive to your shard,
|
||||
which is neither stopped nor started.
|
||||
|
||||
The report is also written to a file — `runicgateway-uninstall-<timestamp>.txt` in the directory you
|
||||
ran the command from — so it survives the scrollback. That is why the cached patches and the
|
||||
originals stay behind by default: they are the only offline record of what the tier changed once the
|
||||
release tarball is gone, and the report tells you to diff against them.
|
||||
The report is also written to a file, so it survives the scrollback.
|
||||
|
||||
---
|
||||
|
||||
@@ -612,17 +495,15 @@ release tarball is gone, and the report tells you to diff against them.
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
|---|---|
|
||||
| **Windows asks for Administrator as soon as you launch it** | Expected, and it needs Administrator anyway. Windows applies *installer detection* to unsigned executables whose file name contains `install` and elevates them before the program starts. Run it from an already-elevated PowerShell and you will not see the prompt. |
|
||||
| **"ServUO is running — stop it before installing"** | Correct, and not overridable. `ServUO.exe` locks `Scripts.dll` and rewrites `Saves/` on exit; deploying underneath it corrupts one or both. Stop the shard, install, start it again. |
|
||||
| Shard boots clean but nothing reaches the site | The classic silent failure: ServUO ignores the script build's exit code and reloaded a **stale `Scripts.dll`**. Run `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` and read the errors it prints. |
|
||||
| `[bridge status` says `connected=False` | The sidecar is not listening on `127.0.0.1:7788`. Check the service is running, and that `[shard] bind` in `sidecar.toml` matches `Host`/`Port` in `Bridge.cfg`. |
|
||||
| `[bridge` is not a command | The plugin did not compile, or `Bridge.cfg` has the bridge disabled. See the row above. |
|
||||
| Website says the shard is offline; `/health` is fine locally | The website cannot reach port 8080 — bind address or firewall. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
|
||||
| Website says the shard is offline; `/health` is fine locally | The website cannot reach the sidecar — bind address, firewall, or proxy. See [§5](#if-your-website-is-on-a-different-machine). Note that the site is *designed* to render normally with the shard offline, so this fails quietly by design. |
|
||||
| REST reads work but **no live events arrive** | The classic reverse-proxy symptom: the WebSocket upgrade is not being forwarded. Confirm the proxy sets `Upgrade`/`Connection` and speaks HTTP/1.1 upstream, and that the site's WebSocket URL is `wss://…/ws` — not `https://`. |
|
||||
| The event feed connects, then drops every minute or two | A proxy read timeout below the sidecar's 30 s WebSocket ping interval, or response buffering. Raise the timeout and turn buffering off. |
|
||||
| Website logs `409` from the sidecar | Protocol mismatch: the number in Admin → Shard does not match the sidecar's. The sidecar rejects rather than mis-parsing. Set the field to what `/health` reports (`protocol`). If the *sidecar* and *overlay* disagree, you have a hand-assembled pair — reinstall from a bundle. |
|
||||
| `401` from the sidecar | Wrong or missing auth token. Read the live one back with `uo-link-sidecar --print-config --config <path>`; do not retype it from a screenshot. |
|
||||
| **"service NOT REGISTERED" at the end of an otherwise successful run** | The host has no service manager the installer can drive — most often no systemd (a container, or a distro that never had it), or the `runicgateway` user could not be created. The binary and config *are* installed; the run prints the exact unit and commands to finish by hand. It never falls back to running the service as root or `LocalSystem`. |
|
||||
| **Windows: `sc start` fails with 1053, "the service did not respond in a timely fashion"** | Almost always a **sidecar older than v1.2.0**, which cannot start as a service no matter how correct its config. 1053 is a handshake failure, not a crash: Windows waited 30 seconds for the process to identify itself to the service control manager, and a sidecar built before service support was added never does. Check with `"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --version`. Tell-tale signs: `sc query` shows `SERVICE_EXIT_CODE : 0` (nothing crashed), and running the same binary in the foreground with the same `--config` works perfectly. |
|
||||
| Service registered but stops immediately | Distinct from 1053 above — here the process really did exit. On Windows read `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log`, which is where a service logs since it has no stdout, and check that `sc qc RunicGatewayLink` shows `--config` in `BINARY_PATH_NAME` and that `NT SERVICE\RunicGatewayLink` has read access to `sidecar.toml`; on Linux check the `runicgateway` user can read `/etc/runicgateway/sidecar.toml` and write `/var/lib/runicgateway/`, and read `journalctl -u runicgateway-link`. |
|
||||
| A patch will not apply | Expected on a hand-modified shard. The base install is unaffected; you lose only the two features in [§4](#4-the-patch-tier-optional). Apply the hunks by hand if you want them. |
|
||||
| `vendor.sale` events never arrive despite patching | The `EventSink.cs` patch is a **core** change. A shard restart is not enough — rebuild the solution (`dotnet build ServUO.sln`). |
|
||||
| Sidecar writes its database somewhere unexpected | A relative `[store] path` resolves against the directory holding `sidecar.toml` — not the working directory. Run `--print-config` to see the absolute path it will actually use. |
|
||||
@@ -632,20 +513,15 @@ release tarball is gone, and the report tells you to diff against them.
|
||||
|
||||
## Appendix A — installing by hand
|
||||
|
||||
This is what the installer automates, done by hand. It is a **supported path**, not a deprecated
|
||||
one — reach for it when the host cannot run the binary, when you would rather not run an unsigned
|
||||
one, when you want to place every file yourself, or when you are developing on the bridge and
|
||||
installing from a working tree instead of a release. It is also the reference for what
|
||||
[§2](#2-run-it) does under the hood.
|
||||
|
||||
For a normal shard, [the installer](#1-download-and-verify) is fewer steps and checks more.
|
||||
This is what the installer automates. It works today, on the current releases, and is the fallback
|
||||
whenever you would rather not run an unsigned binary.
|
||||
|
||||
Throughout: `<servuo>` is your ServUO root, and **the shard is stopped**.
|
||||
|
||||
### A1. Fetch the bundle (so you install a checked pair)
|
||||
|
||||
```bash
|
||||
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||
curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
|
||||
```
|
||||
|
||||
It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset.
|
||||
@@ -687,20 +563,10 @@ cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/
|
||||
```
|
||||
|
||||
`git apply` works in a plain directory — the shard does not need to be a git repo. If you use
|
||||
`patch` instead, note that some core files are CRLF while others are LF: use `patch --binary`.
|
||||
|
||||
**If `git apply` refuses a patch whose target region is visibly untouched, line endings are the
|
||||
usual cause** — the `.patch` files and their targets do not all use the same ones, and `git apply`
|
||||
compares them literally. The installer's own tier normalizes line endings and trailing whitespace
|
||||
for the *comparison* while writing back your file's own endings, which is why it can place patches
|
||||
`git apply` rejects. Running the installer is the easier route here.
|
||||
`patch` instead, note the core files are CRLF: use `patch --binary`.
|
||||
|
||||
### A3. Install the sidecar
|
||||
|
||||
On an arm64 host substitute `uo-link-sidecar-linux-aarch64` for the asset name below (`uname -m`
|
||||
says `aarch64`); releases from v1.2.0 carry both. Take the version from the bundle you fetched in
|
||||
A1 rather than the one written here.
|
||||
|
||||
```bash
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64
|
||||
curl -LO https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/SHA256SUMS
|
||||
@@ -780,43 +646,15 @@ Copy-Item .\uo-link-sidecar-windows-x86_64.exe "$env:ProgramFiles\RunicGateway\u
|
||||
|
||||
& "$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe" --print-config --config "$env:ProgramData\RunicGateway\sidecar.toml"
|
||||
|
||||
# The config file now holds your auth token. Lock it down before anything else can read it:
|
||||
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)'
|
||||
|
||||
# binPath carries the config path. The single quotes matter: the value itself contains the double
|
||||
# quotes the service manager needs around a path with spaces in it.
|
||||
sc.exe create RunicGatewayLink `
|
||||
binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' `
|
||||
obj= 'NT SERVICE\RunicGatewayLink' start= auto
|
||||
sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto
|
||||
sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
|
||||
|
||||
# The service account exists only once sc create has created it, so its grants come after:
|
||||
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
|
||||
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
|
||||
|
||||
[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine')
|
||||
[Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine')
|
||||
sc.exe start RunicGatewayLink
|
||||
```
|
||||
|
||||
Four things there are easy to get wrong:
|
||||
|
||||
- **The sidecar must be v1.2.0 or newer.** Earlier builds are plain console programs, and the
|
||||
Windows service control manager cannot supervise one: it waits 30 seconds for the process to
|
||||
identify itself, then fails the start with **1053** even though the process is running and healthy.
|
||||
From v1.2.0 the same binary does both — started by the SCM it runs as a service, started from a
|
||||
shell it runs in the foreground, with no flag to choose between them.
|
||||
- **The config path goes in `binPath`, not in a machine environment variable.** `sc.exe` has no
|
||||
per-service environment, and a machine-wide `UOLINK_CONFIG` would be inherited by every process on
|
||||
the host and survive an uninstall. Never leave the config path to the default — it is relative to
|
||||
the service's working directory, which for a service is `%SystemRoot%\System32`.
|
||||
- **The database needs no pinning here.** A relative `[store] path` resolves against the directory
|
||||
holding `sidecar.toml`, which is already `%ProgramData%\RunicGateway`.
|
||||
- **`obj=` is what keeps this off `LocalSystem`.** `NT SERVICE\RunicGatewayLink` is a virtual
|
||||
service account: Windows creates it with the service, it has no password, and it exists only for
|
||||
this service. Omit `obj=` and you get the most privileged local identity there is, for a process
|
||||
listening on two TCP ports.
|
||||
|
||||
Once it is running, `%ProgramData%\RunicGateway\uo-link-sidecar.<date>.log` is where it logs — a
|
||||
service has no console to write to. Seven days are kept.
|
||||
Machine environment variables are read at service start, so set them before starting — and never
|
||||
leave the config path to the default, which is relative to the service's working directory.
|
||||
|
||||
### A5. Connect the website, start the shard, verify
|
||||
|
||||
|
||||
@@ -1,54 +1,19 @@
|
||||
# Runic Gateway Installer — plan
|
||||
|
||||
Status: **Shipped.** All five phases are built, the `edge → main` cutover merged on 2026-08-07
|
||||
([installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17)), and it cut the
|
||||
first release —
|
||||
[`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases/tag/v0.1.0), publishing
|
||||
`linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` plus `SHA256SUMS`. The binary does
|
||||
everything [`INSTALL.md`](INSTALL.md) describes: the installer core (bundle resolution, ServUO
|
||||
detection and validation, the overlay sync, `install.json` — [Phase 1 as
|
||||
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff —
|
||||
[Phase 2 as built](#phase-2--uo-link-install-and-service)), the patch tier (the rung ladder, the
|
||||
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)),
|
||||
the day-two commands `doctor`, `update` and `uninstall` ([Phase 4 as
|
||||
built](#phase-4--diagnostics-and-updates)), and the packaging polish that gated the cutover
|
||||
([Phase 5](#phase-5--packaging-polish)).
|
||||
|
||||
**The installer is now the supported way to set a shard up**, and the operator guide leads with it.
|
||||
`INSTALL.md`'s [Appendix A](INSTALL.md#appendix-a--installing-by-hand) remains supported rather than
|
||||
deprecated — it is the path for a host that cannot run the binary, for an operator who wants to
|
||||
place files themselves, and for developing on the bridge from a working tree.
|
||||
|
||||
**Phase 5 came before the cutover, not after it** (org lead, 2026-08-05). The earlier order — cut
|
||||
the release, then polish — would have published a first release immediately superseded by the next,
|
||||
and the release layout is exactly what Phase 5 changed. Both cutover gates were met before the
|
||||
merge:
|
||||
|
||||
1. **Phase 5, packaging polish** (§5) — scope settled and built: **no `.deb` and no MSI** (§5.1 —
|
||||
both would give the service, its unit and its user a second owner), **Linux `aarch64` for both
|
||||
components** (§5.2), **a backup of what an upgrade overwrites** (§5.3), and the docs a first
|
||||
release invalidates (§5.4).
|
||||
2. **The Windows SCM half verified on a real host**, 2026-08-07. It was worth insisting on: the
|
||||
first real `sc start` failed with **1053**, because the SCM waits ~30s for a handshake a plain
|
||||
console program cannot perform. Fixed in the sidecar
|
||||
([link#29](https://gitea.whitlocktech.com/RunicGateway/link/pulls/29), v1.2.0) and verified end
|
||||
to end against a live service — 13/13 checks, start in 1s, `RUNNING`, `/health` served, service
|
||||
log written, clean stop —
|
||||
with [installer#16](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/16) making the
|
||||
installer diagnose 1053 as a handshake rather than blaming the config. systemd registration was
|
||||
verified separately on 2026-08-05 against a real privileged systemd container, and that run
|
||||
likewise found a bug no unit test had.
|
||||
|
||||
This document is the design of record; it supersedes the informal overview it grew out of, which
|
||||
described a ServUO integration that does not match how `servuo-plugins` actually ships (see
|
||||
Status: **Phase 0 complete.** Every prerequisite in another repo has landed, the installer repo
|
||||
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) now specifies the operator-facing run
|
||||
— so *what* the installer installs and *what using it looks like* both exist ahead of the binary.
|
||||
No installer code exists yet; **Phase 1 is next.** This document is the design of record; it
|
||||
supersedes the informal overview it grew out of, which described a ServUO integration that does not
|
||||
match how `servuo-plugins` actually ships (see
|
||||
[Corrections](#corrections-to-the-original-overview)).
|
||||
|
||||
| Phase 0 item | State |
|
||||
|---|---|
|
||||
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
|
||||
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
|
||||
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) |
|
||||
| 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
|
||||
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) |
|
||||
| 0.4 This file + `INSTALL.md` | 🟨 In review — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
|
||||
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |
|
||||
|
||||
---
|
||||
@@ -79,7 +44,7 @@ release/start scripts. The installer never writes a launcher.
|
||||
| Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle <tag>` pins one. Component releases regenerate JSON, not the installer binary |
|
||||
| Token handoff | **Print token + prefilled admin URL** at the end of the run |
|
||||
| Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency |
|
||||
| ServUO version | **57.4 is the only supported version.** The patch tier's gate is content, not a version string: a patch applies where the lines it edits are still stock and is handed to the operator where they are not (§2.2.1). Forks and hand-edited trees are the norm in a public audience, so a non-57.4 tree is still *allowed* to attempt the tier — but **unsupported, untested and not guaranteed**, behind a loud banner, a defaulted-to-no prompt and its own opt-in flag (§2.2.2) |
|
||||
| ServUO version | **Warn and skip.** Patches are verified against stock 57.4 only; on anything else the base install proceeds and the patch tier is skipped with a warning. Forks are the norm in a public audience — refusing outright would block most operators |
|
||||
| Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |
|
||||
|
||||
---
|
||||
@@ -131,102 +96,12 @@ most real shards are hand-modified. Therefore:
|
||||
audit forwarding**.
|
||||
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
|
||||
shard restart.
|
||||
- Record applied patches in `install.json`, **and cache the `.patch` files** next to it
|
||||
- On any ServUO version other than stock **57.4**, skip the whole tier with a warning and continue
|
||||
with the base install. Do not attempt to apply unverified diffs to an unknown tree.
|
||||
- Record applied patches in `install.json`, **and cache the applied `.patch` files** next to it
|
||||
(`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
|
||||
and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
|
||||
Phase 4). As built this caches every patch the tier *evaluated*, not only those that applied,
|
||||
because the refusal message names that path as the file to apply by hand. **The cache outlives an
|
||||
uninstall** — see Phase 4, where the report that would have been left pointing at a deleted
|
||||
directory is what settled it.
|
||||
- **Cache the pre-image of every file the tier edits**, under `patches/originals/`, mirroring its
|
||||
path in the ServUO tree. It is written before the first edit and never overwritten, so a revert
|
||||
can be verified byte-for-byte rather than reconstructed from a printed diff — which matters most
|
||||
after a `region-match` apply, where the surrounding file was already the operator's. It stays out
|
||||
of the ServUO tree, since uninstall has promised never to clean up in there.
|
||||
- **A `.patch` does not carry everything the tier needs.** Which patches form one unit, which
|
||||
companion `.cs` follows which, whether a core rebuild is required and what declining costs are
|
||||
declared by the overlay release and read from its manifest — see §7.0.
|
||||
|
||||
#### 2.2.1 A whole-file hash mismatch is not a verdict — check the region
|
||||
|
||||
A file-level hash compare answers "is this entire file stock?", which is the wrong question. The
|
||||
patches touch three small regions of three large files; an operator who added a custom command to
|
||||
`Logging.cs` or a hook to `EventSink.cs` has changed the file's hash without going anywhere near the
|
||||
lines the patch edits. Refusing on the file hash alone hands most real shards a manual patch job
|
||||
they did not need. So the decision is made in three rungs, cheapest and safest first, and only the
|
||||
last one gives up:
|
||||
|
||||
| Rung | Test | Outcome |
|
||||
|---|---|---|
|
||||
| **0 — already applied** | The hunk's *post*-patch text appears in the file | No-op, recorded as applied. Keeps re-runs idempotent |
|
||||
| **1 — file is stock** | Whole-file hash matches the patch's pre-image (`index <old>..<new>` in the diff — `git hash-object` on the target reproduces it) | Apply verbatim with `git apply` |
|
||||
| **2 — region is stock** | File differs, but every hunk's stock-side region is still byte-identical | Apply hunk-by-hunk at the matched offsets |
|
||||
| **3 — region is modified** | Anything else | **Do not touch the file.** Print the path, the hunks and what is lost; the operator patches by hand |
|
||||
|
||||
Rung 2 is the semantic review, and it needs no new metadata: a unified diff already carries the
|
||||
stock text of the region it edits — the context lines plus the `-` lines *are* the pre-image. For
|
||||
each hunk the installer reconstructs that block and searches the target file for it, under these
|
||||
rules:
|
||||
|
||||
- **Exact match, not fuzzy.** Only line-ending (CRLF/LF) and trailing-whitespace normalization is
|
||||
allowed. No `patch --fuzz`, no context reduction: dropping context to force a match is precisely
|
||||
how a patch lands in the wrong method.
|
||||
- **Exactly one occurrence, or it fails.** Zero means the region moved or was edited. More than one
|
||||
means the anchor is ambiguous and the installer cannot know which the author meant. Both are
|
||||
rung 3.
|
||||
- **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest
|
||||
candidate when reporting; the match itself is by content, since insertions above the region shift
|
||||
every number below it.
|
||||
- **All-or-nothing per patch file, and again per feature.** If one hunk of a patch reaches rung 3,
|
||||
none of that patch's hunks are applied — a half-patched `EventSink.cs` compiles against a
|
||||
companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to
|
||||
unpick than an untouched file. The same rule then applies across the patches of one feature: the
|
||||
two vendor-sale patches are a unit (the event, the call site, and the subscriber that needs both),
|
||||
so a patch that *could* have been placed is held back when a sibling cannot be — and the run says
|
||||
that rather than reporting it as applied.
|
||||
- **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already
|
||||
present and others are not is a hand-merge in progress, not an idempotent re-run — that is
|
||||
rung 3.
|
||||
|
||||
#### 2.2.2 Non-57.4 is allowed, unsupported, and must say so loudly
|
||||
|
||||
The rung ladder replaces the blanket ServUO-version gate. The old rule skipped the entire tier on
|
||||
anything other than stock 57.4 on the grounds that unverified diffs must not be applied to an
|
||||
unknown tree — but forks are the norm (§1), so that rule skipped the tier for most of the audience.
|
||||
Content matching gives a stronger guarantee than a version string does: on a non-57.4 tree, rung 1
|
||||
is simply unavailable (its pre-image hash cannot be trusted), the tier goes straight to rung 2, and
|
||||
a hunk lands only where the surrounding lines are still character-for-character the ones the patch
|
||||
was written against.
|
||||
|
||||
**That is a mechanical safety guarantee about where text lands. It is not a support commitment, and
|
||||
the installer must never let the two be confused.** Runic Gateway is designed, built and tested
|
||||
against **stock ServUO 57.4**. On anything else the patch tier is **unsupported, untested, and not
|
||||
guaranteed to work** — a hunk can match textually and still be wrong for a tree whose surrounding
|
||||
behaviour has diverged, and neither the shard's silent script build (§2.1) nor the installer will
|
||||
tell you that. So:
|
||||
|
||||
- **The disclaimer is unmissable, not a footnote.** On a non-57.4 tree the tier prints a banner
|
||||
before it is even offered — that 57.4 is the only supported version, that the operator is on their
|
||||
own here, and that a bad outcome may not surface until the shard is running.
|
||||
- **It is off by default and takes an explicit, separate yes.** The interactive prompt defaults to
|
||||
**no** on a non-57.4 tree, and `--patches` alone is **not** consent: an unattended run must pass
|
||||
`--patches-unsupported-servuo` as well. A flag an operator had to look up cannot be hit by
|
||||
accident in a script copied from somewhere else.
|
||||
- **The label follows the install.** `install.json` records the detected version and the fact that
|
||||
the tier ran unsupported; `doctor` shows that row on every subsequent run, not just at install
|
||||
time; and the uninstall report carries it too. An operator who inherits this shard six months
|
||||
later must be able to see it without being told.
|
||||
- **It is the first thing quoted back in a bug report.** The tier's summary line names the detected
|
||||
version, so a pasted install log answers "which ServUO?" before anyone asks.
|
||||
|
||||
The version is detected and reported everywhere; it just no longer *silently* decides. A refusal
|
||||
becomes an informed choice, which is the point — but it stays visibly the operator's choice.
|
||||
|
||||
**What the installer records.** `install.json` stores, per patch, which rung applied it
|
||||
(`stock-hash`, `region-match`, `already-present`) and the hunk offsets it matched. `doctor` and
|
||||
`uninstall` report that: a `region-match` apply on a modified file is a different support story from
|
||||
a clean apply to a stock tree, and the operator should be able to see which one they have without
|
||||
re-deriving it.
|
||||
Phase 4).
|
||||
|
||||
### 2.3 Config paths collide with what the sidecar actually reads
|
||||
|
||||
@@ -247,21 +122,13 @@ Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.
|
||||
half in the sidecar — a relative `[store].path` now resolves against the directory holding
|
||||
`sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic —
|
||||
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
|
||||
"where this install wants it". The service definition therefore always pins the **config** path:
|
||||
"where this install wants it". The service definitions therefore still pin `UOLINK_CONFIG` and
|
||||
`UOLINK_DB_PATH` explicitly:
|
||||
|
||||
- Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
|
||||
service user
|
||||
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`**
|
||||
|
||||
**How each is pinned differs by platform, and Phase 2 settled it that way deliberately.** Linux's
|
||||
unit carries `Environment=UOLINK_CONFIG=` *and* `Environment=UOLINK_DB_PATH=`, because `/etc` and
|
||||
`/var/lib` are different directories and both need naming. Windows passes the config as `--config`
|
||||
inside the service's own `binPath`, and pins nothing else: config and data are both
|
||||
`%ProgramData%\RunicGateway`, so the sidecar's own anchoring rule already puts the database exactly
|
||||
where the table above says. The alternative on Windows is a **machine-wide** environment variable —
|
||||
`sc.exe` offers no per-service one — which every process on the host would inherit and which would
|
||||
outlive an uninstall. See [Phase 2 as built](#phase-2--uo-link-install-and-service).
|
||||
|
||||
### 2.4 The token handoff was missing entirely
|
||||
|
||||
The whole point is the website reaching the sidecar, and today that is manual and undocumented in
|
||||
@@ -292,11 +159,9 @@ operators.
|
||||
- **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced
|
||||
by a release tarball, which had to be built first — Phase 0 item 1, now in review
|
||||
([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)).
|
||||
- **arm64 was not buildable today.** `link/release.yml` cross-compiled only
|
||||
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`, so `platform_key()` refused every other
|
||||
host by name. That was a property of the workflows, not of Rust — Phase 5 adds
|
||||
`aarch64-unknown-linux-gnu` to both components in the order §5.2 sets out. There is no `.deb`, so
|
||||
the cross toolchain is the whole cost.
|
||||
- **arm64 is not buildable today.** `link/release.yml` cross-compiles only
|
||||
`x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`. An arm64 `.deb` needs another cross
|
||||
toolchain.
|
||||
- **The compat matrix has no home.** `PROTOCOL_VERSION` lives in `link/sidecar/src/main.rs`. The
|
||||
sidecar publishes it via `X-UOLink-Version` and `/health`, and the website stores an expected
|
||||
value — but the *plugin's* protocol version is not queryable before boot. Phase 0 item 1 gives it
|
||||
@@ -313,13 +178,12 @@ Components are published as Gitea release artifacts. Operators download from the
|
||||
Runic Gateway Installer v1.0.0
|
||||
├── runicgateway-installer-windows-x86_64.exe
|
||||
├── runicgateway-installer-linux-x86_64
|
||||
├── runicgateway-installer-linux-aarch64 (Phase 5)
|
||||
└── SHA256SUMS
|
||||
|
||||
uo-link v1.1.0 (existing release, extended)
|
||||
├── uo-link-sidecar-windows-x86_64.exe
|
||||
├── uo-link-sidecar-linux-x86_64
|
||||
├── uo-link-sidecar-linux-aarch64 (Phase 5)
|
||||
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
|
||||
└── SHA256SUMS
|
||||
|
||||
servuo-plugins v<ver> (new release, Phase 0)
|
||||
@@ -363,7 +227,7 @@ The docs must state this up front rather than let users discover it as a scary d
|
||||
┌────────┴────────┐ ┌────────┴────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
overlay sync patch tier (opt-in) binary install service registration
|
||||
(never deletes) (per-region rungs) + config + data (systemd / Windows SCM)
|
||||
(never deletes) (git apply + guard) + config + data (systemd / Windows SCM)
|
||||
```
|
||||
|
||||
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
|
||||
@@ -445,9 +309,7 @@ Repo work that must land before an installer can exist.
|
||||
|
||||
- **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
|
||||
where and why. That was the one genuinely open question here, and the deciding factor is that
|
||||
this repo's *own* releases are the installer binaries. They were committed to `main` until
|
||||
2026-08-05, when the first run that actually had a bundle to write found `main` protected;
|
||||
they now live on a `bundles` branch (§7.1).
|
||||
this repo's *own* releases are the installer binaries.
|
||||
- **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
|
||||
`--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
|
||||
onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
|
||||
@@ -480,12 +342,12 @@ Repo work that must land before an installer can exist.
|
||||
might exist; it is the specification of what the run asks, where it writes, what it prints, and
|
||||
what the operator does next. Phase 1–4 implement it.
|
||||
|
||||
- **It was useful before the installer existed, and outlives it.** Appendix A is the same
|
||||
deployment done by hand — bundle fetch, tarball verify + overlay copy, the optional patch tier,
|
||||
`--print-config` provisioning, and a systemd unit / `sc create` service — composed from the
|
||||
released artifacts' actual contents and the sidecar's config and CLI source rather than from
|
||||
memory. That appendix doubles as **Phase 1's acceptance test**: walking it end to end on a real
|
||||
shard is what proves the automated path has nothing left to discover.
|
||||
- **It is useful before the installer exists.** Appendix A is the same deployment done by hand —
|
||||
bundle fetch, tarball verify + overlay copy, the optional patch tier, `--print-config`
|
||||
provisioning, and a systemd unit / `sc create` service — composed from the released artifacts'
|
||||
actual contents and the sidecar's config and CLI source rather than from memory. That appendix
|
||||
doubles as **Phase 1's acceptance test**: walking it end to end on a real shard is what proves
|
||||
the automated path has nothing left to discover.
|
||||
- **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on
|
||||
`PATH`; nothing places one there, and adding self-installation would give the tool a second
|
||||
lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it
|
||||
@@ -496,11 +358,19 @@ Repo work that must land before an installer can exist.
|
||||
in §6's handoff has a non-interactive equivalent and an unattended install is expressible.
|
||||
- **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync
|
||||
rule inherited from `deploy.ps1`.
|
||||
- **Remote-website deployments needed an answer.** `[web] bind` defaults to `127.0.0.1`, which
|
||||
only works when the site runs on the shard host. The guide says to widen it, firewall it to
|
||||
the website's address, and front it with TLS or a VPN off a trusted network — because the
|
||||
token is always required but travels as a plain bearer token over HTTP. `[shard] bind` stays
|
||||
on loopback, since that socket carries inbound commands *into* the game.
|
||||
- **Remote-website deployments needed an answer, and it is a reverse proxy.** `[web] bind`
|
||||
defaults to `127.0.0.1`, which only works when the site runs on the shard host. The guide's
|
||||
recommended arrangement — already in production on a real domain — is to **leave the bind on
|
||||
loopback** and put a TLS reverse proxy in front, giving the website the proxied `https://` /
|
||||
`wss://` URLs in place of the pair the installer prints from the bind address. Four
|
||||
requirements make that work and are stated with an nginx block that satisfies them: forward
|
||||
the WebSocket upgrade (`/ws` is the whole live feed), pass headers through unmodified (auth is
|
||||
`Authorization: Bearer`, and a stripped `X-UOLink-Version` silently skips the mismatch check),
|
||||
do not buffer and allow long-lived connections (the sidecar pings every 30 s, so a ≥60 s read
|
||||
timeout is safe), and do not log query strings (`?token=` is an accepted auth form). Widening
|
||||
the bind and firewalling the port stays documented as the trusted-LAN alternative, not the
|
||||
default, because on that path the token crosses the network in the clear. `[shard] bind` is
|
||||
never proxied and never widened — that socket carries inbound commands *into* the game.
|
||||
|
||||
### Phase 1 — installer core
|
||||
|
||||
@@ -521,79 +391,6 @@ Repo work that must land before an installer can exist.
|
||||
timestamp.
|
||||
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.
|
||||
|
||||
**As built** ([installer#4](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/4)) — the
|
||||
crate at the repo root, `install` implemented end to end, `doctor`/`update`/`uninstall` parsed and
|
||||
answered with the phase they arrive in rather than "unrecognized command". The decisions that were
|
||||
not already settled above:
|
||||
|
||||
- **It lands on `edge`, not `main`.** `release.yml` publishes an installer binary on every push to
|
||||
`main`, and its crate guard was written to arm "the moment Phase 1 lands the crate" — which would
|
||||
have published a binary that deploys the overlay but cannot install the sidecar, contradicting
|
||||
everything `INSTALL.md` promises a release does. Phases 1 and 2 land on `edge`; the `edge → main`
|
||||
cutover cuts the first release. `pr-checks.yml` gates PRs into `edge` on the same rules, so the
|
||||
branch where the work happens is not the ungated one. No workflow needed a temporary edit.
|
||||
- **The run says what it did *not* do.** A Phase 1 `install` ends with an unmissable block naming
|
||||
the sidecar as not installed, pointing at `INSTALL.md` A3/A4, and printing the bundle's binary URL
|
||||
and SHA256 so a hand install matches the pair. `--patches` is the sharp edge here: it is accepted
|
||||
(so the flag surface is the published one) but reports `REQUESTED BUT NOT APPLIED — no stock
|
||||
ServUO file has been touched`. A `--patches` run that completed quietly would be read as a
|
||||
patched shard.
|
||||
- **The crate is a library plus a thin binary, and the library is not named after it.** Windows
|
||||
applies UAC *installer detection* to unsigned executables whose file name contains `install`: it
|
||||
demands elevation before the process starts, and a non-interactive session gets `os error 740`
|
||||
instead of a program. That is survivable for the shipped binary — it needs Administrator anyway,
|
||||
and `INSTALL.md` already says to run it from an elevated shell — but Cargo names test harnesses
|
||||
after their target, so a target called `runicgateway_installer` makes `cargo test` **unrunnable on
|
||||
Windows**, on the machine the shard smoke tests live on. The code therefore sits in a library
|
||||
called `rgdeploy`, the binary target keeps its published name, and `[[bin]] test = false` stops
|
||||
Cargo building a harness under it. Nothing an operator sees changes.
|
||||
- **Dependencies chosen for the MinGW cross-build:** `ureq` (blocking HTTP over rustls/ring — no
|
||||
OpenSSL to cross-compile, and no async runtime for a tool that makes four sequential requests),
|
||||
`flate2` on its pure-Rust backend, `tar`, `sha2`, `serde`/`serde_json`, `chrono`, `anyhow`, and
|
||||
`sysinfo` for the running-shard check.
|
||||
- **The shard-running check matches by path, not by process name.** `deploy.ps1` can look for a
|
||||
process called `ServUO` because it only runs on Windows; on Linux the same shard is `mono` or
|
||||
`dotnet` with `ServUO.exe` as an argument, and a name match would answer "not running" for a live
|
||||
shard — the one wrong answer that corrupts `Scripts.dll`. The installer requires a process whose
|
||||
executable or command line names *both* the tree being deployed into and `ServUO.exe`, so a second
|
||||
shard elsewhere on the host does not block this deploy, and the installer never matches itself.
|
||||
- **ServUO's version is read from `Server/AssemblyInfo.cs`**, not from `ServUO.exe`'s PE metadata:
|
||||
it is the same *source* tree the patch tier diffs against, needs no dependency, and works
|
||||
identically on Linux. `57.4.0.0` and `57.4` are normalized to compare equal. An unreadable version
|
||||
is reported as `unknown` and treated as **not** supported — an unreadable version is not evidence
|
||||
of a good one — which is what Phase 3 will gate the tier on.
|
||||
- **`install.json` records a state, not a verb.** Per-file entries are `deployed` or
|
||||
`kept-operator-modified`, never `add`/`change`/`unchanged`. Recording the run's verb made the
|
||||
record differ between a first run and an identical second one, which rewrote the file on every
|
||||
run and broke "a second run writes nothing" in the least visible way available. What later
|
||||
commands need is whose copy is in the tree, and that does not change because time passed.
|
||||
- **The `Bridge.cfg` decision compares against the last hash the installer *deployed*, not the last
|
||||
hash it *saw*.** Once a file has been kept, the record's on-disk hash is the operator's content —
|
||||
so a rule phrased as "is the tree still what the record last saw?" matches on the very next run
|
||||
and overwrites exactly the file it had just protected. A keep has to stay kept for as long as the
|
||||
edit is there; a live three-run test covers it, because the bug only appears from the second run
|
||||
on.
|
||||
- **A prior record is only consulted when it names this tree.** A host whose `install.json` points
|
||||
at a different ServUO root — a shard moved or rebuilt beside the old one — is treated as having no
|
||||
prior deployment, which errs toward keeping the operator's file.
|
||||
- **The download is verified twice, for two different reasons.** The tarball's SHA256 is checked
|
||||
against the bundle while it is being written (the trust anchor — these artifacts are unsigned);
|
||||
then every extracted file is re-hashed against the release's own `manifest.json`, which catches a
|
||||
truncated extraction and is what makes the hashes copied into `install.json` worth trusting. The
|
||||
manifest's `protocol` and `version` are also cross-checked against the bundle, so an artifact that
|
||||
disagrees with the matrix that named it stops the run before anything is written.
|
||||
- **`RUNICGATEWAY_STATE_DIR` relocates the installer's own state**, so a run can be tested without
|
||||
root. Documented in `--help` rather than hidden: an undocumented variable that moves where a tool
|
||||
writes is worse than a documented one, and `doctor` must honour the same value to find what
|
||||
`install` wrote.
|
||||
|
||||
Verified on this machine against a real ServUO 57.4 tree (`--verify`, which reported the tree's
|
||||
`Bridge.cfg` as operator-owned and 23 code files as changed) and end to end into a scratch tree:
|
||||
24 files deployed, a second run reporting `unchanged` and leaving `install.json` untouched, an
|
||||
edited `Bridge.cfg` kept across three further runs while a hand-edited `.cs` was overwritten each
|
||||
time, a pinned `--bundle`, a missing bundle tag, and a refusal — pid and path named, exit 1 — with a
|
||||
process running out of the tree.
|
||||
|
||||
### Phase 2 — uo-link install and service
|
||||
|
||||
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
|
||||
@@ -606,171 +403,11 @@ process running out of the tree.
|
||||
That both writes the config the service will read and returns the token to print, so the service
|
||||
never starts against a config that does not exist yet.
|
||||
|
||||
**As built** ([installer#5](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/5)) —
|
||||
`src/sidecar.rs` (binary, config, handoff) and `src/service.rs` (systemd, Windows SCM), wired into
|
||||
the same `install` run. The decisions that were not already settled above:
|
||||
|
||||
- **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the system
|
||||
user this section already specified; Windows gets a **virtual service account**
|
||||
(`sc create … obj= "NT SERVICE\RunicGatewayLink"`), which the SCM creates itself and which has no
|
||||
password. Plain `sc create` would have run it as `LocalSystem` — the most privileged local
|
||||
identity there is, for a process that listens on two TCP ports while its Linux twin deliberately
|
||||
does not run as root. The account only exists *after* `sc create`, which fixes the order of the
|
||||
file permissions below.
|
||||
- **`sidecar.toml` is locked down, because it holds the token.** Neither default location protects
|
||||
it: `/etc` is world-readable and `%ProgramData%` grants `Users` read by inheritance, so an
|
||||
unprivileged local account could read the shard's auth token out of a stock install. Linux gets
|
||||
`chmod 600` plus `chown` to the service user; Windows gets `icacls /inheritance:r` down to SYSTEM
|
||||
and Administrators **before** registration, then a read grant for the service account after it
|
||||
exists. The database directory gets a separate write grant, since SQLite writes journal and WAL
|
||||
files beside the database.
|
||||
- **`--verify` runs no part of the sidecar half.** `--print-config` provisions — it writes the
|
||||
config and mints a token — so a dry run that called it would create exactly the state it claims
|
||||
not to. A `--verify` run reports what would be installed, reads no token, and prints no handoff.
|
||||
It also **carries the existing `link` section of `install.json` through untouched**, so a dry run
|
||||
on an installed host cannot make its service disappear from the record.
|
||||
- **The installed binary's protocol version is checked against the bundle, and a mismatch stops the
|
||||
run before the service is registered.** Gate 1 (§7.1) read that number from source at the release
|
||||
tag; this is the same check applied to the binary that will actually answer the website. The
|
||||
binary is left on disk — harmless without a service — rather than the run pretending to succeed.
|
||||
- **`RUNICGATEWAY_STATE_DIR` now relocates the sidecar binary too, and suppresses service
|
||||
registration.** Phase 1 left the binary path alone because nothing wrote it. A relocated run that
|
||||
still dropped a binary into `/usr/bin` and registered a system service would be exactly the
|
||||
half-in-the-real-system accident the variable exists to avoid — and there is no such thing as a
|
||||
relocated systemd unit or Windows service. Such a run also leaves file permissions alone, because
|
||||
hardening a scratch config against the only account that will ever read it just breaks the next
|
||||
test run.
|
||||
- **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No
|
||||
systemd (`/run/systemd/system` absent — the correct test, since `systemctl` is present in plenty
|
||||
of containers where PID 1 is not systemd), or a service user that cannot be created: the binary
|
||||
and config are still installed, `install.json` records `service: null`, and the run prints the
|
||||
exact unit text and commands. There is **no fallback to `User=root` or `LocalSystem`** — a service
|
||||
quietly running with more privilege than its own documentation promises is worse than one that was
|
||||
not registered. The printed Windows recipe states plainly whether the run locked the config down
|
||||
or the operator still has to.
|
||||
- **`install.json` never records the token.** The `link` section holds versions, the binary's hash,
|
||||
the config and database paths, and the service's name, unit path and account. The token goes to
|
||||
the terminal and to `sidecar.toml`, and the record is a support artifact people paste into bug
|
||||
reports.
|
||||
- **The service is stopped before its binary is replaced, and restarted rather than started
|
||||
afterwards.** On Windows the file is locked while the service runs (and `sc stop` returns as soon
|
||||
as the stop is *pending*, so the stop is polled, not slept on); on Linux the replacement is
|
||||
permitted but leaves the old code serving until something restarts it. `systemctl start` on an
|
||||
active unit is a no-op, which is precisely the wrong outcome after a replacement.
|
||||
|
||||
Verified on this machine end to end against a relocated layout: the bundle's Windows sidecar
|
||||
downloaded and checksum-verified, `--print-config` provisioning a fresh config and returning a
|
||||
token, the §6 handoff printed with the URLs composed from the host rather than the bind address, a
|
||||
second run reporting `unchanged` / `already present` and leaving `install.json` byte-identical, a
|
||||
`--verify` run over an installed host writing nothing and preserving the `link` section, and a
|
||||
tampered binary detected by hash and replaced with no stray staging file left behind.
|
||||
|
||||
**Service registration itself stayed unverified until Phase 4** — a relocated run deliberately
|
||||
skips it, `sc create` needs elevation, and systemd needs a Linux host. It has now been run for
|
||||
real, on a privileged Debian 12 container with systemd as PID 1, installing into `/usr/bin`,
|
||||
`/etc/runicgateway` and `/var/lib/runicgateway` as root: the unit is written and `enable`d, the
|
||||
service comes up `active, enabled` as the unprivileged `runicgateway` user, `sidecar.toml` lands
|
||||
`600` owned by it, the database is created under `/var/lib` (so the `UOLINK_DB_PATH` pin works),
|
||||
`/health` answers protocol 3, and `uninstall` takes the service, the unit, the binary and the
|
||||
account away again while leaving `sidecar.toml`, the database and every overlay file in the ServUO
|
||||
tree untouched.
|
||||
|
||||
Doing that found one bug that only a real service host could show, fixed in
|
||||
[installer#8](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/8): **`user_created` has
|
||||
to be sticky.** `service::prepare` answers "did *this run* create the account", which is false from
|
||||
the second run on, so recording it verbatim made the field describe the run rather than the state —
|
||||
the same class as Phase 1's two live-run bugs. It rewrote `install.json` on an identical re-run,
|
||||
and it made `uninstall` (which removes only an account it created) silently leave behind the very
|
||||
user this tool had added. The record now inherits `true` from a prior record naming the same
|
||||
account, and only that one. Windows never showed it because the SCM's virtual account is not
|
||||
something the installer creates.
|
||||
|
||||
**Still unverified: the Windows SCM half.** `sc create` demands elevation, and this machine's
|
||||
automation runs unelevated; the systemd half above is the platform that could be driven end to end.
|
||||
|
||||
### Phase 3 — patch tier (opt-in)
|
||||
|
||||
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
|
||||
degrade loudly rather than silently.
|
||||
|
||||
The rung ladder of §2.2.1 is the bulk of the work here: parse each `.patch` into hunks, reconstruct
|
||||
each hunk's pre- and post-image blocks, and resolve the file through rungs 0–3 before writing
|
||||
anything. The unsupported-version path (§2.2.2) is part of this phase, not a later polish — the
|
||||
banner, the defaulted-to-no prompt, the `--patches-unsupported-servuo` flag, and the unsupported
|
||||
marker carried into `install.json`, `doctor` and the uninstall report. Two pieces carry the risk and want direct tests — the hunk parser (headers, `\ No newline
|
||||
at end of file`, CRLF files) and the uniqueness rule (a region that appears twice must fail, not
|
||||
pick the first). Fixtures are cheap: the three stock 57.4 files, each with a hand edit far from the
|
||||
patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched
|
||||
copy (must reach rung 0).
|
||||
|
||||
**As built** ([installer#6](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/6), with the
|
||||
metadata half in [servuo-plugins#10](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/10))
|
||||
— `src/diff.rs` (the parser), `src/patch.rs` (the ladder and the applier) and `src/tier.rs` (consent,
|
||||
writing, reporting, recording), wired into the same `install` run between the overlay sync and the
|
||||
sidecar. The decisions that were not already settled above:
|
||||
|
||||
- **The engine is fully native; `git` is never invoked.** §2.2.1 wrote rung 1 as "apply verbatim
|
||||
with `git apply`", but §1 chose the release tarball precisely so there would be **no git on the
|
||||
shard host**, and rung 2 needs a native applier regardless. One engine now serves both: rung 1
|
||||
keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image,
|
||||
computed as a git blob SHA1 in process — while the write goes through rung 2's code path. That
|
||||
leaves one set of CRLF and whitespace behaviours to reason about instead of two, and a bug report
|
||||
never has to say which engine ran. It is also not academic: the shipped `.patch` files are CRLF in
|
||||
a Windows checkout while two of their three targets are LF, so `git apply` **refuses** patches
|
||||
this places correctly.
|
||||
- **What a `.patch` cannot say is declared by the release, with a built-in fallback.** Which patches
|
||||
form one all-or-nothing unit, which companion `.cs` follows which, whether a **core** rebuild is
|
||||
needed, and what declining costs are all things a diff does not carry. `servuo-plugins/patches/tier.json`
|
||||
declares them and the release workflow folds them into `manifest.json` as `patch_tier` (§7.0), so
|
||||
adding a patch regenerates release metadata rather than requiring an installer release — the same
|
||||
rule §7.1 applies to the bundle. Overlay `v0.1.1` is in the current bundle and declares nothing,
|
||||
so the installer carries a built-in description of exactly that release; a declared tier always
|
||||
wins. A checked-in fixture of the release workflow's **own jq output** asserts the two descriptions
|
||||
are identical, so the two repos cannot drift apart quietly — the failure mode otherwise is a tier
|
||||
that is silently never offered.
|
||||
- **All-or-nothing gained a second level.** §2.2.1 makes it per *patch file*; the tier is also
|
||||
all-or-nothing per **feature**, because the two vendor-sale patches are one unit — `EventSink.cs`
|
||||
grows the event, `PlayerVendorGumps.cs` raises it, and the companion subscribes to it. Applying
|
||||
either alone yields a tree that does not compile or silently never emits. A patch that could have
|
||||
been placed but was held back by a sibling says so in as many words; reporting it as applied is
|
||||
the exact misreading this tier exists to prevent.
|
||||
- **The pre-image of every patched file is cached**, under `<state>/patches/originals/`, mirroring
|
||||
its path in the ServUO tree. The tier is the only part of the installer that edits a file the
|
||||
operator owns, and this is what turns "here are the hunks we added" into a revert anyone can
|
||||
verify — which matters most for a `region-match` apply, where the surrounding file was already
|
||||
theirs. It lives in the state directory rather than beside the file it copies, because an
|
||||
installer-owned file inside the ServUO tree is one `uninstall` has promised never to clean up. It
|
||||
is written before the first edit and never overwritten, so it stays pre-tier however many times
|
||||
`install` runs.
|
||||
- **Every patch the tier *evaluated* is cached, not only the ones that applied** — a refinement of
|
||||
§2.2's "cache the applied `.patch` files". The refusal message names that path as the file to
|
||||
apply by hand (as §4 of `INSTALL.md` already illustrated), so caching only successes would point
|
||||
an operator at a file the run had decided not to write.
|
||||
- **Rung 0 reuses the previous record whole rather than re-deriving it.** The rung is the
|
||||
support-relevant fact — how did this land? — and a later run re-deriving it answers
|
||||
`already-present` for something that first landed as `region-match`. That flip rewrites
|
||||
`install.json` on the second run of an identical install, which is the same class of bug as the
|
||||
`Bridge.cfg` comparison in Phase 1: a record describing the run instead of the state. A tree
|
||||
patched by hand per `INSTALL.md` Appendix A2 has no prior record, so there `already-present` is
|
||||
correctly what gets minted.
|
||||
- **Declining never erases what an earlier run applied**, and no longer claims a loss that is not
|
||||
real. `--no-patches` and an unselected prompt both carry the previous `patches` section through
|
||||
untouched, as `--verify` does — and the "Without it:" line now names only the features the record
|
||||
does not already show as applied.
|
||||
- **Withholding `--patches-unsupported-servuo` skips the tier loudly rather than failing the run.**
|
||||
By that point the overlay is deployed and the sidecar is about to be installed; turning a completed
|
||||
base install into exit 1 over a tier documented as optional would cost the operator more than the
|
||||
tier is worth. Saying nothing would be the real failure, so it is reported where it happens.
|
||||
|
||||
Verified on this machine against the ServUO 57.4 tree at `C:\Users\colby\Desktop\ServUO`, across
|
||||
four scratch roots built from its real files: a hand-patched tree (rung 0 on both vendor-sale
|
||||
patches), a reverse-applied stock one (**rung 1 on the real `EventSink.cs`**, whose blob hash
|
||||
reproduces the patch's declared `index d30788f` pre-image), a feature resolving at mixed rungs, a
|
||||
tree with edits inside two patched regions (rung 3 — nothing written, the placeable sibling held
|
||||
back, no companions copied, and all three patches cached anyway), and a non-57.4 tree both with and
|
||||
without the extra consent flag. Three consecutive runs left `install.json` byte-identical, the
|
||||
patched files unchanged, and the cached pre-image still pre-patch.
|
||||
|
||||
### Phase 4 — diagnostics and updates
|
||||
|
||||
`runicgateway doctor` — the command that makes the whole thing supportable:
|
||||
@@ -778,7 +415,7 @@ patched files unchanged, and the cached pre-image still pre-patch.
|
||||
```
|
||||
✓ ServUO found /opt/ServUO (57.4)
|
||||
✓ Overlay in sync 24 files, all hashes match install.json
|
||||
⚠ Patch tier 1 of 3 applied (region-match) — vendor.sale unavailable
|
||||
⚠ Patch tier 1 of 3 applied — vendor.sale unavailable
|
||||
✓ uo-link installed 1.1.0
|
||||
✓ Service running, enabled
|
||||
✓ Sidecar reachable 127.0.0.1:8080 /health ok
|
||||
@@ -813,246 +450,19 @@ a clever automatic revert risks silently eating their work. It removes and it re
|
||||
|
||||
| Action | Scope |
|
||||
|---|---|
|
||||
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` |
|
||||
| Kept | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and (from Phase 5) the backups an upgrade took — `--purge` to drop them |
|
||||
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set |
|
||||
| Kept | `sidecar.toml` and `uo-link.db` (config and history survive; `--purge` to drop them) |
|
||||
| **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files — with the rung that applied each one (§2.2.1), since a `region-match` apply means the surrounding file was already the operator's — for them to revert by hand |
|
||||
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files, for the operator to revert by hand |
|
||||
|
||||
The printed report is also written to a file, so it survives the terminal scrollback of a long
|
||||
uninstall.
|
||||
|
||||
**As built** ([installer#7](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/7)) —
|
||||
`src/doctor.rs`, `src/update.rs` and `src/uninstall.rs`, plus `service::observe`/`service::remove`
|
||||
and a `Mode` on the install pipeline. The decisions that were not already settled above:
|
||||
|
||||
- **`update` is the `install` pipeline in a different mode, not a second implementation.** This
|
||||
section describes it as "re-resolve the bundle, then move both components to it" — which is what
|
||||
an `install` over an existing deployment already does, down to keeping a modified `Bridge.cfg`
|
||||
and restarting the service after replacing its binary. A separate implementation would have given
|
||||
the sync rules, the two protocol cross-checks and the record-carrying logic a second place to
|
||||
disagree. What actually differs is four things: a prior record is **required** (an `update` on an
|
||||
uninstalled host is a typo or a state directory the run cannot see — never a first install under
|
||||
a verb that promises to preserve), the tree comes from that record rather than from detection (a
|
||||
host with two shards must not have an update silently move to the other one), the tier's scope
|
||||
narrows, and the close is a diff instead of a handoff.
|
||||
- **`update` does not reprint the token, and does call out a protocol change.** The token has not
|
||||
changed and the website already holds it; reprinting a secret nobody has to act on just puts it
|
||||
in another scrollback. The protocol number is the one thing an update *can* change that the
|
||||
website has to be told about — a stale value in Admin → Shard is answered `409` and looks to an
|
||||
operator exactly like the shard going offline.
|
||||
- **The tier under `update` re-resolves only what an earlier run applied, without asking again.**
|
||||
Not a fresh offer: a shard that declined stays unpatched through every update, which is what
|
||||
opt-in has to mean. Consent is not re-sought for what is already in the tree — including on an
|
||||
unsupported ServUO, where `install` demands a second flag — because the record *is* the evidence
|
||||
that the operator opted in, and re-prompting would make an unattended update impossible on
|
||||
precisely the hosts that most need their patches re-checked when an overlay moves. New features
|
||||
the release offers are named but not applied; `--patches` is how they are taken up. A feature the
|
||||
record shows as applied that the release no longer declares keeps its record rather than being
|
||||
dropped: its edits are still in the tree, and a record that forgot them would stop `uninstall`
|
||||
printing hunks that are really there.
|
||||
- **`doctor` asks the thing itself, and asks it the way the service does.** `--print-config` is run
|
||||
under the same `UOLINK_DB_PATH` the unit pins, so the config and database it names are the ones
|
||||
the *service* opens rather than the ones the binary would pick on its own — which is what §5's
|
||||
sketch promised and a bare call would have got wrong on Linux. It is also run **only when the
|
||||
config already exists**, because that flag provisions: a diagnosis must not create the state it
|
||||
is reporting on.
|
||||
- **`doctor` exits `1` when a row failed, and a `⚠` never causes that.** The rule makes it readable
|
||||
from a monitoring script, and the split is what keeps the report worth reading: a stopped shard
|
||||
is a `⚠` with the reason ("you have not started it"), while a *running* shard that has not dialed
|
||||
in is the `✗` (§2.1's silent failure). Being offline is a `⚠` too — a shard host with no route to
|
||||
Gitea is a supported way to run this, and failing a health check over it would report a working
|
||||
deployment as broken. Both network calls take short timeouts for the same reason.
|
||||
- **The patch row re-resolves each recorded patch against the tree.** The cached `.patch` makes it
|
||||
possible offline, and the expected answer is rung 0. A core upgrade, a hand revert or a restored
|
||||
backup silently removes the tier's edits, and nothing else in the report would notice.
|
||||
- **The cached patch set and `patches/originals/` survive an uninstall** — a deviation from the
|
||||
table above, which listed them as removed. The report that same command prints tells the operator
|
||||
to diff their stock files against those originals; deleting them would have made the advice
|
||||
impossible to follow within one command's output. They are the only offline record of what the
|
||||
tier changed once the release tarball is gone, so `--purge` is what removes them, alongside the
|
||||
config and the database. The report names every path it left behind.
|
||||
- **`--yes` means yes on `uninstall`, not "take the default".** Everywhere else that flag answers an
|
||||
offer the *run* made, so taking the safe default is right. Here the operator typed the destructive
|
||||
verb; reading `--yes` as "no" would leave an unattended uninstall unable to express itself at all,
|
||||
and a script that appears to succeed while removing nothing is the worse of the two failures. The
|
||||
interactive prompt still defaults to **no**, after listing exactly what will and will not be
|
||||
touched.
|
||||
- **`uninstall` exits `1` for a step it could not carry out**, having done everything else. The
|
||||
common case is a binary still locked by a sidecar somebody started by hand, so a permission error
|
||||
on that file says so rather than sending the operator to look at ACLs. The Linux service account
|
||||
is removed only when the record says this installer created it; Windows' virtual account goes with
|
||||
the service.
|
||||
- **The overlay listing flags files edited since deployment.** An operator deleting that list file
|
||||
by file must not lose their own `Bridge.cfg` settings or a script edit without being told which
|
||||
ones those are.
|
||||
|
||||
Verified on this machine against a scratch ServUO 57.4 tree built from the real files: a healthy
|
||||
`doctor` (exit 0), one against a tree with a deleted overlay file, an edited one and a reverted
|
||||
patch (all three found, exit 1), an `update --verify` that wrote nothing, a real `update` that
|
||||
repaired all three and left `install.json` byte-identical, `uninstall` with and without `--purge`,
|
||||
a second `uninstall`, a locked binary reported as a problem with exit 1, and `doctor`/`update` on a
|
||||
host with no record. `fmt`/`clippy -D warnings`/tests were run for Linux in Docker as well as on the
|
||||
Windows host, since only half of `service.rs` compiles on either.
|
||||
|
||||
### Phase 5 — packaging polish
|
||||
|
||||
The last work before the first release. It was sketched as four items — `.deb` packaging, Windows
|
||||
MSI, arm64 cross build, optional automated backup before upgrade — and the org lead settled its scope
|
||||
on 2026-08-05: **two of the four are dropped rather than deferred**, because what stops them is an
|
||||
ownership conflict that does not improve with time, and two are built.
|
||||
|
||||
**It ran before the `edge → main` cutover rather than after it.** The original order assumed the
|
||||
cutover would cut a v1 and packaging would follow as a v1.x — but this phase changes the *release
|
||||
layout* (§3), so shipping first would have meant a first release superseded by the next one, and
|
||||
operators who downloaded a bare binary being told to re-download a package. Deferring the cutover
|
||||
cost nothing: nothing was published from `edge`, and the guide's Appendix A was the path meanwhile
|
||||
(and remains supported now that it is no longer the default).
|
||||
|
||||
| Item | Decision | State |
|
||||
|---|---|---|
|
||||
| `.deb` for uo-link | **Dropped** (§5.1) | — |
|
||||
| Windows MSI | **Dropped** (§5.1) | — |
|
||||
| arm64 cross build | **Build** — Linux `aarch64`, both components (§5.2) | ✅ Merged — [installer#9](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/9), [link#26](https://gitea.whitlocktech.com/RunicGateway/link/pulls/26), [installer#10](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/10) |
|
||||
| Backup before upgrade | **Build** — on by default, scoped to what a run overwrites (§5.3) | ✅ Merged — [installer#11](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/11) |
|
||||
| Docs the release invalidates | **Do** (§5.4) | ✅ Done — `INSTALL.md` took the `aarch64` and backup content pre-cutover; the status rewrites landed after it |
|
||||
|
||||
#### 5.1 What ships is plain binaries: no `.deb`, no MSI
|
||||
|
||||
Both were proposed before Phases 2 and 4 existed. They now collide with the components those phases
|
||||
made the installer own, and the collision is the deciding argument in each case.
|
||||
|
||||
- **A `.deb` under link's release would own `/usr/bin/runicgateway-link`, the systemd unit and the
|
||||
service user** — the same three things `service.rs` writes, hardens and removes, and that
|
||||
`install.json` records so `doctor` and `uninstall` can reason about them. Two owners for one file
|
||||
is not a packaging detail: `uninstall` deleting a dpkg-owned binary leaves the package *installed*
|
||||
and broken, an `apt upgrade` replacing that binary makes `doctor` report drift no operator caused,
|
||||
and the unit text would live in two repos free to disagree about the account it runs as. The
|
||||
variant that avoids all of that — a binary-only `.deb`, no unit, no user — buys apt-managed
|
||||
upgrades of one file, which `update` already does from a **protocol-checked** bundle (§7.1). That
|
||||
is the stronger of the two guarantees, so the package would be trading correctness for
|
||||
familiarity.
|
||||
- **An MSI contradicts a decision already locked**: the installer does not install itself (Phase 0.4
|
||||
as built). It would also add a second uninstall path — Add/Remove Programs — beside the
|
||||
`uninstall` verb, which owns state an MSI cannot see: `install.json`, the cached patch set and its
|
||||
pre-images, and the ServUO-tree report that is printed rather than done (Phase 4). Unsigned, it
|
||||
raises the same SmartScreen prompt a bare `.exe` does (§3), so it does not even buy the dialog it
|
||||
looks like it should.
|
||||
|
||||
So §3's release layout loses its `.deb` line and gains no installer package. Nothing in Phases 1–4
|
||||
changes — this item is a removal, which is why settling it is cheap and shipping it first would not
|
||||
have been.
|
||||
|
||||
**What would reopen it.** If operators ask for `apt`- or `winget`-managed installs, the thing to
|
||||
package is the sidecar as a standalone service *with the installer taught to detect and defer to a
|
||||
package-managed one*. That is a design change to who owns the service, not packaging polish, and it
|
||||
is out of scope here.
|
||||
|
||||
#### 5.2 arm64 — Linux `aarch64`, both components
|
||||
|
||||
§2.6 recorded arm64 as "not buildable today", which was true of the *workflows* rather than of Rust:
|
||||
`link/release.yml` and `installer/release.yml` cross-compile `x86_64` Linux and Windows only, so
|
||||
`platform_key()` (`src/bundle.rs`) refuses every other host by name. Ampere/Graviton instances and
|
||||
Pi-class boxes are a realistic ServUO home, and the installer's dependency set was already chosen to
|
||||
avoid OpenSSL and any C toolchain of its own (Phase 1 as built) — which is what makes this two build
|
||||
steps per workflow rather than a toolchain project.
|
||||
|
||||
**Both components or neither.** An installer that runs on `aarch64` but resolves a bundle carrying no
|
||||
`aarch64` sidecar has moved the failure later, not fixed it. Windows-on-arm and macOS stay unbuilt:
|
||||
no supported MinGW target here for the first, and no shard host is the second.
|
||||
|
||||
**The order is forced by the bundle CI, which is strict on purpose** (Phase 0.3 as built): an
|
||||
unrecognized `link` asset name is a hard failure, and `linux-x86_64` + `windows-x86_64` are asserted
|
||||
present. So a link release carrying a new binary reddens the compose job unless it is taught the name
|
||||
first — and requiring the name before link publishes it fails *every* bundle for as long as the gap
|
||||
lasts. Hence:
|
||||
|
||||
| Step | Repo / branch | Change | Why it is this one first |
|
||||
|---|---|---|---|
|
||||
| 1 | `installer` `main` | `bundle.yml` maps `*-linux-aarch64` → `linux-aarch64`, and **does not** add it to the required list | The compose job runs from `main`; teaching it the name first means link's next release composes instead of failing |
|
||||
| 2 | `link` `main` | `release.yml` adds `aarch64-unknown-linux-gnu`, cross-linked with `gcc-aarch64-linux-gnu` | Publishes the first `aarch64` sidecar; the nightly cron folds it into a bundle |
|
||||
| 3 | `installer` `main` | `linux-aarch64` joins the required list | Only safe once a release actually carries it — from here a dropped target reddens CI instead of silently vanishing from every bundle |
|
||||
| 4 | `installer` `edge` | `release.yml` adds the same target; `platform_key()` learns `("linux", "aarch64")` | The installer binary itself, on the branch that carries the crate |
|
||||
|
||||
`bundle.yml` is therefore edited on `main` only and never on `edge`, so the cutover merge has nothing
|
||||
to conflict over.
|
||||
|
||||
**What building it found.** Neither crate cross-compiles with the arm64 *compiler* alone.
|
||||
`gcc-aarch64-linux-gnu` only **recommends** `libc6-dev-arm64-cross`, and both release workflows
|
||||
install with `--no-install-recommends` — so the C that each crate pulls in (bundled SQLite under
|
||||
`sqlx` for the sidecar, `ring` under `ureq`'s rustls for the installer) dies on a missing
|
||||
`bits/libc-header-start.h` while every Rust dependency builds fine. Both halves were reproduced in a
|
||||
`rust:1-slim-bookworm` container, failure then fix, before either workflow was written.
|
||||
|
||||
#### 5.3 Backup before overwrite
|
||||
|
||||
**Scoped by what cannot be fetched again.** Not the sidecar binary or the overlay files — both are
|
||||
re-downloadable and hash-named in the bundle. Not the database either: `link/sidecar/src/store.rs`
|
||||
creates every table `IF NOT EXISTS` and every one of them holds shard state the sweeps repopulate, so
|
||||
it is a cache with a schema rather than a record. What an `update` can destroy irrecoverably is:
|
||||
|
||||
1. **An operator's own edits to a deployed `.cs` file.** Phase 1 overwrites those unconditionally and
|
||||
by design — `Bridge.cfg` is the single exception — so the one place the tool knowingly discards
|
||||
work is the one place it should keep a copy.
|
||||
2. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's saved
|
||||
config starts answering `409`/`401` with nothing on the sidecar to explain why (§2.4).
|
||||
|
||||
So the rule is **copy what this run is about to overwrite, plus `sidecar.toml`** — not a snapshot of
|
||||
everything. A snapshot of all 24 overlay files would be mostly byte-identical to a release tarball
|
||||
that is still downloadable, and the noise would bury the two files that matter.
|
||||
|
||||
- **Where:** `<state>/backups/<utc-timestamp>/`, each file mirroring its path in the tree it came
|
||||
from, beside a `manifest.json` naming the source path, its SHA256, and the bundle moved from and
|
||||
to. In the state directory, not the ServUO tree — the same rule the tier's `patches/originals/`
|
||||
follows, and for the same reason: `uninstall` has promised never to clean up in there.
|
||||
- **When: whenever the run is about to overwrite something**, whichever verb was typed. This
|
||||
section first said "`update`, and `install` over an existing record", justified by a first install
|
||||
overwriting nothing — **which is wrong**, and building it is what showed that. A tree deployed by
|
||||
hand per [`INSTALL.md`](INSTALL.md) Appendix A2 — the path this project recommends while the
|
||||
binary is unreleased — has `.cs` files the first `install` plans as `Change` and overwrites, with
|
||||
no prior record anywhere to notice. The direct test covers that case, and a genuine first install
|
||||
onto a clean tree still writes nothing, because there is nothing to copy. `--verify` writes none
|
||||
either, for the reason it runs no part of the sidecar half (Phase 2): a dry run must not create
|
||||
state.
|
||||
- **`sidecar.toml` joins a backup that is already being taken; it is never the reason for one.**
|
||||
Nothing in the installer rewrites it, so treating it as a trigger would leave a dated directory
|
||||
behind after every no-op `update` — the empty-backup problem one step along. It is copied so that
|
||||
a restored set of files arrives with the token that matches it.
|
||||
- **`--no-backup`** opts out, for an operator with their own snapshotting.
|
||||
- **Retention is three.** Older ones are pruned as new ones are written; an unbounded directory of
|
||||
ServUO source copies on a shard host is its own support problem. `uninstall` keeps them and
|
||||
`--purge` drops them, alongside the config, the database and the cached patch set — same rule as
|
||||
Phase 4, and the same reason: they are the only offline record of what was there before.
|
||||
- **The directory is created lazily and the manifest is written last**, so a directory carrying one
|
||||
is a *complete* backup. Listing and pruning consider only those: a run interrupted mid-copy must
|
||||
neither be mistaken for a backup nor be able to evict a good one by being newer than it. It is
|
||||
left on disk for a human to look at rather than silently deleted.
|
||||
- **Restore is printed, not done.** Consistent with the uninstall report, and for the identical
|
||||
reason: the installer cannot know what has changed since, and a clever automatic restore over a
|
||||
newer overlay eats work rather than saving it. `doctor` names the most recent backup and its
|
||||
timestamp, so an operator asking "can I go back" does not have to know the layout to answer.
|
||||
|
||||
#### 5.4 Documentation the release layout invalidates
|
||||
|
||||
Not optional, and grouped here because a first release is the moment these are read for the first
|
||||
time by someone who was not in the room:
|
||||
|
||||
- **`installer/README.md`'s status table** — the first thing a visitor to the repo reads, and it
|
||||
described a tool that was neither finished nor released. Rewritten at the cutover.
|
||||
- **`INSTALL.md`** gained the `aarch64` download lines, the backup behaviour and `--no-backup`
|
||||
before the cutover, and its status banner afterwards: the installer is the path the guide leads
|
||||
with, Appendix A the supported manual one.
|
||||
- **This file:** §3 lost the `.deb` and gained the `aarch64` assets, §2.6's arm64 bullet is now
|
||||
historical, and §7.1's example bundle grew the third asset key.
|
||||
|
||||
#### Cutover entry criteria — met, 2026-08-07
|
||||
|
||||
Both gates were satisfied before
|
||||
[installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17) merged: this phase,
|
||||
and the **Windows SCM verification** (see the status header and
|
||||
[Phase 2 as built](#phase-2--uo-link-install-and-service)). The second was not busywork — it failed
|
||||
on its first real run with 1053 and needed a sidecar fix before it passed, exactly as running the
|
||||
systemd half for real had turned up a bug unit tests could not. The bundle the release shipped
|
||||
against was `2026.08.07`: link `v1.2.1`, overlay `v0.2.0`, both declaring protocol 3.
|
||||
`.deb` packaging, Windows MSI, arm64 cross build, and optional automated backup before upgrade.
|
||||
Deliberately last: v1 can register services directly (`sc create` / a written systemd unit) and ship
|
||||
plain binaries. Nothing in Phases 1–4 should have to change to add these.
|
||||
|
||||
---
|
||||
|
||||
@@ -1080,6 +490,13 @@ Every value in that block except the host and the site URL comes from one
|
||||
`0.0.0.0`, which is not something to hand a website — so the installer composes the URLs from the
|
||||
host it detects or prompts for, rather than echoing the bind address.
|
||||
|
||||
**It does not attempt to detect a reverse proxy**, which is the recommended arrangement for a
|
||||
website on another host (`INSTALL.md` §5). Nothing visible from the sidecar's side says what fronts
|
||||
it, so guessing would produce a confidently wrong `https://` URL. The two printed URLs always
|
||||
describe the sidecar itself, and the guide tells the operator to paste their public `https://` /
|
||||
`wss://` pair instead when there is a proxy. `--host` accepting a full origin later is a cheap
|
||||
improvement if this proves annoying in practice.
|
||||
|
||||
The installer prompts for the site URL only to build that link; it never contacts the website. A
|
||||
future "installer registers itself with the website" flow (claim code + authenticated endpoint) is
|
||||
explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added
|
||||
@@ -1112,30 +529,14 @@ Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo
|
||||
"repo": "RunicGateway/servuo-plugins",
|
||||
"protocol": 3,
|
||||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||||
"patch_tier": {
|
||||
"features": [{
|
||||
"name": "vendor-sale",
|
||||
"summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
|
||||
"lost": "no vendor.sale events",
|
||||
"rebuild": "core",
|
||||
"patches": [
|
||||
{ "name": "playervendor-sale-eventsink", "file": "patches/playervendor-sale-eventsink.patch", "target": "Server/EventSink.cs" },
|
||||
{ "name": "playervendor-sale-gump", "file": "patches/playervendor-sale-gump.patch", "target": "Scripts/Gumps/PlayerVendorGumps.cs" }
|
||||
],
|
||||
"companions": [
|
||||
{ "file": "patches/BridgeVendorSale.cs", "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" }
|
||||
]
|
||||
}]
|
||||
},
|
||||
"files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
|
||||
}
|
||||
```
|
||||
|
||||
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
|
||||
`servuo-plugins/overlay.toml`; `patch_tier` is folded in from `servuo-plugins/patches/tier.json`;
|
||||
`files` is a SHA256 per shipped file.
|
||||
`servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
|
||||
|
||||
Three of these carry weight beyond documentation:
|
||||
Two of these carry weight beyond documentation:
|
||||
|
||||
- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
|
||||
the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
|
||||
@@ -1146,18 +547,6 @@ Three of these carry weight beyond documentation:
|
||||
overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
|
||||
time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
|
||||
mismatch against `install.json` alone means local edits.
|
||||
- **`patch_tier` is everything a `.patch` cannot say about itself**, and is the reason the tier is
|
||||
data rather than code. Which patches form one all-or-nothing unit, which companion `.cs` may only
|
||||
be copied once that unit lands, whether the change needs a **core** solution rebuild or just the
|
||||
dynamic script build, and what the operator loses by declining are none of them derivable from a
|
||||
diff. Declaring them here means adding a patch regenerates release metadata rather than requiring
|
||||
an installer release — the rule §7.1 already applies to the bundle. The maintainer-facing source
|
||||
is `servuo-plugins/patches/tier.json`; the release workflow folds it in and removes the staged
|
||||
copy, so the tarball carries exactly one statement of the table, and gates that every `.patch` is
|
||||
described by exactly one feature, that every named patch and companion exists, and that each
|
||||
declared `target` is the file its diff actually edits. Installers older than this key ignore it;
|
||||
an installer newer than the overlay it is deploying falls back to a built-in description of the
|
||||
release that predates it (see [Phase 3 as built](#phase-3--patch-tier-opt-in)).
|
||||
|
||||
`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
|
||||
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
|
||||
@@ -1178,7 +567,6 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
|
||||
"repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
|
||||
"assets": {
|
||||
"linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" },
|
||||
"linux-aarch64": { "name": "uo-link-sidecar-linux-aarch64", "url": "…", "sha256": "…" },
|
||||
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
|
||||
}
|
||||
},
|
||||
@@ -1193,8 +581,7 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi
|
||||
|
||||
Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
|
||||
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
|
||||
hash could only ever have described one of them. Phase 5's `linux-aarch64` (§5.2) is the first
|
||||
addition that map was shaped for, and it costs the bundle nothing but a key. `schema` versions this document's shape and is
|
||||
hash could only ever have described one of them. `schema` versions this document's shape and is
|
||||
independent of `protocol` and of either component's release version — all three move separately.
|
||||
|
||||
The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
|
||||
@@ -1217,44 +604,24 @@ Two gates run at compose time, both cheap and both worth it:
|
||||
|
||||
#### Where bundles are published
|
||||
|
||||
Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the
|
||||
installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea
|
||||
credentials (§1):
|
||||
Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous
|
||||
`GET` against a public repo — the shard host has no Gitea credentials (§1):
|
||||
|
||||
```
|
||||
current.json → …/RunicGateway/installer/raw/branch/bundles/current.json
|
||||
bundle-<tag>.json → …/raw/branch/bundles/bundle-2026.08.04.json (--bundle)
|
||||
bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json
|
||||
bundles/bundle-<tag>.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle)
|
||||
```
|
||||
|
||||
Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
|
||||
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
|
||||
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.
|
||||
|
||||
**A branch, not `main`, and that correction cost a day.** This section originally said bundles were
|
||||
committed to `main` and "needs no new branch-protection exception: `release.yml`'s version-bump
|
||||
commit already requires the CI user to be able to push to `main`". Both halves were wrong. `main` is
|
||||
protected and declines a push from CI (`pre-receive hook declined`), and `release.yml` had never
|
||||
pushed anything — its bump step has never executed in any repo carrying it, because an **empty
|
||||
template expression written literally in one of its comments** makes the runner fail to build the
|
||||
step and skip it *without failing the job*. The tags exist because Gitea's release API creates one
|
||||
when it publishes. So the assumption that a working push path already existed was never tested by
|
||||
anything.
|
||||
|
||||
Publishing to a branch of its own keeps every property the original choice was for — a reviewable
|
||||
diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host
|
||||
— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the
|
||||
default branch, buys nothing this does not.
|
||||
|
||||
**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
|
||||
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
|
||||
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
|
||||
carrying no installer binary.
|
||||
|
||||
**The release workflows tag and never write to a branch**, for the same reason and settled at the
|
||||
same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it.
|
||||
The version is still written into `Cargo.toml` before building — so a released binary self-reports
|
||||
correctly — but is no longer committed back, and the next version is computed from the newest tag.
|
||||
A first release must not depend on a write to a protected branch.
|
||||
carrying no installer binary. Committing also yields a reviewable diff and a git history of the
|
||||
compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit
|
||||
already requires the CI user to be able to push to `main`.
|
||||
|
||||
### 7.2 What triggers a bundle
|
||||
|
||||
@@ -1313,51 +680,18 @@ mismatched pair from being published as a bundle — which is the mechanism that
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
1. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
|
||||
1. **Windows service mechanism** — `sc create` against the plain console binary (simplest, works
|
||||
today), a bundled WinSW/NSSM shim, or a native `--service` mode in the sidecar using the
|
||||
`windows-service` crate (cleanest, but changes `link`). Recommendation: `sc create` for v1,
|
||||
revisit if restart semantics prove inadequate.
|
||||
2. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
|
||||
the operator to restart afterward. Offering to stop/start would be friendlier but means owning
|
||||
another shard's process lifecycle, and the shard's own start scripts vary.
|
||||
2. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
|
||||
3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
|
||||
sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
|
||||
different host, or hard-assume co-location?
|
||||
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.
|
||||
|
||||
**Resolved — Windows service mechanism** (was question 1). Registration is `sc create` with
|
||||
`obj= "NT SERVICE\RunicGatewayLink"` (plain `sc create` would run as `LocalSystem`, which the Linux
|
||||
half pointedly does not do), `sc failure … actions= restart/5000` as the counterpart of systemd's
|
||||
`Restart=on-failure` / `RestartSec=5`, and the config pinned in `binPath` rather than in a
|
||||
machine-wide environment variable. No WinSW/NSSM shim: that would be a third binary to keep current.
|
||||
|
||||
**Corrected 2026-08-07 — the sidecar needs its own service mode after all.** This section previously
|
||||
recorded that `sc create` against the *plain console binary* worked and needed no change to `link`.
|
||||
It does not, and the first Windows run proved it: `sc start` failed with **1053** and the event log
|
||||
read *"a timeout was reached (30000 milliseconds) while waiting for the … service to connect"*,
|
||||
with `SERVICE_EXIT_CODE : 0` — the process had started fine and simply never spoke to the SCM.
|
||||
|
||||
The premise was a false symmetry with systemd. systemd supervises *any* foreground process; the
|
||||
Windows SCM supervises only a process that calls `StartServiceCtrlDispatcher` within ~30 seconds and
|
||||
then reports its own state transitions. There is no third option where `sc.exe` adopts an arbitrary
|
||||
console executable — it is a service-aware binary or a shim, and the shim was already rejected.
|
||||
|
||||
So `link` gains a Windows service entry point (the `windows-service` crate, behind
|
||||
`[target.'cfg(windows)'.dependencies]`). The objection that this puts Windows plumbing inside a
|
||||
platform-agnostic component is answered by keeping it *only* at the edges: `app::run` is the whole
|
||||
sidecar and is shared, while `windows.rs` and `unix.rs` do nothing but start it and tell it when to
|
||||
stop. Nothing platform-specific reaches the shared path, and Cargo neither resolves nor builds the
|
||||
Windows crates for Linux.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- **One binary, no `--service` flag.** The dispatcher is tried first; failing with
|
||||
`ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) means "not started by the SCM" and falls through
|
||||
to a normal foreground run. `cargo run` and a hand-run diagnostic are unchanged.
|
||||
- **A service has no stdout**, so in service mode the sidecar logs to a daily-rolled file beside its
|
||||
config instead of into the void.
|
||||
- **`Running` is reported only once the shard port is bound and the store is open**, so a bad config
|
||||
fails the *start* rather than flapping Running → Stopped, and a failed run leaves a nonzero
|
||||
`SERVICE_EXIT_CODE` behind rather than the misleading `0` above.
|
||||
- **A sidecar older than v1.2.0 can never start as a service on Windows**, however good its config.
|
||||
The installer says so by name when it sees 1053.
|
||||
|
||||
**Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed:
|
||||
`servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release
|
||||
workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Runic Gateway installer — Project Tree
|
||||
|
||||
> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in
|
||||
> the [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) repository, which
|
||||
> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit
|
||||
> by hand — changes will be overwritten by the next sync.
|
||||
|
||||
A snapshot of the tracked files in the repository (build output, dependencies, and other
|
||||
git-ignored paths are excluded).
|
||||
|
||||
```text
|
||||
installer/
|
||||
├── .gitea/
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ │ ├── bug_report.md
|
||||
│ │ ├── config.yaml
|
||||
│ │ └── feature_request.md
|
||||
│ ├── scripts/
|
||||
│ │ └── gen_tree.py
|
||||
│ ├── workflows/
|
||||
│ │ ├── bundle.yml
|
||||
│ │ ├── pr-checks.yml
|
||||
│ │ ├── release.yml
|
||||
│ │ └── sync-project-tree.yml
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── bundles/
|
||||
│ └── README.md
|
||||
├── src/
|
||||
│ ├── backup.rs
|
||||
│ ├── bundle.rs
|
||||
│ ├── cli.rs
|
||||
│ ├── diff.rs
|
||||
│ ├── doctor.rs
|
||||
│ ├── install.rs
|
||||
│ ├── lib.rs
|
||||
│ ├── main.rs
|
||||
│ ├── net.rs
|
||||
│ ├── overlay.rs
|
||||
│ ├── patch.rs
|
||||
│ ├── paths.rs
|
||||
│ ├── record.rs
|
||||
│ ├── service.rs
|
||||
│ ├── servuo.rs
|
||||
│ ├── sidecar.rs
|
||||
│ ├── tier.rs
|
||||
│ ├── ui.rs
|
||||
│ ├── uninstall.rs
|
||||
│ ├── update.rs
|
||||
│ └── util.rs
|
||||
├── tests/
|
||||
│ ├── fixtures/
|
||||
│ │ ├── commandlogging-event.patch
|
||||
│ │ ├── patch_tier.json
|
||||
│ │ ├── playervendor-sale-eventsink.patch
|
||||
│ │ ├── playervendor-sale-gump.patch
|
||||
│ │ └── published-bundle.json
|
||||
│ └── real_patches.rs
|
||||
├── .gitignore
|
||||
├── Cargo.lock
|
||||
├── Cargo.toml
|
||||
├── CODE_OF_CONDUCT.md
|
||||
├── CONTRIBUTING.md
|
||||
├── CONTRIBUTORS.md
|
||||
├── LICENSE.md
|
||||
├── README.md
|
||||
└── SECURITY.md
|
||||
```
|
||||
@@ -25,16 +25,13 @@ link/
|
||||
│ └── PULL_REQUEST_TEMPLATE.md
|
||||
├── sidecar/
|
||||
│ ├── src/
|
||||
│ │ ├── app.rs
|
||||
│ │ ├── cli.rs
|
||||
│ │ ├── config.rs
|
||||
│ │ ├── main.rs
|
||||
│ │ ├── rpc.rs
|
||||
│ │ ├── shard.rs
|
||||
│ │ ├── store.rs
|
||||
│ │ ├── unix.rs
|
||||
│ │ ├── web.rs
|
||||
│ │ └── windows.rs
|
||||
│ │ └── web.rs
|
||||
│ ├── .gitignore
|
||||
│ ├── Cargo.lock
|
||||
│ ├── Cargo.toml
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# uo-link
|
||||
|
||||
> **Historical snapshot**, from before the bridge was split into
|
||||
> [`RunicGateway/link`](https://gitea.whitlocktech.com/RunicGateway/link) (sidecar) and
|
||||
> [`RunicGateway/servuo-plugins`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins)
|
||||
> (plugin). Kept for the architecture notes below. **To set a shard up, use
|
||||
> [installer/INSTALL.md](../installer/INSTALL.md)** — `deploy.ps1` as described here is a developer
|
||||
> tool, not the operator path.
|
||||
|
||||
ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
|
||||
|
||||
```
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
# Admin-Configurable Theming & Navigation
|
||||
|
||||
> Build contract for runtime-configurable theme, brand assets, and navigation.
|
||||
> Derived from the design doc *Spec: Admin-Configurable Theming & Navigation*,
|
||||
> **corrected to match the current codebase** and with the open questions resolved.
|
||||
> Same workflow as the hero editor: design → phased build → verify.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let the site admin customize, at runtime with no rebuild or redeploy:
|
||||
|
||||
1. **Visual theme** — colors, fonts (from a curated Google Fonts shortlist), and
|
||||
corner radius / shadow depth — via three presets or per-group custom overrides.
|
||||
2. **Brand assets** — logo, hero image, favicon — uploaded to override the
|
||||
`BRAND_*` env defaults.
|
||||
3. **Navigation** — reorder, relabel, and show/hide items in the public site nav,
|
||||
admin sidebar, and player portal nav, via drag-and-drop.
|
||||
|
||||
All three follow the `settings.model.js` pattern already used for `hero_layout`:
|
||||
a JSON value stored under a settings key, exposed through `getPublic()` where
|
||||
needed, edited from an admin view, applied at runtime.
|
||||
|
||||
## 2. Core principle: `BRAND_*` env stays the default, always
|
||||
|
||||
[`server/src/config/brand.js`](../../website/server/src/config/brand.js) is the
|
||||
existing single source of instance identity, read once at startup from env with
|
||||
baked-in Runic Gateway defaults. The app ships as one prebuilt image and each
|
||||
instance re-skins itself via env. **This feature must not disturb that.**
|
||||
|
||||
Every new setting is an *override layer*, never a replacement:
|
||||
|
||||
- An instance where the admin has not touched these settings renders
|
||||
**identically to today**, driven entirely by `BRAND_*` and the current
|
||||
`theme.css` `:root`.
|
||||
- Saving one setting makes that setting — and only that setting — take
|
||||
precedence. Untouched settings keep following env.
|
||||
- This holds **per field**, not per feature. A custom accent with untouched
|
||||
fonts means the accent comes from the DB and the fonts still come from
|
||||
`--serif`/`--display`/`--sans` as `theme.css` defines them.
|
||||
- "Admin-set" means **a DB row exists for that key**. Absence of the row — not an
|
||||
empty or false value — is what triggers the env/CSS fallback. An admin who
|
||||
explicitly picks a preset that happens to equal the shipped default has still
|
||||
set it, and it is stored and honored as explicit.
|
||||
- **No migration writes defaults into the settings table.** New and existing
|
||||
installs both start with zero rows for these keys; that absence *is* the
|
||||
"use env default" state.
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Brand contract | **`getPublic().brand` returns effective values** (override → env). The Android app and Discord embeds track admin theming for free — see §4.5 |
|
||||
| Structural tokens | **Radius + shadow depth only.** `spacingUnit` and `borderWeight` are **cut**, not deferred — see §4.6 |
|
||||
| Radius token values | **Seeded at today's real values** (four tokens, not three), so the promotion step is a true no-op — see §4.7 |
|
||||
| Presets in v1 | **Three dark presets** — Runic Gateway, Modern, Fantasy. Parchment (light) is Phase 9 — see §4.8 |
|
||||
| Fonts | **Curated shortlist, dropdown-only**, 4 options per role, 8 web families in **one** `css2?` request — see §5 |
|
||||
| Raw custom CSS | **Out of scope entirely** — not deferred. Materially different risk profile (overlay/clickjacking tricks, tracking pixels via `background: url(...)`); would need its own feature and its own review |
|
||||
| Live preview | Out of scope for v1 |
|
||||
| Reduced-motion toggle | Out of scope for v1 |
|
||||
| Nav override power | **`label`, `order`, `hidden`, and (admin nav only) `group`.** Never `to`, `roles`, or `feature` — see §7 |
|
||||
| Reset to defaults | **Deletes the settings row.** Never writes a stored copy of the defaults |
|
||||
| Favicon uploads | **PNG only.** No `.ico` — see §4.10 |
|
||||
|
||||
## 4. Corrections to the design doc (current-code reality)
|
||||
|
||||
The design doc is structurally sound; the token architecture, the
|
||||
override-on-top-of-env principle, the nav-override security framing, and the
|
||||
reuse of `imageUpload.js` all match reality. These are the points where it does
|
||||
not, listed worst-first. §4.1–4.5 are blocking; §4.6–4.11 are scope corrections.
|
||||
|
||||
### 4.1 There is no way to delete a setting
|
||||
|
||||
The entire "Reset to defaults deletes the row" principle — which all five new
|
||||
keys rely on, and which the doc lists as an acceptance criterion — has no
|
||||
implementation.
|
||||
[`settings.db.js`](../../website/server/src/model/settings/settings.db.js)
|
||||
exposes `get` / `getAll` / `set` / `seedDefault` only, and the admin API is
|
||||
`PUT /admin/settings` taking a key/value object
|
||||
([`admin.controller.js:499`](../../website/server/src/router/v1/admin/admin.controller.js)).
|
||||
|
||||
**Fix:** add `settingsDb.remove(key)` and a `DELETE /api/v1/admin/settings/:key`
|
||||
route with an explicit key allowlist (the five new keys plus `hero_layout_draft`).
|
||||
Admin-only, same gate as the existing settings routes. Deleting a key that does
|
||||
not exist is a success, not a 404 — "reset" is idempotent.
|
||||
|
||||
### 4.2 Non-admins cannot read their own nav overrides
|
||||
|
||||
The doc says `nav_admin` / `nav_player` are admin-only settings "fetched by the
|
||||
authenticated `AdminLayout` / `PlayerPortalLayout`." But `GET /admin/settings` is
|
||||
gated `requireRole('admin')`
|
||||
([`settings.router.js:18,28`](../../website/server/src/router/v1/admin/settings.router.js)),
|
||||
while `AdminLayout` renders for **editors and moderators** and
|
||||
`PlayerPortalLayout` renders for **players**. Those users have no endpoint from
|
||||
which to read the key, so their nav would silently never apply the override.
|
||||
|
||||
**Fix:** new `GET /api/v1/settings/nav`, `isLoggedIn` only, returning
|
||||
`{ nav_admin, nav_player }`. Not in `PUBLIC_KEYS` — an anonymous visitor has no
|
||||
use for either, and the admin nav's labels leak the shape of the admin surface.
|
||||
|
||||
### 4.3 `renderIndexHtml` runs once at boot, not per request
|
||||
|
||||
[`app.js:207`](../../website/server/src/app.js) reads and templates `index.html`
|
||||
at module load and serves that one string for every SPA route forever. The doc
|
||||
describes overriding `logo`/`favicon` as "an async settings read inside a
|
||||
currently-synchronous-feeling builder" — it is actually a lifecycle change, not
|
||||
just an `await`.
|
||||
|
||||
**Fix:** keep the rendered shell cached in a module-level variable, render it
|
||||
lazily on first request, and invalidate on any successful write to
|
||||
`brand_assets`. Two hard requirements:
|
||||
|
||||
- A DB fault must never fail the page — on a read error, fall back to the
|
||||
env-only shell (the current behavior).
|
||||
- The shell must stay a single cached string in the steady state. Do not do a
|
||||
settings read per page view.
|
||||
|
||||
### 4.4 Settings values are strings, not objects
|
||||
|
||||
`settings.value` is `TEXT`
|
||||
([`schema.sql:126`](../../website/server/db/schema.sql)) and JSON-valued keys are
|
||||
stored `JSON.stringify`'d and parsed client-side — see `parseLayout` in
|
||||
[`heroLayout.js:58`](../../website/client/src/lib/heroLayout.js). The doc's
|
||||
`settings.brand_assets?.hero` and `settings.nav_public` read as if they arrive
|
||||
parsed. They do not.
|
||||
|
||||
**Fix:** one shared `parseJsonSetting(str, validator)` helper, used by every
|
||||
consumer. A malformed or wrong-shaped value is treated as **absent** (falls back
|
||||
to env/code default), never as an error and never as a partial object. This is
|
||||
the same fail-safe posture `parseLayout` already takes.
|
||||
|
||||
### 4.5 The Android app and Discord embeds are silently excluded
|
||||
|
||||
`getPublic().brand` is a **documented cross-repo contract**, not an internal
|
||||
detail. [`publicBrand.test.js:30`](../../website/server/test/publicBrand.test.js)
|
||||
locks its field list, and the Android app's `BrandDto` seeds the entire Material
|
||||
theme from `brand.accent` (`MainActivity.kt:72` → `RunicGatewayTheme`), with
|
||||
`logo` / `hero` / `favicon` fields alongside it. `brand.accentInt` — derived once
|
||||
at boot — is what Discord embeds color themselves with.
|
||||
|
||||
If theme and asset overrides live only in the new keys, an admin changes the
|
||||
accent on the website and **the phone app and the Discord bot keep the old one**.
|
||||
|
||||
**Fix (locked):** resolve the *effective* values server-side in
|
||||
`getPublic()`'s brand block
|
||||
([`settings.model.js:130-141`](../../website/server/src/model/settings/settings.model.js)):
|
||||
|
||||
```js
|
||||
accent: themeVisual?.colors?.accent ?? brand.accent
|
||||
logo: brandAssets?.logo ?? brand.logo
|
||||
hero: brandAssets?.hero ?? brand.hero
|
||||
favicon: brandAssets?.favicon ?? brand.favicon
|
||||
```
|
||||
|
||||
The web client needs **no change** for this — its existing
|
||||
`setProperty('--accent', brand.accent)` line
|
||||
([`SiteContext.jsx:30-32`](../../website/client/src/contexts/SiteContext.jsx))
|
||||
simply receives a better value. Consequences to handle:
|
||||
|
||||
- `brand.accentInt` must be **recomputed from the effective accent** per request
|
||||
rather than read from the boot-time constant, or Discord embeds drift.
|
||||
- `publicBrand.test.js` gains cases: no rows → env values unchanged (the existing
|
||||
assertions must still pass verbatim); `theme_visual` accent set → effective
|
||||
accent returned; `brand_assets.favicon` set → favicon overridden while `logo`
|
||||
and `hero` still come from env.
|
||||
- The Android app needs **no change** to pick up accent/assets. Whether it should
|
||||
also honor the full preset (radius, fonts) is a separate question for
|
||||
`docs/android/PLAN.md`, out of scope here.
|
||||
|
||||
### 4.6 `spacingUnit` and `borderWeight` are not variable renames
|
||||
|
||||
The doc treats these as the same mechanism as color. They are not:
|
||||
|
||||
- **Spacing.** `theme.css` contains **zero** `calc()`-based spacings (the 5
|
||||
`calc()` uses are all `width: min(…, calc(100% - 32px))` page shells). Every
|
||||
padding is a hand-written non-multiple — `7px 14px`, `12px 26px`, `11px 14px`,
|
||||
`13px 14px`. A density token that actually moves density means rewriting ~40
|
||||
declarations into `calc(var(--space-unit) * n)`, and most of the app's real
|
||||
spacing is inline JSX the token cannot reach anyway.
|
||||
- **Border weight.** 39 hand-written `1px` borders, several of which are
|
||||
*semantic* accents that must not scale with a density slider — `.note`'s 3px
|
||||
left rule, `.page-quote`'s 3px, `.pb-tab`'s 2px active underline.
|
||||
|
||||
**Decision:** both are **cut from v1** and do not appear in the admin form.
|
||||
Colors, fonts, radius and shadow depth cover "brand feel" cleanly; these two do
|
||||
not, and shipping them as no-op fields would be worse than not shipping them.
|
||||
|
||||
### 4.7 Six radii cannot round-trip through three tokens
|
||||
|
||||
The doc's preset blocks set `--radius-card: 8px`, but the actual values in
|
||||
`theme.css` are 14×`8px`, 4×`999px`, 4×`10px`, 1×`12px`, 1×`7px`, 1×`6px`. `.card`
|
||||
and `.panel` are **10px** today and `.panel-flat` is **12px**. Adopting the doc's
|
||||
three tokens verbatim would restyle every existing instance — including ones that
|
||||
never touch the feature — which contradicts the acceptance criterion directly
|
||||
above it.
|
||||
|
||||
**Fix (locked):** four tokens seeded at today's real values, so the promotion step
|
||||
is genuinely a no-op:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--radius-pill: 999px; /* .btn, .pill, .badge, .wiki-tag */
|
||||
--radius-panel: 12px; /* .panel-flat */
|
||||
--radius-card: 10px; /* .card, .panel */
|
||||
--radius-input: 8px; /* .input, .textarea, .select, .btn-sq, .note, .rte, .prose img */
|
||||
}
|
||||
```
|
||||
|
||||
The 7px (`.rte-btn`) and 6px (`.rte-linkmenu-item`) values stay literals — they are
|
||||
interior editor chrome, not brand surface. The preset blocks in §6 carry corrected
|
||||
`--radius-card` values accordingly.
|
||||
|
||||
### 4.8 Parchment is a light-mode port, not a preset
|
||||
|
||||
`theme.css` carries 28 `rgba()` literals that assume a dark background — `.pill`'s
|
||||
`rgba(11,22,48,0.5)` fill, `.note`'s background, all seven `.badge-*` fills, the
|
||||
diff add/del colors, `.moon`'s radial gradient, `#dbe2ea` prose strong — plus the
|
||||
hero overlay stacks `rgba(11,15,20,…)` hardcoded in `heroLayout.js` and four route
|
||||
files, plus `rgba(9,13,18,0.86)` inline in `SiteHeader.jsx:55`. None of that
|
||||
responds to a `[data-theme]` variable block; Parchment would inherit dark chrome
|
||||
on a light background and look broken.
|
||||
|
||||
**Decision:** three dark presets in v1. Parchment becomes **Phase 9**, scoped as a
|
||||
light-mode port with its own contrast pass across every component.
|
||||
|
||||
### 4.9 The hero already has a third override layer
|
||||
|
||||
`hero_layout.background.image_url` **already** beats `brand.hero`
|
||||
([`heroLayout.js:39-50`](../../website/client/src/lib/heroLayout.js)). The real
|
||||
resolution order is:
|
||||
|
||||
```
|
||||
hero_layout.background.image_url → brand_assets.hero → BRAND_HERO → /assets/img/runic-emblem.png
|
||||
```
|
||||
|
||||
The doc's two-link chain omits the existing top link. The admin UI must say so
|
||||
explicitly, or "I uploaded a hero and the portal ignored it" becomes a bug report
|
||||
against a working system.
|
||||
|
||||
### 4.10 Favicon `.ico` is not possible without weakening the upload path
|
||||
|
||||
`MIME_EXT` in
|
||||
[`imageUpload.js:24-30`](../../website/server/src/router/v1/admin/imageUpload.js)
|
||||
has no `image/x-icon` or `image/vnd.microsoft.icon` entry, and the stored
|
||||
extension is derived from that map — which is exactly the property that makes the
|
||||
upload path safe. The doc floats "`.ico`/`.png` only" for favicons; the `.ico`
|
||||
half would mean adding a new file type to `/uploads`.
|
||||
|
||||
**Decision:** **PNG only** for favicons. `<link rel="icon">` accepts PNG in every
|
||||
browser this app supports, and the allowlist is left untouched. A tighter size cap
|
||||
than the shared 8 MB limit is applied at the route, not in the shared multer
|
||||
config.
|
||||
|
||||
### 4.11 Smaller notes
|
||||
|
||||
- **CSP is already fine.** [`config/csp.js:50-51`](../../website/server/src/config/csp.js)
|
||||
already allows `https://fonts.googleapis.com` in `style-src` and
|
||||
`https://fonts.gstatic.com` in `font-src`. The font shortlist needs no CSP
|
||||
change — which is worth stating, because widening CSP for a cosmetic feature
|
||||
would not be worth it.
|
||||
- **Do not touch the footer badge.**
|
||||
[`SiteFooter.jsx:19`](../../website/client/src/components/SiteFooter.jsx) is the
|
||||
hardcoded "powered by Runic Gateway" emblem. It is deliberately not the instance
|
||||
logo and must not follow `brand_assets.logo`.
|
||||
- **Nav labels do not reach the portal hero.** `hero_layout`'s
|
||||
`default-quick-links` element duplicates News / Screenshots / Five on Friday /
|
||||
Newsletter / About as its own buttons. Renaming those in the nav editor will not
|
||||
rename them on the portal; they are edited in the hero editor.
|
||||
- **The nav editor must refuse to hide its own entry.** Not a lockout — hiding is
|
||||
presentation-only and the URL still resolves — but recovering by typing a URL is
|
||||
a bad enough experience to be worth one guard.
|
||||
- **Process, per `CLAUDE.md`.** Every server-side phase requires
|
||||
`npm run swagger`, `npm run routes:manifest` (`routeManifest.test.js` fails
|
||||
otherwise), and a matching edit to
|
||||
[`BACKEND_DESIGN.md`](BACKEND_DESIGN.md). None of this is in the design doc.
|
||||
|
||||
## 5. Fonts: curated Google Fonts, not free text
|
||||
|
||||
`index.html` already loads Cinzel from Google Fonts, so this extends an existing,
|
||||
already-trusted pattern rather than introducing a new one.
|
||||
|
||||
**The dropdown's value — not free text — is what is stored.** Each option's value
|
||||
*is* the full CSS `font-family` stack exactly as it will be applied, so the client
|
||||
does zero string-building from admin input and `theme_visual` stays a closed set of
|
||||
known-safe values.
|
||||
|
||||
### 5.1 The shortlist
|
||||
|
||||
| Role | Option | Stored stack |
|
||||
|---|---|---|
|
||||
| **Serif body** | EB Garamond — strongest fantasy/historic | `'EB Garamond', Georgia, serif` |
|
||||
| | Merriweather — excellent readability | `Merriweather, Georgia, serif` |
|
||||
| | Playfair Display — elegant/editorial | `'Playfair Display', Georgia, serif` |
|
||||
| | IM Fell English — strongest old-world/UO flavor | `'IM Fell English', Georgia, serif` |
|
||||
| **Display heading** | Cinzel — current Runic Gateway identity | `Cinzel, Georgia, serif` |
|
||||
| | Playfair Display — elegant alternative | `'Playfair Display', Georgia, serif` |
|
||||
| | EB Garamond — softer/classic | `'EB Garamond', Georgia, serif` |
|
||||
| | IM Fell English — very strong fantasy | `'IM Fell English', Georgia, serif` |
|
||||
| **Sans UI** | Inter — default modern UI choice | `Inter, Arial, sans-serif` |
|
||||
| | Work Sans — slightly more character | `'Work Sans', Arial, sans-serif` |
|
||||
| | Source Sans 3 — extremely readable | `'Source Sans 3', Arial, sans-serif` |
|
||||
| | Arial — safe fallback/system option | `'Helvetica Neue', Arial, sans-serif` |
|
||||
|
||||
Two properties fall out of this list and are worth keeping:
|
||||
|
||||
- **Arial is the zero-cost option** — its stack is byte-identical to today's
|
||||
`--sans`, so it needs no webfont at all and doubles as the current default.
|
||||
- **Twelve slots, eight web families.** Playfair Display, EB Garamond and IM Fell
|
||||
English each serve two roles.
|
||||
|
||||
### 5.2 Loading
|
||||
|
||||
One combined request, not eight — Google Fonts accepts multiple `family=`
|
||||
parameters per URL, and the font *binaries* are only fetched when a family is
|
||||
actually applied:
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
```
|
||||
|
||||
Static, in `index.html`, alongside the existing `preconnect` hints — a Google
|
||||
Fonts URL is **never** built from admin input at runtime.
|
||||
|
||||
**Weight coverage gotcha:** IM Fell English ships **400 and italic only — no
|
||||
bold.** `.display` and `.h1` use `font-weight: 600`, and `.btn` / `.eyebrow` /
|
||||
`.badge` use 600–700, so choosing it yields browser-synthesized faux-bold. That is
|
||||
acceptable for the display role (it is the authentic look) but is a reason not to
|
||||
present it as a recommended body face.
|
||||
|
||||
## 6. Storage
|
||||
|
||||
Five new keys. `theme_visual`, `brand_assets` and `nav_public` join `PUBLIC_KEYS`;
|
||||
`nav_admin` and `nav_player` are served by the authenticated endpoint from §4.2.
|
||||
All are JSON strings, absent by default.
|
||||
|
||||
### 6.1 `theme_visual`
|
||||
|
||||
```json
|
||||
{ "preset": "runic-gateway", "custom": null }
|
||||
```
|
||||
|
||||
or, when the admin picks Custom:
|
||||
|
||||
```json
|
||||
{
|
||||
"preset": "custom",
|
||||
"custom": {
|
||||
"colors": { "bg": "#0e1318", "bgDeep": "#0b0f14", "panelA": "#192231", "panelB": "#141a21",
|
||||
"accent": "#7f99bd", "accentBright": "#cdd9e8", "ink": "#eef3f8", "text": "#c4cdd8" },
|
||||
"structure": { "radiusPill": "999px", "radiusPanel": "12px", "radiusCard": "10px",
|
||||
"radiusInput": "8px", "shadowDepth": "0 14px 34px rgba(0,0,0,0.3)" },
|
||||
"fonts": { "serif": "'EB Garamond', Georgia, serif",
|
||||
"display": "Cinzel, Georgia, serif",
|
||||
"sans": "Inter, Arial, sans-serif" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`colors` / `structure` / `fonts` are independently overridable groups — a custom
|
||||
accent without touching radius or fonts is expected. A group or field the admin
|
||||
never touched falls back to whatever preset or `:root` value is active. **Never
|
||||
null a field out to "clear" it** — remove it from the object.
|
||||
|
||||
### 6.2 Preset blocks
|
||||
|
||||
`:root` (no `data-theme` attribute set at all) stays the **Runic Gateway** default
|
||||
— today's actual values — so an instance with no `theme_visual` row renders
|
||||
exactly as it does now. `runic-gateway` is *also* declared as a named preset so
|
||||
that switching back to it after trying another is the same code path.
|
||||
|
||||
```css
|
||||
[data-theme="runic-gateway"] {
|
||||
--bg: #0e1318; --bg-deep: #0b0f14; --panel-a: #192231; --panel-b: #141a21;
|
||||
--accent: #7f99bd; --accent-bright: #cdd9e8; --ink: #eef3f8; --text: #c4cdd8;
|
||||
--radius-pill: 999px; --radius-panel: 12px; --radius-card: 10px; --radius-input: 8px;
|
||||
--serif: Georgia, "Times New Roman", serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Modern — flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy. */
|
||||
[data-theme="modern"] {
|
||||
--bg: #101114; --bg-deep: #0a0a0c; --panel-a: #1c1d22; --panel-b: #17181c;
|
||||
--accent: #4f8ef7; --accent-bright: #a8c8ff; --ink: #f2f3f5; --text: #b8bcc4;
|
||||
--radius-pill: 8px; --radius-panel: 8px; --radius-card: 6px; --radius-input: 6px;
|
||||
--serif: Inter, Arial, sans-serif;
|
||||
--display: 'Work Sans', Arial, sans-serif;
|
||||
--sans: Inter, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Fantasy — warmer, higher contrast, carved corners; leans into UO harder. */
|
||||
[data-theme="fantasy"] {
|
||||
--bg: #1a120b; --bg-deep: #120c07; --panel-a: #2c1f14; --panel-b: #241a10;
|
||||
--accent: #c9973f; --accent-bright: #e8c374; --ink: #f3e8d4; --text: #d3bfa0;
|
||||
--radius-pill: 4px; --radius-panel: 3px; --radius-card: 2px; --radius-input: 2px;
|
||||
--serif: 'EB Garamond', Georgia, serif;
|
||||
--display: Cinzel, Georgia, serif;
|
||||
--sans: 'EB Garamond', Georgia, serif;
|
||||
}
|
||||
```
|
||||
|
||||
**Derived-token rule (do not break this):** `--panel-grad` and `--shadow-card` must
|
||||
stay expressed *in terms of* the other variables, never written as a literal
|
||||
gradient in a preset block. If `--panel-grad` is ever hardcoded, a future light
|
||||
preset silently inherits a dark gradient and looks broken. Likewise `--mode-live`
|
||||
and `--mode-maint` (the status dots) are **semantic** — green means live — and stay
|
||||
fixed across all presets rather than being themed.
|
||||
|
||||
### 6.3 `brand_assets`
|
||||
|
||||
```json
|
||||
{ "logo": null, "hero": null, "favicon": null }
|
||||
```
|
||||
|
||||
Each field, once set, holds the stored upload URL (`/uploads/1234-abcd.png`) — the
|
||||
same shape `POST /admin/uploads` already returns. A `null` or absent field falls
|
||||
back to `brand.logo` / `brand.hero` / `brand.favicon`; uploading a logo does not
|
||||
force the admin to also pick a hero.
|
||||
|
||||
### 6.4 `nav_public` / `nav_admin` / `nav_player`
|
||||
|
||||
Keyed by the item's existing `to`:
|
||||
|
||||
```json
|
||||
{
|
||||
"/admin/posts": { "label": "Blog Posts", "order": 10 },
|
||||
"/admin/settings": { "hidden": true },
|
||||
"/admin/moderation": { "order": 5, "group": "Content" }
|
||||
}
|
||||
```
|
||||
|
||||
Any field absent for a given `to` falls back to the code default — label from
|
||||
`NAV`, natural array order, `hidden: false`, original group. **Unknown `to` values
|
||||
(not present in the current code's base array) are ignored, not stored and later
|
||||
honored**, so removing a route in code can never leave a dangling override that
|
||||
does something unexpected.
|
||||
|
||||
## 7. Navigation: hard constraint
|
||||
|
||||
The override system can **only** affect `label`, `order`, `hidden`, and — admin
|
||||
nav only — `group` (which *existing* titled section an item sits under).
|
||||
|
||||
It **cannot**:
|
||||
|
||||
- introduce a `to` that is not already in the corresponding hardcoded `NAV` array;
|
||||
- change or remove an item's `roles` (admin nav) or `feature` (public nav) gate;
|
||||
- un-hide an item for a viewer whose role or feature check would otherwise fail.
|
||||
|
||||
The existing filters in
|
||||
[`SiteHeader.jsx:43`](../../website/client/src/components/SiteHeader.jsx) and
|
||||
[`AdminLayout.jsx:155-164`](../../website/client/src/routes/admin/AdminLayout.jsx)
|
||||
run **after** the override merge, unchanged, and remain the actual security
|
||||
boundary. The override layer is presentation-only. This is the same
|
||||
"server-enforced gate, client-side is only about not advertising a dead end"
|
||||
principle already documented in `SiteHeader.jsx`'s comments, and this feature must
|
||||
not weaken it.
|
||||
|
||||
Two existing behaviors the merge must not disturb:
|
||||
|
||||
- **Moderator confinement.** `AdminLayout` restricts moderators to `MOD_PATHS` and
|
||||
redirects them out of anything else. Overrides apply before that filter, so a
|
||||
moderator can still end up with a legitimately short sidebar — but the redirect
|
||||
effect must keep working untouched.
|
||||
- **Empty groups.** `AdminLayout` drops groups whose items all filtered out. An
|
||||
override that hides every item in a group must produce no orphaned header.
|
||||
|
||||
### 7.1 Merge util
|
||||
|
||||
New shared pure module, `client/src/lib/navOverrides.js`:
|
||||
|
||||
```js
|
||||
function applyNavOverrides(baseNav, overrides) {
|
||||
// baseNav: the existing hardcoded array / grouped array — remains the source
|
||||
// of truth for `to`, `roles`, `feature`, `icon`, `end`
|
||||
// overrides: the parsed settings JSON, or null when the admin never touched it
|
||||
// returns: a new array of the same shape with label/order/hidden/group applied
|
||||
}
|
||||
```
|
||||
|
||||
`overrides` absent → return `baseNav` unchanged. This is the "respect defaults"
|
||||
path and is the single most important case to test.
|
||||
|
||||
## 8. Build phases
|
||||
|
||||
Each phase is independently shippable and leaves the site rendering identically to
|
||||
today until the admin acts.
|
||||
|
||||
| Phase | Work |
|
||||
|---|---|
|
||||
| **0 — Settings-store groundwork** | `settingsDb.remove()`; `DELETE /admin/settings/:key` with key allowlist; `GET /settings/nav` (§4.2); `parseJsonSetting()` helper; register the five keys; three into `PUBLIC_KEYS`. Swagger + route-manifest regen |
|
||||
| **1 — `navOverrides.js` + tests** | The pure merge util, unit-tested in isolation. **The one piece with real correctness risk** |
|
||||
| **2 — Radius/shadow token groundwork** | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||
| **3 — Theme engine** | Three preset blocks, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||
| **4 — Admin theme UI** | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
||||
| **6 — Public nav wiring** | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
|
||||
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
|
||||
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
|
||||
| **9 — Parchment (optional)** | Light-mode port per §4.8 — its own contrast pass across every component |
|
||||
|
||||
Phases 0–2 are one PR pair (website + docs), 3–4 a second, 5 a third, 6–8 a
|
||||
fourth.
|
||||
|
||||
### 8.1 Admin builder UI notes
|
||||
|
||||
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||
- **The palette is filtered to the editing admin's own visible items** — the base
|
||||
array run through *their* role/feature check — so an admin cannot drag in, and
|
||||
therefore can never accidentally expose, an item they cannot already see
|
||||
themselves. A deliberate UX guardrail on top of the merge-time enforcement.
|
||||
- Per item: label input with a "reset to default" that clears the override, an eye
|
||||
toggle for `hidden`, and on the Admin tab a group dropdown limited to the fixed
|
||||
set of titles already in `NAV`.
|
||||
- "Reset to defaults" per nav **deletes the row** (§4.1), never saves `{}`.
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- Fresh instance, no admin action: colors, fonts, radii, brand assets and all
|
||||
three navs render byte-for-byte as today, driven by `BRAND_*` and the current
|
||||
hardcoded `theme.css` / `NAV` arrays.
|
||||
- After Phase 2 and before any admin UI exists, the rendered site is visually
|
||||
identical — the token promotion is a true no-op.
|
||||
- Setting `theme_visual.custom.colors` alone changes colors only; radius, fonts,
|
||||
assets and nav are unaffected.
|
||||
- Font dropdowns only ever produce values from the §5.1 shortlist. No admin input
|
||||
is concatenated into a `font-family` string or a Google Fonts URL at runtime.
|
||||
- Setting only `brand_assets.favicon` changes the served favicon only — the OG
|
||||
image and hero backgrounds still resolve from `brand.js` env values.
|
||||
- With no `brand_assets` row, the served HTML shell is **byte-identical** to
|
||||
today's. Covered by a server-side test in `publicBrand.test.js`.
|
||||
- Uploaded assets go through the existing `imageUpload.js` mimetype allowlist. No
|
||||
second upload path with weaker validation.
|
||||
- `getPublic().brand` with no new rows returns exactly what it returns today —
|
||||
the existing `publicBrand.test.js` assertions pass verbatim.
|
||||
- An admin cannot, through the nav builder, cause any user to see a nav item their
|
||||
role/feature gate would otherwise hide. Verified by overriding `hidden: false`
|
||||
on a role-gated item as a lower-privileged test admin and confirming the filter
|
||||
still hides it.
|
||||
- Deleting a theme/asset/nav row returns that surface to env/code defaults, not to
|
||||
a stored copy of the defaults.
|
||||
Reference in New Issue
Block a user