1use std::{sync::Arc, time::Duration};
4
5use reqwest::{
6 Method, RequestBuilder, StatusCode,
7 cookie::{CookieStore, Jar},
8 header,
9};
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Serialize, de::DeserializeOwned};
12use tokio::sync::Mutex;
13use url::Url;
14
15use crate::{
16 AuthApi, Error, ProxyConfig, Result,
17 response::ApiResponse,
18 transport::{self, ErrorUrlPolicy},
19};
20
21const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
23const DEFAULT_USER_AGENT: &str = concat!("xui-rs/", env!("CARGO_PKG_VERSION"));
24
25pub const DEFAULT_API_RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
27
28pub const DEFAULT_DOWNLOAD_RESPONSE_BODY_LIMIT: usize = 512 * 1024 * 1024;
30
31#[derive(Clone, Copy)]
32pub(crate) enum AuthenticationScope {
33 Session,
34 #[allow(dead_code)]
35 PanelApi,
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40#[non_exhaustive]
41pub enum AuthenticationKind {
42 Session,
44 BearerToken,
46}
47
48#[must_use]
50pub struct ClientBuilder {
51 base_url: Url,
52 bearer_token: Option<SecretString>,
53 timeout: Duration,
54 connect_timeout: Duration,
55 user_agent: String,
56 accept_invalid_certs: bool,
57 proxy: Option<ProxyConfig>,
58 response_body_limit: usize,
59 download_body_limit: usize,
60}
61
62impl std::fmt::Debug for ClientBuilder {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 formatter
65 .debug_struct("ClientBuilder")
66 .field("server_origin", &origin(&self.base_url))
67 .field(
68 "authentication",
69 &self
70 .bearer_token
71 .as_ref()
72 .map(|_| AuthenticationKind::BearerToken),
73 )
74 .field("timeout", &self.timeout)
75 .field("connect_timeout", &self.connect_timeout)
76 .field("user_agent", &self.user_agent)
77 .field("accept_invalid_certs", &self.accept_invalid_certs)
78 .field("proxy", &self.proxy.as_ref().map(ProxyConfig::scheme))
79 .field("response_body_limit", &self.response_body_limit)
80 .field("download_body_limit", &self.download_body_limit)
81 .finish()
82 }
83}
84
85impl ClientBuilder {
86 pub(crate) fn new(base_url: impl AsRef<str>) -> Result<Self> {
87 Ok(Self {
88 base_url: normalize_base_url(base_url.as_ref())?,
89 bearer_token: None,
90 timeout: DEFAULT_TIMEOUT,
91 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
92 user_agent: DEFAULT_USER_AGENT.to_owned(),
93 accept_invalid_certs: false,
94 proxy: None,
95 response_body_limit: DEFAULT_API_RESPONSE_BODY_LIMIT,
96 download_body_limit: DEFAULT_DOWNLOAD_RESPONSE_BODY_LIMIT,
97 })
98 }
99
100 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
102 self.bearer_token = Some(SecretString::from(token.into()));
103 self
104 }
105
106 pub const fn timeout(mut self, timeout: Duration) -> Self {
108 self.timeout = timeout;
109 self
110 }
111
112 pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
114 self.connect_timeout = timeout;
115 self
116 }
117
118 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
120 self.user_agent = user_agent.into();
121 self
122 }
123
124 pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
129 self.accept_invalid_certs = accept;
130 self
131 }
132
133 pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
140 self.proxy = Some(proxy);
141 self
142 }
143
144 pub fn proxy_url(self, proxy_url: impl AsRef<str>) -> Result<Self> {
151 Ok(self.proxy(ProxyConfig::new(proxy_url)?))
152 }
153
154 pub fn no_proxy(mut self) -> Self {
156 self.proxy = None;
157 self
158 }
159
160 pub const fn response_body_limit(mut self, limit: usize) -> Self {
167 self.response_body_limit = limit;
168 self
169 }
170
171 pub const fn download_body_limit(mut self, limit: usize) -> Self {
177 self.download_body_limit = limit;
178 self
179 }
180
181 pub fn build(self) -> Result<Client> {
188 if self
189 .bearer_token
190 .as_ref()
191 .is_some_and(|token| token.expose_secret().trim().is_empty())
192 {
193 return Err(Error::Configuration(
194 "bearer token must not be empty".to_owned(),
195 ));
196 }
197 let mut headers = header::HeaderMap::new();
198 headers.insert(
199 "x-requested-with",
200 header::HeaderValue::from_static("XMLHttpRequest"),
201 );
202
203 let cookie_jar = Arc::new(Jar::default());
204 let mut http_builder = reqwest::Client::builder()
205 .no_proxy()
206 .cookie_provider(Arc::clone(&cookie_jar))
207 .default_headers(headers)
208 .timeout(self.timeout)
209 .connect_timeout(self.connect_timeout)
210 .user_agent(self.user_agent.clone())
211 .danger_accept_invalid_certs(self.accept_invalid_certs);
212 if let Some(proxy) = &self.proxy {
213 http_builder = http_builder.proxy(proxy.reqwest_proxy()?);
214 }
215 let http = http_builder
216 .build()
217 .map_err(|error| Error::Configuration(error.to_string()))?;
218
219 Ok(Client {
220 inner: Arc::new(Inner {
221 http,
222 base_url: self.base_url,
223 bearer_token: self.bearer_token,
224 cookie_jar,
225 csrf_token: Mutex::new(None),
226 connect_timeout: self.connect_timeout,
227 user_agent: self.user_agent,
228 accept_invalid_certs: self.accept_invalid_certs,
229 proxy: self.proxy,
230 response_body_limit: self.response_body_limit,
231 download_body_limit: self.download_body_limit,
232 }),
233 })
234 }
235}
236
237#[derive(Clone)]
239pub struct Client {
240 inner: Arc<Inner>,
241}
242
243impl std::fmt::Debug for Client {
244 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 formatter
246 .debug_struct("Client")
247 .field("server_origin", &origin(&self.inner.base_url))
248 .field("authentication", &self.authentication_kind())
249 .field("proxy", &self.inner.proxy.as_ref().map(ProxyConfig::scheme))
250 .field("response_body_limit", &self.inner.response_body_limit)
251 .field("download_body_limit", &self.inner.download_body_limit)
252 .finish_non_exhaustive()
253 }
254}
255
256struct Inner {
257 http: reqwest::Client,
258 base_url: Url,
259 bearer_token: Option<SecretString>,
260 cookie_jar: Arc<Jar>,
261 csrf_token: Mutex<Option<SecretString>>,
262 connect_timeout: Duration,
263 user_agent: String,
264 accept_invalid_certs: bool,
265 proxy: Option<ProxyConfig>,
266 response_body_limit: usize,
267 download_body_limit: usize,
268}
269
270impl Client {
271 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
278 Self::builder(base_url)?.build()
279 }
280
281 pub fn builder(base_url: impl AsRef<str>) -> Result<ClientBuilder> {
287 ClientBuilder::new(base_url)
288 }
289
290 pub fn base_url(&self) -> &Url {
292 &self.inner.base_url
293 }
294
295 pub fn authentication_kind(&self) -> AuthenticationKind {
297 if self.inner.bearer_token.is_some() {
298 AuthenticationKind::BearerToken
299 } else {
300 AuthenticationKind::Session
301 }
302 }
303
304 pub fn response_body_limit(&self) -> usize {
306 self.inner.response_body_limit
307 }
308
309 pub fn download_body_limit(&self) -> usize {
311 self.inner.download_body_limit
312 }
313
314 pub const fn auth(&self) -> AuthApi<'_> {
316 AuthApi::new(self)
317 }
318
319 pub const fn inbounds(&self) -> crate::InboundsApi<'_> {
321 crate::InboundsApi::new(self)
322 }
323
324 pub const fn clients(&self) -> crate::ClientsApi<'_> {
326 crate::ClientsApi::new(self)
327 }
328
329 pub const fn server(&self) -> crate::ServerApi<'_> {
331 crate::ServerApi::new(self)
332 }
333
334 pub const fn settings(&self) -> crate::SettingsApi<'_> {
336 crate::SettingsApi::new(self)
337 }
338
339 pub const fn xray_settings(&self) -> crate::XraySettingsApi<'_> {
341 crate::XraySettingsApi::new(self)
342 }
343
344 pub const fn hosts(&self) -> crate::HostsApi<'_> {
346 crate::HostsApi::new(self)
347 }
348
349 pub const fn nodes(&self) -> crate::NodesApi<'_> {
351 crate::NodesApi::new(self)
352 }
353
354 pub const fn panel(&self) -> crate::PanelApi<'_> {
356 crate::PanelApi::new(self)
357 }
358
359 pub const fn events(&self) -> crate::EventsApi<'_> {
361 crate::EventsApi::new(self)
362 }
363
364 pub const fn subscription_balancers(&self) -> crate::SubscriptionBalancersApi<'_> {
366 crate::SubscriptionBalancersApi::new(self)
367 }
368
369 pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
370 self.inner
371 .base_url
372 .join(path.trim_start_matches('/'))
373 .map_err(|error| Error::Configuration(format!("invalid endpoint path: {error}")))
374 }
375
376 pub(crate) fn cookie_header(&self, url: &Url) -> Option<header::HeaderValue> {
377 self.inner.cookie_jar.cookies(url)
378 }
379
380 pub(crate) fn connect_timeout(&self) -> Duration {
381 self.inner.connect_timeout
382 }
383
384 pub(crate) fn user_agent(&self) -> &str {
385 &self.inner.user_agent
386 }
387
388 pub(crate) fn accepts_invalid_certs(&self) -> bool {
389 self.inner.accept_invalid_certs
390 }
391
392 pub(crate) fn proxy(&self) -> Option<&ProxyConfig> {
393 self.inner.proxy.as_ref()
394 }
395
396 pub(crate) async fn execute<T, B>(
397 &self,
398 method: Method,
399 path: &str,
400 body: Option<&B>,
401 scope: AuthenticationScope,
402 ) -> Result<ApiResponse<T>>
403 where
404 T: DeserializeOwned,
405 B: Serialize + ?Sized,
406 {
407 self.execute_with(method, path, scope, |request| match body {
408 Some(body) => request.json(body),
409 None => request,
410 })
411 .await
412 }
413
414 pub(crate) async fn execute_form<T, B>(
415 &self,
416 method: Method,
417 path: &str,
418 body: &B,
419 scope: AuthenticationScope,
420 ) -> Result<ApiResponse<T>>
421 where
422 T: DeserializeOwned,
423 B: Serialize + ?Sized,
424 {
425 self.execute_with(method, path, scope, |request| request.form(body))
426 .await
427 }
428
429 pub(crate) async fn execute_response<T, B>(
430 &self,
431 method: Method,
432 path: &str,
433 body: Option<&B>,
434 scope: AuthenticationScope,
435 ) -> Result<T>
436 where
437 T: DeserializeOwned,
438 B: Serialize + ?Sized,
439 {
440 self.execute_response_with(method, path, scope, |request| match body {
441 Some(body) => request.json(body),
442 None => request,
443 })
444 .await
445 }
446
447 pub(crate) async fn execute_empty<B>(
448 &self,
449 method: Method,
450 path: &str,
451 body: Option<&B>,
452 scope: AuthenticationScope,
453 ) -> Result<()>
454 where
455 B: Serialize + ?Sized,
456 {
457 self.execute_configured(method, path, scope, |request| match body {
458 Some(body) => request.json(body),
459 None => request,
460 })
461 .await?;
462 Ok(())
463 }
464
465 pub(crate) async fn execute_query<T, Q>(
466 &self,
467 method: Method,
468 path: &str,
469 query: &Q,
470 scope: AuthenticationScope,
471 ) -> Result<ApiResponse<T>>
472 where
473 T: DeserializeOwned,
474 Q: Serialize + ?Sized,
475 {
476 self.execute_with(method, path, scope, |request| request.query(query))
477 .await
478 }
479
480 pub(crate) async fn execute_query_json<T, Q, B>(
481 &self,
482 method: Method,
483 path: &str,
484 query: &Q,
485 body: &B,
486 scope: AuthenticationScope,
487 ) -> Result<ApiResponse<T>>
488 where
489 T: DeserializeOwned,
490 Q: Serialize + ?Sized,
491 B: Serialize + ?Sized,
492 {
493 self.execute_with(method, path, scope, |request| {
494 request.query(query).json(body)
495 })
496 .await
497 }
498
499 pub(crate) async fn execute_multipart<T>(
500 &self,
501 method: Method,
502 path: &str,
503 field: &'static str,
504 filename: &str,
505 bytes: &[u8],
506 scope: AuthenticationScope,
507 ) -> Result<ApiResponse<T>>
508 where
509 T: DeserializeOwned,
510 {
511 self.execute_with(method, path, scope, |request| {
512 let part = reqwest::multipart::Part::bytes(bytes.to_vec())
513 .file_name(filename.to_owned())
514 .mime_str("application/octet-stream")
515 .expect("static MIME type is valid");
516 request.multipart(reqwest::multipart::Form::new().part(field, part))
517 })
518 .await
519 }
520
521 pub(crate) async fn execute_bytes(
522 &self,
523 method: Method,
524 path: &str,
525 scope: AuthenticationScope,
526 ) -> Result<(header::HeaderMap, Vec<u8>)> {
527 let url = self.endpoint(path)?;
528 let response = self
529 .execute_raw_once(method.clone(), url.clone(), scope, false, |request| request)
530 .await?;
531 let headers = response.headers().clone();
532 let bytes = transport::read_response_body(
533 response,
534 &method,
535 &url,
536 self.inner.download_body_limit,
537 ErrorUrlPolicy::Preserve,
538 )
539 .await?;
540 Ok((headers, bytes))
541 }
542
543 async fn execute_with<T, F>(
544 &self,
545 method: Method,
546 path: &str,
547 scope: AuthenticationScope,
548 configure: F,
549 ) -> Result<ApiResponse<T>>
550 where
551 T: DeserializeOwned,
552 F: Fn(RequestBuilder) -> RequestBuilder,
553 {
554 let (method, url, bytes) = self
555 .execute_configured(method, path, scope, configure)
556 .await?;
557 let envelope: ApiResponse<T> =
558 serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
559 method: method.clone(),
560 url: Box::new(url.clone()),
561 source,
562 })?;
563 if !envelope.success {
564 return Err(Error::Api {
565 method,
566 url: Box::new(url),
567 message: if envelope.msg.is_empty() {
568 "unknown panel error".to_owned()
569 } else {
570 envelope.msg
571 },
572 });
573 }
574 Ok(envelope)
575 }
576
577 async fn execute_response_with<T, F>(
578 &self,
579 method: Method,
580 path: &str,
581 scope: AuthenticationScope,
582 configure: F,
583 ) -> Result<T>
584 where
585 T: DeserializeOwned,
586 F: Fn(RequestBuilder) -> RequestBuilder,
587 {
588 let (method, url, bytes) = self
589 .execute_configured(method, path, scope, configure)
590 .await?;
591 serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
592 method,
593 url: Box::new(url),
594 source,
595 })
596 }
597
598 async fn execute_configured<F>(
599 &self,
600 method: Method,
601 path: &str,
602 scope: AuthenticationScope,
603 configure: F,
604 ) -> Result<(Method, Url, Vec<u8>)>
605 where
606 F: Fn(RequestBuilder) -> RequestBuilder,
607 {
608 let url = self.endpoint(path)?;
609 let is_unsafe = !matches!(
610 method,
611 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
612 );
613 let unsafe_session_request = is_unsafe
614 && match scope {
615 AuthenticationScope::Session => true,
616 AuthenticationScope::PanelApi => self.inner.bearer_token.is_none(),
617 };
618 let first = self
619 .execute_raw_once(
620 method.clone(),
621 url.clone(),
622 scope,
623 unsafe_session_request,
624 &configure,
625 )
626 .await;
627 let response = if unsafe_session_request && matches!(first, Err(Error::Forbidden { .. })) {
628 self.clear_csrf_token().await;
629 self.execute_raw_once(
630 method.clone(),
631 url.clone(),
632 scope,
633 unsafe_session_request,
634 &configure,
635 )
636 .await?
637 } else {
638 first?
639 };
640 let bytes = transport::read_response_body(
641 response,
642 &method,
643 &url,
644 self.inner.response_body_limit,
645 ErrorUrlPolicy::Preserve,
646 )
647 .await?;
648 Ok((method, url, bytes))
649 }
650
651 async fn execute_raw_once<F>(
652 &self,
653 method: Method,
654 url: Url,
655 scope: AuthenticationScope,
656 csrf_required: bool,
657 configure: F,
658 ) -> Result<reqwest::Response>
659 where
660 F: FnOnce(RequestBuilder) -> RequestBuilder,
661 {
662 let mut request = self.inner.http.request(method.clone(), url.clone());
663 if matches!(scope, AuthenticationScope::PanelApi) {
664 request = self.authenticate(request);
665 }
666 if csrf_required {
667 let token = self.ensure_csrf_token().await?;
668 request = request.header("x-csrf-token", token.expose_secret());
669 }
670 request = configure(request);
671
672 let response =
673 transport::send_request(request, &method, &url, ErrorUrlPolicy::Preserve).await?;
674 let status = response.status();
675 match status {
676 StatusCode::UNAUTHORIZED => Err(Error::Unauthorized {
677 method,
678 url: Box::new(url),
679 }),
680 StatusCode::FORBIDDEN => Err(Error::Forbidden {
681 method,
682 url: Box::new(url),
683 }),
684 _ if !status.is_success() => Err(Error::HttpStatus {
685 method,
686 url: Box::new(url),
687 status,
688 }),
689 _ => Ok(response),
690 }
691 }
692
693 fn authenticate(&self, request: RequestBuilder) -> RequestBuilder {
694 match self.inner.bearer_token.as_ref() {
695 Some(token) => request.bearer_auth(token.expose_secret()),
696 None => request,
697 }
698 }
699
700 pub(crate) async fn ensure_csrf_token(&self) -> Result<SecretString> {
701 let mut current = self.inner.csrf_token.lock().await;
702 if let Some(token) = current.as_ref() {
703 return Ok(token.clone());
704 }
705
706 let method = Method::GET;
707 let url = self.endpoint("csrf-token")?;
708 let response = transport::send_request(
709 self.inner.http.get(url.clone()),
710 &method,
711 &url,
712 ErrorUrlPolicy::Preserve,
713 )
714 .await?;
715 let status = response.status();
716 if !status.is_success() {
717 return Err(Error::HttpStatus {
718 method,
719 url: Box::new(url),
720 status,
721 });
722 }
723 let bytes = transport::read_response_body(
724 response,
725 &method,
726 &url,
727 self.inner.response_body_limit,
728 ErrorUrlPolicy::Preserve,
729 )
730 .await?;
731 let envelope: ApiResponse<String> =
732 serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
733 method: method.clone(),
734 url: Box::new(url.clone()),
735 source,
736 })?;
737 if !envelope.success {
738 return Err(Error::Api {
739 method,
740 url: Box::new(url),
741 message: envelope.msg,
742 });
743 }
744 let token = SecretString::from(envelope.obj.ok_or_else(|| Error::MissingObject {
745 method,
746 url: Box::new(url),
747 })?);
748 *current = Some(token.clone());
749 Ok(token)
750 }
751
752 pub(crate) async fn clear_csrf_token(&self) {
753 *self.inner.csrf_token.lock().await = None;
754 }
755}
756
757fn normalize_base_url(value: &str) -> Result<Url> {
758 let mut url = Url::parse(value)
759 .map_err(|error| Error::Configuration(format!("invalid panel URL: {error}")))?;
760 if !matches!(url.scheme(), "http" | "https") {
761 return Err(Error::Configuration(
762 "panel URL scheme must be http or https".to_owned(),
763 ));
764 }
765 if url.host_str().is_none() {
766 return Err(Error::Configuration(
767 "panel URL must contain a host".to_owned(),
768 ));
769 }
770 if !url.username().is_empty() || url.password().is_some() {
771 return Err(Error::Configuration(
772 "panel URL must not contain credentials".to_owned(),
773 ));
774 }
775 url.set_query(None);
776 url.set_fragment(None);
777 if !url.path().ends_with('/') {
778 url.set_path(&format!("{}/", url.path()));
779 }
780 Ok(url)
781}
782
783fn origin(url: &Url) -> String {
784 let mut value = url.clone();
785 value.set_path("");
786 value.set_query(None);
787 value.set_fragment(None);
788 value.to_string().trim_end_matches('/').to_owned()
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
795
796 #[test]
797 fn normalizes_base_url_without_losing_path() {
798 let client = Client::new("https://panel.example.com/secret?ignored=1#fragment").unwrap();
799 assert_eq!(
800 client.base_url().as_str(),
801 "https://panel.example.com/secret/"
802 );
803 assert_eq!(
804 client.endpoint("panel/api/server/status").unwrap().as_str(),
805 "https://panel.example.com/secret/panel/api/server/status"
806 );
807 }
808
809 #[test]
810 fn rejects_non_http_urls() {
811 assert!(matches!(
812 Client::new("ftp://panel.example.com").unwrap_err(),
813 Error::Configuration(_)
814 ));
815 }
816
817 #[test]
818 fn rejects_url_credentials_and_empty_bearer_tokens() {
819 assert!(matches!(
820 Client::new("https://admin:password@panel.example.com").unwrap_err(),
821 Error::Configuration(_)
822 ));
823 assert!(matches!(
824 Client::builder("https://panel.example.com")
825 .unwrap()
826 .bearer_token(" ")
827 .build()
828 .unwrap_err(),
829 Error::Configuration(_)
830 ));
831 }
832
833 #[test]
834 fn debug_output_never_contains_bearer_token_or_secret_base_path() {
835 let builder = Client::builder("https://panel.example.com/private-panel-path")
836 .unwrap()
837 .bearer_token("super-secret-token");
838 let builder_debug = format!("{builder:?}");
839 assert!(!builder_debug.contains("super-secret-token"));
840 assert!(!builder_debug.contains("private-panel-path"));
841 assert!(builder_debug.contains("panel.example.com"));
842 assert!(builder_debug.contains("BearerToken"));
843
844 let client = builder.build().unwrap();
845 let debug = format!("{client:?}");
846 assert!(!debug.contains("super-secret-token"));
847 assert!(!debug.contains("private-panel-path"));
848 assert!(debug.contains("panel.example.com"));
849 assert!(debug.contains("BearerToken"));
850 }
851
852 #[tokio::test]
853 async fn bearer_api_request_skips_csrf() {
854 let server = MockServer::start().await;
855 Mock::given(matchers::method("GET"))
856 .and(matchers::path("/panel/api/example"))
857 .and(matchers::header("authorization", "Bearer api-secret"))
858 .and(matchers::header("x-requested-with", "XMLHttpRequest"))
859 .respond_with(
860 ResponseTemplate::new(200)
861 .set_body_json(serde_json::json!({ "success": true, "obj": 42 })),
862 )
863 .expect(1)
864 .mount(&server)
865 .await;
866 let client = Client::builder(server.uri())
867 .unwrap()
868 .bearer_token("api-secret")
869 .build()
870 .unwrap();
871
872 let response = client
873 .execute::<u32, ()>(
874 Method::GET,
875 "panel/api/example",
876 None,
877 AuthenticationScope::PanelApi,
878 )
879 .await
880 .unwrap();
881
882 assert_eq!(response.obj, Some(42));
883 }
884}