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 = short_node_id(&c.counterparty);
635 let signer = short_node_id(&c.voucher_signer);
636 let last_voucher = match c.seconds_since_last_voucher {
637 None => "never".to_string(),
641 Some(secs) => format_age(secs.saturating_mul(1_000_000)),
646 };
647 let eligible = if c.settlement_eligible { "yes" } else { "no" };
648 writeln!(
649 w,
650 "{lane:<14} {counterparty:<14} {signer:<14} {:>6} {:>12} {:>12} {last_voucher:>12} \
651 {eligible}",
652 c.last_nonce,
653 format_usdc(c.outstanding_micro_usdc),
654 format_usdc(c.deposit_micro_usdc),
655 )
656}
657
658fn format_usdc(micro: u64) -> String {
663 let whole = micro / 1_000_000;
664 let frac = micro % 1_000_000;
665 format!("{whole}.{frac:06}")
666}
667
668pub async fn slashes(args: &cli::SlashesArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
673 anyhow::ensure!(
674 args.timeout_ms > 0,
675 "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
676 'never' rather than 'sub-millisecond deadline')"
677 );
678
679 let config_path = args.config.as_deref().or(global_config);
680 let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
681
682 let client = HttpClientBuilder::default()
683 .request_timeout(Duration::from_millis(args.timeout_ms))
684 .build(&url)
685 .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
686
687 let parsed: SlashesResponse = client
688 .slashes()
689 .await
690 .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
691
692 if args.json {
693 let pretty =
694 serde_json::to_string_pretty(&parsed).context("failed to encode slashes as JSON")?;
695 println!("{pretty}");
696 } else {
697 let mut stdout = io::stdout().lock();
698 write_slashes_table(&mut stdout, &parsed).context("failed to write slashes table")?;
699 }
700
701 Ok(())
702}
703
704pub async fn pools(args: &cli::PoolsArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
711 anyhow::ensure!(
712 args.timeout_ms > 0,
713 "--timeout-ms must be > 0 (jsonrpsee treats Duration::ZERO as \
714 'never' rather than 'sub-millisecond deadline')"
715 );
716
717 let config_path = args.config.as_deref().or(global_config);
718 let url = resolve_admin_url(args.admin_url.as_deref(), config_path)?;
719
720 let client = HttpClientBuilder::default()
721 .request_timeout(Duration::from_millis(args.timeout_ms))
722 .build(&url)
723 .with_context(|| format!("failed to build admin JSON-RPC client for {url}"))?;
724
725 let parsed: BuyerPoolsResponse = client
726 .pools()
727 .await
728 .map_err(|err| classify_client_error(&url, args.timeout_ms, err))?;
729
730 if args.json {
731 let pretty =
732 serde_json::to_string_pretty(&parsed).context("failed to encode pools as JSON")?;
733 println!("{pretty}");
734 } else {
735 crate::commands::pool::write_skipped_buyer_pools(&mut io::stderr().lock(), &parsed.skipped)
736 .context("failed to write skipped pools")?;
737 let mut stdout = io::stdout().lock();
738 writeln!(stdout, "store={url} (admin RPC)").context("failed to write pools header")?;
741 crate::commands::pool::write_buyer_pools(&mut stdout, &parsed)
742 .context("failed to write pools table")?;
743 }
744
745 Ok(())
746}
747
748fn write_slashes_table(w: &mut impl io::Write, resp: &SlashesResponse) -> io::Result<()> {
753 writeln!(w, "slashes={}", resp.slashes.len())?;
754 if resp.slashes.is_empty() {
755 return writeln!(w, "(no slashes detected)");
756 }
757 writeln!(
758 w,
759 "{:<20} {:>6} {:>14} {:>10} {:>10} APPEAL_CLOSE",
760 "SLASH_ID", "TYPE", "AMOUNT", "BLOCK", "EVIDENCE",
761 )?;
762 for s in &resp.slashes {
763 let block = s
764 .block_number
765 .map_or_else(|| "?".to_string(), |b| b.to_string());
766 let close = s
767 .appeal_window_close
768 .map_or_else(|| "?".to_string(), |c| c.to_string());
769 writeln!(
770 w,
771 "{:<20} {:>6} {:>14} {:>10} {:>10} {close}",
772 s.slash_id,
773 s.offense_type,
774 s.amount,
775 block,
776 short_node_id(&s.evidence_hash),
777 )?;
778 }
779 Ok(())
780}
781
782pub(crate) fn classify_client_error(
793 url: &str,
794 timeout_ms: u64,
795 err: JsonRpcClientError,
796) -> anyhow::Error {
797 match err {
798 JsonRpcClientError::RequestTimeout => anyhow::anyhow!(
799 "admin at {url} did not respond within {timeout_ms}ms; \
800 the node may be overloaded or blocked on a long lock hold",
801 ),
802 JsonRpcClientError::Transport(inner) => {
803 if is_connection_refused(inner.as_ref()) {
804 anyhow::anyhow!(
805 "admin at {url} refused the connection ({inner}); is the node \
806 running, and is admin_port configured correctly?",
807 )
808 } else {
809 anyhow::anyhow!("admin request to {url} failed: {inner}")
810 }
811 }
812 JsonRpcClientError::Call(obj) => anyhow::anyhow!(
813 "admin at {url} returned JSON-RPC error {code}: {msg}",
814 code = obj.code(),
815 msg = obj.message(),
816 ),
817 other => anyhow::Error::new(other).context(format!("admin request to {url} failed")),
818 }
819}
820
821pub(crate) fn is_connection_refused(err: &(dyn std::error::Error + 'static)) -> bool {
827 io_error_kind_in_source_chain(err, |kind| kind == std::io::ErrorKind::ConnectionRefused)
828}
829
830fn is_admin_closed_transport_error(err: &(dyn std::error::Error + 'static)) -> bool {
841 io_error_kind_in_source_chain(err, |kind| {
842 matches!(
843 kind,
844 std::io::ErrorKind::ConnectionRefused
845 | std::io::ErrorKind::ConnectionReset
846 | std::io::ErrorKind::ConnectionAborted
847 | std::io::ErrorKind::UnexpectedEof
848 )
849 })
850}
851
852fn io_error_kind_in_source_chain(
855 err: &(dyn std::error::Error + 'static),
856 predicate: impl Fn(std::io::ErrorKind) -> bool,
857) -> bool {
858 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
859 while let Some(e) = current {
860 if let Some(io_err) = e.downcast_ref::<std::io::Error>()
861 && predicate(io_err.kind())
862 {
863 return true;
864 }
865 current = e.source();
866 }
867 false
868}
869
870pub(crate) fn resolve_admin_url(
886 flag: Option<&str>,
887 config_path: Option<&Path>,
888) -> anyhow::Result<String> {
889 if let Some(url) = flag {
890 return Ok(url.to_string());
891 }
892 if let Some(url) = admin_url_from_env(std::env::var("DECDN_ADMIN_PORT").ok())? {
893 return Ok(url);
894 }
895 let (resolved_path, source) = match config_path {
896 Some(p) => (Some(expand_tilde(p)), ConfigPathSource::Explicit),
897 None => (
898 cli::common::default_config_path(),
899 ConfigPathSource::Default,
900 ),
901 };
902 let port =
903 port_from_config_file(resolved_path.as_deref(), source)?.unwrap_or(DEFAULT_ADMIN_PORT);
904 Ok(format!("http://127.0.0.1:{port}"))
905}
906
907fn admin_url_from_env(raw: Option<String>) -> anyhow::Result<Option<String>> {
915 let Some(raw) = raw else { return Ok(None) };
916 let port: u16 = raw
917 .trim()
918 .parse()
919 .map_err(|_| anyhow::anyhow!("DECDN_ADMIN_PORT is not a valid port number: {raw:?}"))?;
920 if port == 0 {
921 anyhow::bail!(
922 "DECDN_ADMIN_PORT=0 disables the admin server; pass --admin-url or set a non-zero port"
923 );
924 }
925 Ok(Some(format!("http://127.0.0.1:{port}")))
926}
927
928fn port_from_config_file(
941 path: Option<&Path>,
942 source: ConfigPathSource,
943) -> anyhow::Result<Option<u16>> {
944 let Some(path) = path else { return Ok(None) };
945 let path: PathBuf = path.to_path_buf();
946 match std::fs::read_to_string(&path) {
947 Ok(contents) => {
948 let parsed: AdminPortConfig = toml::from_str(&contents)
958 .with_context(|| format!("failed to parse config file {}", path.display()))?;
959 match parsed.observability.and_then(|o| o.admin_port) {
960 Some(0) => anyhow::bail!(
961 "config {} disables the admin server (observability.admin_port = 0); \
962 pass --admin-url or enable the admin port",
963 path.display()
964 ),
965 other => Ok(other),
966 }
967 }
968 Err(err) => match (err.kind(), source) {
969 (io::ErrorKind::NotFound, ConfigPathSource::Default) => Ok(None),
971 (io::ErrorKind::PermissionDenied, _) => Err(anyhow::anyhow!(
975 "cannot read config file {}: permission denied; check file mode and ownership",
976 path.display()
977 )),
978 _ => Err(anyhow::anyhow!(
979 "failed to read config file {}: {err}",
980 path.display()
981 )),
982 },
983 }
984}
985
986fn short_node_id(hex: &str) -> String {
987 let mut chars = hex.chars();
990 let prefix: String = chars.by_ref().take(12).collect();
991 if chars.next().is_some() {
992 format!("{prefix}…")
993 } else {
994 prefix
995 }
996}
997
998fn wall_clock_us() -> u64 {
999 std::time::SystemTime::now()
1000 .duration_since(std::time::UNIX_EPOCH)
1001 .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
1002}
1003
1004fn relative_age(now_us: u64, last_seen_us: u64) -> String {
1008 if last_seen_us == 0 {
1009 return "unknown".to_string();
1010 }
1011 if last_seen_us > now_us {
1012 return "in future".to_string();
1015 }
1016 let delta_us = now_us - last_seen_us;
1017 format_age(delta_us)
1018}
1019
1020fn format_age(delta_us: u64) -> String {
1021 const US_PER_SEC: u64 = 1_000_000;
1022 const US_PER_MIN: u64 = 60 * US_PER_SEC;
1023 const US_PER_HOUR: u64 = 60 * US_PER_MIN;
1024 const US_PER_DAY: u64 = 24 * US_PER_HOUR;
1025 if delta_us < US_PER_SEC {
1026 return "<1s ago".to_string();
1027 }
1028 if delta_us < US_PER_MIN {
1029 return format!("{}s ago", delta_us / US_PER_SEC);
1030 }
1031 if delta_us < US_PER_HOUR {
1032 return format!("{}m ago", delta_us / US_PER_MIN);
1033 }
1034 if delta_us < US_PER_DAY {
1035 return format!("{}h ago", delta_us / US_PER_HOUR);
1036 }
1037 format!("{}d ago", delta_us / US_PER_DAY)
1038}
1039
1040fn write_status(w: &mut impl io::Write, s: &StatusResponse, now_us: u64) -> io::Result<()> {
1046 writeln!(w, "node_id={}", s.node_id)?;
1047 writeln!(
1051 w,
1052 "operator_address={}",
1053 s.operator_address.as_deref().unwrap_or("(unknown)")
1054 )?;
1055 writeln!(w, "known_stakers={}", s.known_stakers)?;
1056 let last_refresh = match s.routing.last_refresh_us {
1057 None => "never".to_string(),
1059 Some(us) => relative_age(now_us, us),
1060 };
1061 writeln!(
1062 w,
1063 "routing total_peers={} non_empty_buckets={} refresh_interval={} last_refresh={}",
1064 s.routing.total_peers,
1065 s.routing.non_empty_buckets,
1066 format_interval(s.routing.refresh_interval_s),
1067 last_refresh,
1068 )?;
1069 writeln!(
1070 w,
1071 "record_store records={}/{} ({})",
1072 s.record_store.records,
1073 s.record_store.capacity,
1074 percent(s.record_store.records, s.record_store.capacity),
1075 )?;
1076 writeln!(
1077 w,
1078 "republish scheduled_records={}",
1079 s.republish.scheduled_records
1080 )?;
1081 writeln!(w, "chain_denied_origins={}", s.chain_denied_origins)?;
1082
1083 if s.routing.buckets.is_empty() {
1084 return writeln!(w, "(routing table empty — no buckets populated)");
1085 }
1086 let capacity = s.routing.bucket_capacity;
1088 writeln!(w)?;
1089 writeln!(w, "{:<8} {:<8} FILL%", "BUCKET", "FILL")?;
1090 for b in &s.routing.buckets {
1091 let fill = format!("{}/{capacity}", b.fill);
1092 let pct = percent(u64::from(b.fill), u64::from(capacity));
1093 writeln!(w, "{:<8} {fill:<8} {pct}", b.index)?;
1094 }
1095 Ok(())
1096}
1097
1098fn percent(num: u64, den: u64) -> String {
1103 if den == 0 {
1104 return "n/a".to_string();
1105 }
1106 format!("{}%", num.saturating_mul(100) / den)
1107}
1108
1109fn format_interval(secs: u64) -> String {
1115 const SEC_PER_MIN: u64 = 60;
1116 const SEC_PER_HOUR: u64 = 60 * SEC_PER_MIN;
1117 const SEC_PER_DAY: u64 = 24 * SEC_PER_HOUR;
1118 if secs == 0 {
1119 return "0s".to_string();
1120 }
1121 if secs.is_multiple_of(SEC_PER_DAY) {
1122 return format!("{}d", secs / SEC_PER_DAY);
1123 }
1124 if secs.is_multiple_of(SEC_PER_HOUR) {
1125 return format!("{}h", secs / SEC_PER_HOUR);
1126 }
1127 if secs.is_multiple_of(SEC_PER_MIN) {
1128 return format!("{}m", secs / SEC_PER_MIN);
1129 }
1130 format!("{secs}s")
1131}
1132
1133pub async fn lookup(args: &cli::LookupArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
1144 let config_path = args.chain.common.config.as_deref().or(global_config);
1145 let file = chain_ctx::load_optional_config(config_path)?;
1146 let resolved = chain_ctx::resolve(&args.chain, &file)?;
1147
1148 let node_id = args
1149 .node_id
1150 .as_deref()
1151 .map(|s| {
1152 PublicKey::from_str(s).map_err(|e| anyhow::anyhow!("invalid --node-id {s:?}: {e}"))
1153 })
1154 .transpose()?;
1155 let region = args
1156 .region
1157 .as_deref()
1158 .map(|raw| {
1159 Region::parse(raw).ok_or_else(|| {
1160 anyhow::anyhow!("invalid --region {raw:?}: not an accepted ISO 3166-1 alpha-2 code")
1161 })
1162 })
1163 .transpose()?;
1164
1165 let candidates = discovery::active_nodes(&resolved.rpc_url, resolved.capacity_bond_address)
1166 .await
1167 .with_context(|| {
1168 format!(
1169 "failed to read active nodes from CapacityBond at {}",
1170 resolved.capacity_bond_address
1171 )
1172 })?;
1173 let filtered = filter_candidates(candidates, node_id, region);
1174
1175 let rows = if args.probe {
1176 let shortlisted = select_candidates(filtered, args.region.as_deref(), SELECT_K);
1184 probe_and_rank(shortlisted, config_path, args.timeout_ms).await?
1185 } else {
1186 filtered.into_iter().map(LookupRow::from).collect()
1187 };
1188
1189 let mut out = io::stdout().lock();
1190 if args.chain.common.json {
1191 let json_rows: Vec<LookupJson> = rows.iter().map(LookupJson::from).collect();
1192 serde_json::to_writer_pretty(&mut out, &json_rows)
1193 .context("failed to encode lookup result as JSON")?;
1194 writeln!(out)?;
1195 } else {
1196 write_lookup_table(&mut out, &rows).context("failed to write lookup table")?;
1197 }
1198 Ok(())
1199}
1200
1201fn filter_candidates(
1207 candidates: Vec<NodeCandidate>,
1208 node_id: Option<PublicKey>,
1209 region: Option<Region>,
1210) -> Vec<NodeCandidate> {
1211 candidates
1212 .into_iter()
1213 .filter(|c| node_id.is_none_or(|id| c.node_id == id))
1214 .filter(|c| region.is_none_or(|r| c.region_hint == Some(r)))
1215 .collect()
1216}
1217
1218struct LookupRow {
1222 node_id: PublicKey,
1223 eth_address: Address,
1224 region_hint: Option<Region>,
1225 rtt_ms: Option<f64>,
1226}
1227
1228impl From<NodeCandidate> for LookupRow {
1229 fn from(c: NodeCandidate) -> Self {
1230 Self {
1231 node_id: c.node_id,
1232 eth_address: c.eth_address,
1233 region_hint: c.region_hint,
1234 rtt_ms: None,
1235 }
1236 }
1237}
1238
1239async fn probe_and_rank(
1248 candidates: Vec<NodeCandidate>,
1249 config_path: Option<&Path>,
1250 timeout_ms: u64,
1251) -> anyhow::Result<Vec<LookupRow>> {
1252 if candidates.is_empty() {
1253 return Ok(Vec::new());
1254 }
1255
1256 let relays = client_endpoint::resolve_relays(None, config_path)?;
1260 let discovery_cfg = client_endpoint::client_discovery(config_path)?;
1261 let endpoint = client_endpoint::client_endpoint(&relays, &discovery_cfg).await?;
1262 let timeout = Duration::from_millis(timeout_ms);
1263 let hash = *blake3::hash(b"decdn-node-lookup-sentinel/v1").as_bytes();
1264
1265 let mut rows = Vec::with_capacity(candidates.len());
1266 for c in candidates {
1267 let mut target = EndpointAddr::new(c.node_id);
1268 if let Some(url) = relays.first() {
1269 target = target.with_relay_url(url.clone());
1270 }
1271 let target = discovery::with_dial_addrs(target, &c);
1272 let timestamp_us = wall_clock_us();
1273 match probe_once(&endpoint, target, hash, timestamp_us, timeout).await {
1274 Ok((_, _, rtt)) => rows.push(LookupRow {
1275 node_id: c.node_id,
1276 eth_address: c.eth_address,
1277 region_hint: c.region_hint,
1278 rtt_ms: Some(rtt.ms),
1279 }),
1280 Err(e) => {
1281 eprintln!(
1282 "warning: probe of node {} failed: {e}",
1283 short_node_id(&node_id_hex(&c.node_id))
1284 );
1285 rows.push(LookupRow {
1286 node_id: c.node_id,
1287 eth_address: c.eth_address,
1288 region_hint: c.region_hint,
1289 rtt_ms: None,
1290 });
1291 }
1292 }
1293 }
1294 endpoint.close().await;
1295
1296 rows.sort_by(|a, b| match (a.rtt_ms, b.rtt_ms) {
1299 (Some(x), Some(y)) => x.total_cmp(&y),
1300 (Some(_), None) => std::cmp::Ordering::Less,
1301 (None, Some(_)) => std::cmp::Ordering::Greater,
1302 (None, None) => std::cmp::Ordering::Equal,
1303 });
1304 Ok(rows)
1305}
1306
1307fn node_id_hex(node_id: &PublicKey) -> String {
1310 format!("0x{}", alloy::primitives::hex::encode(node_id.as_bytes()))
1311}
1312
1313fn write_lookup_table(w: &mut impl io::Write, rows: &[LookupRow]) -> io::Result<()> {
1319 writeln!(w, "nodes={}", rows.len())?;
1320 if rows.is_empty() {
1321 return writeln!(w, "(no matching active nodes)");
1322 }
1323 let probed = rows.iter().any(|r| r.rtt_ms.is_some());
1324 if probed {
1325 writeln!(
1326 w,
1327 "{:<14} {:<44} {:<8} {:>12}",
1328 "NODE_ID", "ETH_ADDRESS", "REGION", "RTT_MS"
1329 )?;
1330 } else {
1331 writeln!(w, "{:<14} {:<44} {:<8}", "NODE_ID", "ETH_ADDRESS", "REGION")?;
1332 }
1333 for r in rows {
1334 let node_preview = short_node_id(&node_id_hex(&r.node_id));
1335 let region = r
1336 .region_hint
1337 .map_or_else(|| "?".to_string(), |x| x.to_string());
1338 if probed {
1339 let rtt = r
1340 .rtt_ms
1341 .map_or_else(|| "unreachable".to_string(), |ms| format!("{ms:.1}"));
1342 writeln!(
1343 w,
1344 "{node_preview:<14} {:<44} {region:<8} {rtt:>12}",
1345 r.eth_address
1346 )?;
1347 } else {
1348 writeln!(w, "{node_preview:<14} {:<44} {region:<8}", r.eth_address)?;
1349 }
1350 }
1351 Ok(())
1352}
1353
1354#[derive(Serialize)]
1364struct LookupJson {
1365 node_id: String,
1366 eth_address: String,
1367 region_hint: Option<String>,
1368 #[serde(skip_serializing_if = "Option::is_none")]
1369 rtt_ms: Option<f64>,
1370}
1371
1372impl From<&LookupRow> for LookupJson {
1373 fn from(r: &LookupRow) -> Self {
1374 Self {
1375 node_id: node_id_hex(&r.node_id),
1376 eth_address: r.eth_address.to_string(),
1377 region_hint: r.region_hint.map(|region| region.to_string()),
1378 rtt_ms: r.rtt_ms,
1379 }
1380 }
1381}
1382
1383#[cfg(test)]
1384#[allow(
1385 clippy::unwrap_used,
1386 clippy::expect_used,
1387 clippy::indexing_slicing,
1388 clippy::panic
1389)]
1390mod tests;