Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <auto|system|user>` (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

Expand Down
74 changes: 63 additions & 11 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <auto|system|user>`, §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).

Expand Down Expand Up @@ -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 <auto|system|user>`, 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/<uid>` 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/<uid>/<label>` cannot see a per-user agent from a session with no Aqua/GUI
domain (a headless CI runner, an ssh login), so it reports absence for a service that IS
registered. Therefore the scope the operator NAMED (or `auto` resolved to) MUST be deregistered
**unconditionally**, without gating on the probe — a probe false-negative MUST NEVER turn an
uninstall into a silent no-op. A scope merely being SWEPT (the other scope during `install`, the
second scope of `uninstall --scope auto`) MUST be probe-gated, so a scope nobody asked about is
never written to.
- **`uninstall` scope sweep.** An explicit `--scope` removes exactly that scope. `--scope auto`
removes the resolved scope and then sweeps the other, so an uninstall can never leave a
registration behind still starting the node. Every scope is reported
(`result.removed_scopes: ["system", "user"]`). Anything short of a complete removal MUST be an
error naming the scope, never a silent success: a scope seen but not removed, or one left
**indeterminate** (the probe could not read it AND the removal did not succeed, so a remaining
registration is unproven either way). Only when nothing was removed anywhere AND nothing is
unresolved is the result `NOT_FOUND` ("nothing to uninstall"), carrying the underlying removal
error as context.
- **Native packages register system scope**, consistently with `--scope system`: the `.deb`'s static
systemd unit is `WantedBy=multi-user.target` running as root, and the macOS `postinstall`
bootstraps into the `system` launchd domain (§9.7).

9.2. **Recorded environment.** `install` MUST register the absolute path of the currently-running
executable (never a PATH lookup) and record the resolved config as service environment variables:
Expand Down Expand Up @@ -1727,12 +1776,15 @@ registering a system-level service, `install` MUST verify the program's director
component privileged-owned, non-reparse) and **refuse with `PERMISSION_DENIED`** otherwise, before any
side effect (no state-dir harden, no service create). This is the SAME spawn-free owner gate the
self-heal spawn root (§7 #565) and the TLS material root (§4.1a #661) use — one shared check,
fail-closed on an indeterminate owner. A **user-level** install
(the Linux/macOS default) runs as the very user who owns the binary, crosses no privilege boundary,
and is always allowed. The canonical install path (native OS package, §9.7) places the binary in a
protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so it satisfies
the gate; a manual `dig-node install` from a user-writable download directory is what the gate
refuses. A single explicit, **default-off** opt-out — the `DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET`
fail-closed on an indeterminate owner. The refusal MUST NAME the offending path
LEVEL (the first ancestor that fails the check), not merely the program path: the check walks the
whole chain, so a privileged-owned leaf under a user-writable parent is refused, and an operator
cannot act on a refusal that does not say which level failed. A **user-scope** install runs as the
very user who owns the binary, crosses no privilege boundary, and is always allowed. The canonical
install paths (native OS package, §9.7; the dig-installer's root-owned `/opt/dig/bin`) place the
binary in a protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so
they satisfy the gate; a manual system-scope `dig-node install` from a user-writable download
directory is what the gate refuses — and it is refused loudly, never downgraded to user scope. A single explicit, **default-off** opt-out — the `DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET`
env var (truthy `1`/`true`/`yes`) — bypasses the gate with a loud warning, intended ONLY for a
controlled test/dev install of an unreleased build from a build directory (e.g. the `service-smoke`
CI); it MUST NOT be set on an end-user machine.
Expand Down
52 changes: 40 additions & 12 deletions crates/dig-node-service/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,24 @@ use crate::control_cli::{self, ControlAction};
use crate::open;
use crate::pair::{self, PairAction};
use crate::peers::{self, BanState, PeersAction};
use crate::service::ScopeChoice;
use crate::{serve, service, VERSION};

/// The shared `--scope <auto|system|user>` flag carried by every service verb (#526).
///
/// Flattened into each verb rather than declared four times, so the flag's spelling, default and
/// help text can never drift between `install`, `uninstall`, `start` and `stop`. The default is
/// `auto`, so a caller that passes no flag at all — including a dig-installer release predating
/// this flag — behaves exactly as it did before.
#[derive(clap::Args)]
struct ScopeArg {
/// Which OS scope to act on: `system` (machine-wide, starts at boot, needs root/Administrator),
/// `user` (per-user, no elevation, starts with your login session), or `auto` — system when
/// running elevated, user otherwise. Windows has only system scope.
#[arg(long = "scope", value_enum, default_value_t = ScopeChoice::Auto)]
choice: ScopeChoice,
}

#[derive(Parser)]
#[command(
// A default only: [`run`] overrides both the displayed name and the usage `bin_name`
Expand Down Expand Up @@ -68,13 +84,25 @@ enum Command {
#[command(hide = true)]
RunService,
/// Register the node as an auto-starting OS service.
Install,
Install {
#[command(flatten)]
scope: ScopeArg,
},
/// Remove the OS service.
Uninstall,
Uninstall {
#[command(flatten)]
scope: ScopeArg,
},
/// Start the installed service.
Start,
Start {
#[command(flatten)]
scope: ScopeArg,
},
/// Stop the running service.
Stop,
Stop {
#[command(flatten)]
scope: ScopeArg,
},
/// Report whether the node is serving (probes /health).
Status,
/// Pair a browser controller (the DIG Chrome extension) with this node (#280):
Expand Down Expand Up @@ -291,10 +319,10 @@ impl Command {
fn action(&self) -> &'static str {
match self {
Command::Run | Command::RunService => "run",
Command::Install => "install",
Command::Uninstall => "uninstall",
Command::Start => "start",
Command::Stop => "stop",
Command::Install { .. } => "install",
Command::Uninstall { .. } => "uninstall",
Command::Start { .. } => "start",
Command::Stop { .. } => "stop",
Command::Status => "status",
Command::Pair { .. } => "pair",
Command::Open { .. } => "open",
Expand Down Expand Up @@ -377,10 +405,10 @@ pub fn run() -> std::process::ExitCode {
let exit = match command {
Command::Run => render_serve(block_on_serve(config), action, json),
Command::RunService => render_serve(run_service(config), action, json),
Command::Install => render(service::install(&config), action, json),
Command::Uninstall => render(service::uninstall(), action, json),
Command::Start => render(service::start(), action, json),
Command::Stop => render(service::stop(), action, json),
Command::Install { scope } => render(service::install(&config, scope.choice), action, json),
Command::Uninstall { scope } => render(service::uninstall(scope.choice), action, json),
Command::Start { scope } => render(service::start(scope.choice), action, json),
Command::Stop { scope } => render(service::stop(scope.choice), action, json),
Command::Status => render_status(service::status(&config), action, json),
Command::Pair { action: pair_cmd } => {
let pair_action = match pair_cmd {
Expand Down
11 changes: 10 additions & 1 deletion crates/dig-node-service/src/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ use std::path::Path;
/// component is untrusted. `dir.ancestors()` always yields at least `dir`, so an empty chain can
/// never vacuously pass.
pub fn dir_is_privileged(dir: &Path) -> bool {
dir.ancestors().all(component_is_privileged)
first_unprivileged_ancestor(dir).is_none()
}

/// The FIRST ancestor component of `dir` (walking leaf → filesystem root) that fails the trust bar,
/// or `None` when the whole chain is privileged. The diagnostic twin of [`dir_is_privileged`]: the
/// boolean answers *whether* a path is trustworthy, this answers *which level* is not — so a refusal
/// can name the offending directory instead of leaving an operator to bisect the chain by hand
/// (#526: the system-service install gate must be actionable, never a silent downgrade).
pub fn first_unprivileged_ancestor(dir: &Path) -> Option<&Path> {
dir.ancestors().find(|c| !component_is_privileged(c))
}

/// Whether a SINGLE existing path `component` clears the trust bar: privileged-owned, not
Expand Down
Loading
Loading