From 4f5947255b4525968a8e14ebbe7e3b2e45b51b7f Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 3 Jun 2026 15:44:07 +0000 Subject: [PATCH 1/3] daemon-cli: scaffold doublezero-daemon-cli crate with DaemonClient trait and types Extract daemon client trait, response types, and Unix socket HTTP implementation from client/doublezero/src/servicecontroller.rs into a new library-only crate crates/doublezero-daemon-cli/. This is PR 1 of the RFC-20 daemon verb extraction (#1458): - DaemonClient trait with #[automock] for unit testing - LedgerClient trait (narrow SDK interface for daemon verbs) - All response/request types (LatencyRecord, StatusResponse, etc.) - DaemonClientImpl (concrete Unix socket HTTP implementation) - Empty DaemonCommand enum (populated as verbs migrate) - Serde round-trip tests for response types The existing servicecontroller.rs is left unchanged during the transition. Verbs will switch to importing from the new crate as they migrate in subsequent PRs. --- Cargo.toml | 2 + client/doublezero/Cargo.toml | 1 + crates/doublezero-daemon-cli/Cargo.toml | 34 ++ crates/doublezero-daemon-cli/src/cli.rs | 29 ++ crates/doublezero-daemon-cli/src/client.rs | 492 +++++++++++++++++++++ crates/doublezero-daemon-cli/src/ledger.rs | 21 + crates/doublezero-daemon-cli/src/lib.rs | 12 + 7 files changed, 591 insertions(+) create mode 100644 crates/doublezero-daemon-cli/Cargo.toml create mode 100644 crates/doublezero-daemon-cli/src/cli.rs create mode 100644 crates/doublezero-daemon-cli/src/client.rs create mode 100644 crates/doublezero-daemon-cli/src/ledger.rs create mode 100644 crates/doublezero-daemon-cli/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 6bcae0eca..0dd9fffc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "smartcontract/programs/common", "e2e/docker/ledger/fork-accounts", "crates/doublezero-cli-core", + "crates/doublezero-daemon-cli", "crates/doublezero-geolocation-cli", "crates/sentinel", ] @@ -106,6 +107,7 @@ tokio = { version = "1", default-features = false, features = [ ] } doublezero-cli-core = { path = "crates/doublezero-cli-core" } doublezero-config = { path = "config" } +doublezero-daemon-cli = { path = "crates/doublezero-daemon-cli" } doublezero-geolocation-cli = { path = "crates/doublezero-geolocation-cli" } doublezero-sentinel = { path = "crates/sentinel" } doublezero-serviceability-cli = { path = "smartcontract/cli" } diff --git a/client/doublezero/Cargo.toml b/client/doublezero/Cargo.toml index 6f3a95ed5..4fc025827 100644 --- a/client/doublezero/Cargo.toml +++ b/client/doublezero/Cargo.toml @@ -41,6 +41,7 @@ tokio.workspace = true # Dependencies from this workspace doublezero_sdk = { workspace = true, features = ["cli-context"] } +doublezero-daemon-cli.workspace = true doublezero-geolocation-cli.workspace = true doublezero-serviceability-cli.workspace = true doublezero-cli-core.workspace = true diff --git a/crates/doublezero-daemon-cli/Cargo.toml b/crates/doublezero-daemon-cli/Cargo.toml new file mode 100644 index 000000000..414380fe5 --- /dev/null +++ b/crates/doublezero-daemon-cli/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "doublezero-daemon-cli" + +# Workspace inherited keys +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "doublezero_daemon_cli" + +[dependencies] +chrono.workspace = true +clap.workspace = true +eyre.workspace = true +http.workspace = true +http-body-util.workspace = true +hyper.workspace = true +hyper-util.workspace = true +hyperlocal.workspace = true +mockall.workspace = true +serde.workspace = true +serde_json.workspace = true +tabled.workspace = true + +doublezero-cli-core.workspace = true +doublezero-config.workspace = true + +[dev-dependencies] +doublezero-cli-core = { workspace = true, features = ["testing"] } +tokio.workspace = true diff --git a/crates/doublezero-daemon-cli/src/cli.rs b/crates/doublezero-daemon-cli/src/cli.rs new file mode 100644 index 000000000..4b40f8b8a --- /dev/null +++ b/crates/doublezero-daemon-cli/src/cli.rs @@ -0,0 +1,29 @@ +//! Top-level daemon-control subcommand tree per RFC-20. +//! +//! Mounted flat (`#[command(flatten)]`) — the binary's `Command` enum hoists +//! all variants so verbs surface as `doublezero ` (e.g. `doublezero +//! connect`, `doublezero status`). + +use clap::Subcommand; +use doublezero_cli_core::CliContext; +use std::io::Write; + +use crate::{client::DaemonClient, ledger::LedgerClient}; + +/// Daemon-control verbs hoisted to the binary's top level. +/// +/// Populated incrementally as verbs migrate from the binary into this crate. +#[derive(Subcommand, Debug)] +pub enum DaemonCommand {} + +impl DaemonCommand { + pub async fn execute( + self, + _ctx: &CliContext, + _daemon: &D, + _ledger: &L, + _out: &mut W, + ) -> eyre::Result<()> { + match self {} + } +} diff --git a/crates/doublezero-daemon-cli/src/client.rs b/crates/doublezero-daemon-cli/src/client.rs new file mode 100644 index 000000000..ed1a1a96e --- /dev/null +++ b/crates/doublezero-daemon-cli/src/client.rs @@ -0,0 +1,492 @@ +//! Mockable daemon-client trait wrapping HTTP-over-Unix-socket calls to +//! `doublezerod`, plus all request/response types shared across daemon verbs. + +use chrono::DateTime; +use doublezero_config::Environment; +use eyre::eyre; +use http_body_util::{BodyExt, Empty, Full}; +use hyper::{body::Bytes, Method, Request}; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use hyperlocal::{UnixConnector, Uri}; +use mockall::automock; +use serde::{Deserialize, Serialize}; +use std::{fmt, fs::File, path::Path, sync::OnceLock}; +use tabled::{derive::display, Tabled}; + +pub const DEFAULT_SOCKET_PATH: &str = "/var/run/doublezerod/doublezerod.sock"; +const NANOS_TO_MS: f64 = 1000000.0; +static GLOBAL_SOCKET_PATH: OnceLock = OnceLock::new(); + +// --------------------------------------------------------------------------- +// Response / request types +// --------------------------------------------------------------------------- + +#[derive(Clone, Tabled, Deserialize, Serialize, Debug)] +pub struct LatencyRecord { + #[tabled(rename = "Pubkey")] + pub device_pk: String, + #[tabled(rename = "Code")] + pub device_code: String, + #[tabled(rename = "IP")] + pub device_ip: String, + #[tabled(display = "display_as_ms", rename = "Min")] + pub min_latency_ns: i64, + #[tabled(display = "display_as_ms", rename = "Max")] + pub max_latency_ns: i64, + #[tabled(display = "display_as_ms", rename = "Avg")] + pub avg_latency_ns: i64, + pub reachable: bool, +} + +fn display_as_ms(latency: &i64) -> String { + format!("{:.2}ms", (*latency as f64 / NANOS_TO_MS)) +} + +impl fmt::Display for LatencyRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "device: {}, code: {}, ip: {}, latency min: {}, max: {}, avg: {}, reachable: {}", + self.device_pk, + self.device_code, + self.device_ip, + self.min_latency_ns, + self.max_latency_ns, + self.avg_latency_ns, + self.reachable + ) + } +} + +#[derive(Deserialize, Debug)] +pub struct LatencyResponse { + pub ready: bool, + pub results: Vec, +} + +#[derive(Tabled, Serialize, Deserialize, Debug, Clone)] +#[tabled(display(Option, "display::option", ""))] +pub struct StatusResponse { + #[tabled(inline)] + pub doublezero_status: DoubleZeroStatus, + #[tabled(rename = "Tunnel Name")] + pub tunnel_name: Option, + #[tabled(rename = "Tunnel Src")] + pub tunnel_src: Option, + #[tabled(rename = "Tunnel Dst")] + pub tunnel_dst: Option, + #[tabled(rename = "Doublezero IP")] + pub doublezero_ip: Option, + #[tabled(rename = "User Type")] + pub user_type: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetConfigResponse { + pub program_id: String, + pub rpc_url: String, +} + +#[derive(Tabled, Serialize, Deserialize, Debug, Clone)] +pub struct DoubleZeroStatus { + #[tabled(rename = "Tunnel Status")] + pub session_status: String, + #[tabled(rename = "Last Session Update", display = "maybe_i64_to_dt_str")] + pub last_session_update: Option, +} + +fn maybe_i64_to_dt_str(maybe_i64_dt: &Option) -> String { + maybe_i64_dt.as_ref().map_or_else( + || "no session data".to_string(), + |dt_i64| { + DateTime::from_timestamp(*dt_i64, 0) + .map(|dt| dt.to_string()) + .unwrap_or_else(|| "invalid timestamp".to_string()) + }, + ) +} + +#[derive(Clone, Tabled, Deserialize, Serialize, Debug, PartialEq)] +#[tabled(display(Option, "display::option", ""))] +pub struct RouteRecord { + #[tabled(rename = "Network")] + pub network: String, + #[tabled(rename = "Local IP")] + pub local_ip: String, + #[tabled(rename = "Peer IP")] + pub peer_ip: String, + #[tabled(rename = "Kernel State")] + pub kernel_state: String, + #[tabled(rename = "Liveness Last Updated")] + pub liveness_last_updated: Option, + #[tabled(rename = "Liveness State")] + pub liveness_state: Option, + #[tabled(rename = "Liveness State Reason")] + pub liveness_state_reason: Option, + #[tabled(rename = "Peer Client Version")] + pub peer_client_version: Option, +} + +impl fmt::Display for RouteRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "local_ip: {}, peer_ip: {}", self.local_ip, self.peer_ip) + } +} + +#[derive(Deserialize, Debug)] +pub struct ErrorResponse { + pub status: String, + pub description: String, +} + +/// Parse a daemon response, falling back to ErrorResponse if the primary type fails. +fn parse_daemon_response( + data: &[u8], + endpoint: &str, +) -> eyre::Result { + match serde_json::from_slice::(data) { + Ok(response) => Ok(response), + Err(parse_err) => match serde_json::from_slice::(data) { + Ok(err_resp) if err_resp.status == "error" => Err(eyre!(err_resp.description)), + _ => Err(eyre!( + "Failed to parse daemon {endpoint} response: {parse_err}" + )), + }, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct MulticastGroups { + #[serde(default)] + pub publisher: Vec, + #[serde(default)] + pub subscriber: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct V2ServiceStatus { + #[serde(flatten)] + pub status: StatusResponse, + #[serde(default)] + pub current_device: String, + #[serde(default)] + pub lowest_latency_device: String, + #[serde(default)] + pub metro: String, + #[serde(default)] + pub tenant: String, + #[serde(default)] + pub multicast_groups: MulticastGroups, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct V2StatusResponse { + pub reconciler_enabled: bool, + #[serde(default)] + pub client_ip: String, + #[serde(default)] + pub network: String, + pub services: Vec, +} + +// --------------------------------------------------------------------------- +// DaemonClient trait (renamed from ServiceController) +// --------------------------------------------------------------------------- + +#[allow(async_fn_in_trait)] +#[automock] +pub trait DaemonClient: Send + Sync { + fn daemon_check(&self) -> bool; + fn daemon_can_open(&self) -> bool; + async fn get_config(&self) -> eyre::Result; + async fn get_env(&self) -> eyre::Result; + async fn latency(&self) -> eyre::Result; + async fn status(&self) -> eyre::Result>; + async fn v2_status(&self) -> eyre::Result; + async fn enable(&self) -> eyre::Result<()>; + async fn disable(&self) -> eyre::Result<()>; + async fn routes(&self) -> eyre::Result>; +} + +// --------------------------------------------------------------------------- +// Concrete implementation (Unix-socket HTTP) +// --------------------------------------------------------------------------- + +pub struct DaemonClientImpl { + pub socket_path: String, +} + +impl DaemonClientImpl { + pub fn set_global_socket_path(socket_path: impl Into) { + let _ = GLOBAL_SOCKET_PATH.set(socket_path.into()); + } + + pub fn new(socket_path: Option) -> DaemonClientImpl { + DaemonClientImpl { + socket_path: socket_path.unwrap_or_else(|| { + GLOBAL_SOCKET_PATH + .get() + .cloned() + .unwrap_or_else(|| DEFAULT_SOCKET_PATH.to_string()) + }), + } + } +} + +impl DaemonClient for DaemonClientImpl { + fn daemon_check(&self) -> bool { + Path::new(&self.socket_path).exists() + } + + fn daemon_can_open(&self) -> bool { + let file = File::options() + .read(true) + .write(true) + .open(&self.socket_path); + match file { + Ok(_) => true, + Err(e) => !matches!(e.kind(), std::io::ErrorKind::PermissionDenied), + } + } + + async fn get_config(&self) -> eyre::Result { + let uri = Uri::new(&self.socket_path, "/config").into(); + let client: Client> = + Client::builder(TokioExecutor::new()).build(UnixConnector); + let res = client + .get(uri) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + let data = res + .into_body() + .collect() + .await + .map_err(|e| eyre!("Unable to read response body: {e}"))? + .to_bytes(); + parse_daemon_response::(&data, "/config") + } + + async fn get_env(&self) -> eyre::Result { + let config = self.get_config().await?; + Ok(Environment::from_program_id(&config.program_id).unwrap_or_default()) + } + + async fn latency(&self) -> eyre::Result { + let uri = Uri::new(&self.socket_path, "/v2/latency").into(); + let client: Client> = + Client::builder(TokioExecutor::new()).build(UnixConnector); + let res = client + .get(uri) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + let data = res + .into_body() + .collect() + .await + .map_err(|e| eyre!("Unable to read response body: {e}"))? + .to_bytes(); + parse_daemon_response::(&data, "/v2/latency") + } + + async fn status(&self) -> eyre::Result> { + let client = Client::builder(TokioExecutor::new()).build(UnixConnector); + let req = Request::builder() + .method(Method::GET) + .uri(Uri::new(&self.socket_path, "/status")) + .body(Empty::::new())?; + match client.request(req).await { + Ok(res) => { + if res.status() != 200 { + eyre::bail!("Unable to connect to doublezero daemon: {}", res.status()); + } + let data = res + .into_body() + .collect() + .await + .map_err(|e| eyre!("Unable to read response body: {e}"))? + .to_bytes(); + if data.is_empty() { + eyre::bail!("No data returned from daemon /status"); + } + parse_daemon_response::>(&data, "/status") + } + Err(e) => Err(eyre!("Unable to connect to doublezero daemon: {e}")), + } + } + + async fn v2_status(&self) -> eyre::Result { + let client = Client::builder(TokioExecutor::new()).build(UnixConnector); + let req = Request::builder() + .method(Method::GET) + .uri(Uri::new(&self.socket_path, "/v2/status")) + .body(Empty::::new())?; + let res = client + .request(req) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + let data = res.into_body().collect().await?.to_bytes(); + let response = serde_json::from_slice::(&data) + .map_err(|e| eyre!("Unable to parse V2StatusResponse: {e}"))?; + Ok(response) + } + + async fn enable(&self) -> eyre::Result<()> { + let client: Client> = + Client::builder(TokioExecutor::new()).build(UnixConnector); + let req = Request::builder() + .method(Method::POST) + .uri(Uri::new(&self.socket_path, "/enable")) + .body(Full::from(Bytes::new()))?; + let res = client + .request(req) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + if res.status() != 200 { + eyre::bail!("Failed to enable reconciler: {}", res.status()); + } + Ok(()) + } + + async fn disable(&self) -> eyre::Result<()> { + let client: Client> = + Client::builder(TokioExecutor::new()).build(UnixConnector); + let req = Request::builder() + .method(Method::POST) + .uri(Uri::new(&self.socket_path, "/disable")) + .body(Full::from(Bytes::new()))?; + let res = client + .request(req) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + if res.status() != 200 { + eyre::bail!("Failed to disable reconciler: {}", res.status()); + } + Ok(()) + } + + async fn routes(&self) -> eyre::Result> { + let client = Client::builder(TokioExecutor::new()).build(UnixConnector); + let req = Request::builder() + .method(Method::GET) + .uri(Uri::new(&self.socket_path, "/routes")) + .body(Empty::::new())?; + let res = client.request(req).await?; + let data = res.into_body().collect().await?.to_bytes(); + let response = serde_json::from_slice::>(&data)?; + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_latency_record_serde_roundtrip() { + let record = LatencyRecord { + device_pk: "DevicePubkey123".to_string(), + device_code: "device1".to_string(), + device_ip: "5.6.7.8".to_string(), + min_latency_ns: 1000000, + max_latency_ns: 5000000, + avg_latency_ns: 3000000, + reachable: true, + }; + let json = serde_json::to_string(&record).unwrap(); + let deserialized: LatencyRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.device_pk, "DevicePubkey123"); + assert_eq!(deserialized.min_latency_ns, 1000000); + assert!(deserialized.reachable); + } + + #[test] + fn test_route_record_serde_roundtrip() { + let route = RouteRecord { + network: "10.0.0.0/24".to_string(), + local_ip: "10.1.2.3".to_string(), + peer_ip: "10.1.2.4".to_string(), + kernel_state: "active".to_string(), + liveness_last_updated: Some("2024-01-15T12:00:00Z".to_string()), + liveness_state: Some("up".to_string()), + liveness_state_reason: Some("healthy".to_string()), + peer_client_version: Some("0.8.6".to_string()), + }; + let json = serde_json::to_string(&route).unwrap(); + let deserialized: RouteRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.network, "10.0.0.0/24"); + assert_eq!(deserialized.peer_client_version.as_deref(), Some("0.8.6")); + } + + #[test] + fn test_route_record_serde_with_nulls() { + let route = RouteRecord { + network: "10.0.0.0/24".to_string(), + local_ip: "10.1.2.3".to_string(), + peer_ip: "10.1.2.4".to_string(), + kernel_state: "active".to_string(), + liveness_last_updated: None, + liveness_state: None, + liveness_state_reason: None, + peer_client_version: None, + }; + let json = serde_json::to_value(&route).unwrap(); + assert!(json.get("liveness_state").unwrap().is_null()); + } + + #[test] + fn test_status_response_serde_roundtrip() { + let status = StatusResponse { + doublezero_status: DoubleZeroStatus { + session_status: "BGP Session Up".to_string(), + last_session_update: Some(1625247600), + }, + tunnel_name: Some("doublezero1".to_string()), + tunnel_src: Some("10.0.0.1".to_string()), + tunnel_dst: Some("5.6.7.8".to_string()), + doublezero_ip: Some("10.1.2.3".to_string()), + user_type: Some("IBRL".to_string()), + }; + let json = serde_json::to_string(&status).unwrap(); + let deserialized: StatusResponse = serde_json::from_str(&json).unwrap(); + assert_eq!( + deserialized.doublezero_status.session_status, + "BGP Session Up" + ); + } + + #[test] + fn test_v2_status_response_serde() { + let json = r#"{ + "reconciler_enabled": true, + "client_ip": "1.2.3.4", + "network": "mainnet", + "services": [] + }"#; + let resp: V2StatusResponse = serde_json::from_str(json).unwrap(); + assert!(resp.reconciler_enabled); + assert_eq!(resp.client_ip, "1.2.3.4"); + assert!(resp.services.is_empty()); + } + + #[test] + fn test_multicast_groups_defaults() { + let json = "{}"; + let groups: MulticastGroups = serde_json::from_str(json).unwrap(); + assert!(groups.publisher.is_empty()); + assert!(groups.subscriber.is_empty()); + } + + #[test] + fn test_daemon_client_impl_uses_explicit_socket_path() { + let socket_path = + std::env::temp_dir().join(format!("doublezerod-test-{}.sock", std::process::id())); + { + let mut file = File::create(&socket_path).expect("create socket placeholder"); + std::io::Write::write_all(&mut file, b"test").expect("write"); + } + let client = DaemonClientImpl::new(Some(socket_path.to_string_lossy().into_owned())); + assert!(client.daemon_check()); + assert!(client.daemon_can_open()); + std::fs::remove_file(socket_path).expect("cleanup"); + } +} diff --git a/crates/doublezero-daemon-cli/src/ledger.rs b/crates/doublezero-daemon-cli/src/ledger.rs new file mode 100644 index 000000000..7afb89dd6 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/ledger.rs @@ -0,0 +1,21 @@ +//! Narrow ledger-client trait covering the subset of `CliCommand` methods used +//! by daemon verbs. +//! +//! The binary provides a blanket adapter from `CliCommandImpl` → `LedgerClient`. +//! This trait is intentionally narrow — it only includes SDK operations that +//! daemon-control verbs actually call. It will be expanded as verbs migrate +//! into this crate. + +use doublezero_config::Environment; +use mockall::automock; + +/// The subset of SDK/ledger operations used by daemon-control verbs. +/// +/// All daemon verbs need `get_environment()` for the daemon/client environment +/// match check. More complex verbs (`connect`, `disconnect`) use the +/// additional methods — those will be added as those verbs migrate into this +/// crate. +#[automock] +pub trait LedgerClient: Send + Sync { + fn get_environment(&self) -> Environment; +} diff --git a/crates/doublezero-daemon-cli/src/lib.rs b/crates/doublezero-daemon-cli/src/lib.rs new file mode 100644 index 000000000..ac6c0fc66 --- /dev/null +++ b/crates/doublezero-daemon-cli/src/lib.rs @@ -0,0 +1,12 @@ +//! RFC-20 module crate for daemon-control verbs (`connect`, `disconnect`, +//! `status`, `enable`, `disable`, `latency`, `routes`). +//! +//! See `rfcs/rfc20-cli-standardization.md` and `docs/cli-standard.md`. + +pub mod cli; +pub mod client; +pub mod ledger; + +pub use cli::DaemonCommand; +pub use client::{DaemonClient, DaemonClientImpl}; +pub use ledger::LedgerClient; From c061bb83814679c365d2d82ed007323417d03cb1 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 3 Jun 2026 15:46:36 +0000 Subject: [PATCH 2/3] daemon-cli: fix inconsistent error handling in v2_status and routes Add HTTP status code checks and use parse_daemon_response consistently across all DaemonClientImpl methods. Previously v2_status() and routes() skipped the error response parsing helper, which would surface daemon errors as confusing deserialization failures instead of the daemon's human-readable error descriptions. --- crates/doublezero-daemon-cli/src/client.rs | 34 ++++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/doublezero-daemon-cli/src/client.rs b/crates/doublezero-daemon-cli/src/client.rs index ed1a1a96e..1e5105291 100644 --- a/crates/doublezero-daemon-cli/src/client.rs +++ b/crates/doublezero-daemon-cli/src/client.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::{fmt, fs::File, path::Path, sync::OnceLock}; use tabled::{derive::display, Tabled}; -pub const DEFAULT_SOCKET_PATH: &str = "/var/run/doublezerod/doublezerod.sock"; +pub(crate) const DEFAULT_SOCKET_PATH: &str = "/var/run/doublezerod/doublezerod.sock"; const NANOS_TO_MS: f64 = 1000000.0; static GLOBAL_SOCKET_PATH: OnceLock = OnceLock::new(); @@ -324,10 +324,16 @@ impl DaemonClient for DaemonClientImpl { .request(req) .await .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; - let data = res.into_body().collect().await?.to_bytes(); - let response = serde_json::from_slice::(&data) - .map_err(|e| eyre!("Unable to parse V2StatusResponse: {e}"))?; - Ok(response) + if res.status() != 200 { + eyre::bail!("Unable to connect to doublezero daemon: {}", res.status()); + } + let data = res + .into_body() + .collect() + .await + .map_err(|e| eyre!("Unable to read response body: {e}"))? + .to_bytes(); + parse_daemon_response::(&data, "/v2/status") } async fn enable(&self) -> eyre::Result<()> { @@ -370,10 +376,20 @@ impl DaemonClient for DaemonClientImpl { .method(Method::GET) .uri(Uri::new(&self.socket_path, "/routes")) .body(Empty::::new())?; - let res = client.request(req).await?; - let data = res.into_body().collect().await?.to_bytes(); - let response = serde_json::from_slice::>(&data)?; - Ok(response) + let res = client + .request(req) + .await + .map_err(|e| eyre!("Unable to connect to doublezero daemon: {e}"))?; + if res.status() != 200 { + eyre::bail!("Unable to connect to doublezero daemon: {}", res.status()); + } + let data = res + .into_body() + .collect() + .await + .map_err(|e| eyre!("Unable to read response body: {e}"))? + .to_bytes(); + parse_daemon_response::>(&data, "/routes") } } From 4a2fe9e463dbf90e5eb2f8e1068cac6b007c48d9 Mon Sep 17 00:00:00 2001 From: Ben Blier Date: Wed, 3 Jun 2026 15:47:49 +0000 Subject: [PATCH 3/3] daemon-cli: update Cargo.lock for new crate --- Cargo.lock | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 2e2f780f2..4d3081c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1482,6 +1482,7 @@ dependencies = [ "ctor", "doublezero-cli-core", "doublezero-config", + "doublezero-daemon-cli", "doublezero-geolocation-cli", "doublezero-program-common", "doublezero-serviceability", @@ -1581,6 +1582,27 @@ dependencies = [ "solana-sdk", ] +[[package]] +name = "doublezero-daemon-cli" +version = "0.25.1" +dependencies = [ + "chrono", + "clap", + "doublezero-cli-core", + "doublezero-config", + "eyre", + "http 1.3.1", + "http-body-util", + "hyper", + "hyper-util", + "hyperlocal", + "mockall", + "serde", + "serde_json", + "tabled", + "tokio", +] + [[package]] name = "doublezero-geolocation" version = "0.25.1"