Skip to content
Merged
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
163 changes: 163 additions & 0 deletions .github/workflows/service-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# A REAL, 3-OS end-to-end smoke test of `dig-node install`/`start`/`uninstall` against the
# actual OS service manager (Windows SCM / systemd / launchd) — the thing ci.yml's mocked
# `ServiceBackend` unit tests (crates/dig-node-service/src/service.rs) cannot prove: that
# `install` registers a service the OS actually starts and serves from, that a SECOND `install`
# cleanly recreates it instead of hitting Windows `CreateService` error 1073 (#494), and that
# `uninstall` actually removes the registration.
#
# Kept as its OWN workflow (not folded into ci.yml) because it performs REAL, mutating
# OS-service registration on every runner — a materially different (and slower/less hermetic)
# kind of check than the rest of the PR gate set.
name: Service smoke test

on:
pull_request:
paths:
- "crates/dig-node-service/src/service.rs"
- "crates/dig-node-service/src/config.rs"
- "crates/dig-node-service/src/hosts.rs"
- "packaging/**"
- ".github/workflows/service-smoke.yml"
push:
branches:
- main
workflow_dispatch:

concurrency:
group: ${{ github.ref }}-${{ github.workflow }}-${{ github.event_name }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always

jobs:
smoke:
name: install/start/reinstall/uninstall (${{ matrix.os }})
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-14, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
key: service-smoke

- name: Build dig-node (release)
run: cargo build --release --locked --bin dig-node

# `dig-node install` registers a USER-level systemd unit on Linux (no root needed), which
# needs a real user D-Bus session — the GH-hosted runner's non-interactive job shell does
# not start one implicitly. `enable-linger` + pointing at the per-uid runtime dir brings one
# up so `systemctl --user ...` (what service-manager shells out to) has a bus to talk to.
- name: (Linux) bring up a user systemd/D-Bus session
if: runner.os == 'Linux'
run: |
sudo loginctl enable-linger "$(whoami)"
echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV"

- name: Resolve the built binary path
id: bin
shell: bash
run: |
if [ "${{ runner.os }}" = "Windows" ]; then
echo "path=target/release/dig-node.exe" >> "$GITHUB_OUTPUT"
else
echo "path=target/release/dig-node" >> "$GITHUB_OUTPUT"
fi

- name: Install (1st) — fresh registration
shell: bash
run: |
"${{ steps.bin.outputs.path }}" install --json | tee install-1.json
grep -q '"installed":true' install-1.json
grep -q '"reinstalled":false' install-1.json

- name: Windows — assert the SCM display name
if: runner.os == 'Windows'
shell: bash
run: |
sc.exe qc net.dignetwork.dig-node | tee sc-qc-1.txt
grep -Eq 'DISPLAY_NAME[[:space:]]*: DIG NETWORK: NODE' sc-qc-1.txt

- name: Start + poll for RUNNING
shell: bash
run: |
"${{ steps.bin.outputs.path }}" start --json || true
ok=0
for _ in $(seq 1 20); do
if "${{ steps.bin.outputs.path }}" status --json | tee status-1.json | grep -q '"serving":true'; then
ok=1
break
fi
sleep 1
done
test "$ok" -eq 1

- name: Install (2nd) — proves clean-reinstall avoids CreateService 1073
shell: bash
run: |
"${{ steps.bin.outputs.path }}" install --json | tee install-2.json
grep -q '"installed":true' install-2.json
grep -q '"reinstalled":true' install-2.json

- name: Windows — re-assert the SCM display name after reinstall
if: runner.os == 'Windows'
shell: bash
run: |
sc.exe qc net.dignetwork.dig-node | tee sc-qc-2.txt
grep -Eq 'DISPLAY_NAME[[:space:]]*: DIG NETWORK: NODE' sc-qc-2.txt

- name: Start again + poll for RUNNING
shell: bash
run: |
"${{ steps.bin.outputs.path }}" start --json || true
ok=0
for _ in $(seq 1 20); do
if "${{ steps.bin.outputs.path }}" status --json | tee status-2.json | grep -q '"serving":true'; then
ok=1
break
fi
sleep 1
done
test "$ok" -eq 1

- name: Stop
shell: bash
run: |
"${{ steps.bin.outputs.path }}" stop --json

- name: Uninstall + assert the service is gone
shell: bash
run: |
"${{ steps.bin.outputs.path }}" uninstall --json | tee uninstall.json
grep -q '"installed":false' uninstall.json
if [ "${{ runner.os }}" = "Windows" ]; then
! sc.exe query net.dignetwork.dig-node
elif [ "${{ runner.os }}" = "macOS" ]; then
! launchctl print "gui/$(id -u)/net.dignetwork.dig-node"
else
! systemctl --user cat net.dignetwork.dig-node.service
fi

- name: Upload diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: service-smoke-${{ matrix.os }}
path: |
install-1.json
install-2.json
status-1.json
status-2.json
uninstall.json
sc-qc-1.txt
sc-qc-2.txt
if-no-files-found: ignore

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +35 to +163
13 changes: 12 additions & 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"
# (`[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.28.0"
version = "0.29.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
69 changes: 69 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Development Log — dig-node

High-signal realizations from debugging/development: non-obvious cross-system couplings,
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).

