"""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) + "/", "")