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:
+179
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user