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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/skills/e2e-node-management-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ Verify: `running` is `true`, `api_base` contains a URL like `http://127.0.0.1:<p

Save the `api_base` URL for REST API checks later.

**Step 5.4 — Pinned-port flag round-trip:**

Verify `--port` is honored end-to-end. Stop the daemon, restart it with an explicit port, confirm the bound port matches, then restore the OS-assigned default for the rest of the test.

```
ant node daemon stop --json
ant node daemon start --port 18765 --json
ant node daemon status --json
```

Verify on the status response: `running` is `true` and `port` is exactly `18765`. If port 18765 is already in use on the test host, the daemon will fail to start — choose a different free high port and re-run.

Then restore the baseline so the rest of the phases use the default OS-assigned port:

```
ant node daemon stop --json
ant node daemon start --json
```

Re-run `ant node daemon info --json` and update the saved `api_base` URL (the port will have changed).

### Phase 6: Node management

**Step 6.1 — Add nodes:**
Expand Down Expand Up @@ -403,6 +424,7 @@ Phase 5: Daemon Lifecycle
[PASS] 5.1 Daemon start
[PASS] 5.2 Daemon status
[PASS] 5.3 Daemon info
[PASS] 5.4 Pinned-port flag round-trip
Phase 6: Node Management
[PASS] 6.1 Add 3 nodes
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,33 @@ Manage Autonomi network nodes via a local daemon process. The daemon runs in the

#### `ant node daemon start`

Launch the daemon as a detached background process.
Launch the daemon as a detached background process. By default it binds to a random free port on `127.0.0.1` and writes the chosen port to `daemon.port` for discovery.

```
$ ant node daemon start
Daemon started (pid: 12345, port: 48532)
```

**Options:**

| Flag | Description |
|------|-------------|
| `--port <PORT>` | Pin the HTTP port. `0` means OS-assigned (the default behavior). |
| `--listen-addr <IP>` | 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.
Expand Down
54 changes: 48 additions & 6 deletions ant-cli/src/commands/node/daemon.rs
Original file line number Diff line number Diff line change
@@ -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<u16>,

/// 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<IpAddr>,
}

#[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
Expand All @@ -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 {
Expand Down Expand Up @@ -50,10 +82,13 @@ fn resolve_port(config: &DaemonConfig, status_port: Option<u16>) -> Option<u16>

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)?);
Expand All @@ -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);
Expand Down Expand Up @@ -179,7 +221,7 @@ impl DaemonCommand {
}
}
}
DaemonCommand::Run => {
DaemonCommand::Run(_) => {
client::run(config).await?;
}
}
Expand Down
40 changes: 40 additions & 0 deletions ant-core/src/data/client/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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};
Expand Down Expand Up @@ -361,6 +364,43 @@ impl Client {
pub async fn chunk_exists(&self, address: &XorName) -> Result<bool> {
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<QuoteHash, TxHash>,
) -> Result<XorName> {
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)]
Expand Down
94 changes: 93 additions & 1 deletion ant-core/src/node/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ pub async fn start(config: &DaemonConfig) -> Result<DaemonStartResult> {
.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;
Expand Down Expand Up @@ -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<String> {
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<u16> {
std::fs::read_to_string(path)
.ok()
Expand Down Expand Up @@ -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"]);
}
}
31 changes: 31 additions & 0 deletions ant-core/tests/daemon_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading