326 lines
11 KiB
Python
326 lines
11 KiB
Python
import time
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import py3xui
|
||
|
||
from py3xui import AsyncApi
|
||
|
||
from config import ADMIN_PASSWORD, ADMIN_USERNAME, SUPERNODE, USE_XUI_TOKEN, XUI_TOKEN
|
||
from logger import logger
|
||
|
||
|
||
@dataclass
|
||
class ClientConfig:
|
||
"""Конфигурация клиента для добавления/обновления."""
|
||
|
||
client_id: str
|
||
email: str
|
||
tg_id: str
|
||
limit_ip: int
|
||
total_gb: int
|
||
expiry_time: int
|
||
enable: bool
|
||
flow: str
|
||
inbound_id: int
|
||
sub_id: str
|
||
|
||
|
||
_xui_instance_cache: dict[str, tuple[AsyncApi, float]] = {}
|
||
SESSION_TTL = 1800
|
||
|
||
|
||
async def get_xui_instance(api_url: str) -> AsyncApi:
|
||
key = f"{api_url}|{ADMIN_USERNAME}"
|
||
current_time = time.time()
|
||
|
||
xui_entry = _xui_instance_cache.get(key)
|
||
if xui_entry:
|
||
xui, last_login = xui_entry
|
||
if current_time - last_login < SESSION_TTL:
|
||
return xui
|
||
else:
|
||
logger.info("[XUI Cache] Сессия устарела (>30 минут), переподключение...")
|
||
await xui.login()
|
||
_xui_instance_cache[key] = (xui, current_time)
|
||
return xui
|
||
|
||
xui = AsyncApi(
|
||
api_url,
|
||
ADMIN_USERNAME,
|
||
ADMIN_PASSWORD,
|
||
token=XUI_TOKEN if USE_XUI_TOKEN else None,
|
||
logger=logger,
|
||
)
|
||
await xui.login()
|
||
_xui_instance_cache[key] = (xui, current_time)
|
||
return xui
|
||
|
||
|
||
async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]:
|
||
try:
|
||
client = py3xui.Client(
|
||
id=config.client_id,
|
||
email=config.email.lower(),
|
||
limit_ip=config.limit_ip if config.limit_ip is not None else 0,
|
||
total_gb=config.total_gb,
|
||
expiry_time=config.expiry_time,
|
||
enable=config.enable,
|
||
tg_id=config.tg_id,
|
||
sub_id=config.sub_id,
|
||
flow=config.flow,
|
||
)
|
||
|
||
response = await xui.client.add(config.inbound_id, [client])
|
||
logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}")
|
||
return response if response else {"status": "failed"}
|
||
|
||
except httpx.ConnectTimeout as e:
|
||
logger.error(f"Ошибка при добавлении клиента {config.email}: {e}")
|
||
return {"status": "failed", "error": "Timeout"}
|
||
|
||
except Exception as e:
|
||
error_message = str(e)
|
||
if "Duplicate email" in error_message:
|
||
logger.warning(f"Дублированный email: {config.email}. Пропуск. Сообщение: {error_message}")
|
||
return {"status": "duplicate", "email": config.email}
|
||
|
||
logger.error(f"Ошибка при добавлении клиента {config.email}: {error_message}")
|
||
return {"status": "failed", "error": error_message}
|
||
|
||
|
||
async def extend_client_key(
|
||
xui: py3xui.AsyncApi,
|
||
inbound_id: int,
|
||
email: str,
|
||
new_expiry_time: int,
|
||
client_id: str,
|
||
total_gb: int,
|
||
sub_id: str,
|
||
tg_id: int,
|
||
limit_ip: int = 0,
|
||
) -> bool | None:
|
||
try:
|
||
client = await xui.client.get_by_email(email)
|
||
if not client or not client.id:
|
||
logger.warning(f"Клиент с email {email} не найден или не имеет ID.")
|
||
return None
|
||
|
||
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
|
||
|
||
client.id = client_id
|
||
client.expiry_time = new_expiry_time
|
||
client.flow = "xtls-rprx-vision"
|
||
client.sub_id = sub_id
|
||
client.total_gb = total_gb
|
||
client.enable = True
|
||
client.limit_ip = limit_ip
|
||
client.inbound_id = inbound_id
|
||
client.tg_id = tg_id
|
||
|
||
await xui.client.update(client.id, client)
|
||
await xui.client.reset_stats(inbound_id, email)
|
||
logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}")
|
||
return True
|
||
|
||
except httpx.ConnectTimeout as e:
|
||
logger.error(f"Ошибка при обновлении клиента {email}: {e}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||
return False
|
||
|
||
|
||
async def delete_client(
|
||
xui: py3xui.AsyncApi,
|
||
inbound_id: int,
|
||
email: str,
|
||
client_id: str,
|
||
) -> bool:
|
||
try:
|
||
if SUPERNODE:
|
||
await xui.client.delete(inbound_id, client_id)
|
||
logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)")
|
||
return True
|
||
|
||
client = await xui.client.get_by_email(email)
|
||
if not client:
|
||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден")
|
||
return False
|
||
|
||
client.id = client_id
|
||
await xui.client.delete(inbound_id, client.id)
|
||
logger.info(f"Клиент с ID {client_id} был удален успешно")
|
||
return True
|
||
|
||
except httpx.ConnectTimeout as e:
|
||
logger.error(f"Ошибка при удалении клиента {email}: {e}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
|
||
return False
|
||
|
||
|
||
async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]:
|
||
try:
|
||
traffic_data = await xui.client.get_traffic_by_id(client_id)
|
||
if not traffic_data:
|
||
logger.warning(f"Трафик для клиента {client_id} не найден.")
|
||
return {"status": "not_found", "client_id": client_id}
|
||
|
||
logger.info(f"Трафик для клиента {client_id} успешно получен.")
|
||
return {"status": "success", "client_id": client_id, "traffic": traffic_data}
|
||
|
||
except httpx.ConnectTimeout as e:
|
||
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
|
||
return {"status": "error", "error": "Timeout"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
|
||
return {"status": "error", "error": str(e)}
|
||
|
||
|
||
async def toggle_client(
|
||
xui: py3xui.AsyncApi,
|
||
inbound_id: int,
|
||
email: str,
|
||
client_id: str,
|
||
enable: bool = True,
|
||
) -> bool:
|
||
try:
|
||
client = await xui.client.get_by_email(email)
|
||
if not client:
|
||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||
return False
|
||
|
||
client.sub_id = email
|
||
client.enable = enable
|
||
client.id = client_id
|
||
client.flow = "xtls-rprx-vision"
|
||
client.limit_ip = 0
|
||
client.inbound_id = inbound_id
|
||
|
||
await xui.client.update(client.id, client)
|
||
status = "включен" if enable else "отключен"
|
||
logger.info(f"Клиент с email {email} и ID {client_id} успешно {status}.")
|
||
return True
|
||
|
||
except httpx.ConnectTimeout as e:
|
||
status = "включении" if enable else "отключении"
|
||
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
status = "включении" if enable else "отключении"
|
||
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
|
||
return False
|
||
|
||
|
||
def build_vless_link_from_inbound(
|
||
inbound: py3xui.Inbound,
|
||
user_uuid: str,
|
||
email: str,
|
||
external_host: str,
|
||
port: int,
|
||
remark: str | None = None,
|
||
client_flow: str | None = None,
|
||
) -> str:
|
||
name = remark or email
|
||
security = (inbound.stream_settings.security or "").lower()
|
||
network = (inbound.stream_settings.network or "").lower()
|
||
|
||
def _first(val):
|
||
if isinstance(val, list) and val:
|
||
return val[0]
|
||
return val or ""
|
||
|
||
rs = inbound.stream_settings.reality_settings or {}
|
||
rs_settings = rs.get("settings") or {}
|
||
|
||
pbk = rs_settings.get("publicKey") or rs.get("publicKey") or ""
|
||
sni = _first(
|
||
rs.get("serverNames") or rs_settings.get("serverNames") or rs.get("serverName") or rs_settings.get("serverName")
|
||
)
|
||
sid = _first(rs.get("shortIds") or rs_settings.get("shortIds") or rs.get("shortId") or rs_settings.get("shortId"))
|
||
fp = rs.get("fingerprint") or rs_settings.get("fingerprint") or ""
|
||
|
||
if security == "reality" and network == "tcp":
|
||
parts = [
|
||
f"vless://{user_uuid}@{external_host}:{port}",
|
||
"?type=tcp&security=reality",
|
||
f"&pbk={pbk}" if pbk else "",
|
||
f"&fp={fp}" if fp else "",
|
||
f"&sni={sni}" if sni else "",
|
||
f"&sid={sid}" if sid else "",
|
||
"&spx=%2F",
|
||
f"&flow={client_flow}" if client_flow else "",
|
||
f"#{name}",
|
||
]
|
||
return "".join(parts)
|
||
|
||
if network == "ws":
|
||
ws = inbound.stream_settings.ws_settings or {}
|
||
path = (ws.get("path") or "/").strip() or "/"
|
||
host_hdr = external_host
|
||
if security == "tls":
|
||
parts = [
|
||
f"vless://{user_uuid}@{external_host}:{port}",
|
||
"?type=ws&security=tls",
|
||
f"&host={host_hdr}",
|
||
f"&sni={external_host}",
|
||
f"&path={path}",
|
||
f"#{name}",
|
||
]
|
||
return "".join(parts)
|
||
return f"vless://{user_uuid}@{external_host}:{port}?type=ws&path={path}#{name}"
|
||
|
||
if security == "tls":
|
||
return f"vless://{user_uuid}@{external_host}:{port}?type=tcp&security=tls&sni={external_host}#{name}"
|
||
|
||
return f"vless://{user_uuid}@{external_host}:{port}?type=tcp#{name}"
|
||
|
||
|
||
async def get_vless_link_for_client(
|
||
xui: py3xui.AsyncApi,
|
||
inbound_id: int,
|
||
email: str,
|
||
external_host: str,
|
||
port: int,
|
||
remark: str | None = None,
|
||
) -> str | None:
|
||
try:
|
||
inbound = await xui.inbound.get_by_id(inbound_id)
|
||
if not inbound:
|
||
logger.warning(f"Не удалось собрать VLESS ссылку: inbound_id={inbound_id}, email={email}")
|
||
return None
|
||
|
||
true_uuid = None
|
||
client_flow = None
|
||
if getattr(inbound, "settings", None) and getattr(inbound.settings, "clients", None):
|
||
for c in inbound.settings.clients:
|
||
if getattr(c, "email", None) == email:
|
||
true_uuid = getattr(c, "id", None)
|
||
client_flow = getattr(c, "flow", None)
|
||
break
|
||
|
||
if not true_uuid:
|
||
logger.warning(f"Не удалось получить UUID клиента: inbound_id={inbound_id}, email={email}")
|
||
return None
|
||
|
||
return build_vless_link_from_inbound(
|
||
inbound,
|
||
true_uuid,
|
||
email,
|
||
external_host,
|
||
port,
|
||
remark,
|
||
client_flow,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при сборке VLESS ссылки: {e}")
|
||
return None
|