feat(installer): implement Phase 2 — uo-link install and service #5
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/phase2-sidecar-service"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Phase 2 of
docs/installer/PLAN.md— the sidecar half of a deployment, in the sameinstallrun:--print-config,sc.exe— running as a dedicated unprivileged account,install.json.doctor/update/uninstallstill answer with the phase they arrive in.--patchesstill reportsREQUESTED BUT NOT APPLIED(Phase 3).Still targets
edge. Per the docs half, theedge → maincutover 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 offmain.Docs half: docs#91.
Decisions worth a reviewer's attention
runicgatewaysystem 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. Plainsc 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.tomlis locked down, because it holds the auth token. Neither default location protects it —/etcis world-readable,%ProgramData%grantsUsersread 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 untilsc createcreates 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 with1332: No mapping between account names and security IDs.UOLINK_DB_PATH. On Windows config and data share a directory and the sidecar anchors a relative[store] pathto its config's directory, so the pin is redundant — andsc.exehas 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 ownbinPathinstead, which is scoped to the service by construction.--verifyruns no part of the sidecar half.--print-configprovisions: 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 existinglinksection ofinstall.jsonthrough untouched, so a dry run on an installed host cannot make its service disappear from the record.RUNICGATEWAY_STATE_DIRnow 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./run/systemd/systemabsent — the right test, sincesystemctlis 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.jsonrecordsservice: null, and the exact unit and commands are printed. No fallback toUser=rootorLocalSystem. The printed Windows recipe states plainly whether this run locked the config down or the operator still has to.install.jsonnever records the token. Thelinksection 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.sc stopreturns as soon as the stop is pending, so the stop is polled rather than slept on.systemctl starton 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 on7bfb033.End to end on Windows against a relocated layout, with the binary copied under a neutral name (UAC installer detection):
created), and the §6 handoff printed with the URLs composed from--hostrather than from the127.0.0.1bind address.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.unchanged/ binaryunchanged/ configalready present, reads back the same token, and leavesinstall.jsonbyte-identical.--verifyover an installed host: writes nothing, prints no token, andinstall.jsonis byte-identical with itslinksection intact.replace, re-downloaded and verified, with no staging.newfile left behind.install.jsoncontains no token; the config's ACL after a non-relocated hardening pass lists onlyNT AUTHORITY\SYSTEMandBUILTIN\Administrators.Not yet exercised: the real service registration.
sc createneeds an elevated shell and systemd needs a Linux host, so neither ran here. The unit text, thebinPathcomposition, 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
AI-assisted contributions (required)
Claude Code. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with aCo-Authored-Bytrailer.License