Skip to content

DIG-Network/dig-node

Repository files navigation

dig-node — the localhost DIG node service

The localhost DIG node for the DIG Chrome extension, shipped as a self-contained, cross-platform Rust binary that installs as an OS service (Windows, Linux, macOS).

Naming. The binary, crate, and service are all dig-node — the canonical, user-facing name for the local DIG node. Every machine-readable surface (/health, /version, --json) identifies itself as dig-node. The bind env-var names are DIG_NODE_PORT / DIG_NODE_HOST — the stable configuration contract (see the Environment section). For the legacy Linux package + the installer's pre-rename fallback, the GitHub release also publishes each binary under the old dig-companion-* filename (identical bytes). See USER_JOURNEY.md.

The extension resolves chia:// (DIG) URLs by fetching encrypted, Merkle-proven content over a DIG RPC and then verifying + decrypting it in the extension. By default it talks to rpc.dig.net; pointing its server.host setting at dig-node makes that RPC local. The node speaks the same wire contract as rpc.dig.net — because it routes every request to digstore's dig_node_core::handle_rpc, the exact local-first node the native DIG Browser runs in-process. So the extension works against it byte-for-byte, with the bonus that any .dig store the node has cached locally is served without leaving the machine.

A Rust OS-service binary. The shipped artifact is the Rust dig-node binary — a single binary with no runtime dependency (no Node install required) that installs cleanly as a Windows/Linux/macOS service, which a Node process does not do reliably, especially on Windows.

Install as a service

Download (or build — see below) the dig-node binary for your OS, then:

dig-node install     # register as an auto-starting OS service on 127.0.0.1:8080
dig-node start       # start it now
dig-node status      # confirm it's serving (probes /health)

To remove it:

dig-node stop
dig-node uninstall

Per-OS notes

OS Service backend Privilege Runs as
Windows Service Control Manager (SCM) Administrator required for install/uninstall LocalSystem
Linux systemd (user unit) no root needed the installing user
macOS launchd (user agent) no root needed the installing user
  • Windows: the SCM has no per-user services, so install/uninstall must run from an elevated (Run as administrator) terminal. dig-node detects a non-elevated console and tells you, rather than failing deep inside sc.exe. The installed service runs the binary's internal run-service entrypoint, which speaks the Windows Service Control Protocol (so the SCM does not kill it with error 1053). After install it auto-starts on boot.
  • 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.)

Point the extension at it

In the DIG Chrome extension's options, set server host to:

localhost:8080

(The extension defaults to localhost:80; port 80 needs elevated privileges on most OSes, so the node defaults to 8080. Set the extension to match, or run the node on 80 if you can.)

Addressing — http://dig.local (no port) + http://localhost:<port> (#91)

The node opens two loopback listeners for the same app so it is reachable two ways at once:

Address Listener Always on?
http://localhost:<port> (default localhost:8080) 127.0.0.1:<port> Yes — unprivileged, conflict-free. The guaranteed fallback.
http://dig.local (no port) 127.0.0.2:80 Best-effort — needs the privileged :80 bind (+ on macOS a loopback alias). Falls back gracefully if it can't bind.
  • The bare http://dig.local URL (no :port) works because dig-installer writes a hosts entry 127.0.0.2 dig.local and the node binds 127.0.0.2:80. A distinct loopback IP (.2, not .1) is used so the port-80 bind can never collide with an unrelated localhost:80 service.
  • Neither listener binds 0.0.0.0 — both are loopback IPs, so the node is never LAN-exposed.
  • Graceful fallback (never aborts): binding 127.0.0.2:80 is privileged and may fail (no privilege, port in use, or — on macOS — a missing loopback alias). If it fails, the node logs a structured warning and serves localhost-only; it never aborts. localhost:<port> is the guaranteed fallback. Set DIG_NODE_DIGLOCAL=0 to skip the dig.local attempt entirely.
  • Host-header allowlist. Both listeners answer only to the canonical local names — dig.local, localhost, 127.0.0.1, 127.0.0.2 (with or without a :port). A request with any other Host (the classic DNS-rebinding vector — a public name pointed at the loopback bind) is rejected 421 Misdirected Request with a catalogued INVALID_REQUEST error body, even though the bind is already loopback-only. A missing Host (HTTP/1.0 / health probes) is allowed.

Platform caveats for the dig.local (:80) listener

