diff --git a/installer/INSTALL.md b/installer/INSTALL.md index 0749c72..1de87a9 100644 --- a/installer/INSTALL.md +++ b/installer/INSTALL.md @@ -5,10 +5,12 @@ installation and connects it to a Runic Gateway website. > **Status: the installer binary is not released yet.** > -> Phase 1 — the installer core: bundle resolution, ServUO detection, the overlay sync and -> `install.json` — is built and lives on the installer repo's `edge` branch. It is deliberately not -> released: a binary that deploys the overlay but cannot yet install the sidecar would not do what -> this guide describes. The first release follows Phase 2. +> Phases 1 and 2 are built and live on the installer repo's `edge` branch: the installer core +> (bundle resolution, ServUO detection, the overlay sync, `install.json`) and the sidecar half (the +> binary, its config, its service, and the token handoff). What is still missing is the **patch +> tier** ([§4](#4-the-patch-tier-optional)), which `install` reports as not applied rather than +> silently skipping. The first release follows Phase 3, so that a released binary answers every +> question this guide says it answers. > > 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) @@ -176,11 +178,14 @@ Overlay sync Patch tier skipped (not selected) Without it: no vendor.sale events, no in-game moderation audit forwarding. -uo-link - binary /usr/bin/runicgateway-link - config /etc/runicgateway/sidecar.toml (created) - database /var/lib/runicgateway/uo-link.db - service runicgateway-link.service enabled, running +uo-link sidecar + binary /usr/bin/runicgateway-link install +✓ sidecar binary verified sha256 27d491ef… + config /etc/runicgateway/sidecar.toml created + database /var/lib/runicgateway/uo-link.db + listening on shard 127.0.0.1:7788 website 127.0.0.1:8080 + service runicgateway-link.service active, enabled + running as runicgateway Recorded /etc/runicgateway/install.json @@ -238,7 +243,7 @@ reports "unchanged" and writes nothing. | `%ProgramData%\RunicGateway\install.json` | As above | | `%ProgramData%\RunicGateway\patches\` | As above | | `%ProgramData%\RunicGateway\uo-link.db` | The sidecar's SQLite store | -| Service `RunicGatewayLink` | Automatic start, restart on failure | +| Service `RunicGatewayLink` | Automatic start, restart on failure, running as `NT SERVICE\RunicGatewayLink` | **Inside your ServUO tree** (added by the overlay sync — 24 files): @@ -248,11 +253,30 @@ Scripts/Custom/Bridge/*.cs 22 files: the plugin itself Scripts/Scripts.csproj OVERWRITES a stock file (see below) ``` -Both service definitions pin `UOLINK_CONFIG` and `UOLINK_DB_PATH` explicitly. The sidecar's own -defaults are relative to its working directory, and a service manager's working directory is not -somewhere you want a database — on Windows it can be `%SystemRoot%\System32` or, under +**Both service definitions pin the config path**, because the sidecar's own default is relative to +its working directory — and a service manager's working directory is not somewhere you want a +database or a config file. On Windows it can be `%SystemRoot%\System32` or, under `C:\Program Files\`, a silently redirected VirtualStore copy. +How the *database* path is pinned differs by platform, and that is deliberate: + +| | Config | Database | +|---|---|---| +| **Linux** | `Environment=UOLINK_CONFIG=` in the unit | `Environment=UOLINK_DB_PATH=` in the unit — `/etc` and `/var/lib` are different directories, so both need naming | +| **Windows** | `--config` inside the service's own `binPath` | nothing to set: a relative `[store] path` resolves against the config's directory, which *is* `%ProgramData%\RunicGateway` | + +The Windows service would otherwise need a **machine-wide** environment variable — `sc.exe` has no +per-service one — which every process on the host inherits and which outlives an uninstall. + +**Both run as a dedicated, unprivileged account.** Linux gets a `runicgateway` system user; Windows +gets a virtual service account, `NT SERVICE\RunicGatewayLink`, which Windows creates as part of +registering the service and which has no password. Neither runs as root or `LocalSystem`. + +**`sidecar.toml` is locked down, because it holds your auth token.** Neither default location +protects it on its own — `/etc` is world-readable, and `%ProgramData%` grants `Users` read access by +inheritance — so the installer sets the permissions itself: `chmod 600` plus `chown` to the service +user on Linux, and an explicit ACL of SYSTEM, Administrators and the service account on Windows. + > **`Scripts.csproj` is overwritten deliberately.** The stock file omits `Scripts/Custom/`, so the > plugin would sit in the tree and never compile — and ServUO would not tell you, because it > ignores the script build's exit code and silently reloads the previous `Scripts.dll`. That @@ -509,6 +533,8 @@ The report is also written to a file, so it survives the scrollback. | 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 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 `; 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. | | `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. | @@ -651,15 +677,35 @@ 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" -sc.exe create RunicGatewayLink binPath= "\"$env:ProgramFiles\RunicGateway\uo-link-sidecar.exe\"" start= auto +# The config file now holds your auth token. Lock it down before anything else can read it: +icacls "$env:ProgramData\RunicGateway\sidecar.toml" /inheritance:r /grant:r '*S-1-5-18:(F)' /grant:r '*S-1-5-32-544:(F)' + +# binPath carries the config path. The single quotes matter: the value itself contains the double +# quotes the service manager needs around a path with spaces in it. +sc.exe create RunicGatewayLink ` + binPath= '"C:\Program Files\RunicGateway\uo-link-sidecar.exe" --config "C:\ProgramData\RunicGateway\sidecar.toml"' ` + obj= 'NT SERVICE\RunicGatewayLink' start= auto sc.exe failure RunicGatewayLink reset= 86400 actions= restart/5000 -[Environment]::SetEnvironmentVariable('UOLINK_CONFIG', "$env:ProgramData\RunicGateway\sidecar.toml", 'Machine') -[Environment]::SetEnvironmentVariable('UOLINK_DB_PATH', "$env:ProgramData\RunicGateway\uo-link.db", 'Machine') + +# The service account exists only once sc create has created it, so its grants come after: +icacls "$env:ProgramData\RunicGateway\sidecar.toml" /grant 'NT SERVICE\RunicGatewayLink:(R)' +icacls "$env:ProgramData\RunicGateway" /grant 'NT SERVICE\RunicGatewayLink:(OI)(CI)M' + sc.exe start RunicGatewayLink ``` -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. +Three things there are easy to get wrong: + +- **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 diff --git a/installer/PLAN.md b/installer/PLAN.md index b5ea94f..7081b59 100644 --- a/installer/PLAN.md +++ b/installer/PLAN.md @@ -1,11 +1,15 @@ # Runic Gateway Installer — plan -Status: **Phase 1 built, on `edge`.** Phase 0's prerequisites all landed, the installer repo +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 exists and implements the installer core — bundle -resolution, ServUO detection and validation, the overlay sync and `install.json` — on the -`edge` branch, not `main`, so no half-capable binary is released (see -[Phase 1 as built](#phase-1--installer-core)). **Phase 2 (uo-link install + service) is next.** +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)). @@ -199,13 +203,21 @@ Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0. half in the sidecar — a relative `[store].path` now resolves against the directory holding `sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic — but the config path itself is still CWD-relative by default, and "deterministic" is not the same as -"where this install wants it". The service definitions therefore still pin `UOLINK_CONFIG` and -`UOLINK_DB_PATH` explicitly: +"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 @@ -545,6 +557,65 @@ process running out of the tree. That both writes the config the service will read and returns the token to print, so the service never starts against a config that does not exist yet. +**As built** ([installer#5](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/5)) — +`src/sidecar.rs` (binary, config, handoff) and `src/service.rs` (systemd, Windows SCM), wired into +the same `install` run. The decisions that were not already settled above: + +- **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the system + user this section already specified; Windows gets a **virtual service account** + (`sc create … obj= "NT SERVICE\RunicGatewayLink"`), which the SCM creates itself and which has no + password. Plain `sc create` would have run it as `LocalSystem` — the most privileged local + identity there is, for a process that listens on two TCP ports while its Linux twin deliberately + does not run as root. The account only exists *after* `sc create`, which fixes the order of the + file permissions below. +- **`sidecar.toml` is locked down, because it holds the token.** Neither default location protects + it: `/etc` is world-readable and `%ProgramData%` grants `Users` read by inheritance, so an + unprivileged local account could read the shard's auth token out of a stock install. Linux gets + `chmod 600` plus `chown` to the service user; Windows gets `icacls /inheritance:r` down to SYSTEM + and Administrators **before** registration, then a read grant for the service account after it + exists. The database directory gets a separate write grant, since SQLite writes journal and WAL + files beside the database. +- **`--verify` runs no part of the sidecar half.** `--print-config` provisions — it writes the + config and mints a token — so a dry run that called it would create exactly the state it claims + not to. A `--verify` run reports what would be installed, reads no token, and prints no handoff. + It also **carries the existing `link` section of `install.json` through untouched**, so a dry run + on an installed host cannot make its service disappear from the record. +- **The installed binary's protocol version is checked against the bundle, and a mismatch stops the + run before the service is registered.** Gate 1 (§7.1) read that number from source at the release + tag; this is the same check applied to the binary that will actually answer the website. The + binary is left on disk — harmless without a service — rather than the run pretending to succeed. +- **`RUNICGATEWAY_STATE_DIR` now relocates the sidecar binary too, and suppresses service + registration.** Phase 1 left the binary path alone because nothing wrote it. A relocated run that + still dropped a binary into `/usr/bin` and registered a system service would be exactly the + half-in-the-real-system accident the variable exists to avoid — and there is no such thing as a + relocated systemd unit or Windows service. Such a run also leaves file permissions alone, because + hardening a scratch config against the only account that will ever read it just breaks the next + test run. +- **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No + systemd (`/run/systemd/system` absent — the correct test, since `systemctl` is present in plenty + of containers where PID 1 is not systemd), or a service user that cannot be created: the binary + and config are still installed, `install.json` records `service: null`, and the run prints the + exact unit text and commands. There is **no fallback to `User=root` or `LocalSystem`** — a service + quietly running with more privilege than its own documentation promises is worse than one that was + not registered. The printed Windows recipe states plainly whether the run locked the config down + or the operator still has to. +- **`install.json` never records the token.** The `link` section holds versions, the binary's hash, + the config and database paths, and the service's name, unit path and account. The token goes to + the terminal and to `sidecar.toml`, and the record is a support artifact people paste into bug + reports. +- **The service is stopped before its binary is replaced, and restarted rather than started + afterwards.** On Windows the file is locked while the service runs (and `sc stop` returns as soon + as the stop is *pending*, so the stop is polled, not slept on); on Linux the replacement is + permitted but leaves the old code serving until something restarts it. `systemctl start` on an + active unit is a no-op, which is precisely the wrong outcome after a replacement. + +Verified on this machine end to end against a relocated layout: the bundle's Windows sidecar +downloaded and checksum-verified, `--print-config` provisioning a fresh config and returning a +token, the §6 handoff printed with the URLs composed from the host rather than the bind address, a +second run reporting `unchanged` / `already present` and leaving `install.json` byte-identical, a +`--verify` run over an installed host writing nothing and preserving the `link` section, and a +tampered binary detected by hash and replaced with no stray staging file left behind. + ### Phase 3 — patch tier (opt-in) Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and @@ -825,18 +896,24 @@ mismatched pair from being published as a bundle — which is the mechanism that ## 8. Open questions -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 +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. -3. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so +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