## Bare-git dependency version pins unify across the WHOLE dependency graph, not per-manifest (#494)

A `git`-sourced Cargo dependency with NO `rev`/`branch`/`tag` is identified purely by its URL —
every crate in the build graph that declares `dig-constants = { git = "https://github.com/..." }`
with no ref resolves to the SAME package instance, whatever `version =` requirement each manifest
states individually. If two manifests state incompatible 0.x requirements against that one bare
source (e.g. `dig-node-core`'s transitive `dig-nat`/`dig-gossip`/`dig-dht`/`dig-onion` chain pins
`dig-constants = "0.2"`, i.e. `^0.2` = 0.2.x ONLY), cargo cannot resolve a single version
satisfying both — bumping one manifest's bare-git requirement to `"0.3"` breaks the whole graph
until every bare-git consumer moves together.

**The escape hatch:** an EXPLICIT `rev =` pin is a cargo-DISTINCT source from a bare git dep,
even at the identical commit — so a crate that needs a newer version of a NOT-yet-crates.io'd
dependency can `rev`-pin its OWN copy without forcing every other bare-git consumer forward. This
is exactly how `dig-node-service` picked up `dig_constants::DIG_NODE_PORT` (added in 0.3.0)
without waiting on `dig-nat`/`dig-gossip`/`dig-dht`/`dig-onion` to move off their `^0.2` pin: it
added `dig-constants = { version = "0.3", git = "...", rev = "<v0.3.0 commit>" }`, giving the
crate its own 0.3.0 instance living alongside the graph's existing 0.2.1 (bare-git) and 0.1.0
(crates.io registry) instances. Safe whenever the only thing crossing the boundary between the
two instances is a plain value type (here, a `u16` constant) — never safe if a type from one
instance needs to be passed to/from code built against the other.

## The dig-installer's `install` → `start` sequencing constrains what `dig-node install` may do

`dig-installer`'s `register_dig_node` step calls `dig-node install` and then, when configured to
start it (the default), a SEPARATE `dig-node start` — and treats a `start` FAILURE as fatal for
that installer step (unlike the tolerant treatment of an `install` failure). This means
`dig-node install` must NEVER auto-start the service itself: if it did, the installer's follow-up
`start` would hit "service already running" (Windows SCM 1056, or a systemd/launchd
no-op-or-error depending on backend) and could flip the installer's REPORTED `installed` status
to `false` even though the service is actually up and running fine. `dig-dns`'s equivalent
`reinstall()` DOES auto-start at the end of its clean-reinstall — that pattern was deliberately
NOT mirrored here for this reason. Any future change to `dig-node install`'s start behavior must
also update `dig-installer`'s `register_dig_node`/`install_service` in the SAME unit of work.

## `service-manager`'s systemd backend registers under `to_script_name()`, not `to_qualified_name()` (#494)

