1 Commits

Author SHA1 Message Date
d0363cd62d docs(installer): lead the remote-website case with a reverse proxy
A TLS reverse proxy in front of the sidecar is a supported deployment already
running on a real domain, not the fallback the guide framed it as. Recommend it
first, keep [web] bind on loopback in that arrangement, and demote widen-the-
bind-and-firewall to the trusted-LAN alternative - on that path the token and
every event cross the network in the clear.

Adds the four things a proxy must actually do, checked against web.rs: forward
the WebSocket upgrade (/ws is the entire live feed, and losing it leaves REST
working with no events - a confusing half-working state); pass headers through
unmodified (auth is Authorization: Bearer or X-Api-Key, and a stripped
X-UOLink-Version silently skips the 409 mismatch check); no buffering and
long-lived connections (the sidecar pings every 30s, so a 60s+ read timeout is
safe as it stands); and no query-string logging, since ?token= is an accepted
auth form. Nothing needs X-Forwarded-For - the sidecar never uses the client IP
for authorization. Includes an nginx block that satisfies all four, and notes
Caddy and Traefik need no equivalent.

The website gets the proxied https:// / wss:// URLs, not the pair the installer
prints: those are composed from the sidecar's own bind address, which knows
nothing about what fronts it. PLAN records that the installer deliberately does
not try to detect a proxy - nothing visible from the sidecar's side says what is
in front of it, so guessing would print a confidently wrong URL.

