dig-dns is a standalone OS service that gives the local machine *.dig name resolution:
a browser opening http://<label>.dig/<path> loads DIG store content. This document is the
authoritative contract an independent reimplementation MUST satisfy. Key words MUST,
MUST NOT, SHOULD, MAY are used per RFC 2119.
dig-dns is a client of a dig-node (the local DIG node, or rpc.dig.net): it holds
no content and does no chain I/O of its own — it resolves and serves content the node
provides, exactly as the digstore CLI relates to the node. The .dig read-crypto it
performs is byte-identical to digstore-core / dig-client-wasm (§8).
dig-dns runs two cooperating servers on a dedicated loopback IP (default 127.0.0.5):
- a DNS responder (UDP + TCP, port 53) that answers
*.digwithA 127.0.0.5; and - an HTTP gateway (port 80, deterministic fallback 8053) that resolves the store id from the request host, fetches content from the dig-node, verifies + decrypts it, and serves it.
Because a browser can bypass the OS resolver (DNS-over-HTTPS, its own built-in resolver),
dig-dns MUST support two independent resolution paths, either of which alone makes a
.dig URL load:
- Path A — OS split-DNS. The OS is configured (by the installer, out of scope here) to
send
.digqueries to the DNS responder, which returns127.0.0.5; the browser then makes an ordinary origin-form request to the gateway. - Path B — PAC proxy. The browser is pointed at a PAC file (served at
/.dig/proxy.pac) that routes*.digto the gateway as an HTTP proxy; the gateway handles the absolute-form proxy request directly, needing NO DNS at all.
The doctor subcommand (§9) checks each link of each path independently.
dig-dns also, independently, ENSURES a THIRD address reaches the local dig-node:
http://dig.local (the installer-mapped control host, default 127.0.0.2:80 — distinct from
the dedicated 127.0.0.5 above). This is a plain reverse proxy, not a .dig store lookup; see
§12.
A DIG store id is 32 bytes, canonically 64 lowercase hex characters
(digstore_core::Bytes32). A DNS label is limited to 63 characters (RFC 1035 §2.3.4), so a
64-hex label does not fit. dig-dns therefore encodes the store id in the DNS label as:
- lowercase RFC 4648 base32, no padding — exactly 52 characters (
ceil(256/5) = 52).
A .dig host carries ONE or TWO store labels, selecting which root to serve (aligns with the
#128 URN grammar: a capsule is storeId:rootHash):
<storeId>.dig(one label before the TLD) → serve the store's LATEST chain-anchored root.<rootId>.<storeId>.dig(two labels; the LEFTMOST is the pinned root, the next is the store) → serve that EXACT root — a capsule. A literalstoreId:rootHashcolon is only the logical/URN form::is not a valid DNS label character and a browser parseshost:NNNas a port, so the pinned root is carried as a left-most subdomain label instead.
Both labels use the SAME 52-char base32 codec (§2, a 32-byte id each; each well under the
63-char label limit). The custom DNS responder answers ANY name ending in .<tld> (§3), so a
two-label host resolves fine — it is not bound by real-DNS single-label wildcard semantics.
More than two labels, an empty label, or a label that is not valid base32 is rejected → 404.
Parsing is implemented in dig_dns::host::parse_dig_host → HostTarget::{Latest, Pinned}.
The browsable host is therefore <label>.<tld> (latest) or <rootLabel>.<storeLabel>.<tld>
(pinned), e.g. <52-char-base32>.dig.
Rules.
- Encoding MUST use the RFC 4648 base32 alphabet (
A–Z2–7) rendered lowercase (a–z2–7), with padding removed. Base32 (not base64/base32hex) because its alphabet is letters-and-digits only (LDH-compatible) and case-insensitive — it survives DNS's case-insensitivity and hyphen/label rules; base64 does not. - Decoding MUST be case-insensitive: a resolver applying DNS 0x20 bit-mixing (randomised label case, a cache-poisoning defense) MUST still round-trip. Implementations canonicalise to the uppercase alphabet before decoding.
- Decoding MUST reject: a label that is not exactly 52 characters; any character outside the base32 alphabet; a base32 string that does not decode to exactly 32 bytes. A rejected label is not a valid store and MUST NOT be resolved (fast 404 / NXDOMAIN).
- The decoded 32 bytes are the store id; they are rendered back to 64 lowercase hex for
the dig-node RPC
store_id, matchingBytes32::to_hex.
This codec is the ONLY representation change between the DNS name and the node lookup. It is
implemented in dig_dns::label (store_hex_to_label, label_to_store_hex,
store_bytes_to_label, label_to_store_bytes, is_valid_label).
The responder binds UDP and TCP on <loopback_ip>:53 (default 127.0.0.5:53). It MUST
NOT bind 0.0.0.0 or any routable interface (§5).
Answering policy, by query:
| Query | Response |
|---|---|
<label>.dig or the apex dig, type A |
A <loopback_ip> (default 127.0.0.5), TTL 1–5 s (default 2) |
any *.dig name, type AAAA |
NODATA (NOERROR, empty answer) — no IPv6 loopback answer |
any *.dig name, any other type (MX, TXT, …) |
NODATA (NOERROR, empty answer) |
any name NOT under .dig |
REFUSED — dig-dns is authoritative only for .dig; it is not a recursive resolver |
Additional requirements:
- The responder MUST answer regardless of the base32 label's validity for an A query
(return
127.0.0.5); an invalid label is rejected later by the gateway with a fast 404, so DNS stays a cheap constant-time wildcard. It MUST NOT perform node I/O to answer DNS. - It MUST preserve the exact case of the queried name in the answer (0x20 echo).
- It MUST support EDNS0: echo an OPT record when present; it MUST NOT expand the answer beyond the negotiated UDP payload size — on overflow it sets TC so the client retries over TCP, and it MUST serve the same answer over TCP.
- It MUST NOT ever answer with
0.0.0.0or a non-loopback address. - TTL MUST be short (1–5 s) so an uninstall / re-point takes effect quickly.
Verify (no installer needed): dig @127.0.0.5 -p 53 <label>.dig → 127.0.0.5;
dig @127.0.0.5 -p 53 <label>.dig AAAA → NOERROR/empty; dig @127.0.0.5 -p 53 example.com →
REFUSED. On Windows without BIND tools: Resolve-DnsName -Server 127.0.0.5 <label>.dig.
The gateway binds <loopback_ip>:80 (default 127.0.0.5:80). If :80 cannot be bound
(e.g. held by http.sys or another server) it MUST fall back deterministically to
<loopback_ip>:8053 and report the actually-bound port via /.dig/health and the PAC file.
It MUST NOT bind 0.0.0.0 (§5).
The gateway accepts exactly two request shapes and MUST reject everything else:
- Origin-form (Path A): a normal request with
Host: <label>.<tld>and a path (GET /assets/app.js HTTP/1.1). Used after OS DNS points the host at127.0.0.5. - Absolute-form (Path B): an HTTP proxy request whose target is an absolute URI
(
GET http://<label>.<tld>/assets/app.js HTTP/1.1). Used when the browser proxies via the PAC file.
The store label is taken from the Host header (origin-form) or the absolute-URI authority
(absolute-form); the resource path is the request path in both.
- A CONNECT request MUST be rejected (
405/403) —dig-dnsnever tunnels; it does no TLS interception. - An absolute-form request whose authority is not under
.<tld>MUST be rejected with 403 and MUST NOT be forwarded anywhere.dig-dnsis a.dig-only gateway, never a general forward proxy. - A request whose host is not a syntactically valid
<label>.<tld>(bad/oversized label) MUST get a fast 404 without any node I/O.
For a valid .dig host and request path /<path>:
- Parse the host into a
HostTarget(§2.1):Latest { store_id }(one label) orPinned { store_id, root }(two labels; the pinned root). - Map the path to a resource_key (§4.4) and derive its
retrieval_key(§8). - Determine the trusted root:
- Latest → call
dig.getAnchoredRoot { store_id }to get the current chain-anchored root (§6.1); - Pinned → use the root from the host directly (no
getAnchoredRoot).
- Latest → call
- Fetch the resource via
dig.getContent { store_id, retrieval_key, root }(§6.2) and verify + decrypt against the trusted root (§8). This yields plaintext bytes or a "not found in this store" outcome (a decoy/decrypt failure, or a-32004/-32005node error). For a pinned root,dig.getContentserves that exact generation; the served proof MUST fold to the pinned root or the response is refused. - SPA catch-all (§4.5) decides, for a not-found path, whether to serve
/index.htmlor return 404. - Serve the plaintext with a correct
Content-Type(§4.6), supportingGET,HEAD, and byte-Rangerequests.
A pinned-root host therefore serves that exact generation even after the store has advanced, whereas a bare-store host always tracks the latest anchored root.
- Strip the leading
/. - An empty path (
/) maps to the default resource keyindex.html(digstore_core::DEFAULT_RESOURCE_KEY). - Otherwise the resource_key is the remaining path, forward-slash separated, verbatim
(e.g.
/assets/app.js→assets/app.js). This matches the producer's key convention. - The gateway MUST reject/normalise path traversal (
.., encoded%2e%2e, backslashes, NUL) so a resource_key can never escape the store namespace (§5). A traversal attempt is a 400/404, never a filesystem read (the gateway holds no filesystem content — but the resource_key MUST still be normalised before it is hashed into a retrieval key).
DIG stores that back a .dig site are single-page-app bundles. A "not found" resource — a
decrypt/tag failure (the DIG decoy behaviour: an unknown key returns indistinguishable
ciphertext that fails to decrypt, §8), or an explicit node -32004/-32005 — is resolved as:
- if the request path has no file extension (a client-side route, e.g.
/about,/users/42) → serve/index.htmlwith200so the SPA router handles it (deep links survive a hard reload); - if the request path has a file extension (e.g.
/missing.js,/img/x.png) → return 404.
/index.html itself failing to resolve is a genuine 404/502 (nothing to fall back to).
Content-TypeMUST be inferred from the resource_key's extension (a built-in extension map;application/octet-streamwhen unknown). HTML istext/html; charset=utf-8.HEADMUST return the same headers asGETwith no body.- Byte-
Range(Range: bytes=…) MUST be honoured with206 Partial Content+Content-Range+Accept-Ranges: bytes(the gateway holds the whole decrypted resource in memory for a request and slices it). - Caching: because a resource is addressed by a content-hash root, per-root content is
immutable. The gateway MAY send
Cache-Control: immutableONLY when the response is pinned to a concrete root it resolved this request; the default (root resolved fresh each request) uses a short/no-cachepolicy so a new generation is picked up promptly. - Origin isolation: the gateway MUST NOT set permissive CORS (
Access-Control-Allow-Origin: *) — each<label>.digis its own web origin and MUST stay isolated from every other store.
Reserved under every .dig host AND directly on the loopback IP:
GET /.dig/health→200JSON{ "status": "ok", "version": "<semver>", "bound_port": <u16>, "loopback_ip": "127.0.0.5", "tld": "dig", "node": { … resolved node info … }, "paths": { "dns": <bool>, "gateway": <bool> } }. Machine-readable service state (§6.2).GET /.dig/proxy.pac→200 application/x-ns-proxy-autoconfig— a PAC file whoseFindProxyForURLroutes*.<tld>toPROXY <loopback_ip>:<actual bound port>and everything elseDIRECT. It MUST embed the ACTUAL bound gateway port (§4, fallback-aware).GET /.dig/resolve-probe→204 No Content— a zero-body liveness probe used bydoctorand health checks to confirm the gateway answers without triggering a store fetch.
These endpoints MUST be answerable even when the host label is invalid/absent (they describe the service, not a store).
CLI parity (§6.2, dig_ecosystem #569). Every read-only control endpoint has a matching CLI
verb so the same state is obtainable from the command line, not only over HTTP: GET /.dig/health
↔ dig-dns health (fetches the running service's health object over loopback and prints it, with
--json); GET /.dig/proxy.pac ↔ dig-dns pac; GET /.dig/resolve-probe ↔ the liveness probe
dig-dns status performs. dig-dns exposes NO other RPC/control surface (it is an RPC client of a
dig-node; it hosts only these loopback HTTP endpoints).
- Loopback-only. Every listener (DNS 53, gateway 80/8053) MUST bind a loopback address
(
127.0.0.0/8, default127.0.0.5).dig-dnsMUST NEVER bind0.0.0.0or a routable interface. Config that names a non-loopback bind IP MUST be refused at startup. - Never an open proxy. The absolute-form handler serves ONLY
.<tld>authorities; any other authority is403and is never forwarded.CONNECTis refused. There is no general forward-proxy path. - No TLS interception.
dig-dnsserves plain HTTP on loopback and never terminates or man-in-the-middles TLS..digis anhttp://origin on the local machine. - The serve runtime NEVER reconfigures the OS resolver. The
serve/run-serviceruntime MUST NOT modify/etc/hosts,/etc/resolver, systemd-resolved, NRPT rules, or any OS resolver configuration — it only binds its sockets and answers. OS resolver wiring is instead an EXPLICIT, elevated administrator action,dig-dns configure-os/unconfigure-os(§15), invoked by the native package post-installs or by hand — never a side effect of serving. This keeps the invariant intact: running the resolver never silently edits the machine's DNS. - DNS-rebinding defense. The gateway MUST reject a request whose host is not a valid
<label>.<tld>(a public domain pointed at127.0.0.5is refused), so even though it binds loopback it never serves a foreign-named request. - Verify-then-decrypt, fail-closed (§8). Content served to the browser MUST have passed merkle-inclusion verification against the resolved anchored root before it is decrypted and returned. A verification or decryption failure MUST NOT serve bytes.
- No secrets.
dig-dnshandles no private keys and no wallet material; it serves only public store content (public stores decrypt from the URN alone — no secret salt, §8). - Trusted absolute system-tool paths (defense-in-depth, #657). Every OS tool
dig-dnsspawns while elevated —sc/net/systemctl/launchctl/id/powershell/reg(service registration,configure-os) and the read-onlydoctorprobes — is invoked by its ABSOLUTE path (Windows: under the real system directory fromGetSystemDirectoryW; Unix: the canonical/usr/bin-family location), never a bare name, so a search-order-planted binary in an attacker-controlled working directory cannot hijack the privileged process. - TLD charset guard (defense-in-depth, #538). The configured TLD MUST match
^[a-z0-9-]+$(a DNS label) and is REJECTED at config load otherwise. The TLD flows unescaped into elevated OS-resolver builders (the NRPT PowerShell single-quoted argument, the macOS/etc/resolverPATH, systemd/NetworkManager drop-in bodies); confining it to the DNS-label charset keeps a metacharacter (',/,.., a newline, a shell separator) out of those elevated contexts. - Symlink-safe atomic
/etcwrites (defense-in-depth, #650). Every root-owned policy/resolver fileconfigure-oswrites under/etc/**on Linux is written via a sibling temp file openedO_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW,fsync'd, thenrename'd over the target — so a reader never observes a half-written file and a pre-seeded symlink at the target can never redirect the privileged write.
The IPv6-first rule (CLAUDE.md §5.2) targets peer↔peer node comms; dig-dns's listeners are
deliberately IPv4 loopback (127.0.0.5) local endpoints and are out of that rule's scope.
dig-dns speaks the dig-node control JSON-RPC (JSON-RPC 2.0 over HTTP POST /). It uses two
methods, both on the node's unauthenticated public read surface (no mTLS / no §21.9
signed-request headers required):
POST <node>/ {"jsonrpc":"2.0","id":1,"method":"dig.getAnchoredRoot",
"params":{"store_id":"<64-hex>"}}
-> {"jsonrpc":"2.0","id":1,"result":{"store_id":"<64-hex>","root":"<64-hex>"}}
The node resolves the tip by walking the CHIP-0035 DataStore singleton lineage on chain; it fails closed if the chain can't confirm.
POST <node>/ {"jsonrpc":"2.0","id":1,"method":"dig.getContent",
"params":{"store_id":"<64-hex>","retrieval_key":"<64-hex>","offset":<u64>
/*, "root":"<64-hex>" optional; omitted ⇒ node pins the tip */}}
-> {"jsonrpc":"2.0","id":1,"result":{
"ciphertext":"<base64 window>",
"root":"<64-hex served generation root>",
"complete":<bool>,
"next_offset":<u64>, // present only when !complete
"inclusion_proof":"<base64>", // FIRST window only (offset==0)
"chunk_lens":[<u64>,...] // FIRST window only
}}
dig-dns MUST page by re-requesting with offset = next_offset until complete is true,
concatenating ciphertext, and MUST keep the inclusion_proof + chunk_lens from the first
window. The content is ciphertext, not plaintext — the node is a blind server; dig-dns
verifies + decrypts (§8).
Node error codes dig-dns branches on (by the symbolic data.code, not the number):
-32004 RESOURCE_UNAVAILABLE (not at this root) and -32005 ROOT_NOT_ANCHORED are treated as
"not found" → SPA catch-all (§4.5). -32008 CONTENT_REDIRECT MAY be surfaced as an HTTP 302.
dig-dns MUST resolve the node endpoint in this fixed order, using the first that answers a
GET <base>/health within a short timeout:
- an explicit override — the
--nodeflag,$DIG_NODE_URL, or the persisted confignode.url(precedence flag > env > config) — which wins entirely, no probing; http://dig.local(the installed local node, best-effort127.0.0.2:80);http://localhost:9778(the local node's always-on loopback listener, plain HTTP);https://rpc.dig.net(the public gateway) — the terminal fallback.
The local node serves plain HTTP on loopback; only rpc.dig.net is HTTPS. The resolved
choice SHOULD be cached for the session. A user-facing way to set a custom node
(flag + env + config) is REQUIRED (§5.3).
The 9778 port is sourced from dig_constants::DIG_NODE_PORT — the ecosystem-wide single
source of truth shared with dig-node, dig-installer and dig-sdk — never a value dig-dns
hardcodes independently (src/node.rs::DEFAULT_LOCAL_NODE_PORT).
dig.getManifest {store_id, root} (the store's public path list) exists on the node but is
served only for a public store whose module is locally cached; dig-dns MUST NOT depend
on it for correctness (it uses the decoy/decrypt-fail + extension heuristic of §4.5 instead).
It MAY use it opportunistically to answer /.dig/-style introspection.
rpc.dig.net (§6.3 tier 4) is the ONE public name dig-dns ever asks the network to resolve —
dig.local and localhost are local names needing no public DNS. When DIG_DNS_SECURE_UPSTREAM
is on (the default), that single lookup is routed through an encrypted chain instead of the
plain OS resolver, so a local network resolver can neither observe nor tamper with it. This is
NON-INVASIVE: it changes nothing about the .dig responder (§5), the OS/browser configuration
(§15), or any other name resolution dig-dns performs.
Chain, strict try-order, each provider dialed IPv6-first:
- Mullvad DoH —
[2a07:e340::2]then194.242.2.2, TLS namedns.mullvad.net,https://dns.mullvad.net/dns-query. - Mullvad DoT — the same IPs, port 853.
- Quad9 unfiltered DoT —
[2620:fe::10]then9.9.9.10, TLS namedns10.quad9.net. - The OS stub resolver — a terminal availability net, used ONLY for this one lookup.
A tier is skipped on ANY failure (timeout, refusal, malformed answer) and the NEXT tier is
tried; the OS resolver is used only when all three encrypted tiers failed. Bootstrap is static
provider IPs + TLS hostname verification against the webpki root store (tls_dns_name) —
NEVER a leaf-certificate pin, so a provider's routine leaf rotation can never brick resolution.
Resolver responses are cached per the resolution engine's defaults, respecting each answer's TTL
for the life of the process.
Tier 4 does not violate a never-plaintext guarantee: rpc.dig.net's own TLS connection is
authenticated by webpki regardless of how its address was learned (a spoofed answer cannot pass
certificate validation), so falling back to the OS resolver only costs confidentiality of the
lookup, never integrity of the connection — and it is what lets a DoH/DoT-blocking network
(common on some corporate/hotel networks) still reach the public gateway.
Every other name is untouched, at no added latency. The encrypted chain applies ONLY to the
rpc.dig.net lookup; a reqwest client built with this resolver still resolves dig.local,
localhost, and any loopback IP literal exactly as it always has — those never reach the chain.
doctor --json reports a secure_upstream check: pass names which encrypted tier answered;
warn (degraded) means every encrypted tier failed and the OS-resolver net was used; info
means the feature is toggled off. See §9 and §7.
All values have defaults and are overridable by environment variable (and, for the node endpoint, a CLI flag). Values are validated on load; an invalid value is a startup error.
| Setting | Default | Env var | Notes |
|---|---|---|---|
| loopback bind IP | 127.0.0.5 |
DIG_DNS_IP |
MUST be 127.0.0.0/8 |
| DNS port | 53 |
DIG_DNS_DNS_PORT |
UDP + TCP |
| HTTP port | 80 |
DIG_DNS_HTTP_PORT |
primary |
| HTTP fallback port | 8053 |
DIG_DNS_HTTP_FALLBACK_PORT |
used when :80 is held |
| TLD | dig |
DIG_DNS_TLD |
normalised (trim, strip leading ., lowercase) then validated against ^[a-z0-9-]+$ — a TLD with any other character is REJECTED at load (§5, #538) |
| DNS TTL (s) | 2 |
DIG_DNS_TTL |
1–5 |
| node endpoint override | (ladder) | DIG_NODE_URL |
empty ⇒ use the §6.3 ladder |
dig.local bind IP |
127.0.0.2 |
DIG_DNS_LOCAL_IP |
MUST be 127.0.0.0/8; matches the installer's dig.local hosts registration (#91) and dig-node's own best-effort bind (§12) |
dig.local bind port |
80 |
DIG_DNS_LOCAL_PORT |
http://dig.local has no port suffix, so this MUST stay 80 in production; overridable for unprivileged local testing (§12) |
| encrypted upstream resolution | on |
DIG_DNS_SECURE_UPSTREAM |
on/off; routes the rpc.dig.net lookup (§6.4) through the Mullvad DoH → Mullvad DoT → Quad9 DoT → OS-resolver chain; off restores the OS resolver unconditionally |
dig-dns performs the same read pipeline as dig-client-wasm::decryptResource, reusing
digstore-core primitives (Urn, sha256, derive_decryption_key, decrypt_chunk,
resource_leaf, MerkleProof, DEFAULT_RESOURCE_KEY, CHAIN). For a store id, resource_key,
and served ciphertext:
- retrieval_key — build the canonical root-independent resource URN
urn:dig:chia:<store_id_hex>/<resource_key>(root dropped so the key is stable across generations) and takeretrieval_key = SHA-256(urn), lowercase hex. This is the only URN-derived value sent to the node (§6.2). An empty resource_key usesindex.html. - Integrity gate —
leaf = SHA-256(ciphertext)MUST equal the proof's leaf; the proof MUST fold to its declared root; that root MUST equal the trusted anchored root. Any failure ⇒ refuse (no bytes served). - Confidentiality — derive the AES key with HKDF over the canonical URN
(
derive_decryption_key(urn, salt); public stores passsalt = None), split the concatenated ciphertext bychunk_lens(which MUST sum to the ciphertext length), and AES-256-GCM-SIV-open each chunk, concatenating plaintext in order.
Trust boundary. The strongest model resolves the trusted root from chain independently
of the serving node. dig-dns targeting the local trusted node (the user's own machine, the
common case) uses dig.getAnchoredRoot from the resolved node as the trusted root and verifies
served content against it — the node is the user's own device. Independent on-chain root
verification (via coinset current_root) is an OPTIONAL hardening for the rpc.dig.net
fallback tier and is a documented future enhancement, not required for the local-node MVP. A
GCM tag failure is indistinguishable from a decoy (unknown key); it MUST be surfaced as
"not found in this store" (→ §4.5), never as "corrupt".
dig-dns serves public stores only (URN-derivable, no secret salt). A private store's
salt is not available to dig-dns.
doctor checks each link of each path INDEPENDENTLY and reports pass/fail + a suggested fix;
it exits non-zero if any REQUIRED check fails, and supports --json (§6.2). Checks:
- loopback IP is up / bindable;
- DNS responder answers
<label>.digdirectly at<loopback_ip>:53→127.0.0.5; - OS resolution of
<label>.digreturns127.0.0.5(Path A end-to-end — may be "not configured", which is informational unless Path A is the only path); - OS resolver config STATE (
os_config) — whether theconfigure-os(§15) resolver wiring is present on this machine (informational; explains a "configured but a DoH browser bypasses it"); - gateway
/.dig/resolve-probe→204on the bound port; - gateway serves a real
.digend-to-end (/.dig/healthreports node reachable); - browser policy state relevant to Path B (DoH / built-in-resolver) — informational;
- who holds
:80(informational; explains an:8053fallback); secure_upstream(§6.4) — which encrypted tier answered dig-dns's OWNrpc.dig.netlookup (pass), that every encrypted tier failed and the OS-resolver net was used (warn/degraded), a hard resolution failure (fail), or the feature is toggled off (info).
At least one of {Path A end-to-end, Path B (PAC + gateway)} passing means a .dig URL loads;
doctor MUST make clear which path(s) are live.
doctorand/.dig/healthprovide--json/ JSON output with stable field names.- Errors are catalogued with stable meanings; the gateway uses standard HTTP status codes:
405 CONNECT or any non-
GET/HEADmethod; 403 an absolute-form proxy authority not under.<tld>; 404 an invalid<label>.<tld>host, a missing extensioned resource, or a store with no anchored root; 400 a traversing request path; 200 a served resource or an SPA/index.htmlfallback; 206 a satisfied byte range; 416 an unsatisfiable range; 502 the node is unreachable OR it served content that failed merkle verification against the trusted root (fail-closed — never serve unverified bytes). - Modules are small and single-purpose with doc-comments; the library (
dig_dns) is fully unit-tested and the binary is a thin shell over it.
- The base32 label codec + the gateway↔node contract + ports are mirrored in the superproject
SYSTEM.md(the cross-repo interaction map). - The read-crypto (URN, retrieval-key, verify+decrypt) is byte-identical to
digstore-coreanddig-client-wasm; the node RPC shapes conform to dig-node'sSPEC.mdand the canonicaldig-rpc-typestype contract. - User documentation of
*.diglocal resolution lives at docs.dig.net.
dig.local is the OS-hosts-mapped name for "the user's own dig-node" (§5.3/§6.3 tier 1 of the
client→node ladder; the installer registers it, out of scope here — #91). DNS resolves a name
to an IP only, never a port, so http://dig.local implies port 80 at whatever IP the hosts
file maps it to (default 127.0.0.2, matching dig-node's own best-effort bind, SYSTEM.md).
dig-dns — already the service responsible for making a hostname reach the node over HTTP for
.dig — additionally ENSURES this specific mapping too, idempotently.
On serve startup (and thereafter on a retry interval if needed), dig-dns:
- Probes
GET http://<dig_local_ip>:<dig_local_port>/health(default127.0.0.2:80) with a short timeout. ANY HTTP response (even non-2xx) means something is already answering there — dig-node's own best-effort bind, ordig-dns's own reverse proxy still running from an earlier start — anddig-dnsdoes nothing further (idempotent no-op: [EnsureOutcome::AlreadyMapped]). - If nothing answers,
dig-dnsbinds a listener at<dig_local_ip>:<dig_local_port>itself and serves a TRANSPARENT reverse proxy there ([EnsureOutcome::Established]): every request (any method, path+query, headers minus hop-by-hop, body) is forwarded byte-for-byte to the discovered local dig-node target (§12.2) and the response relayed back unmodified. Unlike the.diggateway (§4), this is NOT the verify-then-decrypt content path —dig.localis the node's OWN control/root host (JSON-RPCPOST /,GET /health, …), sodig-dnsonly relays bytes. - If the bind itself fails ([
EnsureOutcome::Unavailable] — the address genuinely held by something unrelated, or insufficient privilege),dig-dnslogs a warning and retries on a fixed interval (30s) until it either binds or detects the address already answering. It never crashes the service; the.diggateway + DNS responder keep serving regardless.
Because step 1 always precedes step 2, and step 2's own listener answers /health via
passthrough (or 502 when the node is down), a second ensure attempt — a restart, or a retry
tick — always finds "already mapped" and never double-binds. This is also RACE-SAFE against
dig-node's own best-effort bind: whichever of the two gets there first serves dig.local, and
either way requests reach the node (directly, or via dig-dns's transparent proxy).
A node that is down (or not yet started) is handled gracefully: the listener still binds (it
does not need the node to be up), and each proxied request independently attempts the target,
502-ing "dig-node unreachable" until the node appears — the mapping self-heals with no
restart and no separate liveness loop.
The reverse-proxy TARGET is resolved like the §6.3 ladder's override tier, but WITHOUT the
rpc.dig.net terminal fallback — dig.local names the user's OWN node; proxying it to the
public gateway would defeat the purpose of an ensured LOCAL mapping:
- an explicit override (
--node/DIG_NODE_URL/ confignode.url) wins entirely; else http://localhost:<DEFAULT_LOCAL_NODE_PORT>(9778) — the local node's always-on port.
Implemented in dig_local::local_node_target.
Unlike the .dig gateway (§4, deterministic :8053 fallback advertised via the PAC file), the
dig.local reverse proxy has no fallback port: a plain http://dig.local URL has no
proxy indirection to advertise an alternate port to, so a bind must succeed on the CONFIGURED
port (§7) or the retry loop (§12.1 step 3) keeps trying the same port.
dig-dns installs as an auto-starting OS service that runs the headless service run mode
(§13.4): dig-dns run-service on Windows (the SCM-protocol entrypoint), dig-dns serve on
Linux/macOS (systemd/launchd exec the foreground process directly). The dig-dns binary owns
BOTH this run mode AND its registration (via the service-manager crate — Windows SCM, Linux
systemd, macOS launchd).
Registration MUST point the OS service manager at the dig-dns binary's OWN run mode — i.e. the
service program is dig-dns with args run-service (Windows) / serve (elsewhere). A
registration MUST NOT interpose a separate host process that re-launches dig-dns as a child:
there is exactly ONE service process (dig-dns itself), mirroring the sibling dig-node
service. Whichever component performs the registration — the installer, or dig-dns install —
registers this identical program+args under the canonical identity (§13.1), so the two paths are
interchangeable and idempotent (the clean-reinstall, §13.2, makes a re-run safe).
| Value | |
|---|---|
| Service id (name) | net.dignetwork.dig-dns |
| Windows display name | DIG NETWORK: DNS |
-
The service id is a reverse-DNS name used VERBATIM as the Windows SCM service name (
sc create/query/start/stop/delete) and the launchd plist label. It MUST match the sibling conventionnet.dignetwork.dig-nodeused by the dig-node service. -
systemd unit filename — ONE canonical name across both registration paths (dig_ecosystem #523). The systemd unit file is named
net.dignetwork.dig-dns.service— the service id used verbatim — regardless of HOW it was installed:- the native
.debpackage (§14) ships its unit at/lib/systemd/system/net.dignetwork.dig-dns.service(the package-vendor unit dir); dig-dns installwrites the unit at/etc/systemd/system/net.dignetwork.dig-dns.service(the machine-administrator unit dir, which takes precedence if both ever coexist).
Both use the SAME unit NAME, so
systemctl … net.dignetwork.dig-dnsaddresses one service however it was installed, and a monitoring tool needs only one name. The CLI Linux path does NOT go through theservice_managercrate (whoseServiceLabel::to_script_name()named the unitdignetwork-dig-dns.service, dropping thenetqualifier and diverging from the.deb); it manages the canonical-named unit file directly (src/service.rs, the LinuxSystemServiceBackendsystemctl daemon-reload/enable/start). This supersedes the prior two-names note (#502).
- the native
-
Linux install is SYSTEM-level and requires root (#523/#528). The unit is written under
/etc/systemd/systemand binds the privileged loopback ports:53/:80, which a user-domain unit cannot;dig-dns install/uninstalltherefore require root (re-run withsudo) on Linux, matching the.deb. The service runs as root with a bounded capability set —AmbientCapabilities/CapabilityBoundingSet=CAP_NET_BIND_SERVICE,NoNewPrivileges,ProtectSystem=full,ProtectHome=read-only,PrivateTmp— and NO dedicatedUser=/DynamicUser=(the.debruns as root too). The install COPIES thedig-dnsbinary into the root-owned directory/usr/local/lib/dig-dns/(chown root:root,0755) and points the unit'sExecStartat that COPY (#565). A root-executed unit MUST NEVERExecStarta user-writable path: the install fails closed (PermissionDenied) unless both the staged binary and its directory are root-owned and not group/world-writable. This closes the local-privilege-escalation wheresudo dig-dns installwould otherwise bakecurrent_exe()(a user-writable~/.dig/bin/dig-dns) into a root unit, letting any later unprivileged write to that path execute as root at the next service restart.ProtectHomestaysread-only(rather thantrue) as a harmless defensive default — the service holds no secret material and never writes a home dir. macOS remains a user-domain launchd agent (no elevation required); Windows remains system-scope SCM (elevation required). -
The unit-file write is symlink-safe + atomic (#650). The Linux backend writes
/etc/systemd/system/net.dignetwork.dig-dns.servicevia theO_NOFOLLOW-temp-then-renamewriter, so a pre-seeded symlink at the unit path can never redirect the root write. -
Values baked into the unit are validated at config load. The node-endpoint override (
--nodeflag,DIG_NODE_URLenv, or persisted config) is rejected if it contains a control character/newline (#565), mirroring the TLD charset guard (#538) — an embedded newline would otherwise inject a raw systemd directive into the root unit. This guard applies uniformly to ALL paths that set the node URL (flag, env, file config). -
The display name is the human-friendly name shown in the Windows Services console. On Windows it is set with
sc config <id> displayname= "DIG NETWORK: DNS"AFTER create (the underlyingsc createsets the display name to the id). On launchd/systemd the service id is the visible name, so the display name is Windows-facing.
install performs a CLEAN RECREATE, never a reconfigure-in-place. If the service ALREADY
EXISTS, it MUST:
- stop the running service (best-effort — a stopped service is not an error);
- delete (deregister) it;
- wait for the removal to take effect (a Windows deletion can linger until open handles close), bounded by a timeout;
- create it afresh (with the display name on Windows);
- start it.
When no prior registration exists it simply creates + starts. Deleting before creating is what
prevents Windows CreateService 1073 "the specified service already exists" on an installer
re-run. Per OS the primitives are: Windows sc stop/sc delete/sc create/sc start; macOS
launchd bootout/bootstrap; Linux systemd stop+disable then reinstall the unit +
enable --now — all provided by service-manager plus the Windows display-name override.
| Command | Effect |
|---|---|
dig-dns install [--node URL] |
Register (clean-reinstall) + start the service; bakes the resolved config into the service environment. Windows requires an elevated console. |
dig-dns uninstall |
Stop + deregister the service. Windows requires elevation. |
dig-dns start / dig-dns stop |
Start / stop the registered service. |
dig-dns status |
Report whether the resolver is serving (probes GET /.dig/resolve-probe on the bound port) + whether it is registered, PLUS the running service's pid and ACTUALLY-bound port read from the machine-wide runtime file (§13.5). Exits non-zero when nothing is serving. |
dig-dns health |
Query the running service's machine-readable health — the same object served at GET /.dig/health (§4.7): version, bound port, listeners, node reachability. The CLI counterpart of the control endpoint (§6.2 parity, #569). Exits non-zero when nothing is serving. |
dig-dns run-service |
(hidden, Windows only) The SCM protocol entrypoint the installed service launches; speaks StartServiceCtrlDispatcher and reports SERVICE_RUNNING before any startup work so the SCM does not kill it with error 1053 (§13.4). Behaves like serve off Windows. |
dig-dns configure-os [--browser-policy] |
Wire OS-level *.<tld> resolution so names resolve system-wide (§15). An explicit admin action distinct from the serve runtime; needs elevation (root / Administrator). --browser-policy ALSO sets a Chrome/Edge managed DoH-off policy (opt-in; the packages do not pass it). |
dig-dns unconfigure-os |
Reverse configure-os (§15): remove the marker-scoped resolver wiring this tool — or the legacy dig-installer — added, plus any managed browser policy it wrote. Needs elevation. |
Every command supports --json (§10). Install level for the SERVICE (install): system-level on
Linux (root required, #523/#528) and Windows (Administrator); user-level (no elevation) on macOS.
OS resolver configuration (configure-os, §15) ALWAYS needs elevation on every OS (it edits system
resolver state); refusal is reported with a stable needs_elevation: true JSON field.
The digd alias binary (dig_ecosystem #548). The crate builds a SECOND binary, digd, that
is a first-class alias for dig-dns: digd <args> MUST behave identically to dig-dns <args> —
the SAME command surface (every command above, including all service verbs), flags, --json
output, and exit codes. Both binaries share the SINGLE entrypoint dig_dns::cli::run() (no
duplicated logic); each parses argv with its OWN invoked name (arg0 file-stem) as the program +
usage name, so digd --help/--version read digd and dig-dns --help/--version read
dig-dns. The release publishes digd-<ver>-<os_arch>[.exe] alongside
dig-dns-<ver>-<os_arch>[.exe] in the SAME release (byte-for-byte the same asset shape). This
mirrors how digs is the first-class alias for digstore (digstore #434).
On Windows, the SCM launches the registered program and expects it to connect
(StartServiceCtrlDispatcher) and report SERVICE_RUNNING within the SCM start timeout (~30s).
Failing to do so is Windows error 1053 ("the service did not respond … in a timely fashion").
dig-dns run-service is that connection and MUST:
- Report
SERVICE_RUNNINGFIRST — before ANY slow or fallible startup work. Config load, the tokio-runtime build, node-endpoint resolution (§6.3 ladder), and the:80/:53socket binds all happen AFTER theRUNNINGsignal. No bind, no network probe, and no fallible load may precede it. (The control handler is registered beforeRUNNINGonly so an immediateStopis not lost; registration is a constant-time SCM call, not startup work.) - Never hang on bring-up. The gateway bind uses the deterministic
:8053fallback (§4.3); the DNS:53bind is best-effort (a failure is non-fatal — Path B still serves). If BOTH the primary and fallback gateway binds fail, bring-up fails FAST with a clear error naming both addresses — never a hang. - Surface a bring-up failure as a clean stop. A hard failure after
RUNNINGreportsSERVICE_STOPPEDwith a non-zero Win32 exit code (sosc queryreflects it) and returns — never a hang, never a silent success.
This ordering is implemented behind a platform-independent seam (report RUNNING → run body →
report STOPPED(exit)) that is unit-tested with a recording status reporter on every platform,
so the contract holds without a real SCM and cannot silently regress. On Linux/macOS the run
mode is a plain foreground serve loop shutting down on SIGTERM/Ctrl-C (systemd/launchd own the
lifecycle), so no SCM handshake applies.
The dig-dns service runs as a system account (Windows LocalSystem, Linux/macOS root); its CLI
counterpart (status, …) may be invoked by ANY user. So any state the CLI shares with the
running service lives in a MACHINE-WIDE, identity-independent directory — NEVER a per-user
profile dir — so the observation/control path does not vary by who runs the CLI (mirroring the
sibling dig-node machine-wide state model):
| OS | Default state dir |
|---|---|
| Windows | %PROGRAMDATA%\DigDns (typically C:\ProgramData\DigDns) |
| macOS | /Library/Application Support/DigDns |
| Linux | /var/lib/dig-dns |
The DIG_DNS_STATE_DIR environment variable overrides the default; the service and the CLI both
honour it, so they always agree. On startup (after binding) the service records a non-secret
runtime.json in this dir — { pid, loopback_ip, http_port (the ACTUALLY-bound port, which may be the :8053 fallback), dns_active } — and removes it on graceful shutdown; the CLI reads it
to locate + identify the exact running process and its real port regardless of the invoking user.
Writing is best-effort: a non-admin foreground dig-dns serve that cannot write the system dir
still serves, and the CLI falls back to probing the fixed loopback port.
dig-dns holds NO control-token or auth secret (its gateway is loopback-only and
unauthenticated), so the state dir carries no secret material and runtime.json is non-sensitive.
The installer creates the dir and applies its ACL (SYSTEM + Administrators full control,
install-user read; Unix 0640/0600); dig-dns only RESOLVES the path and best-effort
reads/writes runtime.json within it.
dig-dns COMPILES its own native OS install packages that install it as a service (dig_ecosystem
#503). The dig-installer downloads + runs them; it does NOT hand-roll service registration. Each
package registers the SAME canonical identity (§13.1) running the SAME entrypoint, and creates the
SAME machine-wide state dir (§13.5), as a manual dig-dns install — the packaged and manual paths
are interchangeable and idempotent. A package registers NO OS scheme handler (dig-dns owns *.dig
resolution; scheme handling for chia:///urn:dig:chia: is dig-node's job).
Beyond registering the service, each package's post-install ALSO runs dig-dns configure-os (§15)
so the package ALONE makes *.<tld> resolve system-wide — otherwise apt install dig-dns / a bare
.msi/.pkg would leave a running service the OS never routes names to (dig_ecosystem #530). This
is default-ON, cleanly reversible on uninstall, and opt-out per OS (§15.4). The post-install NEVER
passes --browser-policy: a package must not silently place a browser under managed policy.
Per OS the release workflow (.github/workflows/release.yml) builds one package on the matching
runner OS and attaches it to the vX.Y.Z GitHub Release alongside the raw binaries.
| OS | Package | Built by | Service mechanism | Binary path | State dir |
|---|---|---|---|---|---|
| Windows | dig-dns-<ver>-windows-x64.msi |
WiX (wix/main.wxs) |
SCM ServiceInstall/ServiceControl |
C:\Program Files\DIG Network\DIG DNS\dig-dns.exe |
C:\ProgramData\DigDns |
| macOS | dig-dns-<ver>-macos-{arm64,x64}.pkg |
pkgbuild/productbuild (packaging/macos) |
LaunchDaemon | /usr/local/bin/dig-dns |
/Library/Application Support/DigDns |
| Ubuntu | dig-dns_<ver>_amd64.deb |
cargo-deb (Cargo.toml metadata + packaging/linux) |
systemd unit | /usr/bin/dig-dns |
/var/lib/dig-dns |
- Registers the service
net.dignetwork.dig-dns(displayDIG NETWORK: DNS, §13.1) viaServiceInstall,Type=ownProcess,Start=auto,Account=LocalSystem,Arguments=run-service— the SCM-protocol entrypoint (§13.4), NOT a host shim. ServiceControlstarts the service on install and stops + removes it on uninstall, withWait=noso a busy:53never wedgesmsiexec(the service reportsSERVICE_RUNNINGbefore its binds, then degrades — §13.4 / #499).- Creates
C:\ProgramData\DigDns(§13.5). It inherits ProgramData's ACL — SYSTEM + Administrators full control, Users (incl. the installing user) read — which satisfies the "SYSTEM+Administrators full, installing user READ" requirement; no custom ACL is applied (the dir holds no secret). - Adds the install dir to the system
PATH; a fixedUpgradeCode+MajorUpgradegives clean in-place upgrade and uninstall. - Deferred, non-impersonated (LocalSystem) custom actions run
dig-dns.exe configure-osafterStartServiceson a fresh install andunconfigure-osbeforeRemoveFileson a genuine uninstall (skipped during a major-upgrade removal). Gated on the public propertyCONFIGURE_OS(default1; opt out of a silent install withmsiexec /i … CONFIGURE_OS=0);Return=ignoreso a resolver-wiring hiccup never fails the (un)install (§15).
- Installs the binary at
/usr/local/bin/dig-dnsand a LaunchDaemon/Library/LaunchDaemons/net.dignetwork.dig-dns.plist(RunAtLoad+KeepAlive) runningdig-dns serve. postinstallcreates/Library/Application Support/DigDns, thenlaunchctl bootstrap system+launchctl enablethe daemon;preinstalllaunchctl bootouts any existing daemon first so an upgrade re-bootstraps cleanly (§13.2).postinstallTHEN runsdig-dns configure-os(§15) — unconditional (a.pkghas no opt-out prompt), best-effort.- A
.pkghas no built-in uninstaller, so the package ships/usr/local/share/dig-dns/uninstall.shwhich runsdig-dns unconfigure-os(while the binary still exists), boots out the daemon, and removes the payload + state dir.
- Installs the binary at
/usr/bin/dig-dnsand the systemd unit/lib/systemd/system/net.dignetwork.dig-dns.servicerunningdig-dns serve. - The unit grants
AmbientCapabilities=CAP_NET_BIND_SERVICE(+CapabilityBoundingSet) — the ONLY privilegedig-dnsneeds, to bind:53/:80on the loopback IP — and usesStateDirectory=dig-dnsso systemd creates/var/lib/dig-dns(§13.5). - Maintainer scripts (auto-generated by
cargo-deb) runsystemctl daemon-reload, enable, and start the unit on install, stop it on removal, and unmask + purge its enable state on purge.cargo-debonly emits these scripts whenmaintainer-scriptsis set in[package.metadata.deb](alongside[package.metadata.deb.systemd-units]); without it the unit file is still installed but no postinst is generated, so the service would never be enabled or started (dig_ecosystem #525). - Control metadata is apt-correct + stable (
Package: dig-dns,Architecture: amd64, auto-computedDepends,Section: net), soapt.dig.netingests the release-asset.deband GPG-signs it into the apt repo (#425). - The maintainer scripts (
packaging/linux/{postinst,prerm,postrm}) each carry the#DEBHELPER#token cargo-deb substitutes with its systemd enable/start/stop/purge fragments, and additionally:postinstrunsdig-dns configure-os(§15) after the systemd fragments (default ON; opt out withDIG_DNS_SKIP_CONFIGURE_OS=1in the install environment);prermrunsdig-dns unconfigure-oson theremoveaction ONLY (notupgrade), before the binary is deleted. All best-effort.
configure-os wires this machine's DNS resolver so <label>.<tld> names resolve to the local
dig-dns responder SYSTEM-WIDE; unconfigure-os reverses it. This is an EXPLICIT administrator
action, distinct from the serve runtime (§5 invariant: the runtime never edits the resolver). It is
what makes a bare package install actually resolve *.<tld>, and it is the single implementation
both the native packages (§14) and — in future — the dig-installer call, replacing the installer's
own duplicated per-OS logic.
| OS | Artifact | Content |
|---|---|---|
| Linux (systemd-resolved) | /etc/systemd/resolved.conf.d/dig.conf |
[Resolve] DNS=<ip> Domains=~<tld>, then systemctl reload-or-restart systemd-resolved + resolvectl flush-caches |
| Linux (NetworkManager-dnsmasq) | /etc/NetworkManager/dnsmasq.d/dig.conf |
server=/<tld>/<ip>, then systemctl reload NetworkManager |
| macOS | boot-persistent lo0 alias LaunchDaemon /Library/LaunchDaemons/net.dignetwork.dig-dns-lo0.plist + live ifconfig lo0 alias <ip> up + /etc/resolver/<tld> (nameserver <ip>) + dscacheutil -flushcache & killall -HUP mDNSResponder |
— |
| Windows | NRPT rule Add-DnsClientNrptRule -Namespace .<tld> -NameServers <ip>, then Clear-DnsClientCache |
— |
The default <ip>/<tld> are 127.0.0.5 / dig (§7); both follow the resolved config.
Every OS flushes its resolver cache as the last wiring step so resolution goes LIVE with no
reboot (§15.6): Linux resolvectl flush-caches, macOS dscacheutil -flushcache +
killall -HUP mDNSResponder, Windows Clear-DnsClientCache. A Windows NRPT local rule is
effective for subsequent queries immediately — the historical "needs a reboot" symptom was purely
the stale (often negatively-cached) resolver cache, cleared by the flush. configure-os does NOT
restart the Windows Dnscache service (it has dependents and the flush is sufficient).
Elevated spawns are absolute-pathed. Because configure-os/unconfigure-os run elevated, every
external tool (powershell, net, reg, systemctl, resolvectl, ifconfig, launchctl,
dscacheutil, killall, id) is invoked by a trusted ABSOLUTE path — Windows under
%SystemRoot%\System32, Unix from the canonical /usr/bin·/bin·/usr/sbin·/sbin locations —
never a bare name resolved via PATH (anti-hijack, dig_ecosystem #565/#657).
- Linux resolver detection. The owning resolver is DETECTED, never assumed: systemd-resolved
when
/etc/resolv.confsymlinks intosystemd, else NetworkManager-dnsmasq when its drop-in directory exists, else Unknown — in which case a plain/etc/resolv.confis left UNTOUCHED and browsers reach*.<tld>via Path B (the PAC). - The macOS
lo0alias is a FUNCTIONAL PREREQUISITE, not optional. macOS answers only127.0.0.1onlo0by default — the responder's127.0.0.5is unreachable without an explicit alias, which macOS also drops on reboot.configure-ostherefore applies the alias immediately AND installs a one-shot boot LaunchDaemon that re-applies it every boot.
Setting a Chrome/Edge managed policy (DnsOverHttpsMode=off + BuiltInDnsClientEnabled=false,
so those browsers honour the OS resolver) is invasive and is applied ONLY with --browser-policy.
The native packages do NOT pass it. It never clobbers a pre-existing org policy (a key/plist without
this tool's marker is left alone). Its artifacts: Linux uniquely-named files
/etc/opt/{chrome,chromium}/policies/managed/dig-dns.json; macOS
/Library/Managed Preferences/com.google.Chrome.plist (only when no MDM policy exists); Windows
HKLM\SOFTWARE\Policies\{Google\Chrome,Microsoft\Edge} values + a DigDnsManaged marker.
Even with configure-os, a browser with Secure DNS / DNS-over-HTTPS (Brave, Chrome, Edge, …)
resolves names via its own encrypted upstream and BYPASSES the OS resolver — so *.<tld> will not
resolve there via Path A. Such a browser still needs EITHER Path B (point its proxy at the PAC file,
http://<ip>:<port>/.dig/proxy.pac, §4.7) OR its DoH turned off (--browser-policy, or manually).
configure-os alone does not solve DoH browsers; that is expected.
- Idempotent. Re-running
configure-osis a no-op-equivalent (content is written only when it differs; the NRPT add is self-guarded). - Marker-scoped. Every artifact carries the marker
managed by dig-dns configure-os(or is a uniquely-named file this tool solely owns).unconfigure-osremoves ONLY marked artifacts — plus those left by the OLD dig-installer (markermanaged by dig-installer (dig-dns, task #177), and the legacy unmarked/etc/resolver/<tld>nameserver <ip>file) so previously-wired machines clean up. A.<tld>rule / org policy this tool did not write is NEVER touched. - Elevation. Both subcommands need OS privilege (root / Administrator); a non-elevated
invocation is REFUSED with a clear message and
needs_elevation: truein--json, never a cryptic failure inside a system tool. - Package opt-out.
.deb: envDIG_DNS_SKIP_CONFIGURE_OS=1..msi: public propertyCONFIGURE_OS=0..pkg: unconditional (no prompt), reversible via the shippeduninstall.sh.
After applying the resolver wiring (and flushing the cache, §15.1), configure-os VERIFIES that the
OS now routes *.<tld> LIVE: it resolves a probe name (configure-probe.<tld>) through the OS
resolver and evaluates it with the same oracle doctor's Path-A os_routing check uses — the probe
must return the responder's loopback IP. On all three OSes this is expected to PASS with no reboot.
The outcome is reported in OsConfigReport (the --json machine contract, §6.2 / §10) via three
STABLE fields:
| Field | Type | Meaning |
|---|---|---|
activated |
bool | true iff the post-configure verify confirmed the OS routes *.<tld> to the responder LIVE (no reboot). false when no resolver wiring was applied (the Linux PAC-only path) or the verify did not pass. |
reboot_required |
bool | true ONLY as a defensive fallback: resolver wiring WAS applied but the verify still failed. Expected to stay false. |
reboot_reason |
string? | Present iff reboot_required; a per-OS explanation of what the restart will pick up. Omitted otherwise. |
reboot_required is set IFF wiring was applied AND the verify failed — so the Linux PAC-only path
(nothing applied) never prompts a reboot, and a successful activation never does. A consumer (the
dig-installer, §14 / dig_ecosystem #627 WU2) surfaces a restart prompt ONLY when reboot_required
is true. This is NOT a whole-machine reboot claim: a browser with its own DoH/HTTP cache may still
need a browser restart (§15.3) — a distinct, browser-scoped caveat, never surfaced as "restart your
computer".
doctor (§9) reports an informational os_config check — whether the configure-os (or legacy)
resolver wiring is present on this machine — so a "configured but a DoH browser bypasses it" state
is diagnosable. It never changes the pass/fail outcome (Path B can carry traffic without it).
How the dig-dns binary + native OS packages are built and released. The shape is copied from the
ecosystem's reference nightlies implementation (dig-updater); the ops runbook is
runbooks/release.md.
Releases are batched to a nightly cron plus manual dispatch — NOT cut on every merge to main.
Two channels ship from one orchestrator (.github/workflows/nightly-release.yml):
The orchestrator triggers ONLY on:
schedule: cron '0 0 * * *'— midnight UTC (GitHub Actions cron is always UTC; a top-of-hour cron MAY be delayed under load — acceptable, since both channels are idempotent), andworkflow_dispatchwith two inputs:channel(both|stable|nightly, defaultboth) andforce(boolean, defaultfalse).
It MUST NOT trigger on push to main. A schedule run exercises BOTH channels; a dispatch runs the
selected channel(s).
60-day auto-disable caveat. GitHub auto-disables a schedule: trigger after 60 days with no
repo activity on a public repo, with no auto-re-enable — and since this cron is the ONLY automatic
release trigger, a quiet repo can silently stop releasing with no error. Detect it with
gh api repos/DIG-Network/dig-dns/actions/workflows/nightly-release.yml --jq .state (a value of
disabled_inactivity means it was auto-disabled) and recover with gh workflow enable nightly-release.yml (see runbooks/release.md). Any repo activity resets the 60-day counter.
Cuts a semver vX.Y.Z stable release when — and only when — the version in Cargo.toml
([package].version) has advanced beyond the newest existing vX.Y.Z tag. The
skip-if-already-tagged check IS the version-changed check. Cutting a release means: git-cliff
regenerates CHANGELOG.md, commits it to main as chore(release): vX.Y.Z, tags THAT commit (so
the changelog is inside the tag), and pushes commit + tag with RELEASE_TOKEN. The pushed v* tag
fires release.yml, which builds every OS/arch + native package and publishes a GitHub Release with
prerelease: false + make_latest: true — the stable release is the ONLY one that moves latest.
force: true on a manual dispatch bypasses the skip-if-tagged guard and re-cuts the current version
(moving the existing tag onto a fresh changelog commit — main is never force-pushed).
Force is guarded against mutating a published release (supply-chain invariant). A force re-cut
MUST be refused — non-zero exit, clear error — when BOTH: (a) a PUBLISHED (non-draft) GitHub Release
already exists at the version's vX.Y.Z tag, AND (b) that tag currently points at a commit
DIFFERENT from the commit this run would build. Force MAY proceed when either is false: a
same-commit re-cut (a failed-build retry) or a tag with no published release yet (a tag repair). A
version that needs new code released MUST bump Cargo.toml, not force-move a tag. Force-moving a
tag breaks git tag-immutability for that version; because dig-dns ships a GPG-signed .deb
(apt.dig.net signs the release asset into the apt repo, §14), the SIGNATURE on the shipped artifact
— not the mutable tag — is the integrity anchor.
Every night (and on demand) builds main HEAD for every OS/arch + native package and publishes a
GitHub pre-release — so a fresh nightly always exists regardless of a version bump. It:
- Synthesizes the version at build time (nothing is committed):
X.Y.Z-nightly.YYYYMMDD.<shortsha>. As a semver prerelease it sorts BELOW the plainX.Y.Z. Because native package formats require a numeric version, the MSI ProductVersion +.pkgversion strip the prerelease suffix (the raw-binary filenames keep the full nightly string). - Publishes under a dated tag
nightly-YYYYMMDDAND force-moves a rollingnightlytag, withprerelease: trueand neverlatest. Idempotent: a same-day re-run refreshes today's dated release + the rolling pointer. - Retention: keeps the newest 14 dated nightlies plus the rolling
nightly, pruning older dated pre-releases AND their tags together (gh release delete --cleanup-tag).v*stable tags/releases and the rollingnightlyare NEVER pruned.
The cross-OS build lives once in .github/workflows/build-binaries.yml (on: workflow_call, inputs
version + ref). Both release.yml (stable) and the nightly channel call it, so the two paths
can never diverge. It builds dig-dns + the digd alias + the native .msi/.pkg/.deb for
windows-x64, linux-x64, macos-arm64, and macos-x64, stamping the caller's version into
each artifact filename.
Releasing uses the RELEASE_TOKEN org PAT, not GITHUB_TOKEN. If RELEASE_TOKEN is absent, EVERY
channel NO-OPS with a clear ::warning:: — never a half-release. A concurrency: nightly-release
group (cancel-in-progress false) serializes runs so an overlapping cron + dispatch cannot race.
| Service | Address | Transport |
|---|---|---|
| dig-dns DNS responder | 127.0.0.5:53 |
UDP + TCP |
| dig-dns HTTP gateway | 127.0.0.5:80 (fallback 127.0.0.5:8053) |
HTTP |
| dig-node control RPC (localhost tier) | 127.0.0.1:9778 |
HTTP (JSON-RPC) |
dig-node dig.local tier — dig-node's own best-effort bind, OR dig-dns's ensured reverse proxy (§12) |
127.0.0.2:80 |
HTTP |
| public gateway fallback | rpc.dig.net:443 |
HTTPS |
| OS | State dir | Runtime file |
|---|---|---|
| Windows | %PROGRAMDATA%\DigDns (default C:\ProgramData\DigDns) |
runtime.json |
| macOS | /Library/Application Support/DigDns |
runtime.json |
| Linux | /var/lib/dig-dns |
runtime.json |
Overridable via DIG_DNS_STATE_DIR (honoured by both the service and the CLI). runtime.json
is { pid, loopback_ip, http_port, dns_active } — non-secret; written by the service on startup,
removed on graceful shutdown, read by the CLI to target the running service regardless of user.