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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.32.0"
version = "0.33.0"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
29 changes: 29 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,32 @@ no override field; for launchd there is no such key at all. The NATIVE `.deb`/`.
packages (`packaging/`) sidestep this entirely by shipping their own static unit
file/plist/WiX-`ServiceInstall` with the friendly name baked in — only the bare `dig-node install`
CLI path (not via a native package) needs the `sc config`/`sc qc` dance.

## HTTPS serve on dig.local (#624) — the dig-cert consumer

- The node serves the SAME axum router over TLS on `127.0.0.2:443` (`https://dig.local`) + a
best-effort `[::1]:443` sibling, beside the kept plaintext `:80` listener. TLS material comes
from the `dig-cert` crate (pinned git-dep `tag = "v0.1.0"`, NOT `main` — release-first §4.1);
the config is built via `dig_cert::load_server_config` (a reloadable `ReloadableCertResolver`).
- **Fail-soft is mandatory:** the CA + leaf are provisioned by the installer (#623), which may not
have run. `crate::tls::load_https_material` returns `None` (⇒ plaintext only) when `leaf.{crt,key}`
are absent/unloadable — HTTPS is never a hard requirement, mirroring the best-effort `:80` bind.
- **Rotation:** the node is the runtime OWNER but delegates the HOW to dig-cert's `RenewalManager`.
A daily `maintain` pass re-issues the leaf from `ca.key` at <30d remaining, atomically swaps the
pair, and fires `resolver.reload()` → the live listener serves the new leaf with no restart. The
CA anchor is NEVER auto-rotated here (only `ca_renewal_due` is reported; `rotate_ca` is
installer-coordinated). Only install + renewal read `ca.key`.
- **Gotcha — TLS-serving stack:** reuse `axum-server` (`RustlsConfig::from_config(Arc::new(config))`
+ `from_tcp_rustls(...).handle(handle).serve(...)`), the SAME crate dig-wallet's mTLS listener
uses. Pin `rustls` 0.23 default-features-off `ring/std/tls12/logging` byte-identical to dig-cert /
dig-node-core / dig-dns, or a second `CryptoProvider` triggers the install panic.
- **Test gotchas:** (1) an HTTPS integration test that runs the listener as a spawned task AND does a
synchronous rustls handshake probe MUST use a multi-thread runtime + `spawn_blocking`, else the
blocking probe starves the server on a current-thread executor → deadlock. (2) A raw rustls client
that VERIFIES the chain rejects the leaf with `MalformedDnsIdentifier` because webpki refuses the
`*.dig` single-label wildcard SAN (dig-cert SPEC §3.1) — use an accept-any verifier when the probe
only needs to CAPTURE the presented leaf; real CA-trust is proven by the reqwest request instead.
- **Windows target-path gotcha:** building the fresh worktree failed compiling `libz-sys` via cmake
("link.exe could not be run" / `DirectoryNotFoundException` on a `.tlog`) because the deep
`modules/.worktrees/dig-node-624/target/...` path trips MSBuild/cmake MAX_PATH — set a short
`CARGO_TARGET_DIR` (e.g. `C:\dnt624`) to build.
42 changes: 41 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ ARE `DIG_NODE_*`, full stop.
| `DIG_NODE_HOST` | EXPLICIT localhost-listener bind IP override | *(unset)* | Parsed as `IpAddr`; unparsable/blank/unset ⇒ unset (§4.1's dual-stack default — see below), NOT a hardcoded `127.0.0.1` default. Setting it REPLACES the dual-stack default with exactly that one address (#288). |
| `DIG_RPC_UPSTREAM` | upstream DIG RPC base URL for passthrough + miss-proxy | `https://rpc.dig.net` | Normalized (§3.3); highest precedence (§3.4). |
| `DIG_NODE_CACHE` | explicit on-disk `.dig` cache dir | *(unset)* | Blank/whitespace ⇒ unset. Unset ⇒ shared canonical default (§3.5). |
| `DIG_NODE_DIGLOCAL` | toggle for the bare-`http://dig.local` listener | `true` | Falsy = `0`/`false`/`no`/`off`; truthy = `1`/`true`/`yes`/`on`; case/whitespace-insensitive; unset or unrecognized ⇒ **default true**. |
| `DIG_NODE_DIGLOCAL` | toggle for the bare `dig.local` listeners (`http://dig.local` on `127.0.0.2:80` AND, when a dig-cert leaf is present, `https://dig.local` on `127.0.0.2:443` — §4.1a) | `true` | Falsy = `0`/`false`/`no`/`off`; truthy = `1`/`true`/`yes`/`on`; case/whitespace-insensitive; unset or unrecognized ⇒ **default true**. |

The default port is the UNCOMMON high port **`9778`** (not `80`/`8080`). Port 80 requires elevation
on most OSes, and both `80` and `8080` are the collision-prone common-dev ports most likely already
Expand Down Expand Up @@ -273,6 +273,46 @@ environment ONLY when the operator gave an explicit override, so a plain `dig-no
no override yields a service that also dual-binds by default, rather than freezing an IPv4-only
default into every future install.

### 4.1a. Local HTTPS listeners — `https://dig.local` (#624, the #620 local-HTTPS epic)

Beside the plaintext loopback listeners (§4.1) the node serves the SAME router over TLS so
`https://dig.local` is a trusted origin in the browser. The certificate material is owned by the
`dig-cert` crate (per-machine, name-constrained local CA; see `dig-cert` SPEC) and PROVISIONED by
the dig-installer (#623); the node only READS the leaf to serve and OWNS leaf renewal.

The node opens UP TO TWO HTTPS listeners for the SAME router, both **gated on `DIG_NODE_DIGLOCAL`
being truthy AND a dig-cert leaf being present**:

1. **`127.0.0.2:443`** — the bare-`https://dig.local` listener (the IPv4 alias the installer's
`127.0.0.2 dig.local` hosts entry resolves to). Best-effort: `:443` is privileged, so a bind
failure (no privilege, port in use, missing macOS loopback alias) MUST log a structured warning
and be non-fatal — the plaintext surface keeps serving.
2. **`[::1]:443`** — the IPv6-loopback sibling (§5.2). The leaf's SAN covers `::1`, so an
IPv6-loopback client reaches the identical surface. Best-effort, non-fatal on bind failure.

**Fail-soft when no CA/leaf (HARD RULE).** When `dig-cert`'s TLS root has no `leaf.crt`/`leaf.key`
(the installer has not provisioned the CA yet), or the leaf cannot be loaded, the node MUST log an
informational line and serve **plaintext only** — HTTPS is NEVER a hard requirement to start. No
listener may bind `0.0.0.0` or `[::]`.

**Leaf rotation (the node is the runtime OWNER; SPEC §6.4 of dig-cert).** When HTTPS is up the node
drives `dig-cert`'s `RenewalManager::maintain` at service start and once daily. A pass re-issues the
leaf from `ca.key` once it is within 30 days of its 90-day lifetime, atomically swaps
`leaf.{key,crt}` (temp + rename, so no reader observes a torn or mismatched pair), and fires the
reloadable rustls resolver's `reload()` so the running listener presents the new leaf **without a
restart and without dropping connections**. Transient failures retry on a bounded backoff so a leaf
never lapses; the listener keeps serving the previous leaf until a pass succeeds.

**The CA trust anchor is NEVER auto-rotated by the node.** An approaching CA expiry is only REPORTED
(`ca_renewal_due`); anchor rotation is an explicit, installer-coordinated `dig-cert rotate_ca` (it
re-installs trust into every store), never an automatic maintenance side effect. Only two operations
read `ca.key`: install (dig-installer) and leaf renewal (the node via `dig-cert`).

**Transition posture.** The plaintext `127.0.0.2:80` listener (§4.1) is KEPT — no redirect to HTTPS
yet — so existing plaintext consumers (extension/dig-dns/clients) do not break before the §5.3
https-first ladder migration ships. The TLS stack is pinned byte-identical to `dig-cert`/`dig-dns`
(`rustls` 0.23, `ring`, no aws-lc) so exactly one `CryptoProvider` is installed.

### 4.2. Host-header allowlist (anti-rebinding)

Every non-`OPTIONS` request MUST pass the Host allowlist before any handler runs. Allowed host
Expand Down
15 changes: 15 additions & 0 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ dig-wallet = { path = "../dig-wallet" }
# and one server framework across the node and the service shell. `ws` enables
# `axum::extract::ws` for the `GET /ws/status` liveness endpoint (#239).
axum = { version = "0.7", features = ["ws"] }
# TLS-terminating server for the local HTTPS surface (`https://dig.local`, #624). Same
# crate + version dig-wallet already uses for the Sage-parity mTLS listener, so the node
# and service shell share one TLS-serving framework. Feeds it the `rustls::ServerConfig`
# dig-cert builds (its reloadable cert resolver) via `RustlsConfig::from_config`.
axum-server = { version = "0.7", features = ["tls-rustls"] }
# The single source of truth for DIG local-TLS material (#620/#622): the reloadable rustls
# loader + automatic leaf renewal for `https://dig.local`. PINNED to the released tag v0.1.0
# (never `main`) — release-first (§4.1): the crate ships + releases before this consumer.
dig-cert = { git = "https://github.com/DIG-Network/dig-cert", tag = "v0.1.0" }
# The serving TLS stack — pinned byte-identical to dig-cert / dig-node-core / dig-dns (0.23,
# default-features off, ring + std + tls12 + logging) so a build installs exactly ONE
# `CryptoProvider` (ring) and never hits the "multiple CryptoProviders" install panic (#622 §7).
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
# Wall-clock `OffsetDateTime::now_utc()` for the daily leaf-renewal maintenance pass (#624).
time = { version = "0.3" }
# `process` backs the async `tokio::process::Command` the `updater` module (#515) spawns the
# `dig-updater` beacon CLI through — a non-blocking child-process wait alongside the axum runtime.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process"] }
Expand Down
49 changes: 49 additions & 0 deletions crates/dig-node-service/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ pub const DIG_LOCAL_PORT: u16 = 80;
/// 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 {
Expand Down Expand Up @@ -240,6 +247,29 @@ impl Config {
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<String> {
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<String> {
self.dig_local
.then(|| format!("[::1]:{DIG_LOCAL_HTTPS_PORT}"))
}
}

/// Parse the `DIG_NODE_DIGLOCAL` toggle. Truthy (`1`/`true`/`yes`/`on`) ⇒ enable
Expand Down Expand Up @@ -513,6 +543,25 @@ mod tests {
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.
Expand Down
4 changes: 4 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ pub mod service;
/// paired-token store live so the daemon (which may run as a service under a different OS
/// account) and the operator CLI resolve the SAME files. See [`state`].
pub mod state;
/// Local HTTPS TLS wiring for `https://dig.local` (#624): load the dig-cert leaf into a
/// reloadable rustls config (fail-soft when no CA/leaf yet) and drive dig-cert's leaf
/// renewal so the running listener hot-reloads a rotated leaf. See [`tls`].
pub mod tls;
/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update
/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/
/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`].
Expand Down
Loading
Loading