From f953d033c357b4eef686eff2ad390dc46285aac2 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:19:56 +0100 Subject: [PATCH 1/7] refactor(core): impl FromStr for PortRange, drop CLI parse_port_range (V2-744) Port-range parsing ("12000" / "12000-12004") lived in ant-cli while the PortRange type lives in ant-core, so every frontend had to reimplement it. Parse via the standard FromStr trait next to the type instead; the CLI now just calls .parse(). Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 31 +-- ant-core/src/error.rs | 3 + ant-core/src/node/types.rs | 57 +++++ vendor/ant-node/Cargo.toml.orig | 198 ++++++++++++++++++ .../.cache/saorsa/bootstrap_cache.json | 7 + .../.cache/saorsa/bootstrap_cache.lock | 0 6 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 vendor/ant-node/Cargo.toml.orig create mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json create mode 100644 vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index d9fea24a..e6605b76 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -150,7 +150,11 @@ impl AddArgs { } fn to_add_node_opts(&self) -> anyhow::Result { - let node_port = self.parse_port_range(&self.node_port)?; + let node_port = self + .node_port + .as_deref() + .map(str::parse::) + .transpose()?; let binary_source = if let Some(ref path) = self.path { BinarySource::LocalPath(path.clone()) @@ -189,31 +193,6 @@ impl AddArgs { }) } - fn parse_port_range(&self, input: &Option) -> anyhow::Result> { - match input { - None => Ok(None), - Some(s) => { - if let Some((start, end)) = s.split_once('-') { - let start: u16 = start - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port range start: '{start}'"))?; - let end: u16 = end - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port range end: '{end}'"))?; - if end < start { - anyhow::bail!("Port range end ({end}) must be >= start ({start})"); - } - Ok(Some(PortRange::Range(start, end))) - } else { - let port: u16 = s - .parse() - .map_err(|_| anyhow::anyhow!("Invalid port: '{s}'"))?; - Ok(Some(PortRange::Single(port))) - } - } - } - } - async fn add_via_daemon( &self, config: &DaemonConfig, diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 2b62460f..313a61bc 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -44,6 +44,9 @@ pub enum Error { #[error("Port range length ({range_len}) does not match node count ({count})")] PortRangeMismatch { range_len: u16, count: u16 }, + #[error("Invalid port range: {0}")] + InvalidPortRange(String), + #[error("Binary not found at path: {0}")] BinaryNotFound(PathBuf), diff --git a/ant-core/src/node/types.rs b/ant-core/src/node/types.rs index 70481e50..079649d3 100644 --- a/ant-core/src/node/types.rs +++ b/ant-core/src/node/types.rs @@ -279,6 +279,36 @@ impl PortRange { } } +impl std::str::FromStr for PortRange { + type Err = crate::error::Error; + + /// Parse `"12000"` into [`PortRange::Single`] or `"12000-12004"` into + /// [`PortRange::Range`]. + fn from_str(s: &str) -> std::result::Result { + use crate::error::Error; + + if let Some((start, end)) = s.split_once('-') { + let start: u16 = start + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid start port '{start}'")))?; + let end: u16 = end + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid end port '{end}'")))?; + if end < start { + return Err(Error::InvalidPortRange(format!( + "end ({end}) must be >= start ({start})" + ))); + } + Ok(Self::Range(start, end)) + } else { + let port: u16 = s + .parse() + .map_err(|_| Error::InvalidPortRange(format!("invalid port '{s}'")))?; + Ok(Self::Single(port)) + } + } +} + /// Options for adding one or more nodes to the registry. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct AddNodeOpts { @@ -586,6 +616,33 @@ mod tests { assert_eq!(pr.port_at(3), None); } + #[test] + fn port_range_parses_single() { + let pr: PortRange = "12000".parse().unwrap(); + assert!(matches!(pr, PortRange::Single(12000))); + } + + #[test] + fn port_range_parses_range() { + let pr: PortRange = "12000-12004".parse().unwrap(); + assert!(matches!(pr, PortRange::Range(12000, 12004))); + } + + #[test] + fn port_range_rejects_inverted_range() { + let err = "12004-12000".parse::().unwrap_err(); + assert!(err.to_string().contains("must be >= start")); + } + + #[test] + fn port_range_rejects_garbage() { + assert!("abc".parse::().is_err()); + assert!("".parse::().is_err()); + assert!("12000-abc".parse::().is_err()); + assert!("-12000".parse::().is_err()); + assert!("70000".parse::().is_err()); + } + #[test] fn binary_source_serializes_with_tag() { let src = BinarySource::Latest; diff --git a/vendor/ant-node/Cargo.toml.orig b/vendor/ant-node/Cargo.toml.orig new file mode 100644 index 00000000..ba489e41 --- /dev/null +++ b/vendor/ant-node/Cargo.toml.orig @@ -0,0 +1,198 @@ +[package] +name = "ant-node" +version = "0.14.2" +edition = "2021" +authors = ["David Irvine "] +description = "Pure quantum-proof network node for the Autonomi decentralized network" +license = "MIT OR Apache-2.0" +repository = "https://github.com/WithAutonomi/ant-node" +keywords = ["p2p", "decentralized", "quantum-safe", "post-quantum", "dht"] +categories = ["network-programming", "cryptography"] +rust-version = "1.75" + +[lib] +name = "ant_node" +path = "src/lib.rs" + +[[bin]] +name = "ant-node" +path = "src/bin/ant-node/main.rs" + +[[bin]] +name = "ant-devnet" +path = "src/bin/ant-devnet/main.rs" + +[dependencies] +# Global allocator. musl's default malloc is significantly slower than +# glibc's under concurrent allocation churn, which matches the node's +# steady-state workload. mimalloc neutralises that regression for the +# musl Linux builds (and tends to beat glibc's allocator too). +mimalloc = "0.1" + +# Wire protocol — the single version-pin shared with ant-client. +# Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples +# through here automatically; we keep a direct saorsa-core dep for +# node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which +# Cargo unifies with ant-protocol's version constraint. +# +# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. +# Until then, the git pin tracks the matching saorsa-core lineage +# (the rc-2026.4.2 branch) so Cargo can unify the wire types here +# with ant-protocol's re-exports. +ant-protocol = "2.2.2" + +# Core (provides EVERYTHING: networking, DHT, security, trust, storage) +saorsa-core = "0.26.2" +saorsa-pqc = "0.5" + +# Payment verification - autonomi network lookup + EVM payment +evmlib = "0.8.1" +xor_name = "5" + +# Caching - LRU cache for verified XorNames +lru = "0.16.3" +parking_lot = "0.12" # Efficient mutex for cache + +# Storage - LMDB via heed for content-addressed chunk store +heed = "0.22" + +blake3 = "1" + +# Async runtime +tokio = { version = "1.35", features = ["full", "signal"] } +tokio-util = { version = "0.7", features = ["rt"] } +futures = "0.3" + +# CLI +clap = { version = "4.5", features = ["derive", "env"] } + +# Configuration +serde = { version = "1", features = ["derive"] } +toml = "0.8" +directories = "5" + +# Auto-upgrade +reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false } +semver = "1" + +# Logging (optional — behind `logging` feature flag) +tracing = { version = "0.1", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"], optional = true } +tracing-appender = { version = "0.2", optional = true } + +# Error handling +thiserror = "2" +color-eyre = "0.6" + +# Serialization +rmp-serde = "1" +hex = "0.4" + +# Utilities +bytes = "1" +chrono = { version = "0.4", features = ["serde"] } +tempfile = "3" +rand = "0.8" +serde_json = "1" + +# Archive extraction for auto-upgrade +flate2 = "1" +tar = "0.4" +zip = "2" + +# SHA-256 hashing for binary cache integrity +sha2 = "0.10" + +# Cross-platform file locking for upgrade caches +fs2 = "0.4" + +# System page size (for LMDB map alignment during resize) +page_size = "0.6" + +# Protocol serialization +postcard = { version = "1.1.3", features = ["use-std"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +self-replace = "1" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } + +[dev-dependencies] +tokio-test = "0.4" +proptest = "1" +alloy = { version = "1", features = ["node-bindings"] } +serial_test = "3" + +# E2E test infrastructure (run with --features test-utils) +[[test]] +name = "e2e" +path = "tests/e2e/mod.rs" +required-features = ["test-utils"] + +# v12 storage-bound audit attack PoCs. Uses the test-only one-shot +# commitment builder/verifier helpers, so it requires the test-utils +# feature. CI runs it via `cargo test --test poc_commitment_audit_attacks +# --features test-utils`. +[[test]] +name = "poc_commitment_audit_attacks" +path = "tests/poc_commitment_audit_attacks.rs" +required-features = ["test-utils"] + +# Live responder-handler tests for the v12 audit. Use +# LmdbStorageConfig::test_default(), gated on test-utils. +[[test]] +name = "poc_audit_handler_live" +path = "tests/poc_audit_handler_live.rs" +required-features = ["test-utils"] + +# Bootstrap-stall DoS regression marker (documents the unfixed attack; the +# eventual fix must land with a follow-up test asserting bounded drain). +# Declared like the other PoC suites so CI invokes it explicitly. +[[test]] +name = "poc_bootstrap_stall" +path = "tests/poc_bootstrap_stall.rs" +required-features = ["test-utils"] + +[features] +default = ["logging"] +# Enable tracing/logging infrastructure. +# Included in `default` so dev builds (`cargo build`, `cargo test`) get logging +# automatically. Release builds strip it: +# cargo build --release --no-default-features +logging = ["tracing", "tracing-subscriber", "tracing-appender"] +# Expose test helpers (cache_insert, payment_verifier accessor) for +# integration tests and downstream test harnesses. +test-utils = [] + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true + +[profile.dev] +# Faster builds for development +opt-level = 1 + +[lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +# Allow async functions that will have await statements added later +unused_async = "allow" +# Allow slightly complex functions during initial development +cognitive_complexity = "allow" +# Allow non-const functions during initial development (may need runtime features later) +missing_const_for_fn = "allow" diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json new file mode 100644 index 00000000..14c5ebc6 --- /dev/null +++ b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "instance_id": "61794_1765636149551", + "timestamp": "2025-12-13T14:29:09.565437Z", + "contacts": {}, + "checksum": 15130871412783076140 +} \ No newline at end of file diff --git a/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock b/vendor/saorsa-core/.cache/saorsa/bootstrap_cache.lock new file mode 100644 index 00000000..e69de29b From 2b272fb48355da83987f0e1d3bc75e76d7350b48 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:20:32 +0100 Subject: [PATCH 2/7] refactor(core): move env KEY=VALUE parsing onto AddNodeOpts (V2-745) Every frontend passing env vars to AddNodeOpts needs the same KEY=VALUE parsing the CLI hand-rolled; expose it as AddNodeOpts::parse_env_vars. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 13 +---------- ant-core/src/error.rs | 3 +++ ant-core/src/node/types.rs | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index e6605b76..df2c9759 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -166,18 +166,7 @@ impl AddArgs { BinarySource::Latest }; - let env_variables: Vec<(String, String)> = self - .env - .iter() - .map(|e| { - let parts: Vec<&str> = e.splitn(2, '=').collect(); - if parts.len() == 2 { - Ok((parts[0].to_string(), parts[1].to_string())) - } else { - anyhow::bail!("Invalid env variable format: '{e}'. Expected KEY=VALUE") - } - }) - .collect::>>()?; + let env_variables = AddNodeOpts::parse_env_vars(&self.env)?; Ok(AddNodeOpts { count: self.count, diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 313a61bc..2a5c71bf 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -47,6 +47,9 @@ pub enum Error { #[error("Invalid port range: {0}")] InvalidPortRange(String), + #[error("Invalid env variable format: '{0}'. Expected KEY=VALUE")] + InvalidEnvVar(String), + #[error("Binary not found at path: {0}")] BinaryNotFound(PathBuf), diff --git a/ant-core/src/node/types.rs b/ant-core/src/node/types.rs index 079649d3..1661969d 100644 --- a/ant-core/src/node/types.rs +++ b/ant-core/src/node/types.rs @@ -340,6 +340,20 @@ pub struct AddNodeOpts { pub evm_network: EvmNetwork, } +impl AddNodeOpts { + /// Parse `KEY=VALUE` strings (the format frontends accept for node env + /// vars) into the pair list `env_variables` expects. + pub fn parse_env_vars(vars: &[String]) -> crate::error::Result> { + vars.iter() + .map(|e| { + e.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| crate::error::Error::InvalidEnvVar(e.clone())) + }) + .collect() + } +} + impl Default for AddNodeOpts { fn default() -> Self { Self { @@ -655,6 +669,32 @@ mod tests { assert!(json.contains("1.0.0")); } + #[test] + fn parse_env_vars_splits_on_first_equals() { + let parsed = AddNodeOpts::parse_env_vars(&[ + "KEY=VALUE".to_string(), + "RUST_LOG=info,ant_node=debug".to_string(), + "EMPTY=".to_string(), + "URL=http://host?a=b".to_string(), + ]) + .unwrap(); + assert_eq!( + parsed, + vec![ + ("KEY".to_string(), "VALUE".to_string()), + ("RUST_LOG".to_string(), "info,ant_node=debug".to_string()), + ("EMPTY".to_string(), String::new()), + ("URL".to_string(), "http://host?a=b".to_string()), + ] + ); + } + + #[test] + fn parse_env_vars_rejects_missing_equals() { + let err = AddNodeOpts::parse_env_vars(&["NOVALUE".to_string()]).unwrap_err(); + assert!(err.to_string().contains("Expected KEY=VALUE")); + } + #[test] fn add_node_opts_default() { let opts = AddNodeOpts::default(); From 3ec9a81c820a354bbf4226dd6686851392480d63 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:21:35 +0100 Subject: [PATCH 3/7] refactor(core): move bootstrap-peer resolution into ant_core::config (V2-742) The explicit-peers > devnet-manifest > bootstrap_peers.toml priority logic (with manifest-peer socket-addr filtering) lived in ant-cli, but it's business logic every frontend needs. Move it to config::resolve_bootstrap_peers; the no-source case is now a typed Error::NoBootstrapPeers. Co-Authored-By: Claude Fable 5 --- ant-cli/src/main.rs | 35 +------------------------ ant-core/src/config.rs | 59 ++++++++++++++++++++++++++++++++++++++++++ ant-core/src/error.rs | 5 ++++ 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index b8523a3b..5b29fc84 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -209,7 +209,7 @@ async fn build_data_client( } let manifest = load_manifest(ctx)?; - let bootstrap = resolve_bootstrap_from(ctx, manifest.as_ref())?; + let bootstrap = ant_core::config::resolve_bootstrap_peers(&ctx.bootstrap, manifest.as_ref())?; // Explicit network selectors should be isolated from the general client // peer cache. `--bootstrap` and `--devnet-manifest` both mean "use exactly // this network entrypoint", so cached public-network peers must not be @@ -454,39 +454,6 @@ fn resolve_evm_network( } } -/// Resolve bootstrap peers from a pre-loaded manifest. -/// -/// Priority: CLI `--bootstrap` > devnet manifest > `bootstrap_peers.toml` config file. -fn resolve_bootstrap_from( - ctx: &DataCliContext, - manifest: Option<&DevnetManifest>, -) -> anyhow::Result> { - if !ctx.bootstrap.is_empty() { - return Ok(ctx.bootstrap.clone()); - } - - if let Some(m) = manifest { - let bootstrap: Vec = m - .bootstrap - .iter() - .filter_map(MultiAddr::socket_addr) - .collect(); - return Ok(bootstrap); - } - - if let Some(peers) = ant_core::config::load_bootstrap_peers() - .map_err(|e| anyhow::anyhow!("Failed to load bootstrap config: {e}"))? - { - info!("Loaded {} bootstrap peer(s) from config file", peers.len()); - return Ok(peers); - } - - anyhow::bail!( - "No bootstrap peers provided. Use --bootstrap, --devnet-manifest, \ - or install bootstrap_peers.toml to your config directory." - ) -} - async fn create_client_node( bootstrap: &[SocketAddr], allow_loopback: bool, diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index 806f15a9..d771156c 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -1,6 +1,7 @@ use std::net::SocketAddr; use std::path::PathBuf; +use crate::data::{DevnetManifest, MultiAddr}; use crate::error::{Error, Result}; /// Returns the platform-appropriate data directory for ant. @@ -79,6 +80,37 @@ pub fn load_bootstrap_peers() -> Result>> { Ok(Some(addrs)) } +/// Resolve the bootstrap peers for a client connection. +/// +/// Priority: explicitly supplied peers (e.g. a frontend's `--bootstrap` +/// flag) > devnet manifest peers > the platform `bootstrap_peers.toml` +/// config file. Manifest peers without a resolvable socket address are +/// filtered out. +/// +/// # Errors +/// +/// Returns [`Error::NoBootstrapPeers`] when no source yields any peers, +/// and propagates config-file read/parse failures. +pub fn resolve_bootstrap_peers( + explicit: &[SocketAddr], + manifest: Option<&DevnetManifest>, +) -> Result> { + if !explicit.is_empty() { + return Ok(explicit.to_vec()); + } + + if let Some(m) = manifest { + return Ok(m.bootstrap.iter().filter_map(MultiAddr::socket_addr).collect()); + } + + if let Some(peers) = load_bootstrap_peers()? { + tracing::info!("Loaded {} bootstrap peer(s) from config file", peers.len()); + return Ok(peers); + } + + Err(Error::NoBootstrapPeers) +} + #[derive(serde::Deserialize)] struct BootstrapConfig { peers: Vec, @@ -117,6 +149,33 @@ mod tests { ); } + fn test_manifest(addrs: Vec) -> DevnetManifest { + DevnetManifest { + base_port: 10000, + node_count: addrs.len(), + bootstrap: addrs.into_iter().map(MultiAddr::quic).collect(), + data_dir: PathBuf::new(), + created_at: String::new(), + evm: None, + } + } + + #[test] + fn resolve_bootstrap_prefers_explicit_peers() { + let explicit: Vec = vec!["10.0.0.1:10000".parse().unwrap()]; + let manifest = test_manifest(vec!["10.0.0.2:10000".parse().unwrap()]); + let peers = resolve_bootstrap_peers(&explicit, Some(&manifest)).unwrap(); + assert_eq!(peers, explicit); + } + + #[test] + fn resolve_bootstrap_uses_manifest_when_no_explicit_peers() { + let addr: SocketAddr = "10.0.0.2:10000".parse().unwrap(); + let manifest = test_manifest(vec![addr]); + let peers = resolve_bootstrap_peers(&[], Some(&manifest)).unwrap(); + assert_eq!(peers, vec![addr]); + } + #[test] fn load_bootstrap_peers_returns_none_when_no_file() { // Set config dir to a temp location where no file exists diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 2a5c71bf..951add6b 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -71,6 +71,11 @@ pub enum Error { #[error("Failed to parse bootstrap_peers.toml: {0}")] BootstrapConfigParse(String), + #[error( + "No bootstrap peers available: pass peers explicitly, use a devnet manifest, or install bootstrap_peers.toml in the config directory" + )] + NoBootstrapPeers, + #[error("Node count {count} exceeds maximum of {max} per call")] InvalidNodeCount { count: u16, max: u16 }, From 4f2ecb7ba4b300053759a6b036cf4372f50bae1e Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:22:24 +0100 Subject: [PATCH 4/7] refactor(core): route node add/reset through the daemon client (V2-741) ant node add / ant node reset hand-rolled reqwest POSTs against the daemon while every other endpoint goes through ant_core::node::daemon::client. Add client::add_node and client::reset and use them from the CLI. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 27 +--------------- ant-cli/src/commands/node/reset.rs | 19 +----------- ant-core/src/node/daemon/client.rs | 49 ++++++++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 46 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index df2c9759..8ff8c1fb 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -103,7 +103,7 @@ impl AddArgs { // Check if daemon is running; if so, POST to API; otherwise call directly let config = DaemonConfig::default(); let result = match client::status(&config).await { - Ok(status) if status.running => self.add_via_daemon(&config, &opts).await?, + Ok(status) if status.running => client::add_node(&config, &opts).await?, _ => self.add_directly(&config, &opts).await?, }; @@ -182,31 +182,6 @@ impl AddArgs { }) } - async fn add_via_daemon( - &self, - config: &DaemonConfig, - opts: &AddNodeOpts, - ) -> anyhow::Result { - let info = client::info(config); - let api_base = info - .api_base - .ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?; - - let client = reqwest::Client::new(); - let resp = client - .post(format!("{api_base}/nodes")) - .json(opts) - .send() - .await?; - - if resp.status().is_success() { - Ok(resp.json().await?) - } else { - let body = resp.text().await?; - anyhow::bail!("Daemon returned error: {body}"); - } - } - async fn add_directly( &self, config: &DaemonConfig, diff --git a/ant-cli/src/commands/node/reset.rs b/ant-cli/src/commands/node/reset.rs index f6f2ad8c..2d17d909 100644 --- a/ant-cli/src/commands/node/reset.rs +++ b/ant-cli/src/commands/node/reset.rs @@ -48,7 +48,7 @@ impl ResetArgs { } let result = if daemon_running { - self.reset_via_daemon(&config).await? + client::reset(&config).await? } else { self.reset_directly(&config)? }; @@ -85,23 +85,6 @@ impl ResetArgs { Ok(()) } - async fn reset_via_daemon(&self, config: &DaemonConfig) -> anyhow::Result { - let info = client::info(config); - let api_base = info - .api_base - .ok_or_else(|| anyhow::anyhow!("Daemon is running but API base URL not available"))?; - - let client = reqwest::Client::new(); - let resp = client.post(format!("{api_base}/reset")).send().await?; - - if resp.status().is_success() { - Ok(resp.json().await?) - } else { - let body = resp.text().await?; - anyhow::bail!("Daemon returned error: {body}"); - } - } - fn reset_directly(&self, config: &DaemonConfig) -> anyhow::Result { let result = ant_core::node::reset(&config.registry_path)?; Ok(result) diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs index eeb81a4f..bf55789b 100644 --- a/ant-core/src/node/daemon/client.rs +++ b/ant-core/src/node/daemon/client.rs @@ -5,8 +5,9 @@ use crate::error::{Error, Result}; use crate::node::daemon::health::FleetHealth; use crate::node::process::detach; use crate::node::types::{ - DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, DaemonStopResult, NodeStarted, - NodeStatusResult, NodeStopped, RemoveNodeResult, StartNodeResult, StopNodeResult, + AddNodeOpts, AddNodeResult, DaemonConfig, DaemonInfo, DaemonStartResult, DaemonStatus, + DaemonStopResult, NodeStarted, NodeStatusResult, NodeStopped, RemoveNodeResult, ResetResult, + StartNodeResult, StopNodeResult, }; /// Get the daemon's current status by querying its REST API. @@ -205,6 +206,50 @@ pub async fn stop_node(config: &DaemonConfig, node_id: u32) -> Result Result { + let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; + + let url = format!("http://127.0.0.1:{port}/api/v1/nodes"); + let resp = reqwest::Client::new() + .post(&url) + .json(opts) + .send() + .await + .map_err(|e| Error::HttpRequest(e.to_string()))?; + + if resp.status().is_success() { + resp.json::() + .await + .map_err(|e| Error::HttpRequest(e.to_string())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(Error::HttpRequest(body)) + } +} + +/// Reset all node state — clear the registry and remove node data/log +/// directories — via the daemon REST API. +pub async fn reset(config: &DaemonConfig) -> Result { + let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; + + let url = format!("http://127.0.0.1:{port}/api/v1/reset"); + let resp = reqwest::Client::new() + .post(&url) + .send() + .await + .map_err(|e| Error::HttpRequest(e.to_string()))?; + + if resp.status().is_success() { + resp.json::() + .await + .map_err(|e| Error::HttpRequest(e.to_string())) + } else { + let body = resp.text().await.unwrap_or_default(); + Err(Error::HttpRequest(body)) + } +} + /// Dismiss a node — remove it from the registry — via the daemon REST API. /// /// Intended for evicted nodes (whose data directory has already been deleted), but the daemon will From 892f81517abe61c5a7e2ec8468cc4949ab6a6daf Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:22:52 +0100 Subject: [PATCH 5/7] fix(core): resolve service name to node ID via the daemon API (V2-740) ant node start/stop --service-name read node_registry.json directly to translate name -> ID even while the daemon (the registry's owner) is running, so the daemon could mutate the registry between the CLI's read and the API call. Add client::resolve_node_id_by_name, which resolves through GET /nodes/status, and drop the CLI's direct registry read. Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/start.rs | 9 +++------ ant-cli/src/commands/node/stop.rs | 8 +++----- ant-core/src/error.rs | 3 +++ ant-core/src/node/daemon/client.rs | 15 +++++++++++++++ 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ant-cli/src/commands/node/start.rs b/ant-cli/src/commands/node/start.rs index 506e2095..75cb51e8 100644 --- a/ant-cli/src/commands/node/start.rs +++ b/ant-cli/src/commands/node/start.rs @@ -33,12 +33,9 @@ impl StartArgs { service_name: &str, json_output: bool, ) -> anyhow::Result<()> { - // Look up node ID by service name via the registry - let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?; - let node = registry - .find_by_service_name(service_name) - .ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?; - let node_id = node.id; + // Resolve the node ID through the daemon API — the daemon owns the + // registry, so the CLI must not read node_registry.json directly. + let node_id = client::resolve_node_id_by_name(config, service_name).await?; let result = client::start_node(config, node_id).await?; diff --git a/ant-cli/src/commands/node/stop.rs b/ant-cli/src/commands/node/stop.rs index b522734b..8c2bfc0c 100644 --- a/ant-cli/src/commands/node/stop.rs +++ b/ant-cli/src/commands/node/stop.rs @@ -33,11 +33,9 @@ impl StopArgs { service_name: &str, json_output: bool, ) -> anyhow::Result<()> { - let registry = ant_core::node::registry::NodeRegistry::load(&config.registry_path)?; - let node = registry - .find_by_service_name(service_name) - .ok_or_else(|| anyhow::anyhow!("No node found with service name '{service_name}'"))?; - let node_id = node.id; + // Resolve the node ID through the daemon API — the daemon owns the + // registry, so the CLI must not read node_registry.json directly. + let node_id = client::resolve_node_id_by_name(config, service_name).await?; let result = client::stop_node(config, node_id).await?; diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 951add6b..cfa212db 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -11,6 +11,9 @@ pub enum Error { #[error("Node not found: {0}")] NodeNotFound(u32), + #[error("No node found with service name '{0}'")] + NodeNotFoundByName(String), + #[error("Node already running: {0}")] NodeAlreadyRunning(u32), diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs index bf55789b..90704b34 100644 --- a/ant-core/src/node/daemon/client.rs +++ b/ant-core/src/node/daemon/client.rs @@ -312,6 +312,21 @@ pub async fn node_status(config: &DaemonConfig) -> Result { } } +/// Resolve a node's ID from its service name via the daemon REST API. +/// +/// Goes through the daemon — the registry's owner — rather than reading +/// node_registry.json directly, so the lookup cannot observe a +/// half-written registry or race a concurrent mutation by the daemon. +pub async fn resolve_node_id_by_name(config: &DaemonConfig, service_name: &str) -> Result { + let status = node_status(config).await?; + status + .nodes + .iter() + .find(|n| n.name == service_name) + .map(|n| n.node_id) + .ok_or_else(|| Error::NodeNotFoundByName(service_name.to_string())) +} + /// Stop all running nodes via the daemon REST API. pub async fn stop_all_nodes(config: &DaemonConfig) -> Result { let port = read_port_file(&config.port_file_path).ok_or(Error::DaemonNotRunning)?; From 20c389cd78a9f4a8474408536d43a99801c6696c Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:23:39 +0100 Subject: [PATCH 6/7] refactor(cli): consolidate the two ProgressReporter impls (V2-746) CliProgress (node add, unicode bar to stdout) and CliUpdateProgress (self-update, plain percent to stderr) were near-identical impls of the same trait. Keep one CliProgress in ant-cli/src/progress.rs, writing to stderr like the rest of the progress UI. Also fixes: ant node add --json no longer interleaves download-progress output with the JSON result (NoopProgress in JSON mode, matching ant update). Co-Authored-By: Claude Fable 5 --- ant-cli/src/commands/node/add.rs | 43 ++++++++------------------------ ant-cli/src/commands/update.rs | 22 ++-------------- ant-cli/src/progress.rs | 33 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 52 deletions(-) diff --git a/ant-cli/src/commands/node/add.rs b/ant-cli/src/commands/node/add.rs index 8ff8c1fb..5dc61e7f 100644 --- a/ant-cli/src/commands/node/add.rs +++ b/ant-cli/src/commands/node/add.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::Args; use colored::Colorize; -use ant_core::node::binary::ProgressReporter; +use ant_core::node::binary::{NoopProgress, ProgressReporter}; use ant_core::node::daemon::client; use ant_core::node::types::DaemonConfig; use ant_core::node::types::{ @@ -104,7 +104,7 @@ impl AddArgs { let config = DaemonConfig::default(); let result = match client::status(&config).await { Ok(status) if status.running => client::add_node(&config, &opts).await?, - _ => self.add_directly(&config, &opts).await?, + _ => self.add_directly(&config, &opts, json_output).await?, }; if json_output { @@ -186,38 +186,17 @@ impl AddArgs { &self, config: &DaemonConfig, opts: &AddNodeOpts, + json_output: bool, ) -> anyhow::Result { - let progress = CliProgress; + // Suppress progress in JSON mode so stdout stays parseable. + let progress: Box = if json_output { + Box::new(NoopProgress) + } else { + Box::new(crate::progress::CliProgress) + }; let result = - ant_core::node::add_nodes(opts.clone(), &config.registry_path, &progress).await?; + ant_core::node::add_nodes(opts.clone(), &config.registry_path, progress.as_ref()) + .await?; Ok(result) } } - -/// CLI progress reporter that prints to the terminal. -struct CliProgress; - -impl ProgressReporter for CliProgress { - fn report_started(&self, message: &str) { - println!("{} {message}", "⟳".cyan()); - } - - fn report_progress(&self, bytes: u64, total: u64) { - if total > 0 { - let pct = (bytes as f64 / total as f64 * 100.0) as u32; - let bar_width = 30; - let filled = (pct as usize * bar_width) / 100; - let empty = bar_width - filled; - let bar = format!( - "{}{}", - "█".repeat(filled).cyan(), - "░".repeat(empty).dimmed() - ); - print!("\r {} {bar} {pct:>3}%", "Downloading".dimmed()); - } - } - - fn report_complete(&self, message: &str) { - println!("\r{} {message}", "✓".green().bold()); - } -} diff --git a/ant-cli/src/commands/update.rs b/ant-cli/src/commands/update.rs index 45de66ca..3b65070c 100644 --- a/ant-cli/src/commands/update.rs +++ b/ant-cli/src/commands/update.rs @@ -4,25 +4,7 @@ use colored::Colorize; use ant_core::node::binary::NoopProgress; use ant_core::update; -/// Progress reporter that prints to the terminal. -struct CliUpdateProgress; - -impl ant_core::node::binary::ProgressReporter for CliUpdateProgress { - fn report_started(&self, message: &str) { - eprintln!("{}", message.dimmed()); - } - - fn report_progress(&self, bytes: u64, total: u64) { - if total > 0 { - let pct = (bytes as f64 / total as f64 * 100.0) as u64; - eprint!("\r{}", format!(" Downloading... {pct}%").dimmed()); - } - } - - fn report_complete(&self, message: &str) { - eprintln!("\r{}", message.green()); - } -} +use crate::progress::CliProgress; #[derive(Args)] pub struct UpdateArgs { @@ -72,7 +54,7 @@ impl UpdateArgs { let progress: Box = if json_output { Box::new(NoopProgress) } else { - Box::new(CliUpdateProgress) + Box::new(CliProgress) }; let result = update::perform_update(&check, progress.as_ref()).await?; diff --git a/ant-cli/src/progress.rs b/ant-cli/src/progress.rs index 17112667..d8a01774 100644 --- a/ant-cli/src/progress.rs +++ b/ant-cli/src/progress.rs @@ -13,9 +13,12 @@ use std::io::{self, IsTerminal, Write}; use std::sync::OnceLock; use std::time::Duration; +use colored::Colorize; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use tracing_subscriber::fmt::MakeWriter; +use ant_core::node::binary::ProgressReporter; + static MULTI: OnceLock = OnceLock::new(); /// The shared `MultiProgress` instance. Created on first access. @@ -51,6 +54,36 @@ pub fn attach(pb: ProgressBar) -> ProgressBar { } } +/// Terminal implementation of ant-core's `ProgressReporter` (binary +/// downloads during `node add` and self-update). Writes to stderr so +/// stdout stays clean for command output. +pub struct CliProgress; + +impl ProgressReporter for CliProgress { + fn report_started(&self, message: &str) { + eprintln!("{} {message}", "⟳".cyan()); + } + + fn report_progress(&self, bytes: u64, total: u64) { + if total > 0 { + let pct = (bytes as f64 / total as f64 * 100.0) as u32; + let bar_width = 30; + let filled = (pct as usize * bar_width) / 100; + let empty = bar_width - filled; + let bar = format!( + "{}{}", + "█".repeat(filled).cyan(), + "░".repeat(empty).dimmed() + ); + eprint!("\r {} {bar} {pct:>3}%", "Downloading".dimmed()); + } + } + + fn report_complete(&self, message: &str) { + eprintln!("\r{} {message}", "✓".green().bold()); + } +} + /// Tracing writer that suspends active progress bars while writing log lines. #[derive(Clone)] pub struct ProgressAwareWriter; From e3c1540fcdbf138a3483320937b4b7973373b0f2 Mon Sep 17 00:00:00 2001 From: Nic Date: Thu, 23 Jul 2026 09:26:55 +0100 Subject: [PATCH 7/7] chore: changelog for the V2-189 CLI-thinning wave + fmt Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++++ ant-core/src/config.rs | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd8eb0ef..185a6394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `ant node start`/`ant node stop` with `--service-name` now resolve the node ID through the daemon API instead of reading `node_registry.json` directly, eliminating a race against concurrent registry mutations by the daemon. +- `ant node add --json` no longer interleaves binary-download progress with the JSON result; progress output now goes to stderr (was stdout) and is suppressed entirely in JSON mode. + ### Changed - Default network binding changed from IPv4-only to IPv6 dual-stack. Hosts without a working IPv6 stack should pass `--ipv4-only` to avoid advertising unreachable v6 addresses to the DHT (which causes slow connects and junk address records). - `ant file upload` now writes datamaps as `..datamap` instead of stripping the extension. Uploading `photo.jpg` produces `photo.jpg.datamap` (was `photo.datamap`). Existing datamaps remain readable. @@ -18,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ant file download --datamap` now reads both msgpack (canonical) and legacy JSON datamaps, so datamaps produced by older versions of the GUI download cleanly via the CLI. ### Internal +- CLI-audit thinning (V2-189): `PortRange` parsing (`FromStr`), env `KEY=VALUE` parsing (`AddNodeOpts::parse_env_vars`), and bootstrap-peer resolution (`config::resolve_bootstrap_peers`) moved from ant-cli into ant-core; `node add`/`node reset` daemon calls now go through `ant_core::node::daemon::client` (new `add_node`/`reset`/`resolve_node_id_by_name` functions) instead of hand-rolled HTTP; the two CLI `ProgressReporter` impls collapsed into one. - New `ant_core::datamap_file` module owns the on-disk datamap format (msgpack canonical, JSON legacy auto-detect on read) and naming convention. `ant-cli` and consumers like `ant-gui` route through this single helper instead of reimplementing serialization. ## [0.1.1] - 2026-03-28 diff --git a/ant-core/src/config.rs b/ant-core/src/config.rs index d771156c..d14eb5eb 100644 --- a/ant-core/src/config.rs +++ b/ant-core/src/config.rs @@ -100,7 +100,11 @@ pub fn resolve_bootstrap_peers( } if let Some(m) = manifest { - return Ok(m.bootstrap.iter().filter_map(MultiAddr::socket_addr).collect()); + return Ok(m + .bootstrap + .iter() + .filter_map(MultiAddr::socket_addr) + .collect()); } if let Some(peers) = load_bootstrap_peers()? {