decdn_node/admin.rs
1//! Loopback-only admin JSON-RPC server (`appendix-local-admin-http.md`).
2//!
3//! Wire types (the `AdminRpc` trait, DTOs, error codes, [`parse_hash_arg`]) live
4//! in [`decdn_common::admin`] so the user-facing `decdn` CLI can speak the
5//! generated client without dragging in the daemon's runtime
6//! dependencies. This module keeps the server-side implementation:
7//! [`AdminState`], the [`AdminRpcImpl`] that backs the trait against the
8//! live cache and runtime handles, and the bind/serve helpers the runtime
9//! calls during start-up and graceful shutdown.
10
11use std::collections::HashMap;
12use std::net::SocketAddr;
13use std::path::PathBuf;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{Arc, Mutex as StdMutex, OnceLock};
16use std::time::{Duration, Instant};
17
18use alloy::primitives::{Address, U256};
19use decdn_cache::{CacheEngine, CacheError};
20use decdn_common::admin::{
21 AdminRpcServer, BUYER_POOL_STORE_ERROR_CODE, BUYER_POOL_UNAVAILABLE_CODE, BucketStat,
22 BuyerLaneSnapshot, BuyerPoolSnapshot, BuyerPoolsResponse, CACHE_ERROR_CODE,
23 CONFIG_PATH_UNSET_CODE, DHT_POISONED_CODE, DHT_UNAVAILABLE_CODE, DrainRequest, DrainResponse,
24 EvictPreview, EvictRequest, EvictResponse, HealthResponse, LaneSnapshot, LanesResponse,
25 POOL_STORE_ERROR_CODE, RELOAD_ERROR_CODE, RecordStoreHealth, ReloadResponse, RepublishHealth,
26 RoutingHealth, SLASH_DETECTION_UNAVAILABLE_CODE, SlashRecordDto, SlashesResponse,
27 StatusResponse, parse_hash_arg,
28};
29use decdn_incentive::buyer_pool::{BuyerPoolState, BuyerPoolStore};
30use decdn_incentive::{LaneKey, LaneState, PoolStateStore};
31
32pub use crate::handlers::client::LaneActivityClock;
33use jsonrpsee::core::{RpcResult, async_trait};
34use jsonrpsee::server::{Server, ServerConfig};
35use jsonrpsee::types::ErrorObjectOwned;
36use tokio::net::TcpListener;
37use tokio::sync::{Notify, oneshot};
38
39use crate::binding_check::BindingReport;
40use crate::dht::{RecordStore, RepublishScheduler, RoutingTable, StakerSet};
41use crate::metrics::Metrics;
42
43// Wire types live in `decdn_common::admin`. We import the server-side
44// trait, the request DTO, and the few error codes the server impl
45// raises — clients reach the response DTOs through `decdn_common`
46// directly.
47
48use crate::runtime::RuntimeReloadState;
49
50/// Cap concurrent admin connections. The surface is local-operator-only,
51/// but an errant operator script looping requests must not be able to
52/// exhaust the runtime's task budget. `u32` rather than `usize` because
53/// `ServerConfig::max_connections` is typed that way.
54const MAX_ADMIN_CONNECTIONS: u32 = 16;
55
56/// Shared state for admin RPC handlers.
57#[derive(Debug, Clone)]
58pub struct AdminState {
59 /// Raw bytes of this node's iroh `PublicKey`. Hex-encoded on the
60 /// wire by `admin_v1_health`.
61 node_id: [u8; 32],
62 /// Process-start `Instant`, captured by the runtime at the top of
63 /// `run()` before any `await` or I/O. Used as the origin of the
64 /// `uptime_s` field returned by `admin_v1_health`. `Instant` (not
65 /// `SystemTime`) so wall-clock skew during the process's lifetime
66 /// can't make uptime go backwards. May predate `AdminState`
67 /// construction by the time the RPC preflight, identity load, and
68 /// cache open take.
69 started_at: Instant,
70 /// Cache engine handle for `admin_v1_evict` (issue #279). The engine
71 /// is `Clone` (its `Arc<Inner>` is shared), so cloning into
72 /// `AdminState` is cheap.
73 cache: CacheEngine,
74 /// Hot-reload hook for `admin_v1_reload` (issue #373). `None` when
75 /// the node was started without a config file path (CLI-only flag
76 /// invocation), in which case there's nothing on disk for reload to
77 /// re-read; the RPC method translates that into a "config path
78 /// unset" error so the operator gets a specific message instead of
79 /// silently no-op'ing.
80 reload_hook: Option<ReloadHook>,
81 /// Drain trigger for `admin_v1_drain` (issue #244). Always present —
82 /// drain has no preconditions analogous to "no config path", so this
83 /// field is `Arc<DrainTrigger>` (not `Option<…>` like `reload_hook`)
84 /// and the runtime wires it unconditionally.
85 drain_trigger: Arc<DrainTrigger>,
86 /// Live process metrics handle, used by `admin_v1_health` to read
87 /// the current `dispatch_in_flight` gauge for the
88 /// `in_flight_streams` field (issue #604). The `Arc<Metrics>` is
89 /// the same handle the dispatch limiter increments/decrements via
90 /// its permit RAII pair, so the value is always consistent with
91 /// the live in-flight handler count.
92 metrics: Arc<Metrics>,
93 /// DHT introspection handles backing `admin_v1_status` (issue #741).
94 /// `None` when the DHT subsystem isn't wired (the unit tests that
95 /// exercise the cache methods build `AdminState` without it; the
96 /// production runtime always attaches it via
97 /// [`AdminState::with_dht`]). The `status` RPC returns
98 /// [`DHT_UNAVAILABLE_CODE`] when this is `None`.
99 dht: Option<DhtStatusHandles>,
100 /// Lane introspection handles backing `admin_v1_lanes`
101 /// (issue #749). `None` when the lane subsystem isn't wired (the
102 /// unit tests that exercise the cache methods build `AdminState`
103 /// without it; the production runtime always attaches it
104 /// via [`AdminState::with_lanes`]). The `lanes` RPC returns an
105 /// empty list (not an error) when this is `None` — a node with no
106 /// payment surface legitimately has zero lanes to report, and the
107 /// CLI's empty-table sentinel covers it.
108 lanes: Option<LaneStatusHandles>,
109 /// The node's buyer-side pool store, backing `admin_v1_pools` (#2078),
110 /// attached via [`AdminState::with_buyer_pools`]. The SAME handle the buy
111 /// loop records adoptions and top-ups into, so the admin surface reports
112 /// what the daemon actually believes. The production runtime always
113 /// attaches it, so `None` means a hand-built `AdminState` — a test, or a
114 /// future bring-up with no buy leg — and answers
115 /// [`BUYER_POOL_UNAVAILABLE_CODE`] rather than an empty list: a node that
116 /// tracks nothing and a node that owns no pools call for opposite operator
117 /// responses.
118 buyer_pools: Option<BuyerPoolHandle>,
119 /// Slash-detection handles backing `admin_v1_slashes` (#1032), attached via
120 /// [`AdminState::with_slash_detection`]. `None` (no `slash_judge_address`
121 /// wired / unit tests) → `slashes` returns [`SLASH_DETECTION_UNAVAILABLE_CODE`].
122 slash_detection: Option<SlashStatusHandles>,
123 /// Outcome of the bring-up node-id binding check (#1034), attached via
124 /// [`AdminState::with_binding`]. Defaults to
125 /// [`BindingReport::unknown`] — the honest answer for a state that was
126 /// never sampled, and the one the unit tests here construct.
127 binding: BindingReport,
128 /// The SAME ADR 041 per-source warming allowance the buy loop debits, the
129 /// serve path credits, and the eviction driver forgets from, attached via
130 /// [`AdminState::with_warming`]. `admin_v1_evict` durably removes a blob
131 /// exactly like a governance takedown does, so it must also drop the
132 /// hash's `source_of` provenance tag — otherwise a re-admitted hash could
133 /// spuriously credit its old source. `None` (unit tests that don't wire
134 /// it) means the evict handler simply skips the forget.
135 warming: Option<Arc<crate::warming_allowance::WarmingAllowance>>,
136 /// Live on-chain active-staker set, attached via
137 /// [`AdminState::with_staker_set`] (#1030). `None` (unit tests, and any
138 /// build with no chain wiring) reports `registry_active: false`: an unwired
139 /// `AdminState` cannot confirm registration, and the optimistic answer would
140 /// tell an operator their node is registered when nothing checked.
141 staker_set: Option<Arc<dyn crate::dht::staker_set::StakerSet>>,
142 /// The live content denylist, attached via [`AdminState::with_denylist`].
143 /// `None` (unit tests that don't wire it) reports `chain_denied_origins:
144 /// 0` — an unwired `AdminState` has no chain-origin deny-set to count.
145 denylist: Option<Arc<crate::content_deny::ContentDenylist>>,
146 /// The operator Ethereum address this node runs under, attached via
147 /// [`AdminState::with_operator_address`]. Sampled once at bring-up from the
148 /// loaded eth keystore — the same value the node-id binding check derives —
149 /// so `admin_v1_status` can report it without an extra chain round-trip.
150 /// `None` (unit tests, and any build with no chain wiring) reports
151 /// `operator_address: None`, the honest answer when nothing supplied one.
152 operator_address: Option<Address>,
153}
154
155/// The buyer pool store handle `admin_v1_pools` reads, wrapped so
156/// [`AdminState`] keeps its `Debug` derive. An `Arc` clone of the store the
157/// buy loop already writes through, so attaching it adds read access, not new
158/// ownership.
159#[derive(Clone)]
160pub struct BuyerPoolHandle(Arc<dyn BuyerPoolStore>);
161
162// `AdminState` derives `Debug`, and `dyn BuyerPoolStore` carries no `Debug`
163// bound, so name the wrapper without formatting the handle.
164impl std::fmt::Debug for BuyerPoolHandle {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("BuyerPoolHandle").finish_non_exhaustive()
167 }
168}
169
170/// Read-only lane handles the `admin_v1_lanes` handler snapshots (issue
171/// #749). Each is an `Arc` clone of state the runtime already owns and
172/// shares with the `cdn/client/v1` handler and the settlement service, so
173/// attaching this to [`AdminState`] adds read access, not new ownership.
174/// Bundled into one struct so the `with_lanes` builder stays a single
175/// argument.
176#[derive(Clone)]
177pub struct LaneStatusHandles {
178 /// Persistent per-lane voucher state (same handle the client handler
179 /// commits accepted vouchers to). Read via `load_all` to build the
180 /// snapshot — the freshest committed `last_*` per lane.
181 pub pool_store: Arc<dyn PoolStateStore>,
182 /// Read handle over the client handler's live lane registry (issue #1733).
183 /// Read for `seconds_since_last_voucher`; a lane reads back `None` until
184 /// this process accepts a voucher for it.
185 pub lane_activity: LaneActivityClock,
186 /// Configured redemption threshold in micro-USDC
187 /// (`blockchain.redeem_threshold_micro_usdc`). A lane whose accrued
188 /// claim has reached this is reported `settlement_eligible`.
189 pub redeem_threshold_micro_usdc: u64,
190}
191
192// `AdminState` derives `Debug`, so the bundled handles must too. The
193// trait-object `Arc<dyn PoolStateStore>` carries no `Debug` bound, so
194// hand-roll a terse impl that names the struct without formatting the handles.
195impl std::fmt::Debug for LaneStatusHandles {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("LaneStatusHandles")
198 .field(
199 "redeem_threshold_micro_usdc",
200 &self.redeem_threshold_micro_usdc,
201 )
202 .finish_non_exhaustive()
203 }
204}
205
206/// Read-only slash-detection handle the `admin_v1_slashes` handler snapshots
207/// (#1032). The shared in-memory store is populated by the slash watcher; the
208/// admin surface only reads it. Bundled into a struct so the
209/// `with_slash_detection` builder stays a single argument (and to leave room
210/// for future fields without churning the signature).
211#[derive(Debug, Clone)]
212pub struct SlashStatusHandles {
213 /// Detected slashes against this node's operator, appended in detection
214 /// order (deduped by `slashId`). Same handle the watcher writes to.
215 pub store: crate::slash_watcher::SlashStore,
216}
217
218/// Read-only DHT subsystem handles the `admin_v1_status` handler snapshots
219/// (issue #741). Each shared-state field is an `Arc` clone of state the
220/// runtime already owns and shares with the DHT handler / refresh /
221/// republish tasks (plus the `Copy` `refresh_interval`), so attaching this
222/// to [`AdminState`] adds no new ownership — only read access. Bundled into
223/// one struct so the `AdminState` constructor stays a single optional
224/// argument rather than five positional ones.
225#[derive(Clone)]
226pub struct DhtStatusHandles {
227 /// Kademlia routing table (same handle the DHT handler / bucket-refresh
228 /// task mutate). Locked briefly to snapshot bucket fill counts.
229 pub routing: Arc<StdMutex<RoutingTable>>,
230 /// Active-staker set (chain-backed in production). Read for its count.
231 pub staker_set: Arc<dyn StakerSet>,
232 /// Provider-record store. Locked briefly to read size + capacity.
233 pub record_store: Arc<StdMutex<RecordStore>>,
234 /// Republish scheduler. Read for its scheduled-record depth.
235 pub republish: Arc<RepublishScheduler>,
236 /// Wall-clock-µs of the last completed bucket-refresh pass (0 = never),
237 /// stamped by [`crate::dht::bucket_refresh::run_bucket_refresh`].
238 pub refresh_clock: Arc<AtomicU64>,
239 /// Bucket-refresh interval, echoed so the operator sees the cadence the
240 /// last-refresh timestamp is relative to.
241 pub refresh_interval: Duration,
242}
243
244// `AdminState` derives `Debug`, so the bundled handles must too. The
245// trait-object `Arc<dyn StakerSet>` carries no `Debug` bound, so hand-roll
246// a terse impl that names the struct without formatting the handles.
247impl std::fmt::Debug for DhtStatusHandles {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 f.debug_struct("DhtStatusHandles")
250 .field("refresh_interval", &self.refresh_interval)
251 .finish_non_exhaustive()
252 }
253}
254
255/// One-shot trigger that lets `admin_v1_drain` wake the runtime's main
256/// select loop and request graceful shutdown (issue #244). Modelled on
257/// [`tokio::sync::Notify`] so the RPC handler can fire-and-return
258/// without blocking on the runtime's shutdown latency. The runtime
259/// awaits [`wait`](Self::wait) in its select loop; firing twice is a
260/// no-op (the second `notify_one` coalesces, same as `Notify`).
261///
262/// `wait_admin` (issue #604) is a per-drain configuration bit:
263/// [`fire`](Self::fire) takes it as a parameter. **First writer wins**
264/// — once a fire has established the value, subsequent fires return
265/// that same value rather than overwriting. This gives two concurrent
266/// drain RPCs one well-defined outcome: the effective `wait_admin`
267/// value the runtime will see is the first one written, and every
268/// response reports that same value.
269///
270/// Cross-thread visibility comes from the `Notify::notify_one →
271/// Notify::notified()` happens-before edge: the runtime's
272/// [`wait_admin`](Self::wait_admin) load is sequenced after
273/// `notified()` resolves, and tokio's `Notify` publishes prior writes
274/// (including the `OnceLock` store) on that edge. The `OnceLock`'s own
275/// synchronization is belt-and-braces — important only for readers
276/// that don't go through `wait()`.
277#[derive(Debug, Default)]
278pub struct DrainTrigger {
279 notify: Notify,
280 /// `None` until the first `fire(...)` call lands; `Some(v)` once
281 /// some writer has established the effective `wait_admin` value
282 /// for this drain. Subsequent fires return this value without
283 /// overwriting it.
284 wait_admin: OnceLock<bool>,
285}
286
287impl DrainTrigger {
288 /// Create a new `DrainTrigger`.
289 pub fn new() -> Self {
290 Self::default()
291 }
292
293 /// Signal the runtime to begin graceful shutdown with the requested
294 /// `wait_admin` ordering. Returns the *effective* value the runtime
295 /// will see — equal to `wait_admin` if this is the first fire, or
296 /// the prior writer's value if a fire already happened. The drain
297 /// RPC uses the return as the `wait_admin_honored` ack so callers
298 /// see the truth about what the runtime will actually do, even
299 /// under a concurrent-drain race (issue #604 review).
300 ///
301 /// Fire-and-forget: calling this more than once is a no-op as far
302 /// as wake-up goes (the second `notify_one` coalesces into the
303 /// pending permit the first call stored).
304 pub fn fire(&self, wait_admin: bool) -> bool {
305 // First writer wins. `OnceLock::get_or_init` is the atomic
306 // CAS-equivalent: only the first caller's closure runs and
307 // its value is stored; every subsequent call returns the
308 // previously-stored value.
309 let effective = *self.wait_admin.get_or_init(|| wait_admin);
310 // Notify *after* the OnceLock store so the runtime's
311 // `notified()` resolution synchronizes-with our store. Any
312 // reader that calls `wait_admin()` after observing `notified()`
313 // is guaranteed to see `Some(effective)`.
314 self.notify.notify_one();
315 effective
316 }
317
318 /// Read the current `wait_admin` value. `false` when no fire has
319 /// happened yet (runtime should pick the SIGTERM-equivalent
320 /// ordering). The runtime calls this only after `wait()` has
321 /// resolved, at which point `OnceLock::get()` returns `Some`.
322 #[must_use]
323 pub fn wait_admin(&self) -> bool {
324 self.wait_admin.get().copied().unwrap_or(false)
325 }
326
327 /// Wait until [`fire`](Self::fire) is called. Returns immediately
328 /// if `fire` was already called before `wait` was polled.
329 pub async fn wait(&self) {
330 self.notify.notified().await;
331 }
332}
333
334/// Pair of values needed by `admin_v1_reload`: the runtime's reload state
335/// (the same `Arc` the SIGHUP arm holds in `runtime::run`'s select loop)
336/// and the path to the config file the operator started with. Bundled so
337/// `AdminState` can carry "both or neither" as a single `Option`, matching
338/// the runtime's "no path → no reload" invariant.
339#[derive(Debug, Clone)]
340pub struct ReloadHook {
341 /// The runtime's reload state — the same `Arc` the SIGHUP arm holds.
342 pub reload_state: Arc<RuntimeReloadState>,
343 /// Path to the config file the operator started with, re-read on reload.
344 pub config_path: PathBuf,
345}
346
347impl AdminState {
348 // A builder would be tidier but is deferred until the next
349 // signature change forces a refactor — the existing call sites
350 // are few and each already lists every argument explicitly.
351 //
352 // The DHT introspection handles (issue #741) are attached via the
353 // separate [`with_dht`](Self::with_dht) builder rather than as another
354 // positional argument: `new` has several call sites (mostly unit
355 // tests of the cache methods that don't need a DHT), and `with_dht`
356 // lets the production runtime opt in without touching any of them.
357 /// Assemble the admin surface's view of the running node. `reload_hook`
358 /// is `None` when the daemon started with no config path, which is what
359 /// makes reload unavailable rather than merely inert.
360 pub const fn new(
361 node_id: [u8; 32],
362 started_at: Instant,
363 cache: CacheEngine,
364 reload_hook: Option<ReloadHook>,
365 drain_trigger: Arc<DrainTrigger>,
366 metrics: Arc<Metrics>,
367 ) -> Self {
368 Self {
369 node_id,
370 started_at,
371 cache,
372 reload_hook,
373 drain_trigger,
374 metrics,
375 dht: None,
376 lanes: None,
377 buyer_pools: None,
378 slash_detection: None,
379 binding: BindingReport::unknown(),
380 warming: None,
381 staker_set: None,
382 denylist: None,
383 operator_address: None,
384 }
385 }
386
387 /// Whether this node is in the on-chain active-staker set right now
388 /// (#1030), read from the same shared projection that feeds DHT admission
389 /// so the two can never disagree.
390 ///
391 /// Diagnostic only — nothing in the serve path consults it. See
392 /// [`decdn_common::admin::HealthResponse::registry_active`] for why the
393 /// daemon does not gate delivery on its own registration.
394 ///
395 /// `false` when no set is attached: an unwired `AdminState` cannot confirm
396 /// registration, and the optimistic answer would claim something nothing
397 /// checked.
398 fn registry_active(&self) -> bool {
399 self.staker_set.as_ref().is_some_and(|set| {
400 set.is_active(&crate::dht::routing::NodeId::from_bytes(self.node_id))
401 })
402 }
403
404 /// Attach the bring-up node-id binding check so `admin_v1_health` can
405 /// report whether this node's key is the one bound on-chain (#1034). The
406 /// production runtime calls this once after `new`; without it, `health`
407 /// reports `BindingStatus::Unknown`, which means "not checked" rather
408 /// than "fine".
409 #[must_use]
410 pub const fn with_binding(mut self, binding: BindingReport) -> Self {
411 self.binding = binding;
412 self
413 }
414
415 /// Attach the live active-staker set so `admin_v1_health` can report
416 /// whether this node is registered on-chain (#1030). The production runtime
417 /// calls this once after `new` with the SAME `Arc` DHT admission holds. Not
418 /// `const`: unlike [`Self::with_binding`]'s `Copy` report, assigning over an
419 /// `Option<Arc<_>>` runs a destructor.
420 #[must_use]
421 pub fn with_staker_set(
422 mut self,
423 staker_set: Arc<dyn crate::dht::staker_set::StakerSet>,
424 ) -> Self {
425 self.staker_set = Some(staker_set);
426 self
427 }
428
429 /// Attach DHT introspection handles so `admin_v1_status` can report
430 /// routing-table health (issue #741). The production runtime calls
431 /// this once after `new`; without it, `status` returns
432 /// [`DHT_UNAVAILABLE_CODE`].
433 #[must_use]
434 pub fn with_dht(mut self, dht: DhtStatusHandles) -> Self {
435 self.dht = Some(dht);
436 self
437 }
438
439 /// Attach lane introspection handles so `admin_v1_lanes` can report
440 /// lane state (issue #749). The production runtime calls this once
441 /// after `new`; without it, `lanes` returns an empty list with the
442 /// default-zero threshold.
443 #[must_use]
444 pub fn with_lanes(mut self, lanes: LaneStatusHandles) -> Self {
445 self.lanes = Some(lanes);
446 self
447 }
448
449 /// Attach the buyer pool store so `admin_v1_pools` can report this node's
450 /// buyer-side `PaymentPool` state (#2078). The production runtime calls
451 /// this once after `new` with the SAME handle `BuyerPoolService` writes
452 /// through, unconditionally; without it, `pools` returns
453 /// [`BUYER_POOL_UNAVAILABLE_CODE`].
454 #[must_use]
455 pub fn with_buyer_pools(mut self, buyer_pools: Arc<dyn BuyerPoolStore>) -> Self {
456 self.buyer_pools = Some(BuyerPoolHandle(buyer_pools));
457 self
458 }
459
460 /// Attach slash-detection handles so `admin_v1_slashes` can report slashes
461 /// against this node's operator (#1032). The production runtime calls this
462 /// once after `new` when a `slash_judge_address` is configured; without it,
463 /// `slashes` returns [`SLASH_DETECTION_UNAVAILABLE_CODE`].
464 #[must_use]
465 pub fn with_slash_detection(mut self, slash_detection: SlashStatusHandles) -> Self {
466 self.slash_detection = Some(slash_detection);
467 self
468 }
469
470 /// Attach the shared ADR 041 warming allowance so `admin_v1_evict` can
471 /// forget an evicted hash's `source_of` provenance tag (issue #1751
472 /// review). The production runtime calls this once after `new` with the
473 /// SAME `Arc` the buy loop, the eviction driver, and the warming-credit
474 /// aggregator share; without it, an admin evict leaves the tag in place.
475 #[must_use]
476 pub fn with_warming(
477 mut self,
478 warming: Arc<crate::warming_allowance::WarmingAllowance>,
479 ) -> Self {
480 self.warming = Some(warming);
481 self
482 }
483
484 /// Attach the live content denylist so `admin_v1_status` can report the
485 /// current on-chain origin deny-set size (`chain_denied_origins`). The
486 /// production runtime calls this once after `new` with the SAME `Arc` the
487 /// client handler and blacklist watcher hold; without it, `status`
488 /// reports `0`.
489 #[must_use]
490 pub fn with_denylist(mut self, denylist: Arc<crate::content_deny::ContentDenylist>) -> Self {
491 self.denylist = Some(denylist);
492 self
493 }
494
495 /// Attach the operator Ethereum address so `admin_v1_status` can report the
496 /// wallet this node runs under. The production runtime calls this once after
497 /// `new` with the loaded eth signer's address (the same value the bring-up
498 /// binding check derives); without it, `status` reports
499 /// `operator_address: None`.
500 #[must_use]
501 pub const fn with_operator_address(mut self, operator_address: Address) -> Self {
502 self.operator_address = Some(operator_address);
503 self
504 }
505}
506
507/// Convert a [`CacheError`] into a JSON-RPC error suitable for
508/// `admin_v1_evict`. The operator-facing message names the failure and its
509/// whole cause chain (`store error: <context>: <root cause>`) rather than a
510/// generic "cache failed".
511fn cache_error_to_rpc(err: &CacheError) -> ErrorObjectOwned {
512 ErrorObjectOwned::owned(
513 CACHE_ERROR_CODE,
514 err.display_chain().to_string(),
515 None::<()>,
516 )
517}
518
519/// Lock a DHT-subsystem `std::sync::Mutex` for `admin_v1_status`,
520/// converting a poisoned lock into a [`DHT_POISONED_CODE`] RPC error
521/// rather than propagating the panic the standard `unwrap` would (the
522/// workspace anti-panic policy forbids `unwrap`/`expect`). `what` names
523/// the guarded structure for the operator-facing message.
524///
525/// A poisoned lock means a writer panicked while holding it — a severe
526/// in-process fault, distinct from the benign "no DHT wired" state
527/// ([`DHT_UNAVAILABLE_CODE`]). It is logged at `error` level so the fault
528/// is diagnosable from the node's logs even when a scripted client
529/// discards the RPC error body.
530fn lock_or_rpc_err<'a, T>(
531 mutex: &'a StdMutex<T>,
532 what: &str,
533) -> Result<std::sync::MutexGuard<'a, T>, ErrorObjectOwned> {
534 mutex.lock().map_err(|_| {
535 tracing::error!(
536 guard = %what,
537 "DHT mutex poisoned — a writer panicked while holding it; \
538 admin_v1_status cannot read a consistent snapshot"
539 );
540 ErrorObjectOwned::owned(
541 DHT_POISONED_CODE,
542 format!("DHT {what} mutex poisoned"),
543 None::<()>,
544 )
545 })
546}
547
548/// Concrete server implementation backed by the live [`AdminState`].
549#[derive(Debug, Clone)]
550pub struct AdminRpcImpl {
551 state: AdminState,
552}
553
554impl AdminRpcImpl {
555 /// Serve the admin RPC methods against `state`.
556 pub const fn new(state: AdminState) -> Self {
557 Self { state }
558 }
559}
560
561#[async_trait]
562impl AdminRpcServer for AdminRpcImpl {
563 async fn health(&self) -> RpcResult<HealthResponse> {
564 Ok(HealthResponse {
565 node_id: alloy::primitives::hex::encode(self.state.node_id),
566 uptime_s: self.state.started_at.elapsed().as_secs(),
567 in_flight_streams: self.state.metrics.dispatch_in_flight_value(),
568 binding: self.state.binding.status,
569 // Same lowercase-hex encoding as `node_id`, so the two are
570 // directly comparable by eye and by script on a mismatch.
571 bound_node_id: self
572 .state
573 .binding
574 .bound_node_id
575 .map(alloy::primitives::hex::encode),
576 registry_active: self.state.registry_active(),
577 })
578 }
579
580 async fn evict(&self, req: EvictRequest) -> RpcResult<EvictResponse> {
581 // `parse_hash_arg` yields the config-vocabulary leaf hash (keeps
582 // iroh-blobs out of the publisher CLI, #578); the blob store
583 // keys on `decdn_cache::Hash`, so convert at this boundary.
584 let hash = decdn_cache::to_store_hash(parse_hash_arg(&req.hash)?);
585
586 // One `inspect` call snapshots size, pin, evicted, served — the
587 // pre-evict state the response will carry. Any cache I/O
588 // failure here is a genuine problem (the iroh-blobs store is
589 // misbehaving), not a routine "blob is absent" path. Note that
590 // `inspect` reads `BlobStatus` directly, so a complete blob reports
591 // its size even when `already_evicted` is already true — operators
592 // want to see disk-reclaim potential. A partial reports its
593 // validated total, or `None` before its last chunk arrives.
594 let preview = self
595 .state
596 .cache
597 .inspect(hash)
598 .await
599 .map_err(|err| cache_error_to_rpc(&err))?;
600
601 // For a real evict we mutate after the inspect. For a dry-run
602 // we skip the `evict()` call entirely — the response is the
603 // snapshot only.
604 if !req.dry_run {
605 // `evict` returns `Err` if it can't durably persist the
606 // eviction (e.g. evicted-log fsync failed). For DMCA
607 // takedowns the operator must learn about that failure
608 // rather than getting an "ok" response that silently
609 // degraded to in-memory-only.
610 self.state
611 .cache
612 .evict(hash)
613 .await
614 .map_err(|err| cache_error_to_rpc(&err))?;
615 // ADR 041: drop the warming tag for the evicted hash, so a later
616 // reuse of this slot can never credit a stale source's allowance.
617 if let Some(warming) = self.state.warming.as_ref() {
618 warming.forget(hash);
619 }
620 }
621
622 Ok(EvictResponse {
623 was_present: preview.served,
624 dry_run: req.dry_run,
625 preview: EvictPreview {
626 size_bytes: preview.size_bytes,
627 last_accessed_us_ago: preview.last_accessed_us_ago,
628 pinned: preview.pinned,
629 already_evicted: preview.already_evicted,
630 origin_kinds: preview.origin_kinds,
631 },
632 })
633 }
634
635 async fn reload(&self) -> RpcResult<ReloadResponse> {
636 let hook = self.state.reload_hook.as_ref().ok_or_else(|| {
637 ErrorObjectOwned::owned(
638 CONFIG_PATH_UNSET_CODE,
639 "no config file path is in use; restart the node with \
640 --config <path> to enable admin_v1_reload",
641 None::<()>,
642 )
643 })?;
644 // Delegating to `RuntimeReloadState::reload` keeps the SIGHUP
645 // and RPC paths byte-identical: same parse, same resolution,
646 // same "previous values retained on error" contract, and the
647 // same internal mutexes serialise concurrent reloads (a SIGHUP
648 // arriving mid-RPC waits, and vice versa). Surface the error
649 // text via `{err:#}` so the operator sees the underlying
650 // resolution failure rather than a generic wrapper.
651 hook.reload_state
652 .reload(&hook.config_path)
653 .await
654 .map_err(|err| {
655 ErrorObjectOwned::owned(
656 RELOAD_ERROR_CODE,
657 format!("config reload failed: {err:#}"),
658 None::<()>,
659 )
660 })?;
661 let snap = hook.reload_state.current();
662 Ok(ReloadResponse {
663 // `current()` is `None` when `RUST_LOG` drives the live filter
664 // (a reload never replaces it) or on a poisoned mutex; in both
665 // cases no config-file level is running. Returning a stable
666 // string ("unknown") rather than the empty string makes operator
667 // scripts that parse the response trivially unambiguous.
668 log_level: snap
669 .log_level
670 .map_or_else(|| "unknown".to_string(), |l| l.to_string()),
671 })
672 }
673
674 async fn drain(&self, req: DrainRequest) -> RpcResult<DrainResponse> {
675 // Atomic fire-with-wait_admin: returns the *effective* value
676 // the runtime will see. First writer wins, so a concurrent
677 // drain race cannot produce a response that lies about
678 // what the runtime will do (issue #604 review). The CLI reads
679 // this ack and refuses to poll when another caller's
680 // `wait_admin: false` won the race.
681 let honored = self.state.drain_trigger.fire(req.wait_admin);
682 Ok(DrainResponse {
683 initiated: true,
684 wait_admin_honored: honored,
685 })
686 }
687
688 async fn status(&self) -> RpcResult<StatusResponse> {
689 let Some(dht) = self.state.dht.as_ref() else {
690 return Err(ErrorObjectOwned::owned(
691 DHT_UNAVAILABLE_CODE,
692 "DHT subsystem not wired on this node",
693 None::<()>,
694 ));
695 };
696
697 // Snapshot the routing table under a brief lock: copy out only the
698 // per-bucket fill counts, then release before building DTOs. A
699 // poisoned mutex (a panicking writer elsewhere) surfaces as a
700 // specific RPC error rather than propagating the panic — the
701 // workspace anti-panic policy forbids `unwrap`/`expect` here.
702 let (total_peers, bucket_fills) = {
703 let table = lock_or_rpc_err(&dht.routing, "routing table")?;
704 (table.len(), table.non_empty_bucket_fills())
705 };
706 let buckets: Vec<BucketStat> = bucket_fills
707 .into_iter()
708 .map(|(index, fill)| BucketStat {
709 // Bucket indices are 0..=255 and fills 0..=K_BUCKET_SIZE
710 // (20), so both fit u16 with room to spare. `try_from`
711 // (not `as`) avoids the `cast_possible_truncation` lint on
712 // the narrowing `usize → u16`, saturating rather than
713 // panicking on the unreachable overflow.
714 index: u16::try_from(index).unwrap_or(u16::MAX),
715 fill: u16::try_from(fill).unwrap_or(u16::MAX),
716 })
717 .collect();
718 // Capacity is the same Kademlia K for every bucket, so report it
719 // once on RoutingHealth rather than per BucketStat.
720 let bucket_capacity = u16::try_from(crate::dht::routing::K_BUCKET_SIZE).unwrap_or(u16::MAX);
721
722 let (records, records_capacity) = {
723 let store = lock_or_rpc_err(&dht.record_store, "record store")?;
724 (store.len(), store.capacity())
725 };
726
727 // 0 means "no refresh pass has completed yet" → None on the wire.
728 let last_refresh = match dht.refresh_clock.load(Ordering::Relaxed) {
729 0 => None,
730 us => Some(us),
731 };
732
733 // These `usize → u64` conversions are widening, so no
734 // `cast_possible_truncation` lint fires and no policy requires
735 // `try_from` here — it's used purely for stylistic uniformity with
736 // the narrowing bucket conversions above. The `unwrap_or(u64::MAX)`
737 // arms are unreachable on every supported (≤64-bit) target.
738 // Poison reporting to the operator is *partial* by design. The
739 // routing-table and record-store reads above go through
740 // `lock_or_rpc_err`, so a poisoned lock there surfaces as
741 // `DHT_POISONED_CODE`. The `staker_set` / `republish` counts below
742 // can't: their `len()` signatures own their own poison policy and
743 // return a plain `usize`. `RepublishScheduler::len` degrades a
744 // poisoned lock to `0` (so `scheduled_records` under-reports rather
745 // than failing the call); `ChainStakerSet::len` recovers the inner
746 // set and warns (true count, no error). We can't change those
747 // signatures from here, so these two fields trade poison-visibility
748 // for not aborting the whole snapshot — accepted, documented.
749 Ok(StatusResponse {
750 node_id: alloy::primitives::hex::encode(self.state.node_id),
751 routing: RoutingHealth {
752 total_peers: u64::try_from(total_peers).unwrap_or(u64::MAX),
753 non_empty_buckets: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
754 buckets,
755 bucket_capacity,
756 refresh_interval_s: dht.refresh_interval.as_secs(),
757 last_refresh_us: last_refresh,
758 },
759 known_stakers: u64::try_from(dht.staker_set.len()).unwrap_or(u64::MAX),
760 record_store: RecordStoreHealth {
761 records: u64::try_from(records).unwrap_or(u64::MAX),
762 capacity: u64::try_from(records_capacity).unwrap_or(u64::MAX),
763 },
764 republish: RepublishHealth {
765 scheduled_records: u64::try_from(dht.republish.len()).unwrap_or(u64::MAX),
766 },
767 chain_denied_origins: self.state.denylist.as_ref().map_or(0, |d| {
768 u64::try_from(d.chain_origin_count()).unwrap_or(u64::MAX)
769 }),
770 // `Address`'s `Display` is the EIP-55 mixed-case checksum, the same
771 // rendering the lane snapshots use. Sampled once at bring-up (no
772 // chain read here); `None` when no operator was wired in.
773 operator_address: self.state.operator_address.map(|a| a.to_string()),
774 })
775 }
776
777 async fn lanes(&self) -> RpcResult<LanesResponse> {
778 // No lane subsystem wired (unit tests / a node with no payment
779 // surface): report an empty list rather than an error. Zero
780 // lanes is a legitimate state, and the CLI renders the
781 // empty-table sentinel for it.
782 let Some(ch) = self.state.lanes.as_ref() else {
783 return Ok(LanesResponse {
784 lanes: Vec::new(),
785 redeem_threshold_micro_usdc: 0,
786 });
787 };
788
789 // `load_all` on the redb-backed store is blocking I/O (the store
790 // trait is sync per ADR appendix-poc-production-seams §1). Run it
791 // on the blocking pool so we don't stall a runtime worker, then
792 // build the wire DTOs off the loaded `Vec` — the cheap part. A
793 // store failure surfaces as a specific RPC error so the operator
794 // sees "lane snapshot unavailable" rather than a transport fault.
795 let store = Arc::clone(&ch.pool_store);
796 let states = tokio::task::spawn_blocking(move || store.load_all())
797 .await
798 .map_err(|join_err| {
799 // `JoinError` is returned on panic OR cancellation (e.g.
800 // runtime shutdown); name the actual cause so a cancelled
801 // task at teardown isn't misread as a panic.
802 let cause = if join_err.is_cancelled() {
803 "cancelled"
804 } else {
805 "panicked"
806 };
807 tracing::error!(
808 error = %join_err,
809 cause,
810 "pool-store load task did not complete"
811 );
812 ErrorObjectOwned::owned(
813 POOL_STORE_ERROR_CODE,
814 "pool state store load task failed",
815 None::<()>,
816 )
817 })?
818 .map_err(|store_err| {
819 tracing::error!(error = %store_err, "admin_v1_lanes could not load pool store");
820 ErrorObjectOwned::owned(
821 POOL_STORE_ERROR_CODE,
822 format!("pool state store load failed: {store_err}"),
823 None::<()>,
824 )
825 })?;
826
827 let threshold = U256::from(ch.redeem_threshold_micro_usdc);
828 // Read each live lane's last-voucher age off the handler's registry
829 // (issue #1733); lanes with no voucher since restart are absent from the
830 // map and read back as `None`.
831 let ages = ch.lane_activity.ages().await;
832 let lanes = build_lane_snapshots(&states, threshold, &ages);
833 Ok(LanesResponse {
834 lanes,
835 redeem_threshold_micro_usdc: ch.redeem_threshold_micro_usdc,
836 })
837 }
838
839 async fn slashes(&self) -> RpcResult<SlashesResponse> {
840 let Some(handles) = self.state.slash_detection.as_ref() else {
841 return Err(ErrorObjectOwned::owned(
842 SLASH_DETECTION_UNAVAILABLE_CODE,
843 "slash detection is not wired on this node",
844 None::<()>,
845 ));
846 };
847 // Short read of an in-memory Vec — no await held. Poison-tolerant: a
848 // panicked writer must not wedge the read-only admin surface.
849 let guard = handles
850 .store
851 .read()
852 .unwrap_or_else(std::sync::PoisonError::into_inner);
853 // Newest-first: the watcher appends in detection order.
854 let slashes = guard
855 .iter()
856 .rev()
857 .map(|s| SlashRecordDto {
858 slash_id: s.slash_id.to_string(),
859 offense_type: s.offense_type,
860 amount: s.amount.to_string(),
861 evidence_hash: format!("{:#x}", s.evidence_hash),
862 block_number: s.block_number,
863 appeal_window_close: s.appeal_window_close,
864 })
865 .collect();
866 Ok(SlashesResponse { slashes })
867 }
868
869 async fn pools(&self) -> RpcResult<BuyerPoolsResponse> {
870 // Not wired is an ERROR here, unlike `lanes`. A node with no buyer leg
871 // never pays for an upstream pull, and rendering that as "zero pools"
872 // is what sends an operator hunting a stranded deposit in the wrong
873 // place (#2078).
874 let Some(store) = self.state.buyer_pools.as_ref() else {
875 return Err(ErrorObjectOwned::owned(
876 BUYER_POOL_UNAVAILABLE_CODE,
877 "no buyer payment-pool store is attached to this admin surface, so this node \
878 tracks no pools and pays no provider",
879 None::<()>,
880 ));
881 };
882
883 // `load_all` on the redb-backed store is blocking I/O (the store trait
884 // is sync per ADR appendix-poc-production-seams §1). Run it on the
885 // blocking pool so we don't stall a runtime worker.
886 let store = Arc::clone(&store.0);
887 let load = tokio::task::spawn_blocking(move || store.load_all())
888 .await
889 .map_err(|join_err| {
890 // `JoinError` is returned on panic OR cancellation (e.g.
891 // runtime shutdown); name the actual cause so a cancelled task
892 // at teardown isn't misread as a panic.
893 let cause = if join_err.is_cancelled() {
894 "cancelled"
895 } else {
896 "panicked"
897 };
898 tracing::error!(
899 error = %join_err,
900 cause,
901 "buyer-pool-store load task did not complete"
902 );
903 ErrorObjectOwned::owned(
904 BUYER_POOL_STORE_ERROR_CODE,
905 "buyer pool store load task failed",
906 None::<()>,
907 )
908 })?
909 .map_err(|store_err| {
910 tracing::error!(
911 error = %store_err,
912 "admin_v1_pools could not load buyer pool store"
913 );
914 ErrorObjectOwned::owned(
915 BUYER_POOL_STORE_ERROR_CODE,
916 format!("buyer pool store load failed: {store_err}"),
917 None::<()>,
918 )
919 })?;
920
921 Ok(build_buyer_pools_response(load.pools, &load.skipped))
922 }
923}
924
925/// Build the wire [`BuyerPoolsResponse`] from loaded buyer pool states and the
926/// `pool_id`s whose rows would not decode. Pure (no I/O) so the shape and the
927/// ordering are unit-testable without a store or an async runtime.
928///
929/// Both lists are sorted — pools by `pool_id`, lanes by `(signer, provider)` —
930/// because the store's iteration order is a redb implementation detail and an
931/// operator diffing two calls should see a change only when the state changed.
932/// `U256` amounts narrow to `u64` micro-USDC via `try_from(..).unwrap_or(u64::MAX)`;
933/// a real pool is bounded by its on-chain deposit, so the saturation arm is
934/// unreachable.
935fn build_buyer_pools_response(
936 mut pools: Vec<BuyerPoolState>,
937 skipped: &[decdn_incentive::lane::PoolId],
938) -> BuyerPoolsResponse {
939 pools.sort_by_key(|p| p.pool_id);
940 let pools = pools
941 .iter()
942 .map(|p| {
943 let mut lanes: Vec<_> = p.lanes().collect();
944 lanes.sort_by_key(|(lane, _)| (lane.signer, lane.provider));
945 BuyerPoolSnapshot {
946 pool_id: format!("{:#x}", p.pool_id),
947 chain_id: p.deployment.chain_id,
948 payment_pool: p.deployment.payment_pool.to_string(),
949 owner: p.owner.to_string(),
950 token: p.token.to_string(),
951 deposit_micro_usdc: u64::try_from(p.deposit).unwrap_or(u64::MAX),
952 lanes: lanes
953 .into_iter()
954 .map(|(lane, progress)| BuyerLaneSnapshot {
955 voucher_signer: lane.signer.to_string(),
956 provider: lane.provider.to_string(),
957 last_amount_micro_usdc: u64::try_from(progress.last_amount)
958 .unwrap_or(u64::MAX),
959 last_bytes_delivered: u64::try_from(progress.last_bytes)
960 .unwrap_or(u64::MAX),
961 })
962 .collect(),
963 }
964 })
965 .collect();
966 let mut skipped: Vec<_> = skipped.iter().map(|p| format!("{p:#x}")).collect();
967 skipped.sort();
968 BuyerPoolsResponse { pools, skipped }
969}
970
971/// Build the wire `LaneSnapshot` list from loaded lane states, the
972/// redemption `threshold` (in micro-USDC as a `U256`), and each live lane's
973/// whole-seconds last-voucher age (`ages`, keyed by [`LaneKey`]; absent ⇒
974/// `None`). Pure (no I/O, no locks) so it's unit-testable without a store, an
975/// async runtime, or a live lane registry.
976///
977/// Ordering: lanes with a known last-voucher age first (most recently
978/// active ahead of those with `None`), then by descending outstanding
979/// amount — the on-call use case is "which lanes are closest to a
980/// settlement / liquidity event?". `U256` amounts narrow to `u64`
981/// micro-USDC via `try_from(...).unwrap_or(u64::MAX)`; a real pool is
982/// bounded by its on-chain deposit so the saturation arm is unreachable.
983fn build_lane_snapshots(
984 states: &[LaneState],
985 threshold: U256,
986 ages: &HashMap<LaneKey, u64>,
987) -> Vec<LaneSnapshot> {
988 let mut snapshots: Vec<LaneSnapshot> = states
989 .iter()
990 .map(|state| {
991 let outstanding = state.last_amount();
992 LaneSnapshot {
993 // A lane is keyed by `(pool_id, signer, provider)`. The admin
994 // surface reports the pool id as the lane id; the signer as
995 // both counterparty and voucher signer (the shared-pool model
996 // has no separate delegate). There is no per-voucher nonce —
997 // cumulative amount is the sole ordering key — so `last_nonce`
998 // reports 0, and the pool-level deposit is not carried per lane
999 // so `deposit_micro_usdc` reports 0.
1000 pool_id: state.pool_id.to_string(),
1001 counterparty: state.signer.to_string(),
1002 voucher_signer: state.signer.to_string(),
1003 last_nonce: 0,
1004 outstanding_micro_usdc: u64::try_from(outstanding).unwrap_or(u64::MAX),
1005 deposit_micro_usdc: 0,
1006 seconds_since_last_voucher: ages.get(&state.key()).copied(),
1007 // Upper-bound eligibility: the admin surface doesn't read
1008 // the on-chain `withdrawnAmount`, so it compares the full
1009 // accrued claim against the threshold (documented on the
1010 // DTO field). `>=` matches the redeemer's `< floor`
1011 // short-circuit (`redeem_planned_lanes` in
1012 // payment_settlement.rs), whose floor is this threshold.
1013 settlement_eligible: outstanding >= threshold,
1014 }
1015 })
1016 .collect();
1017 // Most recently active first: `Some(age)` sorts ahead of `None`, and
1018 // within each group smaller age (more recent) first; ties broken by
1019 // descending outstanding. `Reverse` on the outstanding term puts the
1020 // largest claim first. The key tuple is a total order so `sort_unstable`
1021 // (no allocation) is safe — equal keys mean identical sort-relevant
1022 // fields, with no insertion-order tiebreak promised.
1023 snapshots.sort_unstable_by_key(|s| {
1024 (
1025 s.seconds_since_last_voucher.is_none(),
1026 s.seconds_since_last_voucher.unwrap_or(0),
1027 std::cmp::Reverse(s.outstanding_micro_usdc),
1028 )
1029 });
1030 snapshots
1031}
1032
1033/// Bind the admin listener. Kept synchronous-at-startup so port
1034/// conflicts fail fast rather than deep inside the runtime task graph.
1035///
1036/// # Errors
1037/// Returns an error if the underlying `crate::net::bind_reuseaddr` fails
1038/// (socket creation, `bind`, or `listen`).
1039pub fn bind(addr: SocketAddr) -> anyhow::Result<TcpListener> {
1040 // `SO_REUSEADDR` so a restart rebinds this fixed port immediately instead of
1041 // racing a `TIME_WAIT` remnant from the prior process (see `crate::net`).
1042 let listener = crate::net::bind_reuseaddr(addr)
1043 .map_err(|e| anyhow::anyhow!("admin bind {addr} failed: {e}"))?;
1044 // Defense-in-depth for the unauthenticated admin RPC surface (#845),
1045 // mirroring `metrics::bind` (#579). `to_canonical()` unwraps IPv4-mapped
1046 // IPv6 (e.g. `::ffff:127.0.0.1`) so the dual-stack loopback form doesn't
1047 // trip a false warning; `Ipv6Addr::is_loopback()` only matches `::1`.
1048 let ip = addr.ip().to_canonical();
1049 if !ip.is_loopback() {
1050 if ip.is_unspecified() {
1051 tracing::warn!(
1052 %addr,
1053 "admin server is binding all interfaces (non-loopback); the admin RPC is \
1054 unauthenticated and can drain lanes, trigger announces, and read peer \
1055 state — gate it behind loopback or a private network"
1056 );
1057 } else {
1058 tracing::warn!(
1059 %addr,
1060 "admin server is binding a non-loopback address; the admin RPC is \
1061 unauthenticated and can drain lanes, trigger announces, and read peer \
1062 state — restrict reachability to trusted operators"
1063 );
1064 }
1065 }
1066 tracing::info!(%addr, "admin server listening");
1067 Ok(listener)
1068}
1069
1070/// Serve admin RPC methods on `listener` until `shutdown` fires.
1071///
1072/// Concurrency is capped via `ServerConfig::max_connections`; shutdown
1073/// is signalled by calling `ServerHandle::stop()`, then we await
1074/// `stopped()` so a caller that drops the future cannot leave the
1075/// background accept loop running.
1076#[allow(clippy::cognitive_complexity)] // Config build + select on two shutdown paths reads linearly.
1077pub async fn serve(
1078 listener: TcpListener,
1079 state: AdminState,
1080 mut shutdown: oneshot::Receiver<()>,
1081) -> anyhow::Result<()> {
1082 // jsonrpsee's `build_from_tcp` expects a `std::net::TcpListener` in
1083 // blocking mode. `into_std` is the official handoff; it must be
1084 // called before any incoming connections have been accepted on the
1085 // tokio side, which is the case here (we've only just bound).
1086 let std_listener = listener
1087 .into_std()
1088 .map_err(|e| anyhow::anyhow!("convert admin listener to std: {e}"))?;
1089
1090 let config = ServerConfig::builder()
1091 .max_connections(MAX_ADMIN_CONNECTIONS)
1092 .http_only()
1093 .build();
1094
1095 let server = Server::builder()
1096 .set_config(config)
1097 .build_from_tcp(std_listener)
1098 .map_err(|e| anyhow::anyhow!("build admin RPC server: {e}"))?;
1099
1100 let rpc = AdminRpcImpl::new(state);
1101 let handle = server.start(rpc.into_rpc());
1102
1103 // Two shutdown paths:
1104 // 1. Runtime fires the oneshot -> we call `handle.stop()` and
1105 // wait for the accept loop to drain.
1106 // 2. Server exits on its own (shouldn't happen for HTTP-only but
1107 // guard against it) -> return Ok so the runtime can notice
1108 // via the `admin_stop_tx` / `warn!` path.
1109 let stopped = handle.clone().stopped();
1110 tokio::pin!(stopped);
1111 tokio::select! {
1112 biased;
1113 _ = &mut shutdown => {
1114 tracing::debug!("admin server shutdown signal received");
1115 if handle.stop().is_err() {
1116 tracing::debug!("admin server already stopped before shutdown signal");
1117 }
1118 handle.stopped().await;
1119 }
1120 () = &mut stopped => {
1121 tracing::warn!("admin server self-stopped before shutdown signal");
1122 }
1123 }
1124 Ok(())
1125}
1126
1127#[cfg(test)]
1128#[allow(
1129 clippy::unwrap_used,
1130 clippy::expect_used,
1131 clippy::indexing_slicing,
1132 clippy::panic
1133)]
1134mod tests;