diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..7f77fd7 --- /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: 15 + + env: + RUST_BACKTRACE: 1 + RUST_LOG: trace + PANEL_BASE_URL: http://127.0.0.1:2053/ + PANEL_USERNAME: admin + PANEL_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 (Docker) + run: | + set -euxo pipefail + docker pull ghcr.io/mhsanaei/3x-ui:v2.6.6 + 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" + break + fi + sleep 2 + if [ "$i" = "90" ]; then + echo "3x-ui failed to become ready" + docker logs xui || true + exit 1 + fi + done + + - name: Run tests + run: cargo test --tests -- --nocapture + + - 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/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..6621e63 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,39 @@ +name: Build + +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 --lib --verbose diff --git a/Cargo.toml b/Cargo.toml index d053873..2e344d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rustix3" -version = "0.2.0" -edition = "2021" +version = "0.3.0" +edition = "2024" authors = ["Dmitriy Sergeev "] description = "API lib for 3x-ui panel" license = "Apache-2.0" @@ -12,8 +12,18 @@ 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"] } +serde_json = "1.0.138" +serde_path_to_error = "0.1.17" thiserror = "2.0.11" -serde_json = "1.0.138" \ No newline at end of file +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/README.md b/README.md index 27bf40e..51c2ee5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,19 @@ -# Implemented endpoints -- [x] login +[![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) + +# 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 @@ -17,4 +31,39 @@ - [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 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..5594fe6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::error::Error; use crate::models::{ClientRequest, CreateInboundRequest}; +use crate::response_ext::ResponseJsonVerboseExt; use log::{debug, error}; use reqwest::{Client as RClient, IntoUrl, StatusCode, Url}; use serde::Serialize; @@ -75,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 => { @@ -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..d0cab4f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use crate::response_ext::JsonVerboseError; use reqwest::StatusCode; use thiserror::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/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 7543941..a8ea1b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,15 @@ extern crate core; + use crate::error::Error; use crate::models::Response; pub use client::Client; use models::{ClientStats, Inbounds}; 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 +20,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 3735482..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, @@ -94,7 +97,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, @@ -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 new file mode 100644 index 0000000..ee8bf65 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,210 @@ +use dotenv::dotenv; +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() { + dotenv().ok(); + env_logger::init(); + + log::info!("Starting full flow"); + 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()); + 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 inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + + 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()); + + let inbounds = client.get_inbounds_list().await.expect("list"); + log::info!("inbounds = {:#?}", inbounds); + + 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 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 + .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()); + log::info!("list_after = {:#?}", list_after); +}