diff --git a/Cargo.lock b/Cargo.lock index bb0260e..44851b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.68.1" +version = "0.69.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index daed0a5..931d408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.68.1" +version = "0.69.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 3cb8c25..7fd6100 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -4,6 +4,29 @@ High-signal realizations from debugging/development: non-obvious cross-system co sharp edges, and gotchas. Concise durable facts with context — NOT a change diary. See `CLAUDE.md` §4.5 for the maintenance contract (a curator periodically re-verifies + prunes). +## Root has NO systemd `--user` bus, so a user-scope service install is impossible under `sudo` (#526) + +`systemctl --user` (and everything `service-manager`'s systemd backend does at `ServiceLevel::User`) +talks to a **per-login-session D-Bus user manager**. A process running as root under `sudo` inherits +the invoking user's environment but NOT their session bus, and root's own user manager is normally not +running, so the call fails with `Failed to connect to bus: Operation not permitted` — surfacing to a +caller as a bare non-zero exit (the dig-installer observed `exited with 6`). There is no environment +tweak that makes this work in general: `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` point at the +*invoking* user's runtime dir, which root may reach but which registers the unit for THAT user's +session — not a machine-wide service. + +Two consequences that generalize beyond this repo: + +- **An elevated installer must register SYSTEM scope, not user scope.** A user-scope unit only starts + when its user's session/manager starts, so on a headless host it may never come back after a reboot. + Only a system unit (`WantedBy=multi-user.target`) starts at boot with no login session — which is why + dig-node's own `.deb`/`.pkg` always registered system scope while the CLI's `install` verb did not. +- **The privilege level, not the platform, decides the default.** A single compile-time "prefer user + level" constant cannot express it: the same binary on the same OS must land a user unit for a desktop + double-click and a system unit under `sudo`. The scope has to be a RUNTIME value (see `resolve_scope` + in `crates/dig-node-service/src/service.rs`) — and, because both scopes can be registered + independently, an install at one scope must clear the other or two units race for the node's port. + ## A git-pinned peer-stack dep hides shipped fixes indefinitely — the network looked dead for 11 minors (#1771) dig-gossip is a **git dependency pinned by rev** (it cannot go to crates.io until dig-peer-protocol diff --git a/README.md b/README.md index 094de96..10cfb17 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,19 @@ dig-node uninstall `sc config` + a `sc qc` read-back to confirm it took). Re-running `install` against an already-registered service cleanly stops + deregisters + recreates it instead of hitting `CreateService` error 1073 ("the specified service already exists"). -- **Linux / macOS:** the service installs at **user level** (systemd `--user` / a launchd GUI agent), - so no `sudo` is needed and it runs as you. (systemd user services start at login; enable - linger — `loginctl enable-linger $USER` — if you want it running without an active session.) - Both back ends restart the service on crash out of the box — systemd's generated unit sets - `Restart=on-failure`, launchd's generated plist sets `KeepAlive: true`. +- **Linux / macOS:** both scopes exist, and `install` picks by privilege — run it plainly and you get a + **user-level** service (systemd `--user` / a launchd GUI agent): no `sudo`, runs as you. (systemd + user services start at login; enable linger — `loginctl enable-linger $USER` — if you want it + running without an active session.) Run it with `sudo` and you get a **system-level** service that + starts at boot with no login session — what you want on a headless host, and what the DIG installer + and the native `.deb`/`.pkg` register. Both back ends restart the service on crash out of the box — + systemd's generated unit sets `Restart=on-failure`, launchd's generated plist sets `KeepAlive: true`. +- **Choosing explicitly:** `install`, `uninstall`, `start` and `stop` all accept + `--scope ` (default `auto` = the privilege-based choice above). An explicit scope + is honoured as given — `--scope system` without root is refused with a clear message rather than + quietly becoming a user service that will not survive a reboot. `install` also removes any + registration at the *other* scope first, so upgrading a user-level install to a system one never + leaves two services fighting over the port; `uninstall` with the default `auto` clears both. ## Point the extension at it diff --git a/SPEC.md b/SPEC.md index 02e67c5..4bfe9f3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1493,7 +1493,8 @@ flood of never-attached `begin`s cannot grow engine state without bound. `run` (default when no subcommand; serves in the foreground and is the unix-service entrypoint) · `run-service` (hidden; the Windows SCM entrypoint, §9.4; behaves as `run` off Windows) · -`install` · `uninstall` · `start` · `stop` · `status` · `pair` (§7.11) · `open` (§8.5) · +`install` · `uninstall` · `start` · `stop` (each accepting `--scope `, §9.1) · +`status` · `pair` (§7.11) · `open` (§8.5) · the **control-parity** subcommands `info` · `config` · `cache` · `stores` · `sync` · `updater` · `subscriptions` (§8.6) · `peers` (§8.7) · `logs` (§11). @@ -1662,10 +1663,58 @@ Numeric values and symbolic names are a stable contract and MUST NOT be renumber ## 9. OS-service contract -9.1. **Install levels.** Linux (systemd) and macOS (launchd) install at **user level** (no -root/sudo; runs as the installing user). Windows SCM has no per-user services, so install is -**system-level only**, and `install`/`uninstall` MUST fail fast with a clear -`PERMISSION_DENIED` when the console is not elevated (probed up front, not deep inside `sc.exe`). +9.1. **Service scope (`--scope`, #526).** A registration lives in exactly ONE of two scopes, and +`install`, `uninstall`, `start` and `stop` all accept `--scope `, default `auto`. + +| Scope | Where the registration lands | Runs as | Survives a reboot with NO login session | Requires | +| --- | --- | --- | --- | --- | +| `system` | systemd system unit (`/etc/systemd/system/dignetwork-dig-node.service`, `WantedBy=multi-user.target`) · launchd daemon (`/Library/LaunchDaemons/net.dignetwork.dig-node.plist`, `system` domain) · Windows SCM service | root / LocalSystem | **YES** — starts at boot | root / Administrator | +| `user` | systemd user unit (`~/.config/systemd/user/dignetwork-dig-node.service`) · launchd agent (`gui/` domain) | the installing user | NO — starts with that user's session | nothing | + +- **Resolution (normative).** Given the operator's choice, whether the OS HAS a user scope, and + whether the process is root, the resolved scope MUST be: `system` whenever the OS has no user + scope (Windows SCM) — **including for an explicit `--scope user`, which cannot be honoured + there**; otherwise the explicit `--scope system`/`--scope user` **verbatim** (an explicit choice is + authoritative and MUST NEVER be silently overridden by the privilege level); otherwise, for + `auto`, `system` when running as root and `user` when not. +- **`auto` is the default**, so a caller that passes no flag — including a dig-installer release + predating this flag — gets the historical behaviour: user scope for an unelevated desktop install, + and now system scope for an ELEVATED install, which is what makes a headless install survive a + reboot. Root has no `systemd --user`/D-Bus session, so a user-scope registration attempted as root + cannot succeed at all. +- **A system scope requested without root MUST be refused** (`PERMISSION_DENIED`) with a message + naming the missing privilege and the `--scope user` alternative — never silently downgraded to + user scope (a silent downgrade produces a registration that does not survive a reboot, which the + operator asked to avoid). On Windows, the equivalent up-front check is elevation itself: + `install`/`uninstall` MUST fail fast with a clear `PERMISSION_DENIED` when the console is not + elevated (probed up front, not deep inside `sc.exe`). +- **Cross-scope migration (`install`).** Before registering at the resolved scope, `install` MUST + deregister this service label at the OTHER scope, so a host upgrading from a previous user-level + install does not end up with two registrations both starting a node bound to the same port. The + other scope is PROBED first (so a scope holding no registration is never written to), the removal + is best-effort (an unprivileged install cannot delete a system unit) and it is REPORTED, never + silently dropped: `result.migrated_from_scope { scope, found, removed, error }` in `--json`, plus a + `note`/`WARN` line in the human summary. A failed other-scope removal MUST NOT fail the install. +- **The existence probe is ADVISORY; the OS deregistration is AUTHORITATIVE.** + `launchctl print gui//