Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 13 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "rustix3"
version = "0.2.0"
edition = "2021"
version = "0.3.0"
edition = "2024"
authors = ["Dmitriy Sergeev <xaneets@gmail.com>"]
description = "API lib for 3x-ui panel"
license = "Apache-2.0"
Expand All @@ -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"
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"] }
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,4 +31,39 @@
- [x] Delete client
- [x] Delete inbound
- [x] Delete depleted clients
- [x] Online clients
- [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(())
}

```
42 changes: 42 additions & 0 deletions e2e_local.sh
Original file line number Diff line number Diff line change
@@ -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
35 changes: 18 additions & 17 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand All @@ -99,21 +100,21 @@ 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<ClientsStatsResponse> {
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<ClientsStatsVecResponse> {
// todo id to uuid
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<()> {
Expand All @@ -128,19 +129,19 @@ impl Client {
pub async fn get_client_ips(&self, client_email: String) -> Result<ClientIpsResponse> {
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<InboundResponse> {
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<NullObjectResponse> {
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(
Expand All @@ -150,7 +151,7 @@ impl Client {
) -> Result<InboundResponse> {
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(
Expand All @@ -160,25 +161,25 @@ impl Client {
) -> Result<NullObjectResponse> {
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<NullObjectResponse> {
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<NullObjectResponse> {
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<NullObjectResponse> {
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(
Expand All @@ -188,30 +189,30 @@ impl Client {
) -> Result<NullObjectResponse> {
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<NullObjectResponse> {
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<DeleteInboundResponse> {
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<NullObjectResponse> {
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<OnlineClientsResponse> {
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)
}
}
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::response_ext::JsonVerboseError;
use reqwest::StatusCode;
use thiserror::Error;

Expand All @@ -13,6 +14,8 @@ pub enum Error {
InvalidCred,
#[error("Error: {0}!")]
OtherError(String),
#[error(transparent)]
JsonVerbose(#[from] JsonVerboseError),
}

impl From<reqwest::Error> for Error {
Expand Down
Loading
Loading