Skip to main content

threexui_rs/
client.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Once};
3use std::time::Duration;
4
5static CRYPTO_PROVIDER_INIT: Once = Once::new();
6
7/// Install the `ring` rustls provider as the process default the first time
8/// any client is constructed. Idempotent and safe to call repeatedly. We do
9/// not depend on `aws-lc-sys` because its native build trips a compiler-bug
10/// check on common CI compilers; `ring` works everywhere.
11fn ensure_crypto_provider() {
12    CRYPTO_PROVIDER_INIT.call_once(|| {
13        // Ignore the error: a second installation attempt fails harmlessly.
14        let _ = rustls::crypto::ring::default_provider().install_default();
15    });
16}
17
18use crate::api::custom_geo::CustomGeoApi;
19use crate::api::inbounds::InboundsApi;
20use crate::api::server::ServerApi;
21use crate::api::settings::SettingsApi;
22use crate::api::xray::XrayApi;
23use crate::config::ClientConfig;
24use crate::error::Result;
25use crate::models::common::ApiResponse;
26use crate::Error;
27
28/// Centralized response decoder.
29///
30/// Treats HTTP 404 as `Error::EndpointNotFound` (older 3x-ui versions return 404
31/// for endpoints added in newer releases — the previous behaviour was an opaque
32/// JSON-decode error).
33///
34/// For any non-success status with a non-JSON body we surface the status code in
35/// the resulting `Error::Api`.
36pub(crate) async fn read_api_response<T: serde::de::DeserializeOwned>(
37    resp: reqwest::Response,
38) -> Result<ApiResponse<T>> {
39    let status = resp.status();
40    let path = resp.url().path().to_string();
41    let bytes = resp.bytes().await?;
42    if status == reqwest::StatusCode::NOT_FOUND {
43        return Err(Error::EndpointNotFound(path));
44    }
45    if bytes.is_empty() {
46        return Err(Error::Api(format!("empty response body (HTTP {})", status)));
47    }
48    serde_json::from_slice::<ApiResponse<T>>(&bytes).map_err(|e| {
49        if status.is_success() {
50            Error::Json(e)
51        } else {
52            // Trim & expose the body so calls hitting an HTML error page get
53            // something readable.
54            let snippet: String = String::from_utf8_lossy(&bytes).chars().take(200).collect();
55            Error::Api(format!(
56                "HTTP {} — non-JSON body: {}",
57                status,
58                snippet.trim()
59            ))
60        }
61    })
62}
63
64pub(crate) struct ClientInner {
65    pub http: reqwest::Client,
66    pub base_url: String,
67    pub authenticated: AtomicBool,
68}
69
70#[derive(Clone)]
71pub struct Client {
72    pub(crate) inner: Arc<ClientInner>,
73}
74
75impl Client {
76    pub fn new(config: ClientConfig) -> Self {
77        ensure_crypto_provider();
78        let mut builder = reqwest::Client::builder()
79            .cookie_store(true)
80            .danger_accept_invalid_certs(config.accept_invalid_certs)
81            .timeout(Duration::from_secs(config.timeout_secs));
82
83        if let Some(proxy_url) = &config.proxy {
84            // URL was already validated in `ClientConfigBuilder::build`, so
85            // this should not fail unless the user constructed `ClientConfig`
86            // by hand with garbage.
87            let mut proxy = reqwest::Proxy::all(proxy_url.as_str())
88                .expect("proxy url validated at config build time");
89            if let (Some(u), Some(p)) = (&config.proxy_username, &config.proxy_password) {
90                proxy = proxy.basic_auth(u, p);
91            }
92            builder = builder.proxy(proxy);
93        }
94
95        let http = builder.build().expect("failed to build reqwest client");
96
97        Client {
98            inner: Arc::new(ClientInner {
99                http,
100                base_url: config.base_url(),
101                authenticated: AtomicBool::new(false),
102            }),
103        }
104    }
105
106    pub(crate) fn url(&self, path: &str) -> String {
107        format!("{}{}", self.inner.base_url, path)
108    }
109
110    pub(crate) fn require_auth(&self) -> Result<()> {
111        if self.inner.authenticated.load(Ordering::Relaxed) {
112            Ok(())
113        } else {
114            Err(Error::NotAuthenticated)
115        }
116    }
117
118    pub async fn login(&self, username: &str, password: &str) -> Result<()> {
119        self.login_inner(username, password, None).await
120    }
121
122    pub async fn login_2fa(&self, username: &str, password: &str, code: &str) -> Result<()> {
123        self.login_inner(username, password, Some(code)).await
124    }
125
126    async fn login_inner(
127        &self,
128        username: &str,
129        password: &str,
130        two_factor: Option<&str>,
131    ) -> Result<()> {
132        let mut params = vec![
133            ("username", username.to_string()),
134            ("password", password.to_string()),
135        ];
136        if let Some(code) = two_factor {
137            params.push(("twoFactorCode", code.to_string()));
138        }
139
140        let resp = self
141            .inner
142            .http
143            .post(self.url("login"))
144            .form(&params)
145            .send()
146            .await?
147            .json::<ApiResponse<serde_json::Value>>()
148            .await?;
149
150        if resp.success {
151            self.inner.authenticated.store(true, Ordering::Relaxed);
152            Ok(())
153        } else {
154            Err(Error::Auth(resp.msg))
155        }
156    }
157
158    pub async fn logout(&self) -> Result<()> {
159        let _ = self.inner.http.get(self.url("logout")).send().await?;
160        self.inner.authenticated.store(false, Ordering::Relaxed);
161        Ok(())
162    }
163
164    pub async fn is_two_factor_enabled(&self) -> Result<bool> {
165        let resp = self
166            .inner
167            .http
168            .post(self.url("getTwoFactorEnable"))
169            .send()
170            .await?
171            .json::<ApiResponse<bool>>()
172            .await?;
173        resp.into_result().map(|v| v.unwrap_or(false))
174    }
175
176    pub async fn backup_to_tgbot(&self) -> Result<()> {
177        self.require_auth()?;
178        self.inner
179            .http
180            .get(self.url("panel/api/backuptotgbot"))
181            .send()
182            .await?;
183        Ok(())
184    }
185
186    pub fn inbounds(&self) -> InboundsApi<'_> {
187        InboundsApi { client: self }
188    }
189
190    pub fn server(&self) -> ServerApi<'_> {
191        ServerApi { client: self }
192    }
193
194    pub fn settings(&self) -> SettingsApi<'_> {
195        SettingsApi { client: self }
196    }
197
198    pub fn xray(&self) -> XrayApi<'_> {
199        XrayApi { client: self }
200    }
201
202    pub fn custom_geo(&self) -> CustomGeoApi<'_> {
203        CustomGeoApi { client: self }
204    }
205
206    pub(crate) async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
207        self.require_auth()?;
208        let raw = self.inner.http.get(self.url(path)).send().await?;
209        let resp = read_api_response::<T>(raw).await?;
210        resp.into_result()
211            .and_then(|v| v.ok_or_else(|| Error::Api("empty response".into())))
212    }
213
214    pub(crate) async fn post<B, T>(&self, path: &str, body: &B) -> Result<T>
215    where
216        B: serde::Serialize,
217        T: serde::de::DeserializeOwned,
218    {
219        self.require_auth()?;
220        let raw = self
221            .inner
222            .http
223            .post(self.url(path))
224            .json(body)
225            .send()
226            .await?;
227        let resp = read_api_response::<T>(raw).await?;
228        resp.into_result()
229            .and_then(|v| v.ok_or_else(|| Error::Api("empty response".into())))
230    }
231
232    pub(crate) async fn post_empty<B>(&self, path: &str, body: &B) -> Result<()>
233    where
234        B: serde::Serialize,
235    {
236        self.require_auth()?;
237        let raw = self
238            .inner
239            .http
240            .post(self.url(path))
241            .json(body)
242            .send()
243            .await?;
244        let resp = read_api_response::<serde_json::Value>(raw).await?;
245        if resp.success {
246            Ok(())
247        } else {
248            Err(Error::Api(resp.msg))
249        }
250    }
251
252    pub(crate) async fn post_form_empty(&self, path: &str, params: &[(&str, &str)]) -> Result<()> {
253        self.require_auth()?;
254        let raw = self
255            .inner
256            .http
257            .post(self.url(path))
258            .form(params)
259            .send()
260            .await?;
261        let resp = read_api_response::<serde_json::Value>(raw).await?;
262        if resp.success {
263            Ok(())
264        } else {
265            Err(Error::Api(resp.msg))
266        }
267    }
268
269    pub(crate) async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
270        self.require_auth()?;
271        let bytes = self
272            .inner
273            .http
274            .get(self.url(path))
275            .send()
276            .await?
277            .bytes()
278            .await?;
279        Ok(bytes.to_vec())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use wiremock::matchers::{method, path};
287    use wiremock::{Mock, MockServer, ResponseTemplate};
288
289    async fn mock_client(server: &MockServer) -> Client {
290        let config = ClientConfig::builder()
291            .host("127.0.0.1")
292            .port(server.address().port())
293            .build()
294            .unwrap();
295        Client::new(config)
296    }
297
298    #[tokio::test]
299    async fn login_sets_authenticated() {
300        let server = MockServer::start().await;
301        Mock::given(method("POST"))
302            .and(path("/login"))
303            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
304                "success": true, "msg": "ok", "obj": null
305            })))
306            .mount(&server)
307            .await;
308
309        let client = mock_client(&server).await;
310        assert!(!client.inner.authenticated.load(Ordering::Relaxed));
311        client.login("admin", "pass").await.unwrap();
312        assert!(client.inner.authenticated.load(Ordering::Relaxed));
313    }
314
315    #[tokio::test]
316    async fn login_failure_returns_auth_error() {
317        let server = MockServer::start().await;
318        Mock::given(method("POST"))
319            .and(path("/login"))
320            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
321                "success": false, "msg": "wrong username or password", "obj": null
322            })))
323            .mount(&server)
324            .await;
325
326        let client = mock_client(&server).await;
327        let err = client.login("admin", "wrong").await.unwrap_err();
328        assert!(matches!(err, Error::Auth(_)));
329    }
330
331    #[tokio::test]
332    async fn http_404_returns_endpoint_not_found() {
333        let server = MockServer::start().await;
334        Mock::given(method("POST"))
335            .and(path("/login"))
336            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
337                "success": true, "msg": "", "obj": null
338            })))
339            .mount(&server)
340            .await;
341        Mock::given(method("GET"))
342            .and(path("/panel/api/missing"))
343            .respond_with(ResponseTemplate::new(404).set_body_string(""))
344            .mount(&server)
345            .await;
346
347        let client = mock_client(&server).await;
348        client.login("admin", "p").await.unwrap();
349
350        let err: Result<serde_json::Value> = client.get("panel/api/missing").await;
351        match err {
352            Err(Error::EndpointNotFound(p)) => assert!(p.contains("missing")),
353            other => panic!("expected EndpointNotFound, got {:?}", other.is_ok()),
354        }
355    }
356
357    #[tokio::test]
358    async fn http_500_with_html_surfaces_status_in_api_error() {
359        let server = MockServer::start().await;
360        Mock::given(method("POST"))
361            .and(path("/login"))
362            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
363                "success": true, "msg": "", "obj": null
364            })))
365            .mount(&server)
366            .await;
367        Mock::given(method("GET"))
368            .and(path("/panel/api/boom"))
369            .respond_with(
370                ResponseTemplate::new(500).set_body_string("<html>Internal Server Error</html>"),
371            )
372            .mount(&server)
373            .await;
374
375        let client = mock_client(&server).await;
376        client.login("admin", "p").await.unwrap();
377
378        let err: Result<serde_json::Value> = client.get("panel/api/boom").await;
379        let msg = err.unwrap_err().to_string();
380        assert!(msg.contains("HTTP 500"), "msg = {}", msg);
381    }
382
383    #[tokio::test]
384    async fn empty_body_returns_api_error_not_panic() {
385        let server = MockServer::start().await;
386        Mock::given(method("POST"))
387            .and(path("/login"))
388            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
389                "success": true, "msg": "", "obj": null
390            })))
391            .mount(&server)
392            .await;
393        Mock::given(method("GET"))
394            .and(path("/panel/api/empty"))
395            .respond_with(ResponseTemplate::new(200).set_body_string(""))
396            .mount(&server)
397            .await;
398
399        let client = mock_client(&server).await;
400        client.login("admin", "p").await.unwrap();
401
402        let err: Result<serde_json::Value> = client.get("panel/api/empty").await;
403        let msg = err.unwrap_err().to_string();
404        assert!(msg.contains("empty response body"), "msg = {}", msg);
405    }
406
407    #[tokio::test]
408    async fn require_auth_fails_when_not_logged_in() {
409        let server = MockServer::start().await;
410        let client = mock_client(&server).await;
411        assert!(matches!(
412            client.require_auth(),
413            Err(Error::NotAuthenticated)
414        ));
415    }
416}