Track site-created 3x-ui clients and per-panel inbound whitelist for users

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 22:32:26 +03:00
co-authored by Cursor
parent 1b2fea4539
commit ad7e1db6b7
9 changed files with 746 additions and 13 deletions
+179 -4
View File
@@ -9,7 +9,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import XuiPanel
from app.models import XuiPanel, XuiPanelInbound, XuiSiteClient
from app.services.xui import (
SUPPORTED_CLIENT_PROTOCOLS,
XuiApiError,
@@ -93,6 +93,11 @@ async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel:
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
@@ -140,6 +145,110 @@ async def load_available_inbounds(
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:
@@ -158,7 +267,28 @@ def compute_expiry_time(*, days: int, start_after_first_use: bool) -> int:
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,
@@ -170,8 +300,14 @@ async def add_xui_client(
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:
@@ -196,6 +332,45 @@ async def add_xui_client(
)
else:
raise ValueError("Поддерживаются только vless и wireguard")
result["expiry_days"] = expiry_days
result["start_after_first_use"] = start_after_first_use
return result
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