1use 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#[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
53pub 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
89pub 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 let mut out = io::stdout().lock();
120 write_health(&mut out, &resp).context("failed to write health output")?;
121 }
122
123 Ok(())
124}
125
126fn 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 writeln!(w, "binding={}", binding_label(resp.binding))?;
136 if let Some(bound) = &resp.bound_node_id {
139 writeln!(w, "bound_node_id={bound}")?;
140 }
141 writeln!(w, "registry_active={}", resp.registry_active)?;
148 Ok(())
149}
150
151const 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
162pub 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 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 "not present"
208 };
209 println!("hash={} status={presence}", args.hash);
210 }
211
212 Ok(())
213}
214
215fn 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 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 None => writeln!(w, "last_accessed=none_since_start")?,
238 }
239 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
262pub 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 println!("log_level={}", resp.log_level);
296 }
297
298 Ok(())
299}
300
301pub 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 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 println!("drain_initiated={}", resp.initiated);
350 }
351 return Ok(());
352 }
353
354 if !resp.wait_admin_honored {
366 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 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 emit_drain_complete(&mut io::stdout().lock(), args.json, true)
399 .context("failed to write drain-complete output")?;
400 return Ok(());
401 }
402 Err(JsonRpcClientError::RequestTimeout | JsonRpcClientError::Transport(_)) => {
411 check_deadline_and_sleep(deadline, poll_interval, args.wait_timeout_secs).await?;
412 continue;
413 }
414 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
445async 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
467async 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
490async 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
509fn 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
532pub 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
567pub 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
601fn 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 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
630fn write_lane_row(w: &mut impl io::Write, c: &LaneSnapshot) -> io::Result<()> {
633 let lane = short_node_id(&c.pool_id);
634 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 None => "never".to_string(),
646 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
663fn 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
673pub 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
709pub 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 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
753fn 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
787pub(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
826pub(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
835fn 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
857fn 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
875pub(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
912fn 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
933fn 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 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 (io::ErrorKind::NotFound, ConfigPathSource::Default) => Ok(None),
976 (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 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
1009fn 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 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
1045fn write_status(w: &mut impl io::Write, s: &StatusResponse, now_us: u64) -> io::Result<()> {
1051 writeln!(w, "node_id={}", s.node_id)?;
1052 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 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 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
1103fn 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
1114fn 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
1138pub 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 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
1206fn 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
1223struct 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
1244async 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 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 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
1312fn node_id_hex(node_id: &PublicKey) -> String {
1315 format!("0x{}", alloy::primitives::hex::encode(node_id.as_bytes()))
1316}
1317
1318fn 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#[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;