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    // The node names the pool owner only once its pool view has seen the pool;
635    // say so rather than leave the column blank.
636    let counterparty = c
637        .counterparty
638        .as_deref()
639        .map_or_else(|| "unknown".to_string(), short_node_id);
640    let signer = short_node_id(&c.voucher_signer);
641    let last_voucher = match c.seconds_since_last_voucher {
642        // No voucher seen since this process started — distinct from
643        // "<1s ago" so operators know the activity clock has no record
644        // (a freshly-restarted node, or a lane that has never billed).
645        None => "never".to_string(),
646        // Reuse `format_age` (microsecond input) by scaling the whole-second
647        // wire value; `saturating_mul` clamps the (unrealistic) overflow on an
648        // absurd age rather than panicking. Boundary buckets are identical
649        // (covered by `format_age_units`).
650        Some(secs) => format_age(secs.saturating_mul(1_000_000)),
651    };
652    let eligible = if c.settlement_eligible { "yes" } else { "no" };
653    writeln!(
654        w,
655        "{lane:<14} {counterparty:<14} {signer:<14} {:>6} {:>12} {:>12} {last_voucher:>12} \
656         {eligible}",
657        c.last_nonce,
658        format_usdc(c.outstanding_micro_usdc),
659        format_usdc(c.deposit_micro_usdc),
660    )
661}
662
663/// Format a micro-USDC amount as a `"N.NNNNNN"` USDC string. USDC has 6
664/// decimals (ADR 003), so 1 USDC == `1_000_000` micro-USDC. Trailing
665/// fractional zeros are kept fixed-width (six places) so columns align and
666/// a script parsing the value sees a stable shape.
667fn format_usdc(micro: u64) -> String {
668    let whole = micro / 1_000_000;
669    let frac = micro % 1_000_000;
670    format!("{whole}.{frac:06}")
671}
672
673/// `decdn node slashes`: call `admin_v1_slashes` on the running node and
674/// print every detected slash against its operator (#1032). Plain text by
675/// default (a summary line + a per-slash table), or pretty JSON with
676/// `--json`.
677pub async fn slashes(args: &cli::SlashesArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
678    anyhow::ensure!(
679        args.timeout_ms > 0,
680        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
681         'never' rather than 'sub-millisecond deadline')"
682    );
683
684    let config_path = args.config.as_deref().or(global_config);
685    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
686
687    let client = HttpClientBuilder::default()
688        .request_timeout(Duration::from_millis(args.timeout_ms))
689        .build(&url)
690        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
691
692    let parsed: SlashesResponse = client
693        .slashes()
694        .await
695        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
696
697    if args.json {
698        let pretty =
699            serde_json::to_string_pretty(&parsed).context("failed to encode slashes as JSON")?;
700        println!("{pretty}");
701    } else {
702        let mut stdout = io::stdout().lock();
703        write_slashes_table(&mut stdout, &parsed).context("failed to write slashes table")?;
704    }
705
706    Ok(())
707}
708
709/// `decdn node pools`: call `admin_v1_pools` on the running node and print its
710/// buyer-side `PaymentPool` state — the pools it pays upstream providers from.
711///
712/// This is the only read path to the daemon's `buyer.redb`: redb holds a
713/// process-exclusive lock on that file for the node's lifetime, so nothing can
714/// open it from disk while the node is up (#2078).
715pub async fn pools(args: &cli::PoolsArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
716    anyhow::ensure!(
717        args.timeout_ms > 0,
718        "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
719         'never' rather than 'sub-millisecond deadline')"
720    );
721
722    let config_path = args.config.as_deref().or(global_config);
723    let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
724
725    let client = HttpClientBuilder::default()
726        .request_timeout(Duration::from_millis(args.timeout_ms))
727        .build(&url)
728        .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
729
730    let parsed: BuyerPoolsResponse = client
731        .pools()
732        .await
733        .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
734
735    if args.json {
736        let pretty =
737            serde_json::to_string_pretty(&parsed).context("failed to encode pools as JSON")?;
738        println!("{pretty}");
739    } else {
740        crate::commands::pool::write_skipped_buyer_pools(&mut io::stderr().lock(), &parsed.skipped)
741            .context("failed to write skipped pools")?;
742        let mut stdout = io::stdout().lock();
743        // Name the daemon that answered, for the same reason `pool list` names
744        // the file it read: an unattributed `pools=0` is the defect (#2078).
745        writeln!(stdout, "store={url} (admin RPC)").context("failed to write pools header")?;
746        crate::commands::pool::write_buyer_pools(&mut stdout, &parsed)
747            .context("failed to write pools table")?;
748    }
749
750    Ok(())
751}
752
753/// Write the slash table to `w`. Pure function (takes `&mut impl
754/// Write`) so the formatting is unit-testable without an HTTP hop,
755/// mirroring [`write_lanes_table`]. A summary line carries the count as a
756/// stable `key=value` token; the per-slash table follows.
757fn write_slashes_table(w: &mut impl io::Write, resp: &SlashesResponse) -> io::Result<()> {
758    writeln!(w, "slashes={}", resp.slashes.len())?;
759    if resp.slashes.is_empty() {
760        return writeln!(w, "(no slashes detected)");
761    }
762    writeln!(
763        w,
764        "{:<20} {:>6} {:>14} {:>10} {:>10} APPEAL_CLOSE",
765        "SLASH_ID", "TYPE", "AMOUNT", "BLOCK", "EVIDENCE",
766    )?;
767    for s in &resp.slashes {
768        let block = s
769            .block_number
770            .map_or_else(|| "?".to_string(), |b| b.to_string());
771        let close = s
772            .appeal_window_close
773            .map_or_else(|| "?".to_string(), |c| c.to_string());
774        writeln!(
775            w,
776            "{:<20} {:>6} {:>14} {:>10} {:>10} {close}",
777            s.slash_id,
778            s.offense_type,
779            s.amount,
780            block,
781            short_node_id(&s.evidence_hash),
782        )?;
783    }
784    Ok(())
785}
786
787/// Map a `jsonrpsee` client error into the three operator-actionable
788/// classes the previous reqwest path exposed:
789///
790/// - `Transport` with an `ECONNREFUSED` in the source chain → "admin
791///   isn't there" (check it's running / port).
792/// - `RequestTimeout` → "admin is slow" (stuck lock, overloaded).
793/// - `Call` → the server returned a JSON-RPC application error; surface
794///   the code and message so the operator can tell a misconfigured
795///   method name from a real server failure.
796/// - Anything else → passed through with the URL as context.
797pub(crate) fn classify_client_error(
798    url: &str,
799    timeout_ms: u64,
800    err: JsonRpcClientError,
801) -> anyhow::Error {
802    match err {
803        JsonRpcClientError::RequestTimeout => anyhow::anyhow!(
804            "admin at {url} did not respond within {timeout_ms}ms; \
805             the node may be overloaded or blocked on a long lock hold",
806        ),
807        JsonRpcClientError::Transport(inner) => {
808            if is_connection_refused(inner.as_ref()) {
809                anyhow::anyhow!(
810                    "admin at {url} refused the connection ({inner}); is the node \
811                     running, and is admin_port configured correctly?",
812                )
813            } else {
814                anyhow::anyhow!("admin request to {url} failed: {inner}")
815            }
816        }
817        JsonRpcClientError::Call(obj) => anyhow::anyhow!(
818            "admin at {url} returned JSON-RPC error {code}: {msg}",
819            code = obj.code(),
820            msg = obj.message(),
821        ),
822        other => anyhow::Error::new(other).context(format!("admin request to {url} failed")),
823    }
824}
825
826/// Walk the `source()` chain looking for an `std::io::Error` of kind
827/// `ConnectionRefused`. The hyper/jsonrpsee error hierarchy is several
828/// layers deep and the exact intermediate types are implementation
829/// details, so match on the innermost `io::Error` kind instead of any
830/// particular transport type.
831pub(crate) fn is_connection_refused(err: &(dyn std::error::Error + 'static)) -> bool {
832    io_error_kind_in_source_chain(err, |kind| kind == std::io::ErrorKind::ConnectionRefused)
833}
834
835/// Walk the `source()` chain looking for any `std::io::Error` whose
836/// kind indicates the admin server's listener (or an already-accepted
837/// connection) has gone away. Used by the `--wait` polling loop to
838/// detect "admin closed after `router.shutdown` returned" — which is
839/// the late-stop success terminal. `ConnectionRefused` is the most
840/// common (new connection rejected because listener dropped), but
841/// `ConnectionReset` / `ConnectionAborted` / `UnexpectedEof` also
842/// occur when a poll lands mid-response as the server tears down.
843/// Without these, the polling loop would treat a race-window error
844/// as fatal and turn drain completion into a non-zero exit.
845fn is_admin_closed_transport_error(err: &(dyn std::error::Error + 'static)) -> bool {
846    io_error_kind_in_source_chain(err, |kind| {
847        matches!(
848            kind,
849            std::io::ErrorKind::ConnectionRefused
850                | std::io::ErrorKind::ConnectionReset
851                | std::io::ErrorKind::ConnectionAborted
852                | std::io::ErrorKind::UnexpectedEof
853        )
854    })
855}
856
857/// Helper: walk the `source()` chain, return `true` if any layer is
858/// an `io::Error` whose kind matches `predicate`.
859fn io_error_kind_in_source_chain(
860    err: &(dyn std::error::Error + 'static),
861    predicate: impl Fn(std::io::ErrorKind) -> bool,
862) -> bool {
863    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
864    while let Some(e) = current {
865        if let Some(io_err) = e.downcast_ref::<std::io::Error>()
866            && predicate(io_err.kind())
867        {
868            return true;
869        }
870        current = e.source();
871    }
872    false
873}
874
875/// Resolve the admin URL in this precedence order:
876///
877/// 1. `--admin-url` flag or the `DECDN_ADMIN_URL` env var (clap folds the
878///    env into `args.admin_url`).
879/// 2. `DECDN_ADMIN_PORT` — the same env var the daemon reads for its admin
880///    bind (`common/src/cli/run.rs`). An env-only deployment (port set via
881///    env, no config file) would otherwise be unreachable: the CLI would
882///    dial the default 9191 and report "is the node running?" (#864). A
883///    malformed or `0` value errors rather than silently falling through.
884/// 3. `observability.admin_port` from the TOML config file — either the
885///    caller's explicit path or the default `~/.decdn/node.toml`. An
886///    explicit path that doesn't exist is an error; a missing default
887///    path falls through. `admin_port = 0` in the file is an operator
888///    opt-out and errors here rather than silently probing the default.
889/// 4. Default `http://127.0.0.1:9191`.
890pub(crate) fn resolve_admin_url(
891    flag: Option<&str>,
892    config_path: Option<&Path>,
893) -> anyhow::Result<String> {
894    if let Some(url) = flag {
895        return Ok(url.to_string());
896    }
897    if let Some(url) = admin_url_from_env(std::env::var("DECDN_ADMIN_PORT").ok())? {
898        return Ok(url);
899    }
900    let (resolved_path, source) = match config_path {
901        Some(p) => (Some(expand_tilde(p)), ConfigPathSource::Explicit),
902        None => (
903            cli::common::default_config_path(),
904            ConfigPathSource::Default,
905        ),
906    };
907    let port =
908        port_from_config_file(resolved_path.as_deref(), source)?.unwrap_or(DEFAULT_ADMIN_PORT);
909    Ok(format!("http://127.0.0.1:{port}"))
910}
911
912/// Build the loopback admin URL from the raw `DECDN_ADMIN_PORT` env value, or
913/// `None` when the var is unset. Pure (takes the value rather than reading the
914/// environment) so the parse/validation logic is testable without mutating
915/// process-global state. A non-numeric value or `0` (the daemon's "admin
916/// disabled" sentinel) is an error — both indicate misconfiguration the
917/// operator should see rather than have masked by a fall-through to the
918/// config file or the built-in default.
919fn admin_url_from_env(raw: Option<String>) -> anyhow::Result<Option<String>> {
920    let Some(raw) = raw else { return Ok(None) };
921    let port: u16 = raw
922        .trim()
923        .parse()
924        .map_err(|_| anyhow::anyhow!("DECDN_ADMIN_PORT is not a valid port number: {raw:?}"))?;
925    if port == 0 {
926        anyhow::bail!(
927            "DECDN_ADMIN_PORT=0 disables the admin server; pass --admin-url or set a non-zero port"
928        );
929    }
930    Ok(Some(format!("http://127.0.0.1:{port}")))
931}
932
933/// Read `observability.admin_port` from a TOML config file. Returns:
934/// - `Ok(None)` when `path` is `None`, or when `path` is the *default*
935///   path and the file doesn't exist (operator hasn't set up a config
936///   file yet — fall through to the built-in default).
937/// - `Ok(Some(port))` for a positive port value.
938/// - `Err` if the file exists but can't be parsed, if the operator
939///   explicitly set `admin_port = 0` (which disables the server), or if
940///   `source` is `Explicit` and the file is missing/unreadable.
941///
942/// Callers are expected to have already tilde-expanded the path;
943/// `resolve_admin_url` is the only intended caller and does so via
944/// [`expand_tilde`] / [`cli::common::default_config_path`].
945fn port_from_config_file(
946    path: Option<&Path>,
947    source: ConfigPathSource,
948) -> anyhow::Result<Option<u16>> {
949    let Some(path) = path else { return Ok(None) };
950    let path: PathBuf = path.to_path_buf();
951    match std::fs::read_to_string(&path) {
952        Ok(contents) => {
953            // Deliberately a partial deserializer: parsing the full
954            // `FileConfig` would couple these commands to every
955            // unrelated field's well-formedness. An operator with a
956            // typo'd `[payments]` table shouldn't lose the ability to
957            // resolve the admin port. Serde's TOML mode ignores unknown
958            // fields by default, so this only fails on (a) genuinely
959            // malformed TOML or (b) a wrong type for
960            // `observability.admin_port` itself — both of which we
961            // genuinely want to surface.
962            let parsed: AdminPortConfig = toml::from_str(&contents)
963                .with_context(|| format!("failed to parse config file {}", path.display()))?;
964            match parsed.observability.and_then(|o| o.admin_port) {
965                Some(0) => anyhow::bail!(
966                    "config {} disables the admin server (observability.admin_port = 0); \
967                     pass --admin-url or enable the admin port",
968                    path.display()
969                ),
970                other => Ok(other),
971            }
972        }
973        Err(err) => match (err.kind(), source) {
974            // Default path not yet created: fall through to the built-in.
975            (io::ErrorKind::NotFound, ConfigPathSource::Default) => Ok(None),
976            // Permission problems usually point at file mode / ownership,
977            // not a wrong path — give the operator that nudge rather than
978            // a generic "failed to read".
979            (io::ErrorKind::PermissionDenied, _) => Err(anyhow::anyhow!(
980                "cannot read config file {}: permission denied; check file mode and ownership",
981                path.display()
982            )),
983            _ => Err(anyhow::anyhow!(
984                "failed to read config file {}: {err}",
985                path.display()
986            )),
987        },
988    }
989}
990
991fn short_node_id(hex: &str) -> String {
992    // Unicode '…' (U+2026) rather than "..." so a pasted preview is
993    // unambiguously a preview and never parses as hex.
994    let mut chars = hex.chars();
995    let prefix: String = chars.by_ref().take(12).collect();
996    if chars.next().is_some() {
997        format!("{prefix}…")
998    } else {
999        prefix
1000    }
1001}
1002
1003fn wall_clock_us() -> u64 {
1004    std::time::SystemTime::now()
1005        .duration_since(std::time::UNIX_EPOCH)
1006        .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
1007}
1008
1009/// Coarse operator-facing age bucket. On-call use case is "is this peer
1010/// fresh in the last N {s,m,h,d}?"; sub-second precision would only add
1011/// noise to the table.
1012fn relative_age(now_us: u64, last_seen_us: u64) -> String {
1013    if last_seen_us == 0 {
1014        return "unknown".to_string();
1015    }
1016    if last_seen_us > now_us {
1017        // Clock skew: report as "in the future" rather than a huge negative
1018        // unsigned delta. Honest about the condition without panicking.
1019        return "in future".to_string();
1020    }
1021    let delta_us = now_us - last_seen_us;
1022    format_age(delta_us)
1023}
1024
1025fn format_age(delta_us: u64) -> String {
1026    const US_PER_SEC: u64 = 1_000_000;
1027    const US_PER_MIN: u64 = 60 * US_PER_SEC;
1028    const US_PER_HOUR: u64 = 60 * US_PER_MIN;
1029    const US_PER_DAY: u64 = 24 * US_PER_HOUR;
1030    if delta_us < US_PER_SEC {
1031        return "<1s ago".to_string();
1032    }
1033    if delta_us < US_PER_MIN {
1034        return format!("{}s ago", delta_us / US_PER_SEC);
1035    }
1036    if delta_us < US_PER_HOUR {
1037        return format!("{}m ago", delta_us / US_PER_MIN);
1038    }
1039    if delta_us < US_PER_DAY {
1040        return format!("{}h ago", delta_us / US_PER_HOUR);
1041    }
1042    format!("{}d ago", delta_us / US_PER_DAY)
1043}
1044
1045/// Write the DHT status report to `w`. Pure function (takes `&mut impl
1046/// Write`) so the formatting is unit-testable without an HTTP hop,
1047/// mirroring [`write_lanes_table`]. The summary block uses stable
1048/// `key=value` tokens so operator scripts can `grep` them without
1049/// `--json`; the per-non-empty-bucket fill table follows.
1050fn write_status(w: &mut impl io::Write, s: &StatusResponse, now_us: u64) -> io::Result<()> {
1051    writeln!(w, "node_id={}", s.node_id)?;
1052    // The operator wallet this node runs under. `None` when the node reports no
1053    // resolvable operator (no chain wiring); render an explicit sentinel rather
1054    // than omitting the line, so the field is always present for scripts.
1055    writeln!(
1056        w,
1057        "operator_address={}",
1058        s.operator_address.as_deref().unwrap_or("(unknown)")
1059    )?;
1060    writeln!(w, "known_stakers={}", s.known_stakers)?;
1061    let last_refresh = match s.routing.last_refresh_us {
1062        // No bucket-refresh pass has completed yet (node up < one interval).
1063        None => "never".to_string(),
1064        Some(us) => relative_age(now_us, us),
1065    };
1066    writeln!(
1067        w,
1068        "routing total_peers={} non_empty_buckets={} refresh_interval={} last_refresh={}",
1069        s.routing.total_peers,
1070        s.routing.non_empty_buckets,
1071        format_interval(s.routing.refresh_interval_s),
1072        last_refresh,
1073    )?;
1074    writeln!(
1075        w,
1076        "record_store records={}/{} ({})",
1077        s.record_store.records,
1078        s.record_store.capacity,
1079        percent(s.record_store.records, s.record_store.capacity),
1080    )?;
1081    writeln!(
1082        w,
1083        "republish scheduled_records={}",
1084        s.republish.scheduled_records
1085    )?;
1086    writeln!(w, "chain_denied_origins={}", s.chain_denied_origins)?;
1087
1088    if s.routing.buckets.is_empty() {
1089        return writeln!(w, "(routing table empty — no buckets populated)");
1090    }
1091    // Capacity is the same K for every bucket — carried once on RoutingHealth.
1092    let capacity = s.routing.bucket_capacity;
1093    writeln!(w)?;
1094    writeln!(w, "{:<8} {:<8} FILL%", "BUCKET", "FILL")?;
1095    for b in &s.routing.buckets {
1096        let fill = format!("{}/{capacity}", b.fill);
1097        let pct = percent(u64::from(b.fill), u64::from(capacity));
1098        writeln!(w, "{:<8} {fill:<8} {pct}", b.index)?;
1099    }
1100    Ok(())
1101}
1102
1103/// Integer percentage of `num/den` as an `"NN%"` string. A `den` of 0
1104/// (which should never happen for a Kademlia bucket capacity or the
1105/// record-store global cap) renders `"n/a"` rather than dividing by zero.
1106/// `saturating_mul` guards the (unrealistic) `num * 100` overflow.
1107fn percent(num: u64, den: u64) -> String {
1108    if den == 0 {
1109        return "n/a".to_string();
1110    }
1111    format!("{}%", num.saturating_mul(100) / den)
1112}
1113
1114/// Format a whole-second interval as a coarse human string (e.g. `"1h"`,
1115/// `"30m"`, `"45s"`). Uses the same unit set as `format_age` (s/m/h/d) but
1116/// selects the coarsest unit that divides *exactly* — so a non-round
1117/// interval like 90 minutes renders `"90m"`, not `"1h"` — keeping the
1118/// reported bucket-refresh cadence precise.
1119fn format_interval(secs: u64) -> String {
1120    const SEC_PER_MIN: u64 = 60;
1121    const SEC_PER_HOUR: u64 = 60 * SEC_PER_MIN;
1122    const SEC_PER_DAY: u64 = 24 * SEC_PER_HOUR;
1123    if secs == 0 {
1124        return "0s".to_string();
1125    }
1126    if secs.is_multiple_of(SEC_PER_DAY) {
1127        return format!("{}d", secs / SEC_PER_DAY);
1128    }
1129    if secs.is_multiple_of(SEC_PER_HOUR) {
1130        return format!("{}h", secs / SEC_PER_HOUR);
1131    }
1132    if secs.is_multiple_of(SEC_PER_MIN) {
1133        return format!("{}m", secs / SEC_PER_MIN);
1134    }
1135    format!("{secs}s")
1136}
1137
1138/// `decdn node lookup` — unpaid client-side discovery of active nodes via
1139/// `CapacityBond.getRegisteredNodes` (#1481). Unlike `register`/`bond`/`unbond`/
1140/// `deregister`, this never loads a keystore: [`discovery::active_nodes`]
1141/// builds its own signer-less read-only provider, so `chain_ctx::resolve`'s
1142/// `keystore`/`data_dir` outputs are simply unused here.
1143///
1144/// Filters (`--node-id`, `--region`) are applied by the pure, network-free
1145/// `filter_candidates`. With `--probe`, the result, sampled down to `SELECT_K`, is
1146/// ranked by measured `cdn/probe/v1` round-trip time via `probe_and_rank`;
1147/// without it, candidates are printed as listed, with no RTT.
1148pub async fn lookup(args: &cli::LookupArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
1149    let config_path = args.chain.common.config.as_deref().or(global_config);
1150    let file = chain_ctx::load_optional_config(config_path)?;
1151    let resolved = chain_ctx::resolve(&args.chain, &file)?;
1152
1153    let node_id = args
1154        .node_id
1155        .as_deref()
1156        .map(|s| {
1157            PublicKey::from_str(s).map_err(|e| anyhow::anyhow!("invalid --node-id {s:?}: {e}"))
1158        })
1159        .transpose()?;
1160    let region = args
1161        .region
1162        .as_deref()
1163        .map(|raw| {
1164            Region::parse(raw).ok_or_else(|| {
1165                anyhow::anyhow!("invalid --region {raw:?}: not an accepted ISO 3166-1 alpha-2 code")
1166            })
1167        })
1168        .transpose()?;
1169
1170    let candidates = discovery::active_nodes(&resolved.rpc_url, resolved.capacity_bond_address)
1171        .await
1172        .with_context(|| {
1173            format!(
1174                "failed to read active nodes from CapacityBond at {}",
1175                resolved.capacity_bond_address
1176            )
1177        })?;
1178    let filtered = filter_candidates(candidates, node_id, region);
1179
1180    let rows = if args.probe {
1181        // Cap probe fan-out the same way the paid discovery path does
1182        // (`select_candidates`, `SELECT_K`) — probing every active node on
1183        // the network does not scale. The shortlist is a random sample of
1184        // `SELECT_K`, not the registry's leading entries. The region sort is
1185        // always a no-op here: with `--region` every survivor of
1186        // `filter_candidates` already matches, and without it no client
1187        // region is passed. Only the shuffle and the cap apply.
1188        let shortlisted = select_candidates(filtered, args.region.as_deref(), SELECT_K);
1189        probe_and_rank(shortlisted, config_path, args.timeout_ms).await?
1190    } else {
1191        filtered.into_iter().map(LookupRow::from).collect()
1192    };
1193
1194    let mut out = io::stdout().lock();
1195    if args.chain.common.json {
1196        let json_rows: Vec<LookupJson> = rows.iter().map(LookupJson::from).collect();
1197        serde_json::to_writer_pretty(&mut out, &json_rows)
1198            .context("failed to encode lookup result as JSON")?;
1199        writeln!(out)?;
1200    } else {
1201        write_lookup_table(&mut out, &rows).context("failed to write lookup table")?;
1202    }
1203    Ok(())
1204}
1205
1206/// Network-free filter over active-node candidates: exact `node_id` match
1207/// and/or exact `region` match. `None` on either axis means "no filter on
1208/// that axis" — with both `None` every candidate passes through unchanged.
1209/// Kept pure and separate from [`lookup`] so it is unit-testable without a
1210/// chain or network (#1481).
1211fn filter_candidates(
1212    candidates: Vec<NodeCandidate>,
1213    node_id: Option<PublicKey>,
1214    region: Option<Region>,
1215) -> Vec<NodeCandidate> {
1216    candidates
1217        .into_iter()
1218        .filter(|c| node_id.is_none_or(|id| c.node_id == id))
1219        .filter(|c| region.is_none_or(|r| c.region_hint == Some(r)))
1220        .collect()
1221}
1222
1223/// One row of `decdn node lookup` output: a candidate plus its measured RTT,
1224/// when probed. `rtt_ms` is `None` both for the unprobed listing path and for
1225/// a probed candidate that did not answer.
1226struct LookupRow {
1227    node_id: PublicKey,
1228    eth_address: Address,
1229    region_hint: Option<Region>,
1230    rtt_ms: Option<f64>,
1231}
1232
1233impl From<NodeCandidate> for LookupRow {
1234    fn from(c: NodeCandidate) -> Self {
1235        Self {
1236            node_id: c.node_id,
1237            eth_address: c.eth_address,
1238            region_hint: c.region_hint,
1239            rtt_ms: None,
1240        }
1241    }
1242}
1243
1244/// Probe each of `candidates` over `cdn/probe/v1` for a fixed sentinel hash
1245/// and sort ascending by measured RTT (decision per task brief: no blob needs
1246/// to exist at the sentinel hash — an absent-hash probe still returns a full
1247/// signed response, so any hash works for RTT). A candidate that cannot be
1248/// reached (offline, no route, timeout) is kept at the end with `rtt_ms:
1249/// None` and a warning on stderr rather than dropped or failing the whole
1250/// lookup — the point of `--probe` is to rank the reachable subset, not to
1251/// require every candidate to answer.
1252async fn probe_and_rank(
1253    candidates: Vec<NodeCandidate>,
1254    config_path: Option<&Path>,
1255    timeout_ms: u64,
1256) -> anyhow::Result<Vec<LookupRow>> {
1257    if candidates.is_empty() {
1258        return Ok(Vec::new());
1259    }
1260
1261    // Same client-endpoint construction as `decdn probe`: discovery-enabled,
1262    // so a target resolves by node-id via `[network.discovery]` / the n0
1263    // default even without an explicit relay/addr.
1264    let relays = client_endpoint::resolve_relays(None, config_path)?;
1265    let discovery_cfg = client_endpoint::client_discovery(config_path)?;
1266    let endpoint = client_endpoint::client_endpoint(&relays, &discovery_cfg).await?;
1267    let timeout = Duration::from_millis(timeout_ms);
1268    let hash = *blake3::hash(b"decdn-node-lookup-sentinel/v1").as_bytes();
1269
1270    let mut rows = Vec::with_capacity(candidates.len());
1271    for c in candidates {
1272        let mut target = EndpointAddr::new(c.node_id);
1273        if let Some(url) = relays.first() {
1274            target = target.with_relay_url(url.clone());
1275        }
1276        let target = discovery::with_dial_addrs(target, &c);
1277        let timestamp_us = wall_clock_us();
1278        match probe_once(&endpoint, target, hash, timestamp_us, timeout).await {
1279            Ok((_, _, rtt)) => rows.push(LookupRow {
1280                node_id: c.node_id,
1281                eth_address: c.eth_address,
1282                region_hint: c.region_hint,
1283                rtt_ms: Some(rtt.ms),
1284            }),
1285            Err(e) => {
1286                eprintln!(
1287                    "warning: probe of node {} failed: {e}",
1288                    short_node_id(&node_id_hex(&c.node_id))
1289                );
1290                rows.push(LookupRow {
1291                    node_id: c.node_id,
1292                    eth_address: c.eth_address,
1293                    region_hint: c.region_hint,
1294                    rtt_ms: None,
1295                });
1296            }
1297        }
1298    }
1299    endpoint.close().await;
1300
1301    // Ascending by RTT; unreachable candidates (`None`) sort last, ties among
1302    // them preserving probe order.
1303    rows.sort_by(|a, b| match (a.rtt_ms, b.rtt_ms) {
1304        (Some(x), Some(y)) => x.total_cmp(&y),
1305        (Some(_), None) => std::cmp::Ordering::Less,
1306        (None, Some(_)) => std::cmp::Ordering::Greater,
1307        (None, None) => std::cmp::Ordering::Equal,
1308    });
1309    Ok(rows)
1310}
1311
1312/// `0x`-prefixed hex encoding of an iroh node id, matching the `0x…` shape
1313/// [`LookupJson::node_id`] emits.
1314fn node_id_hex(node_id: &PublicKey) -> String {
1315    format!("0x{}", alloy::primitives::hex::encode(node_id.as_bytes()))
1316}
1317
1318/// Write the `decdn node lookup` result as a human table. Pure (`&mut impl
1319/// Write`) so the layout is unit-testable without a chain or network,
1320/// mirroring [`write_lanes_table`]. The RTT column
1321/// is only rendered when at least one row was probed, so the unprobed listing
1322/// path doesn't show a column of nothing.
1323fn write_lookup_table(w: &mut impl io::Write, rows: &[LookupRow]) -> io::Result<()> {
1324    writeln!(w, "nodes={}", rows.len())?;
1325    if rows.is_empty() {
1326        return writeln!(w, "(no matching active nodes)");
1327    }
1328    let probed = rows.iter().any(|r| r.rtt_ms.is_some());
1329    if probed {
1330        writeln!(
1331            w,
1332            "{:<14} {:<44} {:<8} {:>12}",
1333            "NODE_ID", "ETH_ADDRESS", "REGION", "RTT_MS"
1334        )?;
1335    } else {
1336        writeln!(w, "{:<14} {:<44} {:<8}", "NODE_ID", "ETH_ADDRESS", "REGION")?;
1337    }
1338    for r in rows {
1339        let node_preview = short_node_id(&node_id_hex(&r.node_id));
1340        let region = r
1341            .region_hint
1342            .map_or_else(|| "?".to_string(), |x| x.to_string());
1343        if probed {
1344            let rtt = r
1345                .rtt_ms
1346                .map_or_else(|| "unreachable".to_string(), |ms| format!("{ms:.1}"));
1347            writeln!(
1348                w,
1349                "{node_preview:<14} {:<44} {region:<8} {rtt:>12}",
1350                r.eth_address
1351            )?;
1352        } else {
1353            writeln!(w, "{node_preview:<14} {:<44} {region:<8}", r.eth_address)?;
1354        }
1355    }
1356    Ok(())
1357}
1358
1359/// `--json` array element for `decdn node lookup` — mirrors the
1360/// `lane list --json` convention (stable string encoding, one Serialize
1361/// view per row). `node_id`/`eth_address` are `0x…` hex strings;
1362/// `region_hint` is `null` for a node with no (or unparseable) region hint;
1363/// `rtt_ms` is omitted entirely from the object whenever it is unknown — both
1364/// the unprobed listing path and a probed-but-unreachable candidate leave it
1365/// unset, so a consumer cannot distinguish "not probed" from "probed and
1366/// didn't answer" from the JSON alone (both print a stderr warning in the
1367/// latter case on the human path; there is currently no JSON-side signal).
1368#[derive(Serialize)]
1369struct LookupJson {
1370    node_id: String,
1371    eth_address: String,
1372    region_hint: Option<String>,
1373    #[serde(skip_serializing_if = "Option::is_none")]
1374    rtt_ms: Option<f64>,
1375}
1376
1377impl From<&LookupRow> for LookupJson {
1378    fn from(r: &LookupRow) -> Self {
1379        Self {
1380            node_id: node_id_hex(&r.node_id),
1381            eth_address: r.eth_address.to_string(),
1382            region_hint: r.region_hint.map(|region| region.to_string()),
1383            rtt_ms: r.rtt_ms,
1384        }
1385    }
1386}
1387
1388#[cfg(test)]
1389#[allow(
1390    clippy::unwrap_used,
1391    clippy::expect_used,
1392    clippy::indexing_slicing,
1393    clippy::panic
1394)]
1395mod tests;