Skip to main content

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;