Phases 1 and 2 now live on the installer repo's `edge` branch, so PLAN.md's status, the config-path section, and the operator guide all move with them. PLAN.md - Status: phases 1 and 2 built. The `edge -> main` cutover now follows Phase 3 rather than Phase 2, because INSTALL.md §4 describes the patch tier as part of the run and a release that answers "not implemented" to all of it is the same half-capable binary that kept Phase 1 off `main`. - §2.3: the service definition always pins the config path, but only Linux pins the database. On Windows config and data share a directory, so the sidecar's own anchoring rule already lands it correctly — and `sc.exe` offers no per-service environment, only a machine-wide one that every process inherits and that outlives an uninstall. - New "Phase 2 as built" section: the virtual service account, the config lockdown and why its two halves straddle registration, `--verify` running no part of the sidecar half, the protocol check against the installed binary, `RUNICGATEWAY_STATE_DIR` relocating the binary and suppressing service registration, degrading to a printed recipe with no root/LocalSystem fallback, and the token never entering install.json. - §8 question 1 (Windows service mechanism) resolved: `sc create`, as recommended — plus the service identity the recommendation did not anticipate. INSTALL.md - Status banner: what is built, and that the patch tier is the remaining gap. - §2: the illustrated run matches the sidecar block the binary actually prints. - §3: a table of how each platform pins config and database, the dedicated service account on both, and the fact that sidecar.toml's permissions are restricted because it holds the auth token. - Appendix A4: the Windows recipe now matches what the installer does — `--config` in binPath (single-quoted so PowerShell keeps the inner quotes), `obj=` for the virtual account, the icacls lockdown before and grants after, and no machine-wide environment variables. - Troubleshooting: a row for a run that could not register a service, and one for a service that starts and immediately stops. Co-Authored-By: Claude <noreply@anthropic.com>
940 lines
62 KiB
Markdown
940 lines
62 KiB
Markdown
# Runic Gateway Installer — plan
|
||
|
||
Status: **Phases 1 and 2 built, on `edge`.** Phase 0's prerequisites all landed, the installer repo
|
||
publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) specified the operator-facing run
|
||
before the binary existed. The crate now implements the installer core (bundle resolution, ServUO
|
||
detection and validation, the overlay sync, `install.json` — [Phase 1 as
|
||
built](#phase-1--installer-core)) and the sidecar half (binary, config, service, token handoff —
|
||
[Phase 2 as built](#phase-2--uo-link-install-and-service)). Both are on the `edge` branch, not
|
||
`main`, so no half-capable binary is released. **Phase 3 (the patch tier) is next, and the
|
||
`edge → main` cutover follows it** rather than Phase 2: `INSTALL.md` §4 describes the tier as part
|
||
of the run, and a first release whose every patch-tier answer is "not implemented" is the same
|
||
half-capable binary that kept Phase 1 off `main`.
|
||
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/main/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 |
|
||
| — 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)) |
|
||
|
||
---
|
||
|
||
## 1. Purpose
|
||
|
||
Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while
|
||
keeping the components separated and independently maintainable.
|
||
|
||
The installer handles environment detection, ServUO overlay deployment, the optional stock-file
|
||
patch tier, uo-link installation and service registration, version tracking, diagnostics, and
|
||
updates from Gitea releases.
|
||
|
||
**It is a deployment tool, not a hosted bootstrapper.** There is no `curl | bash`, no installer
|
||
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.
|
||
|
||
**It does not replace ServUO startup behavior.** ServUO keeps running through its existing
|
||
release/start scripts. The installer never writes a launcher.
|
||
|
||
### Decisions locked
|
||
|
||
| Question | Decision |
|
||
|---|---|
|
||
| Audience | **Public** — any ServUO operator, not just shards we run |
|
||
| Code signing | **Unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
|
||
| Language | **Rust** — single static binary per OS, reuses the cross-compile pattern already proven in `link/.gitea/workflows/release.yml` |
|
||
| Plugin source | **Release tarball artifact** — no git and no Gitea credentials on the shard host |
|
||
| 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) |
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## 2. Corrections to the original overview
|
||
|
||
These are not wording nits — each one changes what the installer has to do.
|
||
|
||
### 2.1 There is no `RunicGateway.dll` and no `Plugins/` directory
|
||
|
||
The plugin ships as **C# source** and ServUO compiles it at boot. The real deployable is
|
||
`servuo-plugins/overlay/`, which mirrors the server root:
|
||
|
||
```
|
||
overlay/
|
||
├── Config/Bridge.cfg
|
||
└── Scripts/
|
||
├── Scripts.csproj # Phase 0 — whole-file overwrite of a stock file
|
||
└── Custom/Bridge/*.cs # 22 files
|
||
```
|
||
|
||
So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
|
||
overview assumed. The sting is that **a successful copy does not mean a working bridge.** Per
|
||
`link/SHARD_PREREQS.md`, `ScriptCompiler.Compile()` shells out to `dotnet build`, prints the output,
|
||
**ignores the exit code**, and reloads the existing `Scripts.dll`. A broken script build is
|
||
invisible: the shard boots clean on stale code. Diagnostics must therefore verify *post-boot* state,
|
||
never treat "files copied" as success.
|
||
|
||
### 2.2 Stock ServUO files *are* modified — by an optional tier
|
||
|
||
`servuo-plugins/patches/` holds unified diffs against stock ServUO 57.4, plus two `.cs` files that
|
||
can only be copied *after* their patch lands (they reference symbols the patch introduces):
|
||
|
||
| Patch | Target | Companion file | Rebuild required |
|
||
|---|---|---|---|
|
||
| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | `BridgeVendorSale.cs` | **Core** — `dotnet build ServUO.sln`; the dynamic script build is not enough |
|
||
| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | (same unit as above) | script build |
|
||
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | `BridgeModerationAudit.cs` | script build |
|
||
|
||
Plus `overlay/Scripts/Scripts.csproj`, which overwrites a stock file (Phase 0 — it fixes the silent
|
||
ServUO build bug above).
|
||
|
||
This is the hardest part of the installer. `git apply` against a hand-modified shard will fail, and
|
||
most real shards are hand-modified. Therefore:
|
||
|
||
- The patch tier is **opt-in and skippable**. The base install must complete without it.
|
||
- Always dry-run (`git apply --check`) before applying, and report per-patch.
|
||
- When skipped or failed, say plainly what is lost: **no `vendor.sale` events, no in-game moderation
|
||
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 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).
|
||
|
||
#### 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.** 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.
|
||
- **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.
|
||
|
||
### 2.3 Config paths collide with what the sidecar actually reads
|
||
|
||
The sidecar reads `$UOLINK_CONFIG`, else `sidecar.toml` in the **working directory**
|
||
(`link/sidecar/src/config.rs`), with keys `[shard].bind`, `[web].bind`, `[web].auth_token`,
|
||
`[store].path`. The overview proposed a `config.toml` with `[updates]`, `[link]`, `[servuo]` — keys
|
||
the sidecar cannot read.
|
||
|
||
Two files, two owners:
|
||
|
||
| File | Owner | Contents |
|
||
|---|---|---|
|
||
| `/etc/runicgateway/sidecar.toml` | uo-link | The sidecar's own schema, unchanged. Service sets `UOLINK_CONFIG` to this path |
|
||
| `/etc/runicgateway/install.json` | installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |
|
||
|
||
**Working-directory trap:** the sidecar wrote both `sidecar.toml` and `uo-link.db` relative to CWD.
|
||
Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.2 fixed the second
|
||
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:
|
||
|
||
- 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
|
||
the install flow: the sidecar generates a token on first run and logs it, then a human pastes base
|
||
URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and
|
||
becomes write-only. This is the largest "I installed it and nothing happened" failure mode.
|
||
|
||
The installer closes it by printing a copy-paste block at the end of a successful run — see §6.
|
||
|
||
Phase 0.2 supplied the missing half of that: `uo-link-sidecar --print-config` provisions the config
|
||
if absent and prints the resolved settings — token, both binds, `ws_path`, protocol version, db
|
||
path — as JSON. The installer reads the block it prints out of that one call. **It never parses the
|
||
log**, which was the alternative and would have made the handoff depend on a log format that is not
|
||
a contract.
|
||
|
||
### 2.5 `deploy.ps1` cannot be the cross-platform deployer
|
||
|
||
It is PowerShell-only; a Linux ServUO host running .NET typically has no `pwsh`. It also hard-throws
|
||
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
|
||
refuse, rather than corrupt a live `Scripts.dll`). The installer reimplements the sync natively; it
|
||
is a short hash-compare-and-copy that never deletes.
|
||
|
||
`deploy.ps1` **stays** in `servuo-plugins` as the developer-facing tool. The installer is for
|
||
operators.
|
||
|
||
### 2.6 Prerequisites the overview assumed away
|
||
|
||
- **`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 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
|
||
a home: `servuo-plugins/overlay.toml`, declared into the overlay manifest. See §7.0 / §7.1.
|
||
|
||
---
|
||
|
||
## 3. Distribution model
|
||
|
||
Components are published as Gitea release artifacts. Operators download from the release page
|
||
(browser, `curl`/`wget`, or `scp` to the server) and run the binary.
|
||
|
||
```
|
||
Runic Gateway Installer v1.0.0
|
||
├── runicgateway-installer-windows-x86_64.exe
|
||
├── runicgateway-installer-linux-x86_64
|
||
└── SHA256SUMS
|
||
|
||
uo-link v1.1.0 (existing release, extended)
|
||
├── uo-link-sidecar-windows-x86_64.exe
|
||
├── uo-link-sidecar-linux-x86_64
|
||
├── runicgateway-link_<ver>_amd64.deb (Phase 5)
|
||
└── SHA256SUMS
|
||
|
||
servuo-plugins v<ver> (new release, Phase 0)
|
||
├── runicgateway-overlay-<ver>.tar.gz # overlay/ + patches/ + manifest.json
|
||
└── SHA256SUMS
|
||
```
|
||
|
||
Binding those together is the **bundle manifest** (§7.1) — published by the installer repo's CI, not
|
||
by any component, and the thing the installer actually resolves against.
|
||
|
||
```bash
|
||
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
|
||
chmod +x runicgateway-installer-linux-x86_64
|
||
sudo ./runicgateway-installer-linux-x86_64
|
||
```
|
||
|
||
### Unsigned-binary posture
|
||
|
||
Because releases are unsigned, trust is anchored on checksums and the operator's own verification.
|
||
The docs must state this up front rather than let users discover it as a scary dialog:
|
||
|
||
- Every release publishes `SHA256SUMS`; the install docs lead with the verification command for both
|
||
OSes.
|
||
- Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
|
||
- The installer verifies the SHA256 of everything **it** downloads (overlay tarball, sidecar binary)
|
||
against the release's `SHA256SUMS` and refuses on mismatch. Self-verification is not optional just
|
||
because the installer itself is unsigned.
|
||
- Revisit signing if it ever becomes affordable; the release layout should not have to change.
|
||
|
||
---
|
||
|
||
## 4. Component architecture
|
||
|
||
```
|
||
Runic Gateway Installer (Rust, one binary per OS)
|
||
│
|
||
┌───────────────┴────────────────┐
|
||
▼ ▼
|
||
ServUO integration uo-link
|
||
│ │
|
||
┌────────┴────────┐ ┌────────┴────────┐
|
||
▼ ▼ ▼ ▼
|
||
overlay sync patch tier (opt-in) binary install service registration
|
||
(never deletes) (per-region rungs) + config + data (systemd / Windows SCM)
|
||
```
|
||
|
||
Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
|
||
|
||
---
|
||
|
||
## 5. Phases
|
||
|
||
### Phase 0 — prerequisites (no installer code)
|
||
|
||
Repo work that must land before an installer can exist.
|
||
|
||
1. **`servuo-plugins`: add `.gitea/workflows/release.yml`.** Retarget the release *engine* half of
|
||
`link/release.yml` (its header comment explicitly anticipates this — the plan/release steps
|
||
consume only `{version, changelog, artifacts}`). The adapter half produces
|
||
`runicgateway-overlay-<ver>.tar.gz` containing `overlay/`, `patches/`, and a `manifest.json`
|
||
(version, commit, per-file SHA256, declared protocol version, minimum ServUO version).
|
||
|
||
As built ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)),
|
||
with three deviations from `link`'s copy that each fell out of the repo rather than being chosen:
|
||
|
||
- **No build gates, structural gates instead.** Nothing in that repo can be compiled without
|
||
ServUO reference assemblies, so CI asserts what it honestly can: `Bridge.cfg` and the Bridge
|
||
scripts present, `Scripts.csproj` present (its absence ships code that never compiles while
|
||
ServUO reports success — §2.1), every `.patch` parseable via `git apply --stat`, and each
|
||
patch's companion `.cs` present.
|
||
- **No bump commit, so no push to `main`.** `link` writes the version into `Cargo.toml` because
|
||
the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag *is*
|
||
the version. That workflow needs no branch-protection exception.
|
||
- **`overlay.toml` at the repo root** holds the declared `protocol` and the ServUO compatibility
|
||
values, read by CI into the manifest. It exists because the number needs one maintained home —
|
||
see §7 for why the plugin cannot simply be asked.
|
||
|
||
The tarball uses a **fixed** top-level directory, `runicgateway-overlay/`, not a versioned one:
|
||
the installer looks for `overlay/`, `patches/` and `manifest.json` at known paths rather than
|
||
parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a
|
||
given tree yields a byte-identical tarball and its checksum moves only when its contents do.
|
||
2. **`link`: make the sidecar installable.** Confirm/settle default data paths, and add a way to
|
||
read back config non-interactively (e.g. `--print-config` emitting JSON: bind addresses, token,
|
||
protocol version, db path) so the installer does not have to scrape logs for the token.
|
||
|
||
As built ([link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24)) — the sidecar
|
||
had **no CLI at all** before this, so the shape was chosen rather than inherited:
|
||
|
||
- **Four flags, hand-rolled:** `--print-config`, `--config <PATH>`, `--version`, `--help`. No
|
||
argument-parsing crate — it would be larger than the code it replaced — and deliberately no
|
||
flags that duplicate a config key, so `sidecar.toml` stays the single place settings live.
|
||
An unrecognized argument exits `2`; silently ignoring a typo'd flag would start a sidecar that
|
||
is not the one the installer asked for.
|
||
- **`--print-config` performs first-run setup rather than only reporting.** It runs the same
|
||
load path a normal start does, so a missing config file is written and a blank token is
|
||
generated and saved. That collapses "provision the sidecar" and "find out its token" into one
|
||
non-interactive call — which is exactly the sequence §6 needs. `config_created` and
|
||
`token_generated` say whether *this* run did either, because the values alone cannot
|
||
distinguish a fresh install from a re-read of an existing one, and a re-run must not report a
|
||
token as newly minted.
|
||
- **The document is the whole of stdout.** The log subscriber writes to stdout, so it is not
|
||
started in this mode. `ws_path` is emitted from the same constant the route is registered
|
||
with, so the installer's WebSocket URL cannot drift from the server's.
|
||
- **Relative `[store].path` now anchors to the config file's directory, not the CWD** — see
|
||
§2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent
|
||
directories are created; `:memory:` and `file:` URIs are left alone.
|
||
- **The db path is handed to sqlx as a path, not a `sqlite://` URL.** The URL spelling is
|
||
parsed as one: it percent-decodes the path and splits it on `?`, so an installed path
|
||
containing `%20` opened a different file than the operator named.
|
||
- **No platform data directories are compiled in.** That is the "settle" half of this item, and
|
||
the answer is that the *installer* owns layout (§2.3) and pins `UOLINK_CONFIG` /
|
||
`UOLINK_DB_PATH` in the service definition. Baking `/etc` and `%ProgramData%` defaults into
|
||
the binary would give the same paths two owners and break `cargo run` in a working tree.
|
||
3. **Bundle CI in the installer repo** (§7). Compose job (read both repos' latest releases → run the
|
||
two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each
|
||
component's release workflow. This must exist before Phase 1 is useful, since the installer
|
||
resolves what to install *from* the bundle.
|
||
|
||
As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3),
|
||
[link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25),
|
||
[servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) —
|
||
`installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open:
|
||
|
||
- **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.
|
||
- **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
|
||
`sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution
|
||
of a downloaded artifact, and does not provision a throwaway config whose auth token would then
|
||
be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating
|
||
"could not read" as "matches" is exactly how a mismatched pair would ship.
|
||
- **Gate 2 records the hash CI computed itself**, after verifying the download against the
|
||
publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no*
|
||
`SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not
|
||
mention, which would put an unverified artifact in the bundle.
|
||
- **Release metadata is read anonymously**, on purpose: those are exactly the requests the
|
||
shipped installer makes on a host with no Gitea credentials, so a repo flipped to private
|
||
fails CI here instead of on an operator's machine.
|
||
- **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys
|
||
by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job
|
||
rather than silently omitting the new binary from every bundle.
|
||
- **A run that changes nothing writes nothing** — the comparison excludes `bundle` and
|
||
`generated`, which are metadata about the run. Without that the nightly cron would commit a
|
||
dated duplicate of the same matrix every morning.
|
||
|
||
The workflow's compose steps were run against the live releases before merge, producing the
|
||
first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so
|
||
the manifest exists ahead of the binary that reads it.
|
||
4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the
|
||
shape is settled.
|
||
|
||
As built ([`INSTALL.md`](INSTALL.md)) — written *before* the binary on purpose. Everything it
|
||
installs is already released (items 1–3), so the guide is not speculation about a tool that
|
||
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 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
|
||
in later examples.
|
||
- **The flag surface got fixed here**, because a guide cannot describe a run in the abstract:
|
||
`--verify`, `--bundle`, `--purge` were already named by §5/§7; `--servuo`, `--patches` /
|
||
`--no-patches`, `--host`, `--site-url` and `--yes` are the remainder, chosen so every prompt
|
||
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.
|
||
|
||
### Phase 1 — installer core
|
||
|
||
- ServUO root detection and validation (`ServUO.exe`, `Scripts/`, `Config/`), with version detection
|
||
and an explicit refusal when the ServUO process is running.
|
||
- Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
|
||
**never delete**. Port of `deploy.ps1` semantics including its `-Verify` dry run (`--verify`).
|
||
- **One deviation from `deploy.ps1`: an operator-modified `Config/Bridge.cfg` is reported, not
|
||
overwritten.** `deploy.ps1` overwrites every file whose hash differs, which is right for a
|
||
developer redeploying their own tree and wrong for an operator who has set `LinkUrl`,
|
||
`PublicConnectAddress` and sweep intervals — an `update` would silently revert the shard's entire
|
||
configuration. `install.json` records the hash deployed, so the installer can distinguish "the
|
||
operator edited this" from "the overlay moved on" (§7.0) and act only on the second. The rule is
|
||
specific to `Bridge.cfg`: it is the only file in the overlay that is *meant* to be edited in
|
||
place, and it carries no code, so a stale copy cannot break the build. Every `.cs` file and
|
||
`Scripts.csproj` still overwrite unconditionally.
|
||
- Write `install.json`: component, version, source commit, per-file hashes, applied patches,
|
||
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 →
|
||
`/var/lib/runicgateway/`, systemd unit with a dedicated user, `enable` + `start`.
|
||
- Windows: `%ProgramFiles%\RunicGateway\`, data in `%ProgramData%\RunicGateway\`, service
|
||
registration with automatic start and restart-on-failure.
|
||
- Both: `UOLINK_CONFIG` and `UOLINK_DB_PATH` pinned in the service definition (§2.3).
|
||
- Token surfacing (§6): run the installed binary once as
|
||
`uo-link-sidecar --print-config --config <the pinned path>` **before** registering the service.
|
||
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.
|
||
|
||
### 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).
|
||
|
||
### Phase 4 — diagnostics and updates
|
||
|
||
`runicgateway doctor` — the command that makes the whole thing supportable:
|
||
|
||
```
|
||
✓ 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
|
||
✓ 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 shard has dialed in since boot
|
||
```
|
||
|
||
The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge
|
||
actually works" (§2.1).
|
||
|
||
Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem:
|
||
`--version` prints `uo-link-sidecar <ver> (protocol <n>)`, and `--print-config` gives the config and
|
||
db paths the *installed service* resolves — so `doctor` reports what the binary would actually do,
|
||
not what `install.json` believes it was told to do. The protocol row compares that number against
|
||
the overlay manifest's declared one (§7.0).
|
||
|
||
`runicgateway update` — resolves the current bundle (§7.1), then acts asymmetrically by component,
|
||
deliberately:
|
||
|
||
- **uo-link**: compare the bundle's version against what is installed → download → verify checksum →
|
||
replace binary → restart service.
|
||
- **plugin overlay**: download the bundle's overlay tarball → verify → re-sync (leaving a modified
|
||
`Bridge.cfg` alone — Phase 1) → record commit → tell the operator ServUO must restart (the
|
||
installer does not restart the shard).
|
||
|
||
Because both come from one bundle, an update always moves to a combination whose protocol versions
|
||
were checked together, rather than to two independently-latest artifacts that may disagree.
|
||
|
||
`runicgateway uninstall` — **removes only what it exclusively owns, and never edits the ServUO
|
||
tree.** The installer cannot know what the operator has changed in those files since deployment, so
|
||
a clever automatic revert risks silently eating their work. It removes and it reports:
|
||
|
||
| Action | Scope |
|
||
|---|---|
|
||
| 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 |
|
||
|
||
The printed report is also written to a file, so it survives the terminal scrollback of a long
|
||
uninstall.
|
||
|
||
### Phase 5 — packaging polish
|
||
|
||
`.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.
|
||
|
||
---
|
||
|
||
## 6. Token handoff (the end of a successful run)
|
||
|
||
```
|
||
Runic Gateway is installed.
|
||
|
||
One manual step remains — connect the website to this sidecar:
|
||
|
||
Base URL http://<this-host>:8080
|
||
WebSocket URL ws://<this-host>:8080/ws
|
||
Protocol version 3
|
||
Auth token 4f9c... (also in /etc/runicgateway/sidecar.toml)
|
||
|
||
Paste these into Admin → Shard on your Runic Gateway site:
|
||
https://<your-site>/admin/shard
|
||
|
||
The token is write-only once saved — the site will never show it back to you.
|
||
```
|
||
|
||
Every value in that block except the host and the site URL comes from one
|
||
`uo-link-sidecar --print-config` call (§2.4): `web.auth_token`, `protocol`, and `web.bind` +
|
||
`web.ws_path` for the two URLs. Only the **host** is substituted — `web.bind` is frequently
|
||
`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.
|
||
|
||
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
|
||
later without changing anything here.
|
||
|
||
The printed token is a secret in transit: `--print-config` output must go to the operator's
|
||
terminal and the config file, never into an installer log file or a support bundle.
|
||
|
||
---
|
||
|
||
## 7. Version tracking, the bundle, and release orchestration
|
||
|
||
Three components version independently, bound by a protocol contract:
|
||
|
||
- **sidecar** — `PROTOCOL_VERSION` in `link/sidecar/src/main.rs`, exposed on `/health` and as
|
||
`X-UOLink-Version` on every response; a mismatch is rejected `409`.
|
||
- **website** — stores an expected protocol version in `uoLinkConfig` (admin-managed).
|
||
- **plugin overlay** — has no queryable version before ServUO boots. The overlay release
|
||
`manifest.json` declares it, and `install.json` records what was deployed.
|
||
|
||
### 7.0 The overlay manifest
|
||
|
||
Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo's release workflow:
|
||
|
||
```json
|
||
{
|
||
"component": "servuo-plugins-overlay",
|
||
"version": "0.1.0",
|
||
"commit": "968b526…",
|
||
"repo": "RunicGateway/servuo-plugins",
|
||
"protocol": 3,
|
||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||
"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`; `files` is a SHA256 per shipped file.
|
||
|
||
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
|
||
this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated in
|
||
`overlay.toml` and in that repo's README: **bump it in the same PR that changes the emitters**, the
|
||
way `link` bumps `PROTOCOL_VERSION`.
|
||
- **`files` is what makes `doctor` able to tell "the operator edited a deployed file" from "the
|
||
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.
|
||
|
||
`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
|
||
against exactly one version (§2.2).
|
||
|
||
### 7.1 The bundle manifest
|
||
|
||
**The bundle is the compat matrix.** Rather than the installer hardcoding versions or blindly
|
||
resolving "latest", CI publishes a small manifest naming an exact, checked combination:
|
||
|
||
```json
|
||
{
|
||
"schema": 1,
|
||
"bundle": "2026.08.04",
|
||
"generated": "2026-08-04T16:07:13Z",
|
||
"protocol": 3,
|
||
"link": {
|
||
"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…" },
|
||
"windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
|
||
}
|
||
},
|
||
"overlay": {
|
||
"repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
|
||
"commit": "3a52abb…", "protocol": 3,
|
||
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
|
||
"asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
|
||
}
|
||
}
|
||
```
|
||
|
||
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. `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
|
||
reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of
|
||
JSON and leaves the installer binary untouched** — operators do not re-download the installer to
|
||
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.
|
||
|
||
Two gates run at compose time, both cheap and both worth it:
|
||
|
||
1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version.
|
||
This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator.
|
||
The two halves are read from different places because they *are* different: the overlay's from
|
||
`manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's
|
||
from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary).
|
||
2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`.
|
||
The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that
|
||
check — and the installer verifies every download against it. These artifacts are deliberately
|
||
unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody
|
||
verified would make the chain decorative.
|
||
|
||
#### Where bundles are published
|
||
|
||
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):
|
||
|
||
```
|
||
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.
|
||
|
||
**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. 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
|
||
|
||
| Trigger | Why |
|
||
|---|---|
|
||
| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step |
|
||
| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step |
|
||
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |
|
||
|
||
`repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version,
|
||
whereas dispatching an existing `workflow_dispatch` workflow via the API works today.
|
||
|
||
**A failed dispatch is a warning, never a failed release.** By the time that step runs the component
|
||
release is published and correct; failing the job would misreport it. This also keeps the dispatch
|
||
from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer
|
||
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
|
||
costs latency, not correctness, which is the whole reason the cron exists.
|
||
|
||
### 7.3 Stale-overlay handling: dispatch, don't wait
|
||
|
||
Each component **self-releases on merge to its own `main`**, using the same conventional-commit
|
||
engine. Note that "updated since the last release" must mean *releasable* commits — the engine sets
|
||
`RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not**
|
||
cut an overlay release, and the bundle keeps using the existing one.
|
||
|
||
The compose job's copy of that rule additionally **excludes merge commits**, whose subject is
|
||
`Merge pull request '<the real subject>'`. Without that, every squash-free merge of a `feat:` branch
|
||
would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a
|
||
`fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly
|
||
declines to run.
|
||
|
||
So by the time the installer's CI looks, the release normally already exists. If it finds
|
||
`servuo-plugins` main ahead of its latest release *with* releasable commits, it:
|
||
|
||
1. fires that repo's release workflow via workflow-dispatch and **does not wait for it**,
|
||
2. composes this bundle from the assets that exist right now,
|
||
3. writes a loud warning into the job summary.
|
||
|
||
The new overlay release lands minutes later on its own and the nightly cron folds it into the next
|
||
bundle. This gets the automation without the flaky part: dispatching another repo's workflow is
|
||
fine — that workflow still runs its own gates — but *polling* it is not, because Gitea's dispatch
|
||
endpoint returns no run handle, so the job would have to guess which run is its own and hold a
|
||
runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once
|
||
rather than being silently retriggered every night forever.
|
||
|
||
### 7.4 Open risk
|
||
|
||
**Settled as of the v3 cutover.** Protocol work landed on `edge` branches and the `edge → main`
|
||
cutover has now merged, so `main` speaks protocol 3 consistently across the repos. The rule it
|
||
motivated stands regardless and is not a temporary measure: **the installer hardcodes no protocol
|
||
version anywhere.** It reads what the artifacts declare, and §7.1's gate 1 is what stops a
|
||
mismatched pair from being published as a bundle — which is the mechanism that will matter at the
|
||
*next* protocol bump, not just this one. See `docs/link/v3.md`.
|
||
|
||
---
|
||
|
||
## 8. Open questions
|
||
|
||
1. **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
|
||
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). `sc create` against the plain console
|
||
binary, as recommended: it works on a stock host, ships nothing extra, and needs no change to
|
||
`link`. A WinSW/NSSM shim would be a third binary to keep current, and a native `--service` mode
|
||
using the `windows-service` crate would put Windows service plumbing inside a component whose whole
|
||
job is being platform-agnostic. Restart semantics turned out to be adequate —
|
||
`sc failure … actions= restart/5000` is the direct counterpart of systemd's `Restart=on-failure` /
|
||
`RestartSec=5`. What the recommendation did *not* anticipate is the service identity: plain
|
||
`sc create` runs as `LocalSystem`, so Phase 2 registers with `obj= "NT SERVICE\RunicGatewayLink"`
|
||
instead (see [Phase 2 as built](#phase-2--uo-link-install-and-service)).
|
||
|
||
**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
|
||
in principle — the installer hardcodes no protocol version, it reads what the artifacts declare —
|
||
but the specific `edge`/`main` disagreement that motivated it is gone.
|
||
|
||
---
|
||
|
||
## 9. Administrator experience
|
||
|
||
Before:
|
||
|
||
```
|
||
find plugins → copy files → edit ServUO → download bridge → start bridge
|
||
→ configure startup → find the token → troubleshoot paths
|
||
```
|
||
|
||
After:
|
||
|
||
```
|
||
download artifact → verify checksum → run installer → select ServUO directory
|
||
→ install components → paste 4 values into Admin → Shard → start ServUO normally
|
||
```
|