377 lines
12 KiB
Python
377 lines
12 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 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.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
|