From 2a1fdaba01a1b5fdf2b8dd9528ccaa09120d98c9 Mon Sep 17 00:00:00 2001 From: xaneets Date: Tue, 30 Dec 2025 22:57:35 +0300 Subject: [PATCH 1/2] improved types, docs, client retry options --- .gitignore | 4 +- Cargo.toml | 3 +- README.md | 166 +++++++++++++++- src/client.rs | 470 ++++++++++++++++++++++++++++++++++--------- src/error.rs | 2 + src/inbounds.rs | 47 ++++- src/lib.rs | 20 +- src/models.rs | 516 ++++++++++++++++++++++++++++++++++++++++++++++-- tests/e2e.rs | 167 +++++++++++----- tests/enums.rs | 13 ++ 10 files changed, 1240 insertions(+), 168 deletions(-) create mode 100644 tests/enums.rs diff --git a/.gitignore b/.gitignore index 3785c33..638a92c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ Cargo.lock **/*.rs.bk -.idea/ \ No newline at end of file +.idea/ + +3X-UI.postman_collection.json \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ca7b758..5f01bfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustix3" -version = "0.5.0" +version = "0.6.0" edition = "2024" authors = ["Dmitriy Sergeev "] description = "API lib for 3x-ui panel" @@ -21,6 +21,7 @@ serde_json = "1.0.138" serde_path_to_error = "0.1.17" serde_with = { version = "3.14.0", features = ["json"] } thiserror = "2.0.11" +tokio = { version = "1", features = ["time"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/README.md b/README.md index df8903e..a96fffd 100644 --- a/README.md +++ b/README.md @@ -90,4 +90,168 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -``` \ No newline at end of file +``` + +## Configure retry and timeouts + +```rust +use rustix3::{Client, ClientOptions}; +use reqwest::Method; +use std::time::Duration; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut options = ClientOptions::default(); + options.retry_count = 3; + options.retry_base_delay = Duration::from_millis(300); + options.retry_max_delay = Duration::from_secs(3); + options.retry_methods = vec![Method::GET, Method::HEAD]; + options.connect_timeout = Duration::from_secs(5); + options.request_timeout = Duration::from_secs(20); + + let client = Client::new_with_options("admin", "admin", "http://127.0.0.1:2053/", options).await?; + let _ = client.get_inbounds_list().await?; + Ok(()) +} +``` + +## Error handling + +All API responses use a `success/msg/obj` envelope. When `success=false`, the client returns +`Error::ApiError { message }` with the server-provided `msg`. + +Network and protocol errors are mapped to: +- `Error::InvalidUrl` for malformed base URL +- `Error::NotFound` for HTTP 404 +- `Error::Connection` for other reqwest failures +- `Error::JsonVerbose` for JSON decoding errors (includes JSON path) + +Example: + +```rust +use rustix3::Error; + +match client.get_inbounds_list().await { + Ok(inbounds) => println!("count={}", inbounds.len()), + Err(Error::ApiError { message }) => eprintln!("api error: {}", message), + Err(e) => eprintln!("request error: {}", e), +} +``` + +--- + +## Create inbound example + +```rust +use rustix3::client::Client; +use rustix3::inbounds::InboundProtocols; +use rustix3::inbounds::TransportProtocol; +use rustix3::models::{CreateInboundRequest, SettingsRequest, Fallback, Sniffing, StreamSettings, TcpHeader, TcpSettings}; +use serde_json::json; + +fn default_stream_settings() -> StreamSettings { + StreamSettings { + network: Some(TransportProtocol::Tcp), + security: Some("none".into()), + external_proxy: Some(Vec::new()), + tcp_settings: Some(TcpSettings { + accept_proxy_protocol: Some(false), + header: Some(TcpHeader { + header_type: Some("none".into()), + extra: Default::default(), + }), + extra: Default::default(), + }), + ws_settings: None, + grpc_settings: None, + kcp_settings: None, + http_upgrade_settings: None, + xhttp_settings: None, + extra: Default::default(), + } +} + +fn default_sniffing() -> Sniffing { + Sniffing { + enabled: false, + dest_override: vec![ + rustix3::inbounds::SniffingOption::Http, + rustix3::inbounds::SniffingOption::Tls, + rustix3::inbounds::SniffingOption::Quic, + rustix3::inbounds::SniffingOption::FakeDns, + ], + metadata_only: false, + route_only: false, + extra: Default::default(), + } +} + +fn default_allocate() -> serde_json::Value { + json!({ + "strategy": "always", + "refresh": 5, + "concurrency": 3 + }) +} + +async fn create_inbound(client: &Client) -> anyhow::Result<()> { + let req = CreateInboundRequest { + up: 0, + down: 0, + total: 0, + remark: "example-inbound".into(), + enable: true, + expiry_time: 0, + listen: "0.0.0.0".into(), + port: 31001, + protocol: InboundProtocols::Vless, + settings: SettingsRequest { + clients: vec![], + decryption: Some("none".into()), + encryption: Some("none".into()), + fallbacks: Vec::::new(), + }, + stream_settings: default_stream_settings(), + sniffing: default_sniffing(), + allocate: default_allocate(), + }; + + let _created = client.add_inbound(&req).await?; + Ok(()) +} +``` + +## Add client example + +```rust +use rustix3::client::Client; +use rustix3::models::{ClientRequest, ClientSettings, UserRequest, TgId}; +use uuid::Uuid; + +async fn add_client(client: &Client, inbound_id: u64) -> anyhow::Result<()> { + let user_id = Uuid::new_v4().to_string(); + let email = format!("{user_id}@example.com"); + let sub_id = Uuid::new_v4().simple().to_string(); + + let user = UserRequest { + id: user_id, + flow: String::new(), + email, + limit_ip: 2, + total_gb: 100, + expiry_time: 0, + enable: true, + tg_id: Some(TgId::Int(0)), + sub_id, + reset: 0, + }; + + let req = ClientRequest { + id: inbound_id, + settings: ClientSettings { clients: vec![user] }, + }; + + client.add_client_to_inbound(&req).await?; + Ok(()) +} +``` diff --git a/src/client.rs b/src/client.rs index a866791..2f6eaa6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,49 +1,96 @@ #![allow(dead_code)] use super::{ - ClientIpsResponse, ClientsStatsResponse, ClientsStatsVecResponse, CpuHistoryResponse, - DeleteInboundResponse, InboundResponse, InboundsResponse, JsonResponse, NullObjectResponse, - OnlineClientsResponse, OptStringVecResponse, Result, StringResponse, StringVecResponse, - UuidResponse, + ClientIpsResponse, ClientsStatsResponse, ClientsStatsVecResponse, ConfigJsonResponse, + CpuHistoryResponse, DeleteInboundResponse, EchCertResponse, InboundResponse, InboundsResponse, + LoginResponse, Mldsa65Response, Mlkem768Response, NullObjectResponse, OnlineClientsResponse, + OptStringVecResponse, Result, ServerStatusResponse, StringResponse, StringVecResponse, + UuidResponse, VlessEncResponse, X25519CertResponse, }; use crate::error::Error; use crate::models::{ - ClientRequest, ClientStats, CpuHistoryPoint, CreateInboundRequest, Inbounds, Uuid, + ClientRequest, ClientStats, ConfigJson, CpuHistoryPoint, CreateInboundRequest, EchCert, + Inbounds, LoginInfo, Mldsa65, Mlkem768, ServerStatus, Uuid, VlessEnc, X25519Cert, }; use crate::response_ext::ResponseJsonVerboseExt; use log::debug; +use reqwest::header::RETRY_AFTER; use reqwest::multipart::{Form, Part}; -use reqwest::{Client as RClient, IntoUrl, StatusCode, Url}; +use reqwest::{Client as RClient, IntoUrl, Method, StatusCode, Url}; use serde::Serialize; -use serde_json::Value; +use tokio::time::{Duration, sleep}; + +/// Client configuration for retry policy and timeouts. +#[derive(Debug, Clone)] +pub struct ClientOptions { + pub retry_count: u32, + pub retry_base_delay: Duration, + pub retry_max_delay: Duration, + pub retry_methods: Vec, + pub connect_timeout: Duration, + pub request_timeout: Duration, +} -type LoginResponse = NullObjectResponse; +impl Default for ClientOptions { + fn default() -> Self { + Self { + retry_count: 2, + retry_base_delay: Duration::from_millis(200), + retry_max_delay: Duration::from_secs(2), + retry_methods: vec![Method::GET, Method::HEAD], + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(30), + } + } +} +/// Result of a login attempt. +#[derive(Debug, Clone)] +pub struct LoginResult { + pub message: String, + pub details: Option, +} + +/// API client for 3x-ui panel. #[derive(Debug)] pub struct Client { username: String, password: String, url: Url, client: RClient, + options: ClientOptions, } impl Client { + /// Create a client with default retry policy and timeouts. pub async fn new( username: impl Into, password: impl Into, url: impl IntoUrl, + ) -> Result { + Self::new_with_options(username, password, url, ClientOptions::default()).await + } + + /// Create a client with custom retry policy and timeouts. + pub async fn new_with_options( + username: impl Into, + password: impl Into, + url: impl IntoUrl, + options: ClientOptions, ) -> Result { let client = Self { username: username.into(), password: password.into(), url: url.into_url()?, - client: RClient::builder().cookie_store(true).build()?, + client: RClient::builder() + .cookie_store(true) + .connect_timeout(options.connect_timeout) + .timeout(options.request_timeout) + .build()?, + options, }; debug!("{:?}", client); - let res = client.login().await?; - if res.is_err() { - return Err(Error::InvalidCred); - } + let _ = client.login().await?; Ok(client) } @@ -71,7 +118,7 @@ impl Client { self.gen_url_with_base(&base_segs, segs) } - async fn login(&self) -> Result { + async fn login(&self) -> Result { #[derive(Serialize)] struct LoginRequest { username: String, @@ -83,122 +130,140 @@ impl Client { }; debug!("Sending login request!"); + let json_url = self.url.clone().join("login").unwrap(); let response = self - .client - .post(self.url.clone().join("login").unwrap()) - .json(&body) - .send() + .send_with_retry(self.client.post(json_url).json(&body)) .await?; match response.status() { - StatusCode::NOT_FOUND => { - return Err(Error::NotFound(response.error_for_status().unwrap_err())); + StatusCode::NOT_FOUND | StatusCode::UNSUPPORTED_MEDIA_TYPE => { + let form_url = self.url.clone().join("login/").unwrap(); + let form = Form::new() + .text("username", self.username.clone()) + .text("password", self.password.clone()) + .text("twoFactorCode", String::new()); + let form_response = self + .send_with_retry(self.client.post(form_url).multipart(form)) + .await?; + match form_response.status() { + StatusCode::NOT_FOUND => { + return Err(Error::NotFound( + form_response.error_for_status().unwrap_err(), + )); + } + StatusCode::OK => {} + e => { + log::warn!("Unimplemented handle err{:?}", e) + } + } + let login: LoginResponse = form_response.json().await?; + let message = login.message.clone(); + let details = login.into_result()?; + return Ok(LoginResult { message, details }); } StatusCode::OK => {} e => { log::warn!("Unimplemented handle err{:?}", e) } } - Ok(response.json().await?) + let login: LoginResponse = response.json().await?; + let message = login.message.clone(); + let details = login.into_result()?; + Ok(LoginResult { message, details }) } + /// List all inbounds. pub async fn get_inbounds_list(&self) -> Result> { let path = vec!["list"]; let res: InboundsResponse = self - .client - .get(self.gen_inbounds_url(path)?) - .send() + .send_with_retry(self.client.get(self.gen_inbounds_url(path)?)) .await? .json_verbose() .await?; res.into_result() } + /// Get inbound by id. pub async fn get_inbound_by_id(&self, inbound_id: u64) -> Result { let id = inbound_id.to_string(); let path = vec!["get", &id]; let res: InboundResponse = self - .client - .get(self.gen_inbounds_url(path)?) - .send() + .send_with_retry(self.client.get(self.gen_inbounds_url(path)?)) .await? .json_verbose() .await?; res.into_result() } + /// Get client traffic by email. pub async fn get_client_traffic_by_email(&self, email: impl AsRef) -> Result { let path = vec!["getClientTraffics", email.as_ref()]; let res: ClientsStatsResponse = self - .client - .get(self.gen_inbounds_url(path)?) - .send() + .send_with_retry(self.client.get(self.gen_inbounds_url(path)?)) .await? .json_verbose() .await?; // todo check is null return user not found res.into_result() } + /// Get client traffic by id. pub async fn get_client_traffic_by_id(&self, id: impl AsRef) -> Result> { // todo id to uuid let id = id.as_ref(); let path = vec!["getClientTrafficsById", id]; let res: ClientsStatsVecResponse = self - .client - .get(self.gen_inbounds_url(path)?) - .send() + .send_with_retry(self.client.get(self.gen_inbounds_url(path)?)) .await? .json_verbose() .await?; res.into_result() } + /// Trigger backup via bot. pub async fn send_backup_by_bot(&self) -> Result<()> { // todo tests let path = vec!["createbackup"]; - let res = self.client.get(self.gen_inbounds_url(path)?).send().await?; + let res = self + .send_with_retry(self.client.get(self.gen_inbounds_url(path)?)) + .await?; if res.status() != StatusCode::OK { return Err(Error::OtherError("Todo".into())); } Ok(()) } + /// Get client IPs by email. pub async fn get_client_ips(&self, client_email: impl AsRef) -> Result { // todo tests let path = vec!["clientIps", client_email.as_ref()]; let res = self - .client - .post(self.gen_inbounds_url(path)?) - .send() + .send_with_retry(self.client.post(self.gen_inbounds_url(path)?)) .await?; res.json_verbose().await.map_err(Into::into) } + /// Create inbound. pub async fn add_inbound(&self, req: &CreateInboundRequest) -> Result { let url = self.gen_inbounds_url(vec!["add"])?; let res: InboundResponse = self - .client - .post(url) - .json(req) - .send() + .send_with_retry(self.client.post(url).json(req)) .await? .json_verbose() .await?; res.into_result() } + /// Add client(s) to inbound. pub async fn add_client_to_inbound(&self, req: &ClientRequest) -> Result> { let url = self.gen_inbounds_url(vec!["addClient"])?; let res: NullObjectResponse = self - .client - .post(url) - .json(req) - .send() + .send_with_retry(self.client.post(url).json(req)) .await? .json_verbose() .await?; res.into_result() } + /// Update inbound. pub async fn update_inbound( &self, inbound_id: u64, @@ -206,226 +271,435 @@ impl Client { ) -> Result { let url = self.gen_inbounds_url(vec!["update", &inbound_id.to_string()])?; let res: InboundResponse = self - .client - .post(url) - .json(req) - .send() + .send_with_retry(self.client.post(url).json(req)) .await? .json_verbose() .await?; res.into_result() } + /// Update client by UUID. pub async fn update_client(&self, uuid: &str, req: &ClientRequest) -> Result> { let url = self.gen_inbounds_url(vec!["updateClient", uuid])?; let res: NullObjectResponse = self - .client - .post(url) - .json(req) - .send() + .send_with_retry(self.client.post(url).json(req)) .await? .json_verbose() .await?; res.into_result() } + /// Clear client IPs by email. pub async fn clear_client_ips(&self, email: &str) -> Result> { let url = self.gen_inbounds_url(vec!["clearClientIps", email])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Reset all inbound traffics. pub async fn reset_all_inbound_traffics(&self) -> Result> { let url = self.gen_inbounds_url(vec!["resetAllTraffics"])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Reset all client traffics for inbound. pub async fn reset_all_client_traffics(&self, inbound_id: u64) -> Result> { let url = self.gen_inbounds_url(vec!["resetAllClientTraffics", &inbound_id.to_string()])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Reset client traffic by email. pub async fn reset_client_traffic(&self, inbound_id: u64, email: &str) -> Result> { let url = self.gen_inbounds_url(vec![&inbound_id.to_string(), "resetClientTraffic", email])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Delete client by UUID. pub async fn delete_client(&self, inbound_id: u64, uuid: &str) -> Result> { let url = self.gen_inbounds_url(vec![&inbound_id.to_string(), "delClient", uuid])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Delete inbound by id. pub async fn delete_inbound(&self, inbound_id: u64) -> Result { let url = self.gen_inbounds_url(vec!["del", &inbound_id.to_string()])?; - let res: DeleteInboundResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: DeleteInboundResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Delete depleted clients by inbound. pub async fn delete_depleted_clients(&self, inbound_id: u64) -> Result> { let url = self.gen_inbounds_url(vec!["delDepletedClients", &inbound_id.to_string()])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// List online clients. pub async fn online_clients(&self) -> Result>> { let url = self.gen_inbounds_url(vec!["onlines"])?; - let res: OnlineClientsResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: OnlineClientsResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Import inbound. pub async fn import_inbound(&self, inbound: &Inbounds) -> Result { let url = self.gen_inbounds_url(vec!["import"])?; let json_str = serde_json::to_string(inbound) .map_err(|e| Error::OtherError(format!("serialize inbound: {e}")))?; let form = Form::new().text("data", json_str); let res: InboundResponse = self - .client - .post(url) - .multipart(form) - .send() + .send_with_retry(self.client.post(url).multipart(form)) .await? .json_verbose() .await?; res.into_result() } + /// Get last online clients. pub async fn get_last_online(&self) -> Result>> { let url = self.gen_inbounds_url(vec!["onlines"])?; - let res: OptStringVecResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: OptStringVecResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Delete client by email. pub async fn del_client_by_email(&self, inbound_id: u64, email: &str) -> Result> { let url = self.gen_inbounds_url(vec![&inbound_id.to_string(), "delClientByEmail", email])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn server_status(&self) -> Result { + /// Get server status. + pub async fn server_status(&self) -> Result> { let url = self.gen_server_url(vec!["status"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: ServerStatusResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Download server database. pub async fn server_get_db(&self) -> Result> { let url = self.gen_server_url(vec!["getDb"])?; - let res = self.client.get(url).send().await?; + let res = self.send_with_retry(self.client.get(url)).await?; Ok(res.bytes().await?.to_vec()) } - pub async fn get_xray_version(&self) -> Result> { + /// Get Xray versions. + pub async fn get_xray_version(&self) -> Result>> { let url = self.gen_server_url(vec!["getXrayVersion"])?; - let res: StringVecResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: OptStringVecResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_config_json(&self) -> Result { + /// Get config JSON. + pub async fn get_config_json(&self) -> Result { let url = self.gen_server_url(vec!["getConfigJson"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: ConfigJsonResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn cpu_history(&self, minutes: u32) -> Result> { + /// Get CPU history for a time bucket. + pub async fn cpu_history(&self, minutes: u32) -> Result>> { let url = self.gen_server_url(vec!["cpuHistory", &minutes.to_string()])?; - let res: CpuHistoryResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: CpuHistoryResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Request a new UUID. pub async fn get_new_uuid(&self) -> Result { let url = self.gen_server_url(vec!["getNewUUID"])?; - let res: UuidResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: UuidResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_new_x25519_cert(&self) -> Result { + /// Request a new X25519 certificate. + pub async fn get_new_x25519_cert(&self) -> Result { let url = self.gen_server_url(vec!["getNewX25519Cert"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: X25519CertResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_new_mldsa65(&self) -> Result { + /// Request a new MLDsa65 bundle. + pub async fn get_new_mldsa65(&self) -> Result { let url = self.gen_server_url(vec!["getNewmldsa65"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: Mldsa65Response = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_new_mlkem768(&self) -> Result { + /// Request a new MLKEM768 bundle. + pub async fn get_new_mlkem768(&self) -> Result { let url = self.gen_server_url(vec!["getNewmlkem768"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: Mlkem768Response = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_new_vless_enc(&self) -> Result { + /// Request VLESS encryption settings. + pub async fn get_new_vless_enc(&self) -> Result { let url = self.gen_server_url(vec!["getNewVlessEnc"])?; - let res: JsonResponse = self.client.get(url).send().await?.json_verbose().await?; + let res: VlessEncResponse = self + .send_with_retry(self.client.get(url)) + .await? + .json_verbose() + .await?; res.into_result() } - pub async fn get_new_ech_cert(&self) -> Result { + /// Request a new ECH certificate. + pub async fn get_new_ech_cert(&self) -> Result { let url = self.gen_server_url(vec!["getNewEchCert"])?; - let res: JsonResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: EchCertResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Stop Xray service. pub async fn stop_xray_service(&self) -> Result> { let url = self.gen_server_url(vec!["stopXrayService"])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Restart Xray service. pub async fn restart_xray_service(&self) -> Result> { let url = self.gen_server_url(vec!["restartXrayService"])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Install Xray version. pub async fn install_xray_version(&self, version: &str) -> Result> { let url = self.gen_server_url(vec!["installXray", version])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Update geofile bundle. pub async fn update_geofile(&self) -> Result> { let url = self.gen_server_url(vec!["updateGeofile"])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Update geofile by name. pub async fn update_geofile_by_name(&self, file_name: &str) -> Result> { let url = self.gen_server_url(vec!["updateGeofile", file_name])?; - let res: NullObjectResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: NullObjectResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Fetch server logs. pub async fn logs(&self, count: u32) -> Result> { let url = self.gen_server_url(vec!["logs", &count.to_string()])?; - let res: StringVecResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: StringVecResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Fetch Xray logs. pub async fn xray_logs(&self, count: u32) -> Result>> { let url = self.gen_server_url(vec!["xraylogs", &count.to_string()])?; - let res: OptStringVecResponse = self.client.post(url).send().await?.json_verbose().await?; + let res: OptStringVecResponse = self + .send_with_retry(self.client.post(url)) + .await? + .json_verbose() + .await?; res.into_result() } + /// Import DB upload. pub async fn import_db_upload(&self, filename: &str, bytes: Vec) -> Result { let url = self.gen_server_url(vec!["importDB"])?; let form = Form::new().part("db", Part::bytes(bytes).file_name(filename.to_string())); let res: StringResponse = self - .client - .post(url) - .multipart(form) - .send() + .send_with_retry(self.client.post(url).multipart(form)) .await? .json_verbose() .await?; res.into_result() } + + async fn send_with_retry(&self, builder: reqwest::RequestBuilder) -> Result { + if self.options.retry_count == 0 || builder.try_clone().is_none() { + return Ok(builder.send().await?); + } + + let mut last_err: Option = None; + for attempt in 0..=self.options.retry_count { + let cloned = builder + .try_clone() + .ok_or_else(|| Error::OtherError("request is not clonable for retry".into()))?; + let request = cloned.build()?; + let method = request.method().clone(); + let response = self.client.execute(request).await; + match response { + Ok(resp) => { + if attempt < self.options.retry_count + && self.should_retry_status(&method, &resp) + { + let delay = self.retry_delay(attempt, resp.headers().get(RETRY_AFTER)); + sleep(delay).await; + continue; + } + return Ok(resp); + } + Err(err) => { + if attempt < self.options.retry_count && self.should_retry_error(&method, &err) + { + last_err = Some(err); + sleep(self.retry_delay(attempt, None)).await; + continue; + } + return Err(err.into()); + } + } + } + + if let Some(err) = last_err { + return Err(err.into()); + } + Err(Error::OtherError("request retry failed".into())) + } + + fn should_retry_status(&self, method: &Method, resp: &reqwest::Response) -> bool { + if !self.is_idempotent(method) { + return false; + } + let status = resp.status(); + status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() + } + + fn should_retry_error(&self, method: &Method, err: &reqwest::Error) -> bool { + if !self.is_idempotent(method) { + return false; + } + err.is_timeout() || err.is_connect() + } + + fn is_idempotent(&self, method: &Method) -> bool { + self.options.retry_methods.iter().any(|m| m == method) + } + + fn retry_delay( + &self, + attempt: u32, + retry_after: Option<&reqwest::header::HeaderValue>, + ) -> Duration { + if let Some(value) = retry_after + && let Ok(s) = value.to_str() + && let Ok(secs) = s.parse::() + { + return Duration::from_secs(secs); + } + let backoff = 1u64.checked_shl(attempt).unwrap_or(u64::MAX); + let ms = self + .options + .retry_base_delay + .as_millis() + .saturating_mul(backoff as u128) + .min(self.options.retry_max_delay.as_millis()); + Duration::from_millis(ms as u64) + } } diff --git a/src/error.rs b/src/error.rs index 67f3aaa..d6873ea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,8 @@ pub enum Error { Connection(#[source] reqwest::Error), #[error("Invalid credentials!")] InvalidCred, + #[error("API error: {message}")] + ApiError { message: String }, #[error("Error: {0}!")] OtherError(String), #[error(transparent)] diff --git a/src/inbounds.rs b/src/inbounds.rs index 74fafea..d607329 100644 --- a/src/inbounds.rs +++ b/src/inbounds.rs @@ -1,20 +1,25 @@ use serde::{Deserialize, Serialize}; #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum InboundProtocols { Vmess, Vless, Trojan, + #[serde(rename = "shadowsocks")] ShadowsSocks, #[serde(rename = "dokodemo-door")] DokodemoDoor, Socks, Http, Wireguard, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum TransportProtocol { Tcp, @@ -25,9 +30,12 @@ pub enum TransportProtocol { GRPC, HTTPUpgrade, XHTTP, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] pub enum SSMethods { #[serde(rename = "aes-256-gcm")] AES256Gcm, @@ -47,17 +55,23 @@ pub enum SSMethods { Blake3Aes256Gcm, #[serde(rename = "2022-blake3-chacha20-poly1305")] Blake3Chacha20Poly1305, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] pub enum TlsFlowControl { #[serde(rename = "xtls-rprx-vision")] Vision, #[serde(rename = "xtls-rprx-vision-udp443")] VisionUdp443, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] pub enum TlsVersionOption { #[serde(rename = "1.0")] TLS10, @@ -67,9 +81,12 @@ pub enum TlsVersionOption { TLS12, #[serde(rename = "1.3")] TLS13, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] pub enum TlsCipherOption { #[serde(rename = "TLS_AES_128_GCM_SHA256")] AES128Gcm, @@ -97,9 +114,12 @@ pub enum TlsCipherOption { EcdheEcdsaChacha20Poly1305, #[serde(rename = "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256")] EcdheRsaChacha20Poly1305, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] pub enum UtlsFingerprint { #[serde(rename = "chrome")] UtlsChrome, @@ -125,36 +145,47 @@ pub enum UtlsFingerprint { UtlsRandomizedNoAlpn, #[serde(rename = "unsafe")] UtlsUnsafe, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum AlpnOption { H3, H2, #[serde(rename = "http/1.1")] Http1, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum SniffingOption { Http, Tls, Quic, FakeDns, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum UsageOption { Encipherment, Verify, Issue, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] - +#[non_exhaustive] pub enum DomainStrategyOption { #[serde(rename = "AsIs")] AsIs, @@ -178,17 +209,23 @@ pub enum DomainStrategyOption { ForceIpv4v6, #[serde(rename = "ForceIPv4")] ForceIpv4, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum TcpCongestionOption { Bbr, Cubic, Reno, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "lowercase")] pub enum UsersSecurity { #[serde(rename = "aes-128-gcm")] @@ -198,18 +235,24 @@ pub enum UsersSecurity { Auto, None, Zero, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[serde(rename_all = "kebab-case")] pub enum ModeOption { Auto, PacketUp, StreamUp, StreamOne, + #[serde(other)] + Unknown, } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[non_exhaustive] #[allow(non_camel_case_types)] pub enum StreamSettings { TlsStreamSettings, @@ -220,4 +263,6 @@ pub enum StreamSettings { GrpcStreamSettings, HttpUpgradeStreamSettings, xHTTPStreamSettings, + #[serde(other)] + Unknown, } diff --git a/src/lib.rs b/src/lib.rs index c43c0e5..c8881f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,14 @@ +#![doc = include_str!("../README.md")] extern crate core; use crate::error::Error; -use crate::models::{CpuHistoryPoint, Response, Uuid}; +use crate::models::{ + ClientIps, ConfigJson, CpuHistoryPoint, EchCert, LoginInfo, Mldsa65, Mlkem768, Response, + ServerStatus, Uuid, VlessEnc, X25519Cert, +}; pub use client::Client; +pub use client::ClientOptions; +pub use client::LoginResult; use models::{ClientStats, Inbounds}; use serde_json::Value; @@ -19,12 +25,20 @@ pub type InboundsResponse = Response>; pub type InboundResponse = Response; pub type ClientsStatsVecResponse = Response>; pub type ClientsStatsResponse = Response; -pub type ClientIpsResponse = Response; // todo ip struct | result [ip, ip] or No ip record string custom deserializer +pub type ClientIpsResponse = Response; pub type DeleteInboundResponse = Response; pub type OnlineClientsResponse = Response>>; pub type StringResponse = Response; pub type JsonResponse = Response; pub type OptStringVecResponse = Response>>; pub type StringVecResponse = Response>; -pub type CpuHistoryResponse = Response>; +pub type CpuHistoryResponse = Response>>; pub type UuidResponse = Response; +pub type ServerStatusResponse = Response>; +pub type ConfigJsonResponse = Response; +pub type X25519CertResponse = Response; +pub type Mldsa65Response = Response; +pub type Mlkem768Response = Response; +pub type VlessEncResponse = Response; +pub type EchCertResponse = Response; +pub type LoginResponse = Response>; diff --git a/src/models.rs b/src/models.rs index fe789f4..9be5350 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,7 @@ -use crate::inbounds::InboundProtocols; +use crate::inbounds::{InboundProtocols, SniffingOption, TransportProtocol}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_with::{json::JsonString, serde_as}; +use std::collections::BTreeMap; use std::ops::Not; #[derive(Debug, Deserialize, Clone)] @@ -25,7 +26,12 @@ impl Response { if self.success { Ok(self.object) } else { - Err(crate::error::Error::OtherError(self.message)) + let msg = if self.message.is_empty() { + "Unknown API error".to_string() + } else { + self.message + }; + Err(crate::error::Error::ApiError { message: msg }) } } } @@ -37,12 +43,19 @@ pub struct ClientStats { pub inbound_id: u64, pub enable: bool, pub email: String, + pub uuid: Option, + #[serde(rename = "subId")] + pub sub_id: Option, pub up: u128, pub down: u128, + #[serde(rename = "allTime")] + pub all_time: Option, #[serde(rename = "expiryTime")] pub expiry_time: i64, // todo pub total: u128, pub reset: i64, + #[serde(rename = "lastOnline")] + pub last_online: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -51,13 +64,19 @@ pub struct Inbounds { pub up: u128, pub down: u128, pub total: u128, + #[serde(rename = "allTime")] + pub all_time: Option, pub remark: String, pub enable: bool, #[serde(rename = "expiryTime")] pub expiry_time: i64, + #[serde(rename = "trafficReset")] + pub traffic_reset: Option, + #[serde(rename = "lastTrafficResetTime")] + pub last_traffic_reset_time: Option, #[serde(rename = "clientStats")] pub client_stats: Option>, - pub listen: String, + pub listen: Option, pub port: u16, pub protocol: InboundProtocols, #[serde( @@ -65,11 +84,25 @@ pub struct Inbounds { serialize_with = "se_settings_as_str" )] pub settings: Settings, - #[serde(rename = "streamSettings")] - pub stream_settings: String, // todo + #[serde( + rename = "streamSettings", + deserialize_with = "de_json_opt_from_str_or_map", + serialize_with = "se_json_opt_as_str" + )] + pub stream_settings: Option, pub tag: String, - pub sniffing: String, // todo - pub allocate: Option, // todo + #[serde( + default, + deserialize_with = "de_json_opt_from_str_or_map", + serialize_with = "se_json_opt_as_str" + )] + pub sniffing: Option, + #[serde( + default, + deserialize_with = "de_json_opt_from_str_or_map", + serialize_with = "se_json_opt_as_str" + )] + pub allocate: Option, } #[serde_as] @@ -86,17 +119,143 @@ pub struct CreateInboundRequest { pub port: u16, pub protocol: InboundProtocols, #[serde_as(as = "JsonString<_>")] - pub settings: Settings, + pub settings: SettingsRequest, + #[serde_as(as = "JsonString<_>")] #[serde(rename = "streamSettings")] - pub stream_settings: String, - pub sniffing: String, - pub allocate: String, + pub stream_settings: StreamSettings, + #[serde_as(as = "JsonString<_>")] + pub sniffing: Sniffing, + #[serde_as(as = "JsonString<_>")] + pub allocate: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StreamSettings { + pub network: Option, + pub security: Option, + #[serde(rename = "externalProxy")] + pub external_proxy: Option>, + #[serde(rename = "tcpSettings")] + pub tcp_settings: Option, + #[serde(rename = "wsSettings")] + pub ws_settings: Option, + #[serde(rename = "grpcSettings")] + pub grpc_settings: Option, + #[serde(rename = "kcpSettings")] + pub kcp_settings: Option, + #[serde(rename = "httpUpgradeSettings")] + pub http_upgrade_settings: Option, + #[serde(rename = "xhttpSettings")] + pub xhttp_settings: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TcpSettings { + pub accept_proxy_protocol: Option, + pub header: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TcpHeader { + #[serde(rename = "type")] + pub header_type: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketSettings { + pub path: Option, + pub headers: Option>, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GrpcSettings { + pub service_name: Option, + pub multi_mode: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct KcpSettings { + pub mtu: Option, + pub tti: Option, + pub uplink_capacity: Option, + pub downlink_capacity: Option, + pub congestion: Option, + pub read_buffer_size: Option, + pub write_buffer_size: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HttpUpgradeSettings { + pub host: Option, + pub path: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct XHttpSettings { + pub host: Option, + pub path: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Sniffing { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + #[serde(rename = "destOverride")] + pub dest_override: Vec, + #[serde(default)] + pub metadata_only: bool, + #[serde(default)] + pub route_only: bool, + #[serde(flatten)] + pub extra: BTreeMap, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Settings { + #[serde(default)] pub clients: Vec, - pub decryption: String, // todo + #[serde(default)] + pub decryption: Option, + #[serde(default)] + pub encryption: Option, + #[serde(default)] + pub fallbacks: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SettingsRequest { + #[serde(default)] + pub clients: Vec, + #[serde(default)] + pub decryption: Option, + #[serde(default)] + pub encryption: Option, + #[serde(default)] pub fallbacks: Vec, } @@ -124,9 +283,92 @@ where s.serialize_str(&json) } +fn de_json_opt_from_str_or_map<'de, D, T>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Str(String), + Map(T), + } + let opt = Option::>::deserialize(d)?; + match opt { + None => Ok(None), + Some(Wire::Str(s)) => serde_json::from_str(&s).map_err(serde::de::Error::custom), + Some(Wire::Map(m)) => Ok(Some(m)), + } +} + +fn se_json_opt_as_str(value: &Option, s: S) -> Result +where + S: Serializer, + T: Serialize, +{ + match value { + Some(val) => { + let json = serde_json::to_string(val).map_err(serde::ser::Error::custom)?; + s.serialize_some(&json) + } + None => s.serialize_none(), + } +} + +fn de_opt_num_from_str_or_num<'de, D, T>(d: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: serde::de::DeserializeOwned + std::str::FromStr, + ::Err: std::fmt::Display, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Str(String), + Num(T), + } + let opt = Option::>::deserialize(d)?; + match opt { + None => Ok(None), + Some(Wire::Str(s)) => s.parse::().map(Some).map_err(serde::de::Error::custom), + Some(Wire::Num(n)) => Ok(Some(n)), + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct User { + pub id: String, + pub email: String, + #[serde(default)] + pub flow: Option, + pub comment: Option, + #[serde(rename = "created_at")] + pub created_at: Option, + #[serde(rename = "updated_at")] + pub updated_at: Option, + pub password: Option, + #[serde(default)] + pub limit_ip: Option, + #[serde(rename = "totalGB")] + #[serde(default)] + pub total_gb: Option, + #[serde(default)] + pub expiry_time: Option, + #[serde(default)] + pub enable: Option, + #[serde(default)] + pub tg_id: Option, + #[serde(default)] + pub sub_id: Option, + #[serde(default)] + pub reset: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UserRequest { pub id: String, pub flow: String, pub email: String, @@ -135,7 +377,8 @@ pub struct User { pub total_gb: u32, pub expiry_time: u64, pub enable: bool, - pub tg_id: TgId, + #[serde(default)] + pub tg_id: Option, pub sub_id: String, pub reset: u32, } @@ -161,7 +404,7 @@ pub struct Fallback { #[derive(Debug, Serialize, Deserialize)] pub struct ClientSettings { - pub clients: Vec, + pub clients: Vec, } #[serde_as] @@ -178,7 +421,252 @@ pub struct CpuHistoryPoint { pub t: u64, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum ClientIps { + Ips(Vec), + Message(String), +} + +impl ClientIps { + pub fn as_ips(&self) -> Option<&[String]> { + match self { + ClientIps::Ips(v) => Some(v), + ClientIps::Message(_) => None, + } + } + + pub fn as_message(&self) -> Option<&str> { + match self { + ClientIps::Message(s) => Some(s), + ClientIps::Ips(_) => None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Uuid { pub uuid: uuid::Uuid, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LoginInfo { + pub token: Option, + #[serde(rename = "twoFactorEnabled")] + pub two_factor_enabled: Option, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + pub username: Option, + #[serde(rename = "role")] + pub role: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatus { + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub cpu: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + #[serde(rename = "cpuCores")] + pub cpu_cores: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + #[serde(rename = "logicalPro")] + pub logical_pro: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + #[serde(rename = "cpuSpeedMhz")] + pub cpu_speed_mhz: Option, + pub mem: Option, + pub swap: Option, + pub disk: Option, + pub xray: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub uptime: Option, + #[serde(default)] + pub loads: Vec, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + #[serde(rename = "tcpCount")] + pub tcp_count: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + #[serde(rename = "udpCount")] + pub udp_count: Option, + #[serde(rename = "netIO")] + pub net_io: Option, + #[serde(rename = "netTraffic")] + pub net_traffic: Option, + #[serde(rename = "publicIP")] + pub public_ip: Option, + #[serde(rename = "appStats")] + pub app_stats: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct MemStat { + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub current: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub total: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct XrayStatus { + pub state: Option, + #[serde(rename = "errorMsg")] + pub error_msg: Option, + pub version: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NetIo { + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub up: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub down: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NetTraffic { + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub sent: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub recv: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PublicIp { + pub ipv4: Option, + pub ipv6: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AppStats { + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub threads: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub mem: Option, + #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] + pub uptime: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct X25519Cert { + #[serde(rename = "privateKey")] + pub private_key: Option, + #[serde(rename = "publicKey")] + pub public_key: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Mldsa65 { + pub seed: Option, + pub verify: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Mlkem768 { + pub seed: Option, + pub verify: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VlessEnc { + #[serde(default)] + pub auths: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct VlessAuth { + pub decryption: Option, + pub encryption: Option, + pub label: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EchCert { + #[serde(rename = "echConfigList")] + pub ech_config_list: Option, + #[serde(rename = "echServerKeys")] + pub ech_server_keys: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigJson { + pub api: Option, + pub inbounds: Option>, + pub outbounds: Option>, + pub log: Option, + pub metrics: Option, + pub routing: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigApi { + #[serde(default)] + pub services: Vec, + pub tag: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConfigInbound { + pub listen: Option, + pub port: Option, + pub protocol: Option, + pub settings: Option, + pub sniffing: Option, + #[serde(rename = "streamSettings")] + pub stream_settings: Option, + pub tag: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigOutbound { + pub protocol: Option, + pub settings: Option, + pub tag: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConfigLog { + pub access: Option, + pub dns_log: Option, + pub error: Option, + pub loglevel: Option, + pub mask_address: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConfigMetrics { + pub listen: Option, + pub tag: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ConfigRouting { + pub domain_strategy: Option, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RoutingRule { + #[serde(rename = "type")] + pub rule_type: Option, + #[serde(rename = "inboundTag")] + pub inbound_tag: Option>, + #[serde(rename = "outboundTag")] + pub outbound_tag: Option, + pub ip: Option>, + pub domain: Option>, + pub port: Option, + pub protocol: Option>, + #[serde(flatten)] + pub extra: BTreeMap, +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 9a5df0f..d84cf3b 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,16 +1,74 @@ use anyhow::Context; use dotenv::dotenv; use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::time::{Duration, sleep}; use uuid::Uuid; use rustix3::models::TgId; use rustix3::{ client::Client, - inbounds::InboundProtocols, - models::{ClientRequest, ClientSettings, CreateInboundRequest, Fallback, Settings, User}, + inbounds::{InboundProtocols, SniffingOption, TransportProtocol}, + models::{ + ClientRequest, ClientSettings, CreateInboundRequest, Fallback, SettingsRequest, Sniffing, + StreamSettings, TcpHeader, TcpSettings, UserRequest, + }, }; +fn future_expiry_ms(days: u64) -> i64 { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_millis() as i64; + let delta = (days as i64) * 24 * 60 * 60 * 1000; + now_ms + delta +} + +fn default_stream_settings() -> StreamSettings { + StreamSettings { + network: Some(TransportProtocol::Tcp), + security: Some("none".into()), + external_proxy: Some(Vec::new()), + tcp_settings: Some(TcpSettings { + accept_proxy_protocol: Some(false), + header: Some(TcpHeader { + header_type: Some("none".into()), + extra: Default::default(), + }), + extra: Default::default(), + }), + ws_settings: None, + grpc_settings: None, + kcp_settings: None, + http_upgrade_settings: None, + xhttp_settings: None, + extra: Default::default(), + } +} + +fn default_sniffing() -> Sniffing { + Sniffing { + enabled: false, + dest_override: vec![ + SniffingOption::Http, + SniffingOption::Tls, + SniffingOption::Quic, + SniffingOption::FakeDns, + ], + metadata_only: false, + route_only: false, + extra: Default::default(), + } +} + +fn default_allocate() -> serde_json::Value { + json!({ + "strategy": "always", + "refresh": 5, + "concurrency": 3 + }) +} + #[tokio::test] async fn e2e_full_flow() -> anyhow::Result<()> { dotenv().ok(); @@ -28,24 +86,26 @@ async fn e2e_full_flow() -> anyhow::Result<()> { log::info!("list_before = {:#?}", list_before); let remark = format!("e2e-{}", Uuid::new_v4()); + let inbound_expiry = future_expiry_ms(30); let req = CreateInboundRequest { up: 0, down: 0, total: 0, remark: remark.clone(), enable: true, - expiry_time: 0, - listen: String::new(), + expiry_time: inbound_expiry, + listen: "0.0.0.0".into(), port: 31001, protocol: InboundProtocols::Vless, - settings: Settings { + settings: SettingsRequest { clients: vec![], - decryption: "none".into(), + decryption: Some("none".into()), + encryption: Some("none".into()), fallbacks: Vec::::new(), }, - stream_settings: "{}".into(), - sniffing: "{}".into(), - allocate: "{}".into(), + stream_settings: default_stream_settings(), + sniffing: default_sniffing(), + allocate: default_allocate(), }; let created = client.add_inbound(&req).await.context("add_inbound")?; @@ -71,16 +131,17 @@ async fn e2e_full_flow() -> anyhow::Result<()> { let cuuid = Uuid::new_v4().to_string(); let email = format!("{}@example.com", cuuid); - let user_obj = User { + let sub_id = Uuid::new_v4().simple().to_string(); + let user_obj = UserRequest { id: cuuid.clone(), flow: String::new(), email: email.clone(), - limit_ip: 0, - total_gb: 0, - expiry_time: 0, + limit_ip: 2, + total_gb: 100, + expiry_time: future_expiry_ms(14) as u64, enable: true, - tg_id: TgId::Int(0), - sub_id: String::new(), + tg_id: Some(TgId::Int(0)), + sub_id, reset: 0, }; let add_client_req = ClientRequest { @@ -147,16 +208,17 @@ async fn e2e_full_flow() -> anyhow::Result<()> { let cuuid = Uuid::new_v4().to_string(); let email = format!("{}@example.com", cuuid); - let user_obj = User { + let sub_id = Uuid::new_v4().simple().to_string(); + let user_obj = UserRequest { id: cuuid.clone(), flow: String::new(), email: email.clone(), - limit_ip: 0, - total_gb: 0, - expiry_time: 0, + limit_ip: 2, + total_gb: 100, + expiry_time: future_expiry_ms(14) as u64, enable: true, - tg_id: TgId::Int(0), - sub_id: String::new(), + tg_id: Some(TgId::Int(0)), + sub_id, reset: 0, }; let add_client_req = ClientRequest { @@ -201,53 +263,57 @@ async fn e2e_full_flow() -> anyhow::Result<()> { let cuuid = Uuid::new_v4().to_string(); let email = "testclient".to_string(); - let user_obj1 = User { + let sub_id = Uuid::new_v4().simple().to_string(); + let user_obj1 = UserRequest { id: cuuid.clone(), flow: String::new(), email: email.clone(), - limit_ip: 0, - total_gb: 0, - expiry_time: 0, + limit_ip: 2, + total_gb: 100, + expiry_time: future_expiry_ms(7) as u64, enable: true, - tg_id: TgId::Int(0), - sub_id: String::new(), + tg_id: Some(TgId::Int(0)), + sub_id, reset: 0, }; let cuuid = Uuid::new_v4().to_string(); let email = "testclient2".to_string(); - let user_obj2 = User { + let sub_id = Uuid::new_v4().simple().to_string(); + let user_obj2 = UserRequest { id: cuuid.clone(), flow: String::new(), email: email.clone(), - limit_ip: 0, - total_gb: 0, - expiry_time: 0, + limit_ip: 2, + total_gb: 100, + expiry_time: future_expiry_ms(7) as u64, enable: true, - tg_id: TgId::Int(0), - sub_id: String::new(), + tg_id: Some(TgId::Int(0)), + sub_id, reset: 0, }; let remark2 = format!("e2e-del-by-email-{}", Uuid::new_v4()); + let inbound_expiry = future_expiry_ms(30); let tmp_inb_req = CreateInboundRequest { up: 0, down: 0, total: 0, remark: remark2.clone(), enable: true, - expiry_time: 0, - listen: String::new(), + expiry_time: inbound_expiry, + listen: "0.0.0.0".into(), port: 31002, protocol: InboundProtocols::Vless, - settings: Settings { + settings: SettingsRequest { clients: vec![user_obj1, user_obj2], - decryption: "none".into(), + decryption: Some("none".into()), + encryption: Some("none".into()), fallbacks: Vec::::new(), }, - stream_settings: "{}".into(), - sniffing: "{}".into(), - allocate: "{}".into(), + stream_settings: default_stream_settings(), + sniffing: default_sniffing(), + allocate: default_allocate(), }; let tmp_created = client .add_inbound(&tmp_inb_req) @@ -282,14 +348,14 @@ async fn e2e_full_flow() -> anyhow::Result<()> { log::info!("imported_db = {:#?}", imported_db); let xver = client.get_xray_version().await.context("xray_version")?; - let current_version = xver.clone(); + let current_version = xver.clone().unwrap_or_default(); let cfg = client.get_config_json().await.context("get_config_json")?; log::info!("cfg = {:#?}", cfg); let cpu_hist = client.cpu_history(2).await.context("cpu_history_1min")?; // todo bucket - if let Some(first) = cpu_hist.first() { + if let Some(first) = cpu_hist.as_ref().and_then(|v| v.first()) { assert!(first.t > 0, "cpu history timestamp should be > 0"); } @@ -359,24 +425,26 @@ async fn e2e_full_flow() -> anyhow::Result<()> { log::info!("xlogs = {:#?}", xlogs); let remark = format!("e2e-{}", Uuid::new_v4()); + let inbound_expiry = future_expiry_ms(30); let req = CreateInboundRequest { up: 0, down: 0, total: 0, remark: remark.clone(), enable: true, - expiry_time: 0, - listen: String::new(), + expiry_time: inbound_expiry, + listen: "0.0.0.0".into(), port: 31001, protocol: InboundProtocols::Vless, - settings: Settings { + settings: SettingsRequest { clients: vec![], - decryption: "none".into(), + decryption: Some("none".into()), + encryption: Some("none".into()), fallbacks: Vec::::new(), }, - stream_settings: "{}".into(), - sniffing: "{}".into(), - allocate: "{}".into(), + stream_settings: default_stream_settings(), + sniffing: default_sniffing(), + allocate: default_allocate(), }; let created = client.add_inbound(&req).await.context("add_inbound")?; @@ -398,3 +466,4 @@ async fn e2e_full_flow() -> anyhow::Result<()> { log::info!("import_inbound = {:#?}", import_inb); Ok(()) } +use serde_json::json; diff --git a/tests/enums.rs b/tests/enums.rs new file mode 100644 index 0000000..378802f --- /dev/null +++ b/tests/enums.rs @@ -0,0 +1,13 @@ +use rustix3::inbounds::InboundProtocols; + +#[test] +fn inbound_protocol_shadowsocks_serializes() { + let json = serde_json::to_string(&InboundProtocols::ShadowsSocks).unwrap(); + assert_eq!(json, "\"shadowsocks\""); +} + +#[test] +fn inbound_protocol_unknown_deserializes() { + let val: InboundProtocols = serde_json::from_str("\"new-proto\"").unwrap(); + assert!(matches!(val, InboundProtocols::Unknown)); +} From a428fbb787900890966ab4e5d33096a985266cb4 Mon Sep 17 00:00:00 2001 From: xaneets Date: Tue, 30 Dec 2025 23:01:26 +0300 Subject: [PATCH 2/2] fix type --- src/models.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models.rs b/src/models.rs index 9be5350..9137f9c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -477,7 +477,7 @@ pub struct ServerStatus { pub logical_pro: Option, #[serde(default, deserialize_with = "de_opt_num_from_str_or_num")] #[serde(rename = "cpuSpeedMhz")] - pub cpu_speed_mhz: Option, + pub cpu_speed_mhz: Option, pub mem: Option, pub swap: Option, pub disk: Option,