Skip to main content

decdn_cli/commands/
node.rs

1//! `decdn node ...` — operator-local admin commands that talk to a running
2//! node over its loopback JSON-RPC admin surface (`appendix-local-admin-http`).
3
4use std::io;
5use std::io::Write as _;
6use std::num::NonZeroU64;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::time::Duration;
10
11use alloy::primitives::Address;
12use anyhow::Context;
13use iroh::{EndpointAddr, PublicKey};
14use jsonrpsee::core::client::Error as JsonRpcClientError;
15use jsonrpsee::http_client::HttpClientBuilder;
16use serde::{Deserialize, Serialize};
17
18use decdn_client::discovery::{self, NodeCandidate, SELECT_K, select_candidates};
19use decdn_client::endpoint as client_endpoint;
20use decdn_client::probe::probe_once;
21use decdn_common::admin::{
22    AdminRpcClient, BindingStatus, BuyerPoolsResponse, DrainRequest, DrainResponse, EvictRequest,
23    EvictResponse, HealthResponse, LaneSnapshot, LanesResponse, ReloadResponse, SlashesResponse,
24    StatusResponse,
25};
26use decdn_common::cli;
27use decdn_common::cli::ConfigPathSource;
28use decdn_common::cli::common::expand_tilde;
29use decdn_common::config::DEFAULT_ADMIN_PORT;
30use decdn_protocol::Region;
31
32use crate::commands::chain_ctx;
33
34/// Partial deserializer for the TOML config — only the path
35/// `observability.admin_port` is interesting to the `node` admin
36/// subcommands. Kept private here (rather than reusing
37/// `decdn_common::config::FileConfig`) so an operator's typo in an
38/// unrelated section can't make these commands unusable. `serde(default)`
39/// and serde-toml's default
40/// "ignore unknown fields" together guarantee that any other valid
41/// TOML — including missing tables — round-trips through with no
42/// effect.
43#[derive(Debug, Default, Deserialize)]
44struct AdminPortConfig {
45    observability: Option<AdminPortObservability>,
46}
47
48#[derive(Debug, Default, Deserialize)]
49struct AdminPortObservability {
50    admin_port: Option<u16>,
51}
52
53/// Dispatch a `decdn node <sub>` invocation.
54///
55/// `global_config` is the path (if any) from the top-level `decdn
56/// --config` flag. It's consulted as a fallback when the subcommand
57/// didn't set its own `--config`, so `decdn --config foo.toml node
58/// health` works the way the help text implies.
59pub async fn node_dispatch(
60    args: &cli::NodeArgs,
61    global_config: Option<&Path>,
62) -> anyhow::Result<()> {
63    match &args.cmd {
64        cli::NodeCommand::Health(h) => health(h, global_config).await,
65        cli::NodeCommand::Status(s) => status(s, global_config).await,
66        cli::NodeCommand::Lanes(c) => lanes(c, global_config).await,
67        cli::NodeCommand::Slashes(s) => slashes(s, global_config).await,
68        cli::NodeCommand::Pools(p) => pools(p, global_config).await,
69        cli::NodeCommand::Evict(e) => evict(e, global_config).await,
70        cli::NodeCommand::Reload(r) => reload(r, global_config).await,
71        cli::NodeCommand::Drain(d) => drain(d, global_config).await,
72        cli::NodeCommand::Top(t) => crate::commands::node_top::run(t, global_config).await,
73        cli::NodeCommand::Register(r) => crate::commands::register::run(r, global_config).await,
74        cli::NodeCommand::Bond(b) => crate::commands::bond::run(b, global_config).await,
75        cli::NodeCommand::Unbond(u) => crate::commands::unbond::run(u, global_config).await,
76        cli::NodeCommand::Deregister(d) => crate::commands::deregister::run(d, global_config).await,
77        cli::NodeCommand::RotateKey(r) => crate::commands::rotate_key::run(r, global_config).await,
78        cli::NodeCommand::UpdateMultiaddrs(u) => {
79            crate::commands::update_multiaddrs::run(u, global_config).await
80        }
81        cli::NodeCommand::UpdateRegion(u) => {
82            crate::commands::update_region::run(u, global_config).await
83        }
84        cli::NodeCommand::Lookup(l) => lookup(l, global_config).await,
85        cli::NodeCommand::Doctor(d) => crate::commands::doctor::run(d, global_config).await,
86    }
87}
88
89/// `decdn node health`: call `admin_v1_health` on the running node and
90/// print the result. Two-line plain text by default (`node_id=…` /
91/// `uptime_s=…`), or pretty JSON with `--json`.
92pub async fn health(args: &cli::HealthArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
93    anyhow::ensure!(
94        args.timeout_ms > 0,
95        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
96         'never' rather than 'sub-millisecond deadline')"
97    );
98
99    let config_path = args.config.as_deref().or(global_config);
100    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
101
102    let client = HttpClientBuilder::default()
103        .request_timeout(Duration::from_millis(args.timeout_ms))
104        .build(&url)
105        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
106
107    let resp: HealthResponse = client
108        .health()
109        .await
110        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
111
112    if args.json {
113        let pretty =
114            serde_json::to_string_pretty(&resp).context("failed to encode health as JSON")?;
115        println!("{pretty}");
116    } else {
117        // Stable, grep-friendly lines so `decdn node health | grep node_id=`
118        // works in operator scripts without `--json`.
119        let mut out = io::stdout().lock();
120        write_health(&mut out, &resp).context("failed to write health output")?;
121    }
122
123    Ok(())
124}
125
126/// Render `admin_v1_health` as `key=value` lines. Pure (`&mut impl Write`) so
127/// the shape — in particular the binding lines, which an operator script keys
128/// on — is unit-testable without a running node.
129fn write_health(w: &mut impl io::Write, resp: &HealthResponse) -> io::Result<()> {
130    writeln!(w, "node_id={}", resp.node_id)?;
131    writeln!(w, "uptime_s={}", resp.uptime_s)?;
132    // Always printed, including `unknown`. An absent line would read as "fine"
133    // to a script grepping for a problem, and `unknown` means the opposite:
134    // the node's slashability was never verified (#1034).
135    writeln!(w, "binding={}", binding_label(resp.binding))?;
136    // Only meaningful when there IS a binding; on a mismatch this names the
137    // key to restore, which is the whole reason the field is carried.
138    if let Some(bound) = &resp.bound_node_id {
139        writeln!(w, "bound_node_id={bound}")?;
140    }
141    // Always printed (#1030). `false` is the answer to "the node is up and
142    // healthy, why is it earning nothing": it is absent from the on-chain
143    // registry, so it accrues no governance weight (its declared capacity caps
144    // credited bytes at zero) and peers have no reason to route to a node they
145    // cannot slash. Nothing on the wire says so — the node simply gets no
146    // requests — which is exactly why this line exists.
147    writeln!(w, "registry_active={}", resp.registry_active)?;
148    Ok(())
149}
150
151/// Wire spelling of [`BindingStatus`], matching its `snake_case` serde
152/// representation so the plain and `--json` renderings agree.
153const fn binding_label(status: BindingStatus) -> &'static str {
154    match status {
155        BindingStatus::Bound => "bound",
156        BindingStatus::Mismatch => "mismatch",
157        BindingStatus::Unbound => "unbound",
158        BindingStatus::Unknown => "unknown",
159    }
160}
161
162/// `decdn node evict`: call `admin_v1_evict` on the running node to
163/// forcibly remove a single blob from the local cache (issue #279), or
164/// preview what the evict would touch when `--dry-run` is set (issue
165/// #379).
166pub async fn evict(args: &cli::EvictArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
167    anyhow::ensure!(
168        args.timeout_ms > 0,
169        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
170         'never' rather than 'sub-millisecond deadline')"
171    );
172
173    let config_path = args.config.as_deref().or(global_config);
174    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
175
176    let client = HttpClientBuilder::default()
177        .request_timeout(Duration::from_millis(args.timeout_ms))
178        .build(&url)
179        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
180
181    let resp: EvictResponse = client
182        .evict(EvictRequest {
183            hash: args.hash.clone(),
184            dry_run: args.dry_run,
185        })
186        .await
187        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
188
189    if args.json {
190        let pretty =
191            serde_json::to_string_pretty(&resp).context("failed to encode evict response")?;
192        println!("{pretty}");
193    } else if args.dry_run {
194        // Multi-line, grep-friendly so operators can pipe through
195        // `grep size_bytes=` / `grep already_evicted=` in scripts. Each
196        // line stands on its own; the "dry_run=true" tag is the
197        // load-bearing indicator that no state was mutated.
198        write_dry_run_human(&mut io::stdout().lock(), &args.hash, &resp)
199            .context("failed to write dry-run preview")?;
200    } else {
201        let presence = if resp.was_present {
202            "evicted"
203        } else {
204            // Operator ran evict on a hash the node never held — log it
205            // explicitly rather than printing nothing, otherwise scripts
206            // can't tell success-with-no-effect from a hung command.
207            "not present"
208        };
209        println!("hash={} status={presence}", args.hash);
210    }
211
212    Ok(())
213}
214
215/// Format the dry-run preview as a multi-line plain-text block. Pure
216/// function (takes `&mut impl Write`) so unit tests can assert exact
217/// output without an HTTP round-trip — same pattern as
218/// [`write_status`].
219fn write_dry_run_human(w: &mut impl io::Write, hash: &str, resp: &EvictResponse) -> io::Result<()> {
220    writeln!(w, "hash={hash}")?;
221    writeln!(w, "dry_run=true")?;
222    writeln!(w, "was_present={}", resp.was_present)?;
223    writeln!(w, "pinned={}", resp.preview.pinned)?;
224    writeln!(w, "already_evicted={}", resp.preview.already_evicted)?;
225    match resp.preview.size_bytes {
226        Some(b) => writeln!(w, "size_bytes={b}")?,
227        // Distinct from "0" (a legitimately empty blob): the iroh-blobs
228        // status reported `NotFound`, or a partial blob whose last chunk has
229        // not arrived (`Partial { size: None }`), not `Complete { size: 0 }`.
230        None => writeln!(w, "size_bytes=not_stored")?,
231    }
232    match resp.preview.last_accessed_us_ago {
233        Some(us) => writeln!(w, "last_accessed={}", format_age(us))?,
234        // Distinct from "<1s ago"; operators want to know the
235        // engine has *no* access record since the node started vs a very
236        // recent one. A blob hot before a restart also reads this way.
237        None => writeln!(w, "last_accessed=none_since_start")?,
238    }
239    // Origin egress-cost cue (#439). Distinct from omitting the line
240    // when the engine has no origins: operators evaluating disk-reclaim
241    // potential against re-fetch cost want this signal explicitly,
242    // not buried in "the field is missing because there's no origin
243    // at all". `none` matches the JSON serialisation skip-condition
244    // semantically (`Vec::is_empty` is omitted in JSON, surfaced as
245    // `none` here). Multi-origin chains (#284) render as a
246    // comma-separated list in declared order.
247    if resp.preview.origin_kinds.is_empty() {
248        writeln!(w, "origin_kinds=none")?;
249    } else {
250        let joined = resp
251            .preview
252            .origin_kinds
253            .iter()
254            .map(ToString::to_string)
255            .collect::<Vec<_>>()
256            .join(",");
257        writeln!(w, "origin_kinds={joined}")?;
258    }
259    Ok(())
260}
261
262/// `decdn node reload`: call `admin_v1_reload` on the running node to
263/// re-read the config file it was started with and apply the
264/// hot-reloadable subset (issue #373). Equivalent to `kill -HUP <pid>`
265/// for operators who'd rather not stat the PID — and shares the same
266/// internal mutex, so concurrent SIGHUPs queue rather than race.
267pub async fn reload(args: &cli::ReloadArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
268    anyhow::ensure!(
269        args.timeout_ms > 0,
270        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
271         'never' rather than 'sub-millisecond deadline')"
272    );
273
274    let config_path = args.config.as_deref().or(global_config);
275    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
276
277    let client = HttpClientBuilder::default()
278        .request_timeout(Duration::from_millis(args.timeout_ms))
279        .build(&url)
280        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
281
282    let resp: ReloadResponse = client
283        .reload()
284        .await
285        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
286
287    if args.json {
288        let pretty =
289            serde_json::to_string_pretty(&resp).context("failed to encode reload response")?;
290        println!("{pretty}");
291    } else {
292        // Stable, grep-friendly line so an operator can do
293        // `decdn node reload | grep log_level=` without `--json`.
294        // Same shape as `decdn node health`'s plain output.
295        println!("log_level={}", resp.log_level);
296    }
297
298    Ok(())
299}
300
301/// `decdn node drain`: call `admin_v1_drain` on the running node to trigger
302/// graceful shutdown (issue #244, `appendix-local-admin-http.md`).
303///
304/// Without `--wait`: fire-and-forget — returns `drain_initiated=true` as
305/// soon as the trigger is queued; observe completion via process exit
306/// or `decdn node health` until ECONNREFUSED.
307///
308/// With `--wait` (issue #604): asks the runtime to keep the admin
309/// server alive through `router.shutdown` (`DrainRequest { wait_admin:
310/// true }`), then polls `admin_v1_health` for `in_flight_streams == 0`.
311/// Returns `Ok` once the count reaches 0 or admin closes
312/// (ECONNREFUSED — drain finished and tore admin down). Returns `Err`
313/// with `drain_timeout=true in_flight_streams=N` on stderr if
314/// `--wait-timeout-secs` elapses first.
315pub async fn drain(args: &cli::DrainArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
316    anyhow::ensure!(
317        args.timeout_ms > 0,
318        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
319         'never' rather than 'sub-millisecond deadline')"
320    );
321    // `wait_timeout_secs` and `wait_poll_ms` are `NonZeroU64` — clap
322    // rejects `0` at parse time, so no further runtime check needed.
323
324    let config_path = args.config.as_deref().or(global_config);
325    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
326
327    let client = HttpClientBuilder::default()
328        .request_timeout(Duration::from_millis(args.timeout_ms))
329        .build(&url)
330        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
331
332    let resp: DrainResponse = client
333        .drain(DrainRequest {
334            wait_admin: args.wait,
335        })
336        .await
337        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
338
339    if !args.wait {
340        if args.json {
341            let pretty =
342                serde_json::to_string_pretty(&resp).context("failed to encode drain response")?;
343            println!("{pretty}");
344        } else {
345            // One stable, grep-friendly line. "Initiated", not "completed":
346            // fire-and-forget semantics mean the process is still running when
347            // this prints. Observe completion via `decdn node health` until
348            // ECONNREFUSED, or let the process supervisor (systemd/K8s) notify.
349            println!("drain_initiated={}", resp.initiated);
350        }
351        return Ok(());
352    }
353
354    // `--wait` is only safe when the server is actually keeping admin
355    // alive through `router.shutdown`. The drain trigger is
356    // first-writer-wins, so when another caller's drain without
357    // `wait_admin` fired first, the server reports
358    // `wait_admin_honored=false`. Refuse to enter the polling loop in
359    // that case — otherwise the imminent ECONNREFUSED from the early
360    // admin tear-down would be misread as drain completion while
361    // in-flight streams are still running, defeating the
362    // zero-payment-loss guarantee. An ack of `true` covers admin-RPC
363    // races only: a SIGINT/SIGTERM that wins the runtime's shutdown
364    // select still takes the early ordering (`admin_stop_order`).
365    if !resp.wait_admin_honored {
366        // The drain is already underway server-side and cannot be
367        // undone; the actionable advice is to observe completion via
368        // process exit or health-until-ECONNREFUSED.
369        return Err(anyhow::anyhow!(
370            "admin at {url} did not honor --wait (wait_admin_honored=false): \
371             an earlier drain without wait_admin already started shutdown, so the \
372             admin server closes before in-flight streams finish. Observe \
373             completion via process exit or `decdn node health` until \
374             ECONNREFUSED",
375        ));
376    }
377
378    // `--wait` path: drain has been initiated with `wait_admin=true`,
379    // and the server acked it. Poll until `in_flight_streams == 0`,
380    // admin closes (ECONNREFUSED — the runtime tore admin down after
381    // `router.shutdown` returned), or the wall-clock budget elapses.
382    // Both terminal-success paths emit the same JSON shape so machine
383    // consumers see a single schema regardless of which branch fires.
384    let deadline = std::time::Instant::now() + Duration::from_secs(args.wait_timeout_secs.get());
385    let poll_interval = Duration::from_millis(args.wait_poll_ms.get());
386    loop {
387        let h = match client.health().await {
388            Ok(h) => h,
389            Err(JsonRpcClientError::Transport(inner))
390                if is_admin_closed_transport_error(inner.as_ref()) =>
391            {
392                // Admin closed — runtime finished `router.shutdown` and
393                // moved on. In-flight streams are necessarily zero
394                // (router awaited them all). Treat
395                // ConnectionRefused/Reset/Aborted and UnexpectedEof
396                // all the same: each is a way for a TCP connection to
397                // observe "the listener / accepted socket went away."
398                emit_drain_complete(&mut io::stdout().lock(), args.json, true)
399                    .context("failed to write drain-complete output")?;
400                return Ok(());
401            }
402            // Transient errors during polling shouldn't end the wait
403            // — the polling loop has its own wall-clock deadline. A
404            // single 5s RPC timeout against a momentarily-busy admin
405            // would otherwise collapse a 30s drain budget after one
406            // tick. Retry until the deadline check fires; the
407            // deadline is the single authority on "give up".
408            // Bundling `RequestTimeout` and non-close `Transport`
409            // into the same arm — they have the same retry policy.
410            Err(JsonRpcClientError::RequestTimeout | JsonRpcClientError::Transport(_)) => {
411                check_deadline_and_sleep(deadline, poll_interval, args.wait_timeout_secs).await?;
412                continue;
413            }
414            // Server-side JSON-RPC errors during polling indicate
415            // something genuinely unexpected (MethodNotFound here
416            // would have been caught upstream by the
417            // `wait_admin_honored=false` guard, so any `Call(...)` is
418            // novel). Bail with a clear diagnostic.
419            Err(JsonRpcClientError::Call(obj)) => {
420                return Err(anyhow::anyhow!(
421                    "admin at {url} returned JSON-RPC error {} during --wait poll: {}",
422                    obj.code(),
423                    obj.message(),
424                ));
425            }
426            Err(other) => {
427                return Err(classify_client_error(&url, args.timeout_ms, other));
428            }
429        };
430        if h.in_flight_streams == 0 {
431            emit_drain_complete(&mut io::stdout().lock(), args.json, false)
432                .context("failed to write drain-complete output")?;
433            return Ok(());
434        }
435        check_deadline_and_sleep_with_count(
436            deadline,
437            poll_interval,
438            args.wait_timeout_secs,
439            h.in_flight_streams,
440        )
441        .await?;
442    }
443}
444
445/// Sleep until `poll_interval` elapses or `deadline` is reached,
446/// whichever fires first; on deadline overrun, return `Err` with the
447/// "no in-flight count known" timeout shape. Sleep is racy with
448/// `ctrl_c` so an operator can interrupt a long wait without
449/// `SIGKILL`ing the process.
450async fn check_deadline_and_sleep(
451    deadline: std::time::Instant,
452    poll_interval: Duration,
453    wait_timeout_secs: NonZeroU64,
454) -> anyhow::Result<()> {
455    if std::time::Instant::now() >= deadline {
456        let _ = writeln!(
457            io::stderr().lock(),
458            "drain_timeout=true wait_timeout_secs={wait_timeout_secs}"
459        );
460        return Err(anyhow::anyhow!(
461            "drain --wait timed out after {wait_timeout_secs}s"
462        ));
463    }
464    sleep_or_ctrl_c(poll_interval).await
465}
466
467/// Variant of [`check_deadline_and_sleep`] used when the last
468/// successful health response is available, so the timeout diagnostic
469/// can report the last-known in-flight count to the operator.
470async fn check_deadline_and_sleep_with_count(
471    deadline: std::time::Instant,
472    poll_interval: Duration,
473    wait_timeout_secs: NonZeroU64,
474    in_flight: u64,
475) -> anyhow::Result<()> {
476    if std::time::Instant::now() >= deadline {
477        let _ = writeln!(
478            io::stderr().lock(),
479            "drain_timeout=true in_flight_streams={in_flight} \
480             wait_timeout_secs={wait_timeout_secs}"
481        );
482        return Err(anyhow::anyhow!(
483            "drain --wait timed out after {wait_timeout_secs}s; \
484             {in_flight} stream(s) still in flight"
485        ));
486    }
487    sleep_or_ctrl_c(poll_interval).await
488}
489
490/// Sleep for `dur` but resolve early on Ctrl-C, returning `Err` so
491/// the polling loop can short-circuit with a clean exit. Without the
492/// `ctrl_c` race, a long `--wait-poll-ms` would leave the operator
493/// wedged for the remainder of the tick before they could escape.
494async fn sleep_or_ctrl_c(dur: Duration) -> anyhow::Result<()> {
495    tokio::select! {
496        () = tokio::time::sleep(dur) => Ok(()),
497        result = tokio::signal::ctrl_c() => {
498            result.context("ctrl_c handler failed")?;
499            let _ = writeln!(
500                io::stderr().lock(),
501                "drain --wait interrupted by Ctrl-C; drain continues server-side \
502                 (use `decdn node health` to observe completion)",
503            );
504            Err(anyhow::anyhow!("drain --wait interrupted"))
505        }
506    }
507}
508
509/// Write the unified terminal-success record for `decdn node drain
510/// --wait` to `w`. Both the polled-to-zero and ECONNREFUSED branches
511/// share the same `{drain_complete, in_flight_streams, admin_closed}`
512/// shape so machine consumers don't have to parse two unrelated
513/// schemas depending on which path fired (#662 review). Same
514/// `&mut impl io::Write` pattern as [`write_status`] so tests
515/// can assert exact bytes without a stdout capture.
516fn emit_drain_complete(w: &mut impl io::Write, json: bool, admin_closed: bool) -> io::Result<()> {
517    if json {
518        let value = serde_json::json!({
519            "drain_complete": true,
520            "in_flight_streams": 0,
521            "admin_closed": admin_closed,
522        });
523        writeln!(w, "{value}")
524    } else {
525        writeln!(
526            w,
527            "drain_complete=true in_flight_streams=0 admin_closed={admin_closed}"
528        )
529    }
530}
531
532/// `decdn node status`: call `admin_v1_status` on the running node and
533/// print a snapshot of its DHT participation health (issue #741).
534pub async fn status(args: &cli::StatusArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
535    anyhow::ensure!(
536        args.timeout_ms > 0,
537        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
538         'never' rather than 'sub-millisecond deadline')"
539    );
540
541    let config_path = args.config.as_deref().or(global_config);
542    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
543
544    let client = HttpClientBuilder::default()
545        .request_timeout(Duration::from_millis(args.timeout_ms))
546        .build(&url)
547        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
548
549    let parsed: StatusResponse = client
550        .status()
551        .await
552        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
553
554    if args.json {
555        let pretty =
556            serde_json::to_string_pretty(&parsed).context("failed to encode status as JSON")?;
557        println!("{pretty}");
558    } else {
559        let mut stdout = io::stdout().lock();
560        write_status(&mut stdout, &parsed, wall_clock_us())
561            .context("failed to write status report")?;
562    }
563
564    Ok(())
565}
566
567/// `decdn node lanes`: call `admin_v1_lanes` on the running node and
568/// print a snapshot of its open payment lanes (issue #749).
569pub async fn lanes(args: &cli::LanesArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
570    anyhow::ensure!(
571        args.timeout_ms > 0,
572        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
573         'never' rather than 'sub-millisecond deadline')"
574    );
575
576    let config_path = args.config.as_deref().or(global_config);
577    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
578
579    let client = HttpClientBuilder::default()
580        .request_timeout(Duration::from_millis(args.timeout_ms))
581        .build(&url)
582        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
583
584    let parsed: LanesResponse = client
585        .lanes()
586        .await
587        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
588
589    if args.json {
590        let pretty =
591            serde_json::to_string_pretty(&parsed).context("failed to encode lanes as JSON")?;
592        println!("{pretty}");
593    } else {
594        let mut stdout = io::stdout().lock();
595        write_lanes_table(&mut stdout, &parsed).context("failed to write lanes table")?;
596    }
597
598    Ok(())
599}
600
601/// Write the lane table to `w`. Pure function (takes `&mut impl
602/// Write`) so the formatting is unit-testable without an HTTP hop,
603/// mirroring [`write_status`]. A summary line
604/// carries the redemption threshold as a stable `key=value` token; the
605/// per-lane table follows. USDC amounts are rendered from micro-USDC.
606fn write_lanes_table(w: &mut impl io::Write, resp: &LanesResponse) -> io::Result<()> {
607    writeln!(
608        w,
609        "redeem_threshold={} lanes={}",
610        format_usdc(resp.redeem_threshold_micro_usdc),
611        resp.lanes.len(),
612    )?;
613    if resp.lanes.is_empty() {
614        return writeln!(w, "(no open lanes)");
615    }
616    // Fixed-column layout: lane preview | counterparty preview | voucher
617    // signer preview | nonce | outstanding | deposit | last-voucher age |
618    // eligible.
619    writeln!(
620        w,
621        "{:<14} {:<14} {:<14} {:>6} {:>12} {:>12} {:>12} ELIGIBLE",
622        "LANE", "COUNTERPARTY", "SIGNER", "NONCE", "OUTSTANDING", "DEPOSIT", "LAST_VOUCHER",
623    )?;
624    for c in &resp.lanes {
625        write_lane_row(w, c)?;
626    }
627    Ok(())
628}
629
630/// Render one lane as a fixed-column row. Split out so the column
631/// formatting stays in one place and the loop body reads as a single call.
632fn write_lane_row(w: &mut impl io::Write, c: &LaneSnapshot) -> io::Result<()> {
633    let lane = short_node_id(&c.pool_id);
634    let counterparty = short_node_id(&c.counterparty);
635    let signer = short_node_id(&c.voucher_signer);
636    let last_voucher = match c.seconds_since_last_voucher {
637        // No voucher seen since this process started — distinct from
638        // "<1s ago" so operators know the activity clock has no record
639        // (a freshly-restarted node, or a lane that has never billed).
640        None => "never".to_string(),
641        // Reuse `format_age` (microsecond input) by scaling the whole-second
642        // wire value; `saturating_mul` clamps the (unrealistic) overflow on an
643        // absurd age rather than panicking. Boundary buckets are identical
644        // (covered by `format_age_units`).
645        Some(secs) => format_age(secs.saturating_mul(1_000_000)),
646    };
647    let eligible = if c.settlement_eligible { "yes" } else { "no" };
648    writeln!(
649        w,
650        "{lane:<14} {counterparty:<14} {signer:<14} {:>6} {:>12} {:>12} {last_voucher:>12} \
651         {eligible}",
652        c.last_nonce,
653        format_usdc(c.outstanding_micro_usdc),
654        format_usdc(c.deposit_micro_usdc),
655    )
656}
657
658/// Format a micro-USDC amount as a `"N.NNNNNN"` USDC string. USDC has 6
659/// decimals (ADR 003), so 1 USDC == `1_000_000` micro-USDC. Trailing
660/// fractional zeros are kept fixed-width (six places) so columns align and
661/// a script parsing the value sees a stable shape.
662fn format_usdc(micro: u64) -> String {
663    let whole = micro / 1_000_000;
664    let frac = micro % 1_000_000;
665    format!("{whole}.{frac:06}")
666}
667
668/// `decdn node slashes`: call `admin_v1_slashes` on the running node and
669/// print every detected slash against its operator (#1032). Plain text by
670/// default (a summary line + a per-slash table), or pretty JSON with
671/// `--json`.
672pub async fn slashes(args: &cli::SlashesArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
673    anyhow::ensure!(
674        args.timeout_ms > 0,
675        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
676         'never' rather than 'sub-millisecond deadline')"
677    );
678
679    let config_path = args.config.as_deref().or(global_config);
680    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
681
682    let client = HttpClientBuilder::default()
683        .request_timeout(Duration::from_millis(args.timeout_ms))
684        .build(&url)
685        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
686
687    let parsed: SlashesResponse = client
688        .slashes()
689        .await
690        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
691
692    if args.json {
693        let pretty =
694            serde_json::to_string_pretty(&parsed).context("failed to encode slashes as JSON")?;
695        println!("{pretty}");
696    } else {
697        let mut stdout = io::stdout().lock();
698        write_slashes_table(&mut stdout, &parsed).context("failed to write slashes table")?;
699    }
700
701    Ok(())
702}
703
704/// `decdn node pools`: call `admin_v1_pools` on the running node and print its
705/// buyer-side `PaymentPool` state — the pools it pays upstream providers from.
706///
707/// This is the only read path to the daemon's `buyer.redb`: redb holds a
708/// process-exclusive lock on that file for the node's lifetime, so nothing can
709/// open it from disk while the node is up (#2078).
710pub async fn pools(args: &cli::PoolsArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
711    anyhow::ensure!(
712        args.timeout_ms > 0,
713        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
714         'never' rather than 'sub-millisecond deadline')"
715    );
716
717    let config_path = args.config.as_deref().or(global_config);
718    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
719
720    let client = HttpClientBuilder::default()
721        .request_timeout(Duration::from_millis(args.timeout_ms))
722        .build(&url)
723        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
724
725    let parsed: BuyerPoolsResponse = client
726        .pools()
727        .await
728        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
729
730    if args.json {
731        let pretty =
732            serde_json::to_string_pretty(&parsed).context("failed to encode pools as JSON")?;
733        println!("{pretty}");
734    } else {
735        crate::commands::pool::write_skipped_buyer_pools(&mut io::stderr().lock(), &parsed.skipped)
736            .context("failed to write skipped pools")?;
737        let mut stdout = io::stdout().lock();
738        // Name the daemon that answered, for the same reason `pool list` names
739        // the file it read: an unattributed `pools=0` is the defect (#2078).
740        writeln!(stdout, "store={url} (admin RPC)").context("failed to write pools header")?;
741        crate::commands::pool::write_buyer_pools(&mut stdout, &parsed)
742            .context("failed to write pools table")?;
743    }
744
745    Ok(())
746}
747
748/// Write the slash table to `w`. Pure function (takes `&mut impl
749/// Write`) so the formatting is unit-testable without an HTTP hop,
750/// mirroring [`write_lanes_table`]. A summary line carries the count as a
751/// stable `key=value` token; the per-slash table follows.
752fn write_slashes_table(w: &mut impl io::Write, resp: &SlashesResponse) -> io::Result<()> {
753    writeln!(w, "slashes={}", resp.slashes.len())?;
754    if resp.slashes.is_empty() {
755        return writeln!(w, "(no slashes detected)");
756    }
757    writeln!(
758        w,
759        "{:<20} {:>6} {:>14} {:>10} {:>10} APPEAL_CLOSE",
760        "SLASH_ID", "TYPE", "AMOUNT", "BLOCK", "EVIDENCE",
761    )?;
762    for s in &resp.slashes {
763        let block = s
764            .block_number
765            .map_or_else(|| "?".to_string(), |b| b.to_string());
766        let close = s
767            .appeal_window_close
768            .map_or_else(|| "?".to_string(), |c| c.to_string());
769        writeln!(
770            w,
771            "{:<20} {:>6} {:>14} {:>10} {:>10} {close}",
772            s.slash_id,
773            s.offense_type,
774            s.amount,
775            block,
776            short_node_id(&s.evidence_hash),
777        )?;
778    }
779    Ok(())
780}
781
782/// Map a `jsonrpsee` client error into the three operator-actionable
783/// classes the previous reqwest path exposed:
784///
785/// - `Transport` with an `ECONNREFUSED` in the source chain → "admin
786///   isn't there" (check it's running / port).
787/// - `RequestTimeout` → "admin is slow" (stuck lock, overloaded).
788/// - `Call` → the server returned a JSON-RPC application error; surface
789///   the code and message so the operator can tell a misconfigured
790///   method name from a real server failure.
791/// - Anything else → passed through with the URL as context.
792pub(crate) fn classify_client_error(
793    url: &str,
794    timeout_ms: u64,
795    err: JsonRpcClientError,
796) -> anyhow::Error {
797    match err {
798        JsonRpcClientError::RequestTimeout => anyhow::anyhow!(
799            "admin at {url} did not respond within {timeout_ms}ms; \
800             the node may be overloaded or blocked on a long lock hold",
801        ),
802        JsonRpcClientError::Transport(inner) => {
803            if is_connection_refused(inner.as_ref()) {
804                anyhow::anyhow!(
805                    "admin at {url} refused the connection ({inner}); is the node \
806                     running, and is admin_port configured correctly?",
807                )
808            } else {
809                anyhow::anyhow!("admin request to {url} failed: {inner}")
810            }
811        }
812        JsonRpcClientError::Call(obj) => anyhow::anyhow!(
813            "admin at {url} returned JSON-RPC error {code}: {msg}",
814            code = obj.code(),
815            msg = obj.message(),
816        ),
817        other => anyhow::Error::new(other).context(format!("admin request to {url} failed")),
818    }
819}
820
821/// Walk the `source()` chain looking for an `std::io::Error` of kind
822/// `ConnectionRefused`. The hyper/jsonrpsee error hierarchy is several
823/// layers deep and the exact intermediate types are implementation
824/// details, so match on the innermost `io::Error` kind instead of any
825/// particular transport type.
826pub(crate) fn is_connection_refused(err: &(dyn std::error::Error + 'static)) -> bool {
827    io_error_kind_in_source_chain(err, |kind| kind == std::io::ErrorKind::ConnectionRefused)
828}
829
830/// Walk the `source()` chain looking for any `std::io::Error` whose
831/// kind indicates the admin server's listener (or an already-accepted
832/// connection) has gone away. Used by the `--wait` polling loop to
833/// detect "admin closed after `router.shutdown` returned" — which is
834/// the late-stop success terminal. `ConnectionRefused` is the most
835/// common (new connection rejected because listener dropped), but
836/// `ConnectionReset` / `ConnectionAborted` / `UnexpectedEof` also
837/// occur when a poll lands mid-response as the server tears down.
838/// Without these, the polling loop would treat a race-window error
839/// as fatal and turn drain completion into a non-zero exit.
840fn is_admin_closed_transport_error(err: &(dyn std::error::Error + 'static)) -> bool {
841    io_error_kind_in_source_chain(err, |kind| {
842        matches!(
843            kind,
844            std::io::ErrorKind::ConnectionRefused
845                | std::io::ErrorKind::ConnectionReset
846                | std::io::ErrorKind::ConnectionAborted
847                | std::io::ErrorKind::UnexpectedEof
848        )
849    })
850}
851
852/// Helper: walk the `source()` chain, return `true` if any layer is
853/// an `io::Error` whose kind matches `predicate`.
854fn io_error_kind_in_source_chain(
855    err: &(dyn std::error::Error + 'static),
856    predicate: impl Fn(std::io::ErrorKind) -> bool,
857) -> bool {
858    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
859    while let Some(e) = current {
860        if let Some(io_err) = e.downcast_ref::<std::io::Error>()
861            && predicate(io_err.kind())
862        {
863            return true;
864        }
865        current = e.source();
866    }
867    false
868}
869
870/// Resolve the admin URL in this precedence order:
871///
872/// 1. `--admin-url` flag or the `DECDN_ADMIN_URL` env var (clap folds the
873///    env into `args.admin_url`).
874/// 2. `DECDN_ADMIN_PORT` — the same env var the daemon reads for its admin
875///    bind (`common/src/cli/run.rs`). An env-only deployment (port set via
876///    env, no config file) would otherwise be unreachable: the CLI would
877///    dial the default 9191 and report "is the node running?" (#864). A
878///    malformed or `0` value errors rather than silently falling through.
879/// 3. `observability.admin_port` from the TOML config file — either the
880///    caller's explicit path or the default `~/.decdn/node.toml`. An
881///    explicit path that doesn't exist is an error; a missing default
882///    path falls through. `admin_port = 0` in the file is an operator
883///    opt-out and errors here rather than silently probing the default.
884/// 4. Default `http://127.0.0.1:9191`.
885pub(crate) fn resolve_admin_url(
886    flag: Option<&str>,
887    config_path: Option<&Path>,
888) -> anyhow::Result<String> {
889    if let Some(url) = flag {
890        return Ok(url.to_string());
891    }
892    if let Some(url) = admin_url_from_env(std::env::var("DECDN_ADMIN_PORT").ok())? {
893        return Ok(url);
894    }
895    let (resolved_path, source) = match config_path {
896        Some(p) => (Some(expand_tilde(p)), ConfigPathSource::Explicit),
897        None => (
898            cli::common::default_config_path(),
899            ConfigPathSource::Default,
900        ),
901    };
902    let port =
903        port_from_config_file(resolved_path.as_deref(), source)?.unwrap_or(DEFAULT_ADMIN_PORT);
904    Ok(format!("http://127.0.0.1:{port}"))
905}
906
907/// Build the loopback admin URL from the raw `DECDN_ADMIN_PORT` env value, or
908/// `None` when the var is unset. Pure (takes the value rather than reading the
909/// environment) so the parse/validation logic is testable without mutating
910/// process-global state. A non-numeric value or `0` (the daemon's "admin
911/// disabled" sentinel) is an error — both indicate misconfiguration the
912/// operator should see rather than have masked by a fall-through to the
913/// config file or the built-in default.
914fn admin_url_from_env(raw: Option<String>) -> anyhow::Result<Option<String>> {
915    let Some(raw) = raw else { return Ok(None) };
916    let port: u16 = raw
917        .trim()
918        .parse()
919        .map_err(|_| anyhow::anyhow!("DECDN_ADMIN_PORT is not a valid port number: {raw:?}"))?;
920    if port == 0 {
921        anyhow::bail!(
922            "DECDN_ADMIN_PORT=0 disables the admin server; pass --admin-url or set a non-zero port"
923        );
924    }
925    Ok(Some(format!("http://127.0.0.1:{port}")))
926}
927
928/// Read `observability.admin_port` from a TOML config file. Returns:
929/// - `Ok(None)` when `path` is `None`, or when `path` is the *default*
930///   path and the file doesn't exist (operator hasn't set up a config
931///   file yet — fall through to the built-in default).
932/// - `Ok(Some(port))` for a positive port value.
933/// - `Err` if the file exists but can't be parsed, if the operator
934///   explicitly set `admin_port = 0` (which disables the server), or if
935///   `source` is `Explicit` and the file is missing/unreadable.
936///
937/// Callers are expected to have already tilde-expanded the path;
938/// `resolve_admin_url` is the only intended caller and does so via
939/// [`expand_tilde`] / [`cli::common::default_config_path`].
940fn port_from_config_file(
941    path: Option<&Path>,
942    source: ConfigPathSource,
943) -> anyhow::Result<Option<u16>> {
944    let Some(path) = path else { return Ok(None) };
945    let path: PathBuf = path.to_path_buf();
946    match std::fs::read_to_string(&path) {
947        Ok(contents) => {
948            // Deliberately a partial deserializer: parsing the full
949            // `FileConfig` would couple these commands to every
950            // unrelated field's well-formedness. An operator with a
951            // typo'd `[payments]` table shouldn't lose the ability to
952            // resolve the admin port. Serde's TOML mode ignores unknown
953            // fields by default, so this only fails on (a) genuinely
954            // malformed TOML or (b) a wrong type for
955            // `observability.admin_port` itself — both of which we
956            // genuinely want to surface.
957            let parsed: AdminPortConfig = toml::from_str(&contents)
958                .with_context(|| format!("failed to parse config file {}", path.display()))?;
959            match parsed.observability.and_then(|o| o.admin_port) {
960                Some(0) => anyhow::bail!(
961                    "config {} disables the admin server (observability.admin_port = 0); \
962                     pass --admin-url or enable the admin port",
963                    path.display()
964                ),
965                other => Ok(other),
966            }
967        }
968        Err(err) => match (err.kind(), source) {
969            // Default path not yet created: fall through to the built-in.
970            (io::ErrorKind::NotFound, ConfigPathSource::Default) => Ok(None),
971            // Permission problems usually point at file mode / ownership,
972            // not a wrong path — give the operator that nudge rather than
973            // a generic "failed to read".
974            (io::ErrorKind::PermissionDenied, _) => Err(anyhow::anyhow!(
975                "cannot read config file {}: permission denied; check file mode and ownership",
976                path.display()
977            )),
978            _ => Err(anyhow::anyhow!(
979                "failed to read config file {}: {err}",
980                path.display()
981            )),
982        },
983    }
984}
985
986fn short_node_id(hex: &str) -> String {
987    // Unicode '…' (U+2026) rather than "..." so a pasted preview is
988    // unambiguously a preview and never parses as hex.
989    let mut chars = hex.chars();
990    let prefix: String = chars.by_ref().take(12).collect();
991    if chars.next().is_some() {
992        format!("{prefix}…")
993    } else {
994        prefix
995    }
996}
997
998fn wall_clock_us() -> u64 {
999    std::time::SystemTime::now()
1000        .duration_since(std::time::UNIX_EPOCH)
1001        .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
1002}
1003
1004/// Coarse operator-facing age bucket. On-call use case is "is this peer
1005/// fresh in the last N {s,m,h,d}?"; sub-second precision would only add
1006/// noise to the table.
1007fn relative_age(now_us: u64, last_seen_us: u64) -> String {
1008    if last_seen_us == 0 {
1009        return "unknown".to_string();
1010    }
1011    if last_seen_us > now_us {
1012        // Clock skew: report as "in the future" rather than a huge negative
1013        // unsigned delta. Honest about the condition without panicking.
1014        return "in future".to_string();
1015    }
1016    let delta_us = now_us - last_seen_us;
1017    format_age(delta_us)
1018}
1019
1020fn format_age(delta_us: u64) -> String {
1021    const US_PER_SEC: u64 = 1_000_000;
1022    const US_PER_MIN: u64 = 60 * US_PER_SEC;
1023    const US_PER_HOUR: u64 = 60 * US_PER_MIN;
1024    const US_PER_DAY: u64 = 24 * US_PER_HOUR;
1025    if delta_us < US_PER_SEC {
1026        return "<1s ago".to_string();
1027    }
1028    if delta_us < US_PER_MIN {
1029        return format!("{}s ago", delta_us / US_PER_SEC);
1030    }
1031    if delta_us < US_PER_HOUR {
1032        return format!("{}m ago", delta_us / US_PER_MIN);
1033    }
1034    if delta_us < US_PER_DAY {
1035        return format!("{}h ago", delta_us / US_PER_HOUR);
1036    }
1037    format!("{}d ago", delta_us / US_PER_DAY)
1038}
1039
1040/// Write the DHT status report to `w`. Pure function (takes `&mut impl
1041/// Write`) so the formatting is unit-testable without an HTTP hop,
1042/// mirroring [`write_lanes_table`]. The summary block uses stable
1043/// `key=value` tokens so operator scripts can `grep` them without
1044/// `--json`; the per-non-empty-bucket fill table follows.
1045fn write_status(w: &mut impl io::Write, s: &StatusResponse, now_us: u64) -> io::Result<()> {
1046    writeln!(w, "node_id={}", s.node_id)?;
1047    // The operator wallet this node runs under. `None` when the node reports no
1048    // resolvable operator (no chain wiring); render an explicit sentinel rather
1049    // than omitting the line, so the field is always present for scripts.
1050    writeln!(
1051        w,
1052        "operator_address={}",
1053        s.operator_address.as_deref().unwrap_or("(unknown)")
1054    )?;
1055    writeln!(w, "known_stakers={}", s.known_stakers)?;
1056    let last_refresh = match s.routing.last_refresh_us {
1057        // No bucket-refresh pass has completed yet (node up < one interval).
1058        None => "never".to_string(),
1059        Some(us) => relative_age(now_us, us),
1060    };
1061    writeln!(
1062        w,
1063        "routing total_peers={} non_empty_buckets={} refresh_interval={} last_refresh={}",
1064        s.routing.total_peers,
1065        s.routing.non_empty_buckets,
1066        format_interval(s.routing.refresh_interval_s),
1067        last_refresh,
1068    )?;
1069    writeln!(
1070        w,
1071        "record_store records={}/{} ({})",
1072        s.record_store.records,
1073        s.record_store.capacity,
1074        percent(s.record_store.records, s.record_store.capacity),
1075    )?;
1076    writeln!(
1077        w,
1078        "republish scheduled_records={}",
1079        s.republish.scheduled_records
1080    )?;
1081    writeln!(w, "chain_denied_origins={}", s.chain_denied_origins)?;
1082
1083    if s.routing.buckets.is_empty() {
1084        return writeln!(w, "(routing table empty — no buckets populated)");
1085    }
1086    // Capacity is the same K for every bucket — carried once on RoutingHealth.
1087    let capacity = s.routing.bucket_capacity;
1088    writeln!(w)?;
1089    writeln!(w, "{:<8} {:<8} FILL%", "BUCKET", "FILL")?;
1090    for b in &s.routing.buckets {
1091        let fill = format!("{}/{capacity}", b.fill);
1092        let pct = percent(u64::from(b.fill), u64::from(capacity));
1093        writeln!(w, "{:<8} {fill:<8} {pct}", b.index)?;
1094    }
1095    Ok(())
1096}
1097
1098/// Integer percentage of `num/den` as an `"NN%"` string. A `den` of 0
1099/// (which should never happen for a Kademlia bucket capacity or the
1100/// record-store global cap) renders `"n/a"` rather than dividing by zero.
1101/// `saturating_mul` guards the (unrealistic) `num * 100` overflow.
1102fn percent(num: u64, den: u64) -> String {
1103    if den == 0 {
1104        return "n/a".to_string();
1105    }
1106    format!("{}%", num.saturating_mul(100) / den)
1107}
1108
1109/// Format a whole-second interval as a coarse human string (e.g. `"1h"`,
1110/// `"30m"`, `"45s"`). Uses the same unit set as `format_age` (s/m/h/d) but
1111/// selects the coarsest unit that divides *exactly* — so a non-round
1112/// interval like 90 minutes renders `"90m"`, not `"1h"` — keeping the
1113/// reported bucket-refresh cadence precise.
1114fn format_interval(secs: u64) -> String {
1115    const SEC_PER_MIN: u64 = 60;
1116    const SEC_PER_HOUR: u64 = 60 * SEC_PER_MIN;
1117    const SEC_PER_DAY: u64 = 24 * SEC_PER_HOUR;
1118    if secs == 0 {
1119        return "0s".to_string();
1120    }
1121    if secs.is_multiple_of(SEC_PER_DAY) {
1122        return format!("{}d", secs / SEC_PER_DAY);
1123    }
1124    if secs.is_multiple_of(SEC_PER_HOUR) {
1125        return format!("{}h", secs / SEC_PER_HOUR);
1126    }
1127    if secs.is_multiple_of(SEC_PER_MIN) {
1128        return format!("{}m", secs / SEC_PER_MIN);
1129    }
1130    format!("{secs}s")
1131}
1132
1133/// `decdn node lookup` — unpaid client-side discovery of active nodes via
1134/// `CapacityBond.getRegisteredNodes` (#1481). Unlike `register`/`bond`/`unbond`/
1135/// `deregister`, this never loads a keystore: [`discovery::active_nodes`]
1136/// builds its own signer-less read-only provider, so `chain_ctx::resolve`'s
1137/// `keystore`/`data_dir` outputs are simply unused here.
1138///
1139/// Filters (`--node-id`, `--region`) are applied by the pure, network-free
1140/// `filter_candidates`. With `--probe`, the result, sampled down to `SELECT_K`, is
1141/// ranked by measured `cdn/probe/v1` round-trip time via `probe_and_rank`;
1142/// without it, candidates are printed as listed, with no RTT.
1143pub async fn lookup(args: &cli::LookupArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
1144    let config_path = args.chain.common.config.as_deref().or(global_config);
1145    let file = chain_ctx::load_optional_config(config_path)?;
1146    let resolved = chain_ctx::resolve(&args.chain, &file)?;
1147
1148    let node_id = args
1149        .node_id
1150        .as_deref()
1151        .map(|s| {
1152            PublicKey::from_str(s).map_err(|e| anyhow::anyhow!("invalid --node-id {s:?}: {e}"))
1153        })
1154        .transpose()?;
1155    let region = args
1156        .region
1157        .as_deref()
1158        .map(|raw| {
1159            Region::parse(raw).ok_or_else(|| {
1160                anyhow::anyhow!("invalid --region {raw:?}: not an accepted ISO 3166-1 alpha-2 code")
1161            })
1162        })
1163        .transpose()?;
1164
1165    let candidates = discovery::active_nodes(&resolved.rpc_url, resolved.capacity_bond_address)
1166        .await
1167        .with_context(|| {
1168            format!(
1169                "failed to read active nodes from CapacityBond at {}",
1170                resolved.capacity_bond_address
1171            )
1172        })?;
1173    let filtered = filter_candidates(candidates, node_id, region);
1174
1175    let rows = if args.probe {
1176        // Cap probe fan-out the same way the paid discovery path does
1177        // (`select_candidates`, `SELECT_K`) — probing every active node on
1178        // the network does not scale. The shortlist is a random sample of
1179        // `SELECT_K`, not the registry's leading entries. The region sort is
1180        // always a no-op here: with `--region` every survivor of
1181        // `filter_candidates` already matches, and without it no client
1182        // region is passed. Only the shuffle and the cap apply.
1183        let shortlisted = select_candidates(filtered, args.region.as_deref(), SELECT_K);
1184        probe_and_rank(shortlisted, config_path, args.timeout_ms).await?
1185    } else {
1186        filtered.into_iter().map(LookupRow::from).collect()
1187    };
1188
1189    let mut out = io::stdout().lock();
1190    if args.chain.common.json {
1191        let json_rows: Vec<LookupJson> = rows.iter().map(LookupJson::from).collect();
1192        serde_json::to_writer_pretty(&mut out, &json_rows)
1193            .context("failed to encode lookup result as JSON")?;
1194        writeln!(out)?;
1195    } else {
1196        write_lookup_table(&mut out, &rows).context("failed to write lookup table")?;
1197    }
1198    Ok(())
1199}
1200
1201/// Network-free filter over active-node candidates: exact `node_id` match
1202/// and/or exact `region` match. `None` on either axis means "no filter on
1203/// that axis" — with both `None` every candidate passes through unchanged.
1204/// Kept pure and separate from [`lookup`] so it is unit-testable without a
1205/// chain or network (#1481).
1206fn filter_candidates(
1207    candidates: Vec<NodeCandidate>,
1208    node_id: Option<PublicKey>,
1209    region: Option<Region>,
1210) -> Vec<NodeCandidate> {
1211    candidates
1212        .into_iter()
1213        .filter(|c| node_id.is_none_or(|id| c.node_id == id))
1214        .filter(|c| region.is_none_or(|r| c.region_hint == Some(r)))
1215        .collect()
1216}
1217
1218/// One row of `decdn node lookup` output: a candidate plus its measured RTT,
1219/// when probed. `rtt_ms` is `None` both for the unprobed listing path and for
1220/// a probed candidate that did not answer.
1221struct LookupRow {
1222    node_id: PublicKey,
1223    eth_address: Address,
1224    region_hint: Option<Region>,
1225    rtt_ms: Option<f64>,
1226}
1227
1228impl From<NodeCandidate> for LookupRow {
1229    fn from(c: NodeCandidate) -> Self {
1230        Self {
1231            node_id: c.node_id,
1232            eth_address: c.eth_address,
1233            region_hint: c.region_hint,
1234            rtt_ms: None,
1235        }
1236    }
1237}
1238
1239/// Probe each of `candidates` over `cdn/probe/v1` for a fixed sentinel hash
1240/// and sort ascending by measured RTT (decision per task brief: no blob needs
1241/// to exist at the sentinel hash — an absent-hash probe still returns a full
1242/// signed response, so any hash works for RTT). A candidate that cannot be
1243/// reached (offline, no route, timeout) is kept at the end with `rtt_ms:
1244/// None` and a warning on stderr rather than dropped or failing the whole
1245/// lookup — the point of `--probe` is to rank the reachable subset, not to
1246/// require every candidate to answer.
1247async fn probe_and_rank(
1248    candidates: Vec<NodeCandidate>,
1249    config_path: Option<&Path>,
1250    timeout_ms: u64,
1251) -> anyhow::Result<Vec<LookupRow>> {
1252    if candidates.is_empty() {
1253        return Ok(Vec::new());
1254    }
1255
1256    // Same client-endpoint construction as `decdn probe`: discovery-enabled,
1257    // so a target resolves by node-id via `[network.discovery]` / the n0
1258    // default even without an explicit relay/addr.
1259    let relays = client_endpoint::resolve_relays(None, config_path)?;
1260    let discovery_cfg = client_endpoint::client_discovery(config_path)?;
1261    let endpoint = client_endpoint::client_endpoint(&relays, &discovery_cfg).await?;
1262    let timeout = Duration::from_millis(timeout_ms);
1263    let hash = *blake3::hash(b"decdn-node-lookup-sentinel/v1").as_bytes();
1264
1265    let mut rows = Vec::with_capacity(candidates.len());
1266    for c in candidates {
1267        let mut target = EndpointAddr::new(c.node_id);
1268        if let Some(url) = relays.first() {
1269            target = target.with_relay_url(url.clone());
1270        }
1271        let target = discovery::with_dial_addrs(target, &c);
1272        let timestamp_us = wall_clock_us();
1273        match probe_once(&endpoint, target, hash, timestamp_us, timeout).await {
1274            Ok((_, _, rtt)) => rows.push(LookupRow {
1275                node_id: c.node_id,
1276                eth_address: c.eth_address,
1277                region_hint: c.region_hint,
1278                rtt_ms: Some(rtt.ms),
1279            }),
1280            Err(e) => {
1281                eprintln!(
1282                    "warning: probe of node {} failed: {e}",
1283                    short_node_id(&node_id_hex(&c.node_id))
1284                );
1285                rows.push(LookupRow {
1286                    node_id: c.node_id,
1287                    eth_address: c.eth_address,
1288                    region_hint: c.region_hint,
1289                    rtt_ms: None,
1290                });
1291            }
1292        }
1293    }
1294    endpoint.close().await;
1295
1296    // Ascending by RTT; unreachable candidates (`None`) sort last, ties among
1297    // them preserving probe order.
1298    rows.sort_by(|a, b| match (a.rtt_ms, b.rtt_ms) {
1299        (Some(x), Some(y)) => x.total_cmp(&y),
1300        (Some(_), None) => std::cmp::Ordering::Less,
1301        (None, Some(_)) => std::cmp::Ordering::Greater,
1302        (None, None) => std::cmp::Ordering::Equal,
1303    });
1304    Ok(rows)
1305}
1306
1307/// `0x`-prefixed hex encoding of an iroh node id, matching the `0x…` shape
1308/// [`LookupJson::node_id`] emits.
1309fn node_id_hex(node_id: &PublicKey) -> String {
1310    format!("0x{}", alloy::primitives::hex::encode(node_id.as_bytes()))
1311}
1312
1313/// Write the `decdn node lookup` result as a human table. Pure (`&mut impl
1314/// Write`) so the layout is unit-testable without a chain or network,
1315/// mirroring [`write_lanes_table`]. The RTT column
1316/// is only rendered when at least one row was probed, so the unprobed listing
1317/// path doesn't show a column of nothing.
1318fn write_lookup_table(w: &mut impl io::Write, rows: &[LookupRow]) -> io::Result<()> {
1319    writeln!(w, "nodes={}", rows.len())?;
1320    if rows.is_empty() {
1321        return writeln!(w, "(no matching active nodes)");
1322    }
1323    let probed = rows.iter().any(|r| r.rtt_ms.is_some());
1324    if probed {
1325        writeln!(
1326            w,
1327            "{:<14} {:<44} {:<8} {:>12}",
1328            "NODE_ID", "ETH_ADDRESS", "REGION", "RTT_MS"
1329        )?;
1330    } else {
1331        writeln!(w, "{:<14} {:<44} {:<8}", "NODE_ID", "ETH_ADDRESS", "REGION")?;
1332    }
1333    for r in rows {
1334        let node_preview = short_node_id(&node_id_hex(&r.node_id));
1335        let region = r
1336            .region_hint
1337            .map_or_else(|| "?".to_string(), |x| x.to_string());
1338        if probed {
1339            let rtt = r
1340                .rtt_ms
1341                .map_or_else(|| "unreachable".to_string(), |ms| format!("{ms:.1}"));
1342            writeln!(
1343                w,
1344                "{node_preview:<14} {:<44} {region:<8} {rtt:>12}",
1345                r.eth_address
1346            )?;
1347        } else {
1348            writeln!(w, "{node_preview:<14} {:<44} {region:<8}", r.eth_address)?;
1349        }
1350    }
1351    Ok(())
1352}
1353
1354/// `--json` array element for `decdn node lookup` — mirrors the
1355/// `lane list --json` convention (stable string encoding, one Serialize
1356/// view per row). `node_id`/`eth_address` are `0x…` hex strings;
1357/// `region_hint` is `null` for a node with no (or unparseable) region hint;
1358/// `rtt_ms` is omitted entirely from the object whenever it is unknown — both
1359/// the unprobed listing path and a probed-but-unreachable candidate leave it
1360/// unset, so a consumer cannot distinguish "not probed" from "probed and
1361/// didn't answer" from the JSON alone (both print a stderr warning in the
1362/// latter case on the human path; there is currently no JSON-side signal).
1363#[derive(Serialize)]
1364struct LookupJson {
1365    node_id: String,
1366    eth_address: String,
1367    region_hint: Option<String>,
1368    #[serde(skip_serializing_if = "Option::is_none")]
1369    rtt_ms: Option<f64>,
1370}
1371
1372impl From<&LookupRow> for LookupJson {
1373    fn from(r: &LookupRow) -> Self {
1374        Self {
1375            node_id: node_id_hex(&r.node_id),
1376            eth_address: r.eth_address.to_string(),
1377            region_hint: r.region_hint.map(|region| region.to_string()),
1378            rtt_ms: r.rtt_ms,
1379        }
1380    }
1381}
1382
1383#[cfg(test)]
1384#[allow(
1385    clippy::unwrap_used,
1386    clippy::expect_used,
1387    clippy::indexing_slicing,
1388    clippy::panic
1389)]
1390mod tests;