Files

644 lines
22 KiB
Python
Raw Permalink 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.
"""Клиент API панели 3x-ui (v3.5.0+): login, inbounds, WireGuard clients."""
from __future__ import annotations
import json
import logging
import re
import secrets
import string
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, urljoin, urlparse
import httpx
logger = logging.getLogger(__name__)
# 3x-ui 3.5.0: WireGuard — multi-client через settings.clients + addClient
SUPPORTED_PANEL = "3.5.0"
class XuiError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class XuiInbound:
id: int
remark: str
protocol: str
port: int
enable: bool
clients_count: int = 0
listen: str = ""
@property
def is_wireguard(self) -> bool:
return (self.protocol or "").lower() in {"wireguard", "wg"}
@dataclass(frozen=True, slots=True)
class XuiConnectionInfo:
ok: bool
version: str | None
inbounds_total: int
wireguard: list[XuiInbound]
message: str = ""
def _normalize_base_url(url: str) -> str:
raw = (url or "").strip().rstrip("/")
if not raw:
raise XuiError("API URL пуст")
if not raw.startswith(("http://", "https://")):
raw = "https://" + raw
parsed = urlparse(raw)
if not parsed.netloc:
raise XuiError("Некорректный API URL")
return raw
def _new_sub_id(length: int = 16) -> str:
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def build_subscription_link(subscription_base: str, sub_id: str) -> str:
"""Склеивает ручной base URL подписки с subId клиента."""
base = (subscription_base or "").strip().rstrip("/")
sid = (sub_id or "").strip().lstrip("/")
if not base or not sid:
return ""
return f"{base}/{sid}"
def _parse_settings(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw.strip():
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _clients_count(settings: dict[str, Any]) -> int:
clients = settings.get("clients")
if isinstance(clients, list):
return len(clients)
peers = settings.get("peers")
if isinstance(peers, list):
return len(peers)
return 0
def _inbound_from_obj(obj: dict[str, Any]) -> XuiInbound:
settings = _parse_settings(obj.get("settings"))
return XuiInbound(
id=int(obj.get("id") or 0),
remark=str(obj.get("remark") or ""),
protocol=str(obj.get("protocol") or ""),
port=int(obj.get("port") or 0),
enable=bool(obj.get("enable", True)),
clients_count=_clients_count(settings),
listen=str(obj.get("listen") or ""),
)
class XuiClient:
"""Сессия к одной панели 3x-ui.
Предпочтительно: API Token (Settings → Security → API Token) как Bearer.
Fallback: cookie после POST /login (username + password).
"""
def __init__(
self,
api_url: str,
username: str = "",
password: str = "",
*,
api_key: str = "",
verify_ssl: bool = True,
timeout: float = 25.0,
) -> None:
self.base_url = _normalize_base_url(api_url)
self.username = (username or "").strip()
self.password = password or ""
self.api_key = (api_key or "").strip()
self.verify_ssl = verify_ssl
self.timeout = timeout
self._client: httpx.AsyncClient | None = None
self._authed = False
async def __aenter__(self) -> XuiClient:
await self.open()
return self
async def __aexit__(self, *args: Any) -> None:
await self.close()
async def open(self) -> None:
if self._client is not None:
return
headers: dict[str, str] = {"Accept": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=self.timeout,
verify=self.verify_ssl,
follow_redirects=True,
headers=headers,
)
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
self._authed = False
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise XuiError("Клиент не открыт — вызовите open() или используйте async with")
return self._client
async def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
http = self._http()
url = path if path.startswith("/") else f"/{path}"
try:
resp = await http.request(method, url, json=json_body, data=data)
except httpx.RequestError as exc:
raise XuiError(f"Сеть: {exc}") from exc
try:
payload = resp.json()
except json.JSONDecodeError:
text = (resp.text or "")[:200].replace("\n", " ")
hint = ""
if resp.status_code == 404:
hint = (
" (часто: неверный API Token — панель отвечает 404; "
"или устаревший путь API)"
)
raise XuiError(
f"Не JSON ответ ({resp.status_code}){hint}: {text}"
) from None
if not isinstance(payload, dict):
raise XuiError("Неожиданный формат ответа панели")
if resp.status_code >= 400:
msg = payload.get("msg") or payload.get("message") or resp.reason_phrase
raise XuiError(f"HTTP {resp.status_code}: {msg}")
# 3x-ui обычно: {success: bool, msg: str, obj: ...}
if "success" in payload and payload.get("success") is False:
raise XuiError(str(payload.get("msg") or "Ошибка панели"))
return payload
async def ensure_auth(self) -> None:
"""Авторизация: Bearer API key или login cookie."""
if self._authed:
return
await self.open()
if self.api_key:
# Bearer уже в headers — login не нужен (обходит CSRF на /panel/api/*)
self._authed = True
return
await self.login()
self._authed = True
async def login(self) -> None:
if not self.username or not self.password:
raise XuiError("Нужен API Token или пара логин/пароль")
# Классический form login; часть сборок принимает JSON
try:
await self._request(
"POST",
"/login",
data={"username": self.username, "password": self.password},
)
except XuiError:
await self._request(
"POST",
"/login",
json_body={"username": self.username, "password": self.password},
)
async def get_version(self) -> str | None:
for path in ("/panel/api/server/status", "/panel/api/server/getConfigJson"):
try:
payload = await self._request("GET", path)
except XuiError:
continue
obj = payload.get("obj")
if isinstance(obj, dict):
for key in ("version", "xrayVersion", "appVersion"):
val = obj.get(key)
if val:
return str(val)
# status иногда кладёт version на верхний уровень obj как строку в nested
if isinstance(obj, dict) and "cpu" in obj:
# нет версии в status — ок
return None
return None
async def list_inbounds(self) -> list[XuiInbound]:
payload = await self._request("GET", "/panel/api/inbounds/list")
obj = payload.get("obj")
items: list[Any]
if isinstance(obj, list):
items = obj
elif isinstance(obj, dict):
items = obj.get("inbounds") or obj.get("list") or []
else:
items = []
out: list[XuiInbound] = []
for item in items:
if isinstance(item, dict) and item.get("id") is not None:
out.append(_inbound_from_obj(item))
return out
async def get_inbound(self, inbound_id: int) -> dict[str, Any]:
payload = await self._request("GET", f"/panel/api/inbounds/get/{int(inbound_id)}")
obj = payload.get("obj")
if not isinstance(obj, dict):
raise XuiError(f"Инбаунд #{inbound_id} не найден")
return obj
async def list_wireguard_inbounds(self) -> list[XuiInbound]:
return [i for i in await self.list_inbounds() if i.is_wireguard]
async def add_wireguard_client(
self,
inbound_id: int,
*,
email: str,
sub_id: str | None = None,
tg_id: int | str = 0,
expiry_ms: int = 0,
total_gb: int = 0,
limit_ip: int = 0,
enable: bool = True,
comment: str = "",
) -> dict[str, Any]:
"""Добавляет WireGuard-клиента (3x-ui 3.5.0: POST /panel/api/clients/add)."""
await self.ensure_auth()
sid = (sub_id or "").strip() or _new_sub_id()
try:
tg_num = int(tg_id) if tg_id not in ("", None) else 0
except (TypeError, ValueError):
tg_num = 0
client: dict[str, Any] = {
"email": email,
"enable": bool(enable),
"expiryTime": int(expiry_ms),
"totalGB": int(total_gb),
"limitIp": int(limit_ip),
"tgId": tg_num,
"subId": sid,
"comment": comment or "",
"reset": 0,
}
modern_body = {
"client": client,
"inboundIds": [int(inbound_id)],
}
try:
payload = await self._request(
"POST",
"/panel/api/clients/add",
json_body=modern_body,
)
except XuiError as modern_exc:
# Fallback для старых панелей с /inbounds/addClient
legacy_body = {
"id": int(inbound_id),
"settings": json.dumps({"clients": [client]}, ensure_ascii=False),
}
try:
payload = await self._request(
"POST",
"/panel/api/inbounds/addClient",
json_body=legacy_body,
)
except XuiError:
raise modern_exc from None
created = await self.get_client(inbound_id, email=email)
if created and created.get("subId"):
sid = str(created.get("subId") or sid)
return {
"sub_id": sid,
"email": email,
"client": created,
"response": payload,
}
def _find_client_in_inbound(
self, inbound: dict[str, Any], *, email: str
) -> dict[str, Any] | None:
settings = _parse_settings(inbound.get("settings"))
clients = settings.get("clients")
if not isinstance(clients, list):
return None
email_l = email.strip().lower()
for client in clients:
if isinstance(client, dict) and str(client.get("email") or "").strip().lower() == email_l:
return client
return None
def _path_email(self, email: str) -> str:
"""Email в path: простые id без кодирования, иначе PathEscape-совместимо."""
raw = (email or "").strip()
if re.fullmatch(r"[A-Za-z0-9_.-]+", raw or ""):
return raw
return quote(raw, safe="._-~")
@staticmethod
def _sanitize_client_payload(
client: dict[str, Any],
*,
enable: bool | None = None,
email: str | None = None,
) -> dict[str, Any]:
"""Только поля клиента — без traffic/inboundIds (иначе update ломается)."""
allowed = {
"email",
"enable",
"expiryTime",
"totalGB",
"limitIp",
"tgId",
"subId",
"comment",
"reset",
"flow",
"password",
"id",
"security",
"encryption",
"privateKey",
"publicKey",
"preSharedKey",
"allowedIPs",
"keepAlive",
"method",
"auth",
}
out: dict[str, Any] = {}
for key, value in client.items():
if key not in allowed or value is None:
continue
if key == "allowedIPs" and not isinstance(value, list):
continue
if key != "allowedIPs" and isinstance(value, (dict, list)):
continue
out[key] = value
# Client.id в 3x-ui — строка (UUID и т.п.), не numeric id из таблицы clients
raw_id = out.get("id", None)
if raw_id is None and client.get("uuid"):
raw_id = client.get("uuid")
if isinstance(raw_id, int):
# числовой PK таблицы clients нельзя слать как Client.id
out.pop("id", None)
if client.get("uuid"):
out["id"] = str(client["uuid"])
elif raw_id is not None:
out["id"] = str(raw_id)
if email:
out["email"] = email
elif "email" not in out and client.get("email"):
out["email"] = client["email"]
if enable is not None:
out["enable"] = bool(enable)
# tgId панель иногда ждёт число
if "tgId" in out:
try:
out["tgId"] = int(out["tgId"] or 0)
except (TypeError, ValueError):
out["tgId"] = 0
return out
async def get_client(
self, inbound_id: int, *, email: str
) -> dict[str, Any] | None:
await self.ensure_auth()
# Modern API
try:
payload = await self._request(
"GET",
f"/panel/api/clients/get/{self._path_email(email)}",
)
obj = payload.get("obj")
if isinstance(obj, dict):
client = obj.get("client") if isinstance(obj.get("client"), dict) else obj
if isinstance(client, dict) and client.get("email"):
return client
except XuiError:
pass
inbound = await self.get_inbound(inbound_id)
return self._find_client_in_inbound(inbound, email=email)
async def update_client(
self,
inbound_id: int,
client_id: str,
client: dict[str, Any],
) -> dict[str, Any]:
"""Обновление клиента: modern clients/update → legacy updateClient → inbound update."""
await self.ensure_auth()
email = str(client.get("email") or client_id).strip()
clean = self._sanitize_client_payload(client, email=email)
# 1) Modern API
for path in (
f"/panel/api/clients/update/{self._path_email(email)}",
f"/panel/api/clients/update/{self._path_email(email)}?inboundIds={int(inbound_id)}",
):
try:
return await self._request("POST", path, json_body=clean)
except XuiError:
pass
# 2) Legacy inbounds/updateClient
legacy_body = {
"id": int(inbound_id),
"settings": json.dumps({"clients": [clean]}, ensure_ascii=False),
}
for client_key in (email, str(clean.get("id") or ""), client_id):
if not client_key:
continue
path = f"/panel/api/inbounds/updateClient/{self._path_email(str(client_key))}"
try:
return await self._request("POST", path, json_body=legacy_body)
except XuiError:
pass
try:
return await self._request(
"POST",
path,
data={
"id": str(int(inbound_id)),
"settings": legacy_body["settings"],
},
)
except XuiError:
pass
# 3) Fallback: правим поля прямо в settings inbound
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch=clean,
)
async def _update_inbound_client_fields(
self,
inbound_id: int,
*,
email: str,
patch: dict[str, Any],
) -> dict[str, Any]:
inbound = await self.get_inbound(inbound_id)
settings = _parse_settings(inbound.get("settings"))
clients = settings.get("clients")
if not isinstance(clients, list):
raise XuiError(f"В inbound #{inbound_id} нет clients[]")
email_l = email.strip().lower()
found = False
new_clients: list[Any] = []
for item in clients:
if not isinstance(item, dict):
new_clients.append(item)
continue
if str(item.get("email") or "").strip().lower() == email_l:
merged = dict(item)
merged.update(patch)
merged["email"] = item.get("email") or email
new_clients.append(merged)
found = True
else:
new_clients.append(item)
if not found:
raise XuiError(f"Клиент {email} не найден в inbound #{inbound_id}")
settings["clients"] = new_clients
payload = dict(inbound)
payload["id"] = int(inbound_id)
payload["settings"] = json.dumps(settings, ensure_ascii=False)
for key in ("streamSettings", "sniffing", "allocate"):
val = payload.get(key)
if isinstance(val, (dict, list)):
payload[key] = json.dumps(val, ensure_ascii=False)
# убрать служебные поля ответа get
for key in ("clientStats", "clients", "obj"):
payload.pop(key, None)
return await self._request(
"POST",
f"/panel/api/inbounds/update/{int(inbound_id)}",
json_body=payload,
)
async def set_client_enabled(
self,
inbound_id: int,
*,
email: str,
enable: bool,
) -> dict[str, Any]:
await self.ensure_auth()
current = await self.get_client(inbound_id, email=email)
if not current:
# всё равно пробуем через inbound settings
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch={"enable": bool(enable), "email": email},
)
clean = self._sanitize_client_payload(current, enable=enable, email=email)
try:
return await self.update_client(inbound_id, email, clean)
except XuiError:
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch=clean,
)
async def delete_client_by_email(self, inbound_id: int, email: str) -> dict[str, Any]:
await self.ensure_auth()
try:
return await self._request(
"POST",
f"/panel/api/clients/del/{self._path_email(email)}",
)
except XuiError:
path = (
f"/panel/api/inbounds/{int(inbound_id)}/"
f"delClientByEmail/{self._path_email(email)}"
)
try:
return await self._request("POST", path)
except XuiError:
# отвязка / disable как мягкое удаление
return await self._update_inbound_client_fields(
inbound_id,
email=email,
patch={"enable": False, "email": email},
)
async def test_connection(self) -> XuiConnectionInfo:
await self.open()
try:
await self.ensure_auth()
inbounds = await self.list_inbounds()
wg = [i for i in inbounds if i.is_wireguard]
version = None
try:
version = await self.get_version()
except XuiError:
version = None
auth_mode = "api_key" if self.api_key else "login"
msg = (
f"OK · {auth_mode} · inbounds {len(inbounds)} · WireGuard {len(wg)}"
+ (f" · panel {version}" if version else f" · target {SUPPORTED_PANEL}+")
)
return XuiConnectionInfo(
ok=True,
version=version,
inbounds_total=len(inbounds),
wireguard=wg,
message=msg,
)
except XuiError as exc:
return XuiConnectionInfo(
ok=False,
version=None,
inbounds_total=0,
wireguard=[],
message=str(exc),
)
def subscription_preview(subscription_url: str, sample_sub_id: str = "exampleSubId") -> str:
link = build_subscription_link(subscription_url, sample_sub_id)
if link:
return link
return urljoin(_normalize_base_url(subscription_url or "https://example.invalid") + "/", sample_sub_id)