524 lines
19 KiB
Python
524 lines
19 KiB
Python
"""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) + "/", "")
|