singbox_driver/api/
clash_api.rs1use 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#[derive(Clone)]
20pub struct SingBoxClashApiClient {
21 base_url: Url,
22 authorization: SingBoxControllerAuthorization,
23 http: Arc<dyn SingBoxClashHttpPort>,
24}
25
26impl SingBoxClashApiClient {
27 pub fn with_reqwest(controller: SingBoxControllerConfiguration) -> Result<Self, PortError> {
32 Self::new(controller, Arc::new(ReqwestSingBoxClashHttpPort))
33 }
34
35 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 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 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#[derive(Clone)]
139pub struct SingBoxCoreVersionProvider {
140 api: SingBoxClashApiClient,
141}
142
143impl SingBoxCoreVersionProvider {
144 pub fn new(api: SingBoxClashApiClient) -> Self {
146 Self { api }
147 }
148
149 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}