Skip to main content

singbox_driver/api/
clash_api.rs

1//! sing-box-specific Clash API client and live version observation.
2
3use crate::api::controller_configuration::SingBoxControllerConfiguration;
4use crate::api::delay::{DelayAnswer, classify};
5use crate::api::http::{
6    ReqwestSingBoxClashHttpPort, SingBoxClashHttpPort, SingBoxClashMethod, SingBoxClashResponse,
7    SingBoxControllerAuthorization, endpoint_with_path, parse_loopback_endpoint,
8};
9use lurker_core_contract::identity::CoreVersion;
10use lurker_core_ports::error::PortError;
11use serde::de::DeserializeOwned;
12use std::sync::Arc;
13use std::time::Duration;
14use url::Url;
15
16const API_TIMEOUT: Duration = Duration::from_secs(5);
17
18/// The sing-box Clash API client.
19#[derive(Clone)]
20pub struct SingBoxClashApiClient {
21    base_url: Url,
22    authorization: SingBoxControllerAuthorization,
23    http: Arc<dyn SingBoxClashHttpPort>,
24}
25
26impl SingBoxClashApiClient {
27    /// Creates the client with the production HTTP boundary.
28    ///
29    /// Fails with a `Failed` port error when the controller endpoint is not
30    /// an explicit HTTP loopback URL.
31    pub fn with_reqwest(controller: SingBoxControllerConfiguration) -> Result<Self, PortError> {
32        Self::new(controller, Arc::new(ReqwestSingBoxClashHttpPort))
33    }
34
35    /// Creates the client with an injected HTTP boundary.
36    pub fn new(
37        controller: SingBoxControllerConfiguration,
38        http: Arc<dyn SingBoxClashHttpPort>,
39    ) -> Result<Self, PortError> {
40        Ok(Self {
41            base_url: parse_loopback_endpoint(&controller.endpoint())?,
42            authorization: controller.authorization().clone(),
43            http,
44        })
45    }
46
47    /// Returns the loopback controller endpoint the client uses.
48    pub fn endpoint(&self) -> &Url {
49        &self.base_url
50    }
51
52    pub(crate) async fn get_json<T: DeserializeOwned>(
53        &self,
54        segments: &[&str],
55    ) -> Result<T, PortError> {
56        let response = self
57            .send(SingBoxClashMethod::Get, segments, None, API_TIMEOUT)
58            .await?;
59        require_status(&response, 200, "GET")?;
60        serde_json::from_slice(&response.body)
61            .map_err(|_| PortError::Network("sing-box Clash API JSON was invalid".to_owned()))
62    }
63
64    pub(crate) async fn put_json(
65        &self,
66        segments: &[&str],
67        body: serde_json::Value,
68    ) -> Result<(), PortError> {
69        let body = serde_json::to_vec(&body).map_err(|_| {
70            PortError::Failed("sing-box request JSON could not be encoded".to_owned())
71        })?;
72        let response = self
73            .send(SingBoxClashMethod::Put, segments, Some(body), API_TIMEOUT)
74            .await?;
75        require_status(&response, 204, "PUT")
76    }
77
78    /// Asks the engine for the delay of one outbound.
79    ///
80    /// The request is `GET /proxies/{name}/delay?url={url}&timeout={ms}`: the
81    /// engine measures the target through the named outbound and answers with
82    /// its own typed delay answer. The caller owns the transport timeout, so
83    /// the driver's own deadline stays the single authority over a late
84    /// answer.
85    pub(crate) async fn get_delay(
86        &self,
87        name: &str,
88        url: &str,
89        timeout_ms: u32,
90        transport_timeout: Duration,
91    ) -> Result<DelayAnswer, PortError> {
92        let mut request_url = endpoint_with_path(&self.base_url, &["proxies", name, "delay"])?;
93        {
94            let mut query = request_url.query_pairs_mut();
95            query.append_pair("url", url);
96            query.append_pair("timeout", &timeout_ms.to_string());
97        }
98        let response = self
99            .send_to(
100                SingBoxClashMethod::Get,
101                request_url,
102                None,
103                transport_timeout,
104            )
105            .await?;
106        Ok(classify(response.status, &response.body))
107    }
108
109    async fn send(
110        &self,
111        method: SingBoxClashMethod,
112        segments: &[&str],
113        body: Option<Vec<u8>>,
114        timeout: Duration,
115    ) -> Result<SingBoxClashResponse, PortError> {
116        let url = endpoint_with_path(&self.base_url, segments)?;
117        self.send_to(method, url, body, timeout).await
118    }
119
120    async fn send_to(
121        &self,
122        method: SingBoxClashMethod,
123        url: Url,
124        body: Option<Vec<u8>>,
125        timeout: Duration,
126    ) -> Result<SingBoxClashResponse, PortError> {
127        self.http
128            .request(&url, method, &self.authorization, body, timeout)
129            .await
130    }
131}
132
133/// Reads the running sing-box version from the live Clash API `/version`.
134///
135/// This value is never inferred from the locked download manifest or the
136/// executable path. The API reports `sing-box <version>`; the core identity
137/// stores the canonical `v<version>` form used by operation evidence locks.
138#[derive(Clone)]
139pub struct SingBoxCoreVersionProvider {
140    api: SingBoxClashApiClient,
141}
142
143impl SingBoxCoreVersionProvider {
144    /// Creates the version provider over one API client.
145    pub fn new(api: SingBoxClashApiClient) -> Self {
146        Self { api }
147    }
148
149    /// Reads the running core version from the live Clash API.
150    pub async fn observe(&self) -> Result<CoreVersion, PortError> {
151        let response: VersionResponse = self.api.get_json(&["version"]).await?;
152        parse_core_version(&response.version)
153    }
154}
155
156#[derive(serde::Deserialize)]
157struct VersionResponse {
158    version: String,
159}
160
161fn parse_core_version(value: &str) -> Result<CoreVersion, PortError> {
162    let version = value.strip_prefix("sing-box ").ok_or_else(|| {
163        PortError::Network("sing-box /version returned an unexpected engine identity".to_owned())
164    })?;
165    if version.is_empty()
166        || version.trim() != version
167        || !version.as_bytes()[0].is_ascii_digit()
168        || !version
169            .bytes()
170            .all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
171    {
172        return Err(PortError::Network(
173            "sing-box /version returned a non-canonical version".to_owned(),
174        ));
175    }
176    CoreVersion::new(format!("v{version}"))
177        .map_err(|_| PortError::Network("sing-box /version returned an empty version".to_owned()))
178}
179
180fn require_status(
181    response: &SingBoxClashResponse,
182    expected: u16,
183    method: &str,
184) -> Result<(), PortError> {
185    if response.status == expected {
186        Ok(())
187    } else {
188        Err(PortError::Network(format!(
189            "sing-box Clash API {method} returned HTTP {}",
190            response.status
191        )))
192    }
193}
194
195#[cfg(test)]
196mod version_tests {
197    use super::parse_core_version;
198
199    #[test]
200    fn live_version_parser_requires_the_sing_box_engine_prefix() {
201        assert_eq!(
202            parse_core_version("sing-box 1.14.3").unwrap().raw(),
203            "v1.14.3"
204        );
205        assert!(parse_core_version("mihomo v1.19.33").is_err());
206        assert!(parse_core_version("sing-box ").is_err());
207        assert!(parse_core_version("sing-box 1.14.3 unexpected").is_err());
208    }
209}