diff --git a/.claude/skills/e2e-node-management-test/SKILL.md b/.claude/skills/e2e-node-management-test/SKILL.md index 60d1f9e..f75f8bc 100644 --- a/.claude/skills/e2e-node-management-test/SKILL.md +++ b/.claude/skills/e2e-node-management-test/SKILL.md @@ -83,6 +83,27 @@ Verify: `running` is `true`, `api_base` contains a URL like `http://127.0.0.1:

` | Pin the HTTP port. `0` means OS-assigned (the default behavior). | +| `--listen-addr ` | Bind address. Defaults to `127.0.0.1`. | + +Pin the port and bind on all interfaces — useful when the daemon runs inside a container and the API needs to be reachable through a port mapping: + +``` +$ ant node daemon start --listen-addr 0.0.0.0 --port 8765 +``` + +``` +$ docker run -d -p 8765:8765 my/ant-image \ + ant node daemon start --listen-addr 0.0.0.0 --port 8765 +``` + +> **Warning:** the daemon has no authentication. Binding to a non-loopback address exposes node management — start, stop, reset, registry mutation — to anyone who can reach the port. Only do this when the network path is controlled (e.g. a container with an explicit port mapping or a trusted private network). + #### `ant node daemon stop` Shut down the running daemon. Sends SIGTERM and waits for exit. diff --git a/ant-cli/src/commands/node/daemon.rs b/ant-cli/src/commands/node/daemon.rs index d69cca6..a5f981e 100644 --- a/ant-cli/src/commands/node/daemon.rs +++ b/ant-cli/src/commands/node/daemon.rs @@ -1,13 +1,33 @@ -use clap::Subcommand; +use std::net::IpAddr; + +use clap::{Args, Subcommand}; use colored::Colorize; use ant_core::node::daemon::client; use ant_core::node::types::DaemonConfig; +/// Bind overrides shared by `daemon start` and `daemon run`. +#[derive(Args, Clone, Debug, Default)] +pub struct BindArgs { + /// Pin the daemon's HTTP port. Unset (default) lets the OS assign one; + /// `0` is also accepted as an explicit OS-assigned request. + #[arg(long, value_name = "PORT")] + pub port: Option, + + /// Address the daemon binds to. Defaults to `127.0.0.1`. + /// + /// Binding to a non-loopback address (e.g. `0.0.0.0`) exposes node + /// management to anyone who can reach the port. The daemon has no + /// authentication — only do this when the network path is controlled + /// (e.g. inside a container with an explicit port mapping). + #[arg(long, value_name = "IP")] + pub listen_addr: Option, +} + #[derive(Subcommand)] pub enum DaemonCommand { /// Launch the daemon as a detached background process - Start, + Start(BindArgs), /// Shut down the running daemon Stop, /// Show whether the daemon is running and summary stats @@ -16,7 +36,19 @@ pub enum DaemonCommand { Info, /// Run the daemon in the foreground (used internally) #[command(hide = true)] - Run, + Run(BindArgs), +} + +/// Overlay user-provided bind overrides onto `DaemonConfig::default()`. +fn apply_bind_args(args: &BindArgs) -> DaemonConfig { + let mut config = DaemonConfig::default(); + if let Some(port) = args.port { + config.port = Some(port); + } + if let Some(addr) = args.listen_addr { + config.listen_addr = addr; + } + config } fn format_uptime(secs: u64) -> String { @@ -50,10 +82,13 @@ fn resolve_port(config: &DaemonConfig, status_port: Option) -> Option impl DaemonCommand { pub async fn execute(self, json_output: bool) -> anyhow::Result<()> { - let config = DaemonConfig::default(); + let config = match &self { + DaemonCommand::Start(args) | DaemonCommand::Run(args) => apply_bind_args(args), + _ => DaemonConfig::default(), + }; match self { - DaemonCommand::Start => { + DaemonCommand::Start(args) => { let result = client::start(&config).await?; if json_output { println!("{}", serde_json::to_string(&result)?); @@ -67,6 +102,13 @@ impl DaemonCommand { if let Some(p) = port { println!(" {} http://127.0.0.1:{p}/console", "Console".dimmed()); } + if args.port.is_some() || args.listen_addr.is_some() { + println!( + " {} the running daemon was started with different settings; \ + stop it first to apply --port / --listen-addr", + "Note:".yellow() + ); + } } else { let pid = result.pid.to_string().bold(); let port = resolve_port(&config, result.port); @@ -179,7 +221,7 @@ impl DaemonCommand { } } } - DaemonCommand::Run => { + DaemonCommand::Run(_) => { client::run(config).await?; } } diff --git a/ant-core/src/data/client/chunk.rs b/ant-core/src/data/client/chunk.rs index dab11a8..8bbf503 100644 --- a/ant-core/src/data/client/chunk.rs +++ b/ant-core/src/data/client/chunk.rs @@ -3,9 +3,11 @@ //! Chunks are immutable, content-addressed data blocks where the address //! is the BLAKE3 hash of the content. +use crate::data::client::batch::{finalize_batch_payment, PreparedChunk}; use crate::data::client::peer_cache::record_peer_outcome; use crate::data::client::Client; use crate::data::error::{Error, Result}; +use ant_protocol::evm::{QuoteHash, TxHash}; use ant_protocol::transport::{MultiAddr, PeerId}; use ant_protocol::{ compute_address, detect_proof_type, send_and_await_chunk_response, ChunkGetRequest, @@ -14,6 +16,7 @@ use ant_protocol::{ }; use bytes::Bytes; use futures::stream::{FuturesUnordered, StreamExt}; +use std::collections::HashMap; use std::future::Future; use std::time::{Duration, Instant}; use tracing::{debug, warn}; @@ -361,6 +364,43 @@ impl Client { pub async fn chunk_exists(&self, address: &XorName) -> Result { self.chunk_get(address).await.map(|opt| opt.is_some()) } + + /// Finalize a single-chunk publish after an external signer has paid. + /// + /// Single-chunk analogue of [`Client::finalize_upload`]. Takes a + /// [`PreparedChunk`] (from [`Client::prepare_chunk_payment`]) and a + /// `quote_hash -> tx_hash` map containing receipts for every non-zero + /// quote in the chunk's payment. Builds the `PaymentProof` and stores + /// the chunk on `CLOSE_GROUP_MAJORITY` peers, returning its address. + /// + /// Wave-batch payment shape only. Single-chunk publishes don't need + /// Merkle batching: one chunk's worth of quotes is well below the + /// wave-batch threshold. + /// + /// # Errors + /// + /// Returns an error if the proof construction fails (e.g. missing + /// `tx_hash` for a non-zero quote) or if fewer than + /// `CLOSE_GROUP_MAJORITY` peers accept the chunk. + pub async fn finalize_chunk( + &self, + prepared: PreparedChunk, + tx_hash_map: &HashMap, + ) -> Result { + let mut paid = finalize_batch_payment(vec![prepared], tx_hash_map)?; + // finalize_batch_payment returns one PaidChunk per PreparedChunk + // input; we passed exactly one. If that invariant is ever violated + // it's an upstream bug — fail loudly rather than silently address-0. + let chunk = paid.pop().ok_or_else(|| { + Error::Payment( + "finalize_batch_payment returned no paid chunks for a single \ + prepared chunk — internal invariant violated" + .into(), + ) + })?; + self.chunk_put_to_close_group(chunk.content, chunk.proof_bytes, &chunk.quoted_peers) + .await + } } #[cfg(test)] diff --git a/ant-core/src/node/daemon/client.rs b/ant-core/src/node/daemon/client.rs index d70d4de..2331d57 100644 --- a/ant-core/src/node/daemon/client.rs +++ b/ant-core/src/node/daemon/client.rs @@ -120,7 +120,9 @@ pub async fn start(config: &DaemonConfig) -> Result { .to_str() .ok_or_else(|| Error::ProcessSpawn("Executable path is not valid UTF-8".to_string()))?; - let pid = detach::spawn_detached(exe_str, &["node", "daemon", "run"])?; + let args = daemon_run_args(config); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let pid = detach::spawn_detached(exe_str, &arg_refs)?; // Wait briefly for the daemon to write its port file let mut port = None; @@ -349,6 +351,25 @@ fn validate_daemon_process(pid: u32) -> bool { } } +/// Build the arg list passed to the detached `ant node daemon run` child. +/// +/// Overrides are forwarded explicitly so the child binds to the same address +/// and port the caller asked for. Unset fields fall through to the child's +/// own defaults (loopback + OS-assigned port). +fn daemon_run_args(config: &DaemonConfig) -> Vec { + let defaults = DaemonConfig::default(); + let mut args = vec!["node".to_string(), "daemon".to_string(), "run".to_string()]; + if let Some(port) = config.port { + args.push("--port".to_string()); + args.push(port.to_string()); + } + if config.listen_addr != defaults.listen_addr { + args.push("--listen-addr".to_string()); + args.push(config.listen_addr.to_string()); + } + args +} + fn read_port_file(path: &Path) -> Option { std::fs::read_to_string(path) .ok() @@ -432,3 +453,74 @@ fn is_process_alive(pid: u32) -> bool { success != 0 && exit_code == STILL_ACTIVE as u32 } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn run_args_default_config_has_no_overrides() { + let config = DaemonConfig::default(); + let args = daemon_run_args(&config); + assert_eq!(args, vec!["node", "daemon", "run"]); + } + + #[test] + fn run_args_forward_explicit_port() { + let config = DaemonConfig { + port: Some(8765), + ..DaemonConfig::default() + }; + let args = daemon_run_args(&config); + assert_eq!(args, vec!["node", "daemon", "run", "--port", "8765"]); + } + + #[test] + fn run_args_forward_explicit_listen_addr() { + let config = DaemonConfig { + listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED), + ..DaemonConfig::default() + }; + let args = daemon_run_args(&config); + assert_eq!( + args, + vec!["node", "daemon", "run", "--listen-addr", "0.0.0.0"] + ); + } + + #[test] + fn run_args_forward_both_overrides() { + let config = DaemonConfig { + port: Some(8765), + listen_addr: std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED), + ..DaemonConfig::default() + }; + let args = daemon_run_args(&config); + assert_eq!( + args, + vec![ + "node", + "daemon", + "run", + "--port", + "8765", + "--listen-addr", + "0.0.0.0", + ] + ); + } + + #[test] + fn run_args_forward_explicit_zero_port() { + // Explicit `--port 0` is preserved so the user's intent (OS-assigned) + // round-trips through the spawn, even though the child's default would + // produce the same bind behavior. + let config = DaemonConfig { + port: Some(0), + ..DaemonConfig::default() + }; + let args = daemon_run_args(&config); + assert_eq!(args, vec!["node", "daemon", "run", "--port", "0"]); + } +} diff --git a/ant-core/tests/daemon_integration.rs b/ant-core/tests/daemon_integration.rs index 0fee3d8..03255f6 100644 --- a/ant-core/tests/daemon_integration.rs +++ b/ant-core/tests/daemon_integration.rs @@ -110,6 +110,37 @@ async fn port_and_pid_files_written() { ); } +#[tokio::test] +async fn server_binds_to_pinned_port() { + // Reserve a free port by binding to 0, then drop the listener so the + // server can claim it. A tiny TOCTOU race is acceptable in tests. + let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let pinned_port = probe.local_addr().unwrap().port(); + drop(probe); + + let dir = tempfile::tempdir().unwrap(); + let config = DaemonConfig { + port: Some(pinned_port), + ..test_config(&dir) + }; + let port_file = config.port_file_path.clone(); + let registry = NodeRegistry::load(&config.registry_path).unwrap(); + let shutdown = tokio_util::sync::CancellationToken::new(); + + let addr = server::start(config, registry, shutdown.clone()) + .await + .unwrap(); + + assert_eq!(addr.port(), pinned_port, "server bound to the wrong port"); + + let port_contents = std::fs::read_to_string(&port_file).unwrap(); + let written_port: u16 = port_contents.trim().parse().unwrap(); + assert_eq!(written_port, pinned_port); + + shutdown.cancel(); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; +} + #[tokio::test] async fn console_returns_html() { let dir = tempfile::tempdir().unwrap();