Platform What it needs for 127.0.0.2:80 Notes
Windows The installed service runs as LocalSystem (elevated), so the :80 bind works. A manual dig-node run from a non-elevated shell will fail the bind → localhost-only.
Linux root or the CAP_NET_BIND_SERVICE capability on the binary (sudo setcap cap_net_bind_service=+ep $(command -v dig-node)). A systemd user service has neither by default → localhost-only unless granted. 127.0.0.2 is part of the always-up 127.0.0.0/8 loopback range — no alias needed.
macOS The 127.0.0.2 loopback alias must exist first: sudo ifconfig lo0 alias 127.0.0.2. Binding :80 also needs elevation. The installer/service can add the alias (it does not persist across reboot unless made a launchd/networksetup step). .local is RFC-6762 mDNS-reserved (mDNSResponder); a /etc/hosts entry normally wins, but verify resolution if a dig.local name does not resolve.

.local is RFC-6762 (mDNS) reserved. A hosts//etc/hosts entry for dig.local (written by the installer) normally takes precedence over mDNS on all three platforms, so the name resolves to 127.0.0.2 without a multicast lookup. On macOS, where mDNSResponder is the resolver, confirm the hosts entry wins if dig.local ever fails to resolve.

Run in the foreground (no service)

dig-node run         # serve on 127.0.0.1:8080 until Ctrl-C
# or simply:
dig-node             # bare invocation == run

Configuration

