docs(installer): record Phase 2 as built — sidecar install and service

Phases 1 and 2 now live on the installer repo's `edge` branch, so PLAN.md's
status, the config-path section, and the operator guide all move with them.

PLAN.md
- Status: phases 1 and 2 built. The `edge -> main` cutover now follows Phase 3
  rather than Phase 2, because INSTALL.md §4 describes the patch tier as part of
  the run and a release that answers "not implemented" to all of it is the same
  half-capable binary that kept Phase 1 off `main`.
- §2.3: the service definition always pins the config path, but only Linux pins
  the database. On Windows config and data share a directory, so the sidecar's
  own anchoring rule already lands it correctly — and `sc.exe` offers no
  per-service environment, only a machine-wide one that every process inherits
  and that outlives an uninstall.
- New "Phase 2 as built" section: the virtual service account, the config
  lockdown and why its two halves straddle registration, `--verify` running no
  part of the sidecar half, the protocol check against the installed binary,
  `RUNICGATEWAY_STATE_DIR` relocating the binary and suppressing service
  registration, degrading to a printed recipe with no root/LocalSystem fallback,
  and the token never entering install.json.
- §8 question 1 (Windows service mechanism) resolved: `sc create`, as
  recommended — plus the service identity the recommendation did not anticipate.

INSTALL.md
- Status banner: what is built, and that the patch tier is the remaining gap.
- §2: the illustrated run matches the sidecar block the binary actually prints.
- §3: a table of how each platform pins config and database, the dedicated
  service account on both, and the fact that sidecar.toml's permissions are
  restricted because it holds the auth token.
- Appendix A4: the Windows recipe now matches what the installer does —
  `--config` in binPath (single-quoted so PowerShell keeps the inner quotes),
  `obj=` for the virtual account, the icacls lockdown before and grants after,
  and no machine-wide environment variables.
- Troubleshooting: a row for a run that could not register a service, and one
  for a service that starts and immediately stops.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:40:26 -05:00
parent ce02abbc11
commit 3f12e5f49c
2 changed files with 154 additions and 31 deletions

View File

@@ -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