From 5cbb078636fa35d1acd29a58a1248c9bac861708 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 27 Jul 2026 06:22:48 -0700 Subject: [PATCH 1/3] chore: open #1668 dig-node cascade adoption lane Refs #1668 Co-Authored-By: Claude From 63c5ed477c117b975d0ccecc3f16d9a86fc82eae Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 27 Jul 2026 06:35:26 -0700 Subject: [PATCH 2/3] chore(deps)!: adopt the released peer-stack cascade (#1668) Adopt the released peer-stack cascade from crates.io plus the dig-gossip v0.17.0 tag: - dig-nat 0.13 -> 0.14 - dig-dht 0.7 -> 0.8 - dig-download 0.11 -> 0.12 - dig-peer 0.6 -> 0.7 - dig-peer-selector 0.6 -> 0.7 - dig-gossip git rev b065e70c -> ce6cce75 (tag v0.17.0), in lockstep with the two workspace `[patch.crates-io]` revs for its vendored chia-protocol / chia-sdk-client forks (a git dep's own patches do not apply transitively, so the root manifest re-declares them). BREAKING CHANGE: the peer stack moves to the 0.14 dig-nat generation; a host embedding dig-node-core must move its own peer-stack deps in lockstep. Refs #1668 Co-Authored-By: Claude --- Cargo.lock | 28 ++++++++++++++-------------- Cargo.toml | 4 ++-- crates/dig-node-core/Cargo.toml | 14 +++++++------- crates/dig-node-core/src/download.rs | 11 +++++++---- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65dd838..4a51325 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,7 +787,7 @@ dependencies = [ [[package]] name = "chia-protocol" version = "0.26.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=dbb03430f2a9c5cebd5b6f97ac86b03012be5a41#dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=ce6cce753c7ac1407966a2a4aa8380fd79075009#ce6cce753c7ac1407966a2a4aa8380fd79075009" dependencies = [ "chia-bls 0.26.0", "chia-serde", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "chia-sdk-client" version = "0.28.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=dbb03430f2a9c5cebd5b6f97ac86b03012be5a41#dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=ce6cce753c7ac1407966a2a4aa8380fd79075009#ce6cce753c7ac1407966a2a4aa8380fd79075009" dependencies = [ "aws-lc-rs", "chia-protocol", @@ -1992,9 +1992,9 @@ dependencies = [ [[package]] name = "dig-dht" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcc1c46d868c6fd308bf34a00b96d4874b8f0f20a1a6b701093940e38fd820f" +checksum = "3e0effb120e4932d05b28e3211d3f7a47dff62aea394a3bed239334ea8ee558a" dependencies = [ "async-trait", "dig-ip", @@ -2010,9 +2010,9 @@ dependencies = [ [[package]] name = "dig-download" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f550127432a34f6bec7743bc3e8e68ed964362bea99f6324706ec3a05be6e5a2" +checksum = "9d6ef799dea375447c379e76b3c442835cdc9aa9e6b97cfc9ab737a7ef503080" dependencies = [ "async-trait", "dig-constants 0.8.0", @@ -2031,8 +2031,8 @@ dependencies = [ [[package]] name = "dig-gossip" -version = "0.16.0" -source = "git+https://github.com/DIG-Network/dig-gossip?rev=b065e70c5bc4ad8f343d363e71c605870a4923d2#b065e70c5bc4ad8f343d363e71c605870a4923d2" +version = "0.17.0" +source = "git+https://github.com/DIG-Network/dig-gossip?rev=ce6cce753c7ac1407966a2a4aa8380fd79075009#ce6cce753c7ac1407966a2a4aa8380fd79075009" dependencies = [ "bincode", "chia-protocol", @@ -2181,9 +2181,9 @@ dependencies = [ [[package]] name = "dig-nat" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2093ca487bf797e12bacff33a0cc92484879d53d0087ea8e3503e6b2ffc26f7" +checksum = "b1935057b8b49a73074eba083d43b02c2b16e872e387f627614d8725c347a858" dependencies = [ "arc-swap", "async-trait", @@ -2303,9 +2303,9 @@ dependencies = [ [[package]] name = "dig-peer" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500f0090661cc9a6fe476e44fa2cf5436633e1cced658ef03b4c13cbc2bda79d" +checksum = "d6de94e87ca139b5a61a09c6d22c94d825ac9280bd0b1fe91b9ea3daa3451c7a" dependencies = [ "chia-protocol", "chia-traits 0.26.0", @@ -2337,9 +2337,9 @@ dependencies = [ [[package]] name = "dig-peer-selector" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c78396bc4384367eab08e2a31aaac072bc8863a3f119b2f27fc5b03be13ff344" +checksum = "b5e08046d2d2f5eb470930df4eeffabacb13f5424922838b895d6d3b7cda00f8" dependencies = [ "dig-dht", "dig-nat", diff --git a/Cargo.toml b/Cargo.toml index 110549b..bdde9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,8 +54,8 @@ overflow-checks = true # standalone. `native-tls` is NOT patched: dig-gossip is pulled with # `default-features = false, features = ["rustls", "relay"]`, so the OpenSSL/native-tls path is off. [patch.crates-io] -chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" } -chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "dbb03430f2a9c5cebd5b6f97ac86b03012be5a41" } +chia-protocol = { git = "https://github.com/DIG-Network/dig-gossip", rev = "ce6cce753c7ac1407966a2a4aa8380fd79075009" } +chia-sdk-client = { git = "https://github.com/DIG-Network/dig-gossip", rev = "ce6cce753c7ac1407966a2a4aa8380fd79075009" } # The dig-nat unification patch is RETIRED (#1280 crates.io cascade): dig-nat 0.7 is on crates.io and # the ENTIRE peer stack (dig-gossip 0.7.1, dig-dht 0.2.2, dig-download 0.2.1, dig-peer-selector 0.2.1, diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index b7dd5b7..1cf4279 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -146,7 +146,7 @@ getrandom = "0.2" # DIG-Network/dig_ecosystem#1686 (release-first, # §4.1); `tests/dependency_tree.rs` asserts the single-instance invariant against the resolved LOCK so # a manifest edit cannot quietly reintroduce the duplicate. -dig-nat = "0.13" +dig-nat = "0.14" # dig-gossip is the ONE peer-stack exception: at v0.15.2 (on dig-nat 0.11 + dig-tls 0.3, crates.io # deps) but NOT yet published to crates.io — its own publish is guarded pending dig-peer-protocol (#681). # So it stays a git dependency, PINNED to the v0.15.2 tag commit (rev dbb03430f2a9, #1584 — both @@ -159,7 +159,7 @@ dig-nat = "0.13" # graph still unifies to ONE of each. The vendored chia-protocol/chia-sdk-client forks it path-patches # are re-declared at this SAME rev in the workspace `[patch.crates-io]` (a git dep's own patches do not # apply transitively — the #1529 3-rev lockstep: this dep rev + the two patch revs move together). -dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "b065e70c5bc4ad8f343d363e71c605870a4923d2", default-features = false, features = ["rustls", "relay"] } +dig-gossip = { git = "https://github.com/DIG-Network/dig-gossip", rev = "ce6cce753c7ac1407966a2a4aa8380fd79075009", default-features = false, features = ["rustls", "relay"] } # The canonical DIG network id (genesis challenge) for the gossip handshake / introducer scope. From # crates.io: dig-nat 0.7 requires `>=0.4, <0.6` and dig-gossip 0.7.1 requires `>=0.2, <0.5`, so `0.4` # sits in both windows and the whole graph resolves to ONE crates.io dig-constants 0.4.x. @@ -175,7 +175,7 @@ dig-constants = "0.4" # so cargo unifies to ONE dig-dht instance — dig-dht's `PeerId` / `Contact` / `DhtTransport` share the # exact types `dig_nat::connect` returns. dig-dht 0.5 deps dig-nat "^0.10" (crates.io), the SAME # dig-nat "0.10" this crate pins, so the whole graph shares ONE dig-nat 0.10 instance. -dig-dht = "0.7" +dig-dht = "0.8" # -- L7 Peer Exchange (PEX, #166) -------------------------------------------------------------------- # The node<->node peer-sharing protocol: over each established mTLS peer connection, a dedicated PEX # logical stream exchanges delta-based first-hand known-peer sets (handshake -> snapshot -> deltas), @@ -210,7 +210,7 @@ dig-pex = "0.1" # `seams/dig_peer/module_anchor.rs` (`ChainAnchoredModuleVerifier`). NOTE the `testkit` feature is # deliberately NOT enabled: it is what makes the fail-OPEN `AcceptAnyModuleAnchor` nameable, and this # crate's anchor gate is the reshare path's ONLY root of trust. -dig-download = "0.11" +dig-download = "0.12" # -- The shared peer client (#1283/#1576) ------------------------------------------------------------- # `DigPeer` — the ONE DIG Network peer client: peer_id-pinned mTLS over the full NAT ladder plus typed # RPC. Depended on DIRECTLY (not only transitively through dig-download) because dig-node supplies the @@ -222,7 +222,7 @@ dig-download = "0.11" # module pull's trust boundary — on the fields that drive the whole pull plan. dig-download 0.8.1 is on # dig-peer 0.5 too, so exactly ONE dig-rpc-protocol + ONE dig-peer resolve here (asserted by # `crates/dig-node-core/tests/dependency_tree.rs`). -dig-peer = "0.6" +dig-peer = "0.7" # -- Self-optimizing peer selection (#178) ------------------------------------------------------------ # The decision + learning layer between dig-dht discovery and dig-download execution: it ranks the # providers `find_providers` returns (learning throughput/rtt/reliability + a per-class saturation @@ -239,7 +239,7 @@ dig-peer = "0.6" # dig-nat/dig-dht types). The selector mirrors `dig_gossip::PoolEvent` LOCALLY (it does not depend on # dig-gossip, which lags as a git dep), so the node maps `dig_gossip::PoolEvent` → the selector's local # `PoolEvent` 1:1 (see `download.rs::pool_event_to_selector`). -dig-peer-selector = "0.6" +dig-peer-selector = "0.7" # The canonical DIG mTLS certificate crate (L00, crates.io). The node's PERSISTENT machine identity # is a CA-signed `dig_tls::NodeCert` minted from the node's own BLS identity key and persisted 0600 in # the data dir (#908 identity boundary: this is the MACHINE key, never a user key). Replaces the @@ -284,7 +284,7 @@ tempfile = "3" # # Pinned by the `the_fail_open_anchor_verifier_is_not_reachable_from_a_production_build` test, which # fails if `testkit` ever appears on the production entry. -dig-download = { version = "0.11", features = ["testkit"] } +dig-download = { version = "0.12", features = ["testkit"] } # Captures the peer-facing serve's real emitted tracing records into an in-memory buffer, so the # serve-observability tests (#1595) assert what an operator would actually see in the node log — # and that no payload byte or proof ever reaches it. diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index 1bee5bd..87d2348 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -652,10 +652,13 @@ impl NodeContent { // already self-filtered but otherwise unranked; the selector refines the SOURCE choice at // schedule time, not the discovered set. The same RAW `locator` stays on the engine for the // redirect-on-miss path (a redirect offers ALL known non-self holders). - let config = DownloadConfig { - selector: Some(Arc::new(SelectorAdapter::new(selector.clone()))), - ..DownloadConfig::default() - }; + // + // Built by MUTATING the default rather than by a struct expression: `DownloadConfig` is + // `#[non_exhaustive]` as of dig-download 0.12, so a consumer names only the fields it means to + // override and inherits every field the crate adds later — which is the whole point of the + // attribute, and the reason a `..default()` functional update is rejected too. + let mut config = DownloadConfig::default(); + config.selector = Some(Arc::new(SelectorAdapter::new(selector.clone()))); // The DOWNLOAD locator (#1590) = a [`PoolProviderLocator`] over the live connected-pool set // UNIONed with the raw discovery `locator`. So a fetch's locate step also offers the peers the // node is ALREADY CONNECTED to — reaching a holder whose DHT provider record is unreachable on From 71f174a712518fd0a3ecc2b58111db7809cf646f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 27 Jul 2026 08:36:30 -0700 Subject: [PATCH 3/3] fix(config)!: bracket an IPv6 DIG_NODE_HOST so a v6 bind cannot brick the node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config::bind_addr` rendered its authority as `format!("{host}:{port}")`, which is invalid for every IPv6 literal: `DIG_NODE_HOST=::1` produced the unbindable `::1:9778`, and the primary bind failure is documented as FATAL — so one missing pair of brackets stopped the node from starting at all. The same string is reused for the CLI control-plane URL and `/health`'s `addr`, so all three were affected. Both `bind_addr` and `bind_addr_v6` now render through `SocketAddr`, which brackets a v6 literal for free. `open::browser_host` was already correct (it bracketed by hand) but built its authority with the same banned idiom; it becomes `browser_authority`, rendering an explicit host through `SocketAddr` and interpolating only the port for the `localhost` NAME case. That empties the `banned_address_patterns` tracked-violation list, which the guard also used as its cross-crate reach detector. Since an empty list makes that assertion trivially true, the detector is replaced by an explicit `SIBLING_CRATE_UNDER_SCAN` assertion, which holds whether or not anything is ever tracked again, and the entry-matching self-test now runs against a synthetic list rather than going vacuous. SPEC.md §4.1 states the rendering requirement normatively. BREAKING CHANGE: an operator who set an IPv6 `DIG_NODE_HOST` and worked around the fatal bind failure by also setting a v4 host now gets the v6 bind they asked for. Refs #1682, #1593 Co-Authored-By: Claude --- SPEC.md | 6 + .../tests/banned_address_patterns.rs | 92 +- crates/dig-node-service/src/config.rs | 1711 +++++++++-------- crates/dig-node-service/src/open.rs | 82 +- 4 files changed, 1005 insertions(+), 886 deletions(-) diff --git a/SPEC.md b/SPEC.md index 6ea9b6b..8ad559b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -312,6 +312,12 @@ The server opens UP TO THREE listeners for the SAME router: 1. **`:`** (default `127.0.0.1:9778`, §3.2) — always on. A bind failure here is FATAL (`serve` returns the error; CLI exit `BIND_FAILED`, §8.4). + The rendered authority MUST be a valid socket address for BOTH address families: an IPv6 + `DIG_NODE_HOST` MUST be bracketed (`DIG_NODE_HOST=::1` ⇒ `[::1]:9778`). Implementations MUST + render it from the parsed `IpAddr` and the port (e.g. `SocketAddr`) and MUST NOT concatenate + host and port as text — the unbracketed form binds nowhere, and this bind failure is fatal. + The same rendering applies wherever the node reports or reuses that address: the + `/health` `addr` field, the CLI control-plane URL, and the `localhost`-tier serve URL (§5.3). 2. **`[::1]:`** (§5.2 dual-stack loopback) — the SAME `localhost:` on the IPv6 loopback. Present ONLY when `DIG_NODE_HOST` is unset (the default): some resolvers return `::1` before `127.0.0.1` for `localhost` (Windows by default), so without this listener such a diff --git a/crates/dig-node-core/tests/banned_address_patterns.rs b/crates/dig-node-core/tests/banned_address_patterns.rs index a612bf6..5aea410 100644 --- a/crates/dig-node-core/tests/banned_address_patterns.rs +++ b/crates/dig-node-core/tests/banned_address_patterns.rs @@ -22,43 +22,44 @@ use std::path::{Path, PathBuf}; /// Violations that already existed when this guard was strengthened — TRACKED, not waived. /// -/// Each entry is a real instance of the banned construct that a weaker version of this scanner (one that -/// matched three literal spellings) never saw. They are recorded here rather than fixed in the same -/// change because fixing them alters production bind/URL behaviour, which belongs in its own reviewed -/// change; they are recorded rather than hidden because weakening the matcher to make the guard green -/// would trade a real defect for a comfortable test. +/// **The list is now EMPTY: both tracked sites were fixed under #1682.** `Config::bind_addr` renders +/// through `SocketAddr`, and `open::browser_authority` does the same for the one URL authority that +/// carries a port, so neither builds an address by text any more. The machinery is kept because the +/// list is the sanctioned way to record a violation that cannot be fixed in the change that finds it — +/// deleting it would leave a future finder with only two options, fix-here or weaken-the-matcher. /// -/// Both are live IPv6 defects, not stylistic: -/// - `config.rs` renders `DIG_NODE_HOST` + port by text, so an operator setting a v6 host gets the -/// unbracketed `::1:9333`, which then fails to parse at bind time — and that bind failure is -/// documented as FATAL. -/// - `open.rs` builds a URL authority the same way, so a v6 host yields an unparseable URL. +/// Note the list can no longer double as the scan's cross-crate reach detector (an empty list makes the +/// old `waived == KNOWN_VIOLATIONS.len()` assertion trivially true). That job moved to +/// [`SIBLING_CRATE_UNDER_SCAN`], which holds whether or not anything is ever tracked here again. /// -/// An entry is matched on its distinguishing snippet, not a line number, so unrelated edits to these -/// files do not silently move the exception onto different code. **Delete the entry when the site is -/// fixed**, which also drops it from the waived-count assertion in -/// [`no_source_file_builds_a_socket_address_from_concatenated_text`] — the two maintain each other. -/// -/// Tracked as **#1682**. -const KNOWN_VIOLATIONS: &[(&str, &str)] = &[ - // #1682 — `Config::bind_addr` renders host + port by text, so `DIG_NODE_HOST=::1` yields the - // unbracketed `::1:9333` and the bind then fails — a failure documented as FATAL. - ( - "dig-node-service/src/config.rs", - "self.host.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))", - ), - // #1682 — `serve_url` builds a URL authority the same way, so a v6 host yields an unparseable URL. - ("dig-node-service/src/open.rs", r#""{host}:{p}""#), -]; +/// An entry is matched on its distinguishing snippet, not a line number, so unrelated edits to a +/// tracked file do not silently move the exception onto different code. **Delete an entry when its +/// site is fixed**, which the waived-count assertion in +/// [`no_source_file_builds_a_socket_address_from_concatenated_text`] enforces — the two maintain +/// each other. +const KNOWN_VIOLATIONS: &[(&str, &str)] = &[]; -/// Is this call one of the [`KNOWN_VIOLATIONS`], in the file that entry names? -fn is_known_violation(relative_path: &str, call: &str) -> bool { +/// Is this call one of `tracked`, in the file that entry names? +/// +/// The list is a PARAMETER rather than a direct read of [`KNOWN_VIOLATIONS`] so the matching rule stays +/// testable now that the real list is empty — an entry-matching test that can only feed it the live list +/// would silently stop asserting anything the moment the last entry was fixed. +fn is_tracked_violation(tracked: &[(&str, &str)], relative_path: &str, call: &str) -> bool { let normalized = relative_path.replace('\\', "/"); - KNOWN_VIOLATIONS + tracked .iter() .any(|(file, snippet)| normalized.ends_with(file) && call.contains(snippet)) } +/// A crate OTHER than the one holding this test that the scan must reach. +/// +/// This is the cross-crate reach detector. [`crates_root`] climbs out of `dig-node-core` to the whole +/// `crates/` directory, and if that ever stops working — a directory move, a changed level layout — the +/// scan would quietly narrow to this crate alone and report a clean, meaningless pass. The file-count +/// floor catches a large collapse; this catches the exact accident, and unlike the tracked-violation +/// count it does not evaporate when the last tracked site is fixed. +const SIBLING_CRATE_UNDER_SCAN: &str = "dig-node-service/src"; + /// The crates directory this test scans — the whole workspace, not just this crate, so a sibling crate /// cannot reintroduce the pattern unnoticed. fn crates_root() -> PathBuf { @@ -345,6 +346,7 @@ fn no_source_file_builds_a_socket_address_from_concatenated_text() { let mut files_scanned = 0usize; let mut format_calls_seen = 0usize; let mut waived = 0usize; + let mut reached_sibling_crate = false; for file in rust_sources(&root) { // This file necessarily CONTAINS the banned pattern — as the strings it matches on, and as the // fixtures that prove the matcher works. Scanning itself would make the ban permanently red. @@ -363,13 +365,16 @@ fn no_source_file_builds_a_socket_address_from_concatenated_text() { .unwrap_or(&file) .display() .to_string(); + reached_sibling_crate |= relative + .replace('\\', "/") + .contains(SIBLING_CRATE_UNDER_SCAN); for (line, call) in format_calls(&without_comment_lines(&contents)) { format_calls_seen += 1; let collapsed = call.split_whitespace().collect::>().join(" "); if !builds_an_address_from_text(&call) { continue; } - if is_known_violation(&relative, &collapsed) { + if is_tracked_violation(KNOWN_VIOLATIONS, &relative, &collapsed) { waived += 1; } else { offenders.push(format!("{relative} line {line}: format!({collapsed})")); @@ -396,12 +401,17 @@ fn no_source_file_builds_a_socket_address_from_concatenated_text() { "the scan parsed only {format_calls_seen} `format!` calls (expected at least \ {MINIMUM_FORMAT_CALLS_SEEN}), so it listed files without reading their contents meaningfully." ); + assert!( + reached_sibling_crate, + "the scan never reached `{SIBLING_CRATE_UNDER_SCAN}`, so it stayed inside its own crate and a \ + clean result proves nothing about the workspace. Did `crates_root` stop resolving to the \ + `crates/` directory?" + ); assert_eq!( waived, KNOWN_VIOLATIONS.len(), - "expected to reach all {} tracked violations and reached {waived}. Both live in the SIBLING \ - crate `dig-node-service`, so this failing means either the scan no longer escapes its own \ - crate, or a tracked site was fixed — in which case delete its `KNOWN_VIOLATIONS` entry (#1682).", + "expected to reach all {} tracked violations and reached {waived}. A tracked site was fixed \ + without deleting its `KNOWN_VIOLATIONS` entry, or an entry no longer names the file it is in.", KNOWN_VIOLATIONS.len() ); } @@ -412,19 +422,27 @@ fn no_source_file_builds_a_socket_address_from_concatenated_text() { /// **Catches:** the exception list widening into a per-file waiver, which would let the two known /// defects shelter any future violation that happened to land beside them. An allowlist that matched by /// filename alone would pass this file and fail this test. +/// +/// It runs against a SYNTHETIC entry rather than [`KNOWN_VIOLATIONS`], which is empty today (#1682). +/// Reading the live list would make this test vacuous exactly when the codebase is clean — and the +/// matching rule still has to be right for the next entry anyone adds. #[test] fn a_tracked_violation_excuses_only_its_own_call_not_its_whole_file() { - let (tracked_file, tracked_snippet) = KNOWN_VIOLATIONS[0]; + let tracked_file = "dig-node-service/src/config.rs"; + let tracked_snippet = r#"format!("{host}:{port}")"#; + let tracked: &[(&str, &str)] = &[(tracked_file, tracked_snippet)]; + assert!( - is_known_violation(tracked_file, tracked_snippet), + is_tracked_violation(tracked, tracked_file, tracked_snippet), "the tracked site must be recognised in its own file" ); assert!( - !is_known_violation(tracked_file, r#""{ip}:{port}""#), + !is_tracked_violation(tracked, tracked_file, r#"format!("{ip}:{port}")"#), "a DIFFERENT offending call in a tracked file must still be an offender" ); assert!( - !is_known_violation( + !is_tracked_violation( + tracked, "dig-node-core/src/seams/dig_peer/union_locator.rs", tracked_snippet ), diff --git a/crates/dig-node-service/src/config.rs b/crates/dig-node-service/src/config.rs index 9a52637..ae14131 100644 --- a/crates/dig-node-service/src/config.rs +++ b/crates/dig-node-service/src/config.rs @@ -1,831 +1,880 @@ -//! Runtime configuration for the dig-node service, resolved from the environment. -//! -//! The service's knobs use the canonical `DIG_NODE_*` env contract: `DIG_NODE_PORT` -//! / `DIG_NODE_HOST` pick the bind address; `DIG_RPC_UPSTREAM` picks the upstream -//! the embedded dig-node read path proxies blind ciphertext/proof requests to on a -//! cache miss. -//! -//! **STABLE ENV CONTRACT — the `DIG_NODE_*` names are the binary's canonical, -//! stable configuration contract.** The dig-installer sets them and apt.dig.net -//! documents them, so renaming them again would break those consumers. -//! -//! The upstream is wired into the read path via its own `DIG_NODE_UPSTREAM` env var -//! (see [`Config::apply_to_env`]) — the dig-node read-path crate reads that name -//! internally, so this service translates its public `DIG_RPC_UPSTREAM` knob into it. -//! -//! ## Shared `.dig` cache (#96) -//! -//! `DIG_NODE_CACHE` points the read path at the on-disk `.dig` cache. This service -//! reads it **explicitly** ([`Config::cache_dir`]) so an operator/installer can aim -//! it at one canonical cache, and re-applies it to the read path's environment in -//! [`Config::apply_to_env`]. -//! -//! **Omitting it is the right default for sharing.** When `DIG_NODE_CACHE` is -//! unset, this service does NOT invent a path — it leaves the read path to resolve its -//! own canonical default (`%LOCALAPPDATA%\DigNode\cache` on Windows, -//! `$HOME/DigNode/cache` on Unix/macOS), which is **byte-identical** to the dir the -//! DIG Browser's in-process node uses. So when both the standalone service and the -//! browser are installed they share ONE cache — a capsule fetched by either is -//! served from disk by the other, with no double-store. dig-node makes that shared -//! dir safe for two processes (atomic content-addressed writes + a cross-process -//! lock; #95/#96 Pass A). Set `DIG_NODE_CACHE` only to move that shared cache -//! somewhere explicit (e.g. a service data dir, or a volume shared between -//! installs) — and set the SAME value for both the service and the browser launch -//! so they keep sharing it. - -use std::net::{IpAddr, Ipv4Addr}; - -/// Default loopback bind port — an UNCOMMON high port, deliberately clear of the -/// collision-prone common-dev ports (80/443/3000/5000/8000/8080/8888/9000) that a -/// dev machine is most likely to already have in use (#132). `9778` is the sibling -/// of the dig-wallet HTTP API's `9777` (wallet on `9777`, node on `9778`) and is the -/// port the digstore-remote §5.3 resolver already expects a local node on -/// (`DEFAULT_LOCAL_NODE_PORT`), so aligning here removes that cross-repo drift. Every -/// consumer of the §5.3 `localhost` tier (the extension's `server.host` default, the -/// installer, the DIG Browser) MUST target `9778` to match. `DIG_NODE_PORT` overrides -/// it. (`dig.local` on `127.0.0.2:80` is unaffected — only this localhost port moves.) -/// -/// Single-sourced from the shared `dig-constants` crate (`DIG_NODE_PORT`) rather than -/// re-declared here, so every §5.3 client→node consumer that also imports the constant agrees -/// with this service byte-for-byte with no copy to drift. -pub const DEFAULT_PORT: u16 = dig_constants::DIG_NODE_PORT; - -/// Default upstream DIG RPC the embedded node proxies to on a local cache miss. -pub const DEFAULT_UPSTREAM: &str = "https://rpc.dig.net"; - -/// The loopback IP the bare-`http://dig.local` listener binds to (#91). The -/// dig-installer writes a hosts entry `127.0.0.2 dig.local`, so binding this IP on -/// the privileged port 80 makes `http://dig.local` (NO port) reach the node. A -/// distinct loopback IP (`.2`, not `.1`) is used so the port-80 bind can never -/// collide with an unrelated `localhost:80` service the user already runs. On macOS -/// the loopback alias must exist first (`sudo ifconfig lo0 alias 127.0.0.2`); the -/// installer/service handles that — see the README. -pub const DIG_LOCAL_IP: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2); - -/// The privileged port the bare-`http://dig.local` listener binds to (#91). Port 80 -/// means the URL carries no `:port`, which is the whole point. Binding it is -/// privileged (root / `CAP_NET_BIND_SERVICE` on Linux; Administrator/LocalSystem on -/// Windows — the installed service runs elevated, so it works there). The bind is -/// BEST-EFFORT: if it fails the localhost listener still serves (see `server`). -pub const DIG_LOCAL_PORT: u16 = 80; - -/// The canonical hostname the bare-`http://dig.local` listener answers to (#91). -/// Matches the dig-installer hosts entry and the extension's resolver base-domain -/// list (`dig.local` / `localhost` / `127.0.0.1`). -pub const DIG_LOCAL_HOST: &str = "dig.local"; - -/// The port the local HTTPS listener binds for `https://dig.local` (#624, #620 epic). -/// Port 443 means the URL carries no `:port`. Binding it is privileged like `:80` -/// (root / `CAP_NET_BIND_SERVICE`; elevated on Windows — the installed service runs -/// elevated). The bind is BEST-EFFORT and additionally GATED on a dig-cert leaf being -/// present: with no CA/leaf yet the node serves plaintext only (see `crate::tls`). -pub const DIG_LOCAL_HTTPS_PORT: u16 = 443; - -/// Resolved dig-node service configuration. -#[derive(Debug, Clone)] -pub struct Config { - /// Explicit `DIG_NODE_HOST` override, or `None` for the default (#288, §5.2). - /// `None` — the default — means "bind BOTH loopback families": `127.0.0.1` - /// (always-on, fatal on failure) AND `[::1]` (best-effort), so `localhost` - /// reaches the node whether the resolver returns the IPv4 or the IPv6 loopback - /// first (Windows resolves `localhost` to `::1` first by default, which made the - /// node appear offline to an IPv6-first client before this). `Some(ip)` — an - /// explicit override — REPLACES the default dual bind with exactly that one - /// address; it does not add to it. See [`Config::bind_addr`] (the primary/ - /// always-on address) and [`Config::bind_addr_v6`] (the additional IPv6 - /// loopback address, when applicable). - pub host: Option, - /// Bind port. - pub port: u16, - /// Upstream DIG RPC base URL the embedded dig-node proxies to on a miss. - pub upstream: String, - /// Explicit on-disk cache dir for dig-node's `.dig` modules, from - /// `DIG_NODE_CACHE`. `None` (the default) means "use dig-node's shared - /// canonical default" — the SAME dir the DIG Browser's in-process node uses, - /// so the two share ONE cache (see the module-level "Shared `.dig` cache" - /// note). `Some(path)` moves that shared cache to an explicit location. - pub cache_dir: Option, - /// Whether to ALSO open the bare-`http://dig.local` loopback listener - /// (`127.0.0.2:80`) beside the always-on `localhost:` one (#91). From - /// `DIG_NODE_DIGLOCAL` (`1`/`true`/`yes`/`on` ⇒ enabled, `0`/`false`/… ⇒ - /// disabled); **default `true`** — auto-attempt with graceful fallback. The - /// attempt is BEST-EFFORT: if the privileged `:80` bind fails (no privilege, - /// port in use, or — on macOS — the `127.0.0.2` loopback alias is missing) the - /// node logs a structured warning and serves localhost-only, never aborting. - /// Set `DIG_NODE_DIGLOCAL=0` to skip the attempt entirely. - pub dig_local: bool, - /// Whether the node-custodied wallet broadcasts spends for REAL on mainnet (§18.12, #428). - /// From `DIG_WALLET_ENABLE_LIVE_BROADCAST` (`1`/`true`/`yes`/`on` ⇒ enabled); **default - /// `false`** — the money-safe default: no broadcaster is attached and NO $DIG moves (a tip / - /// sign-on-behalf / send cleanly reports unavailable). When enabled, the served wallet attaches - /// a real `chia_query` broadcaster + confirmer + lineage so node-custodied spends execute + - /// confirm on mainnet. Enabling it means REAL $DIG movement — opt-in only. - pub enable_live_broadcast: bool, - /// Whether a NON-loopback `DIG_NODE_HOST` override is permitted (#1662). From - /// `DIG_NODE_ALLOW_REMOTE` (`1`/`true`/`yes`/`on` ⇒ permitted); **default `false`** - /// — the security-safe default. When `false`, a non-loopback `host` is refused at - /// startup ([`host_override_refusal`], enforced in `server::serve_with_shutdown`) so - /// the local RPC/content API is never silently exposed to the network. Loopback - /// overrides and the no-override dual-stack default never need this flag. - pub allow_remote: bool, -} - -impl Default for Config { - fn default() -> Self { - Config { - host: None, - port: DEFAULT_PORT, - upstream: DEFAULT_UPSTREAM.to_string(), - cache_dir: None, - // Auto-attempt the bare-dig.local listener by default (graceful - // fallback if the privileged bind fails) — see the field doc + #91. - dig_local: true, - // Money-safe default: live broadcast is OFF unless explicitly enabled. - enable_live_broadcast: false, - // Security-safe default: a non-loopback bind is opt-in only (#1662). - allow_remote: false, - } - } -} - -impl Config { - /// Resolve the config from the process environment, falling back to defaults. - /// Mirrors the stable `DIG_NODE_PORT` / `DIG_NODE_HOST` / `DIG_RPC_UPSTREAM` env - /// contract (see the module-level "STABLE ENV CONTRACT" note). - pub fn from_env() -> Self { - let port = std::env::var("DIG_NODE_PORT") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|p| *p != 0) - .unwrap_or(DEFAULT_PORT); - - let host = parse_host_override(std::env::var("DIG_NODE_HOST").ok()); - - // Upstream precedence: explicit DIG_RPC_UPSTREAM env > the persisted - // override (set via the control plane's `control.config.setUpstream`, - // stored in dig-node's config.json) > the default. The env var still wins - // so a deploy/CI override is never silently overridden by a saved setting; - // the persisted value is the "I set this in the controller UI" choice that - // takes effect on the next start (the running node captured its upstream at - // construction — see `control.config.setUpstream` → `requires_restart`). - let upstream = std::env::var("DIG_RPC_UPSTREAM") - .ok() - .map(|s| normalize_upstream(&s)) - .filter(|s| !s.is_empty()) - .or_else(|| crate::control::read_upstream_override().map(|s| normalize_upstream(&s))) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_UPSTREAM.to_string()); - - // DIG_NODE_CACHE is read with the read path's OWN env var name (not a - // service-specific alias) so a value the operator sets reaches the node - // directly and this service just makes honouring it explicit. A - // blank/whitespace value is treated as unset → shared default (see - // resolve_cache_dir). - let cache_dir = resolve_cache_dir(std::env::var("DIG_NODE_CACHE").ok()); - - // The bare-dig.local listener is on by default (auto-attempt + graceful - // fallback); DIG_NODE_DIGLOCAL=0/false/no/off turns it off entirely. - let dig_local = parse_dig_local_flag(std::env::var("DIG_NODE_DIGLOCAL").ok()); - - // Live mainnet broadcast is OFF unless explicitly enabled (money-safe default). - let enable_live_broadcast = - parse_live_broadcast_flag(std::env::var("DIG_WALLET_ENABLE_LIVE_BROADCAST").ok()); - - // A non-loopback DIG_NODE_HOST is opt-in only (#1662); enforcement happens at - // the bind site (server::serve_with_shutdown) so `status`/`install` — which - // never bind — still resolve the config the operator set. - let allow_remote = parse_allow_remote_flag(std::env::var("DIG_NODE_ALLOW_REMOTE").ok()); - - Config { - host, - port, - upstream, - cache_dir, - dig_local, - enable_live_broadcast, - allow_remote, - } - } - - /// Apply this config to the environment dig-node reads at `Node::from_env()`: - /// - /// * `DIG_NODE_UPSTREAM` ← this service's public `DIG_RPC_UPSTREAM` knob. - /// (dig-node deliberately uses a distinct name from the browser's - /// `DIG_RPC_ENDPOINT`, which points a client AT the node; reusing that would - /// make the node proxy to itself.) - /// * `DIG_NODE_CACHE` ← the explicit cache dir, **only when one was set**. When - /// it was omitted we leave the env untouched so dig-node resolves its shared - /// canonical default (the dir the DIG Browser's in-process node also uses) — - /// writing an empty value here would instead point the node at a bogus path - /// and break cache sharing. See the module-level "Shared `.dig` cache" note. - /// - /// Called before constructing the node so both knobs are honoured. - pub fn apply_to_env(&self) { - std::env::set_var("DIG_NODE_UPSTREAM", &self.upstream); - if let Some(dir) = cache_dir_env_value(self.cache_dir.as_deref()) { - std::env::set_var("DIG_NODE_CACHE", dir); - } - } - - /// The `host:port` socket string for the always-on localhost listener - /// (binding / logging): the explicit `DIG_NODE_HOST` override, or the default - /// `127.0.0.1` when unset. A bind failure on THIS address is fatal (see - /// `server::serve_with_shutdown`) — every consumer (CLI `status`/`pair`, the - /// installed-service summary, `/health`'s `addr` field) treats it as THE - /// address, so its shape never changes based on the dual-stack default below. - pub fn bind_addr(&self) -> String { - format!( - "{}:{}", - self.host.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)), - self.port - ) - } - - /// The IPv6 loopback bind address (`[::1]:`) to open BESIDE - /// [`Config::bind_addr`] (#288, §5.2 dual-stack loopback): `Some` when no - /// explicit `DIG_NODE_HOST` override is set (the default — bind BOTH loopback - /// families, since some resolvers — Windows' `localhost` by default — return - /// `::1` before `127.0.0.1`); `None` when an explicit host override is set (it - /// REPLACES the default dual bind with exactly that one address, rather than - /// adding to it). This listener is BEST-EFFORT at bind time (see `serve`): an - /// IPv6-loopback-unavailable system falls back to IPv4-only, mirroring the - /// existing [`Config::dig_local_addr`] best-effort pattern. - pub fn bind_addr_v6(&self) -> Option { - self.host.is_none().then(|| format!("[::1]:{}", self.port)) - } - - /// The `host:port` socket string for the BEST-EFFORT bare-`http://dig.local` - /// listener (`127.0.0.2:80`), or `None` when `dig_local` is disabled (#91). - /// `serve` tries to bind this in ADDITION to [`bind_addr`]; a failure is - /// logged and ignored (localhost keeps serving). - pub fn dig_local_addr(&self) -> Option { - self.dig_local - .then(|| format!("{DIG_LOCAL_IP}:{DIG_LOCAL_PORT}")) - } - - /// The `host:port` for the BEST-EFFORT local HTTPS listener serving - /// `https://dig.local` (`127.0.0.2:443`, #624), or `None` when `dig_local` is - /// disabled. Shares the `dig_local` toggle with the plaintext `:80` listener — - /// both are the "bare dig.local" surface, one plaintext, one TLS. The TLS listener - /// is additionally gated on a dig-cert leaf being present (`crate::tls`); with no - /// leaf yet only plaintext serves. - pub fn dig_local_https_addr(&self) -> Option { - self.dig_local - .then(|| format!("{DIG_LOCAL_IP}:{DIG_LOCAL_HTTPS_PORT}")) - } - - /// The IPv6-loopback HTTPS bind (`[::1]:443`) to open BESIDE - /// [`Config::dig_local_https_addr`] (§5.2 IPv6-first loopback), or `None` when - /// `dig_local` is disabled. `https://dig.local` resolves to the IPv4 alias - /// `127.0.0.2` via the installer hosts entry, but the leaf's SAN also covers `::1`, - /// so an IPv6 loopback client (e.g. `https://localhost` where `localhost` resolves - /// to `::1` first) reaches the identical surface. BEST-EFFORT: a bind failure is - /// logged and the node continues on the IPv4 listener (see `server`). - pub fn dig_local_https_addr_v6(&self) -> Option { - self.dig_local - .then(|| format!("[::1]:{DIG_LOCAL_HTTPS_PORT}")) - } -} - -/// Parse the `DIG_NODE_DIGLOCAL` toggle. Truthy (`1`/`true`/`yes`/`on`) ⇒ enable -/// the bare-dig.local listener; falsy (`0`/`false`/`no`/`off`) ⇒ disable; **unset -/// or unrecognised ⇒ the default `true`** (auto-attempt with graceful fallback). -/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable. -pub fn parse_dig_local_flag(raw: Option) -> bool { - match raw.as_deref().map(str::trim).map(str::to_ascii_lowercase) { - Some(ref v) if matches!(v.as_str(), "0" | "false" | "no" | "off") => false, - Some(ref v) if matches!(v.as_str(), "1" | "true" | "yes" | "on") => true, - // Unset, blank, or anything unrecognised → the default-on behaviour. - _ => true, - } -} - -/// Parse the `DIG_WALLET_ENABLE_LIVE_BROADCAST` toggle (§18.12, #428). Truthy -/// (`1`/`true`/`yes`/`on`) ⇒ enable REAL mainnet broadcast; **anything else — including unset, -/// blank, or unrecognised — ⇒ the money-safe default `false`** (no $DIG moves). This is the -/// OPPOSITE default to `parse_dig_local_flag`: money movement is opt-in, never on by accident. -/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable without process env. -pub fn parse_live_broadcast_flag(raw: Option) -> bool { - matches!( - raw.as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("1" | "true" | "yes" | "on") - ) -} - -/// Parse the `DIG_NODE_HOST` override (#288): `Some(ip)` when the raw value is a -/// valid IP literal, `None` when unset/blank/unparsable. `None` is the DEFAULT and -/// carries meaning — it is not merely "no value" — see [`Config::host`] and -/// [`Config::bind_addr_v6`]: it means "bind BOTH loopback families", not merely -/// "fall back to 127.0.0.1". PURE so the override-vs-default policy is -/// unit-testable without touching process env. -pub fn parse_host_override(raw: Option) -> Option { - raw.as_deref().and_then(|s| s.trim().parse::().ok()) -} - -/// Parse the `DIG_NODE_ALLOW_REMOTE` escape hatch (#1662): truthy -/// (`1`/`true`/`yes`/`on`) ⇒ permit a NON-loopback `DIG_NODE_HOST`; **anything else -/// — including unset, blank, or unrecognised — ⇒ the security-safe default `false`** -/// (loopback-only). Same opt-in-only shape as [`parse_live_broadcast_flag`]: exposing -/// the local RPC/content API to the network is a deliberate act, never on by accident. -/// Case/whitespace-insensitive. PURE so the policy is unit-testable without process env. -pub fn parse_allow_remote_flag(raw: Option) -> bool { - matches!( - raw.as_deref() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref(), - Some("1" | "true" | "yes" | "on") - ) -} - -/// Whether `ip` is a loopback address — the SHARED loopback predicate for the whole -/// service. Beyond the stdlib [`IpAddr::is_loopback`] it also treats an IPv4-MAPPED -/// IPv6 loopback (`::ffff:127.0.0.1`) as loopback: on a `::` dual-stack bind the OS -/// reports an IPv4 loopback client in that mapped form, which `Ipv6Addr::is_loopback` -/// (true only for `::1`) would otherwise miss (#1664b). Shared so the origin classifier -/// (`server::read_origin_for`) and the `DIG_NODE_HOST` enforcement below apply ONE rule, -/// and so #1646 can reuse it rather than re-deriving the mapped-loopback case. -pub fn is_loopback_addr(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => v4.is_loopback(), - // `::1` directly, or an IPv4-mapped loopback like `::ffff:127.0.0.1`. - IpAddr::V6(v6) => { - v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) - } - } -} - -/// Enforce the loopback-only invariant on a `DIG_NODE_HOST` override (#1662): -/// returns `Some(message)` when the override MUST be refused — a non-loopback bind -/// address without the explicit `DIG_NODE_ALLOW_REMOTE=1` escape hatch — and `None` -/// when the bind is permitted (no override, a loopback override, or `allow_remote`). -/// -/// This is what makes the ~25 "loopback-only / never peer-reachable" invariants across -/// the service TRUE rather than merely asserted: the local RPC/content API is either -/// bound to loopback, or the operator has DELIBERATELY opted into a remote bind. The -/// caller (`server::serve_with_shutdown`) fails CLOSED on `Some` — a bad override is a -/// hard startup error, never a silent LAN exposure. PURE so the policy is unit-testable. -/// -/// This governs ONLY the local RPC/content bind ([`Config::bind_addr`]); the peer P2P -/// wire (mTLS `:9444`, in dig-node-core) and the loopback wallet mTLS `:9257` listener -/// bind independently, so enforcing loopback here never affects peer connectivity. -pub fn host_override_refusal(host: Option, allow_remote: bool) -> Option { - match host { - Some(ip) if !is_loopback_addr(&ip) && !allow_remote => Some(format!( - "refusing to bind the local API to a non-loopback address ({ip}); this exposes the \ - node's RPC/content API to the network. Set DIG_NODE_ALLOW_REMOTE=1 to override." - )), - _ => None, - } -} - -/// Whether a request `Host` header is allowed (#91, #288). The node is -/// loopback-only and answers to the canonical local names — bare `dig.local`, -/// `localhost`, the loopback IPs `127.0.0.1`/`127.0.0.2`, and the IPv6 loopback -/// `::1` (bracketed `[::1]`/`[::1]:` per RFC 7230's mandatory bracketing for -/// an IPv6-literal Host, or bare `::1` for a non-browser client that omits them) — -/// with or without a `:port` suffix; a missing Host is allowed (HTTP/1.0 / health -/// probes). Any OTHER host (e.g. a public domain pointed at the machine, the -/// classic DNS-rebinding vector) is rejected, so even though the listeners are -/// loopback-only (enforced — a non-loopback `DIG_NODE_HOST` is refused unless -/// `DIG_NODE_ALLOW_REMOTE=1`, [`host_override_refusal`], #1662) the node never serves -/// a foreign-named request. PURE: takes the -/// raw header value, returns the decision. -pub fn host_is_allowed(host_header: Option<&str>) -> bool { - // No Host header at all (HTTP/1.0, some probes) → allow: it cannot be a - // rebinding attack (there is no attacker-chosen name) and the loopback bind - // already constrains reachability. - let Some(raw) = host_header else { - return true; - }; - let host = raw.trim(); - if host.is_empty() { - return true; - } - - // IPv6-literal forms (#288): `[::1]` / `[::1]:` (bracketed, the ONLY - // legal way to carry an IPv6 literal in a Host header per RFC 7230 — the - // brackets disambiguate the address's own colons from the port separator), or - // bare `::1` for a non-browser client that skips the brackets. Checked BEFORE - // the generic `:port`-strip below, because naively splitting an IPv6 literal - // on its LAST `:` would still work for these two specific shapes, but bracket - // handling makes the intent explicit and rejects malformed bracket forms. - if let Some(inner) = host.strip_prefix('[') { - return match inner.strip_suffix(']') { - Some(addr) => addr == "::1", - None => inner - .rsplit_once("]:") - .is_some_and(|(addr, _port)| addr == "::1"), - }; - } - if host == "::1" { - return true; - } - - // Strip a trailing `:port` (IPv4 / hostname forms only). `dig.local:80`, - // `localhost:9778`, `127.0.0.1` all reduce to their hostname for the - // allowlist check. - let name = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); - matches!( - name, - DIG_LOCAL_HOST | "localhost" | "127.0.0.1" | "127.0.0.2" - ) -} - -/// Normalise an upstream URL: trim, strip trailing slashes, and default a bare -/// host to `https://`. Pure so the precedence/normalisation is unit-testable. -pub fn normalize_upstream(raw: &str) -> String { - let t = raw.trim().trim_end_matches('/'); - if t.is_empty() { - return String::new(); - } - if t.starts_with("http://") || t.starts_with("https://") { - t.to_string() - } else { - format!("https://{t}") - } -} - -/// Resolve the explicit cache dir from a raw `DIG_NODE_CACHE` value: a non-blank -/// value is honoured (trimmed); a missing or blank/whitespace value is `None`, -/// meaning "use dig-node's shared canonical default". PURE so the -/// honour-vs-default policy is unit-testable without touching process env. -pub fn resolve_cache_dir(raw: Option) -> Option { - cache_dir_env_value(raw.as_deref()) -} - -/// The value to write to `DIG_NODE_CACHE`, given the config's `cache_dir`: a -/// trimmed non-empty path, or `None` (don't set the env var → shared default). -/// PURE — the single place the "only set when explicit" rule lives, shared by -/// [`Config::from_env`] and [`Config::apply_to_env`]. -pub fn cache_dir_env_value(cache_dir: Option<&str>) -> Option { - cache_dir - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_upstream_trims_and_strips_trailing_slash() { - assert_eq!( - normalize_upstream("https://rpc.dig.net/"), - "https://rpc.dig.net" - ); - assert_eq!( - normalize_upstream(" https://rpc.dig.net/// "), - "https://rpc.dig.net" - ); - } - - #[test] - fn normalize_upstream_defaults_scheme_to_https() { - assert_eq!(normalize_upstream("rpc.dig.net"), "https://rpc.dig.net"); - assert_eq!( - normalize_upstream("http://127.0.0.1:9000"), - "http://127.0.0.1:9000" - ); - } - - #[test] - fn normalize_upstream_empty_stays_empty() { - assert_eq!(normalize_upstream(""), ""); - assert_eq!(normalize_upstream(" "), ""); - assert_eq!(normalize_upstream("///"), ""); - } - - #[test] - fn default_config_is_loopback_9778() { - let c = Config::default(); - assert_eq!(c.port, DEFAULT_PORT); - // #132: the default localhost port is the uncommon high port 9778 (the - // dig-wallet 9777 sibling), NOT the collision-prone 8080. - assert_eq!(DEFAULT_PORT, 9778); - assert_eq!(c.bind_addr(), "127.0.0.1:9778"); - assert_eq!(c.upstream, DEFAULT_UPSTREAM); - } - - // ----- #288: dual-stack loopback bind (127.0.0.1 AND [::1]) ---------------- - - #[test] - fn default_config_binds_both_loopback_families() { - // No DIG_NODE_HOST override → the default is dual-stack: the always-on - // IPv4 loopback AND the additional (best-effort) IPv6 loopback, same port. - let c = Config::default(); - assert_eq!(c.host, None); - assert_eq!(c.bind_addr(), "127.0.0.1:9778"); - assert_eq!(c.bind_addr_v6().as_deref(), Some("[::1]:9778")); - } - - #[test] - fn explicit_host_override_replaces_rather_than_extends_the_default_bind() { - // An explicit DIG_NODE_HOST fully replaces the dual-stack default with - // exactly that one address — it does not ALSO open [::1]. - let c = Config { - host: Some(std::net::Ipv4Addr::new(10, 0, 0, 5).into()), - ..Config::default() - }; - assert_eq!(c.bind_addr(), "10.0.0.5:9778"); - assert_eq!(c.bind_addr_v6(), None); - } - - #[test] - fn parse_host_override_parses_a_valid_ip_literal() { - assert_eq!( - parse_host_override(Some("127.0.0.1".to_string())), - Some(IpAddr::V4(Ipv4Addr::LOCALHOST)) - ); - assert_eq!( - parse_host_override(Some(" ::1 ".to_string())), - Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) - ); - } - - #[test] - fn parse_host_override_is_none_when_unset_blank_or_unparsable() { - assert_eq!(parse_host_override(None), None); - assert_eq!(parse_host_override(Some(String::new())), None); - assert_eq!(parse_host_override(Some(" ".to_string())), None); - assert_eq!(parse_host_override(Some("not-an-ip".to_string())), None); - } - - #[test] - fn default_config_has_no_explicit_cache_dir() { - // Omitting DIG_NODE_CACHE means "use dig-node's shared canonical default" - // (the SAME dir the DIG Browser's in-process node uses) — so the resolved - // config carries None, never a hard-coded path that would diverge from it. - assert_eq!(Config::default().cache_dir, None); - } - - #[test] - fn apply_to_env_does_not_set_cache_when_unset() { - // When the operator did NOT set DIG_NODE_CACHE, this service must NOT write - // it — leaving dig-node free to resolve its shared canonical default. (We - // assert via the pure helper so the test never mutates process-global env, - // which would race the concurrent server tests.) - let none: Option<&str> = None; - assert_eq!(cache_dir_env_value(none), None); - assert_eq!(cache_dir_env_value(Some(" ")), None); - } - - #[test] - fn apply_to_env_sets_explicit_cache_dir() { - // An explicit DIG_NODE_CACHE is honoured: it is the value this service - // re-applies to the read path's env (so a service install records it, and the - // node + this service's /health agree on the same shared dir). - assert_eq!( - cache_dir_env_value(Some("D:/dig/shared-cache")), - Some("D:/dig/shared-cache".to_string()) - ); - } - - #[test] - fn from_env_reads_explicit_cache_dir() { - // Drive the same resolution the real Config::from_env runs, but on an - // explicit value (pure helper) so we don't touch process env. - assert_eq!( - resolve_cache_dir(Some("/var/lib/dignode/cache".to_string())), - Some("/var/lib/dignode/cache".to_string()) - ); - assert_eq!(resolve_cache_dir(Some(" ".to_string())), None); - assert_eq!(resolve_cache_dir(None), None); - } - - // ----- #91: the dig.local listener flag + addressing ----------------------- - - #[test] - fn dig_local_is_on_by_default() { - // Auto-attempt with graceful fallback: a default Config wants the - // bare-dig.local listener, addressed 127.0.0.2:80. - let c = Config::default(); - assert!(c.dig_local); - assert_eq!(c.dig_local_addr().as_deref(), Some("127.0.0.2:80")); - } - - #[test] - fn dig_local_addr_is_none_when_disabled() { - let c = Config { - dig_local: false, - ..Config::default() - }; - assert_eq!(c.dig_local_addr(), None); - } - - #[test] - fn dig_local_https_addr_is_443_when_enabled() { - // The bare `https://dig.local` surface (#624) shares the dig_local toggle and - // binds 127.0.0.2:443, with the IPv6 loopback sibling on [::1]:443 (§5.2). - let c = Config::default(); - assert_eq!(c.dig_local_https_addr().as_deref(), Some("127.0.0.2:443")); - assert_eq!(c.dig_local_https_addr_v6().as_deref(), Some("[::1]:443")); - } - - #[test] - fn dig_local_https_addr_is_none_when_disabled() { - let c = Config { - dig_local: false, - ..Config::default() - }; - assert_eq!(c.dig_local_https_addr(), None); - assert_eq!(c.dig_local_https_addr_v6(), None); - } - - #[test] - fn parse_dig_local_flag_honours_truthy_and_falsy_values() { - // Falsy turns it off. - for off in ["0", "false", "FALSE", "no", " off ", "Off"] { - assert!( - !parse_dig_local_flag(Some(off.to_string())), - "{off:?} should disable dig.local" - ); - } - // Truthy keeps it on. - for on in ["1", "true", "YES", "on", " On "] { - assert!( - parse_dig_local_flag(Some(on.to_string())), - "{on:?} should enable dig.local" - ); - } - // Unset / blank / unrecognised → default ON (auto-attempt + fallback). - assert!(parse_dig_local_flag(None)); - assert!(parse_dig_local_flag(Some(String::new()))); - assert!(parse_dig_local_flag(Some("maybe".to_string()))); - } - - #[test] - fn parse_live_broadcast_flag_is_off_by_default_and_only_truthy_enables() { - // Truthy enables real mainnet broadcast. - for on in ["1", "true", "YES", "on", " On "] { - assert!( - parse_live_broadcast_flag(Some(on.to_string())), - "{on:?} should enable live broadcast" - ); - } - // Everything else — unset, blank, falsy, or unrecognised — is the money-safe default OFF. - assert!(!parse_live_broadcast_flag(None), "unset ⇒ OFF (money-safe)"); - assert!(!parse_live_broadcast_flag(Some(String::new()))); - assert!(!parse_live_broadcast_flag(Some("maybe".to_string()))); - for off in ["0", "false", "no", "off"] { - assert!(!parse_live_broadcast_flag(Some(off.to_string()))); - } - // And the resolved Config default matches (opt-in only). - assert!(!Config::default().enable_live_broadcast); - } - - #[test] - fn host_allowlist_accepts_the_canonical_local_names() { - // The four canonical names, bare and with a :port suffix, plus a missing - // Host (probes / HTTP/1.0) are all allowed. - for ok in [ - "dig.local", - "dig.local:80", - "localhost", - "localhost:9778", - "127.0.0.1", - "127.0.0.1:9778", - "127.0.0.2", - "127.0.0.2:80", - " dig.local ", - ] { - assert!(host_is_allowed(Some(ok)), "{ok:?} must be allowed"); - } - assert!(host_is_allowed(None), "a missing Host must be allowed"); - assert!(host_is_allowed(Some("")), "an empty Host must be allowed"); - } - - #[test] - fn host_allowlist_accepts_ipv6_loopback_forms() { - // #288: a `localhost` client whose resolver returns `::1` first (Windows - // default) sends a bracketed IPv6-literal Host; a non-browser client may - // send it bare. All must be allowed the same as the IPv4 loopback forms. - for ok in ["::1", "[::1]", "[::1]:9778", "[::1]:80"] { - assert!(host_is_allowed(Some(ok)), "{ok:?} must be allowed"); - } - } - - #[test] - fn host_allowlist_rejects_foreign_hosts() { - // Anything not on the loopback allowlist (the DNS-rebinding vector) is - // rejected even though the listeners are loopback-only. - for bad in [ - "evil.example.com", - "example.com:80", - "dig.local.evil.com", - "169.254.1.1", - "0.0.0.0", - "attacker", - ] { - assert!(!host_is_allowed(Some(bad)), "{bad:?} must be rejected"); - } - } - - #[test] - fn host_allowlist_rejects_non_loopback_ipv6_and_malformed_brackets() { - // A non-loopback IPv6 literal (the rebinding vector, ipv6 flavor) and - // malformed bracket forms must NOT be allowed. - for bad in ["[::2]", "[fe80::1]", "[::1", "[]", "[::1]evil"] { - assert!(!host_is_allowed(Some(bad)), "{bad:?} must be rejected"); - } - } - - // ----- #1662: enforce a loopback-only DIG_NODE_HOST unless DIG_NODE_ALLOW_REMOTE ---- - - #[test] - fn allow_remote_flag_is_off_by_default_and_only_truthy_enables() { - for on in ["1", "true", "YES", "on", " On "] { - assert!( - parse_allow_remote_flag(Some(on.to_string())), - "{on:?} should permit a non-loopback bind" - ); - } - // Unset, blank, falsy, or unrecognised → the security-safe default OFF. - assert!( - !parse_allow_remote_flag(None), - "unset ⇒ OFF (loopback-only)" - ); - assert!(!parse_allow_remote_flag(Some(String::new()))); - assert!(!parse_allow_remote_flag(Some("maybe".to_string()))); - for off in ["0", "false", "no", "off"] { - assert!(!parse_allow_remote_flag(Some(off.to_string()))); - } - // The resolved Config default is loopback-only (opt-in remote only). - assert!(!Config::default().allow_remote); - } - - #[test] - fn is_loopback_addr_covers_v4_v6_and_ipv4_mapped_loopback() { - use std::net::{Ipv4Addr, Ipv6Addr}; - // Native loopback, both families. - assert!(is_loopback_addr(&IpAddr::V4(Ipv4Addr::LOCALHOST))); - assert!(is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); - assert!(is_loopback_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST))); - // #1664b: the IPv4-mapped IPv6 loopback (`::ffff:127.0.0.1`) seen on a `::` - // dual-stack bind is loopback too, even though `Ipv6Addr::is_loopback` misses it. - assert!(is_loopback_addr(&IpAddr::V6( - Ipv4Addr::LOCALHOST.to_ipv6_mapped() - ))); - // Non-loopback of either family is not loopback. - assert!(!is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)))); - assert!(!is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)))); - assert!(!is_loopback_addr(&IpAddr::V6(Ipv6Addr::new( - 0xfe80, 0, 0, 0, 0, 0, 0, 1 - )))); - // An IPv4-mapped NON-loopback stays non-loopback. - assert!(!is_loopback_addr(&IpAddr::V6( - Ipv4Addr::new(10, 0, 0, 5).to_ipv6_mapped() - ))); - } - - #[test] - fn non_loopback_host_override_is_refused_without_the_flag() { - // #1662: binding the local API to a non-loopback address without the explicit - // escape hatch is a hard configuration error (fail-closed at startup). - let host = Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - let refusal = host_override_refusal(host, /* allow_remote */ false); - assert!( - refusal.is_some(), - "0.0.0.0 without DIG_NODE_ALLOW_REMOTE must be refused" - ); - assert!( - refusal.unwrap().contains("DIG_NODE_ALLOW_REMOTE"), - "the message must name the escape hatch" - ); - // A LAN address is refused the same way. - assert!( - host_override_refusal(Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))), false).is_some() - ); - } - - #[test] - fn non_loopback_host_override_is_accepted_with_the_flag() { - // #1662: the explicit DIG_NODE_ALLOW_REMOTE=1 escape hatch permits a - // deliberate non-loopback bind (e.g. a remote-API test rig, #1062). - let host = Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - assert!(host_override_refusal(host, /* allow_remote */ true).is_none()); - } - - #[test] - fn loopback_host_override_is_accepted_without_the_flag() { - use std::net::Ipv6Addr; - // #1662: loopback overrides (IPv4 AND IPv6) never need the flag — they are - // not peer-reachable, so they preserve the loopback-only invariant. - for host in [ - IpAddr::V4(Ipv4Addr::LOCALHOST), - IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), - IpAddr::V6(Ipv6Addr::LOCALHOST), - ] { - assert!( - host_override_refusal(Some(host), false).is_none(), - "{host:?} is loopback and must be accepted without the flag" - ); - } - // And the no-override default (dual-stack loopback) is always accepted. - assert!(host_override_refusal(None, false).is_none()); - } -} +//! Runtime configuration for the dig-node service, resolved from the environment. +//! +//! The service's knobs use the canonical `DIG_NODE_*` env contract: `DIG_NODE_PORT` +//! / `DIG_NODE_HOST` pick the bind address; `DIG_RPC_UPSTREAM` picks the upstream +//! the embedded dig-node read path proxies blind ciphertext/proof requests to on a +//! cache miss. +//! +//! **STABLE ENV CONTRACT — the `DIG_NODE_*` names are the binary's canonical, +//! stable configuration contract.** The dig-installer sets them and apt.dig.net +//! documents them, so renaming them again would break those consumers. +//! +//! The upstream is wired into the read path via its own `DIG_NODE_UPSTREAM` env var +//! (see [`Config::apply_to_env`]) — the dig-node read-path crate reads that name +//! internally, so this service translates its public `DIG_RPC_UPSTREAM` knob into it. +//! +//! ## Shared `.dig` cache (#96) +//! +//! `DIG_NODE_CACHE` points the read path at the on-disk `.dig` cache. This service +//! reads it **explicitly** ([`Config::cache_dir`]) so an operator/installer can aim +//! it at one canonical cache, and re-applies it to the read path's environment in +//! [`Config::apply_to_env`]. +//! +//! **Omitting it is the right default for sharing.** When `DIG_NODE_CACHE` is +//! unset, this service does NOT invent a path — it leaves the read path to resolve its +//! own canonical default (`%LOCALAPPDATA%\DigNode\cache` on Windows, +//! `$HOME/DigNode/cache` on Unix/macOS), which is **byte-identical** to the dir the +//! DIG Browser's in-process node uses. So when both the standalone service and the +//! browser are installed they share ONE cache — a capsule fetched by either is +//! served from disk by the other, with no double-store. dig-node makes that shared +//! dir safe for two processes (atomic content-addressed writes + a cross-process +//! lock; #95/#96 Pass A). Set `DIG_NODE_CACHE` only to move that shared cache +//! somewhere explicit (e.g. a service data dir, or a volume shared between +//! installs) — and set the SAME value for both the service and the browser launch +//! so they keep sharing it. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +/// Default loopback bind port — an UNCOMMON high port, deliberately clear of the +/// collision-prone common-dev ports (80/443/3000/5000/8000/8080/8888/9000) that a +/// dev machine is most likely to already have in use (#132). `9778` is the sibling +/// of the dig-wallet HTTP API's `9777` (wallet on `9777`, node on `9778`) and is the +/// port the digstore-remote §5.3 resolver already expects a local node on +/// (`DEFAULT_LOCAL_NODE_PORT`), so aligning here removes that cross-repo drift. Every +/// consumer of the §5.3 `localhost` tier (the extension's `server.host` default, the +/// installer, the DIG Browser) MUST target `9778` to match. `DIG_NODE_PORT` overrides +/// it. (`dig.local` on `127.0.0.2:80` is unaffected — only this localhost port moves.) +/// +/// Single-sourced from the shared `dig-constants` crate (`DIG_NODE_PORT`) rather than +/// re-declared here, so every §5.3 client→node consumer that also imports the constant agrees +/// with this service byte-for-byte with no copy to drift. +pub const DEFAULT_PORT: u16 = dig_constants::DIG_NODE_PORT; + +/// Default upstream DIG RPC the embedded node proxies to on a local cache miss. +pub const DEFAULT_UPSTREAM: &str = "https://rpc.dig.net"; + +/// The loopback IP the bare-`http://dig.local` listener binds to (#91). The +/// dig-installer writes a hosts entry `127.0.0.2 dig.local`, so binding this IP on +/// the privileged port 80 makes `http://dig.local` (NO port) reach the node. A +/// distinct loopback IP (`.2`, not `.1`) is used so the port-80 bind can never +/// collide with an unrelated `localhost:80` service the user already runs. On macOS +/// the loopback alias must exist first (`sudo ifconfig lo0 alias 127.0.0.2`); the +/// installer/service handles that — see the README. +pub const DIG_LOCAL_IP: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2); + +/// The privileged port the bare-`http://dig.local` listener binds to (#91). Port 80 +/// means the URL carries no `:port`, which is the whole point. Binding it is +/// privileged (root / `CAP_NET_BIND_SERVICE` on Linux; Administrator/LocalSystem on +/// Windows — the installed service runs elevated, so it works there). The bind is +/// BEST-EFFORT: if it fails the localhost listener still serves (see `server`). +pub const DIG_LOCAL_PORT: u16 = 80; + +/// The canonical hostname the bare-`http://dig.local` listener answers to (#91). +/// Matches the dig-installer hosts entry and the extension's resolver base-domain +/// list (`dig.local` / `localhost` / `127.0.0.1`). +pub const DIG_LOCAL_HOST: &str = "dig.local"; + +/// The port the local HTTPS listener binds for `https://dig.local` (#624, #620 epic). +/// Port 443 means the URL carries no `:port`. Binding it is privileged like `:80` +/// (root / `CAP_NET_BIND_SERVICE`; elevated on Windows — the installed service runs +/// elevated). The bind is BEST-EFFORT and additionally GATED on a dig-cert leaf being +/// present: with no CA/leaf yet the node serves plaintext only (see `crate::tls`). +pub const DIG_LOCAL_HTTPS_PORT: u16 = 443; + +/// Resolved dig-node service configuration. +#[derive(Debug, Clone)] +pub struct Config { + /// Explicit `DIG_NODE_HOST` override, or `None` for the default (#288, §5.2). + /// `None` — the default — means "bind BOTH loopback families": `127.0.0.1` + /// (always-on, fatal on failure) AND `[::1]` (best-effort), so `localhost` + /// reaches the node whether the resolver returns the IPv4 or the IPv6 loopback + /// first (Windows resolves `localhost` to `::1` first by default, which made the + /// node appear offline to an IPv6-first client before this). `Some(ip)` — an + /// explicit override — REPLACES the default dual bind with exactly that one + /// address; it does not add to it. See [`Config::bind_addr`] (the primary/ + /// always-on address) and [`Config::bind_addr_v6`] (the additional IPv6 + /// loopback address, when applicable). + pub host: Option, + /// Bind port. + pub port: u16, + /// Upstream DIG RPC base URL the embedded dig-node proxies to on a miss. + pub upstream: String, + /// Explicit on-disk cache dir for dig-node's `.dig` modules, from + /// `DIG_NODE_CACHE`. `None` (the default) means "use dig-node's shared + /// canonical default" — the SAME dir the DIG Browser's in-process node uses, + /// so the two share ONE cache (see the module-level "Shared `.dig` cache" + /// note). `Some(path)` moves that shared cache to an explicit location. + pub cache_dir: Option, + /// Whether to ALSO open the bare-`http://dig.local` loopback listener + /// (`127.0.0.2:80`) beside the always-on `localhost:` one (#91). From + /// `DIG_NODE_DIGLOCAL` (`1`/`true`/`yes`/`on` ⇒ enabled, `0`/`false`/… ⇒ + /// disabled); **default `true`** — auto-attempt with graceful fallback. The + /// attempt is BEST-EFFORT: if the privileged `:80` bind fails (no privilege, + /// port in use, or — on macOS — the `127.0.0.2` loopback alias is missing) the + /// node logs a structured warning and serves localhost-only, never aborting. + /// Set `DIG_NODE_DIGLOCAL=0` to skip the attempt entirely. + pub dig_local: bool, + /// Whether the node-custodied wallet broadcasts spends for REAL on mainnet (§18.12, #428). + /// From `DIG_WALLET_ENABLE_LIVE_BROADCAST` (`1`/`true`/`yes`/`on` ⇒ enabled); **default + /// `false`** — the money-safe default: no broadcaster is attached and NO $DIG moves (a tip / + /// sign-on-behalf / send cleanly reports unavailable). When enabled, the served wallet attaches + /// a real `chia_query` broadcaster + confirmer + lineage so node-custodied spends execute + + /// confirm on mainnet. Enabling it means REAL $DIG movement — opt-in only. + pub enable_live_broadcast: bool, + /// Whether a NON-loopback `DIG_NODE_HOST` override is permitted (#1662). From + /// `DIG_NODE_ALLOW_REMOTE` (`1`/`true`/`yes`/`on` ⇒ permitted); **default `false`** + /// — the security-safe default. When `false`, a non-loopback `host` is refused at + /// startup ([`host_override_refusal`], enforced in `server::serve_with_shutdown`) so + /// the local RPC/content API is never silently exposed to the network. Loopback + /// overrides and the no-override dual-stack default never need this flag. + pub allow_remote: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { + host: None, + port: DEFAULT_PORT, + upstream: DEFAULT_UPSTREAM.to_string(), + cache_dir: None, + // Auto-attempt the bare-dig.local listener by default (graceful + // fallback if the privileged bind fails) — see the field doc + #91. + dig_local: true, + // Money-safe default: live broadcast is OFF unless explicitly enabled. + enable_live_broadcast: false, + // Security-safe default: a non-loopback bind is opt-in only (#1662). + allow_remote: false, + } + } +} + +impl Config { + /// Resolve the config from the process environment, falling back to defaults. + /// Mirrors the stable `DIG_NODE_PORT` / `DIG_NODE_HOST` / `DIG_RPC_UPSTREAM` env + /// contract (see the module-level "STABLE ENV CONTRACT" note). + pub fn from_env() -> Self { + let port = std::env::var("DIG_NODE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|p| *p != 0) + .unwrap_or(DEFAULT_PORT); + + let host = parse_host_override(std::env::var("DIG_NODE_HOST").ok()); + + // Upstream precedence: explicit DIG_RPC_UPSTREAM env > the persisted + // override (set via the control plane's `control.config.setUpstream`, + // stored in dig-node's config.json) > the default. The env var still wins + // so a deploy/CI override is never silently overridden by a saved setting; + // the persisted value is the "I set this in the controller UI" choice that + // takes effect on the next start (the running node captured its upstream at + // construction — see `control.config.setUpstream` → `requires_restart`). + let upstream = std::env::var("DIG_RPC_UPSTREAM") + .ok() + .map(|s| normalize_upstream(&s)) + .filter(|s| !s.is_empty()) + .or_else(|| crate::control::read_upstream_override().map(|s| normalize_upstream(&s))) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_UPSTREAM.to_string()); + + // DIG_NODE_CACHE is read with the read path's OWN env var name (not a + // service-specific alias) so a value the operator sets reaches the node + // directly and this service just makes honouring it explicit. A + // blank/whitespace value is treated as unset → shared default (see + // resolve_cache_dir). + let cache_dir = resolve_cache_dir(std::env::var("DIG_NODE_CACHE").ok()); + + // The bare-dig.local listener is on by default (auto-attempt + graceful + // fallback); DIG_NODE_DIGLOCAL=0/false/no/off turns it off entirely. + let dig_local = parse_dig_local_flag(std::env::var("DIG_NODE_DIGLOCAL").ok()); + + // Live mainnet broadcast is OFF unless explicitly enabled (money-safe default). + let enable_live_broadcast = + parse_live_broadcast_flag(std::env::var("DIG_WALLET_ENABLE_LIVE_BROADCAST").ok()); + + // A non-loopback DIG_NODE_HOST is opt-in only (#1662); enforcement happens at + // the bind site (server::serve_with_shutdown) so `status`/`install` — which + // never bind — still resolve the config the operator set. + let allow_remote = parse_allow_remote_flag(std::env::var("DIG_NODE_ALLOW_REMOTE").ok()); + + Config { + host, + port, + upstream, + cache_dir, + dig_local, + enable_live_broadcast, + allow_remote, + } + } + + /// Apply this config to the environment dig-node reads at `Node::from_env()`: + /// + /// * `DIG_NODE_UPSTREAM` ← this service's public `DIG_RPC_UPSTREAM` knob. + /// (dig-node deliberately uses a distinct name from the browser's + /// `DIG_RPC_ENDPOINT`, which points a client AT the node; reusing that would + /// make the node proxy to itself.) + /// * `DIG_NODE_CACHE` ← the explicit cache dir, **only when one was set**. When + /// it was omitted we leave the env untouched so dig-node resolves its shared + /// canonical default (the dir the DIG Browser's in-process node also uses) — + /// writing an empty value here would instead point the node at a bogus path + /// and break cache sharing. See the module-level "Shared `.dig` cache" note. + /// + /// Called before constructing the node so both knobs are honoured. + pub fn apply_to_env(&self) { + std::env::set_var("DIG_NODE_UPSTREAM", &self.upstream); + if let Some(dir) = cache_dir_env_value(self.cache_dir.as_deref()) { + std::env::set_var("DIG_NODE_CACHE", dir); + } + } + + /// The `host:port` socket string for the always-on localhost listener + /// (binding / logging): the explicit `DIG_NODE_HOST` override, or the default + /// `127.0.0.1` when unset. A bind failure on THIS address is fatal (see + /// `server::serve_with_shutdown`) — every consumer (CLI `status`/`pair`, the + /// installed-service summary, `/health`'s `addr` field) treats it as THE + /// address, so its shape never changes based on the dual-stack default below. + /// + /// Rendered through [`SocketAddr`] rather than by text (#1682/#1593): an IPv6 literal's + /// authority REQUIRES brackets, so `format!("{host}:{port}")` yielded the unbindable + /// `::1:9778` for a v6 `DIG_NODE_HOST` — and this bind failure is fatal, so that one + /// missing pair of brackets bricked the node. + pub fn bind_addr(&self) -> String { + SocketAddr::new( + self.host.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)), + self.port, + ) + .to_string() + } + + /// The IPv6 loopback bind address (`[::1]:`) to open BESIDE + /// [`Config::bind_addr`] (#288, §5.2 dual-stack loopback): `Some` when no + /// explicit `DIG_NODE_HOST` override is set (the default — bind BOTH loopback + /// families, since some resolvers — Windows' `localhost` by default — return + /// `::1` before `127.0.0.1`); `None` when an explicit host override is set (it + /// REPLACES the default dual bind with exactly that one address, rather than + /// adding to it). This listener is BEST-EFFORT at bind time (see `serve`): an + /// IPv6-loopback-unavailable system falls back to IPv4-only, mirroring the + /// existing [`Config::dig_local_addr`] best-effort pattern. + pub fn bind_addr_v6(&self) -> Option { + self.host + .is_none() + .then(|| SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.port).to_string()) + } + + /// The `host:port` socket string for the BEST-EFFORT bare-`http://dig.local` + /// listener (`127.0.0.2:80`), or `None` when `dig_local` is disabled (#91). + /// `serve` tries to bind this in ADDITION to [`bind_addr`]; a failure is + /// logged and ignored (localhost keeps serving). + pub fn dig_local_addr(&self) -> Option { + self.dig_local + .then(|| format!("{DIG_LOCAL_IP}:{DIG_LOCAL_PORT}")) + } + + /// The `host:port` for the BEST-EFFORT local HTTPS listener serving + /// `https://dig.local` (`127.0.0.2:443`, #624), or `None` when `dig_local` is + /// disabled. Shares the `dig_local` toggle with the plaintext `:80` listener — + /// both are the "bare dig.local" surface, one plaintext, one TLS. The TLS listener + /// is additionally gated on a dig-cert leaf being present (`crate::tls`); with no + /// leaf yet only plaintext serves. + pub fn dig_local_https_addr(&self) -> Option { + self.dig_local + .then(|| format!("{DIG_LOCAL_IP}:{DIG_LOCAL_HTTPS_PORT}")) + } + + /// The IPv6-loopback HTTPS bind (`[::1]:443`) to open BESIDE + /// [`Config::dig_local_https_addr`] (§5.2 IPv6-first loopback), or `None` when + /// `dig_local` is disabled. `https://dig.local` resolves to the IPv4 alias + /// `127.0.0.2` via the installer hosts entry, but the leaf's SAN also covers `::1`, + /// so an IPv6 loopback client (e.g. `https://localhost` where `localhost` resolves + /// to `::1` first) reaches the identical surface. BEST-EFFORT: a bind failure is + /// logged and the node continues on the IPv4 listener (see `server`). + pub fn dig_local_https_addr_v6(&self) -> Option { + self.dig_local + .then(|| format!("[::1]:{DIG_LOCAL_HTTPS_PORT}")) + } +} + +/// Parse the `DIG_NODE_DIGLOCAL` toggle. Truthy (`1`/`true`/`yes`/`on`) ⇒ enable +/// the bare-dig.local listener; falsy (`0`/`false`/`no`/`off`) ⇒ disable; **unset +/// or unrecognised ⇒ the default `true`** (auto-attempt with graceful fallback). +/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable. +pub fn parse_dig_local_flag(raw: Option) -> bool { + match raw.as_deref().map(str::trim).map(str::to_ascii_lowercase) { + Some(ref v) if matches!(v.as_str(), "0" | "false" | "no" | "off") => false, + Some(ref v) if matches!(v.as_str(), "1" | "true" | "yes" | "on") => true, + // Unset, blank, or anything unrecognised → the default-on behaviour. + _ => true, + } +} + +/// Parse the `DIG_WALLET_ENABLE_LIVE_BROADCAST` toggle (§18.12, #428). Truthy +/// (`1`/`true`/`yes`/`on`) ⇒ enable REAL mainnet broadcast; **anything else — including unset, +/// blank, or unrecognised — ⇒ the money-safe default `false`** (no $DIG moves). This is the +/// OPPOSITE default to `parse_dig_local_flag`: money movement is opt-in, never on by accident. +/// Case/whitespace-insensitive. PURE so the toggle policy is unit-testable without process env. +pub fn parse_live_broadcast_flag(raw: Option) -> bool { + matches!( + raw.as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +/// Parse the `DIG_NODE_HOST` override (#288): `Some(ip)` when the raw value is a +/// valid IP literal, `None` when unset/blank/unparsable. `None` is the DEFAULT and +/// carries meaning — it is not merely "no value" — see [`Config::host`] and +/// [`Config::bind_addr_v6`]: it means "bind BOTH loopback families", not merely +/// "fall back to 127.0.0.1". PURE so the override-vs-default policy is +/// unit-testable without touching process env. +pub fn parse_host_override(raw: Option) -> Option { + raw.as_deref().and_then(|s| s.trim().parse::().ok()) +} + +/// Parse the `DIG_NODE_ALLOW_REMOTE` escape hatch (#1662): truthy +/// (`1`/`true`/`yes`/`on`) ⇒ permit a NON-loopback `DIG_NODE_HOST`; **anything else +/// — including unset, blank, or unrecognised — ⇒ the security-safe default `false`** +/// (loopback-only). Same opt-in-only shape as [`parse_live_broadcast_flag`]: exposing +/// the local RPC/content API to the network is a deliberate act, never on by accident. +/// Case/whitespace-insensitive. PURE so the policy is unit-testable without process env. +pub fn parse_allow_remote_flag(raw: Option) -> bool { + matches!( + raw.as_deref() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} + +/// Whether `ip` is a loopback address — the SHARED loopback predicate for the whole +/// service. Beyond the stdlib [`IpAddr::is_loopback`] it also treats an IPv4-MAPPED +/// IPv6 loopback (`::ffff:127.0.0.1`) as loopback: on a `::` dual-stack bind the OS +/// reports an IPv4 loopback client in that mapped form, which `Ipv6Addr::is_loopback` +/// (true only for `::1`) would otherwise miss (#1664b). Shared so the origin classifier +/// (`server::read_origin_for`) and the `DIG_NODE_HOST` enforcement below apply ONE rule, +/// and so #1646 can reuse it rather than re-deriving the mapped-loopback case. +pub fn is_loopback_addr(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_loopback(), + // `::1` directly, or an IPv4-mapped loopback like `::ffff:127.0.0.1`. + IpAddr::V6(v6) => { + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + } +} + +/// Enforce the loopback-only invariant on a `DIG_NODE_HOST` override (#1662): +/// returns `Some(message)` when the override MUST be refused — a non-loopback bind +/// address without the explicit `DIG_NODE_ALLOW_REMOTE=1` escape hatch — and `None` +/// when the bind is permitted (no override, a loopback override, or `allow_remote`). +/// +/// This is what makes the ~25 "loopback-only / never peer-reachable" invariants across +/// the service TRUE rather than merely asserted: the local RPC/content API is either +/// bound to loopback, or the operator has DELIBERATELY opted into a remote bind. The +/// caller (`server::serve_with_shutdown`) fails CLOSED on `Some` — a bad override is a +/// hard startup error, never a silent LAN exposure. PURE so the policy is unit-testable. +/// +/// This governs ONLY the local RPC/content bind ([`Config::bind_addr`]); the peer P2P +/// wire (mTLS `:9444`, in dig-node-core) and the loopback wallet mTLS `:9257` listener +/// bind independently, so enforcing loopback here never affects peer connectivity. +pub fn host_override_refusal(host: Option, allow_remote: bool) -> Option { + match host { + Some(ip) if !is_loopback_addr(&ip) && !allow_remote => Some(format!( + "refusing to bind the local API to a non-loopback address ({ip}); this exposes the \ + node's RPC/content API to the network. Set DIG_NODE_ALLOW_REMOTE=1 to override." + )), + _ => None, + } +} + +/// Whether a request `Host` header is allowed (#91, #288). The node is +/// loopback-only and answers to the canonical local names — bare `dig.local`, +/// `localhost`, the loopback IPs `127.0.0.1`/`127.0.0.2`, and the IPv6 loopback +/// `::1` (bracketed `[::1]`/`[::1]:` per RFC 7230's mandatory bracketing for +/// an IPv6-literal Host, or bare `::1` for a non-browser client that omits them) — +/// with or without a `:port` suffix; a missing Host is allowed (HTTP/1.0 / health +/// probes). Any OTHER host (e.g. a public domain pointed at the machine, the +/// classic DNS-rebinding vector) is rejected, so even though the listeners are +/// loopback-only (enforced — a non-loopback `DIG_NODE_HOST` is refused unless +/// `DIG_NODE_ALLOW_REMOTE=1`, [`host_override_refusal`], #1662) the node never serves +/// a foreign-named request. PURE: takes the +/// raw header value, returns the decision. +pub fn host_is_allowed(host_header: Option<&str>) -> bool { + // No Host header at all (HTTP/1.0, some probes) → allow: it cannot be a + // rebinding attack (there is no attacker-chosen name) and the loopback bind + // already constrains reachability. + let Some(raw) = host_header else { + return true; + }; + let host = raw.trim(); + if host.is_empty() { + return true; + } + + // IPv6-literal forms (#288): `[::1]` / `[::1]:` (bracketed, the ONLY + // legal way to carry an IPv6 literal in a Host header per RFC 7230 — the + // brackets disambiguate the address's own colons from the port separator), or + // bare `::1` for a non-browser client that skips the brackets. Checked BEFORE + // the generic `:port`-strip below, because naively splitting an IPv6 literal + // on its LAST `:` would still work for these two specific shapes, but bracket + // handling makes the intent explicit and rejects malformed bracket forms. + if let Some(inner) = host.strip_prefix('[') { + return match inner.strip_suffix(']') { + Some(addr) => addr == "::1", + None => inner + .rsplit_once("]:") + .is_some_and(|(addr, _port)| addr == "::1"), + }; + } + if host == "::1" { + return true; + } + + // Strip a trailing `:port` (IPv4 / hostname forms only). `dig.local:80`, + // `localhost:9778`, `127.0.0.1` all reduce to their hostname for the + // allowlist check. + let name = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host); + matches!( + name, + DIG_LOCAL_HOST | "localhost" | "127.0.0.1" | "127.0.0.2" + ) +} + +/// Normalise an upstream URL: trim, strip trailing slashes, and default a bare +/// host to `https://`. Pure so the precedence/normalisation is unit-testable. +pub fn normalize_upstream(raw: &str) -> String { + let t = raw.trim().trim_end_matches('/'); + if t.is_empty() { + return String::new(); + } + if t.starts_with("http://") || t.starts_with("https://") { + t.to_string() + } else { + format!("https://{t}") + } +} + +/// Resolve the explicit cache dir from a raw `DIG_NODE_CACHE` value: a non-blank +/// value is honoured (trimmed); a missing or blank/whitespace value is `None`, +/// meaning "use dig-node's shared canonical default". PURE so the +/// honour-vs-default policy is unit-testable without touching process env. +pub fn resolve_cache_dir(raw: Option) -> Option { + cache_dir_env_value(raw.as_deref()) +} + +/// The value to write to `DIG_NODE_CACHE`, given the config's `cache_dir`: a +/// trimmed non-empty path, or `None` (don't set the env var → shared default). +/// PURE — the single place the "only set when explicit" rule lives, shared by +/// [`Config::from_env`] and [`Config::apply_to_env`]. +pub fn cache_dir_env_value(cache_dir: Option<&str>) -> Option { + cache_dir + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_upstream_trims_and_strips_trailing_slash() { + assert_eq!( + normalize_upstream("https://rpc.dig.net/"), + "https://rpc.dig.net" + ); + assert_eq!( + normalize_upstream(" https://rpc.dig.net/// "), + "https://rpc.dig.net" + ); + } + + #[test] + fn normalize_upstream_defaults_scheme_to_https() { + assert_eq!(normalize_upstream("rpc.dig.net"), "https://rpc.dig.net"); + assert_eq!( + normalize_upstream("http://127.0.0.1:9000"), + "http://127.0.0.1:9000" + ); + } + + #[test] + fn normalize_upstream_empty_stays_empty() { + assert_eq!(normalize_upstream(""), ""); + assert_eq!(normalize_upstream(" "), ""); + assert_eq!(normalize_upstream("///"), ""); + } + + #[test] + fn default_config_is_loopback_9778() { + let c = Config::default(); + assert_eq!(c.port, DEFAULT_PORT); + // #132: the default localhost port is the uncommon high port 9778 (the + // dig-wallet 9777 sibling), NOT the collision-prone 8080. + assert_eq!(DEFAULT_PORT, 9778); + assert_eq!(c.bind_addr(), "127.0.0.1:9778"); + assert_eq!(c.upstream, DEFAULT_UPSTREAM); + } + + // ----- #288: dual-stack loopback bind (127.0.0.1 AND [::1]) ---------------- + + #[test] + fn default_config_binds_both_loopback_families() { + // No DIG_NODE_HOST override → the default is dual-stack: the always-on + // IPv4 loopback AND the additional (best-effort) IPv6 loopback, same port. + let c = Config::default(); + assert_eq!(c.host, None); + assert_eq!(c.bind_addr(), "127.0.0.1:9778"); + assert_eq!(c.bind_addr_v6().as_deref(), Some("[::1]:9778")); + } + + #[test] + fn explicit_host_override_replaces_rather_than_extends_the_default_bind() { + // An explicit DIG_NODE_HOST fully replaces the dual-stack default with + // exactly that one address — it does not ALSO open [::1]. + let c = Config { + host: Some(std::net::Ipv4Addr::new(10, 0, 0, 5).into()), + ..Config::default() + }; + assert_eq!(c.bind_addr(), "10.0.0.5:9778"); + assert_eq!(c.bind_addr_v6(), None); + } + + // ----- #1682: an IPv6 `DIG_NODE_HOST` must not brick the node ------------- + + /// **Proves:** every address [`Config::bind_addr`] renders PARSES as a socket address, + /// for an IPv6 host as much as an IPv4 one. + /// + /// **Catches:** the text-concatenated authority (#1593) that produced the unbracketed + /// `::1:9778`. That string binds nowhere, and the primary bind failure is FATAL, so an + /// operator who set a v6 host got a node that could not start at all. + /// + /// The fixture varies ONE thing — the host family — and keeps the IPv4 case beside it as + /// the honest control, because a v6-only fixture cannot distinguish "renders v6 right" + /// from "renders nothing right". + #[test] + fn bind_addr_is_parseable_for_every_host_family() { + use std::net::{Ipv6Addr, SocketAddr}; + + // A compressed loopback and a full global literal: `::1` alone would leave a + // special-case for the shortest possible v6 spelling indistinguishable from a + // general fix. + let cases: [(IpAddr, &str); 3] = [ + (IpAddr::V4(Ipv4Addr::LOCALHOST), "127.0.0.1:9778"), + (IpAddr::V6(Ipv6Addr::LOCALHOST), "[::1]:9778"), + ( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x1)), + "[2001:db8::1]:9778", + ), + ]; + for (host, expected) in cases { + let c = Config { + host: Some(host), + ..Config::default() + }; + assert_eq!(c.bind_addr(), expected, "rendering for {host}"); + let parsed: SocketAddr = c + .bind_addr() + .parse() + .unwrap_or_else(|e| panic!("bind_addr for {host} must parse ({e})")); + assert_eq!(parsed.ip(), host); + assert_eq!(parsed.port(), DEFAULT_PORT); + } + } + + #[test] + fn parse_host_override_parses_a_valid_ip_literal() { + assert_eq!( + parse_host_override(Some("127.0.0.1".to_string())), + Some(IpAddr::V4(Ipv4Addr::LOCALHOST)) + ); + assert_eq!( + parse_host_override(Some(" ::1 ".to_string())), + Some(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) + ); + } + + #[test] + fn parse_host_override_is_none_when_unset_blank_or_unparsable() { + assert_eq!(parse_host_override(None), None); + assert_eq!(parse_host_override(Some(String::new())), None); + assert_eq!(parse_host_override(Some(" ".to_string())), None); + assert_eq!(parse_host_override(Some("not-an-ip".to_string())), None); + } + + #[test] + fn default_config_has_no_explicit_cache_dir() { + // Omitting DIG_NODE_CACHE means "use dig-node's shared canonical default" + // (the SAME dir the DIG Browser's in-process node uses) — so the resolved + // config carries None, never a hard-coded path that would diverge from it. + assert_eq!(Config::default().cache_dir, None); + } + + #[test] + fn apply_to_env_does_not_set_cache_when_unset() { + // When the operator did NOT set DIG_NODE_CACHE, this service must NOT write + // it — leaving dig-node free to resolve its shared canonical default. (We + // assert via the pure helper so the test never mutates process-global env, + // which would race the concurrent server tests.) + let none: Option<&str> = None; + assert_eq!(cache_dir_env_value(none), None); + assert_eq!(cache_dir_env_value(Some(" ")), None); + } + + #[test] + fn apply_to_env_sets_explicit_cache_dir() { + // An explicit DIG_NODE_CACHE is honoured: it is the value this service + // re-applies to the read path's env (so a service install records it, and the + // node + this service's /health agree on the same shared dir). + assert_eq!( + cache_dir_env_value(Some("D:/dig/shared-cache")), + Some("D:/dig/shared-cache".to_string()) + ); + } + + #[test] + fn from_env_reads_explicit_cache_dir() { + // Drive the same resolution the real Config::from_env runs, but on an + // explicit value (pure helper) so we don't touch process env. + assert_eq!( + resolve_cache_dir(Some("/var/lib/dignode/cache".to_string())), + Some("/var/lib/dignode/cache".to_string()) + ); + assert_eq!(resolve_cache_dir(Some(" ".to_string())), None); + assert_eq!(resolve_cache_dir(None), None); + } + + // ----- #91: the dig.local listener flag + addressing ----------------------- + + #[test] + fn dig_local_is_on_by_default() { + // Auto-attempt with graceful fallback: a default Config wants the + // bare-dig.local listener, addressed 127.0.0.2:80. + let c = Config::default(); + assert!(c.dig_local); + assert_eq!(c.dig_local_addr().as_deref(), Some("127.0.0.2:80")); + } + + #[test] + fn dig_local_addr_is_none_when_disabled() { + let c = Config { + dig_local: false, + ..Config::default() + }; + assert_eq!(c.dig_local_addr(), None); + } + + #[test] + fn dig_local_https_addr_is_443_when_enabled() { + // The bare `https://dig.local` surface (#624) shares the dig_local toggle and + // binds 127.0.0.2:443, with the IPv6 loopback sibling on [::1]:443 (§5.2). + let c = Config::default(); + assert_eq!(c.dig_local_https_addr().as_deref(), Some("127.0.0.2:443")); + assert_eq!(c.dig_local_https_addr_v6().as_deref(), Some("[::1]:443")); + } + + #[test] + fn dig_local_https_addr_is_none_when_disabled() { + let c = Config { + dig_local: false, + ..Config::default() + }; + assert_eq!(c.dig_local_https_addr(), None); + assert_eq!(c.dig_local_https_addr_v6(), None); + } + + #[test] + fn parse_dig_local_flag_honours_truthy_and_falsy_values() { + // Falsy turns it off. + for off in ["0", "false", "FALSE", "no", " off ", "Off"] { + assert!( + !parse_dig_local_flag(Some(off.to_string())), + "{off:?} should disable dig.local" + ); + } + // Truthy keeps it on. + for on in ["1", "true", "YES", "on", " On "] { + assert!( + parse_dig_local_flag(Some(on.to_string())), + "{on:?} should enable dig.local" + ); + } + // Unset / blank / unrecognised → default ON (auto-attempt + fallback). + assert!(parse_dig_local_flag(None)); + assert!(parse_dig_local_flag(Some(String::new()))); + assert!(parse_dig_local_flag(Some("maybe".to_string()))); + } + + #[test] + fn parse_live_broadcast_flag_is_off_by_default_and_only_truthy_enables() { + // Truthy enables real mainnet broadcast. + for on in ["1", "true", "YES", "on", " On "] { + assert!( + parse_live_broadcast_flag(Some(on.to_string())), + "{on:?} should enable live broadcast" + ); + } + // Everything else — unset, blank, falsy, or unrecognised — is the money-safe default OFF. + assert!(!parse_live_broadcast_flag(None), "unset ⇒ OFF (money-safe)"); + assert!(!parse_live_broadcast_flag(Some(String::new()))); + assert!(!parse_live_broadcast_flag(Some("maybe".to_string()))); + for off in ["0", "false", "no", "off"] { + assert!(!parse_live_broadcast_flag(Some(off.to_string()))); + } + // And the resolved Config default matches (opt-in only). + assert!(!Config::default().enable_live_broadcast); + } + + #[test] + fn host_allowlist_accepts_the_canonical_local_names() { + // The four canonical names, bare and with a :port suffix, plus a missing + // Host (probes / HTTP/1.0) are all allowed. + for ok in [ + "dig.local", + "dig.local:80", + "localhost", + "localhost:9778", + "127.0.0.1", + "127.0.0.1:9778", + "127.0.0.2", + "127.0.0.2:80", + " dig.local ", + ] { + assert!(host_is_allowed(Some(ok)), "{ok:?} must be allowed"); + } + assert!(host_is_allowed(None), "a missing Host must be allowed"); + assert!(host_is_allowed(Some("")), "an empty Host must be allowed"); + } + + #[test] + fn host_allowlist_accepts_ipv6_loopback_forms() { + // #288: a `localhost` client whose resolver returns `::1` first (Windows + // default) sends a bracketed IPv6-literal Host; a non-browser client may + // send it bare. All must be allowed the same as the IPv4 loopback forms. + for ok in ["::1", "[::1]", "[::1]:9778", "[::1]:80"] { + assert!(host_is_allowed(Some(ok)), "{ok:?} must be allowed"); + } + } + + #[test] + fn host_allowlist_rejects_foreign_hosts() { + // Anything not on the loopback allowlist (the DNS-rebinding vector) is + // rejected even though the listeners are loopback-only. + for bad in [ + "evil.example.com", + "example.com:80", + "dig.local.evil.com", + "169.254.1.1", + "0.0.0.0", + "attacker", + ] { + assert!(!host_is_allowed(Some(bad)), "{bad:?} must be rejected"); + } + } + + #[test] + fn host_allowlist_rejects_non_loopback_ipv6_and_malformed_brackets() { + // A non-loopback IPv6 literal (the rebinding vector, ipv6 flavor) and + // malformed bracket forms must NOT be allowed. + for bad in ["[::2]", "[fe80::1]", "[::1", "[]", "[::1]evil"] { + assert!(!host_is_allowed(Some(bad)), "{bad:?} must be rejected"); + } + } + + // ----- #1662: enforce a loopback-only DIG_NODE_HOST unless DIG_NODE_ALLOW_REMOTE ---- + + #[test] + fn allow_remote_flag_is_off_by_default_and_only_truthy_enables() { + for on in ["1", "true", "YES", "on", " On "] { + assert!( + parse_allow_remote_flag(Some(on.to_string())), + "{on:?} should permit a non-loopback bind" + ); + } + // Unset, blank, falsy, or unrecognised → the security-safe default OFF. + assert!( + !parse_allow_remote_flag(None), + "unset ⇒ OFF (loopback-only)" + ); + assert!(!parse_allow_remote_flag(Some(String::new()))); + assert!(!parse_allow_remote_flag(Some("maybe".to_string()))); + for off in ["0", "false", "no", "off"] { + assert!(!parse_allow_remote_flag(Some(off.to_string()))); + } + // The resolved Config default is loopback-only (opt-in remote only). + assert!(!Config::default().allow_remote); + } + + #[test] + fn is_loopback_addr_covers_v4_v6_and_ipv4_mapped_loopback() { + use std::net::{Ipv4Addr, Ipv6Addr}; + // Native loopback, both families. + assert!(is_loopback_addr(&IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); + assert!(is_loopback_addr(&IpAddr::V6(Ipv6Addr::LOCALHOST))); + // #1664b: the IPv4-mapped IPv6 loopback (`::ffff:127.0.0.1`) seen on a `::` + // dual-stack bind is loopback too, even though `Ipv6Addr::is_loopback` misses it. + assert!(is_loopback_addr(&IpAddr::V6( + Ipv4Addr::LOCALHOST.to_ipv6_mapped() + ))); + // Non-loopback of either family is not loopback. + assert!(!is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)))); + assert!(!is_loopback_addr(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)))); + assert!(!is_loopback_addr(&IpAddr::V6(Ipv6Addr::new( + 0xfe80, 0, 0, 0, 0, 0, 0, 1 + )))); + // An IPv4-mapped NON-loopback stays non-loopback. + assert!(!is_loopback_addr(&IpAddr::V6( + Ipv4Addr::new(10, 0, 0, 5).to_ipv6_mapped() + ))); + } + + #[test] + fn non_loopback_host_override_is_refused_without_the_flag() { + // #1662: binding the local API to a non-loopback address without the explicit + // escape hatch is a hard configuration error (fail-closed at startup). + let host = Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + let refusal = host_override_refusal(host, /* allow_remote */ false); + assert!( + refusal.is_some(), + "0.0.0.0 without DIG_NODE_ALLOW_REMOTE must be refused" + ); + assert!( + refusal.unwrap().contains("DIG_NODE_ALLOW_REMOTE"), + "the message must name the escape hatch" + ); + // A LAN address is refused the same way. + assert!( + host_override_refusal(Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5))), false).is_some() + ); + } + + #[test] + fn non_loopback_host_override_is_accepted_with_the_flag() { + // #1662: the explicit DIG_NODE_ALLOW_REMOTE=1 escape hatch permits a + // deliberate non-loopback bind (e.g. a remote-API test rig, #1062). + let host = Some(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert!(host_override_refusal(host, /* allow_remote */ true).is_none()); + } + + #[test] + fn loopback_host_override_is_accepted_without_the_flag() { + use std::net::Ipv6Addr; + // #1662: loopback overrides (IPv4 AND IPv6) never need the flag — they are + // not peer-reachable, so they preserve the loopback-only invariant. + for host in [ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + assert!( + host_override_refusal(Some(host), false).is_none(), + "{host:?} is loopback and must be accepted without the flag" + ); + } + // And the no-override default (dual-stack loopback) is always accepted. + assert!(host_override_refusal(None, false).is_none()); + } +} diff --git a/crates/dig-node-service/src/open.rs b/crates/dig-node-service/src/open.rs index e6c8b5a..d58e8cb 100644 --- a/crates/dig-node-service/src/open.rs +++ b/crates/dig-node-service/src/open.rs @@ -61,7 +61,7 @@ //! "open" facility as a SINGLE non-shell argv entry. use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; +use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::{Duration, Instant}; use dig_urn_resolver::images::{self, ErrorImage}; @@ -69,7 +69,7 @@ use dig_urn_resolver::{ResolveError, ResolveOutcome, ResolvedData}; use serde_json::json; use crate::cli::Outcome; -use crate::config::Config; +use crate::config::{Config, DIG_LOCAL_HOST}; /// Shell metacharacters (and quoting/grouping characters) rejected anywhere in the link. The /// launch path never uses a shell, so this is defense-in-depth against the untrusted OS argument @@ -455,19 +455,19 @@ fn candidate_urls(config: &Config, link: &DigLink) -> Vec { if link.root.is_none() { urls.push(format!("http://{}.dig/{}", link.store_id, path)); } - urls.push(serve_url("dig.local", None, &store_ref, path)); - let host = browser_host(config); - urls.push(serve_url(&host, Some(config.port), &store_ref, path)); + urls.push(serve_url(DIG_LOCAL_HOST, &store_ref, path)); + urls.push(serve_url(&browser_authority(config), &store_ref, path)); urls } -/// Build a node `/s//` serve URL for `host` (with an optional explicit `port`). A -/// store-root (empty path) keeps a trailing slash (the #289 route contract). -fn serve_url(host: &str, port: Option, store_ref: &str, path: &str) -> String { - let authority = match port { - Some(p) => format!("{host}:{p}"), - None => host.to_string(), - }; +/// Build a node `/s//` serve URL for an already-rendered `authority` +/// (`host` or `host:port`). A store-root (empty path) keeps a trailing slash (the #289 +/// route contract). +/// +/// The authority arrives pre-rendered rather than as `(host, port)` so that the one place +/// that has to reconcile a port with a possibly-IPv6 host is [`browser_authority`], where +/// it is done through [`SocketAddr`] (#1682/#1593) instead of by text. +fn serve_url(authority: &str, store_ref: &str, path: &str) -> String { if path.is_empty() { format!("http://{authority}/s/{store_ref}/") } else { @@ -475,13 +475,17 @@ fn serve_url(host: &str, port: Option, store_ref: &str, path: &str) -> Stri } } -/// The host for the `localhost` tier URL: the operator's explicit `DIG_NODE_HOST` when set -/// (bracketed for IPv6), else `localhost` — friendlier than `127.0.0.1` and equally reachable. -fn browser_host(config: &Config) -> String { +/// The authority for the `localhost` tier URL: the operator's explicit `DIG_NODE_HOST` with +/// the node's port, else the `localhost` NAME with its port — friendlier than `127.0.0.1` +/// and equally reachable. +/// +/// An explicit host is rendered through [`SocketAddr`], which brackets an IPv6 literal for +/// free; the name case interpolates only the port, so no host is ever concatenated onto a +/// colon by hand (#1682/#1593). +fn browser_authority(config: &Config) -> String { match config.host { - Some(ip) if ip.is_ipv6() => format!("[{ip}]"), - Some(ip) => ip.to_string(), - None => "localhost".to_string(), + Some(ip) => SocketAddr::new(ip, config.port).to_string(), + None => format!("localhost:{}", config.port), } } @@ -607,6 +611,48 @@ mod tests { "b".repeat(64) } + /// **Proves:** every `localhost`-tier candidate URL carries a PARSEABLE authority, + /// whatever family the operator's `DIG_NODE_HOST` is (#1682). + /// + /// **Catches:** a v6 host reaching the URL as a bare `::1:9778`, which no browser can + /// navigate. The v4 and the default-name cases sit beside the v6 one as honest + /// controls — a v6-only fixture could not tell a general fix from a broken renderer. + #[test] + fn the_localhost_tier_url_has_a_parseable_authority_for_every_host_family() { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + let cases: [(Option, &str); 3] = [ + (None, "http://localhost:9778/s/"), + ( + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + "http://127.0.0.1:9778/s/", + ), + ( + Some(IpAddr::V6(Ipv6Addr::LOCALHOST)), + "http://[::1]:9778/s/", + ), + ]; + for (host, expected_prefix) in cases { + let config = Config { + host, + port: 9778, + ..Config::default() + }; + let link = DigLink { + store_id: store(), + root: None, + path: String::new(), + }; + let localhost_tier = candidate_urls(&config, &link) + .pop() + .expect("the localhost tier is always offered"); + assert!( + localhost_tier.starts_with(expected_prefix), + "host {host:?} produced {localhost_tier}, expected it to start {expected_prefix}" + ); + } + } + /// A test launcher recording every URL it was asked to open (proving the real launcher — and /// thus a browser/shell — is never reached in CI). #[derive(Default)]