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:
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
+48
View File
@@ -0,0 +1,48 @@
"""Track 3x-ui clients created via website
Revision ID: 0006_xui_site_clients
Revises: 0005_xui_panels
Create Date: 2026-07-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006_xui_site_clients"
down_revision: Union[str, None] = "0005_xui_panels"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"xui_site_clients",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("panel_id", sa.Integer(), sa.ForeignKey("xui_panels.id", ondelete="CASCADE"), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("protocol", sa.String(length=32), nullable=False),
sa.Column("inbound_id", sa.Integer(), nullable=False),
sa.Column("inbound_remark", sa.String(length=255), nullable=True),
sa.Column("uuid", sa.String(length=128), nullable=True),
sa.Column("flow", sa.String(length=64), nullable=True),
sa.Column("private_key", sa.Text(), nullable=True),
sa.Column("public_key", sa.Text(), nullable=True),
sa.Column("link", sa.Text(), nullable=True),
sa.Column("expiry_days", sa.Integer(), nullable=False, server_default="0"),
sa.Column("start_after_first_use", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("expiry_time", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_gb", sa.Integer(), nullable=False, server_default="0"),
sa.Column("limit_ip", sa.Integer(), nullable=False, server_default="0"),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("created_via", sa.String(length=32), nullable=False, server_default="website"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("panel_id", "email", name="uq_xui_site_client_email"),
)
op.create_index("ix_xui_site_clients_panel_id", "xui_site_clients", ["panel_id"])
op.create_index("ix_xui_site_clients_email", "xui_site_clients", ["email"])
def downgrade() -> None:
op.drop_table("xui_site_clients")
@@ -0,0 +1,39 @@
"""Cache 3x-ui inbounds and user availability flags
Revision ID: 0007_xui_panel_inbounds
Revises: 0006_xui_site_clients
Create Date: 2026-07-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007_xui_panel_inbounds"
down_revision: Union[str, None] = "0006_xui_site_clients"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"xui_panel_inbounds",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("panel_id", sa.Integer(), sa.ForeignKey("xui_panels.id", ondelete="CASCADE"), nullable=False),
sa.Column("inbound_id", sa.Integer(), nullable=False),
sa.Column("protocol", sa.String(length=32), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("port", sa.Integer(), nullable=True),
sa.Column("enable", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_available_for_users", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("panel_id", "inbound_id", name="uq_xui_panel_inbound"),
)
op.create_index("ix_xui_panel_inbounds_panel_id", "xui_panel_inbounds", ["panel_id"])
op.create_index("ix_xui_panel_inbounds_protocol", "xui_panel_inbounds", ["protocol"])
def downgrade() -> None:
op.drop_table("xui_panel_inbounds")
+56
View File
@@ -110,3 +110,59 @@ class XuiPanel(Base):
last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True) is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
site_clients: Mapped[list["XuiSiteClient"]] = relationship(
back_populates="panel", cascade="all, delete-orphan"
)
inbounds: Mapped[list["XuiPanelInbound"]] = relationship(
back_populates="panel", cascade="all, delete-orphan"
)
class XuiPanelInbound(Base):
"""Cached 3x-ui inbound; admin marks which are available for users."""
__tablename__ = "xui_panel_inbounds"
__table_args__ = (UniqueConstraint("panel_id", "inbound_id", name="uq_xui_panel_inbound"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
panel_id: Mapped[int] = mapped_column(ForeignKey("xui_panels.id", ondelete="CASCADE"), index=True)
inbound_id: Mapped[int] = mapped_column(Integer)
protocol: Mapped[str] = mapped_column(String(32), index=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
enable: Mapped[bool] = mapped_column(Boolean, default=True)
is_available_for_users: Mapped[bool] = mapped_column(Boolean, default=False)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
panel: Mapped[XuiPanel] = relationship(back_populates="inbounds")
class XuiSiteClient(Base):
"""Clients created via this website (not imported from existing 3x-ui users)."""
__tablename__ = "xui_site_clients"
__table_args__ = (UniqueConstraint("panel_id", "email", name="uq_xui_site_client_email"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
panel_id: Mapped[int] = mapped_column(ForeignKey("xui_panels.id", ondelete="CASCADE"), index=True)
email: Mapped[str] = mapped_column(String(255), index=True)
protocol: Mapped[str] = mapped_column(String(32))
inbound_id: Mapped[int] = mapped_column(Integer)
inbound_remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
flow: Mapped[str | None] = mapped_column(String(64), nullable=True)
private_key: Mapped[str | None] = mapped_column(Text, nullable=True)
public_key: Mapped[str | None] = mapped_column(Text, nullable=True)
link: Mapped[str | None] = mapped_column(Text, nullable=True)
expiry_days: Mapped[int] = mapped_column(Integer, default=0)
start_after_first_use: Mapped[bool] = mapped_column(Boolean, default=False)
expiry_time: Mapped[int] = mapped_column(Integer, default=0)
total_gb: Mapped[int] = mapped_column(Integer, default=0)
limit_ip: Mapped[int] = mapped_column(Integer, default=0)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
created_via: Mapped[str] = mapped_column(String(32), default="website")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
panel: Mapped[XuiPanel] = relationship(back_populates="site_clients")
+80 -2
View File
@@ -8,8 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import AdminUser, Protocol, VpnClient, VpnServer from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel
from app.services import vpn as vpn_service from app.services import vpn as vpn_service
from app.services import xui_panels as xui_service
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -230,18 +231,41 @@ async def update_server(
async def clients_page( async def clients_page(
request: Request, request: Request,
protocol: str | None = None, protocol: str | None = None,
source: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
tab = (source or protocol or "wg").lower()
if tab in ("wireguard", "awg2"):
tab = "wg"
if tab not in ("wg", "xui"):
tab = "wg"
query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc()) query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc())
if protocol: if protocol in ("wireguard", "awg2"):
query = query.join(VpnServer).where(VpnServer.protocol == protocol) query = query.join(VpnServer).where(VpnServer.protocol == protocol)
tab = "wg"
clients = (await db.execute(query)).scalars().all() clients = (await db.execute(query)).scalars().all()
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all() servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
xui_panels = await xui_service.list_panels(db)
xui_clients = await xui_service.list_all_site_clients(db) if tab == "xui" else []
user_inbounds_by_panel: dict[int, list] = {}
if tab == "xui":
for panel in xui_panels:
rows = await xui_service.list_user_inbounds(db, panel.id)
user_inbounds_by_panel[panel.id] = [
{
"inbound_id": r.inbound_id,
"protocol": r.protocol,
"remark": r.remark,
"port": r.port,
}
for r in rows
]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/clients.html", "admin/clients.html",
@@ -250,6 +274,10 @@ async def clients_page(
"admin": admin, "admin": admin,
"clients": clients, "clients": clients,
"servers": servers, "servers": servers,
"xui_panels": xui_panels,
"xui_clients": xui_clients,
"user_inbounds_by_panel": user_inbounds_by_panel,
"tab": tab,
"filter_protocol": protocol, "filter_protocol": protocol,
"protocols": Protocol, "protocols": Protocol,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
@@ -279,6 +307,56 @@ async def create_client(
return RedirectResponse("/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse("/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/clients/xui")
async def create_xui_client_from_clients(
request: Request,
panel_id: int = Form(...),
protocol: str = Form(...),
email: str = Form(...),
inbound_id: int = Form(...),
total_gb: int = Form(0),
limit_ip: int = Form(0),
expiry_days: int = Form(30),
start_after_first_use: str = Form(""),
flow: str = Form(""),
comment: str = Form(""),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
panel = await db.get(XuiPanel, panel_id)
if not panel:
return RedirectResponse(
"/admin/clients?source=xui&flash=error:Панель не найдена",
status_code=status.HTTP_303_SEE_OTHER,
)
try:
await xui_service.add_xui_client(
db,
panel,
protocol=protocol,
email=email.strip(),
inbound_id=inbound_id,
total_gb=total_gb,
limit_ip=limit_ip,
expiry_days=expiry_days,
start_after_first_use=start_after_first_use == "1",
flow=flow.strip(),
comment=comment.strip(),
require_user_available=True,
)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/clients?source=xui&flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
"/admin/clients?source=xui&flash=created",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post("/clients/{client_id}/toggle") @router.post("/clients/{client_id}/toggle")
async def toggle_client( async def toggle_client(
client_id: int, client_id: int,
+69 -1
View File
@@ -102,22 +102,83 @@ async def xui_delete(
async def xui_inbounds_api( async def xui_inbounds_api(
panel_id: int, panel_id: int,
protocol: str | None = Query(None), protocol: str | None = Query(None),
for_users: int = Query(0),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
"""JSON: available inbounds for dropdown (optionally filtered by protocol).""" """JSON: inbounds for dropdown. for_users=1 — only admin-whitelisted."""
if _is_redirect(admin): if _is_redirect(admin):
return JSONResponse({"success": False, "error": "unauthorized"}, status_code=401) return JSONResponse({"success": False, "error": "unauthorized"}, status_code=401)
panel = await db.get(XuiPanel, panel_id) panel = await db.get(XuiPanel, panel_id)
if not panel: if not panel:
return JSONResponse({"success": False, "error": "not found"}, status_code=404) return JSONResponse({"success": False, "error": "not found"}, status_code=404)
try: try:
if for_users:
rows = await xui_service.list_user_inbounds(db, panel_id, protocol=protocol)
items = [
{
"id": r.inbound_id,
"protocol": r.protocol,
"remark": r.remark,
"port": r.port,
"enable": r.enable,
"is_available_for_users": True,
}
for r in rows
]
else:
items = await xui_service.load_available_inbounds(panel, protocol=protocol) items = await xui_service.load_available_inbounds(panel, protocol=protocol)
return JSONResponse({"success": True, "obj": items}) return JSONResponse({"success": True, "obj": items})
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return JSONResponse({"success": False, "error": str(exc)}, status_code=400) return JSONResponse({"success": False, "error": str(exc)}, status_code=400)
@router.post("/{panel_id}/inbounds/sync")
async def xui_inbounds_sync(
panel_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
try:
await xui_service.sync_panel_inbounds(db, panel_id)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/xui/{panel_id}?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
f"/admin/xui/{panel_id}?flash=inbounds_synced",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post("/{panel_id}/inbounds/users")
async def xui_inbounds_save_users(
panel_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
form = await request.form()
raw = form.getlist("inbound_ids")
inbound_ids = [int(v) for v in raw if str(v).isdigit()]
try:
await xui_service.save_user_inbounds(db, panel_id, inbound_ids)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/xui/{panel_id}?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
f"/admin/xui/{panel_id}?flash=inbounds_saved",
status_code=status.HTTP_303_SEE_OTHER,
)
@router.get("/{panel_id}", response_class=HTMLResponse) @router.get("/{panel_id}", response_class=HTMLResponse)
async def xui_detail( async def xui_detail(
panel_id: int, panel_id: int,
@@ -133,8 +194,12 @@ async def xui_detail(
data = None data = None
error = None error = None
site_clients = await xui_service.list_site_clients(db, panel_id)
panel_inbounds = await xui_service.list_panel_inbounds(db, panel_id)
try: try:
data = await xui_service.fetch_panel_data(panel) data = await xui_service.fetch_panel_data(panel)
if not panel_inbounds:
panel_inbounds = await xui_service.sync_panel_inbounds(db, panel_id)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
error = str(exc) error = str(exc)
@@ -153,6 +218,8 @@ async def xui_detail(
"admin": admin, "admin": admin,
"panel": panel, "panel": panel,
"data": data, "data": data,
"site_clients": site_clients,
"panel_inbounds": panel_inbounds,
"created": created, "created": created,
"error": error, "error": error,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
@@ -183,6 +250,7 @@ async def xui_add_client(
return RedirectResponse("/admin/xui", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse("/admin/xui", status_code=status.HTTP_303_SEE_OTHER)
try: try:
result = await xui_service.add_xui_client( result = await xui_service.add_xui_client(
db,
panel, panel,
protocol=protocol, protocol=protocol,
email=email.strip(), email=email.strip(),
+176 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import XuiPanel from app.models import XuiPanel, XuiPanelInbound, XuiSiteClient
from app.services.xui import ( from app.services.xui import (
SUPPORTED_CLIENT_PROTOCOLS, SUPPORTED_CLIENT_PROTOCOLS,
XuiApiError, XuiApiError,
@@ -93,6 +93,11 @@ async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel:
await session.commit() await session.commit()
await session.refresh(panel) await session.refresh(panel)
try:
await sync_panel_inbounds(session, panel_id)
except Exception: # noqa: BLE001
pass
await session.refresh(panel)
return panel return panel
@@ -140,6 +145,110 @@ async def load_available_inbounds(
return await client.list_available_inbounds(protocols=protocols, enabled_only=True) 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: def compute_expiry_time(*, days: int, start_after_first_use: bool) -> int:
""" """
3x-ui expiryTime encoding: 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 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( async def add_xui_client(
session: AsyncSession,
panel: XuiPanel, panel: XuiPanel,
*, *,
protocol: str, protocol: str,
@@ -170,8 +300,14 @@ async def add_xui_client(
start_after_first_use: bool = False, start_after_first_use: bool = False,
flow: str = "", flow: str = "",
comment: str = "", comment: str = "",
require_user_available: bool = False,
) -> dict: ) -> dict:
proto = protocol.strip().lower() 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 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) expiry_time = compute_expiry_time(days=expiry_days, start_after_first_use=start_after_first_use)
async with _client_for(panel) as client: async with _client_for(panel) as client:
@@ -196,6 +332,45 @@ async def add_xui_client(
) )
else: else:
raise ValueError("Поддерживаются только vless и wireguard") 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["expiry_days"] = expiry_days
result["start_after_first_use"] = start_after_first_use result["start_after_first_use"] = start_after_first_use
result["site_client_id"] = row.id
result["created_via"] = "website"
return result return result
+165 -3
View File
@@ -4,9 +4,12 @@
<div class="admin-top"> <div class="admin-top">
<h1>Клиенты</h1> <h1>Клиенты</h1>
<div class="actions"> <div class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/clients">Все</a> <a class="btn btn-sm {% if tab == 'wg' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients">WireGuard / AWG</a>
<a class="btn btn-sm {% if tab == 'xui' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients?source=xui">3x-ui</a>
{% if tab == 'wg' %}
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=wireguard">WireGuard</a> <a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=wireguard">WireGuard</a>
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=awg2">AWG 2.0</a> <a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=awg2">AWG 2.0</a>
{% endif %}
</div> </div>
</div> </div>
@@ -20,8 +23,9 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if tab == 'wg' %}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Новый доступ</strong></div> <div class="panel-head"><strong>Новый доступ (WireGuard / AWG)</strong></div>
<div class="panel-body"> <div class="panel-body">
<form method="post" action="/admin/clients" class="form-grid"> <form method="post" action="/admin/clients" class="form-grid">
<label>Имя <label>Имя
@@ -31,10 +35,12 @@
<select name="server_id" required> <select name="server_id" required>
{% for s in servers %} {% for s in servers %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.protocol }})</option> <option value="{{ s.id }}">{{ s.name }} ({{ s.protocol }})</option>
{% else %}
<option value="" disabled selected>Нет серверов</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<button class="btn btn-accent" type="submit">Создать</button> <button class="btn btn-accent" type="submit" {% if not servers %}disabled{% endif %}>Создать</button>
<label style="grid-column: 1 / -1">Заметка <label style="grid-column: 1 / -1">Заметка
<input name="notes" placeholder="опционально" /> <input name="notes" placeholder="опционально" />
</label> </label>
@@ -84,4 +90,160 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %}
{# ——— 3x-ui ——— #}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head">
<strong>Новый клиент 3x-ui</strong>
<span class="muted">только inbound’ы, отмеченные на сервере как доступные пользователям</span>
</div>
<div class="panel-body">
{% if not xui_panels %}
<p class="muted" style="margin:0">Сначала добавьте панель на странице <a href="/admin/xui">3x-ui</a>.</p>
{% else %}
<form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form">
<div class="form-grid">
<label>Сервер 3x-ui
<select name="panel_id" id="xui-panel" required>
{% for p in xui_panels %}
<option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option>
{% endfor %}
</select>
</label>
<label>Протокол
<select name="protocol" id="xui-protocol" required>
<option value="vless">VLESS</option>
<option value="wireguard">WireGuard</option>
</select>
</label>
<label>Inbound
<select name="inbound_id" id="xui-inbound" required>
<option value="" disabled selected>Выберите сервер</option>
</select>
</label>
</div>
<div class="form-grid">
<label>Email / имя
<input name="email" placeholder="user@example.com" required />
</label>
<label>Срок (дни, 0 = безлимит)
<input type="number" name="expiry_days" value="30" min="0" />
</label>
<label>Лимит GB
<input type="number" name="total_gb" value="0" min="0" />
</label>
<label>Limit IP
<input type="number" name="limit_ip" value="0" min="0" />
</label>
</div>
<label class="check">
<input type="checkbox" name="start_after_first_use" value="1" checked />
Старт после первого использования
</label>
<label id="xui-flow-wrap">Flow (VLESS)
<input name="flow" placeholder="xtls-rprx-vision" />
</label>
<label>Комментарий
<input name="comment" />
</label>
<button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать в 3x-ui</button>
<p class="muted" id="xui-inbound-hint" style="margin:0">Нет доступных inbound’ов — откройте сервер в <a href="/admin/xui">3x-ui</a> и отметьте нужные.</p>
</form>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-head"><strong>Клиенты, созданные через сайт (3x-ui)</strong></div>
<table>
<thead>
<tr>
<th>Email</th>
<th>Сервер</th>
<th>Протокол</th>
<th>Inbound</th>
<th>Срок</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in xui_clients %}
<tr>
<td>
<strong>{{ c.email }}</strong>
{% if c.link %}<div class="muted" style="font-size:.78rem;max-width:240px;word-break:break-all">{{ c.link[:64] }}{% if c.link|length > 64 %}…{% endif %}</div>{% endif %}
</td>
<td>{{ c.panel.name if c.panel else '—' }}</td>
<td><span class="badge badge-proto">{{ c.protocol }}</span></td>
<td class="muted">#{{ c.inbound_id }} {% if c.inbound_remark %}{{ c.inbound_remark }}{% endif %}</td>
<td class="muted">
{% if c.expiry_days and c.expiry_days > 0 %}
{{ c.expiry_days }} дн.{% if c.start_after_first_use %} · после 1-го входа{% endif %}
{% else %}без срока{% endif %}
</td>
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
<td>
{% if c.panel %}
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ c.panel_id }}">Панель</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Клиентов 3x-ui пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(function () {
const panelSelect = document.getElementById('xui-panel');
const protocolSelect = document.getElementById('xui-protocol');
const inboundSelect = document.getElementById('xui-inbound');
const submitBtn = document.getElementById('xui-submit');
const hint = document.getElementById('xui-inbound-hint');
const flowWrap = document.getElementById('xui-flow-wrap');
if (!panelSelect || !inboundSelect) return;
const cached = {{ user_inbounds_by_panel | tojson }};
function fillInbounds() {
const panelId = panelSelect.value;
const protocol = protocolSelect.value;
const rows = (cached[panelId] || []).filter((r) => r.protocol === protocol);
inboundSelect.innerHTML = '';
if (!rows.length) {
const opt = document.createElement('option');
opt.value = '';
opt.disabled = true;
opt.selected = true;
opt.textContent = 'Нет доступных inbound’ов';
inboundSelect.appendChild(opt);
submitBtn.disabled = true;
if (hint) hint.style.display = '';
return;
}
rows.forEach((r) => {
const opt = document.createElement('option');
opt.value = r.inbound_id;
opt.textContent = `#${r.inbound_id} ${r.remark || r.protocol} :${r.port || '?'}`;
inboundSelect.appendChild(opt);
});
submitBtn.disabled = false;
if (hint) hint.style.display = 'none';
}
function toggleFlow() {
if (flowWrap) flowWrap.style.display = protocolSelect.value === 'vless' ? '' : 'none';
}
panelSelect.addEventListener('change', fillInbounds);
protocolSelect.addEventListener('change', () => { toggleFlow(); fillInbounds(); });
toggleFlow();
fillInbounds();
})();
</script>
{% endif %}
{% endblock %} {% endblock %}
+108 -2
View File
@@ -20,6 +20,10 @@
<div class="flash flash-error">{{ flash[6:] }}</div> <div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'client_created' %} {% elif flash == 'client_created' %}
<div class="flash">Клиент создан в 3x-ui</div> <div class="flash">Клиент создан в 3x-ui</div>
{% elif flash == 'inbounds_synced' %}
<div class="flash">Inbound’ы синхронизированы с 3x-ui</div>
{% elif flash == 'inbounds_saved' %}
<div class="flash">Доступные пользователям inbound’ы сохранены</div>
{% elif flash == 'ok' %} {% elif flash == 'ok' %}
<div class="flash">Данные обновлены</div> <div class="flash">Данные обновлены</div>
{% endif %} {% endif %}
@@ -65,6 +69,60 @@ PublicKey = {{ created.publicKey }}
</div> </div>
{% endif %} {% endif %}
<div class="panel">
<div class="panel-head">
<strong>Создано через сайт</strong>
<span class="muted">только клиенты, которых вы создали в этой панели</span>
</div>
<table>
<thead>
<tr>
<th>Email</th>
<th>Протокол</th>
<th>Inbound</th>
<th>Срок</th>
<th>Лимиты</th>
<th>Ссылка / ключи</th>
<th>Когда</th>
</tr>
</thead>
<tbody>
{% for c in site_clients %}
<tr>
<td>
<strong>{{ c.email }}</strong>
{% if c.comment %}<div class="muted">{{ c.comment }}</div>{% endif %}
</td>
<td><span class="badge badge-proto">{{ c.protocol }}</span></td>
<td class="muted">#{{ c.inbound_id }} {% if c.inbound_remark %}{{ c.inbound_remark }}{% endif %}</td>
<td class="muted">
{% if c.expiry_days and c.expiry_days > 0 %}
{{ c.expiry_days }} дн.
{% if c.start_after_first_use %}<br><span class="badge badge-ok">после 1-го входа</span>{% endif %}
{% else %}
без срока
{% endif %}
</td>
<td class="muted">
{{ c.total_gb or 0 }} GB
{% if c.limit_ip %}<br>IP ≤ {{ c.limit_ip }}{% endif %}
</td>
<td>
{% if c.link %}
<div class="link-box" style="margin:0;max-width:280px;font-size:.78rem;word-break:break-all;">{{ c.link }}</div>
{% elif c.private_key %}
<div class="muted" style="font-size:.78rem;word-break:break-all;">priv: {{ c.private_key[:24] }}…</div>
{% else %}—{% endif %}
</td>
<td class="muted">{{ c.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">Пока никого не создавали через сайт</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if data %} {% if data %}
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
@@ -165,10 +223,55 @@ PublicKey = {{ created.publicKey }}
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"> <div class="panel-head">
<strong>Доступные inbound’ы (VLESS / WireGuard)</strong> <strong>Inbound’ы для пользователей</strong>
<span class="muted">отметьте, какие будут доступны при создании клиентов</span>
</div>
<form method="post" action="/admin/xui/{{ panel.id }}/inbounds/users">
<table>
<thead>
<tr>
<th>Для пользователей</th>
<th>ID</th>
<th>Remark</th>
<th>Protocol</th>
<th>Port</th>
<th>В 3x-ui</th>
</tr>
</thead>
<tbody>
{% for ib in panel_inbounds %}
<tr>
<td>
<label class="check" style="margin:0">
<input type="checkbox" name="inbound_ids" value="{{ ib.inbound_id }}" {% if ib.is_available_for_users %}checked{% endif %} />
</label>
</td>
<td>{{ ib.inbound_id }}</td>
<td>{{ ib.remark or '—' }}</td>
<td><span class="badge badge-proto">{{ ib.protocol }}</span></td>
<td>{{ ib.port or '—' }}</td>
<td>{% if ib.enable %}<span class="badge badge-ok">on</span>{% else %}<span class="badge badge-off">off</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Список пуст — нажмите «Синхронизировать inbound’ы»</td></tr>
{% endfor %}
</tbody>
</table>
<div class="actions" style="padding:1rem;gap:.5rem;display:flex;flex-wrap:wrap">
<button class="btn btn-accent" type="submit" {% if not panel_inbounds %}disabled{% endif %}>Сохранить для пользователей</button>
<button class="btn btn-ghost" type="submit" formaction="/admin/xui/{{ panel.id }}/inbounds/sync" formmethod="post">Синхронизировать с 3x-ui</button>
</div>
</form>
</div>
{% if data %}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head">
<strong>Все inbound’ы из API (VLESS / WireGuard)</strong>
<span class="muted" id="inbounds-status"></span> <span class="muted" id="inbounds-status"></span>
</div> </div>
<table> <table>
@@ -198,7 +301,10 @@ PublicKey = {{ created.publicKey }}
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-head"><strong>Clients</strong></div> <div class="panel-head">
<strong>Все клиенты 3x-ui</strong>
<span class="muted">из API панели (включая созданных не через сайт)</span>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
+1
View File
@@ -12,4 +12,5 @@ itsdangerous==2.2.0
qrcode[pil]==8.0 qrcode[pil]==8.0
httpx==0.28.1 httpx==0.28.1
greenlet==3.1.1 greenlet==3.1.1
cryptography==44.0.0
paramiko==3.5.0 paramiko==3.5.0