diff --git a/alembic/versions/0006_xui_site_clients.py b/alembic/versions/0006_xui_site_clients.py new file mode 100644 index 0000000..fa2723b --- /dev/null +++ b/alembic/versions/0006_xui_site_clients.py @@ -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") diff --git a/alembic/versions/0007_xui_panel_inbounds.py b/alembic/versions/0007_xui_panel_inbounds.py new file mode 100644 index 0000000..4482feb --- /dev/null +++ b/alembic/versions/0007_xui_panel_inbounds.py @@ -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") diff --git a/app/models.py b/app/models.py index a5b9bbe..2026701 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/routers/admin.py b/app/routers/admin.py index dc7ac51..7638f27 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -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, diff --git a/app/routers/xui.py b/app/routers/xui.py index 04120db..e4d5bf4 100644 --- a/app/routers/xui.py +++ b/app/routers/xui.py @@ -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: - items = await xui_service.load_available_inbounds(panel, protocol=protocol) + 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(), diff --git a/app/services/xui_panels.py b/app/services/xui_panels.py index d77f8fe..56c0b8d 100644 --- a/app/services/xui_panels.py +++ b/app/services/xui_panels.py @@ -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 diff --git a/app/templates/admin/clients.html b/app/templates/admin/clients.html index b70fe5b..ed859ae 100644 --- a/app/templates/admin/clients.html +++ b/app/templates/admin/clients.html @@ -4,9 +4,12 @@

Клиенты

- Все + WireGuard / AWG + 3x-ui + {% if tab == 'wg' %} WireGuard AWG 2.0 + {% endif %}
@@ -20,8 +23,9 @@ {% endif %} {% endif %} +{% if tab == 'wg' %}
-
Новый доступ
+
Новый доступ (WireGuard / AWG)
- + @@ -84,4 +90,160 @@
+ +{% else %} +{# ——— 3x-ui ——— #} +
+
+ Новый клиент 3x-ui + только inbound’ы, отмеченные на сервере как доступные пользователям +
+
+ {% if not xui_panels %} +

Сначала добавьте панель на странице 3x-ui.

+ {% else %} + +
+ + + +
+
+ + + + +
+ + + + +

Нет доступных inbound’ов — откройте сервер в 3x-ui и отметьте нужные.

+ + {% endif %} +
+
+ +
+
Клиенты, созданные через сайт (3x-ui)
+ + + + + + + + + + + + + + {% for c in xui_clients %} + + + + + + + + + + {% else %} + + {% endfor %} + +
EmailСерверПротоколInboundСрокСоздан
+ {{ c.email }} + {% if c.link %}
{{ c.link[:64] }}{% if c.link|length > 64 %}…{% endif %}
{% endif %} +
{{ c.panel.name if c.panel else '—' }}{{ c.protocol }}#{{ c.inbound_id }} {% if c.inbound_remark %}{{ c.inbound_remark }}{% endif %} + {% if c.expiry_days and c.expiry_days > 0 %} + {{ c.expiry_days }} дн.{% if c.start_after_first_use %} · после 1-го входа{% endif %} + {% else %}без срока{% endif %} + {{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }} + {% if c.panel %} + Панель + {% endif %} +
Клиентов 3x-ui пока нет
+
+ + +{% endif %} {% endblock %} diff --git a/app/templates/admin/xui_detail.html b/app/templates/admin/xui_detail.html index 7aad174..bdf82ae 100644 --- a/app/templates/admin/xui_detail.html +++ b/app/templates/admin/xui_detail.html @@ -20,6 +20,10 @@
{{ flash[6:] }}
{% elif flash == 'client_created' %}
Клиент создан в 3x-ui
+ {% elif flash == 'inbounds_synced' %} +
Inbound’ы синхронизированы с 3x-ui
+ {% elif flash == 'inbounds_saved' %} +
Доступные пользователям inbound’ы сохранены
{% elif flash == 'ok' %}
Данные обновлены
{% endif %} @@ -65,6 +69,60 @@ PublicKey = {{ created.publicKey }}
{% endif %} +
+
+ Создано через сайт + только клиенты, которых вы создали в этой панели +
+ + + + + + + + + + + + + + {% for c in site_clients %} + + + + + + + + + + {% else %} + + {% endfor %} + +
EmailПротоколInboundСрокЛимитыСсылка / ключиКогда
+ {{ c.email }} + {% if c.comment %}
{{ c.comment }}
{% endif %} +
{{ c.protocol }}#{{ c.inbound_id }} {% if c.inbound_remark %}{{ c.inbound_remark }}{% endif %} + {% if c.expiry_days and c.expiry_days > 0 %} + {{ c.expiry_days }} дн. + {% if c.start_after_first_use %}
после 1-го входа{% endif %} + {% else %} + без срока + {% endif %} +
+ {{ c.total_gb or 0 }} GB + {% if c.limit_ip %}
IP ≤ {{ c.limit_ip }}{% endif %} +
+ {% if c.link %} + + {% elif c.private_key %} +
priv: {{ c.private_key[:24] }}…
+ {% else %}—{% endif %} +
{{ c.created_at }}
Пока никого не создавали через сайт
+
+ {% if data %}
@@ -165,10 +223,55 @@ PublicKey = {{ created.publicKey }}
+{% endif %}
- Доступные inbound’ы (VLESS / WireGuard) + Inbound’ы для пользователей + отметьте, какие будут доступны при создании клиентов +
+
+ + + + + + + + + + + + + {% for ib in panel_inbounds %} + + + + + + + + + {% else %} + + {% endfor %} + +
Для пользователейIDRemarkProtocolPortВ 3x-ui
+ + {{ ib.inbound_id }}{{ ib.remark or '—' }}{{ ib.protocol }}{{ ib.port or '—' }}{% if ib.enable %}on{% else %}off{% endif %}
Список пуст — нажмите «Синхронизировать inbound’ы»
+
+ + +
+
+
+ +{% if data %} +
+
+ Все inbound’ы из API (VLESS / WireGuard)
@@ -198,7 +301,10 @@ PublicKey = {{ created.publicKey }}
-
Clients
+
+ Все клиенты 3x-ui + из API панели (включая созданных не через сайт) +
diff --git a/requirements.txt b/requirements.txt index 5a4a35c..3b69950 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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