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
+297
View File
@@ -0,0 +1,297 @@
"""Клиент Amnezia Web Panel (AWG / AWG 2.0) — Bearer API token."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
import httpx
logger = logging.getLogger(__name__)
DEFAULT_PROTOCOL = "awg2"
class AwgError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class AwgPanelServer:
id: int
name: str
host: str
protocols: list[str]
@property
def has_awg2(self) -> bool:
return any(
str(p).split("__", 1)[0] in {"awg2", "awg"} for p in self.protocols
)
@dataclass(frozen=True, slots=True)
class AwgConnectionInfo:
ok: bool
alive: bool
ping_ms: int | None
protocols: list[str]
message: str = ""
def _normalize_base_url(url: str) -> str:
raw = (url or "").strip().rstrip("/")
if not raw:
raise AwgError("API URL пуст")
if not raw.startswith(("http://", "https://")):
raw = "https://" + raw
parsed = urlparse(raw)
if not parsed.netloc:
raise AwgError("Некорректный API URL")
return raw
class AwgClient:
"""HTTP-клиент к Amnezia Web Panel (panel5 / PRVTPRO)."""
def __init__(
self,
api_url: str,
*,
api_token: str,
verify_ssl: bool = True,
timeout: float = 60.0,
) -> None:
self.base_url = _normalize_base_url(api_url)
self.api_token = (api_token or "").strip()
if not self.api_token:
raise AwgError("Нужен API Token (Settings → API Tokens)")
self.verify_ssl = verify_ssl
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=timeout,
verify=verify_ssl,
headers={
"Authorization": f"Bearer {self.api_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
)
async def __aenter__(self) -> "AwgClient":
return self
async def __aexit__(self, *args: Any) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
try:
res = await self._client.request(
method, path, json=json_body, params=params
)
except httpx.HTTPError as exc:
raise AwgError(f"Сеть: {exc}") from exc
if res.status_code in (401, 403):
raise AwgError("Доступ запрещён — проверьте API Token")
if res.status_code == 404:
raise AwgError("Не найдено (проверьте server_id на панели)")
data: Any
ctype = res.headers.get("content-type", "")
if "application/json" in ctype or res.text[:1] in "{[":
try:
data = res.json()
except Exception:
data = {"raw": res.text[:500]}
else:
data = {"raw": res.text[:500]}
if res.status_code >= 400:
err = ""
if isinstance(data, dict):
err = str(data.get("error") or data.get("detail") or "")
raise AwgError(err or f"HTTP {res.status_code}")
return data
async def ping(self, panel_server_id: int) -> dict[str, Any]:
return await self._request("GET", f"/api/servers/{int(panel_server_id)}/ping")
async def list_panel_servers(self) -> list[AwgPanelServer]:
"""Список нод панели через backup download (единственный полный inventory API)."""
try:
res = await self._client.get("/api/settings/backup/download")
except httpx.HTTPError as exc:
raise AwgError(f"Сеть: {exc}") from exc
if res.status_code in (401, 403):
raise AwgError("Доступ запрещён — проверьте API Token")
if res.status_code >= 400:
raise AwgError(f"Не удалось загрузить список серверов (HTTP {res.status_code})")
try:
data = res.json()
except Exception as exc:
raise AwgError("Ответ панели не JSON") from exc
servers = data.get("servers") if isinstance(data, dict) else None
if not isinstance(servers, list):
raise AwgError("В backup нет списка servers")
out: list[AwgPanelServer] = []
for i, s in enumerate(servers):
if not isinstance(s, dict):
continue
protos = s.get("protocols") or {}
keys = list(protos.keys()) if isinstance(protos, dict) else []
out.append(
AwgPanelServer(
id=i,
name=str(s.get("name") or s.get("host") or f"Server #{i}"),
host=str(s.get("host") or ""),
protocols=keys,
)
)
return out
async def probe(
self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
) -> AwgConnectionInfo:
try:
ping = await self.ping(panel_server_id)
except AwgError as exc:
return AwgConnectionInfo(
ok=False, alive=False, ping_ms=None, protocols=[], message=str(exc)
)
alive = bool(ping.get("alive"))
ms = ping.get("ms")
try:
ms_int = int(ms) if ms is not None else None
except (TypeError, ValueError):
ms_int = None
protocols: list[str] = []
try:
servers = await self.list_panel_servers()
for s in servers:
if s.id == int(panel_server_id):
protocols = s.protocols
break
except AwgError:
protocols = []
base = (protocol or DEFAULT_PROTOCOL).split("__", 1)[0]
has_proto = any(str(p).split("__", 1)[0] == base for p in protocols) if protocols else True
if not alive:
return AwgConnectionInfo(
ok=False,
alive=False,
ping_ms=ms_int,
protocols=protocols,
message=str(ping.get("error") or "SSH недоступен"),
)
if protocols and not has_proto:
return AwgConnectionInfo(
ok=False,
alive=True,
ping_ms=ms_int,
protocols=protocols,
message=f"Протокол {base} не найден на сервере",
)
return AwgConnectionInfo(
ok=True,
alive=True,
ping_ms=ms_int,
protocols=protocols,
message=f"OK · ping {ms_int or ''} ms",
)
async def list_connections(
self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
) -> list[dict[str, Any]]:
data = await self._request(
"GET",
f"/api/servers/{int(panel_server_id)}/connections",
params={"protocol": protocol or DEFAULT_PROTOCOL},
)
clients = data.get("clients") if isinstance(data, dict) else None
return clients if isinstance(clients, list) else []
async def add_connection(
self,
panel_server_id: int,
*,
name: str,
protocol: str = DEFAULT_PROTOCOL,
) -> dict[str, Any]:
data = await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/add",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"name": (name or "Client").strip()[:80] or "Client",
},
)
if not isinstance(data, dict):
raise AwgError("Пустой ответ при создании клиента")
if not data.get("client_id") and not data.get("config"):
raise AwgError("Панель не вернула client_id/config")
return data
async def remove_connection(
self,
panel_server_id: int,
*,
client_id: str,
protocol: str = DEFAULT_PROTOCOL,
) -> None:
await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/remove",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
},
)
async def get_connection_config(
self,
panel_server_id: int,
*,
client_id: str,
protocol: str = DEFAULT_PROTOCOL,
) -> dict[str, Any]:
data = await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/config",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
},
)
return data if isinstance(data, dict) else {}
async def toggle_connection(
self,
panel_server_id: int,
*,
client_id: str,
enable: bool,
protocol: str = DEFAULT_PROTOCOL,
) -> None:
await self._request(
"POST",
f"/api/servers/{int(panel_server_id)}/connections/toggle",
json_body={
"protocol": protocol or DEFAULT_PROTOCOL,
"client_id": client_id,
"enable": bool(enable),
},
)