diff --git a/alembic/versions/0005_xui_panels.py b/alembic/versions/0005_xui_panels.py new file mode 100644 index 0000000..e0da72c --- /dev/null +++ b/alembic/versions/0005_xui_panels.py @@ -0,0 +1,39 @@ +"""Add xui_panels for 3x-ui API connections + +Revision ID: 0005_xui_panels +Revises: 0004_real_vpn +Create Date: 2026-07-25 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005_xui_panels" +down_revision: Union[str, None] = "0004_real_vpn" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "xui_panels", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("base_url", sa.String(length=512), nullable=False), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("password", sa.Text(), nullable=True), + sa.Column("api_token", sa.Text(), nullable=True), + sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("is_reachable", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("panel_info", sa.Text(), nullable=True), + sa.Column("last_checked", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("xui_panels") diff --git a/app/main.py b/app/main.py index f47e260..2d870a3 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from fastapi.templating import Jinja2Templates from app.config import settings from app.deps import install_session_middleware -from app.routers import admin, auth, public +from app.routers import admin, auth, public, xui def create_app() -> FastAPI: @@ -23,6 +23,7 @@ def create_app() -> FastAPI: app.include_router(public.router) app.include_router(auth.router) app.include_router(admin.router) + app.include_router(xui.router) return app diff --git a/app/models.py b/app/models.py index 62b6eec..a5b9bbe 100644 --- a/app/models.py +++ b/app/models.py @@ -90,3 +90,23 @@ class VpnClient(Base): ) server: Mapped[VpnServer] = relationship(back_populates="clients") + + +class XuiPanel(Base): + """Remote 3x-ui panel connection (API v3.5.0).""" + + __tablename__ = "xui_panels" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120)) + base_url: Mapped[str] = mapped_column(String(512)) + username: Mapped[str | None] = mapped_column(String(128), nullable=True) + password: Mapped[str | None] = mapped_column(Text, nullable=True) + api_token: Mapped[str | None] = mapped_column(Text, nullable=True) + verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True) + is_reachable: Mapped[bool] = mapped_column(Boolean, default=False) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + panel_info: Mapped[str | None] = mapped_column(Text, nullable=True) + 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()) diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 113c7b0..9d970a2 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,3 +1,3 @@ -from app.routers import admin, auth, public +from app.routers import admin, auth, public, xui -__all__ = ["admin", "auth", "public"] +__all__ = ["admin", "auth", "public", "xui"] diff --git a/app/routers/xui.py b/app/routers/xui.py new file mode 100644 index 0000000..db65863 --- /dev/null +++ b/app/routers/xui.py @@ -0,0 +1,170 @@ +"""Admin routes for 3x-ui API panels.""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, Form, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models import XuiPanel +from app.routers.admin import _is_redirect, require_admin +from app.services import xui_panels as xui_service + +router = APIRouter(prefix="/admin/xui", tags=["xui"]) + + +@router.get("", response_class=HTMLResponse) +async def xui_list(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)): + if _is_redirect(admin): + return admin + panels = await xui_service.list_panels(db) + return request.app.state.templates.TemplateResponse( + "admin/xui.html", + { + "request": request, + "admin": admin, + "panels": panels, + "app_name": request.app.state.settings.app_name, + "flash": request.query_params.get("flash"), + }, + ) + + +@router.post("") +async def xui_create( + name: str = Form(""), + base_url: str = Form(...), + username: str = Form(""), + password: str = Form(""), + api_token: str = Form(""), + verify_ssl: str = Form(""), + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + try: + await xui_service.create_panel( + db, + name=name, + base_url=base_url, + username=username, + password=password, + api_token=api_token, + verify_ssl=verify_ssl == "1", + ) + except Exception as exc: # noqa: BLE001 + return RedirectResponse( + f"/admin/xui?flash=error:{exc}", + status_code=status.HTTP_303_SEE_OTHER, + ) + return RedirectResponse("/admin/xui?flash=created", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/{panel_id}/check") +async def xui_check( + panel_id: int, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + try: + await xui_service.check_panel(db, panel_id) + except Exception as exc: # noqa: BLE001 + return RedirectResponse( + f"/admin/xui?flash=error:{exc}", + status_code=status.HTTP_303_SEE_OTHER, + ) + return RedirectResponse("/admin/xui?flash=ok", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/{panel_id}/delete") +async def xui_delete( + panel_id: int, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + await xui_service.delete_panel(db, panel_id) + return RedirectResponse("/admin/xui?flash=deleted", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/{panel_id}", response_class=HTMLResponse) +async def xui_detail( + panel_id: int, + request: Request, + 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/xui", status_code=status.HTTP_303_SEE_OTHER) + + data = None + error = None + try: + data = await xui_service.fetch_panel_data(panel) + except Exception as exc: # noqa: BLE001 + error = str(exc) + + info = None + if panel.panel_info: + try: + info = json.loads(panel.panel_info) + except json.JSONDecodeError: + info = None + + return request.app.state.templates.TemplateResponse( + "admin/xui_detail.html", + { + "request": request, + "admin": admin, + "panel": panel, + "data": data, + "info": info, + "error": error, + "app_name": request.app.state.settings.app_name, + "flash": request.query_params.get("flash"), + }, + ) + + +@router.post("/{panel_id}/clients") +async def xui_add_client( + panel_id: int, + email: str = Form(...), + inbound_id: int = Form(...), + total_gb: int = Form(0), + limit_ip: int = Form(0), + 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/xui", status_code=status.HTTP_303_SEE_OTHER) + try: + await xui_service.add_xui_client( + panel, + email=email.strip(), + inbound_ids=[inbound_id], + total_gb=total_gb, + limit_ip=limit_ip, + ) + 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=client_created", + status_code=status.HTTP_303_SEE_OTHER, + ) diff --git a/app/services/xui.py b/app/services/xui.py new file mode 100644 index 0000000..0581373 --- /dev/null +++ b/app/services/xui.py @@ -0,0 +1,236 @@ +"""3x-ui panel API client (compatible with v3.5.0).""" + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urljoin + +import httpx + +logger = logging.getLogger(__name__) + + +class XuiApiError(Exception): + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + + +class XuiClient: + """ + Client for MHSanaei/3x-ui REST API (v3.5.0). + + Auth options: + 1) API Token — Settings → Security → API Token → Authorization: Bearer … + 2) Username/password session (with CSRF when required) + """ + + def __init__( + self, + base_url: str, + *, + username: str | None = None, + password: str | None = None, + api_token: str | None = None, + verify_ssl: bool = True, + timeout: float = 30.0, + ): + self.base_url = base_url.rstrip("/") + self.username = (username or "").strip() or None + self.password = (password or "").strip() or None + self.api_token = (api_token or "").strip() or None + self.verify_ssl = verify_ssl + self.timeout = timeout + self._client: httpx.AsyncClient | None = None + self._logged_in = False + + if not self.api_token and not (self.username and self.password): + raise ValueError("Укажите API Token или логин/пароль 3x-ui") + + async def __aenter__(self) -> XuiClient: + headers: dict[str, str] = {"Accept": "application/json"} + if self.api_token: + headers["Authorization"] = f"Bearer {self.api_token}" + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=headers, + verify=self.verify_ssl, + timeout=self.timeout, + follow_redirects=True, + ) + if not self.api_token: + await self.login() + return self + + async def __aexit__(self, *args) -> None: + if self._client: + await self._client.aclose() + self._client = None + + @property + def client(self) -> httpx.AsyncClient: + if not self._client: + raise RuntimeError("XuiClient is not started") + return self._client + + async def login(self) -> None: + """Session login for cookie auth (when no API token).""" + assert self.username and self.password + # Warm-up cookies / CSRF from login page if present + csrf: str | None = None + try: + warm = await self.client.get("/") + csrf = warm.headers.get("X-CSRF-Token") or warm.cookies.get("csrf_token") + except Exception: # noqa: BLE001 + pass + + try: + csrf_resp = await self.client.get("/panel/csrf-token") + if csrf_resp.status_code == 200: + data = csrf_resp.json() + if isinstance(data, dict) and data.get("obj"): + csrf = str(data["obj"]) + except Exception: # noqa: BLE001 + pass + + headers = {} + if csrf: + headers["X-CSRF-Token"] = csrf + + payload = {"username": self.username, "password": self.password} + # Try JSON first (v3), then form (older) + resp = await self.client.post("/login", json=payload, headers=headers) + if resp.status_code == 403 and csrf: + resp = await self.client.post( + "/login", + data=payload, + headers={**headers, "Content-Type": "application/x-www-form-urlencoded"}, + ) + elif resp.status_code >= 400: + resp = await self.client.post("/login", data=payload, headers=headers) + + if resp.status_code >= 400: + raise XuiApiError(f"Login failed HTTP {resp.status_code}: {resp.text[:300]}", resp.status_code) + + try: + body = resp.json() + except Exception: # noqa: BLE001 + body = {} + if isinstance(body, dict) and body.get("success") is False: + raise XuiApiError(body.get("msg") or "Wrong username or password") + self._logged_in = True + + async def _request(self, method: str, path: str, **kwargs) -> Any: + path = path if path.startswith("/") else f"/{path}" + resp = await self.client.request(method, path, **kwargs) + if resp.status_code == 401 and not self.api_token and not self._logged_in: + await self.login() + resp = await self.client.request(method, path, **kwargs) + + if resp.status_code >= 400: + raise XuiApiError(f"HTTP {resp.status_code}: {resp.text[:400]}", resp.status_code) + + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + raise XuiApiError(f"Invalid JSON response: {exc}") from exc + + if isinstance(data, dict) and data.get("success") is False: + raise XuiApiError(data.get("msg") or "API error") + return data + + async def get_status(self) -> dict: + data = await self._request("GET", "/panel/api/server/status") + return data.get("obj") if isinstance(data, dict) else data + + async def get_panel_update_info(self) -> dict: + data = await self._request("GET", "/panel/api/server/getPanelUpdateInfo") + return data.get("obj") if isinstance(data, dict) else data + + async def get_xray_version(self) -> Any: + data = await self._request("GET", "/panel/api/server/getXrayVersion") + return data.get("obj") if isinstance(data, dict) else data + + async def list_inbounds(self) -> list[dict]: + data = await self._request("GET", "/panel/api/inbounds/list") + obj = data.get("obj") if isinstance(data, dict) else data + return obj if isinstance(obj, list) else [] + + async def list_clients(self) -> list[dict]: + data = await self._request("GET", "/panel/api/clients/list") + obj = data.get("obj") if isinstance(data, dict) else data + return obj if isinstance(obj, list) else [] + + async def add_client( + self, + *, + email: str, + inbound_ids: list[int], + total_gb: int = 0, + expiry_time: int = 0, + limit_ip: int = 0, + enable: bool = True, + remark: str | None = None, + ) -> dict: + body = { + "client": { + "email": email, + "totalGB": total_gb, + "expiryTime": expiry_time, + "limitIp": limit_ip, + "enable": enable, + "tgId": 0, + "comment": remark or "", + }, + "inboundIds": inbound_ids, + } + return await self._request("POST", "/panel/api/clients/add", json=body) + + async def delete_client(self, email: str) -> dict: + return await self._request("POST", f"/panel/api/clients/del/{email}") + + async def get_client(self, email: str) -> dict: + data = await self._request("GET", f"/panel/api/clients/get/{email}") + return data.get("obj") if isinstance(data, dict) else data + + async def test_connection(self) -> dict[str, Any]: + """Verify credentials and return summary for UI.""" + status = await self.get_status() + info: dict[str, Any] = {"status": status} + try: + info["xray_version"] = await self.get_xray_version() + except Exception as exc: # noqa: BLE001 + info["xray_version_error"] = str(exc) + try: + inbounds = await self.list_inbounds() + info["inbounds_count"] = len(inbounds) + info["inbounds"] = [ + { + "id": i.get("id"), + "remark": i.get("remark"), + "protocol": i.get("protocol"), + "port": i.get("port"), + "enable": i.get("enable"), + } + for i in inbounds[:50] + ] + except Exception as exc: # noqa: BLE001 + info["inbounds_error"] = str(exc) + try: + clients = await self.list_clients() + info["clients_count"] = len(clients) + except Exception as exc: # noqa: BLE001 + info["clients_error"] = str(exc) + return info + + +def normalize_base_url(url: str) -> str: + url = url.strip().rstrip("/") + if not url.startswith(("http://", "https://")): + url = "https://" + url + return url + + +def public_panel_link(base_url: str) -> str: + return urljoin(normalize_base_url(base_url) + "/", "") diff --git a/app/services/xui_panels.py b/app/services/xui_panels.py new file mode 100644 index 0000000..e087dd0 --- /dev/null +++ b/app/services/xui_panels.py @@ -0,0 +1,134 @@ +"""CRUD + connection tests for linked 3x-ui panels.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import XuiPanel +from app.services.xui import XuiClient, XuiApiError, normalize_base_url + + +async def list_panels(session: AsyncSession) -> list[XuiPanel]: + result = await session.execute(select(XuiPanel).order_by(XuiPanel.id.desc())) + return list(result.scalars().all()) + + +async def create_panel( + session: AsyncSession, + *, + name: str, + base_url: str, + username: str = "", + password: str = "", + api_token: str = "", + verify_ssl: bool = True, +) -> XuiPanel: + token = api_token.strip() or None + user = username.strip() or None + pwd = password.strip() or None + if not token and not (user and pwd): + raise ValueError("Укажите API Token или логин и пароль") + + url = normalize_base_url(base_url) + panel = XuiPanel( + name=(name.strip() or url), + base_url=url, + username=user, + password=pwd, + api_token=token, + verify_ssl=verify_ssl, + is_enabled=True, + ) + session.add(panel) + await session.commit() + await session.refresh(panel) + + # Verify immediately + await check_panel(session, panel.id) + await session.refresh(panel) + if not panel.is_reachable: + raise ValueError(panel.last_error or "Не удалось подключиться к 3x-ui") + return panel + + +async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel: + panel = await session.get(XuiPanel, panel_id) + if not panel: + raise ValueError("Панель не найдена") + + now = datetime.now(timezone.utc) + try: + async with XuiClient( + panel.base_url, + username=panel.username, + password=panel.password, + api_token=panel.api_token, + verify_ssl=panel.verify_ssl, + ) as client: + info = await client.test_connection() + panel.is_reachable = True + panel.last_error = None + panel.panel_info = json.dumps(info, ensure_ascii=False, default=str)[:8000] + panel.last_checked = now + except (XuiApiError, ValueError, OSError) as exc: + panel.is_reachable = False + panel.last_error = str(exc)[:2000] + panel.last_checked = now + await session.commit() + await session.refresh(panel) + raise ValueError(str(exc)) from exc + + await session.commit() + await session.refresh(panel) + return panel + + +async def delete_panel(session: AsyncSession, panel_id: int) -> None: + panel = await session.get(XuiPanel, panel_id) + if panel: + await session.delete(panel) + await session.commit() + + +async def fetch_panel_data(panel: XuiPanel) -> dict: + async with XuiClient( + panel.base_url, + username=panel.username, + password=panel.password, + api_token=panel.api_token, + verify_ssl=panel.verify_ssl, + ) as client: + status = await client.get_status() + inbounds = await client.list_inbounds() + try: + clients = await client.list_clients() + except XuiApiError: + clients = [] + return {"status": status, "inbounds": inbounds, "clients": clients} + + +async def add_xui_client( + panel: XuiPanel, + *, + email: str, + inbound_ids: list[int], + total_gb: int = 0, + limit_ip: int = 0, +) -> dict: + async with XuiClient( + panel.base_url, + username=panel.username, + password=panel.password, + api_token=panel.api_token, + verify_ssl=panel.verify_ssl, + ) as client: + return await client.add_client( + email=email, + inbound_ids=inbound_ids, + total_gb=total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0, + limit_ip=limit_ip, + ) diff --git a/app/templates/admin/layout.html b/app/templates/admin/layout.html index e5c70ca..2adc07f 100644 --- a/app/templates/admin/layout.html +++ b/app/templates/admin/layout.html @@ -10,6 +10,7 @@ diff --git a/app/templates/admin/xui.html b/app/templates/admin/xui.html new file mode 100644 index 0000000..0cf9bbd --- /dev/null +++ b/app/templates/admin/xui.html @@ -0,0 +1,90 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}3x-ui{% endblock %} +{% block admin_content %} +
+ Укажите URL панели (с webBasePath, если есть) и API Token + (Settings → Security → API Token) — предпочтительно для v3.5.0. + Либо логин/пароль админа. +
+ ++ {{ p.base_url }} + · auth: {% if p.api_token %}API Token{% else %}login/password{% endif %} + {% if p.last_checked %}· checked {{ p.last_checked.strftime('%Y-%m-%d %H:%M') }}{% endif %} +
+ {% if p.last_error %} +Пока нет подключённых 3x-ui панелей.
+{% endfor %} +{% endblock %} diff --git a/app/templates/admin/xui_detail.html b/app/templates/admin/xui_detail.html new file mode 100644 index 0000000..3d0737f --- /dev/null +++ b/app/templates/admin/xui_detail.html @@ -0,0 +1,132 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}{{ panel.name }}{% endblock %} +{% block admin_content %} +| ID | +Remark | +Protocol | +Port | +Enable | +
|---|---|---|---|---|
| {{ ib.id }} | +{{ ib.remark or '—' }} | +{{ ib.protocol }} | +{{ ib.port }} | +{% if ib.enable %}on{% else %}off{% endif %} | +
| Нет inbound’ов | ||||
| Enable | +Inbounds | +Traffic | +|
|---|---|---|---|
| {{ c.email }} | +{% if c.enable %}on{% else %}off{% endif %} | +{{ c.inboundIds or c.inbound_ids or '—' }} | ++ {% if c.traffic %} + ↑ {{ c.traffic.up or 0 }} / ↓ {{ c.traffic.down or 0 }} + {% else %}—{% endif %} + | +
| Нет клиентов или endpoint /clients/list недоступен | |||