Files
vpn/app/services/xui.py
T

409 lines
15 KiB
Python

"""3x-ui panel API client (compatible with v3.5.0)."""
from __future__ import annotations
import logging
import secrets
import string
from typing import Any
from urllib.parse import urljoin
import httpx
logger = logging.getLogger(__name__)
SUPPORTED_CLIENT_PROTOCOLS = ("vless", "wireguard")
class XuiApiError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _random_sub_id(length: int = 16) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
class XuiClient:
"""
Client for MHSanaei/3x-ui REST API (v3.5.0).
Auth:
1) API Token → Authorization: Bearer …
2) Username/password session (+ CSRF when required)
"""
def __init__(
self,
base_url: str,
*,
username: str | None = None,
password: str | None = None,
api_token: str | None = None,
verify_ssl: bool = True,
timeout: float = 30.0,
):
self.base_url = base_url.rstrip("/")
self.username = (username or "").strip() or None
self.password = (password or "").strip() or None
self.api_token = (api_token or "").strip() or None
self.verify_ssl = verify_ssl
self.timeout = timeout
self._client: httpx.AsyncClient | None = None
self._logged_in = False
if not self.api_token and not (self.username and self.password):
raise ValueError("Укажите API Token или логин/пароль 3x-ui")
async def __aenter__(self) -> XuiClient:
headers: dict[str, str] = {"Accept": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=headers,
verify=self.verify_ssl,
timeout=self.timeout,
follow_redirects=True,
)
if not self.api_token:
await self.login()
return self
async def __aexit__(self, *args) -> None:
if self._client:
await self._client.aclose()
self._client = None
@property
def client(self) -> httpx.AsyncClient:
if not self._client:
raise RuntimeError("XuiClient is not started")
return self._client
async def login(self) -> None:
assert self.username and self.password
csrf: str | None = None
try:
warm = await self.client.get("/")
csrf = warm.headers.get("X-CSRF-Token") or warm.cookies.get("csrf_token")
except Exception: # noqa: BLE001
pass
try:
csrf_resp = await self.client.get("/panel/csrf-token")
if csrf_resp.status_code == 200:
data = csrf_resp.json()
if isinstance(data, dict) and data.get("obj"):
csrf = str(data["obj"])
except Exception: # noqa: BLE001
pass
headers: dict[str, str] = {}
if csrf:
headers["X-CSRF-Token"] = csrf
payload = {"username": self.username, "password": self.password}
resp = await self.client.post("/login", json=payload, headers=headers)
if resp.status_code == 403 and csrf:
resp = await self.client.post(
"/login",
data=payload,
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
)
elif resp.status_code >= 400:
resp = await self.client.post("/login", data=payload, headers=headers)
if resp.status_code >= 400:
raise XuiApiError(f"Login failed HTTP {resp.status_code}: {resp.text[:300]}", resp.status_code)
try:
body = resp.json()
except Exception: # noqa: BLE001
body = {}
if isinstance(body, dict) and body.get("success") is False:
raise XuiApiError(body.get("msg") or "Wrong username or password")
self._logged_in = True
async def _request(self, method: str, path: str, **kwargs) -> Any:
path = path if path.startswith("/") else f"/{path}"
resp = await self.client.request(method, path, **kwargs)
if resp.status_code == 401 and not self.api_token and not self._logged_in:
await self.login()
resp = await self.client.request(method, path, **kwargs)
if resp.status_code >= 400:
raise XuiApiError(f"HTTP {resp.status_code}: {resp.text[:400]}", resp.status_code)
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
raise XuiApiError(f"Invalid JSON response: {exc}") from exc
if isinstance(data, dict) and data.get("success") is False:
raise XuiApiError(data.get("msg") or "API error")
return data
async def get_status(self) -> dict:
data = await self._request("GET", "/panel/api/server/status")
return data.get("obj") if isinstance(data, dict) else data
async def get_xray_version(self) -> Any:
data = await self._request("GET", "/panel/api/server/getXrayVersion")
return data.get("obj") if isinstance(data, dict) else data
async def get_new_uuid(self) -> str:
data = await self._request("GET", "/panel/api/server/getNewUUID")
obj = data.get("obj") if isinstance(data, dict) else data
if isinstance(obj, str):
return obj
if isinstance(obj, dict) and obj.get("uuid"):
return str(obj["uuid"])
raise XuiApiError("Не удалось получить UUID")
async def get_new_x25519(self) -> dict[str, str]:
data = await self._request("GET", "/panel/api/server/getNewX25519Cert")
obj = data.get("obj") if isinstance(data, dict) else data
if not isinstance(obj, dict) or not obj.get("privateKey") or not obj.get("publicKey"):
raise XuiApiError("Не удалось получить X25519 ключи")
return {"privateKey": str(obj["privateKey"]), "publicKey": str(obj["publicKey"])}
async def list_inbounds(self) -> list[dict]:
data = await self._request("GET", "/panel/api/inbounds/list")
obj = data.get("obj") if isinstance(data, dict) else data
return obj if isinstance(obj, list) else []
async def list_inbound_options(self) -> list[dict]:
"""Lightweight inbound list for dropdowns (v3.5)."""
try:
data = await self._request("GET", "/panel/api/inbounds/options")
obj = data.get("obj") if isinstance(data, dict) else data
if isinstance(obj, list):
return obj
except XuiApiError:
pass
# Fallback: slim projection from full list
inbounds = await self.list_inbounds()
return [
{
"id": i.get("id"),
"remark": i.get("remark"),
"tag": i.get("tag"),
"protocol": i.get("protocol"),
"port": i.get("port"),
"enable": i.get("enable", True),
"tlsFlowCapable": False,
}
for i in inbounds
]
async def list_available_inbounds(
self,
*,
protocols: tuple[str, ...] | list[str] | None = None,
enabled_only: bool = True,
) -> list[dict]:
options = await self.list_inbound_options()
result: list[dict] = []
wanted = {p.lower() for p in protocols} if protocols else None
for item in options:
proto = str(item.get("protocol") or "").lower()
if wanted and proto not in wanted:
continue
if enabled_only and item.get("enable") is False:
continue
result.append(item)
return result
async def get_inbound(self, inbound_id: int) -> dict:
data = await self._request("GET", f"/panel/api/inbounds/get/{inbound_id}")
obj = data.get("obj") if isinstance(data, dict) else data
if not isinstance(obj, dict):
raise XuiApiError(f"Inbound {inbound_id} not found")
return obj
async def list_clients(self) -> list[dict]:
data = await self._request("GET", "/panel/api/clients/list")
obj = data.get("obj") if isinstance(data, dict) else data
return obj if isinstance(obj, list) else []
async def get_client(self, email: str) -> dict:
data = await self._request("GET", f"/panel/api/clients/get/{email}")
return data.get("obj") if isinstance(data, dict) else data
async def get_client_links(self, email: str) -> list[str]:
try:
data = await self._request("GET", f"/panel/api/clients/links/{email}")
obj = data.get("obj") if isinstance(data, dict) else data
if isinstance(obj, list):
return [str(x) for x in obj]
except XuiApiError:
pass
return []
async def add_client_raw(self, client: dict, inbound_ids: list[int]) -> dict:
body = {"client": client, "inboundIds": inbound_ids}
return await self._request("POST", "/panel/api/clients/add", json=body)
async def add_vless_client(
self,
*,
email: str,
inbound_id: int,
total_gb: int = 0,
expiry_time: int = 0,
limit_ip: int = 0,
flow: str = "",
comment: str = "",
) -> dict:
inbound = await self.get_inbound(inbound_id)
proto = str(inbound.get("protocol") or "").lower()
if proto != "vless":
raise XuiApiError(f"Inbound #{inbound_id} — не VLESS (protocol={proto})")
uuid = await self.get_new_uuid()
if not flow:
try:
stream = inbound.get("streamSettings") or {}
if isinstance(stream, str):
import json
stream = json.loads(stream)
network = str(stream.get("network") or "tcp").lower()
security = str(stream.get("security") or "").lower()
if network == "tcp" and security in {"reality", "tls"}:
flow = "xtls-rprx-vision"
except Exception: # noqa: BLE001
flow = ""
client = {
"id": uuid,
"email": email,
"enable": True,
"flow": flow,
"totalGB": total_gb,
"expiryTime": int(expiry_time),
"limitIp": limit_ip,
"tgId": 0,
"subId": _random_sub_id(),
"comment": comment,
}
await self.add_client_raw(client, [inbound_id])
details = await self.get_client(email)
links = await self.get_client_links(email)
return {
"protocol": "vless",
"email": email,
"inbound_id": inbound_id,
"uuid": uuid,
"flow": flow,
"expiryTime": int(expiry_time),
"details": details,
"links": links,
}
async def add_wireguard_client(
self,
*,
email: str,
inbound_id: int,
total_gb: int = 0,
expiry_time: int = 0,
limit_ip: int = 0,
comment: str = "",
) -> dict:
inbound = await self.get_inbound(inbound_id)
proto = str(inbound.get("protocol") or "").lower()
if proto != "wireguard":
raise XuiApiError(f"Inbound #{inbound_id} — не WireGuard (protocol={proto})")
keys = await self.get_new_x25519()
# Leave allowedIPs empty — 3x-ui allocates a unique peer address
# (e.g. 10.0.0.3/32). Sending 0.0.0.0/0 collides with other clients.
client = {
"email": email,
"enable": True,
"privateKey": keys["privateKey"],
"publicKey": keys["publicKey"],
"preSharedKey": "",
"allowedIPs": [],
"keepAlive": 0,
"totalGB": total_gb,
"expiryTime": int(expiry_time),
"limitIp": limit_ip,
"tgId": 0,
"subId": _random_sub_id(),
"comment": comment,
}
await self.add_client_raw(client, [inbound_id])
details = await self.get_client(email)
allowed_ips = []
if isinstance(details, dict):
allowed_ips = details.get("allowedIPs") or details.get("allowed_ips") or []
if not allowed_ips:
# Client may be nested under inbounds / clients list
for key in ("client", "obj"):
nested = details.get(key)
if isinstance(nested, dict) and nested.get("allowedIPs"):
allowed_ips = nested["allowedIPs"]
break
return {
"protocol": "wireguard",
"email": email,
"inbound_id": inbound_id,
"privateKey": keys["privateKey"],
"publicKey": keys["publicKey"],
"allowedIPs": allowed_ips,
"expiryTime": int(expiry_time),
"details": details,
"inbound": {
"id": inbound.get("id"),
"remark": inbound.get("remark"),
"port": inbound.get("port"),
"listen": inbound.get("listen"),
},
"links": [],
}
async def delete_client(self, email: str) -> dict:
return await self._request("POST", f"/panel/api/clients/del/{email}")
async def test_connection(self) -> dict[str, Any]:
status = await self.get_status()
info: dict[str, Any] = {"status": status}
try:
info["xray_version"] = await self.get_xray_version()
except Exception as exc: # noqa: BLE001
info["xray_version_error"] = str(exc)
try:
available = await self.list_available_inbounds(
protocols=SUPPORTED_CLIENT_PROTOCOLS, enabled_only=False
)
info["inbounds_count"] = len(available)
info["inbounds"] = available[:100]
info["vless_inbounds"] = [i for i in available if str(i.get("protocol")).lower() == "vless"]
info["wireguard_inbounds"] = [
i for i in available if str(i.get("protocol")).lower() == "wireguard"
]
except Exception as exc: # noqa: BLE001
info["inbounds_error"] = str(exc)
try:
clients = await self.list_clients()
info["clients_count"] = len(clients)
except Exception as exc: # noqa: BLE001
info["clients_error"] = str(exc)
return info
def normalize_base_url(url: str) -> str:
url = url.strip().rstrip("/")
if not url.startswith(("http://", "https://")):
url = "https://" + url
return url
def public_panel_link(base_url: str) -> str:
return urljoin(normalize_base_url(base_url) + "/", "")