feat(installer): implement Phase 2 — uo-link install and service #5

Merged
whitlocktech merged 2 commits from feat/phase2-sidecar-service into edge 2026-08-04 21:02:02 +00:00
Member

What & why

Phase 2 of docs/installer/PLAN.md — the sidecar half of a deployment, in the same install run:

  • download the bundle's sidecar binary, verify its SHA256, install it (hash-compared, so an unchanged binary is left alone),
  • provision its config and read the token back with --print-config,
  • register, enable and start a service — systemd unit or sc.exe — running as a dedicated unprivileged account,
  • print the §6 token handoff, and record the sidecar in install.json.

doctor / update / uninstall still answer with the phase they arrive in. --patches still reports REQUESTED BUT NOT APPLIED (Phase 3).

Still targets edge. Per the docs half, the edge → main cutover now waits for Phase 3 rather than following this PR: INSTALL.md §4 describes the patch tier as part of the run, and a first release that answers "not implemented" to all of it is the same half-capable binary that kept Phase 1 off main.

Docs half: docs#91.

Decisions worth a reviewer's attention

  • Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets the runicgateway system user the plan already specified; Windows gets a virtual service accountsc create … obj= "NT SERVICE\RunicGatewayLink" — which the SCM creates itself and which has no password. Plain sc create (what PLAN §8 Q1 originally sketched) runs as LocalSystem: the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root.
  • sidecar.toml is locked down, because it holds the auth token. Neither default location protects it — /etc is world-readable, %ProgramData% grants Users read by inheritance — so a stock install would leave the shard's token readable by any local account. The lockdown straddles registration because it has to: on Windows the service account does not exist until sc create creates it, so the file is first cut down to SYSTEM + Administrators (icacls /inheritance:r, well-known SIDs so it works on a localized Windows), and the account's read grant comes after. Found by the smoke run, which failed with 1332: No mapping between account names and security IDs.
  • Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative [store] path to its config's directory, so the pin is redundant — and sc.exe has no per-service environment, only a machine-wide one that every process on the host inherits and that outlives an uninstall. The config path rides in the service's own binPath instead, which is scoped to the service by construction.
  • --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. It also carries an 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 before the service is registered. Gate 1 read that number from source at the release tag; this applies the same check 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 both service registration and the permission hardening. Phase 1 left the binary path alone because nothing wrote it. There is no such thing as a relocated systemd unit or Windows service, and hardening a scratch config against the only account that will ever read it just breaks the next test run — which is exactly how that fell out, on the second run of the smoke test.
  • A host the installer cannot drive gets the recipe, not a failure or a weaker service. No systemd (/run/systemd/system absent — the right test, since systemctl is present in plenty of containers where PID 1 is not systemd) or a service user that cannot be created: binary and config still installed, install.json records service: null, and the exact unit and commands are printed. No fallback to User=root or LocalSystem. The printed Windows recipe states plainly whether this run locked the config down or the operator still has to.
  • install.json never records the token. The link section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account — and there is a test asserting the serialized record contains no token field, because that file 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 rather than slept on. systemctl start on an active unit is a no-op — precisely the wrong outcome after a replacement.

How it was tested

Gates pass on both platforms — cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo test --locked, 72 tests (50 → 72). CI green on 7bfb033.

The first push was red, and usefully so: three faults lived in code that only compiles under cfg(unix), which the Windows dev machine cannot see — a Result<_, anyhow::Error> compared with == Ok(true), an import used only by the Windows path, and a cfg(not(windows)) assertion block that had ended up in the wrong test. They are fixed in the second commit, and they were found by running the runner's own gate set against the working tree inside a rust:1-slim container, which is now the way to check the other platform's half before pushing.

End to end on Windows against a relocated layout, with the binary copied under a neutral name (UAC installer detection):

  • Full install: bundle resolved over the network, overlay deployed (24 files), the bundle's Windows sidecar downloaded and checksum-verified, config provisioned (created), and the §6 handoff printed with the URLs composed from --host rather than from the 127.0.0.1 bind address.
  • Service skip path: reported as NOT REGISTERED — RUNICGATEWAY_STATE_DIR is set, so this is a test run, followed by the full pasteable recipe and an honest note that this run did not restrict the config's permissions.
  • Idempotency: second run reports unchanged / binary unchanged / config already present, reads back the same token, and leaves install.json byte-identical.
  • --verify over an installed host: writes nothing, prints no token, and install.json is byte-identical with its link section intact.
  • Binary replacement: a tampered installed binary is detected by hash, reported as replace, re-downloaded and verified, with no staging .new file left behind.
  • Secret handling: install.json contains no token; the config's ACL after a non-relocated hardening pass lists only NT AUTHORITY\SYSTEM and BUILTIN\Administrators.