`ServiceLabel` has TWO different string renderings — `to_qualified_name()` (`{qualifier}.
{organization}.{application}`, e.g. `net.dignetwork.dig-node`) and `to_script_name()`
(`{organization}-{application}`, e.g. `dignetwork-dig-node` — the qualifier is DROPPED
entirely). `service-manager` 0.7's Windows (`sc.rs`) and launchd (`launchd.rs`) backends both
register the service under `to_qualified_name()`, but its **systemd** backend (`systemd.rs`)
names the actual unit file from `to_script_name()` instead — a real, silent divergence with no
compile-time signal. Any code that probes "is this service registered?" by shelling out
directly (`service-manager` itself exposes no such query) MUST use the SAME name the relevant
backend actually registered under, per-platform — using `to_qualified_name()` uniformly makes
the probe always report "not found" on Linux, invisibly. This was caught only by a REAL 3-OS
`service-smoke` CI run (mocked-backend unit tests, being backend-agnostic by design, cannot
catch it) — a second `dig-node install` on `ubuntu-latest` reported `reinstalled:false` because
`is_installed()` never saw the service it had just registered.

## Windows `sc create` always names the display the same as the service id

`service-manager` 0.7's `sc.rs` backend hardcodes the Windows SCM display name to the service id
at `sc create` time — there is no `ServiceInstallCtx` field for it. The only way to set a
friendly display name is a POST-create `sc config <id> displayname= "<name>"` follow-up (and,
per #494, a `sc qc <id>` read-back to actually confirm it took, rather than trusting the `sc
config` exit code). `service-manager`'s systemd/launchd backends have no display-name-equivalent
override either — for systemd the closest analog is `Description=`, generated from the label with
no override field; for launchd there is no such key at all. The NATIVE `.deb`/`.pkg`/`.msi`
packages (`packaging/`) sidestep this entirely by shipping their own static unit
file/plist/WiX-`ServiceInstall` with the friendly name baked in — only the bare `dig-node install`
CLI path (not via a native package) needs the `sc config`/`sc qc` dance.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ dig-node uninstall
kill it with error 1053). After install it auto-starts on boot, and `install` also configures SCM
**recovery actions** (`sc.exe failure` — restart 5s/10s/30s after the 1st/2nd/subsequent crash, resetting after a day with no further crashes) so a crashed service comes back up on its own,
same as the systemd/launchd behavior below; check with `sc qfailure net.dignetwork.dig-node`.
It shows up in the Services console as **"DIG NETWORK: NODE"** (set via a post-create
`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.)
Expand Down Expand Up @@ -273,7 +277,7 @@ dig-node status --json
# {"ok":true,"action":"status","service":"dig-node","version":"0.5.0","serving":false,"addr":"127.0.0.1:9778",…}

