From de592344ca93682cb9b7a474e3266585678924f8 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 16 Mar 2025 22:39:56 +0300 Subject: [PATCH 1/9] github actions, rust edition 2024 --- .github/workflows/rust.yml | 39 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..d0d65d3 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,39 @@ +name: Rust CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose diff --git a/Cargo.toml b/Cargo.toml index d053873..9f59cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rustix3" version = "0.2.0" -edition = "2021" +edition = "2024" authors = ["Dmitriy Sergeev "] description = "API lib for 3x-ui panel" license = "Apache-2.0" From 9172e0f89b8cabd274b72dd56f01efe81ec0dabc Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 18:38:38 +0300 Subject: [PATCH 2/9] try ci e2e test --- .github/workflows/e2e.yml | 66 ++++++++++++++++ Cargo.toml | 7 +- src/models.rs | 2 +- tests/e2e.rs | 162 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e.rs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f9dbc36 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,66 @@ +name: e2e + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + + env: + RUST_TOOLCHAIN: stable + RUST_LOG: debug + RUST_BACKTRACE: 1 + 3XUI_BASE_URL: http://127.0.0.1:2053/ + 3XUI_USERNAME: admin + 3XUI_PASSWORD: admin + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Start 3x-ui container + run: | + docker pull ghcr.io/mhsanaei/3x-ui:v2.6.6 + docker run -d --name xui -p 2053:2053 ghcr.io/mhsanaei/3x-ui:v2.6.6 + for i in {1..60}; do + if docker exec xui x-ui status >/dev/null 2>&1; then + break + fi + sleep 2 + done + docker exec xui x-ui setting -username admin -password admin -port 2053 -webBasePath admin + docker exec xui x-ui restart + for i in {1..60}; do + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' \ + http://127.0.0.1:2053/login || true) + if [ "$code" = "200" ]; then + echo "3x-ui is ready" + exit 0 + fi + sleep 2 + done + echo "3x-ui failed to become ready" + docker logs xui + exit 1 + + - name: Run tests + run: cargo test --tests -- --nocapture + + - name: Print container logs on failure + if: failure() + run: docker logs xui diff --git a/Cargo.toml b/Cargo.toml index 9f59cd0..8341d0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,4 +16,9 @@ log = "0.4.25" reqwest = { version = "0.12.12", features = ["json", "cookies"] } serde = { version = "1.0.217", features = ["derive"] } thiserror = "2.0.11" -serde_json = "1.0.138" \ No newline at end of file +serde_json = "1.0.138" + + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +uuid = { version = "1", features = ["v4"] } diff --git a/src/models.rs b/src/models.rs index 3735482..999c69c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -94,7 +94,7 @@ where serde_json::from_str(&settings_str).map_err(serde::de::Error::custom) } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct User { pub id: String, diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..058925b --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,162 @@ +use std::env; +use tokio::time::{Duration, sleep}; +use uuid::Uuid; + +use rustix3::{ + client::Client, + inbounds::InboundProtocols, + models::{ClientRequest, ClientSettings, CreateInboundRequest, Fallback, Settings, User}, +}; + +#[tokio::test] +async fn e2e_full_flow() { + let base = env::var("3XUI_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:2053/".into()); + let user = env::var("3XUI_USERNAME").unwrap_or_else(|_| "admin".into()); + let pass = env::var("3XUI_PASSWORD").unwrap_or_else(|_| "admin".into()); + + let client = Client::new(user, pass, base).await.expect("login"); + + let list_before = client.get_inbounds_list().await.expect("list"); + assert!(list_before.is_ok()); + + let remark = format!("e2e-{}", Uuid::new_v4().to_string()); + let req = CreateInboundRequest { + up: 0, + down: 0, + total: 0, + remark: remark.clone(), + enable: true, + expiry_time: 0, + listen: String::new(), + port: 31001, + protocol: InboundProtocols::Vless, + settings: Settings { + clients: vec![], + decryption: "none".into(), + fallbacks: Vec::::new(), + }, + stream_settings: "{}".into(), + sniffing: "{}".into(), + allocate: "{}".into(), + }; + + let created = client.add_inbound(&req).await.expect("add_inbound"); + assert!(created.is_ok()); + let inbound_id = created.object.id; + + let by_id = client + .get_inbound_by_id(inbound_id) + .await + .expect("get_by_id"); + assert!(by_id.is_ok()); + assert_eq!(by_id.object.remark, remark); + + let mut updated_req = req; + updated_req.remark = format!("{}-upd", remark); + let updated = client + .update_inbound(inbound_id, &updated_req) + .await + .expect("update_inbound"); + assert!(updated.is_ok()); + assert_eq!(updated.object.remark, updated_req.remark); + + let cuuid = Uuid::new_v4().to_string(); + let email = format!("{}@example.com", cuuid); + let user_obj = User { + id: cuuid.clone(), + flow: String::new(), + email: email.clone(), + limit_ip: 0, + total_gb: 0, + expiry_time: 0, + enable: true, + tg_id: String::new(), + sub_id: String::new(), + reset: 0, + }; + let add_client_req = ClientRequest { + id: inbound_id, + settings: ClientSettings { + clients: vec![user_obj.clone()], + }, + }; + let add_client = client + .add_client_to_inbound(&add_client_req) + .await + .expect("add_client"); + assert!(add_client.is_ok()); + + sleep(Duration::from_millis(200)).await; + + let traffic_by_email = client + .get_client_traffic_by_email(email.clone()) + .await + .expect("traffic_by_email"); + assert!(traffic_by_email.is_ok()); + assert_eq!(traffic_by_email.object.email, email); + + let traffic_by_id = client + .get_client_traffic_by_id(cuuid.clone()) + .await + .expect("traffic_by_id"); + assert!(traffic_by_id.is_ok()); + + let mut updated_user = user_obj; + updated_user.limit_ip = 1; + let upd_client_req = ClientRequest { + id: inbound_id, + settings: ClientSettings { + clients: vec![updated_user], + }, + }; + let upd_client = client + .update_client(&cuuid, &upd_client_req) + .await + .expect("update_client"); + assert!(upd_client.is_ok()); + + let clear_ips = client.clear_client_ips(&email).await.expect("clear_ips"); + assert!(clear_ips.is_ok()); + + let reset_client = client + .reset_client_traffic(inbound_id, &email) + .await + .expect("reset_client"); + assert!(reset_client.is_ok()); + + let reset_all_clients = client + .reset_all_client_traffics(inbound_id) + .await + .expect("reset_all_clients"); + assert!(reset_all_clients.is_ok()); + + let reset_all_inbounds = client + .reset_all_inbound_traffics() + .await + .expect("reset_all_inbounds"); + assert!(reset_all_inbounds.is_ok()); + + let onlines = client.online_clients().await.expect("online_clients"); + assert!(onlines.is_ok()); + + let del_client = client + .delete_client(inbound_id, &cuuid) + .await + .expect("delete_client"); + assert!(del_client.is_ok()); + + let del_depleted = client + .delete_depleted_clients(inbound_id) + .await + .expect("delete_depleted"); + assert!(del_depleted.is_ok()); + + let del_inbound = client + .delete_inbound(inbound_id) + .await + .expect("delete_inbound"); + assert!(del_inbound.is_ok()); + + let list_after = client.get_inbounds_list().await.expect("list_after"); + assert!(list_after.is_ok()); +} From 1b043dd5a429c8bff42665f5a7f2a1326def80a4 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:23:21 +0300 Subject: [PATCH 3/9] tests --- .github/workflows/e2e.yml | 42 +++++++++++++------------- Cargo.toml | 8 ++++- e2e_local.sh | 42 ++++++++++++++++++++++++++ src/client.rs | 35 +++++++++++----------- src/error.rs | 3 ++ src/lib.rs | 7 +++-- src/models.rs | 5 ++++ src/response_ext.rs | 62 +++++++++++++++++++++++++++++++++++++++ tests/e2e.rs | 55 ++++++++++++++++++++++++++++++++-- 9 files changed, 215 insertions(+), 44 deletions(-) create mode 100755 e2e_local.sh create mode 100644 src/response_ext.rs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f9dbc36..7f77fd7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,15 +9,14 @@ on: jobs: e2e: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 env: - RUST_TOOLCHAIN: stable - RUST_LOG: debug RUST_BACKTRACE: 1 - 3XUI_BASE_URL: http://127.0.0.1:2053/ - 3XUI_USERNAME: admin - 3XUI_PASSWORD: admin + RUST_LOG: trace + PANEL_BASE_URL: http://127.0.0.1:2053/ + PANEL_USERNAME: admin + PANEL_PASSWORD: admin steps: - name: Checkout @@ -31,32 +30,29 @@ jobs: with: save-if: ${{ github.event_name != 'pull_request' }} - - name: Start 3x-ui container + - name: Start 3x-ui (Docker) run: | + set -euxo pipefail docker pull ghcr.io/mhsanaei/3x-ui:v2.6.6 - docker run -d --name xui -p 2053:2053 ghcr.io/mhsanaei/3x-ui:v2.6.6 - for i in {1..60}; do - if docker exec xui x-ui status >/dev/null 2>&1; then - break - fi - sleep 2 - done - docker exec xui x-ui setting -username admin -password admin -port 2053 -webBasePath admin - docker exec xui x-ui restart - for i in {1..60}; do + docker rm -f xui >/dev/null 2>&1 || true + docker run -d --name xui --network host ghcr.io/mhsanaei/3x-ui:v2.6.6 + + for i in $(seq 1 90); do code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"admin"}' \ http://127.0.0.1:2053/login || true) if [ "$code" = "200" ]; then echo "3x-ui is ready" - exit 0 + break fi sleep 2 + if [ "$i" = "90" ]; then + echo "3x-ui failed to become ready" + docker logs xui || true + exit 1 + fi done - echo "3x-ui failed to become ready" - docker logs xui - exit 1 - name: Run tests run: cargo test --tests -- --nocapture @@ -64,3 +60,7 @@ jobs: - name: Print container logs on failure if: failure() run: docker logs xui + + - name: Cleanup + if: always() + run: docker rm -f xui || true diff --git a/Cargo.toml b/Cargo.toml index 8341d0c..30d97f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,19 @@ homepage = "https://github.com/Xaneets/rustix3" keywords = ["x-ui", "3x-ui", "api", "xray-core", "vpn"] categories = ["development-tools"] [dependencies] +futures = "0.3.31" log = "0.4.25" reqwest = { version = "0.12.12", features = ["json", "cookies"] } serde = { version = "1.0.217", features = ["derive"] } -thiserror = "2.0.11" serde_json = "1.0.138" +serde_path_to_error = "0.1.17" +thiserror = "2.0.11" +tracing = "0.1.41" +serde_with = { version = "3.14.0", features = ["json"] } [dev-dependencies] +dotenv = "0.15.0" +env_logger = "0.11.6" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } uuid = { version = "1", features = ["v4"] } diff --git a/e2e_local.sh b/e2e_local.sh new file mode 100755 index 0000000..932b70f --- /dev/null +++ b/e2e_local.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMG="ghcr.io/mhsanaei/3x-ui:v2.6.6" +NAME="xui" +BASE="http://127.0.0.1:2053" +USER="admin" +PASS="admin" + +cleanup() { docker rm -f "$NAME" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +docker pull "$IMG" +docker rm -f "$NAME" >/dev/null 2>&1 || true + +# РЕКОМЕНДУЕМО по докам — host-network; так порт 2053 будет доступен напрямую +docker run -d --name "$NAME" --network host "$IMG" + +# Ожидаем поднятия панели и работоспособности /login (доки: логин admin/admin) +for i in $(seq 1 90); do + code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"${USER}\",\"password\":\"${PASS}\"}" \ + "${BASE}/login" || true) + if [ "$code" = "200" ]; then + echo "3x-ui is ready" + break + fi + sleep 2 + if [ "$i" = "90" ]; then + echo "3x-ui failed to become ready" + docker logs "$NAME" || true + exit 1 + fi +done + +export PANEL_BASE_URL="${BASE}/" +export PANEL_USERNAME="${USER}" +export PANEL_PASSWORD="${PASS}" +export RUST_LOG="trace" + +cargo test --tests -- --nocapture diff --git a/src/client.rs b/src/client.rs index 0fc6c1b..0d9296c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,9 +4,10 @@ use super::{ }; use crate::error::Error; use crate::models::{ClientRequest, CreateInboundRequest}; -use log::{debug, error}; +use log::{debug, error, trace}; use reqwest::{Client as RClient, IntoUrl, StatusCode, Url}; use serde::Serialize; +use crate::response_ext::ResponseJsonVerboseExt; type LoginResponse = NullObjectResponse; @@ -99,13 +100,13 @@ impl Client { let id = inbound_id.to_string(); let path = vec!["get", &id]; let res = self.client.get(self.gen_url(path)?).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn get_client_traffic_by_email(&self, email: String) -> Result { let path = vec!["getClientTraffics", &email]; let res = self.client.get(self.gen_url(path)?).send().await?; // todo check is null return user not found - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn get_client_traffic_by_id(&self, id: String) -> Result { @@ -113,7 +114,7 @@ impl Client { let id = id.to_string(); let path = vec!["getClientTrafficsById", &id]; let res = self.client.get(self.gen_url(path)?).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn send_backup_by_bot(&self) -> Result<()> { @@ -128,19 +129,19 @@ impl Client { pub async fn get_client_ips(&self, client_email: String) -> Result { let path = vec!["clientIps", &client_email]; let res = self.client.post(self.gen_url(path)?).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn add_inbound(&self, req: &CreateInboundRequest) -> Result { let url = self.gen_url(vec!["add"])?; let res = self.client.post(url).json(req).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn add_client_to_inbound(&self, req: &ClientRequest) -> Result { let url = self.gen_url(vec!["addClient"])?; let res = self.client.post(url).json(req).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn update_inbound( @@ -150,7 +151,7 @@ impl Client { ) -> Result { let url = self.gen_url(vec!["update", &inbound_id.to_string()])?; let res = self.client.post(url).json(req).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn update_client( @@ -160,25 +161,25 @@ impl Client { ) -> Result { let url = self.gen_url(vec!["updateClient", uuid])?; let res = self.client.post(url).json(req).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn clear_client_ips(&self, email: &str) -> Result { let url = self.gen_url(vec!["clearClientIps", email])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn reset_all_inbound_traffics(&self) -> Result { let url = self.gen_url(vec!["resetAllTraffics"])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn reset_all_client_traffics(&self, inbound_id: u64) -> Result { let url = self.gen_url(vec!["resetAllClientTraffics", &inbound_id.to_string()])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn reset_client_traffic( @@ -188,30 +189,30 @@ impl Client { ) -> Result { let url = self.gen_url(vec![&inbound_id.to_string(), "resetClientTraffic", email])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn delete_client(&self, inbound_id: u64, uuid: &str) -> Result { let url = self.gen_url(vec![&inbound_id.to_string(), "delClient", uuid])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn delete_inbound(&self, inbound_id: u64) -> Result { let url = self.gen_url(vec!["del", &inbound_id.to_string()])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn delete_depleted_clients(&self, inbound_id: u64) -> Result { let url = self.gen_url(vec!["delDepletedClients", &inbound_id.to_string()])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } pub async fn online_clients(&self) -> Result { let url = self.gen_url(vec!["onlines"])?; let res = self.client.post(url).send().await?; - Ok(res.json().await?) + res.json_verbose().await.map_err(Into::into) } } diff --git a/src/error.rs b/src/error.rs index 0342401..356e679 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,6 @@ use reqwest::StatusCode; use thiserror::Error; +use crate::response_ext::JsonVerboseError; #[derive(Error, Debug)] pub enum Error { @@ -13,6 +14,8 @@ pub enum Error { InvalidCred, #[error("Error: {0}!")] OtherError(String), + #[error(transparent)] + JsonVerbose(#[from] JsonVerboseError), } impl From for Error { diff --git a/src/lib.rs b/src/lib.rs index 7543941..a8ad721 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ extern crate core; + use crate::error::Error; use crate::models::Response; pub use client::Client; use models::{ClientStats, Inbounds}; +use serde::{Deserialize, Serialize}; pub mod client; -pub mod inbounds; pub mod error; +pub mod inbounds; pub mod models; +pub mod response_ext; pub type Result = std::result::Result; @@ -18,4 +21,4 @@ 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 DeleteInboundResponse = Response; -pub type OnlineClientsResponse = Response>; +pub type OnlineClientsResponse = Response>>; diff --git a/src/models.rs b/src/models.rs index 999c69c..7cb3183 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,6 @@ use crate::inbounds::InboundProtocols; use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{json::JsonString, serde_as}; use std::ops::Not; #[derive(Debug, Deserialize)] @@ -60,6 +61,7 @@ pub struct Inbounds { pub allocate: String, // todo } +#[serde_as] #[derive(Debug, Serialize, Deserialize)] pub struct CreateInboundRequest { pub up: i64, @@ -72,6 +74,7 @@ pub struct CreateInboundRequest { pub listen: String, pub port: u16, pub protocol: InboundProtocols, + #[serde_as(as = "JsonString<_>")] pub settings: Settings, #[serde(rename = "streamSettings")] pub stream_settings: String, @@ -127,8 +130,10 @@ pub struct ClientSettings { pub clients: Vec, } +#[serde_as] #[derive(Debug, Serialize, Deserialize)] pub struct ClientRequest { pub id: u64, + #[serde_as(as = "JsonString<_>")] pub settings: ClientSettings, } diff --git a/src/response_ext.rs b/src/response_ext.rs new file mode 100644 index 0000000..95adf5a --- /dev/null +++ b/src/response_ext.rs @@ -0,0 +1,62 @@ +use log::trace; +use reqwest::{Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::string::FromUtf8Error; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum JsonVerboseError { + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error(transparent)] + Utf8(#[from] FromUtf8Error), + #[error("http status {status}: {body}")] + Status { status: StatusCode, body: String }, + #[error("json decode at {path}: {source}. body={body}")] + Decode { + source: serde_json::Error, + path: String, + body: String, + }, +} + +pub type Result = std::result::Result; + +pub trait ResponseJsonVerboseExt { + fn json_verbose( + self, + ) -> futures::future::BoxFuture<'static, Result>; +} + +impl ResponseJsonVerboseExt for Response { + fn json_verbose( + self, + ) -> futures::future::BoxFuture<'static, Result> { + Box::pin(async move { + let status = self.status(); + let bytes = self.bytes().await?; + let body = String::from_utf8(bytes.to_vec())?; + if !status.is_success() { + trace!("status={} body={}", status.as_u16(), body); + return Err(JsonVerboseError::Status { status, body }); + } + match serde_json::from_str::(&body) { + Ok(v) => trace!("json={}", v), + Err(_) => trace!("raw={}", body), + } + let mut de = serde_json::Deserializer::from_str(&body); + match serde_path_to_error::deserialize::<_, T>(&mut de) { + Ok(val) => Ok(val), + Err(e) => { + let path = e.path().to_string(); + Err(JsonVerboseError::Decode { + source: e.into_inner(), + path, + body, + }) + } + } + }) + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 058925b..17b4d80 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,3 +1,5 @@ +use dotenv::dotenv; +use env_logger; use std::env; use tokio::time::{Duration, sleep}; use uuid::Uuid; @@ -10,13 +12,20 @@ use rustix3::{ #[tokio::test] async fn e2e_full_flow() { - let base = env::var("3XUI_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:2053/".into()); - let user = env::var("3XUI_USERNAME").unwrap_or_else(|_| "admin".into()); - let pass = env::var("3XUI_PASSWORD").unwrap_or_else(|_| "admin".into()); + dotenv().ok(); + env_logger::init(); + + log::info!("Starting full flow"); + log::trace!("Starting full flow2"); + let base = env::var("PANEL_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:2053/".into()); + let user = env::var("PANEL_USERNAME").unwrap_or_else(|_| "admin".into()); + let pass = env::var("PANEL_PASSWORD").unwrap_or_else(|_| "admin".into()); let client = Client::new(user, pass, base).await.expect("login"); + log::info!("connected"); let list_before = client.get_inbounds_list().await.expect("list"); + log::info!("list_before = {:#?}", list_before); assert!(list_before.is_ok()); let remark = format!("e2e-{}", Uuid::new_v4().to_string()); @@ -41,7 +50,12 @@ async fn e2e_full_flow() { }; let created = client.add_inbound(&req).await.expect("add_inbound"); + assert!(created.is_ok()); + + let inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + let inbound_id = created.object.id; let by_id = client @@ -86,6 +100,9 @@ async fn e2e_full_flow() { .expect("add_client"); assert!(add_client.is_ok()); + let inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + sleep(Duration::from_millis(200)).await; let traffic_by_email = client @@ -139,12 +156,44 @@ async fn e2e_full_flow() { let onlines = client.online_clients().await.expect("online_clients"); assert!(onlines.is_ok()); + let cuuid = Uuid::new_v4().to_string(); + let email = format!("{}@example.com", cuuid); + let user_obj = User { + id: cuuid.clone(), + flow: String::new(), + email: email.clone(), + limit_ip: 0, + total_gb: 0, + expiry_time: 0, + enable: true, + tg_id: String::new(), + sub_id: String::new(), + reset: 0, + }; + let add_client_req = ClientRequest { + id: inbound_id, + settings: ClientSettings { + clients: vec![user_obj.clone()], + }, + }; + let add_client = client + .add_client_to_inbound(&add_client_req) + .await + .expect("add_client"); + assert!(add_client.is_ok()); + + let inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + let del_client = client .delete_client(inbound_id, &cuuid) .await .expect("delete_client"); assert!(del_client.is_ok()); + let inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + let del_depleted = client .delete_depleted_clients(inbound_id) .await From be442ddb85905180a75586b8202106b47d068999 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:29:35 +0300 Subject: [PATCH 4/9] fix --- .github/workflows/rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d0d65d3..0b33ce4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -36,4 +36,4 @@ jobs: run: cargo build --verbose - name: Run tests - run: cargo test --verbose + run: cargo test --lib --verbose From c3524d778e02ddd96a392842e20765ddfb6ae7c9 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:39:58 +0300 Subject: [PATCH 5/9] badges --- README.md | 4 ++++ tests/e2e.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 27bf40e..a02858b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +[![Build](https://github.com/Xaneets/rustix3/actions/workflows/rust.yml/badge.svg)](https://github.com/Xaneets/rustix3/actions/workflows/rust.yml) +[![e2e](https://github.com/Xaneets/rustix3/actions/workflows/e2e.yml/badge.svg)](https://github.com/Xaneets/rustix3/actions/workflows/e2e.yml) +[![Crates.io](https://img.shields.io/crates/v/rustix3.svg)](https://crates.io/crates/rustix3) + # Implemented endpoints - [x] login - [x] Inbounds diff --git a/tests/e2e.rs b/tests/e2e.rs index 17b4d80..9aaf555 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -16,7 +16,6 @@ async fn e2e_full_flow() { env_logger::init(); log::info!("Starting full flow"); - log::trace!("Starting full flow2"); let base = env::var("PANEL_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:2053/".into()); let user = env::var("PANEL_USERNAME").unwrap_or_else(|_| "admin".into()); let pass = env::var("PANEL_PASSWORD").unwrap_or_else(|_| "admin".into()); @@ -208,4 +207,5 @@ async fn e2e_full_flow() { let list_after = client.get_inbounds_list().await.expect("list_after"); assert!(list_after.is_ok()); + log::info!("list_after = {:#?}", list_after); } From 3093d543b8053034ae1a0246aa3619f65188552b Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:51:15 +0300 Subject: [PATCH 6/9] update readme --- .github/workflows/rust.yml | 2 +- README.md | 49 +++++++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0b33ce4..6621e63 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,4 +1,4 @@ -name: Rust CI +name: Build on: push: diff --git a/README.md b/README.md index a02858b..d0824b4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,18 @@ [![e2e](https://github.com/Xaneets/rustix3/actions/workflows/e2e.yml/badge.svg)](https://github.com/Xaneets/rustix3/actions/workflows/e2e.yml) [![Crates.io](https://img.shields.io/crates/v/rustix3.svg)](https://crates.io/crates/rustix3) -# Implemented endpoints -- [x] login +# rustix3 + +Unofficial Rust client for the **3x-ui** panel API (Xray-core). +Provides typed models and high-level methods for common panel operations. + +> Note: Some 3x-ui endpoints expect certain nested structures to be sent as **JSON strings** (e.g., inbound `settings`). The client models handle these specifics transparently. + +--- + +## Implemented endpoints + +- [x] login - [x] Inbounds - [x] Inbound - [x] Client traffics with email @@ -21,4 +31,37 @@ - [x] Delete client - [x] Delete inbound - [x] Delete depleted clients -- [x] Online clients \ No newline at end of file +- [x] Online clients + +--- + +## Installation + +Use the Git dependency directly: + +```toml +[dependencies] +rustix3 = { git = "https://github.com/Xaneets/rustix3", branch = "main" } +``` + +## Quick start + +```rust +use rustix3::client::Client; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let base_url = "http://127.0.0.1:2053/"; + let username = "admin"; + let password = "admin"; + + let client = Client::new(username, password, base_url).await?; + + // Example: list inbounds + let inbounds = client.get_inbounds_list().await?; + println!("{:#?}", inbounds); + + Ok(()) +} + +``` \ No newline at end of file From f61e0a428a98f67875f3497ab1f2d9556e58065e Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:54:27 +0300 Subject: [PATCH 7/9] fix --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d0824b4..51c2ee5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Use the Git dependency directly: rustix3 = { git = "https://github.com/Xaneets/rustix3", branch = "main" } ``` +--- + ## Quick start ```rust From dd939eeb111be99f6e3ab423273b7b2fd1f48fa9 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 20:56:53 +0300 Subject: [PATCH 8/9] fix warn --- src/client.rs | 6 +++--- src/error.rs | 2 +- src/inbounds.rs | 1 + src/lib.rs | 1 - 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client.rs b/src/client.rs index 0d9296c..5594fe6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,10 +4,10 @@ use super::{ }; use crate::error::Error; use crate::models::{ClientRequest, CreateInboundRequest}; -use log::{debug, error, trace}; +use crate::response_ext::ResponseJsonVerboseExt; +use log::{debug, error}; use reqwest::{Client as RClient, IntoUrl, StatusCode, Url}; use serde::Serialize; -use crate::response_ext::ResponseJsonVerboseExt; type LoginResponse = NullObjectResponse; @@ -76,7 +76,7 @@ impl Client { .await?; match response.status() { StatusCode::NOT_FOUND => { - return Err(Error::NotFound(response.error_for_status().unwrap_err())) + return Err(Error::NotFound(response.error_for_status().unwrap_err())); } StatusCode::OK => {} e => { diff --git a/src/error.rs b/src/error.rs index 356e679..d0cab4f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,6 @@ +use crate::response_ext::JsonVerboseError; use reqwest::StatusCode; use thiserror::Error; -use crate::response_ext::JsonVerboseError; #[derive(Error, Debug)] pub enum Error { diff --git a/src/inbounds.rs b/src/inbounds.rs index f930fca..74fafea 100644 --- a/src/inbounds.rs +++ b/src/inbounds.rs @@ -210,6 +210,7 @@ pub enum ModeOption { } #[derive(Copy, Clone, Debug, Deserialize, Serialize)] +#[allow(non_camel_case_types)] pub enum StreamSettings { TlsStreamSettings, RealityStreamSettings, diff --git a/src/lib.rs b/src/lib.rs index a8ad721..a8ea1b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,6 @@ use crate::error::Error; use crate::models::Response; pub use client::Client; use models::{ClientStats, Inbounds}; -use serde::{Deserialize, Serialize}; pub mod client; pub mod error; From b38acb0fb2fc38a14cf337fc86b4229bcb138658 Mon Sep 17 00:00:00 2001 From: Xaneets Date: Sun, 17 Aug 2025 21:00:29 +0300 Subject: [PATCH 9/9] fix --- Cargo.toml | 3 +-- tests/e2e.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 30d97f0..2e344d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustix3" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["Dmitriy Sergeev "] description = "API lib for 3x-ui panel" @@ -19,7 +19,6 @@ serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.138" serde_path_to_error = "0.1.17" thiserror = "2.0.11" -tracing = "0.1.41" serde_with = { version = "3.14.0", features = ["json"] } diff --git a/tests/e2e.rs b/tests/e2e.rs index 9aaf555..ee8bf65 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,5 +1,4 @@ use dotenv::dotenv; -use env_logger; use std::env; use tokio::time::{Duration, sleep}; use uuid::Uuid; @@ -27,7 +26,7 @@ async fn e2e_full_flow() { log::info!("list_before = {:#?}", list_before); assert!(list_before.is_ok()); - let remark = format!("e2e-{}", Uuid::new_v4().to_string()); + let remark = format!("e2e-{}", Uuid::new_v4()); let req = CreateInboundRequest { up: 0, down: 0,