Not yet exercised: the real service registration. sc create needs an elevated shell and systemd needs a Linux host, so neither ran here. The unit text, the binPath composition, the virtual-account name and both manual recipes are covered by pure-function tests that run on both platforms. A scripted elevated smoke test — register → sc qc / qfailure → listening ports → /health → config ACL → machine-env check → idempotent second run → -Cleanup — is ready and should be run before merge.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why Phase 2 of [`docs/installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md) — the sidecar half of a deployment, in the same `install` run: - download the bundle's sidecar binary, verify its SHA256, install it (hash-compared, so an unchanged binary is left alone), - provision its config and read the token back with `--print-config`, - register, enable and start a service — systemd unit or `sc.exe` — running as a dedicated unprivileged account, - print the §6 token handoff, and record the sidecar in `install.json`. `doctor` / `update` / `uninstall` still answer with the phase they arrive in. `--patches` still reports `REQUESTED BUT NOT APPLIED` (Phase 3). **Still targets `edge`.** Per the docs half, the `edge → main` cutover now waits for Phase 3 rather than following this PR: INSTALL.md §4 describes the patch tier as part of the run, and a first release that answers "not implemented" to all of it is the same half-capable binary that kept Phase 1 off `main`. Docs half: [docs#91](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/91). ### Decisions worth a reviewer's attention - **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the `runicgateway` system user the plan 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` (what PLAN §8 Q1 originally sketched) runs as **LocalSystem**: the most privileged local identity there is, for a process listening on two TCP ports while its Linux twin deliberately does not run as root. - **`sidecar.toml` is locked down, because it holds the auth token.** Neither default location protects it — `/etc` is world-readable, `%ProgramData%` grants `Users` read by inheritance — so a stock install would leave the shard's token readable by any local account. The lockdown *straddles* registration because it has to: on Windows the service account does not exist until `sc create` creates it, so the file is first cut down to SYSTEM + Administrators (`icacls /inheritance:r`, well-known SIDs so it works on a localized Windows), and the account's read grant comes after. Found by the smoke run, which failed with `1332: No mapping between account names and security IDs`. - **Only Linux pins `UOLINK_DB_PATH`.** On Windows config and data share a directory and the sidecar anchors a relative `[store] path` to its config's directory, so the pin is redundant — and `sc.exe` has no per-service environment, only a machine-wide one that every process on the host inherits and that outlives an uninstall. The config path rides in the service's own `binPath` instead, which is scoped to the service by construction. - **`--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. It also carries an 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 before the service is registered.** Gate 1 read that number from source at the release tag; this applies the same check 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 both service registration and the permission hardening.** Phase 1 left the binary path alone because nothing wrote it. There is no such thing as a relocated systemd unit or Windows service, and hardening a scratch config against the only account that will ever read it just breaks the next test run — which is exactly how that fell out, on the second run of the smoke test. - **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No systemd (`/run/systemd/system` absent — the right test, since `systemctl` is present in plenty of containers where PID 1 is not systemd) or a service user that cannot be created: binary and config still installed, `install.json` records `service: null`, and the exact unit and commands are printed. **No fallback to `User=root` or `LocalSystem`.** The printed Windows recipe states plainly whether this run locked the config down or the operator still has to. - **`install.json` never records the token.** The `link` section carries versions, the binary's hash, the config and database paths, and the service's name, unit path and account — and there is a test asserting the serialized record contains no token field, because that file 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 rather than slept on. `systemctl start` on an active unit is a no-op — precisely the wrong outcome after a replacement. ## How it was tested Gates pass on **both** platforms — `cargo fmt --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, **72 tests** (50 → 72). CI green on `7bfb033`. > The first push was red, and usefully so: three faults lived in code that only compiles under `cfg(unix)`, which the Windows dev machine cannot see — a `Result<_, anyhow::Error>` compared with `== Ok(true)`, an import used only by the Windows path, and a `cfg(not(windows))` assertion block that had ended up in the wrong test. They are fixed in the second commit, and they were found by running the runner's own gate set against the working tree inside a `rust:1-slim` container, which is now the way to check the other platform's half before pushing. End to end on Windows against a relocated layout, with the binary copied under a neutral name (UAC installer detection): - **Full install**: bundle resolved over the network, overlay deployed (24 files), the bundle's Windows sidecar downloaded and checksum-verified, config provisioned (`created`), and the §6 handoff printed with the URLs composed from `--host` rather than from the `127.0.0.1` bind address. - **Service skip path**: reported as `NOT REGISTERED — RUNICGATEWAY_STATE_DIR is set, so this is a test run`, followed by the full pasteable recipe and an honest note that this run did *not* restrict the config's permissions. - **Idempotency**: second run reports `unchanged` / binary `unchanged` / config `already present`, reads back the *same* token, and leaves `install.json` byte-identical. - **`--verify` over an installed host**: writes nothing, prints no token, and `install.json` is byte-identical with its `link` section intact. - **Binary replacement**: a tampered installed binary is detected by hash, reported as `replace`, re-downloaded and verified, with no staging `.new` file left behind. - **Secret handling**: `install.json` contains no token; the config's ACL after a non-relocated hardening pass lists only `NT AUTHORITY\SYSTEM` and `BUILTIN\Administrators`. **Not yet exercised: the real service registration.** `sc create` needs an elevated shell and systemd needs a Linux host, so neither ran here. The unit text, the `binPath` composition, the virtual-account name and both manual recipes are covered by pure-function tests that run on both platforms. A scripted elevated smoke test — register → `sc qc` / `qfailure` → listening ports → `/health` → config ACL → machine-env check → idempotent second run → `-Cleanup` — is ready and should be run before merge. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-04 20:42:19 +00:00
feat(installer): implement Phase 2 — uo-link install and service
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 1m15s
2228e0848b
Adds the sidecar half of a deployment to the same `install` run: download and
verify the bundle's binary, provision its config, register and start a service,
and print the token handoff PLAN.md §6 specifies. `src/sidecar.rs` owns the
binary and the config document; `src/service.rs` owns systemd and the Windows
SCM.

The order is fixed by PLAN.md §5 and matters: stop anything running the old
binary, replace it, then `--print-config` (which writes the config the service
will be pointed at), then register. Registering first points a service at a file
that does not exist yet.

Decisions worth a reviewer's attention:

- Both platforms run the sidecar as a dedicated unprivileged identity. Linux gets
  the `runicgateway` system user the plan 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` runs
  as LocalSystem — the most privileged local identity there is, for a process
  listening on two TCP ports while its Linux twin deliberately does not run as
  root.
- `sidecar.toml` holds the auth token and neither default location protects it:
  /etc is world-readable and %ProgramData% grants Users read by inheritance, so a
  stock install would leave the shard's token readable by any local account. The
  lockdown straddles registration because it has to — on Windows the service
  account does not exist until `sc create` creates it, so the file is first cut
  down to SYSTEM + Administrators, and the account's read grant comes after.
- Only Linux pins UOLINK_DB_PATH. On Windows config and data share a directory
  and the sidecar anchors a relative [store] path to its config's directory, so
  the pin is redundant — and `sc.exe` has no per-service environment, only a
  machine-wide one that every process inherits and that outlives an uninstall.
  The config path rides in the service's own binPath instead.
- `--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
  the state it claims not to. It also carries an existing `link` section of
  install.json through untouched, so a dry run cannot make a service disappear
  from the record.
- The installed binary's protocol version is checked against the bundle before
  the service is registered. Gate 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.
- RUNICGATEWAY_STATE_DIR now relocates the sidecar binary as well, and suppresses
  service registration and the file-permission hardening. There is no such thing
  as a relocated systemd unit, and hardening a scratch config against the only
  account that will ever read it just breaks the next test run.
- A host with no systemd, or where the service user cannot be created, still gets
  a working binary and config plus the exact unit and commands. There is no
  fallback to User=root or LocalSystem: a service quietly running with more
  privilege than its documentation promises is worse than one that was not
  registered.
- install.json never records the token. The `link` section carries versions, the
  binary's hash, the config and database paths, and the service's name, unit path
  and account.

Docs half: docs#91.

Tested: cargo fmt --check, clippy --all-targets -D warnings, 72 tests. End to end
on Windows against a relocated layout — bundle sidecar downloaded and verified,
config provisioned, handoff printed with URLs composed from the host rather than
the bind address, second run reporting unchanged with install.json byte-identical,
--verify over an installed host writing nothing and preserving the link section,
and a tampered binary detected by hash and replaced with no staging file left.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-04 20:45:56 +00:00
Dismissed
wtclaude added 1 commit 2026-08-04 20:52:11 +00:00
fix(installer): make the Linux half of Phase 2 compile
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m56s
7bfb033957
Three faults in code that only compiles under cfg(unix), none of which the
Windows build could see:

- `run(...).map(...) == Ok(true)` compared two `Result<_, anyhow::Error>`
  values, and anyhow::Error is not PartialEq. Replaced with `is_ok_and`.
- `command_line` is used only by the Windows registration path, so importing it
  unconditionally is an unused-import error under `-D warnings`. Qualified at
  its call site instead.
- A cfg(not(windows)) assertion block had ended up in the wrong test, leaving it
  referencing a binding from its original one.

Caught by running the same gates the CI runner does inside a rust:1-slim
container against this working tree — fmt, clippy --all-targets -D warnings, and
cargo test --locked all pass there now, as they do on Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-04 20:52:39 +00:00
whitlocktech merged commit 7122bc6911 into edge 2026-08-04 21:02:02 +00:00
whitlocktech deleted branch feat/phase2-sidecar-service 2026-08-04 21:02:03 +00:00
Sign in to join this conversation.
No description provided.