All knobs are environment variables (read at startup; install records the current values into the service's environment so the service serves identically):

Env var Default Meaning
DIG_NODE_PORT 8080 Port the node listens on (127.0.0.1).
DIG_NODE_HOST 127.0.0.1 Bind address (loopback — the node is a same-machine endpoint).
DIG_RPC_UPSTREAM https://rpc.dig.net Upstream DIG RPC the embedded node proxies ciphertext/proof requests to on a local cache miss, and relays unhandled methods to.
DIG_NODE_CACHE %LOCALAPPDATA%\DigNode\cache / $HOME/DigNode/cache On-disk cache dir for synced .dig modules (owned by dig-node). Leave it unset to share one cache with the DIG Browser — see below.
DIG_NODE_CACHE_CAP 1 GiB Cache size cap (floored at 64 MiB), LRU-evicted. Also settable via the cache.setCapBytes RPC.
DIG_NODE_DIGLOCAL 1 (on) Whether to ALSO open the bare-http://dig.local listener (127.0.0.2:80) beside localhost:<port>. Auto-attempt with graceful fallback (see Addressing). Set to 0/false to skip the attempt.
DIG_NODE_MAX_OUTGOING_BYTES_PER_SEC 0 (unlimited) Outgoing-bandwidth cap in bytes/second. Past the cap, a request for content this node holds is redirected to another peer known to hold it (SPEC §17) instead of served over-budget; served locally as a graceful fallback when no alternate holder is known.

Shared .dig cache with the DIG Browser (#96)

dig-node and the native DIG Browser both run the SAME canonical dig-node-core node engine library (this repo), and both default to the SAME on-disk cache dir (%LOCALAPPDATA%\DigNode\cache on Windows, $HOME/DigNode/cache on Linux/macOS). So when both are installed they share ONE cache — a capsule fetched by the browser is served from disk by the standalone service and vice-versa, with no double-store.

  • Omit DIG_NODE_CACHE (the default) to keep that sharing — the node does not invent a path, it leaves dig-node to resolve its shared canonical default. dig-node makes that shared dir safe for two processes at once: atomic content-addressed module writes (so two writers converge with no partial files) plus a cross-process advisory lock around eviction and the config read-modify-write. (Requires the dig-node crate at the #95/#96 Pass A revision this repo pins.)
  • Set DIG_NODE_CACHE only to move that shared cache to an explicit location (a service data dir, or a volume shared between machines). If you do, set the same value for the browser's launch environment so the two keep sharing one cache; pointing them at different dirs gives each its own (un-shared) cache. install records an explicit DIG_NODE_CACHE into the service environment so the installed service uses the same dir you installed it with.
  • Is the cache actually shared right now? GET /health and cache.getConfig report a cache.shared boolean: true = the shared canonical dir, false = dig-node fell back to a process-private dir because the canonical dir was unwritable (it logs a one-shot warning and keeps serving, just un-shared for that session). cache.getConfig also returns the effective cache_dir path.

JSON-RPC surface

POST / speaks JSON-RPC 2.0, the same contract rpc.dig.net and the native DIG Browser's in-process node expose:

Method Behaviour
dig.getContent / dig.getCapsule Verified retrieval — returns blind ciphertext + a Merkle inclusion proof + chunk lengths ({ ciphertext, root, complete, next_offset?, inclusion_proof, chunk_lens, …, source }). Served local-first from a cached .dig module, else proxied to the upstream verbatim (so the proxy path carries total_length / offset too) and the window cached. source is local or remote. The client (extension/hub/browser) verifies + decrypts — the node mirrors the ciphertext contract, it does not return plaintext.
dig.getAnchoredRoot The store's chain-anchored tip root, resolved on-chain by walking the DataStore singleton lineage on coinset.org (the trusted root for the extension's chia:// root-pinning).
dig.getManifest A capsule's (storeId:rootHash) embedded normalized public manifest (data-section id 13): the store's complete public file surface as of that commit — { schema_version, entries: [{ path, latest_root, generation_index, sha256_latest, version_count }] }. Served local-first when this node holds the requested capsule. null (never an error) when the module carries no manifest section (an older .dig, or a private store); -32004 when the capsule isn't held locally at all.
cache.getConfig / cache.setCapBytes / cache.clear On-disk cache config: { cap_bytes (floored at 64 MiB), used_bytes, cache_dir, shared }cache_dir is the effective dir and shared whether it is the canonical dir shared with the DIG Browser (#96).
cache.listCached / cache.removeCached / cache.fetchAndCache Cached-capsule manager (storeId:rootHash).
rpc.discover Method discovery — returns this node's OpenRPC document (the standard OpenRPC discovery method), so a client can introspect every method + error over the wire with no out-of-band knowledge.
control.* CONTROL / admin surface (loopback-only + local-token gated — see below). Manage the node: hosted/pinned stores, cache, §21 sync, config. Read methods above stay open; only control.* requires the token.
dig.getProof, dig.listCapsules, anything else Blind passthrough — relayed verbatim to the upstream, so the node stays a correct transparent proxy for methods it doesn't resolve locally.

Control / admin surface (control.*) — manage the node

Beside the open read RPC, the node exposes a CONTROL / admin surface so a same-host controller — the DIG Browser "My Node" UI, or any local tool — can MANAGE the node. This is the server side of SYSTEM.md → "the browser is also the dig-node's CONTROLLER UI" (dig-node = serve + be-controllable; the browser = consume + control).

Security — loopback-only + locally authorized

Two layers gate the control surface (the read methods are not gated):

  1. Loopback-only — the whole server binds 127.0.0.1, so nothing off-machine can reach any method.
  2. Local authorization for the mutating control.* namespace. A random control token (32 bytes, 64-hex) is generated at first run into the node's config dir at <config_dir>/control-token (next to dig-node's config.json; 0600 on Unix). A same-host controller reads that file — it can, because it runs as the same user on the same machine — and presents the token on every control.* call, as the X-Dig-Control-Token request header or a params._control_token field. A call without a valid token is rejected with UNAUTHORIZED (-32030). Token verification is constant-time; the token is generated at runtime and never committed.

This is the standard local-capability-file pattern (cf. Chia's daemon / Bitcoin's cookie auth): possession of the on-disk token is authorization, so a random web page (which cannot read a local file) is rejected even though it can reach loopback, while the legitimate local controller is allowed.

Methods

All control.* methods require the local control token. Params are a JSON object; store is a capsule reference storeId or storeId:rootHash (each part lowercase 64-hex).

Method Params Result
control.status { running, service, version, commit, dig_node_version, protocol, uptime_secs, addr, upstream, cache:{cap_bytes,used_bytes,dir,shared}, hosted_store_count, cached_capsule_count, pinned_store_count, sync:{available} }
control.config.get { addr, port, upstream, upstream_override, cache_dir, cache_shared, config_path, sync_available }
control.config.setUpstream { upstream } { upstream, requires_restart:true } — persisted; the running node captured its upstream at startup, so the change takes effect on the next start. A blank upstream clears the override.
control.cache.get { cap_bytes, used_bytes, dir, shared }
control.cache.setCap { cap_bytes } { cap_bytes } (floored at 64 MiB)
control.cache.clear { cleared:true }
control.hostedStores.list { stores:[ { store_id, pinned, capsule_count, total_bytes, capsules:[{capsule,root,size_bytes,last_used_unix_ms}] } ] }
control.hostedStores.pin { store } { store_id, root, pinned:true, fetch:{status,…} } — records the pin; pre-fetches the capsule via §21 sync when a concrete root is given.
control.hostedStores.unpin { store } { store_id, unpinned, evicted_capsules } — removes the pin and evicts the store's cached capsules.
control.hostedStores.status { store } { store_id, pinned, capsule_count, total_bytes, capsules:[…] }
control.sync.status { available, method:"section-21-whole-store-sync", pinned_total, pinned_synced, whole_store_trigger_supported }
control.sync.trigger { store } (= storeId:rootHash) or { store_id, root } { store_id, root, status:"synced", size_bytes, served_root }, or NOT_SUPPORTED (-32031) if no §21 identity.

What's proxied vs. owned. Cache + sync operations proxy to the node engine library (dig_node_core) (cache_*, clear_cache, set_cache_cap_bytes, Node::cache_fetch_and_cache / cache_remove_cached / cache_list_cached) — the node never duplicates the cache/read logic. The shell owns only the small state the crate does not model: the pin registry (pinned_stores) and the upstream override (upstream_override), persisted under the node's own keys in dig-node's shared config.json (atomic temp+rename writes that never clobber dig-node's keys).

Driving it from the DIG Browser controller (part b)

The DIG Browser "My Node" UI calls these methods over loopback (http://localhost:<port>/), reading the token from <config_dir>/control-token and sending it as X-Dig-Control-Token. Discover the whole surface — methods, x-requires-auth flags, the info.x-control-auth token scheme, and the error catalogue — from GET /openrpc.json or rpc.discover.

Discovery & health endpoints

A single fetch tells an agent what the node is, where it serves, and what it speaks:

Endpoint Returns
GET /health { status, service:"dig-node", version, commit, mode, addr, upstream, cache:{ dir, cap_bytes, used_bytes, shared }, methods:[…] } — extends the original health body (existing probes keep parsing status/version/mode/upstream/cache). cache.shared (#96) tells whether the cache is the dir shared with the DIG Browser.
GET /version { service:"dig-node", version, commit, dig_node_version, protocol } — the build fingerprint, to correlate a running node to an exact source revision.
GET /openrpc.json The OpenRPC document for the JSON-RPC surface (methods + error catalogue), generated from the method/error source so it cannot drift.
GET /.well-known/dig-node.json The canonical discovery doc: identity, bound addr, cache (dir/cap_bytes/used_bytes/shared), the method catalogue, the error catalogue, and pointers to the OpenRPC/health/version endpoints.

CORS reflects chrome-extension:// and the local page origins http://localhost / http://dig.local / http://127.0.0.1 / http://127.0.0.2 (with or without a :port), so the extension and any page served from a canonical local name can call it.

Machine-readable contracts (agent-friendly)

CLI --json

Every subcommand accepts the global --json flag: machine output goes to stdout, human prose to stderr.

dig-node status --json
# {"ok":true,"action":"status","service":"dig-node","version":"0.3.0","serving":false,"addr":"127.0.0.1:8080",…}

dig-node install --json
# {"ok":true,"action":"install","installed":true,"registered":true,"started":false,"label":"…","scope":"system","addr":"127.0.0.1:8080",…}

On failure: { "ok":false, "action":…, "error":{ "code", "exit_code", "message", "hint" } }.

Exit-code table

Each failure class maps to a distinct process exit code (not a generic 1), backed by the typed ExitCode enum in src/cli.rs:

Exit Code (UPPER_SNAKE) Meaning
0 OK Success.
1 NOT_SERVING status: the node is not responding (scriptable "is it up?").
2 USAGE Bad arguments / usage error.
3 PERMISSION_DENIED install/uninstall need an elevated (Administrator) console (Windows).
4 SERVICE_FAILED A service operation failed (register/start/stop/uninstall).
5 BIND_FAILED run: could not bind the loopback address.
6 IO_ERROR Other I/O error.

JSON-RPC error-code catalogue

Wire errors carry a stable UPPER_SNAKE symbolic name in error.data.code (+ error.data.origin distinguishing node-shell errors from upstream/boundary ones), beside the numeric JSON-RPC code. Catalogued in src/meta.rs (the ErrorCode enum), embedded in /openrpc.json and /.well-known/dig-node.json:

JSON-RPC code Name Origin Meaning
-32700 PARSE_ERROR shell Request body was not valid JSON.
-32600 INVALID_REQUEST shell Not a single JSON-RPC object (batch arrays unsupported).
-32601 METHOD_NOT_FOUND boundary Not resolved locally or by the upstream.
-32602 INVALID_PARAMS upstream Invalid or missing method parameters.
-32000 DISPATCH_FAILED shell The node failed to dispatch the request.
-32010 UPSTREAM_ERROR shell The blind-passthrough relay to the upstream failed.
-32030 UNAUTHORIZED shell A control.* method was called without a valid local control token.
-32031 NOT_SUPPORTED shell A control op the node build can't perform (e.g. §21 sync with no identity).
-32032 CONTROL_ERROR shell A control operation failed at runtime (e.g. could not persist the pin registry).

The control-plane codes are -32030/-32031/-32032. -32020/-32021/-32022 are RESERVED for the onion-routing (private-retrieval) contract and are never minted by the control plane.

How the read path is wired (the important design decision)

The node engine does not reimplement the .dig store format — the dig-node-core crate depends on digstore's store-format library crates (digstore-core/-crypto/-chain/-host/-remote/ -stage, git-pinned to one coherent rev) and owns the read path itself in dig_node_core::handle_rpc. Every host shell routes each request to that one dispatch. This is the same node the native DIG Browser runs in-process, so the node and the browser share one read path, one cache, and one cache contract (see "Shared .dig cache" above). The dependency direction is dig-node-core → store-lib, never the reverse — digstore is only ever an RPC client of a node.

  • dig-node is a clean Cargo dependency: the guest-wasm build prerequisite that gates digstore-cli (its build.rs embeds the compiled guest) does not apply to dig-node, which depends only on digstore-host / -core / -remote / -chain. No special build step is needed.
  • All TLS is rustls (no system OpenSSL), so the binary is genuinely self-contained.
  • The node adds only the shell around that node: the HTTP server, CORS, /health, request normalisation, a blind-passthrough fallback (dig-node answers only dig.getContent / dig.getAnchoredRoot / cache.* and returns "method not found" for the rest; the node relays those to the upstream), and the OS-service install/management.

Build from source

Requires Rust (the repo pins 1.94.1).

cargo build --release          # → target/release/dig-node[.exe]
cargo test                     # routing, config, cache-key, service helpers + an in-process server test
cargo fmt --check
cargo clippy --all-targets -- -D warnings

The first build fetches the dig-node git dependency (and its digstore/wasmtime tree), so it takes a few minutes; subsequent builds are fast.

Architecture

build.rs          captures the git commit SHA at build time (the /version `commit` field)
src/
  main.rs         CLI (run / run-service / install / uninstall / start / stop / status) + --json rendering
  lib.rs          module wiring
  config.rs       env-driven Config (port/host/upstream) — pure, tested
  meta.rs         self-describing surface: version/build info, method catalogue, ErrorCode catalogue,
                  OpenRPC + /.well-known/dig-node.json documents — pure, tested
  cli.rs          --json envelopes + the differentiated ExitCode table — pure, tested
  rpc.rs          JSON-RPC routing + request normalisation + catalogued error envelopes — pure, tested
  control.rs      CONTROL/admin surface (control.*): hosted-stores/cache/sync/config management +
                  the loopback-only local-token auth gate + pin registry — pure helpers tested
  server.rs       axum HTTP server: /health, /version, /openrpc.json, /.well-known/dig-node.json, CORS,
                  POST / → dig_node_core::handle_rpc (+ rpc.discover) + the gated control plane, passthrough fallback
  service.rs      OS-service install/uninstall/start/stop/status (service-manager) + /health status probe
  win_service.rs  Windows Service Control Protocol entrypoint (windows-service; Windows only)
USER_JOURNEY.md   the dig-node user/operator/agent journey, surfaces, and ecosystem hand-offs

Relationship to the rest of the ecosystem

  • The wire contract is identical to rpc.dig.net and to the native browser's in-process node, because all three are (or route to) dig_node_core::handle_rpc. Clients see one consistent local-node API across the extension and the native browser.
  • The verify/decrypt read-crypto lives in the extension (the same dig_client WASM the hub uses); the node serves the ciphertext + proof the extension consumes. See the repo SYSTEM.md.

Docs & help

License

MIT (the binary). The bundled dig-node read path is GPL-2.0-only (digstore). See the DIG Network organization.

About

No description or website provided.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors