From 8e5e962f0b4ba365a5a91a8b432a6fcab1bd5a8f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Thu, 16 Jul 2026 02:10:52 -0700 Subject: [PATCH] feat(serve): HTTPS on dig.local (127.0.0.2:443) + live leaf rotation (#624) Serve the SAME local content router over TLS on 127.0.0.2:443 (https://dig.local), plus a best-effort [::1]:443 IPv6-loopback sibling (SS5.2), beside the kept plaintext :80 listener. TLS material comes from the dig-cert crate (pinned git-dep tag v0.1.0, release-first): the reloadable rustls ServerConfig is built via load_server_config. Leaf rotation is delegated to dig-cert's RenewalManager (the node is the runtime OWNER): a startup + daily maintain pass re-issues the leaf from ca.key at <30d remaining, atomically swaps the pair, and fires resolver.reload() so the running listener serves the new leaf with no downtime. The CA anchor is NEVER auto-rotated here (only ca_renewal_due is reported; rotate_ca is installer-coordinated). Fail-soft: when the installer (#623) has not provisioned a CA/leaf, or the leaf cannot be loaded, HTTPS is skipped and the node serves plaintext only -- HTTPS is never a hard requirement to start. SECURITY-CRITICAL: touches the ca.key rotation read path -> dual gate + loop-security required before merge. Windows rollout gates on #623's DACL (noted, non-blocking). Tests: config addressing units; tls fail-soft + load units; an end-to-end HTTPS integration test (client trusting the CA gets the content; a live rotation swaps the served leaf with the listener staying up). Bump 0.32.0 -> 0.33.0 (feat -> minor). Closes #624 Refs #620, #622 Co-Authored-By: Claude --- Cargo.lock | 23 ++- Cargo.toml | 2 +- DEVELOPMENT_LOG.md | 29 +++ SPEC.md | 42 ++++- crates/dig-node-service/Cargo.toml | 15 ++ crates/dig-node-service/src/config.rs | 49 +++++ crates/dig-node-service/src/lib.rs | 4 + crates/dig-node-service/src/server.rs | 114 ++++++++++++ crates/dig-node-service/src/tls.rs | 171 +++++++++++++++++ crates/dig-node-service/tests/https_serve.rs | 186 +++++++++++++++++++ 10 files changed, 632 insertions(+), 3 deletions(-) create mode 100644 crates/dig-node-service/src/tls.rs create mode 100644 crates/dig-node-service/tests/https_serve.rs diff --git a/Cargo.lock b/Cargo.lock index 11995e2..3cbb9b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1756,6 +1756,23 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "dig-cert" +version = "0.1.0" +source = "git+https://github.com/DIG-Network/dig-cert?tag=v0.1.0#408d604ad2fd398c635b9e5759eadeaf9ad4eb11" +dependencies = [ + "arc-swap", + "rcgen", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "tempfile", + "thiserror 2.0.18", + "time", + "tracing", + "x509-parser", +] + [[package]] name = "dig-clvm" version = "0.1.1" @@ -1966,11 +1983,13 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.32.0" +version = "0.33.0" dependencies = [ "axum", + "axum-server", "base64", "clap", + "dig-cert", "dig-constants 0.3.0", "dig-node-core", "dig-wallet", @@ -1979,10 +1998,12 @@ dependencies = [ "digstore-stage", "futures-util", "reqwest", + "rustls", "serde", "serde_json", "service-manager", "tempfile", + "time", "tokio", "tokio-tungstenite", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index 9bc8040..ff61bf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index 4890b33..e1841b5 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 8fb837a..44505df 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 @@ -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 diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 74fcb8f..74b3bf8 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -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"] } diff --git a/crates/dig-node-service/src/config.rs b/crates/dig-node-service/src/config.rs index 65f0445..b60e990 100644 --- a/crates/dig-node-service/src/config.rs +++ b/crates/dig-node-service/src/config.rs @@ -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 { @@ -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 { + 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 @@ -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. diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 2e276dd..a200fb6 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -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`]. diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index adb1522..7a97f60 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -1430,6 +1430,14 @@ where }); } + // (4) Local HTTPS for `https://dig.local` (#624, the #620 epic): the SAME app served over + // TLS on `127.0.0.2:443` (plus the best-effort IPv6 loopback `[::1]:443`, §5.2), backed by a + // dig-cert leaf with live rotation. GATED on a leaf being present — fail-soft to plaintext + // when the installer (#623) has not provisioned the CA yet. Best-effort like the mTLS + + // bare-dig.local listeners: a bind failure logs and is non-fatal; the plaintext surface above + // keeps serving. Runs as spawned tasks driven to graceful stop by the shared shutdown signal. + bring_up_local_https(&config, &app, &shutdown_notify); + let localhost_srv = { let app = app.clone(); let n = shutdown_notify.clone(); @@ -1490,6 +1498,112 @@ fn warn_dig_local_bind_failed(dl_addr: &str, e: &std::io::Error) { ); } +/// Bring up the local HTTPS listeners for `https://dig.local` (#624). Fail-soft: when +/// `dig_local` is disabled, the TLS root cannot be resolved, or no dig-cert leaf is present +/// yet, this does NOTHING (the node keeps serving plaintext) — HTTPS is never required to +/// start. When a leaf IS present it builds the reloadable rustls config once, spawns the +/// leaf-rotation loop (dig-cert renewal manager, hot-reloading the shared resolver), and +/// spawns a best-effort TLS listener on `127.0.0.2:443` and the IPv6 loopback `[::1]:443`, +/// each serving `app` and stopped gracefully by `shutdown_notify`. +fn bring_up_local_https(config: &Config, app: &Router, shutdown_notify: &Arc) { + // Only attempt HTTPS on the bare-dig.local surface (shares the `dig_local` toggle). + let Some(https_addr) = config.dig_local_https_addr() else { + return; + }; + + let paths = match dig_cert::TlsPaths::machine() { + Ok(paths) => paths, + Err(e) => { + eprintln!( + "dig-node: WARN cannot resolve the TLS material root ({e}); \ + https://dig.local disabled, serving plaintext only." + ); + return; + } + }; + + // Fail-soft when the installer (#623) has not provisioned a CA + leaf yet. + let Some(material) = crate::tls::load_https_material(paths) else { + return; + }; + + // Drive leaf rotation off the SHARED resolver so a renewal hot-reloads every listener + // built from this config; the CA anchor is never auto-rotated (see `crate::tls`). + crate::tls::spawn_leaf_rotation(material.paths.clone(), material.resolver.clone()); + + // ONE rustls config shared by both loopback-family listeners: its cert resolver is the + // shared `ReloadableCertResolver`, so a rotation reload is served on both at once. + let rustls_config = + axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(material.config)); + + // (4a) The IPv4 dig.local alias `127.0.0.2:443` — the name `https://dig.local` resolves to. + spawn_https_listener( + &https_addr, + rustls_config.clone(), + app.clone(), + shutdown_notify, + ); + + // (4b) The best-effort IPv6 loopback sibling `[::1]:443` (§5.2), covered by the leaf SAN. + if let Some(v6_addr) = config.dig_local_https_addr_v6() { + spawn_https_listener(&v6_addr, rustls_config, app.clone(), shutdown_notify); + } +} + +/// Bind `addr` and spawn a best-effort TLS listener serving `app`. A bind failure logs a +/// structured warning and is non-fatal (the plaintext surface keeps serving), mirroring the +/// bare-`http://dig.local` policy — `:443` is privileged (the installed service runs elevated). +fn spawn_https_listener( + addr: &str, + rustls_config: axum_server::tls_rustls::RustlsConfig, + app: Router, + shutdown_notify: &Arc, +) { + match std::net::TcpListener::bind(addr) { + Ok(listener) => { + eprintln!("dig-node: HTTPS (https://dig.local) listening on {addr}"); + let shutdown = shutdown_notify.clone(); + let addr = addr.to_string(); + tokio::spawn(async move { + if let Err(e) = serve_https(listener, rustls_config, app, shutdown).await { + eprintln!("dig-node: WARN HTTPS listener on {addr} exited: {e}"); + } + }); + } + Err(e) => eprintln!( + "dig-node: WARN could not bind {addr} for https://dig.local ({e}); non-fatal — \ + plaintext keeps serving. `:443` is privileged (run elevated / grant \ + CAP_NET_BIND_SERVICE; the installed service runs elevated) and the 127.0.0.2 / ::1 \ + loopback address must exist (macOS: sudo ifconfig lo0 alias 127.0.0.2)." + ), + } +} + +/// Serve `app` over TLS on a pre-bound listener until `shutdown` fires, then stop gracefully. +/// +/// Uses the same `axum-server` TLS stack as the wallet mTLS listener, fed the reloadable +/// rustls config so a leaf rotation is picked up live (no restart, no dropped connections). +/// `pub` so the HTTPS integration test can drive it against an ephemeral loopback port. +pub async fn serve_https( + listener: std::net::TcpListener, + rustls_config: axum_server::tls_rustls::RustlsConfig, + app: Router, + shutdown: Arc, +) -> std::io::Result<()> { + let handle = axum_server::Handle::new(); + { + let handle = handle.clone(); + tokio::spawn(async move { + shutdown.notified().await; + handle.graceful_shutdown(Some(std::time::Duration::from_secs(5))); + }); + } + axum_server::from_tcp_rustls(listener, rustls_config) + .handle(handle) + .serve(app.into_make_service()) + .await +} + /// Resolve when the process receives Ctrl-C (all platforms) or SIGTERM (unix), /// which is how a service manager stops the service — letting `serve` shut down /// gracefully instead of being killed mid-request. diff --git a/crates/dig-node-service/src/tls.rs b/crates/dig-node-service/src/tls.rs new file mode 100644 index 0000000..b435650 --- /dev/null +++ b/crates/dig-node-service/src/tls.rs @@ -0,0 +1,171 @@ +//! Local HTTPS TLS wiring for `https://dig.local` (#624, the #620 local-HTTPS epic). +//! +//! dig-node serves the SAME local content surface as the plaintext `/s/` path over TLS on +//! `127.0.0.2:443`, using a leaf issued by the per-machine, name-constrained CA that the +//! `dig-cert` crate owns (#622). The CA + leaf are provisioned by the installer (#623): this +//! module NEVER generates the CA and NEVER auto-rotates the trust anchor — it only READS the +//! leaf to serve, and drives `dig-cert`'s renewal manager to keep the *leaf* fresh. +//! +//! Two responsibilities: +//! +//! - [`load_https_material`] — build the reloadable `rustls` config from the on-disk leaf, +//! **failing SOFT** (returning `None` + logging) when no CA/leaf is present yet, so a node +//! on a machine the installer has not provisioned simply keeps serving plaintext. +//! - [`spawn_leaf_rotation`] — run `dig-cert`'s [`RenewalManager`] at startup and daily so the +//! leaf is re-issued from `ca.key` before it lapses and the running listener hot-reloads the +//! new leaf with no downtime and no dropped connections. + +use std::sync::Arc; +use std::time::Duration; + +use dig_cert::{load_server_config, ReloadableCertResolver, RenewalManager, TlsPaths}; +use rustls::ServerConfig; +use time::OffsetDateTime; + +/// How often dig-node runs a leaf-renewal maintenance pass (dig-cert SPEC §6): once daily. +/// A 90-day leaf renewed at 30 days remaining leaves ample margin for the manager's own +/// bounded retry backoff, so a daily cadence never lets a leaf lapse. +const RENEWAL_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// The material needed to bring up the local HTTPS listener: the `rustls` server config +/// (its cert resolver is hot-reloadable) plus the resolver handle and the TLS paths the +/// renewal manager needs. +pub struct HttpsMaterial { + /// The `rustls::ServerConfig` to hand to the TLS listener. Its certificate resolver is + /// the shared [`ReloadableCertResolver`], so a leaf rotation is picked up live. + pub config: ServerConfig, + /// The resolver handle the renewal manager fires `reload()` on after a rotation. + pub resolver: Arc, + /// The canonical TLS paths (`ca.{key,crt}`, `leaf.{key,crt}`) the renewal manager reads. + pub paths: TlsPaths, +} + +/// Try to load the dig-cert leaf backing `https://dig.local` from `paths`. +/// +/// **Fail-soft (SPEC #624):** returns `None` — and logs why — when the leaf is absent or +/// unreadable, because the installer (#623) is what provisions the CA + leaf. Until then the +/// node serves plaintext only; HTTPS is never a hard requirement. A present-but-unparseable +/// leaf is likewise treated as "not yet available" rather than a fatal error. +pub fn load_https_material(paths: TlsPaths) -> Option { + // Gate on BOTH files existing before touching rustls — the common "installer hasn't run + // yet" case, reported as an informational line (not a warning), since it is expected. + if !paths.leaf_cert().exists() || !paths.leaf_key().exists() { + eprintln!( + "dig-node: HTTPS (https://dig.local) unavailable — no TLS leaf under {} yet. \ + The installer provisions the per-machine CA + leaf (#623); serving plaintext only.", + paths.root.display() + ); + return None; + } + match load_server_config(paths.leaf_cert(), paths.leaf_key()) { + Ok((config, resolver)) => Some(HttpsMaterial { + config, + resolver, + paths, + }), + Err(e) => { + eprintln!( + "dig-node: WARN could not load the TLS leaf under {} ({e}); serving plaintext \ + only. HTTPS starts once a valid CA + leaf are present (#623).", + paths.root.display() + ); + None + } + } +} + +/// Spawn the leaf-rotation loop (dig-cert [`RenewalManager`], SPEC §6). Runs a maintenance +/// pass immediately at startup and once every [`RENEWAL_INTERVAL`] thereafter. Each pass: +/// re-issues the leaf from `ca.key` when it is within 30 days of expiry, atomically swaps +/// `leaf.{key,crt}` (temp + rename, so no reader sees a torn or mismatched pair), and fires +/// `resolver.reload()` so the running HTTPS listener presents the new leaf WITHOUT a restart. +/// +/// dig-node is the runtime rotation OWNER but delegates the HOW to dig-cert's manager, whose +/// resolver reload reads the completed pair (and keeps the previous cert on a torn read), so +/// the listener never serves an expired or mismatched leaf. The CA trust anchor is NEVER +/// auto-rotated here: an approaching CA expiry is only REPORTED (`ca_renewal_due`); anchor +/// rotation is an explicit, installer-coordinated `dig-cert rotate_ca` (SPEC §6.4). +pub fn spawn_leaf_rotation(paths: TlsPaths, resolver: Arc) { + // `RenewalManager::maintain` is synchronous and may sleep on its retry backoff, so each + // pass runs on a blocking worker — never on an async runtime thread. The manager is shared + // (Arc) so a clone can move into each blocking task. + let manager = Arc::new(RenewalManager::new(paths, resolver)); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RENEWAL_INTERVAL); + loop { + // `interval`'s first tick completes immediately → the startup pass; subsequent + // ticks are the daily cadence. + ticker.tick().await; + let manager = manager.clone(); + match tokio::task::spawn_blocking(move || manager.maintain(OffsetDateTime::now_utc())) + .await + { + Ok(Ok(report)) => { + if report.leaf_renewed { + eprintln!( + "dig-node: TLS leaf for https://dig.local renewed and hot-reloaded \ + (attempts={})", + report.attempts + ); + } + if report.ca_renewal_due { + eprintln!( + "dig-node: WARN the local CA is within its renewal window. CA rotation \ + is installer-coordinated (dig-cert rotate_ca) and re-installs trust — \ + the node does NOT auto-rotate the anchor." + ); + } + } + Ok(Err(e)) => eprintln!( + "dig-node: WARN TLS leaf renewal pass failed ({e}); HTTPS keeps serving the \ + current leaf and the next pass retries." + ), + Err(e) => { + eprintln!("dig-node: WARN TLS leaf renewal task did not complete ({e})") + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use dig_cert::{generate_ca, issue_leaf, ParsedCa}; + + /// Write a freshly generated CA + leaf into `paths` so a test can exercise the loader. + fn provision(paths: &TlsPaths) { + std::fs::create_dir_all(&paths.root).unwrap(); + let now = OffsetDateTime::now_utc(); + let ca = generate_ca("test-host", now).unwrap(); + std::fs::write(paths.ca_cert(), &ca.cert_pem).unwrap(); + std::fs::write(paths.ca_key(), &ca.key_pem).unwrap(); + let parsed = ParsedCa::from_pem(&ca.cert_pem, &ca.key_pem).unwrap(); + let leaf = issue_leaf(&parsed, now).unwrap(); + std::fs::write(paths.leaf_cert(), &leaf.cert_pem).unwrap(); + std::fs::write(paths.leaf_key(), &leaf.key_pem).unwrap(); + } + + #[test] + fn load_is_none_when_no_leaf_present() { + // Fail-soft: an unprovisioned machine (no installer run yet) yields no HTTPS material, + // so the caller falls back to plaintext instead of failing to start. + let dir = tempfile::tempdir().unwrap(); + let paths = TlsPaths::under(dir.path()); + assert!(load_https_material(paths).is_none()); + } + + #[test] + fn load_is_some_when_leaf_present() { + let dir = tempfile::tempdir().unwrap(); + let paths = TlsPaths::under(dir.path()); + provision(&paths); + let material = load_https_material(paths).expect("a provisioned leaf loads"); + // Building the config proves the leaf key parsed as a usable ECDSA signing key, and the + // resolver re-reads the just-written pair without error (the rotation hot-reload path). + material + .resolver + .reload() + .expect("reload the just-written leaf pair"); + } +} diff --git a/crates/dig-node-service/tests/https_serve.rs b/crates/dig-node-service/tests/https_serve.rs new file mode 100644 index 0000000..cdd3d70 --- /dev/null +++ b/crates/dig-node-service/tests/https_serve.rs @@ -0,0 +1,186 @@ +//! End-to-end tests for the local HTTPS surface `https://dig.local` (#624, the #620 epic). +//! +//! These prove the transport contract the design requires: +//! +//! - the node serves its app over TLS with a dig-cert leaf (a client trusting the per-machine +//! CA gets a valid chain and the real content); +//! - a leaf ROTATION is picked up LIVE — the served certificate changes without the listener +//! being torn down or a connection dropped; +//! - loading the leaf FAILS SOFT when no CA/leaf is present (covered as a unit test in +//! `src/tls.rs`; here we assert the positive serve + rotation path end-to-end). +//! +//! The listener binds an ephemeral loopback port (`127.0.0.1:0`) rather than the privileged +//! `127.0.0.2:443` so the test needs no elevation; `127.0.0.1` is in the leaf's SAN set, so the +//! chain validates identically. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::routing::get; +use axum::Router; +use dig_cert::{generate_ca, issue_leaf, load_server_config, ParsedCa, TlsPaths}; +use rustls::pki_types::{CertificateDer, ServerName}; +use time::OffsetDateTime; + +/// Write a freshly generated per-machine CA + leaf into `paths`, returning the CA cert PEM +/// (a client trusts this to validate the served leaf). +fn provision(paths: &TlsPaths) -> String { + std::fs::create_dir_all(&paths.root).unwrap(); + let now = OffsetDateTime::now_utc(); + let ca = generate_ca("test-host", now).unwrap(); + std::fs::write(paths.ca_cert(), &ca.cert_pem).unwrap(); + std::fs::write(paths.ca_key(), &ca.key_pem).unwrap(); + write_leaf(paths, &ca.cert_pem, &ca.key_pem); + ca.cert_pem +} + +/// Issue a fresh leaf from the on-disk CA and write it — used to simulate a rotation. +fn write_leaf(paths: &TlsPaths, ca_cert_pem: &str, ca_key_pem: &str) { + let parsed = ParsedCa::from_pem(ca_cert_pem, ca_key_pem).unwrap(); + let leaf = issue_leaf(&parsed, OffsetDateTime::now_utc()).unwrap(); + std::fs::write(paths.leaf_cert(), &leaf.cert_pem).unwrap(); + std::fs::write(paths.leaf_key(), &leaf.key_pem).unwrap(); +} + +/// A client-side verifier that accepts ANY server certificate. This probe's sole job is to +/// CAPTURE the leaf the server presents (to assert it changed after a rotation) — chain +/// validation is proven separately by the trusting `reqwest` request. Decoupling capture from +/// validation also sidesteps rustls-webpki's rejection of the leaf's `*.dig` wildcard SAN +/// (dig-cert SPEC §3.1, a known + regression-pinned limitation). +#[derive(Debug)] +struct AcceptAnyServerCert; + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +/// Complete a TLS handshake against `addr` and return the leaf certificate the server presented, +/// so a test can assert the served leaf actually changed after a rotation (cert capture only — +/// see [`AcceptAnyServerCert`]). +fn served_leaf_der(addr: SocketAddr) -> Vec { + use std::io::{Read, Write}; + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(); + let server_name = ServerName::IpAddress(std::net::Ipv4Addr::LOCALHOST.into()); + let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap(); + let mut sock = std::net::TcpStream::connect(addr).unwrap(); + let mut tls = rustls::Stream::new(&mut conn, &mut sock); + // Drive the handshake by exchanging a minimal request; the server closes the connection, + // so the read may end in an error we deliberately ignore — the cert is captured regardless. + tls.write_all(b"GET /health HTTP/1.1\r\nHost: dig.local\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut buf = Vec::new(); + let _ = tls.read_to_end(&mut buf); + conn.peer_certificates() + .expect("server presented a certificate")[0] + .to_vec() +} + +// A multi-threaded runtime is required: the TLS listener runs as a spawned task while the test +// drives requests, and the synchronous rustls cert-capture probe (`served_leaf_der`) is run on a +// blocking worker via `spawn_blocking` so it never starves the server on a shared executor thread. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn https_serves_content_and_a_rotation_is_picked_up_live() { + let dir = tempfile::tempdir().unwrap(); + let paths = TlsPaths::under(dir.path()); + let ca_pem = provision(&paths); + + // Build the reloadable rustls config from the leaf (the same call `crate::tls` makes) and + // keep the resolver handle so the test can drive a rotation on the SAME live config. + let (config, resolver) = load_server_config(paths.leaf_cert(), paths.leaf_key()).unwrap(); + let rustls_config = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config)); + + let app = Router::new().route("/health", get(|| async { "dig-node-ok" })); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let shutdown = Arc::new(tokio::sync::Notify::new()); + + let server = { + let rustls_config = rustls_config.clone(); + let app = app.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + dig_node_service::server::serve_https(listener, rustls_config, app, shutdown).await + }) + }; + // Let the listener come up. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // A client trusting the per-machine CA gets a valid chain and the real content. + let client = reqwest::Client::builder() + .add_root_certificate(reqwest::Certificate::from_pem(ca_pem.as_bytes()).unwrap()) + .build() + .unwrap(); + let resp = client + .get(format!("https://127.0.0.1:{}/health", addr.port())) + .send() + .await + .expect("HTTPS request succeeds over the dig-cert leaf"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().await.unwrap(), "dig-node-ok"); + + // Capture the leaf currently served, then ROTATE: issue a fresh leaf on disk and reload the + // shared resolver — exactly what dig-cert's renewal manager does on a real rotation. + let ca_key_pem = std::fs::read_to_string(paths.ca_key()).unwrap(); + let before = tokio::task::spawn_blocking(move || served_leaf_der(addr)) + .await + .unwrap(); + write_leaf(&paths, &ca_pem, &ca_key_pem); + resolver.reload().expect("hot-reload the rotated leaf"); + + // The SAME listener is still up and now presents the NEW leaf — rotation caused no downtime. + let after = tokio::task::spawn_blocking(move || served_leaf_der(addr)) + .await + .unwrap(); + assert_ne!(before, after, "the served leaf changed after a rotation"); + + let resp = client + .get(format!("https://127.0.0.1:{}/health", addr.port())) + .send() + .await + .expect("HTTPS still serves after the live rotation"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().await.unwrap(), "dig-node-ok"); + + shutdown.notify_waiters(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(6), server).await; +}