Files
vpn/app/services/xui.py
T

524 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""3x-ui panel API client (compatible with v3.5.0)."""
from __future__ import annotations
import json
import logging
import secrets
import string
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
from app.services.crypto import public_key_from_private, render_xui_wireguard_config
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))
def _as_dict(value: Any) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _extract_client_allowed_ips(details: Any, email: str, inbound: dict) -> list[str]:
allowed: list[str] = []
if isinstance(details, dict):
allowed = details.get("allowedIPs") or details.get("allowed_ips") or []
if not allowed:
for key in ("client", "obj"):
nested = details.get(key)
if isinstance(nested, dict) and nested.get("allowedIPs"):
allowed = nested["allowedIPs"]
break
if allowed:
return [str(x) for x in allowed if x]
settings = _as_dict(inbound.get("settings"))
for c in settings.get("clients") or []:
if not isinstance(c, dict):
continue
if str(c.get("email") or "") == email:
return [str(x) for x in (c.get("allowedIPs") or []) if x]
return []
def build_wireguard_client_bundle(
*,
panel_base_url: str,
inbound: dict,
private_key: str,
public_key: str,
email: str,
allowed_ips: list[str],
remark: str = "",
) -> dict[str, Any]:
settings = _as_dict(inbound.get("settings"))
secret_key = str(settings.get("secretKey") or "").strip()
if not secret_key:
raise XuiApiError("У inbound WireGuard нет secretKey — не удалось собрать конфиг")
server_public_key = public_key_from_private(secret_key)
dns = str(settings.get("dns") or "").strip() or "1.1.1.1, 1.0.0.1"
mtu = int(settings.get("mtu") or 1420)
port = int(inbound.get("port") or 0)
host = urlparse(panel_base_url).hostname or ""
listen = str(inbound.get("listen") or "").strip()
if listen and listen not in {"0.0.0.0", "::", ""}:
host = listen
if not host or not port:
raise XuiApiError("Не удалось определить Endpoint (host:port) для WireGuard")
endpoint = f"{host}:{port}"
address = ""
for item in allowed_ips:
s = str(item).strip()
if s and not s.startswith("0.0.0.0") and s not in {"::/0", "::"}:
address = s if "/" in s else f"{s}/32"
break
if not address and allowed_ips:
address = str(allowed_ips[0])
if not address:
raise XuiApiError("3x-ui не выделил Address (allowedIPs) для клиента")
peer_remark = remark or str(inbound.get("remark") or email)
config_text = render_xui_wireguard_config(
private_key=private_key,
address=address,
dns=dns,
mtu=mtu,
server_public_key=server_public_key,
endpoint=endpoint,
remark=peer_remark,
)
return {
"address": address,
"dns": dns,
"mtu": mtu,
"endpoint": endpoint,
"server_public_key": server_public_key,
"config_text": config_text,
"allowedIPs": allowed_ips,
"privateKey": private_key,
"publicKey": public_key,
}
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 = "",
endpoint_host: str | None = None,
) -> 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)
# Re-fetch inbound so allocated allowedIPs are present in settings.clients
inbound = await self.get_inbound(inbound_id)
allowed_ips = _extract_client_allowed_ips(details, email, inbound)
base = endpoint_host or self.base_url
# If endpoint_host is a bare hostname, wrap as URL for urlparse
if endpoint_host and "://" not in endpoint_host:
panel_url = f"https://{endpoint_host}"
else:
panel_url = base if "://" in str(base) else f"https://{base}"
remark = str(inbound.get("remark") or email)
bundle = build_wireguard_client_bundle(
panel_base_url=panel_url,
inbound=inbound,
private_key=keys["privateKey"],
public_key=keys["publicKey"],
email=email,
allowed_ips=allowed_ips,
remark=remark,
)
return {
"protocol": "wireguard",
"email": email,
"inbound_id": inbound_id,
"privateKey": keys["privateKey"],
"publicKey": keys["publicKey"],
"allowedIPs": allowed_ips,
"address": bundle["address"],
"dns": bundle["dns"],
"mtu": bundle["mtu"],
"endpoint": bundle["endpoint"],
"server_public_key": bundle["server_public_key"],
"config_text": bundle["config_text"],
"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) + "/", "")