Initial commit: VPN Telegram bot (sanitized defaults)

This commit is contained in:
VPN Service
2026-07-26 23:21:41 +03:00
commit aaf04faa96
77 changed files with 33249 additions and 0 deletions
+643
View File
@@ -0,0 +1,643 @@
"""Клиент 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)