Files
vpn/app/services/xui_panels.py
T

418 lines
14 KiB
Python

"""CRUD + connection tests for linked 3x-ui panels."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import XuiPanel, XuiPanelInbound, XuiSiteClient
from app.services.xui import (
SUPPORTED_CLIENT_PROTOCOLS,
XuiApiError,
XuiClient,
normalize_base_url,
)
def _client_for(panel: XuiPanel) -> XuiClient:
return XuiClient(
panel.base_url,
username=panel.username,
password=panel.password,
api_token=panel.api_token,
verify_ssl=panel.verify_ssl,
)
async def list_panels(session: AsyncSession) -> list[XuiPanel]:
result = await session.execute(select(XuiPanel).order_by(XuiPanel.id.desc()))
return list(result.scalars().all())
async def create_panel(
session: AsyncSession,
*,
name: str,
base_url: str,
username: str = "",
password: str = "",
api_token: str = "",
verify_ssl: bool = True,
) -> XuiPanel:
token = api_token.strip() or None
user = username.strip() or None
pwd = password.strip() or None
if not token and not (user and pwd):
raise ValueError("Укажите API Token или логин и пароль")
url = normalize_base_url(base_url)
panel = XuiPanel(
name=(name.strip() or url),
base_url=url,
username=user,
password=pwd,
api_token=token,
verify_ssl=verify_ssl,
is_enabled=True,
)
session.add(panel)
await session.commit()
await session.refresh(panel)
await check_panel(session, panel.id)
await session.refresh(panel)
if not panel.is_reachable:
raise ValueError(panel.last_error or "Не удалось подключиться к 3x-ui")
return panel
async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel:
panel = await session.get(XuiPanel, panel_id)
if not panel:
raise ValueError("Панель не найдена")
now = datetime.now(timezone.utc)
try:
async with _client_for(panel) as client:
info = await client.test_connection()
panel.is_reachable = True
panel.last_error = None
panel.panel_info = json.dumps(info, ensure_ascii=False, default=str)[:8000]
panel.last_checked = now
except (XuiApiError, ValueError, OSError) as exc:
panel.is_reachable = False
panel.last_error = str(exc)[:2000]
panel.last_checked = now
await session.commit()
await session.refresh(panel)
raise ValueError(str(exc)) from exc
await session.commit()
await session.refresh(panel)
try:
await sync_panel_inbounds(session, panel_id)
except Exception: # noqa: BLE001
pass
await session.refresh(panel)
return panel
async def delete_panel(session: AsyncSession, panel_id: int) -> None:
panel = await session.get(XuiPanel, panel_id)
if panel:
await session.delete(panel)
await session.commit()
async def fetch_panel_data(panel: XuiPanel) -> dict[str, Any]:
async with _client_for(panel) as client:
status = await client.get_status()
all_inbounds = await client.list_inbounds()
available = await client.list_available_inbounds(
protocols=SUPPORTED_CLIENT_PROTOCOLS, enabled_only=False
)
vless_inbounds = [i for i in available if str(i.get("protocol")).lower() == "vless"]
wg_inbounds = [i for i in available if str(i.get("protocol")).lower() == "wireguard"]
try:
clients = await client.list_clients()
except XuiApiError:
clients = []
return {
"status": status,
"inbounds": all_inbounds,
"available_inbounds": available,
"vless_inbounds": vless_inbounds,
"wireguard_inbounds": wg_inbounds,
"clients": clients,
}
async def load_available_inbounds(
panel: XuiPanel,
*,
protocol: str | None = None,
) -> list[dict]:
protocols: tuple[str, ...]
if protocol:
protocols = (protocol.lower(),)
else:
protocols = SUPPORTED_CLIENT_PROTOCOLS
async with _client_for(panel) as client:
return await client.list_available_inbounds(protocols=protocols, enabled_only=True)
async def sync_panel_inbounds(session: AsyncSession, panel_id: int) -> list[XuiPanelInbound]:
"""Fetch VLESS/WG inbounds from 3x-ui and upsert into DB (preserve user flags)."""
panel = await session.get(XuiPanel, panel_id)
if not panel:
raise ValueError("Панель не найдена")
remote = await load_available_inbounds(panel)
now = datetime.now(timezone.utc)
seen_ids: set[int] = set()
existing_rows = (
await session.execute(select(XuiPanelInbound).where(XuiPanelInbound.panel_id == panel_id))
).scalars().all()
by_inbound = {r.inbound_id: r for r in existing_rows}
for item in remote:
inbound_id = int(item.get("id"))
seen_ids.add(inbound_id)
row = by_inbound.get(inbound_id)
if row is None:
row = XuiPanelInbound(
panel_id=panel_id,
inbound_id=inbound_id,
is_available_for_users=False,
)
session.add(row)
row.protocol = str(item.get("protocol") or "").lower()
row.remark = item.get("remark")
port = item.get("port")
row.port = int(port) if port is not None else None
row.enable = bool(item.get("enable", True))
row.last_synced_at = now
for row in existing_rows:
if row.inbound_id not in seen_ids:
await session.delete(row)
await session.commit()
return await list_panel_inbounds(session, panel_id)
async def list_panel_inbounds(session: AsyncSession, panel_id: int) -> list[XuiPanelInbound]:
result = await session.execute(
select(XuiPanelInbound)
.where(XuiPanelInbound.panel_id == panel_id)
.order_by(XuiPanelInbound.protocol, XuiPanelInbound.inbound_id)
)
return list(result.scalars().all())
async def list_user_inbounds(
session: AsyncSession,
panel_id: int,
*,
protocol: str | None = None,
) -> list[XuiPanelInbound]:
query = select(XuiPanelInbound).where(
XuiPanelInbound.panel_id == panel_id,
XuiPanelInbound.is_available_for_users.is_(True),
XuiPanelInbound.enable.is_(True),
)
if protocol:
query = query.where(XuiPanelInbound.protocol == protocol.lower())
query = query.order_by(XuiPanelInbound.protocol, XuiPanelInbound.inbound_id)
result = await session.execute(query)
return list(result.scalars().all())
async def set_inbound_for_users(
session: AsyncSession,
panel_id: int,
inbound_id: int,
*,
available: bool,
) -> XuiPanelInbound:
result = await session.execute(
select(XuiPanelInbound).where(
XuiPanelInbound.panel_id == panel_id,
XuiPanelInbound.inbound_id == inbound_id,
)
)
row = result.scalar_one_or_none()
if not row:
raise ValueError("Inbound не найден — сначала синхронизируйте список")
row.is_available_for_users = available
await session.commit()
await session.refresh(row)
return row
async def save_user_inbounds(
session: AsyncSession,
panel_id: int,
inbound_ids: list[int],
) -> list[XuiPanelInbound]:
"""Set which inbound IDs are available for users (others off)."""
rows = await list_panel_inbounds(session, panel_id)
selected = set(inbound_ids)
for row in rows:
row.is_available_for_users = row.inbound_id in selected
await session.commit()
return await list_panel_inbounds(session, panel_id)
def compute_expiry_time(*, days: int, start_after_first_use: bool) -> int:
"""
3x-ui expiryTime encoding:
0 — unlimited
>0 — absolute deadline in unix ms
<0 — duration ms; countdown starts after first use
"""
days = int(days or 0)
if days <= 0:
return 0
duration_ms = days * 24 * 60 * 60 * 1000
if start_after_first_use:
return -duration_ms
import time
return int(time.time() * 1000) + duration_ms
async def list_site_clients(session: AsyncSession, panel_id: int) -> list[XuiSiteClient]:
result = await session.execute(
select(XuiSiteClient)
.where(XuiSiteClient.panel_id == panel_id)
.order_by(XuiSiteClient.id.desc())
)
return list(result.scalars().all())
async def list_all_site_clients(session: AsyncSession) -> list[XuiSiteClient]:
from sqlalchemy.orm import selectinload
result = await session.execute(
select(XuiSiteClient)
.options(selectinload(XuiSiteClient.panel))
.order_by(XuiSiteClient.id.desc())
)
return list(result.scalars().all())
async def get_site_client(session: AsyncSession, site_client_id: int) -> XuiSiteClient | None:
from sqlalchemy.orm import selectinload
result = await session.execute(
select(XuiSiteClient)
.options(selectinload(XuiSiteClient.panel))
.where(XuiSiteClient.id == site_client_id)
)
return result.scalar_one_or_none()
async def site_client_download_payload(row: XuiSiteClient) -> tuple[str, str]:
"""Return (filename_stem, text content) for conf/link download."""
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (row.email or "client"))
if row.protocol == "wireguard":
text = row.config_text or ""
if not text and row.private_key and row.address and row.server_public_key and row.endpoint:
from app.services.crypto import render_xui_wireguard_config
text = render_xui_wireguard_config(
private_key=row.private_key,
address=row.address,
dns=row.dns or "1.1.1.1, 1.0.0.1",
mtu=row.mtu or 1420,
server_public_key=row.server_public_key,
endpoint=row.endpoint,
remark=row.inbound_remark or row.email,
)
return safe, text
text = row.link or row.config_text or ""
return safe, text
async def add_xui_client(
session: AsyncSession,
panel: XuiPanel,
*,
protocol: str,
email: str,
inbound_id: int,
total_gb: int = 0,
limit_ip: int = 0,
expiry_days: int = 0,
start_after_first_use: bool = False,
flow: str = "",
comment: str = "",
require_user_available: bool = False,
) -> dict:
proto = protocol.strip().lower()
if require_user_available:
allowed = await list_user_inbounds(session, panel.id, protocol=proto)
if inbound_id not in {r.inbound_id for r in allowed}:
raise ValueError("Этот inbound недоступен для пользователей — включите его на сервере 3x-ui")
bytes_limit = total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0
expiry_time = compute_expiry_time(days=expiry_days, start_after_first_use=start_after_first_use)
async with _client_for(panel) as client:
if proto == "vless":
result = await client.add_vless_client(
email=email,
inbound_id=inbound_id,
total_gb=bytes_limit,
expiry_time=expiry_time,
limit_ip=limit_ip,
flow=flow,
comment=comment,
)
elif proto == "wireguard":
result = await client.add_wireguard_client(
email=email,
inbound_id=inbound_id,
total_gb=bytes_limit,
expiry_time=expiry_time,
limit_ip=limit_ip,
comment=comment,
)
else:
raise ValueError("Поддерживаются только vless и wireguard")
links = result.get("links") or []
inbound_meta = result.get("inbound") or {}
# Prefer remark from loaded inbound options if missing
remark = inbound_meta.get("remark")
if not remark and proto == "vless":
remark = f"vless#{inbound_id}"
existing = await session.execute(
select(XuiSiteClient).where(
XuiSiteClient.panel_id == panel.id,
XuiSiteClient.email == email,
)
)
row = existing.scalar_one_or_none()
if row is None:
row = XuiSiteClient(panel_id=panel.id, email=email)
session.add(row)
row.protocol = proto
row.inbound_id = inbound_id
row.inbound_remark = remark
row.uuid = result.get("uuid")
row.flow = result.get("flow")
row.private_key = result.get("privateKey")
row.public_key = result.get("publicKey")
row.link = links[0] if links else None
row.address = result.get("address")
row.endpoint = result.get("endpoint")
row.dns = result.get("dns")
row.mtu = result.get("mtu")
row.server_public_key = result.get("server_public_key")
row.config_text = result.get("config_text")
if not row.config_text and row.link:
row.config_text = row.link
row.expiry_days = expiry_days
row.start_after_first_use = start_after_first_use
row.expiry_time = int(result.get("expiryTime") or expiry_time or 0)
row.total_gb = total_gb
row.limit_ip = limit_ip
row.comment = comment or None
row.created_via = "website"
await session.commit()
await session.refresh(row)
result["expiry_days"] = expiry_days
result["start_after_first_use"] = start_after_first_use
result["site_client_id"] = row.id
result["created_via"] = "website"
return result