diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5af48..32e01d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org). +## [0.37.0] - 2026-07-16 + +### Features +- **cli:** Control-parity subcommands (`info`/`config`/`cache`/`stores`/`sync`/`updater`/`subscriptions`) — a CLI verb for every `control.*` method the extension drives, thin-dispatched over the loopback control plane with `--json` (#426) +- **cli:** `peers` — view + manage peer connections, IPv6-first address display, parity with the extension's peer surface (#559) + ## [0.35.0] - 2026-07-16 ### Features diff --git a/Cargo.lock b/Cargo.lock index 32ede37..ae2e166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1983,7 +1983,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.36.0" +version = "0.37.0" dependencies = [ "axum", "axum-server", diff --git a/Cargo.toml b/Cargo.toml index 79df058..c4a0b2b 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.36.0" +version = "0.37.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/SPEC.md b/SPEC.md index 5d16ddb..d031eaa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1284,12 +1284,60 @@ write) is cancelled and reported as a failed kick, so it can never block the pas `run` (default when no subcommand; serves in the foreground and is the unix-service entrypoint) · `run-service` (hidden; the Windows SCM entrypoint, §9.4; behaves as `run` off Windows) · -`install` · `uninstall` · `start` · `stop` · `status` · `pair` (§7.11) · `open` (§8.5). +`install` · `uninstall` · `start` · `stop` · `status` · `pair` (§7.11) · `open` (§8.5) · +the **control-parity** subcommands `info` · `config` · `cache` · `stores` · `sync` · `updater` · +`subscriptions` (§8.6) · `peers` (§8.7). The `dign` alias binary (§2.1a) exposes this SAME subcommand set with the SAME semantics — `dign ` is equivalent to `dig-node ` in every respect except the reported program name. +### 8.6. Control-parity subcommands (#426) + +For EVERY gated `control.*` method the DIG Chrome extension drives (§7), the CLI exposes an +equivalent subcommand, so an operator/agent can drive the node from a terminal exactly as the +extension drives it from a browser. Each subcommand is a THIN dispatch — it calls the SAME +`control.*` method over the node's loopback endpoint, presenting the MASTER control token +(`X-Dig-Control-Token`, read WITHOUT minting — §7.11/#501); no CLI logic is forked from the control +plane. A mutating CLI control is therefore gated by the identical capability as the WS surface (the +on-disk master token = local-machine control), never an unauthenticated backdoor. + +- `info` → `control.status` — the rich node status (version, uptime, cache, hosted-store + + cached-capsule counts, §21 sync availability). DISTINCT from `status` (§8.3), which is an + unauthenticated `/health` liveness probe; `info` is the token-gated detailed view. +- `config [get]` → `control.config.get`; `config set-upstream ` → `control.config.setUpstream`. +- `cache [get]` → `control.cache.get`; `cache set-cap ` → `control.cache.setCap`; + `cache clear` → `control.cache.clear`. +- `stores [list]` → `control.hostedStores.list`; `stores pin|unpin|status ` → + `control.hostedStores.pin|unpin|status`. +- `sync [status]` → `control.sync.status`; `sync trigger ` → `control.sync.trigger`. +- `updater [status]` → `control.updater.status`; `updater set-channel ` / `pause [--until ]` + / `resume` / `check-now` → the matching `control.updater.*`. +- `subscriptions [list]` → `control.listSubscriptions`; `subscriptions add|remove ` → + `control.subscribe`/`control.unsubscribe`. + +**Parity is enforced mechanically.** `control::CONTROL_METHODS` is the canonical set of every +`control.*` method the node resolves; a compile-time-adjacent test asserts every method in it is +reachable from a CLI verb, so a new node control method cannot ship without a CLI subcommand. + +### 8.7. `peers` — view + manage peer connections (#559) + +`peers` reaches parity with the extension's peer surface (`src/features/peers/peersApi.ts`): + +- `peers [list]` → `control.peerStatus` — the live peer status: running flag, connected count, + relay reservation, and (when a newer node fills the optional field) the per-peer list. Peer + addresses are displayed **IPv6-first, IPv4 second** per the ecosystem §5.2 address-family policy. +- `peers connect ` → `control.peers.connect`; `peers disconnect ` → + `control.peers.disconnect`; `peers ban --state ` → + `control.peers.setBan`; `peers pool-config --max-connections ` → `control.peers.setPoolConfig`. + +The management verbs + the extended per-peer `control.peerStatus` payload are a **known node-side +gap** (the same gap the extension documents): the node today implements only the running flag + +connected count. Until it ships the management RPCs the `list` view degrades honestly (count only) +and the management verbs surface the node's METHOD_NOT_FOUND. The CLI verbs exist now so the surface +reaches parity and lights up with NO CLI change once the node implements them (tracked cross-repo +follow-up). + ### 8.5. `open` — the OS scheme handler (#389) `dig-node open ` is the target the installer registers for the OS `chia://` and diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 2328412..b85e983 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -74,6 +74,48 @@ pub fn is_control_method(method: &str) -> bool { method.starts_with("control.") } +/// The canonical set of `control.*` methods the node's control plane RESOLVES — the +/// union of the methods this shell owns ([`dispatch_control`]) and the ones it delegates +/// to the embedded node's own control surface (`control.peerStatus` + +/// `control.subscribe`/`unsubscribe`/`listSubscriptions`). This is the SINGLE source of +/// truth for "what can be controlled", consumed by: +/// +/// * the CLI-parity drift test (#426) — every method here MUST have a `dig-node` CLI verb +/// (see `crate::control_cli::cli_covered_control_methods`), so the CLI never silently +/// falls behind the WS control surface the extension drives; +/// * introspection — a stable list a machine can enumerate. +/// +/// Keep it in lockstep with [`dispatch_control`]: a new `control.*` method added there MUST +/// be added here (and given a CLI verb), or the drift test fails. +pub const CONTROL_METHODS: &[&str] = &[ + // Owned by this shell (dispatch_control). + "control.status", + "control.config.get", + "control.config.setUpstream", + "control.cache.get", + "control.cache.setCap", + "control.cache.clear", + "control.hostedStores.list", + "control.hostedStores.pin", + "control.hostedStores.unpin", + "control.hostedStores.status", + "control.sync.status", + "control.sync.trigger", + "control.updater.status", + "control.updater.setChannel", + "control.updater.pause", + "control.updater.resume", + "control.updater.checkNow", + "control.pairing.list", + "control.pairing.approve", + "control.pairing.revoke", + // Delegated to the embedded node's own control surface. + "control.peerStatus", + "control.subscribe", + "control.unsubscribe", + "control.listSubscriptions", +]; + /// Is this a PAIRING-ADMINISTRATION control method (#280)? PURE. /// /// These manage the pairing lifecycle — list pending requests, approve one (minting diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs new file mode 100644 index 0000000..bd225ff --- /dev/null +++ b/crates/dig-node-service/src/control_cli.rs @@ -0,0 +1,339 @@ +//! CLI parity with the node's `control.*` surface (#426). +//! +//! The DIG Chrome extension drives the node over the token-gated `control.*` WS/RPC surface +//! (status, config, cache, hosted stores, §21 sync, the auto-update beacon, subscriptions). +//! This module gives the `dig-node` / `dign` CLI a subcommand for EVERY one of those controls, +//! so an operator (or an agent) can drive the node from a terminal exactly as the extension +//! does from a browser — with `--json` machine output beside the human summary. +//! +//! # No forked logic — thin dispatch over the ONE control plane +//! +//! Every action here is a THIN dispatch: it calls the SAME `control.*` method the extension +//! calls, through the shared [`crate::control_client::call_control`] client (master-token auth +//! over loopback — the identical gate, never an unauthenticated backdoor). The node owns the +//! behaviour; this module only maps a subcommand to a method + renders the result. So the CLI +//! and the extension can never drift in WHAT they do — only in HOW they present it. +//! +//! # Staying in sync with the extension surface +//! +//! [`crate::control::CONTROL_METHODS`] is the canonical control-method set; every method there +//! MUST be reachable from a CLI verb ([`cli_covered_control_methods`]). The drift test at the +//! bottom of this module fails if a new `control.*` method is added to the node without a CLI +//! verb — so the parity is enforced mechanically, not by memory. + +use serde_json::{json, Value}; + +use crate::cli::Outcome; +use crate::config::Config; +use crate::control_client::call_control; + +/// One control-parity CLI action, clap-agnostic (mapped from the subcommand in `entrypoint.rs`). +/// Each variant names the single `control.*` method it dispatches — see [`ControlAction::method`]. +pub enum ControlAction { + /// `control.status` — the rich at-a-glance node status (version, uptime, cache, hosted + /// stores, sync availability). Distinct from `dig-node status`, which is an UNAUTHENTICATED + /// liveness probe of `/health`; this is the token-gated detailed view the extension shows. + Info, + /// `control.config.get` — the node's effective config (addr/port, upstream, cache dir). + ConfigGet, + /// `control.config.setUpstream` — persist the upstream DIG RPC override (next-start effective). + ConfigSetUpstream { url: String }, + /// `control.cache.get` — cache cap/used/dir/shared. + CacheGet, + /// `control.cache.setCap` — set the on-disk cache size cap (bytes; floored at 64 MiB). + CacheSetCap { bytes: u64 }, + /// `control.cache.clear` — delete all locally cached DIG content. + CacheClear, + /// `control.hostedStores.list` — every hosted/pinned store + its cached capsules. + StoresList, + /// `control.hostedStores.pin` — pin a store (`storeId[:rootHash]`) + pre-fetch when possible. + StoresPin { store: String }, + /// `control.hostedStores.unpin` — unpin a store + evict its cached capsules. + StoresUnpin { store: String }, + /// `control.hostedStores.status` — one store's pin/cache status. + StoresStatus { store: String }, + /// `control.sync.status` — §21 whole-store sync availability + pinned coverage. + SyncStatus, + /// `control.sync.trigger` — trigger a §21 sync for one capsule (`storeId:rootHash`). + SyncTrigger { store: String }, + /// `control.updater.status` — the DIG auto-update beacon's status. + UpdaterStatus, + /// `control.updater.setChannel` — set the beacon channel (`nightly` | `stable`). + UpdaterSetChannel { channel: String }, + /// `control.updater.pause` — pause auto-updates (optionally until a unix-seconds deadline). + UpdaterPause { until: Option }, + /// `control.updater.resume` — resume auto-updates. + UpdaterResume, + /// `control.updater.checkNow` — check for an update now. + UpdaterCheckNow, + /// `control.listSubscriptions` — the node's persisted store-subscription set. + SubsList, + /// `control.subscribe` — subscribe the node to a store id (chain-watch + gap-fill). + SubsAdd { store_id: String }, + /// `control.unsubscribe` — remove a store subscription. + SubsRemove { store_id: String }, +} + +impl ControlAction { + /// The `control.*` method this action dispatches. The single place the action↔method + /// mapping lives, so [`cli_covered_control_methods`] and [`run`] never disagree. + pub fn method(&self) -> &'static str { + match self { + ControlAction::Info => "control.status", + ControlAction::ConfigGet => "control.config.get", + ControlAction::ConfigSetUpstream { .. } => "control.config.setUpstream", + ControlAction::CacheGet => "control.cache.get", + ControlAction::CacheSetCap { .. } => "control.cache.setCap", + ControlAction::CacheClear => "control.cache.clear", + ControlAction::StoresList => "control.hostedStores.list", + ControlAction::StoresPin { .. } => "control.hostedStores.pin", + ControlAction::StoresUnpin { .. } => "control.hostedStores.unpin", + ControlAction::StoresStatus { .. } => "control.hostedStores.status", + ControlAction::SyncStatus => "control.sync.status", + ControlAction::SyncTrigger { .. } => "control.sync.trigger", + ControlAction::UpdaterStatus => "control.updater.status", + ControlAction::UpdaterSetChannel { .. } => "control.updater.setChannel", + ControlAction::UpdaterPause { .. } => "control.updater.pause", + ControlAction::UpdaterResume => "control.updater.resume", + ControlAction::UpdaterCheckNow => "control.updater.checkNow", + ControlAction::SubsList => "control.listSubscriptions", + ControlAction::SubsAdd { .. } => "control.subscribe", + ControlAction::SubsRemove { .. } => "control.unsubscribe", + } + } + + /// The JSON-RPC params for this action (an empty object for the read/no-arg methods). + fn params(&self) -> Value { + match self { + ControlAction::ConfigSetUpstream { url } => json!({ "upstream": url }), + ControlAction::CacheSetCap { bytes } => json!({ "cap_bytes": bytes }), + ControlAction::StoresPin { store } + | ControlAction::StoresUnpin { store } + | ControlAction::StoresStatus { store } + | ControlAction::SyncTrigger { store } => json!({ "store": store }), + ControlAction::UpdaterSetChannel { channel } => json!({ "channel": channel }), + ControlAction::UpdaterPause { until: Some(u) } => json!({ "until": u }), + ControlAction::SubsAdd { store_id } | ControlAction::SubsRemove { store_id } => { + json!({ "store_id": store_id }) + } + _ => json!({}), + } + } +} + +/// Run a control-parity subcommand: dispatch the mapped `control.*` method over the shared +/// loopback client and render an [`Outcome`] (a concise human summary + the raw `result` for +/// `--json`). Transport / node errors surface as `io::Error` for the differentiated exit code. +pub fn run(config: &Config, action: ControlAction) -> std::io::Result { + let method = action.method(); + let result = call_control(config, method, action.params())?; + Ok(Outcome::new(summarize(method, &result), result)) +} + +/// Every `control.*` method reachable from a `dig-node` CLI verb — the union of the +/// control-parity actions here and the `control.pairing.*` methods `dig-node pair` drives +/// (#280). The drift test asserts this COVERS [`crate::control::CONTROL_METHODS`], so a new +/// node control method cannot ship without a CLI verb. +pub fn cli_covered_control_methods() -> Vec<&'static str> { + let mut methods: Vec<&'static str> = vec![ + // The control-parity actions (this module). + ControlAction::Info.method(), + ControlAction::ConfigGet.method(), + ControlAction::ConfigSetUpstream { url: String::new() }.method(), + ControlAction::CacheGet.method(), + ControlAction::CacheSetCap { bytes: 0 }.method(), + ControlAction::CacheClear.method(), + ControlAction::StoresList.method(), + ControlAction::StoresPin { + store: String::new(), + } + .method(), + ControlAction::StoresUnpin { + store: String::new(), + } + .method(), + ControlAction::StoresStatus { + store: String::new(), + } + .method(), + ControlAction::SyncStatus.method(), + ControlAction::SyncTrigger { + store: String::new(), + } + .method(), + ControlAction::UpdaterStatus.method(), + ControlAction::UpdaterSetChannel { + channel: String::new(), + } + .method(), + ControlAction::UpdaterPause { until: None }.method(), + ControlAction::UpdaterResume.method(), + ControlAction::UpdaterCheckNow.method(), + ControlAction::SubsList.method(), + ControlAction::SubsAdd { + store_id: String::new(), + } + .method(), + ControlAction::SubsRemove { + store_id: String::new(), + } + .method(), + // `dig-node peers` drives the live peer status (#559). + "control.peerStatus", + // `dig-node pair …` drives the pairing-admin methods (#280). + "control.pairing.list", + "control.pairing.approve", + "control.pairing.revoke", + ]; + methods.sort_unstable(); + methods.dedup(); + methods +} + +/// A concise human summary of a control result. Falls back to compact JSON for a method with +/// no bespoke line, so every subcommand prints SOMETHING readable even without hand-tuning. +fn summarize(method: &str, result: &Value) -> String { + match method { + "control.status" => format!( + "dig-node {} — up {}s · {} hosted store(s) · {} cached capsule(s) · sync {}", + result["version"].as_str().unwrap_or("?"), + result["uptime_secs"].as_u64().unwrap_or(0), + result["hosted_store_count"].as_u64().unwrap_or(0), + result["cached_capsule_count"].as_u64().unwrap_or(0), + avail(&result["sync"]["available"]), + ), + "control.config.get" => format!( + "addr {} · upstream {} · cache {}", + result["addr"].as_str().unwrap_or("?"), + result["upstream"].as_str().unwrap_or("?"), + result["cache_dir"].as_str().unwrap_or("?"), + ), + "control.config.setUpstream" => format!( + "upstream set to {} (effective on next node start)", + result["upstream"].as_str().unwrap_or("?"), + ), + "control.cache.get" => format!( + "cache {} / {} bytes used/cap · {}", + result["used_bytes"].as_u64().unwrap_or(0), + result["cap_bytes"].as_u64().unwrap_or(0), + result["dir"].as_str().unwrap_or("?"), + ), + "control.cache.setCap" => format!( + "cache cap set to {} bytes", + result["cap_bytes"].as_u64().unwrap_or(0), + ), + "control.cache.clear" => "cache cleared".to_string(), + "control.hostedStores.list" => { + let stores = result["stores"].as_array().map(Vec::len).unwrap_or(0); + format!("{stores} hosted store(s)") + } + "control.sync.status" => format!( + "§21 sync {} · {}/{} pinned store(s) synced", + avail(&result["available"]), + result["pinned_synced"].as_u64().unwrap_or(0), + result["pinned_total"].as_u64().unwrap_or(0), + ), + _ => compact(result), + } +} + +/// "available" / "unavailable" for a boolean sync/availability flag. +fn avail(v: &Value) -> &'static str { + if v.as_bool().unwrap_or(false) { + "available" + } else { + "unavailable" + } +} + +/// Compact single-line JSON for results without a bespoke summary (the pin/unpin/sync-trigger/ +/// updater/subscription results, whose shape is small and self-describing). +fn compact(result: &Value) -> String { + serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::control::CONTROL_METHODS; + + #[test] + fn every_action_maps_to_a_control_method() { + // A representative of each variant → its method is a real `control.*` name. + for m in cli_covered_control_methods() { + assert!(m.starts_with("control."), "{m} is not a control method"); + } + } + + /// PARITY GATE (#426): every `control.*` method the node resolves MUST have a `dig-node` + /// CLI verb, so the CLI never silently falls behind the WS surface the extension drives. + /// A new node control method with no CLI verb fails HERE. + #[test] + fn cli_covers_every_node_control_method() { + let covered = cli_covered_control_methods(); + let missing: Vec<&str> = CONTROL_METHODS + .iter() + .copied() + .filter(|m| !covered.contains(m)) + .collect(); + assert!( + missing.is_empty(), + "these node control methods have NO CLI verb (add one in control_cli.rs): {missing:?}" + ); + } + + #[test] + fn params_carry_the_expected_fields() { + assert_eq!( + ControlAction::CacheSetCap { bytes: 123 }.params(), + json!({ "cap_bytes": 123 }) + ); + assert_eq!( + ControlAction::StoresPin { + store: "abc".into() + } + .params(), + json!({ "store": "abc" }) + ); + assert_eq!( + ControlAction::UpdaterPause { until: Some(99) }.params(), + json!({ "until": 99 }) + ); + // A pause with no deadline sends an empty object (indefinite pause). + assert_eq!( + ControlAction::UpdaterPause { until: None }.params(), + json!({}) + ); + assert_eq!( + ControlAction::SubsAdd { + store_id: "s".into() + } + .params(), + json!({ "store_id": "s" }) + ); + } + + #[test] + fn status_summary_reads_the_key_fields() { + let s = summarize( + "control.status", + &json!({ + "version": "0.37.0", + "uptime_secs": 42, + "hosted_store_count": 3, + "cached_capsule_count": 7, + "sync": { "available": true }, + }), + ); + assert!(s.contains("0.37.0")); + assert!(s.contains("42s")); + assert!(s.contains("3 hosted")); + assert!(s.contains("sync available")); + } + + #[test] + fn unknown_method_summary_falls_back_to_compact_json() { + let s = summarize("control.updater.status", &json!({ "channel": "stable" })); + assert_eq!(s, "{\"channel\":\"stable\"}"); + } +} diff --git a/crates/dig-node-service/src/control_client.rs b/crates/dig-node-service/src/control_client.rs new file mode 100644 index 0000000..8000ca7 --- /dev/null +++ b/crates/dig-node-service/src/control_client.rs @@ -0,0 +1,78 @@ +//! The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface. +//! +//! Every CLI subcommand that drives a `control.*` method — `pair` (#280), and the +//! control-parity commands (`cache`/`stores`/`sync`/`config`/`updater`/`subscriptions`/ +//! `info`, #426) + `peers` (#559) — reaches the running node through THIS one client, so +//! there is exactly ONE codepath for "read the master control token, POST a control method +//! to loopback, unwrap the result". No subcommand forks the transport or the auth. +//! +//! # Auth — the SAME gate the extension uses (never a backdoor) +//! +//! The client presents the node's MASTER control token — read WITHOUT minting one +//! ([`crate::control::load_token_readonly`], #501) — as the `X-Dig-Control-Token` header on +//! `POST /`. This is the exact authorized surface the DIG Browser / extension speak +//! ([`crate::control`]); possession of the on-disk master token = local-machine control, so a +//! mutating CLI control is gated by the same capability as the WS, not an unauthenticated side +//! door. A node running as a service under another OS account surfaces the precise +//! service-vs-user remedy from `load_token_readonly` (elevate / grant read ACL / start the node). + +use serde_json::{json, Value}; + +use crate::config::Config; +use crate::control; + +/// Call one `control.*` method on the running node and return its `result` object. +/// +/// Reads the master control token read-only, builds a single-shot current-thread runtime, +/// and POSTs the JSON-RPC request to the node's loopback address. Every failure surfaces as +/// an [`std::io::Error`] whose KIND maps to the differentiated CLI exit code +/// ([`crate::cli::ExitCode::from_io_error`]): a transport failure → `ConnectionRefused` +/// ("is the node running?"), a JSON-RPC `error` → `Other` (the node's own message). +pub fn call_control(config: &Config, method: &str, params: Value) -> std::io::Result { + let addr = config.bind_addr(); + let token = control::load_token_readonly()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(call_async(&addr, &token, method, params)) +} + +/// POST one JSON-RPC control method with the master token; return its `result` (or `{}` when +/// the node omits one). A transport failure = the node isn't running; a JSON-RPC `error` = +/// the node rejected the call (e.g. a method it does not implement → METHOD_NOT_FOUND). +async fn call_async( + addr: &str, + token: &str, + method: &str, + params: Value, +) -> std::io::Result { + let client = reqwest::Client::builder() + .build() + .map_err(std::io::Error::other)?; + let url = format!("http://{addr}/"); + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let resp = client + .post(&url) + .header(control::CONTROL_TOKEN_HEADER, token) + .json(&body) + .send() + .await + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + format!( + "could not reach the dig-node at {url}: {e} — is it running? \ + Start it with `dig-node run` (or `dig-node start` for the service)." + ), + ) + })?; + let v: Value = resp.json().await.map_err(std::io::Error::other)?; + if let Some(err) = v.get("error") { + let msg = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown control error"); + return Err(std::io::Error::other(format!("dig-node: {msg}"))); + } + Ok(v.get("result").cloned().unwrap_or_else(|| json!({}))) +} diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 626efd1..764bb5d 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -32,8 +32,10 @@ use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use crate::cli::{error_envelope, success_envelope, ExitCode, Outcome}; use crate::config::Config; +use crate::control_cli::{self, ControlAction}; use crate::open; use crate::pair::{self, PairAction}; +use crate::peers::{self, BanState, PeersAction}; use crate::{serve, service, VERSION}; #[derive(Parser)] @@ -88,6 +90,48 @@ enum Command { /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). link: String, }, + /// Detailed node status (the gated `control.status`): version, uptime, cache, hosted-store + + /// cached-capsule counts, §21 sync availability. Distinct from `status` (an unauthenticated + /// liveness probe of /health); this is the token-gated rich view the extension shows. + Info, + /// View or change the node's config (the `control.config.*` surface the extension drives). + Config { + #[command(subcommand)] + action: Option, + }, + /// View or manage the local content cache (the `control.cache.*` surface). + Cache { + #[command(subcommand)] + action: Option, + }, + /// List/pin/unpin hosted stores (the `control.hostedStores.*` surface). + Stores { + #[command(subcommand)] + action: Option, + }, + /// View §21 whole-store sync status or trigger a capsule sync (the `control.sync.*` surface). + Sync { + #[command(subcommand)] + action: Option, + }, + /// Drive the DIG auto-update beacon (the `control.updater.*` surface). + Updater { + #[command(subcommand)] + action: Option, + }, + /// List/add/remove the node's store subscriptions (the `control.subscribe`/`unsubscribe`/ + /// `listSubscriptions` surface). + Subscriptions { + #[command(subcommand)] + action: Option, + }, + /// View + manage the node's peer connections (#559) — parity with the extension's peer surface. + /// With no sub-action, lists the live peer status (running flag, connected count, relay, and — + /// on a newer node — the per-peer list with addresses shown IPv6-first per §5.2). + Peers { + #[command(subcommand)] + action: Option, + }, /// Internal: idempotently register the `dig.local` → `127.0.0.2` OS hosts entry (#91/#503), /// so `http://dig.local` resolves to the node. Invoked by the native install packages; /// requires write access to the hosts file (run elevated). Not meant to be run by hand. @@ -95,6 +139,136 @@ enum Command { EnsureHosts, } +/// `dig-node config` sub-actions. With none, prints the current config. +#[derive(Subcommand)] +enum ConfigCommand { + /// Print the node's effective config (addr/port, upstream, cache dir). + Get, + /// Persist the upstream DIG RPC override (effective on next node start). + SetUpstream { + /// The upstream RPC URL (blank clears the override). + url: String, + }, +} + +/// `dig-node cache` sub-actions. With none, prints the cache config. +#[derive(Subcommand)] +enum CacheCommand { + /// Print the cache cap/used/dir/shared. + Get, + /// Set the on-disk cache size cap in bytes (floored at 64 MiB by the node). + SetCap { + /// The cap in bytes. + bytes: u64, + }, + /// Delete all locally cached DIG content. + Clear, +} + +/// `dig-node stores` sub-actions. With none, lists hosted stores. +#[derive(Subcommand)] +enum StoresCommand { + /// List every hosted/pinned store + its cached capsules. + List, + /// Pin a store (`storeId` or `storeId:rootHash`); pre-fetches when a root is given. + Pin { + /// The store reference (`storeId[:rootHash]`). + store: String, + }, + /// Unpin a store + evict its cached capsules. + Unpin { + /// The store reference (`storeId[:rootHash]`). + store: String, + }, + /// Show one store's pin/cache status. + Status { + /// The store reference (`storeId[:rootHash]`). + store: String, + }, +} + +/// `dig-node sync` sub-actions. With none, prints §21 sync status. +#[derive(Subcommand)] +enum SyncCommand { + /// Print §21 whole-store sync availability + pinned-store coverage. + Status, + /// Trigger a §21 sync for one capsule (`storeId:rootHash`). + Trigger { + /// The capsule reference (`storeId:rootHash`). + store: String, + }, +} + +/// `dig-node updater` sub-actions. With none, prints the beacon status. +#[derive(Subcommand)] +enum UpdaterCommand { + /// Print the DIG auto-update beacon's status. + Status, + /// Set the beacon update channel. + SetChannel { + /// The channel: `nightly` or `stable`. + channel: String, + }, + /// Pause auto-updates, optionally until a unix-seconds deadline (else indefinitely). + Pause { + /// Resume automatically at this unix-seconds time (omit for an indefinite pause). + #[arg(long)] + until: Option, + }, + /// Resume auto-updates. + Resume, + /// Check for an update now. + CheckNow, +} + +/// `dig-node subscriptions` sub-actions. With none, lists subscriptions. +#[derive(Subcommand)] +enum SubscriptionsCommand { + /// List the node's persisted store subscriptions. + List, + /// Subscribe the node to a store id (chain-watch + gap-fill). + Add { + /// The store id (64-hex). + store_id: String, + }, + /// Remove a store subscription. + Remove { + /// The store id (64-hex). + store_id: String, + }, +} + +/// `dig-node peers` sub-actions (#559). With none, lists the live peer status. +#[derive(Subcommand)] +enum PeersCommand { + /// List the live peer status (running flag, connected count, relay, per-peer list). + List, + /// Dial a peer by address or peer_id. + Connect { + /// The peer address or peer_id to dial. + peer: String, + }, + /// Drop a connected peer. + Disconnect { + /// The peer address or peer_id to drop. + peer: String, + }, + /// Block (`ban`), soft-block (`blacklist`), or clear (`none`) a peer. + Ban { + /// The peer address or peer_id. + peer: String, + /// The ban state: `ban`, `blacklist`, or `none`. + #[arg(long)] + state: String, + }, + /// Set the peer-pool max-connections cap. + PoolConfig { + /// The maximum number of pool connections. + #[arg(long)] + max_connections: u32, + }, +} + /// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. #[derive(Subcommand)] enum PairCommand { @@ -124,6 +298,14 @@ impl Command { Command::Status => "status", Command::Pair { .. } => "pair", Command::Open { .. } => "open", + Command::Info => "info", + Command::Config { .. } => "config", + Command::Cache { .. } => "cache", + Command::Stores { .. } => "stores", + Command::Sync { .. } => "sync", + Command::Updater { .. } => "updater", + Command::Subscriptions { .. } => "subscriptions", + Command::Peers { .. } => "peers", Command::EnsureHosts => "ensure-hosts", } } @@ -198,11 +380,111 @@ pub fn run() -> std::process::ExitCode { render(pair::run(&config, pair_action), action, json) } Command::Open { link } => render(open::run(&config, &link), action, json), + Command::Info => render(control_cli::run(&config, ControlAction::Info), action, json), + Command::Config { action: cmd } => { + render(control_cli::run(&config, config_action(cmd)), action, json) + } + Command::Cache { action: cmd } => { + render(control_cli::run(&config, cache_action(cmd)), action, json) + } + Command::Stores { action: cmd } => { + render(control_cli::run(&config, stores_action(cmd)), action, json) + } + Command::Sync { action: cmd } => { + render(control_cli::run(&config, sync_action(cmd)), action, json) + } + Command::Updater { action: cmd } => { + render(control_cli::run(&config, updater_action(cmd)), action, json) + } + Command::Subscriptions { action: cmd } => render( + control_cli::run(&config, subscriptions_action(cmd)), + action, + json, + ), + Command::Peers { action: cmd } => match peers_action(cmd) { + Ok(a) => render(peers::run(&config, a), action, json), + Err(e) => emit_error(&e, action, json), + }, Command::EnsureHosts => render(crate::hosts::run(), action, json), }; std::process::ExitCode::from(exit.code()) } +/// Map the `config` subcommand to its [`ControlAction`] (no sub-action → print the config). +fn config_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(ConfigCommand::Get) => ControlAction::ConfigGet, + Some(ConfigCommand::SetUpstream { url }) => ControlAction::ConfigSetUpstream { url }, + } +} + +/// Map the `cache` subcommand to its [`ControlAction`] (no sub-action → print the cache config). +fn cache_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(CacheCommand::Get) => ControlAction::CacheGet, + Some(CacheCommand::SetCap { bytes }) => ControlAction::CacheSetCap { bytes }, + Some(CacheCommand::Clear) => ControlAction::CacheClear, + } +} + +/// Map the `stores` subcommand to its [`ControlAction`] (no sub-action → list hosted stores). +fn stores_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(StoresCommand::List) => ControlAction::StoresList, + Some(StoresCommand::Pin { store }) => ControlAction::StoresPin { store }, + Some(StoresCommand::Unpin { store }) => ControlAction::StoresUnpin { store }, + Some(StoresCommand::Status { store }) => ControlAction::StoresStatus { store }, + } +} + +/// Map the `sync` subcommand to its [`ControlAction`] (no sub-action → print §21 sync status). +fn sync_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(SyncCommand::Status) => ControlAction::SyncStatus, + Some(SyncCommand::Trigger { store }) => ControlAction::SyncTrigger { store }, + } +} + +/// Map the `updater` subcommand to its [`ControlAction`] (no sub-action → print beacon status). +fn updater_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(UpdaterCommand::Status) => ControlAction::UpdaterStatus, + Some(UpdaterCommand::SetChannel { channel }) => { + ControlAction::UpdaterSetChannel { channel } + } + Some(UpdaterCommand::Pause { until }) => ControlAction::UpdaterPause { until }, + Some(UpdaterCommand::Resume) => ControlAction::UpdaterResume, + Some(UpdaterCommand::CheckNow) => ControlAction::UpdaterCheckNow, + } +} + +/// Map the `subscriptions` subcommand to its [`ControlAction`] (no sub-action → list them). +fn subscriptions_action(cmd: Option) -> ControlAction { + match cmd { + None | Some(SubscriptionsCommand::List) => ControlAction::SubsList, + Some(SubscriptionsCommand::Add { store_id }) => ControlAction::SubsAdd { store_id }, + Some(SubscriptionsCommand::Remove { store_id }) => ControlAction::SubsRemove { store_id }, + } +} + +/// Map the `peers` subcommand to its [`PeersAction`] (no sub-action → list the peer status). +/// The only fallible mapping: a bad `--state` on `ban` becomes a USAGE `io::Error`. +fn peers_action(cmd: Option) -> std::io::Result { + Ok(match cmd { + None | Some(PeersCommand::List) => PeersAction::List, + Some(PeersCommand::Connect { peer }) => PeersAction::Connect { peer }, + Some(PeersCommand::Disconnect { peer }) => PeersAction::Disconnect { peer }, + Some(PeersCommand::Ban { peer, state }) => { + let state = BanState::parse(&state) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + PeersAction::SetBan { peer, state } + } + Some(PeersCommand::PoolConfig { max_connections }) => { + PeersAction::SetPoolConfig { max_connections } + } + }) +} + /// Render a one-shot subcommand outcome: under `--json` emit the success/error envelope /// to stdout; otherwise print the human summary (success → stdout, errors → stderr). /// Returns the exit code. diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index a29f1c6..4da2fb8 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -37,6 +37,14 @@ pub mod config; /// served-store CSP. The wiring lives in [`server`]. pub mod content; pub mod control; +/// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every +/// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, +/// subscriptions), each a thin dispatch over [`control_client`] with `--json`. See [`control_cli`]. +pub mod control_cli; +/// The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface: reads the +/// master control token read-only and POSTs a control method to the node. The ONE transport every +/// control-driving subcommand (`pair`, `control_cli`, `peers`) uses. See [`control_client`]. +pub mod control_client; /// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class /// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over /// it, so the two binaries share ONE codepath and each reports its own invoked name. @@ -51,6 +59,9 @@ pub mod meta; pub mod open; pub mod pair; pub mod pairing; +/// `dig-node peers` (#559): view + manage the node's peer connections from the CLI — parity with +/// the extension's peer surface, driven over the token-gated `control.*` client. See [`peers`]. +pub mod peers; pub mod rpc; /// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a /// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal diff --git a/crates/dig-node-service/src/pair.rs b/crates/dig-node-service/src/pair.rs index ea5b63c..ef94f80 100644 --- a/crates/dig-node-service/src/pair.rs +++ b/crates/dig-node-service/src/pair.rs @@ -20,7 +20,7 @@ use serde_json::{json, Value}; use crate::cli::Outcome; use crate::config::Config; -use crate::control; +use crate::control_client::call_control; /// The operator action, clap-agnostic (mapped from the CLI subcommand in `main.rs`). pub enum PairAction { @@ -32,41 +32,22 @@ pub enum PairAction { Revoke { token_id: String }, } -/// Run a `pair` subcommand: read the master token, call the node, render an -/// [`Outcome`]. Errors (node unreachable, bad id) surface as `io::Error` so +/// Run a `pair` subcommand: read the master token, call the node's `control.pairing.*`, +/// render an [`Outcome`]. The loopback transport + master-token auth is the shared +/// [`call_control`] client. Errors (node unreachable, bad id) surface as `io::Error` so /// `main.rs` maps them to the differentiated exit code. pub fn run(config: &Config, action: PairAction) -> std::io::Result { - let addr = config.bind_addr(); - // Read the master token WITHOUT minting one (#501): if the running node (a service under a - // different OS account) wrote a token this user cannot read, minting a fresh one here would - // recreate the exact bug — a token the node never trusts. `load_token_readonly` instead - // surfaces the precise service-vs-user remedy (elevate / grant read ACL / start the node). - let token = control::load_token_readonly()?; - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - rt.block_on(run_async(&addr, &token, action)) -} - -async fn run_async(addr: &str, token: &str, action: PairAction) -> std::io::Result { - let client = reqwest::Client::builder() - .build() - .map_err(std::io::Error::other)?; - let url = format!("http://{addr}/"); match action { PairAction::List => { - let result = call(&client, &url, token, "control.pairing.list", json!({})).await?; + let result = call_control(config, "control.pairing.list", json!({}))?; Ok(Outcome::new(format_list(&result), result)) } PairAction::Approve { pairing_id } => { - let result = call( - &client, - &url, - token, + let result = call_control( + config, "control.pairing.approve", json!({ "pairing_id": pairing_id }), - ) - .await?; + )?; let name = result["client_name"].as_str().unwrap_or("controller"); let tid = result["token_id"].as_str().unwrap_or(""); Ok(Outcome::new( @@ -79,14 +60,11 @@ async fn run_async(addr: &str, token: &str, action: PairAction) -> std::io::Resu )) } PairAction::Revoke { token_id } => { - let result = call( - &client, - &url, - token, + let result = call_control( + config, "control.pairing.revoke", json!({ "token_id": token_id }), - ) - .await?; + )?; let revoked = result["revoked"].as_bool().unwrap_or(false); let summary = if revoked { format!("dig-node: revoked controller token {token_id}.") @@ -98,43 +76,6 @@ async fn run_async(addr: &str, token: &str, action: PairAction) -> std::io::Resu } } -/// POST one JSON-RPC control method with the master token; return its `result` or an -/// `io::Error` (a transport failure = the node isn't running; a JSON-RPC `error` = -/// the node rejected it). -async fn call( - client: &reqwest::Client, - url: &str, - token: &str, - method: &str, - params: Value, -) -> std::io::Result { - let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); - let resp = client - .post(url) - .header(control::CONTROL_TOKEN_HEADER, token) - .json(&body) - .send() - .await - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::ConnectionRefused, - format!( - "could not reach the dig-node at {url}: {e} — is it running? \ - Start it with `dig-node run` (or `dig-node start` for the service)." - ), - ) - })?; - let v: Value = resp.json().await.map_err(std::io::Error::other)?; - if let Some(err) = v.get("error") { - let msg = err - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("unknown control error"); - return Err(std::io::Error::other(format!("dig-node: {msg}"))); - } - Ok(v.get("result").cloned().unwrap_or_else(|| json!({}))) -} - /// Render `control.pairing.list` as an operator-friendly summary. fn format_list(result: &Value) -> String { let mut out = String::new(); diff --git a/crates/dig-node-service/src/peers.rs b/crates/dig-node-service/src/peers.rs new file mode 100644 index 0000000..80e5a2a --- /dev/null +++ b/crates/dig-node-service/src/peers.rs @@ -0,0 +1,278 @@ +//! `dig-node peers` — view + manage the node's peer connections from the CLI (#559). +//! +//! Parity with the DIG Chrome extension's peer surface (`src/features/peers/peersApi.ts`): +//! the node OWNS peer management (dig-nat + dig-gossip's `AddressManager`); the CLI, like the +//! extension, is a THIN frontend driving it over the token-gated `control.*` RPC surface via +//! the shared [`crate::control_client::call_control`] client (master-token auth over loopback). +//! +//! Subcommands (see `entrypoint.rs`): +//! * `dig-node peers` / `peers list` — the live peer status: running flag, connected count, +//! relay reservation, and (when a newer node fills them) the per-peer list — addresses +//! shown IPv6-FIRST per the ecosystem §5.2 address-family policy. +//! * `dig-node peers connect ` — dial a peer by address or peer_id. +//! * `dig-node peers disconnect ` — drop a connected peer. +//! * `dig-node peers ban --state ` — block / soft-block / clear a peer. +//! * `dig-node peers pool-config --max-connections ` — set the peer-pool connection cap. +//! +//! # Node-side gap (cross-repo follow-up, flagged NOT fixed here) +//! +//! Today the node implements ONLY `control.peerStatus` (a running flag + a connected COUNT); it +//! does not yet return a per-peer list, nor implement the management RPCs +//! (`control.peers.connect`/`disconnect`/`setBan`/`setPoolConfig`) — the SAME gap the extension +//! documents. So the `list` view degrades honestly (count only) and the management verbs return +//! the node's METHOD_NOT_FOUND until the node ships those methods. The CLI verbs exist now so the +//! surface reaches parity and lights up with no CLI change once the node implements them. + +use serde_json::{json, Value}; + +use crate::cli::Outcome; +use crate::config::Config; +use crate::control_client::call_control; + +/// A peer-ban state — soft (blacklist), hard (ban), or cleared (none). Mirrors the extension's +/// `BanState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BanState { + /// Refuse connections from the peer (hard block). + Ban, + /// Do not dial/prefer the peer (soft block). + Blacklist, + /// Clear any ban/blacklist. + None, +} + +impl BanState { + /// The wire token sent as `params.state` (matches the extension's `control.peers.setBan`). + fn as_str(self) -> &'static str { + match self { + BanState::Ban => "ban", + BanState::Blacklist => "blacklist", + BanState::None => "none", + } + } + + /// Parse the `--state` flag value; `Err` names the accepted tokens (USAGE). + pub fn parse(s: &str) -> Result { + match s { + "ban" => Ok(BanState::Ban), + "blacklist" => Ok(BanState::Blacklist), + "none" => Ok(BanState::None), + other => Err(format!( + "invalid ban state {other:?} — expected one of: ban, blacklist, none" + )), + } + } +} + +/// One `peers` action, clap-agnostic (mapped from the subcommand in `entrypoint.rs`). +pub enum PeersAction { + /// List the live peer status (the default `dig-node peers`). + List, + /// Dial a peer by address or peer_id. + Connect { peer: String }, + /// Drop a connected peer. + Disconnect { peer: String }, + /// Block / soft-block / clear a peer. + SetBan { peer: String, state: BanState }, + /// Set the peer-pool max-connections cap. + SetPoolConfig { max_connections: u32 }, +} + +/// Run a `peers` subcommand: dispatch the mapped `control.*` method and render an [`Outcome`]. +pub fn run(config: &Config, action: PeersAction) -> std::io::Result { + match action { + PeersAction::List => { + let result = call_control(config, "control.peerStatus", json!({}))?; + Ok(Outcome::new(format_status(&result), result)) + } + PeersAction::Connect { peer } => { + let result = call_control(config, "control.peers.connect", json!({ "peer": peer }))?; + Ok(Outcome::new( + format!("dig-node: dialing peer {peer}"), + result, + )) + } + PeersAction::Disconnect { peer } => { + let result = call_control(config, "control.peers.disconnect", json!({ "peer": peer }))?; + Ok(Outcome::new( + format!("dig-node: disconnected peer {peer}"), + result, + )) + } + PeersAction::SetBan { peer, state } => { + let result = call_control( + config, + "control.peers.setBan", + json!({ "peer": peer, "state": state.as_str() }), + )?; + Ok(Outcome::new( + format!("dig-node: set ban state {} for peer {peer}", state.as_str()), + result, + )) + } + PeersAction::SetPoolConfig { max_connections } => { + let result = call_control( + config, + "control.peers.setPoolConfig", + json!({ "max_connections": max_connections }), + )?; + Ok(Outcome::new( + format!("dig-node: set peer-pool max_connections to {max_connections}"), + result, + )) + } + } +} + +/// Render `control.peerStatus` as an operator-friendly summary. Shows the running flag, the +/// connected count, and the relay reservation; when a newer node fills the optional per-peer +/// list it prints each peer with its addresses ordered IPv6-first (§5.2). +fn format_status(result: &Value) -> String { + let running = result["running"].as_bool().unwrap_or(false); + if !running { + return "dig-node: peer network is not running (no connected peers).".to_string(); + } + let mut out = format!( + "dig-node peer network: running · {} connected peer(s)", + result["connected_peers"].as_u64().unwrap_or(0), + ); + if let Some(url) = result["relay"]["url"].as_str() { + let reserved = result["relay"]["reserved"].as_bool().unwrap_or(false); + out.push_str(&format!( + "\n relay {url} — reservation {}", + if reserved { "held" } else { "none" } + )); + } + let peers = result["peers"].as_array().cloned().unwrap_or_default(); + if peers.is_empty() { + // Honest degradation: today's node reports a count but not a per-peer list. + out.push_str( + "\n (this node build reports a count only — a per-peer list needs a newer node)", + ); + } else { + out.push_str("\n peers:"); + for p in &peers { + out.push_str(&format!("\n • {}", format_peer(p))); + } + } + out +} + +/// One peer line: `peer_id type/direction addr, addr` with addresses IPv6-first (§5.2). +fn format_peer(p: &Value) -> String { + let id = p["peer_id"].as_str().unwrap_or("?"); + let ty = p["connection_type"].as_str().unwrap_or("?"); + let dir = p["direction"].as_str().unwrap_or("?"); + let addrs = ipv6_first_addrs(p); + let addrs = if addrs.is_empty() { + "(no address)".to_string() + } else { + addrs.join(", ") + }; + format!("{id} {ty}/{dir} {addrs}") +} + +/// A peer's addresses ordered IPv6-FIRST, IPv4 second (§5.2 display policy). PURE. +fn ipv6_first_addrs(p: &Value) -> Vec { + let mut addrs: Vec = p["addresses"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + // IPv6 literals contain ':' inside brackets; IPv4 do not. Stable sort by "is IPv4" so the + // relative order within each family is preserved but IPv6 sorts ahead. + addrs.sort_by_key(|a| is_ipv4(a)); + addrs +} + +/// Heuristic: does this address string look IPv4 (dotted quad, no bracketed IPv6)? PURE. +fn is_ipv4(addr: &str) -> bool { + !addr.contains('[') && addr.matches('.').count() >= 3 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ban_state_parses_and_rejects() { + assert_eq!(BanState::parse("ban").unwrap(), BanState::Ban); + assert_eq!(BanState::parse("blacklist").unwrap(), BanState::Blacklist); + assert_eq!(BanState::parse("none").unwrap(), BanState::None); + assert!(BanState::parse("nope").is_err()); + } + + #[test] + fn not_running_status_reads_clearly() { + let s = format_status(&json!({ "running": false })); + assert!(s.contains("not running")); + } + + #[test] + fn running_status_reports_count_and_relay() { + let s = format_status(&json!({ + "running": true, + "connected_peers": 4, + "relay": { "url": "wss://relay.dig.net:9450", "reserved": true }, + })); + assert!(s.contains("4 connected peer")); + assert!(s.contains("relay.dig.net")); + assert!(s.contains("reservation held")); + // No per-peer list today → the honest degradation note appears. + assert!(s.contains("newer node")); + } + + #[test] + fn ipv6_addresses_sort_before_ipv4() { + let peer = json!({ + "peer_id": "abc", + "addresses": ["1.2.3.4:9444", "[2001:db8::1]:9444", "5.6.7.8:9444"], + }); + let ordered = ipv6_first_addrs(&peer); + assert_eq!(ordered[0], "[2001:db8::1]:9444", "IPv6 comes first (§5.2)"); + assert_eq!(ordered[1], "1.2.3.4:9444"); + assert_eq!(ordered[2], "5.6.7.8:9444"); + } + + #[test] + fn peer_line_shows_id_type_direction_and_ipv6_first() { + let peer = json!({ + "peer_id": "deadbeef", + "connection_type": "direct", + "direction": "outbound", + "addresses": ["9.9.9.9:1", "[fe80::1]:2"], + }); + let line = format_peer(&peer); + assert!(line.contains("deadbeef")); + assert!(line.contains("direct/outbound")); + // IPv6 precedes IPv4 in the rendered address list. + let v6 = line.find("[fe80::1]:2").unwrap(); + let v4 = line.find("9.9.9.9:1").unwrap(); + assert!(v6 < v4, "IPv6 address must render before IPv4"); + } + + #[test] + fn running_status_with_peer_list_renders_each_peer() { + let s = format_status(&json!({ + "running": true, + "connected_peers": 1, + "peers": [{ + "peer_id": "p1", + "connection_type": "relayed", + "direction": "inbound", + "addresses": ["[2001:db8::9]:9444"], + }], + })); + assert!(s.contains("peers:")); + assert!(s.contains("p1")); + assert!(s.contains("relayed/inbound")); + assert!( + !s.contains("newer node"), + "a filled list omits the degradation note" + ); + } +}