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)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
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 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 xui_panels as xui_service
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -230,18 +231,41 @@ async def update_server(
async def clients_page(
request: Request,
protocol: str | None = None,
source: str | None = None,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(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())
if protocol:
if protocol in ("wireguard", "awg2"):
query = query.join(VpnServer).where(VpnServer.protocol == protocol)
tab = "wg"
clients = (await db.execute(query)).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(
"admin/clients.html",
@@ -250,6 +274,10 @@ async def clients_page(
"admin": admin,
"clients": clients,
"servers": servers,
"xui_panels": xui_panels,
"xui_clients": xui_clients,
"user_inbounds_by_panel": user_inbounds_by_panel,
"tab": tab,
"filter_protocol": protocol,
"protocols": Protocol,
"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)
@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")
async def toggle_client(
client_id: int,
+69 -1
View File
@@ -102,22 +102,83 @@ async def xui_delete(
async def xui_inbounds_api(
panel_id: int,
protocol: str | None = Query(None),
for_users: int = Query(0),
db: AsyncSession = Depends(get_db),
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):
return JSONResponse({"success": False, "error": "unauthorized"}, status_code=401)
panel = await db.get(XuiPanel, panel_id)
if not panel:
return JSONResponse({"success": False, "error": "not found"}, status_code=404)
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)
return JSONResponse({"success": True, "obj": items})
except Exception as exc: # noqa: BLE001
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)
async def xui_detail(
panel_id: int,
@@ -133,8 +194,12 @@ async def xui_detail(
data = 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:
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
error = str(exc)
@@ -153,6 +218,8 @@ async def xui_detail(
"admin": admin,
"panel": panel,
"data": data,
"site_clients": site_clients,
"panel_inbounds": panel_inbounds,
"created": created,
"error": error,
"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)
try:
result = await xui_service.add_xui_client(
db,
panel,
protocol=protocol,
email=email.strip(),
+176 -1
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")
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
+165 -3
View File
@@ -4,9 +4,12 @@
<div class="admin-top">
<h1>Клиенты</h1>
<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=awg2">AWG 2.0</a>
{% endif %}
</div>
</div>
@@ -20,8 +23,9 @@
{% endif %}
{% endif %}
{% if tab == 'wg' %}
<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">
<form method="post" action="/admin/clients" class="form-grid">
<label>Имя
@@ -31,10 +35,12 @@
<select name="server_id" required>
{% for s in servers %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.protocol }})</option>
{% else %}
<option value="" disabled selected>Нет серверов</option>
{% endfor %}
</select>
</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">Заметка
<input name="notes" placeholder="опционально" />
</label>
@@ -84,4 +90,160 @@
</tbody>
</table>
</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 %}
+108 -2
View File
@@ -20,6 +20,10 @@
<div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'client_created' %}
<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' %}
<div class="flash">Данные обновлены</div>
{% endif %}
@@ -65,6 +69,60 @@ PublicKey = {{ created.publicKey }}
</div>
{% 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 %}
<div class="stats">
<div class="stat">
@@ -165,10 +223,55 @@ PublicKey = {{ created.publicKey }}
</div>
</div>
</div>
{% endif %}
<div class="panel" style="margin-bottom:1rem">
<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>
</div>
<table>
@@ -198,7 +301,10 @@ PublicKey = {{ created.publicKey }}
</div>
<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>
<thead>
<tr>
+1
View File
@@ -12,4 +12,5 @@ itsdangerous==2.2.0
qrcode[pil]==8.0
httpx==0.28.1
greenlet==3.1.1
cryptography==44.0.0
paramiko==3.5.0