Two new troubleshooting rows for the symptoms this causes: REST works but no
events (upgrade not forwarded), and a feed that drops every minute or two (read
timeout under the ping interval, or buffering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 12:12:46 -05:00
3 changed files with 137 additions and 590 deletions

View File

@@ -5,14 +5,6 @@ installation and connects it to a Runic Gateway website.
> **Status: the installer binary is not released yet.** > **Status: the installer binary is not released yet.**
> >
> Phases 1 to 3 are built and live on the installer repo's `edge` branch: the installer core
> (bundle resolution, ServUO detection, the overlay sync, `install.json`), the sidecar half (the
> binary, its config, its service, and the token handoff), and the
> [patch tier](#4-the-patch-tier-optional). `install` is therefore complete; what is still missing
> is [`doctor`, `update` and `uninstall`](#7-day-two), each of which reports which phase it arrives
> in rather than failing as though you had mistyped it. The first release is being cut from that
> branch now.
>
> Everything it installs *is* released and published — the sidecar, the plugin overlay, and the > 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) > [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 > that names the checked combination of the two. This guide is the operator-facing contract those
@@ -35,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 | | 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 | | 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 ServUO shard ──loopback TCP 127.0.0.1:7788──► uo-link sidecar ──HTTP + WebSocket──► website
@@ -61,7 +53,7 @@ Only the sidecar is exposed, and only to your website.
| Requirement | Detail | | 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. | | 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. | | 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. | | 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. | | 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. |
@@ -148,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 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/` sibling, otherwise prompted. A directory qualifies only if it contains `ServUO.exe`, `Scripts/`
and `Config/`. and `Config/`.
2. **Whether to apply the patch tier** — off unless you say yes. On a ServUO that is not 57.4 the 2. **Whether to apply the patch tier** — off unless you say yes, and not offered at all if your
prompt defaults to **no** and carries an unsupported-version warning you have to answer past. ServUO is not 57.4. See [§4](#4-the-patch-tier-optional).
See [§4](#4-the-patch-tier-optional).
3. **The hostname your website should use to reach this machine** — used only to compose the two 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`, 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. neither of which is something to hand to a website.
@@ -174,19 +165,16 @@ Overlay sync
ADD Config/Bridge.cfg ADD Config/Bridge.cfg
ADD Scripts/Custom/Bridge/*.cs (22 files) ADD Scripts/Custom/Bridge/*.cs (22 files)
CHANGE Scripts/Scripts.csproj 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. Without it: no vendor.sale events, no in-game moderation audit forwarding.
uo-link sidecar uo-link
binary /usr/bin/runicgateway-link install binary /usr/bin/runicgateway-link
✓ sidecar binary verified sha256 27d491ef… config /etc/runicgateway/sidecar.toml (created)
config /etc/runicgateway/sidecar.toml created database /var/lib/runicgateway/uo-link.db
database /var/lib/runicgateway/uo-link.db service runicgateway-link.service enabled, running
listening on shard 127.0.0.1:7788 website 127.0.0.1:8080
service runicgateway-link.service active, enabled
running as runicgateway
Recorded /etc/runicgateway/install.json Recorded /etc/runicgateway/install.json
@@ -213,8 +201,7 @@ reports "unchanged" and writes nothing.
| `--verify` | `install`, `update` | Dry run. Report every change that would be made; write 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. | | `--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. | | `--bundle <tag>` | `install`, `update` | Pin an exact published bundle instead of the current one. |
| `--patches` / `--no-patches` | `install` | 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. | | `--patches` / `--no-patches` | `install` | Decide the patch tier non-interactively. `--patches` still refuses on a non-57.4 tree. |
| `--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. |
| `--host <name>` | `install` | The hostname to print in the website URLs. | | `--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. | | `--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. | | `--yes` | all | Assume the default answer to every prompt. Combine with the flags above for an unattended run. |
@@ -231,8 +218,7 @@ reports "unchanged" and writes nothing.
| `/usr/bin/runicgateway-link` | The sidecar binary | | `/usr/bin/runicgateway-link` | The sidecar binary |
| `/etc/runicgateway/sidecar.toml` | Sidecar config, including the auth token | | `/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/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/` | Copies of any patches applied, so `uninstall` can print the exact hunks long after the release tarball is gone |
| `/etc/runicgateway/patches/originals/` | Each file the patch tier edited, exactly as it was beforehand — a revert you can verify rather than reconstruct |
| `/var/lib/runicgateway/uo-link.db` | The sidecar's SQLite store (event history, cached profiles, link map) | | `/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 | | `/etc/systemd/system/runicgateway-link.service` | The service unit, running as a dedicated user |
@@ -244,9 +230,8 @@ reports "unchanged" and writes nothing.
| `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token | | `%ProgramData%\RunicGateway\sidecar.toml` | Sidecar config, including the auth token |
| `%ProgramData%\RunicGateway\install.json` | As above | | `%ProgramData%\RunicGateway\install.json` | As above |
| `%ProgramData%\RunicGateway\patches\` | As above | | `%ProgramData%\RunicGateway\patches\` | As above |
| `%ProgramData%\RunicGateway\patches\originals\` | As above |
| `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store | | `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store |
| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink` | | Service `RunicGatewayLink` | Automatic start, restart on failure |
**Inside your ServUO tree** (added by the overlay sync — 24 files): **Inside your ServUO tree** (added by the overlay sync — 24 files):
@@ -256,30 +241,11 @@ Scripts/Custom/Bridge/*.cs 22 files: the plugin itself
Scripts/Scripts.csproj OVERWRITES a stock file (see below) 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 Both service definitions pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own
its working directory and a service manager's working directory is not somewhere you want a defaults are relative to its working directory, and a service manager's working directory is not
database or a config file. On Windows it can be `%SystemRoot%\System32` or, under somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under
`C:\Program Files\`, a silently redirected VirtualStore copy. `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 > **`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 > 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 > ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That
@@ -306,87 +272,15 @@ How the installer handles it:
- **Opt-in.** The base install completes without it, and declining is a supported outcome, not a - **Opt-in.** The base install completes without it, and declining is a supported outcome, not a
degraded one. degraded one.
- **Dry-run first, always.** Every patch is checked before anything is applied, and reported per - **Dry-run first, always.** Every patch is checked (`git apply --check`) before anything is
patch. Most real shards are hand-modified; a patch that does not apply is expected, not alarming. applied, and reported per patch. Most real shards are hand-modified; a patch that does not apply
- **A modified file is not automatically a refusal.** These patches touch three small regions of is expected, not alarming.
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.
- **All or nothing per feature.** The two vendor-sale patches are one unit and are applied together - **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 or not at all.
could have been placed but was held back by its sibling says exactly that; it is never reported as - **Skipped entirely on a ServUO that is not 57.4**, with a warning. Unverified diffs are never
applied. applied to an unknown tree.
- **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.
- **Recorded, and the `.patch` files cached**, so re-runs stay idempotent and `uninstall` can print - **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 the exact hunks to revert.
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.
If it is skipped or fails, you lose exactly two things — **`vendor.sale` events** and **in-game 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 moderation audit forwarding**. Everything else works. You can apply the patches later by hand (see
@@ -431,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 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. 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 ### 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 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 **Leave `[web] bind` on `127.0.0.1:8080`** and let the proxy be the only thing that talks to it.
service. Widening the bind and firewalling the port is the alternative, not the default (see below).
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.
The `[shard] bind` line is a different matter: leave it on `127.0.0.1:7788`. That socket accepts Give the website the **proxied** URLs — `https://link.example.com` and
*inbound commands* to the game, and being loopback-only is what makes that safe. `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.
--- ---
@@ -506,7 +448,7 @@ first thing a maintainer will want.
``` ```
✓ ServUO found /opt/ServUO (57.4) ✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json ✓ 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 ✓ uo-link installed 1.1.0
✓ Service running, enabled ✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok ✓ Sidecar reachable 127.0.0.1:8080 /health ok
@@ -543,7 +485,7 @@ your work.
| **Removed** | The sidecar binary, its service entry, `install.json`, the cached patch set | | **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) | | **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** | 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 — 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. | | **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs` and `Logging.cs`, for you to revert |
The report is also written to a file, so it survives the scrollback. The report is also written to a file, so it survives the scrollback.
@@ -553,16 +495,15 @@ The report is also written to a file, so it survives the scrollback.
| Symptom | Cause and fix | | 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. | | **"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. | | 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 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. | | `[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. | | 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. | | `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`. |
| Service registered but stops immediately | It cannot read its config. On Windows 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/`. |
| 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. | | 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`). | | `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. | | 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. |
@@ -622,13 +563,7 @@ 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 `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`. `patch` instead, note the core files are CRLF: 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.
### A3. Install the sidecar ### A3. Install the sidecar
@@ -711,35 +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" & "$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: sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto
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 failure RunicGatewayLink reset= 86400 actions= restart/5000 sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000
[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine')
# The service account exists only once sc create has created it, so its grants come after: [Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine')
icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)'
icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M'
sc.exe start RunicGatewayLink sc.exe start RunicGatewayLink
``` ```
Three things there are easy to get wrong: 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.
- **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.
### A5. Connect the website, start the shard, verify ### A5. Connect the website, start the shard, verify

View File

@@ -1,17 +1,11 @@
# Runic Gateway Installer — plan # Runic Gateway Installer — plan
Status: **Phases 1, 2 and 3 built, on `edge`.** Phase 0's prerequisites all landed, the installer Status: **Phase 0 complete.** Every prerequisite in another repo has landed, the installer repo
repo publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) specified the operator-facing run publishes the bundle manifest, and [`INSTALL.md`](INSTALL.md) now specifies the operator-facing run
before the binary existed. The crate now implements the installer core (bundle resolution, ServUO — so *what* the installer installs and *what using it looks like* both exist ahead of the binary.
detection and validation, the overlay sync, `install.json` — [Phase 1 as No installer code exists yet; **Phase 1 is next.** This document is the design of record; it
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff — supersedes the informal overview it grew out of, which described a ServUO integration that does not
[Phase 2 as built](#phase-2--uo-link-install-and-service)), and the patch tier (the rung ladder, the match how `servuo-plugins` actually ships (see
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)).
All three are on the `edge` branch, not `main`, so no half-capable binary is released. **The
`edge → main` cutover is next**, and it now cuts a binary that does everything `INSTALL.md`
describes except `doctor`/`update`/`uninstall`, each of which says which phase it arrives in.
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)). [Corrections](#corrections-to-the-original-overview)).
| Phase 0 item | State | | Phase 0 item | State |
@@ -19,7 +13,7 @@ described a ServUO integration that does not match how `servuo-plugins` actually
| 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.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.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.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 | | 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)) | | — 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)) |
--- ---
@@ -50,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 | | 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 | | 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 | | 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 | | 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 |
--- ---
@@ -102,100 +96,12 @@ most real shards are hand-modified. Therefore:
audit forwarding**. audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a - The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
shard restart. 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, (`/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, 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, Phase 4).
because the refusal message names that path as the file to apply by hand.
- **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.
### 2.3 Config paths collide with what the sidecar actually reads ### 2.3 Config paths collide with what the sidecar actually reads
@@ -216,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 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 — `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 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 - Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
service user service user
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`** - 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 ### 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 whole point is the website reaching the sidecar, and today that is manual and undocumented in
@@ -329,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 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. Each component keeps its own lifecycle. ServUO's existing startup process is untouched.
@@ -460,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. 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 - **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync
rule inherited from `deploy.ps1`. rule inherited from `deploy.ps1`.
- **Remote-website deployments needed an answer.** `[web] bind` defaults to `127.0.0.1`, which - **Remote-website deployments needed an answer, and it is a reverse proxy.** `[web] bind`
only works when the site runs on the shard host. The guide says to widen it, firewall it to defaults to `127.0.0.1`, which only works when the site runs on the shard host. The guide's
the website's address, and front it with TLS or a VPN off a trusted network — because the recommended arrangement — already in production on a real domain — is to **leave the bind on
token is always required but travels as a plain bearer token over HTTP. `[shard] bind` stays loopback** and put a TLS reverse proxy in front, giving the website the proxied `https://` /
on loopback, since that socket carries inbound commands *into* the game. `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 ### Phase 1 — installer core
@@ -485,79 +391,6 @@ Repo work that must land before an installer can exist.
timestamp. timestamp.
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing. - 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 ### Phase 2 — uo-link install and service
- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db → - Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
@@ -570,148 +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 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. 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) ### Phase 3 — patch tier (opt-in)
Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
degrade loudly rather than silently. 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 03 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 ### Phase 4 — diagnostics and updates
`runicgateway doctor` — the command that makes the whole thing supportable: `runicgateway doctor` — the command that makes the whole thing supportable:
@@ -719,7 +415,7 @@ patched files unchanged, and the cached pre-image still pre-patch.
``` ```
✓ ServUO found /opt/ServUO (57.4) ✓ ServUO found /opt/ServUO (57.4)
✓ Overlay in sync 24 files, all hashes match install.json ✓ 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 ✓ uo-link installed 1.1.0
✓ Service running, enabled ✓ Service running, enabled
✓ Sidecar reachable 127.0.0.1:8080 /health ok ✓ Sidecar reachable 127.0.0.1:8080 /health ok
@@ -757,7 +453,7 @@ a clever automatic revert risks silently eating their work. It removes and it re
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` and the cached patch set | | 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) | | 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** | 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 The printed report is also written to a file, so it survives the terminal scrollback of a long
uninstall. uninstall.
@@ -794,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 `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. 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 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 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 explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added
@@ -826,30 +529,14 @@ Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo
"repo": "RunicGateway/servuo-plugins", "repo": "RunicGateway/servuo-plugins",
"protocol": 3, "protocol": 3,
"servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, "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/…": "…" } "files": { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
} }
``` ```
`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from `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`; `servuo-plugins/overlay.toml`; `files` is a SHA256 per shipped file.
`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 - **`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 the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
@@ -860,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 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 time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
mismatch against `install.json` alone means local edits. 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* `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 files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
@@ -1005,24 +680,18 @@ mismatched pair from being published as a bundle — which is the mechanism that
## 8. Open questions ## 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 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. 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 sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
different host, or hard-assume co-location? different host, or hard-assume co-location?
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version. 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: **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 `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 workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies

View File

@@ -1,37 +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/
│ ├── bundle-2026.08.04.json
│ ├── current.json
│ └── README.md
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── CONTRIBUTORS.md
├── LICENSE.md
├── README.md
└── SECURITY.md
```