dig-node install --json
# {"ok":true,"action":"install","installed":true,"registered":true,"started":false,"label":"…","scope":"system","addr":"127.0.0.1:9778",…}
# {"ok":true,"action":"install","installed":true,"reinstalled":false,"registered":true,"started":false,"label":"…","display_name":"DIG NETWORK: NODE","scope":"system","addr":"127.0.0.1:9778",…}
```

On failure: `{ "ok":false, "action":…, "error":{ "code", "exit_code", "message", "hint" } }`.
Expand Down
34 changes: 33 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,12 @@ does not own them (except `DIG_NODE_UPSTREAM`, which the shell SETS — see belo
| `DIG_NODE_MAX_OUTGOING_BYTES_PER_SEC` | outgoing-bandwidth throttle cap, in bytes/second (§17) | `0` (UNLIMITED — opt-in) | Parsed as `u64`; `0`, unparsable, or unset ⇒ unlimited (the throttle is a no-op until an operator configures a cap). Resolved ONCE at node construction. |

The peer-network layer additionally honors `DIG_PEER_NETWORK` (set to a falsy value to disable the L7
peer network) and `DIG_RELAY_URL` (override or disable the relay), which gate the P2P bring-up.
peer network) and `DIG_RELAY_URL` (override or disable the relay), which gate the P2P bring-up, and
**`DIG_PEER_PORT`** — the P2P listen port for the mTLS peer-RPC server (dig-node-to-dig-node
traffic, §5.2/§14 — distinct from the client-facing `DIG_NODE_PORT` JSON-RPC listener above).
Parsed as `u16`; unparsable/unset ⇒ the default **`9444`** (`peer::DEFAULT_P2P_PORT`, matching
dig-gossip's own `DEFAULT_P2P_PORT` so a node and its gossip peers agree on the conventional port
with no configuration). Bound dual-stack IPv6-first with an IPv4 fallback, per §5.2.

### 3.3. Upstream normalization

Expand Down Expand Up @@ -1198,6 +1203,33 @@ up on its own, not sit stopped until a human restarts it:
`result.recovery_configured: false` in `--json` output (`true` otherwise, and always `true` on
Linux/macOS since their defaults already apply).

9.2b. **Display name + clean-reinstall (`install`, #494).** `install` is a stop→delete→wait→create
CLEAN-REINSTALL, never a reconfigure-in-place, so re-running it against an already-registered
service does not hit Windows `CreateService` error 1073 ("the specified service already exists"):

- If the service is not yet registered, `install` simply creates it.
- If it IS already registered, `install` best-effort stops it, deletes (deregisters) it, polls for
the deregistration to actually take effect (bounded, `TimedOut` if it never does — a lingering
Windows deletion can hold on until open handles close), and only THEN recreates it.
- **`install` never starts the service** (fresh or reinstalled) — it only registers
`autostart: true` for the next boot/login. A caller starts it explicitly with `dig-node start`.
This is deliberate: the dig-installer calls `install` then, when configured to start it, a
SEPARATE `start` and treats a `start` failure as fatal for that step; if `install` also started
the service, that second `start` would hit "already running" and could flip the installer's
reported outcome to failed even though the service is up.
- **Windows display name.** `sc create` (via `service-manager`) always sets the SCM display name to
the service id; `install` follows it with `sc config <id> displayname= "DIG NETWORK: NODE"`, then
reads the config back with `sc qc <id>` to CONFIRM the override took (rather than trusting the
`sc config` exit code alone). Both steps are best-effort — a failure leaves the service
registered and usable, just possibly showing its id instead of the friendly name in the Services
console; `result.display_name_verified` (`--json`) reports whether the read-back confirmed it.
- **macOS/Linux friendly name.** launchd has no display-name-equivalent plist key, so the daemon is
identified by its `Label` (`net.dignetwork.dig-node`) only. systemd's `Description=` DOES carry a
friendly name; the native `.deb`'s STATIC unit file (§9.7) already sets
`Description=DIG NETWORK: NODE`. A bare `dig-node install` (not via the `.deb`) registers a
service-manager-generated unit whose `Description` is the service id, matching `dig-dns`'s own
established precedent for the CLI-only path.

9.3. **Entrypoint per platform.** The installed service runs `dig-node run-service` on Windows and
`dig-node run` on systemd/launchd (which exec the foreground process directly).

Expand Down
15 changes: 15 additions & 0 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ service-manager = "0.7"
# system OpenSSL), same as the node + store crates.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

# `dig_constants::DIG_NODE_PORT` (§5.3) — the single-sourced canonical default localhost port
# for client→node connections; `config.rs`'s DEFAULT_PORT reads it rather than re-declaring the
# literal. Rev-PINNED deliberately, to a DISTINCT source from dig-node-core's own bare-git
# `dig-constants = { version = "0.2", git = "..." }` (which the P2P stack — dig-nat/dig-gossip/
# dig-dht/dig-onion — is release-ordered to stay on until 0.2.x reaches crates.io): a bare-git
# dependency has no `rev`, so cargo unifies EVERY bare-git reference to that URL into ONE
# resolved version, and ^0.2 + ^0.3 cannot both be satisfied by a single instance. Pinning this
# crate's dependency to the exact v0.3.0 commit makes it a cargo-DISTINCT source (a `rev`-pinned
# git dependency never unifies with a bare one, even at the identical commit), so this crate gets
# its own 0.3.0 instance without forcing dig-node-core's 0.2.x pin forward. Safe because the only
# thing crossing the boundary is a plain `u16` constant — the two instances never need to
# interoperate. Bump the `rev` (and the `version` req) when dig-node-core's own pin moves past
# 0.3.x and unification becomes possible again.
dig-constants = { version = "0.3", git = "https://github.com/DIG-Network/dig-constants", rev = "438ac8caf9ffb80cadd10362deacd63763a7f141" }

# Windows Service Control Protocol. service-manager only REGISTERS the service in the
# SCM; the binary the SCM launches must itself speak the service protocol
# (StartServiceCtrlDispatcher → report RUNNING/STOPPED) or the SCM kills it with error
Expand Down
Loading
Loading