Add 3x-ui v3.5.0 API panel connection and client management
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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) + "/", "")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -10,6 +10,7 @@
|
||||
<nav>
|
||||
<a href="/admin" class="{% if request.url.path == '/admin' %}active{% endif %}">Дашборд</a>
|
||||
<a href="/admin/servers" class="{% if '/servers' in request.url.path %}active{% endif %}">Серверы</a>
|
||||
<a href="/admin/xui" class="{% if '/xui' in request.url.path %}active{% endif %}">3x-ui</a>
|
||||
<a href="/admin/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a>
|
||||
<a href="/" target="_blank">Сайт</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}3x-ui{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-top">
|
||||
<h1>3x-ui панели</h1>
|
||||
<span class="badge badge-proto">API v3.5.0</span>
|
||||
</div>
|
||||
|
||||
{% if flash %}
|
||||
{% if flash.startswith('error:') %}
|
||||
<div class="flash flash-error">{{ flash[6:] }}</div>
|
||||
{% elif flash == 'created' %}
|
||||
<div class="flash">Панель 3x-ui подключена</div>
|
||||
{% elif flash == 'ok' %}
|
||||
<div class="flash">API отвечает</div>
|
||||
{% elif flash == 'deleted' %}
|
||||
<div class="flash">Панель удалена</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="panel" style="margin-bottom:1.25rem">
|
||||
<div class="panel-head"><strong>Подключить 3x-ui</strong></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="margin-top:0">
|
||||
Укажите URL панели (с webBasePath, если есть) и <strong>API Token</strong>
|
||||
(Settings → Security → API Token) — предпочтительно для v3.5.0.
|
||||
Либо логин/пароль админа.
|
||||
</p>
|
||||
<form method="post" action="/admin/xui" class="stack">
|
||||
<div class="form-grid-2">
|
||||
<label>Название
|
||||
<input name="name" placeholder="Мой 3x-ui" />
|
||||
</label>
|
||||
<label>Base URL
|
||||
<input name="base_url" placeholder="https://1.2.3.4:2053/secret" required />
|
||||
</label>
|
||||
</div>
|
||||
<h3 class="form-section">Авторизация</h3>
|
||||
<label>API Token (рекомендуется)
|
||||
<input name="api_token" placeholder="Bearer token из 3x-ui" autocomplete="off" />
|
||||
</label>
|
||||
<div class="form-grid-2">
|
||||
<label>Логин (если без токена)
|
||||
<input name="username" autocomplete="username" />
|
||||
</label>
|
||||
<label>Пароль
|
||||
<input type="password" name="password" autocomplete="new-password" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="verify_ssl" value="1" checked /> проверять SSL-сертификат
|
||||
</label>
|
||||
<button class="btn btn-accent" type="submit">Проверить API и сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for p in panels %}
|
||||
<div class="panel" style="margin-bottom:1rem">
|
||||
<div class="panel-head">
|
||||
<strong>
|
||||
<span class="ping {% if p.is_reachable %}ping-on{% else %}ping-off{% endif %}"></span>
|
||||
{{ p.name }}
|
||||
{% if p.is_reachable %}<span class="badge badge-ok">online</span>{% else %}<span class="badge badge-off">offline</span>{% endif %}
|
||||
</strong>
|
||||
<div class="actions">
|
||||
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ p.id }}">Открыть</a>
|
||||
<form method="post" action="/admin/xui/{{ p.id }}/check" class="inline-form">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Проверить</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/xui/{{ p.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить подключение?')">
|
||||
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted" style="margin-top:0">
|
||||
<a href="{{ p.base_url }}" target="_blank" rel="noopener">{{ p.base_url }}</a>
|
||||
· 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 %}
|
||||
</p>
|
||||
{% if p.last_error %}
|
||||
<div class="flash flash-error">{{ p.last_error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Пока нет подключённых 3x-ui панелей.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ panel.name }}{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-top">
|
||||
<div>
|
||||
<h1>{{ panel.name }}</h1>
|
||||
<div class="muted"><a href="{{ panel.base_url }}" target="_blank" rel="noopener">{{ panel.base_url }}</a></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn btn-sm btn-ghost" href="/admin/xui">Назад</a>
|
||||
<form method="post" action="/admin/xui/{{ panel.id }}/check" class="inline-form">
|
||||
<button class="btn btn-sm btn-accent" type="submit">Обновить API</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if flash %}
|
||||
{% if flash.startswith('error:') %}
|
||||
<div class="flash flash-error">{{ flash[6:] }}</div>
|
||||
{% elif flash == 'client_created' %}
|
||||
<div class="flash">Клиент создан в 3x-ui</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="flash flash-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if data %}
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="label">Inbounds</div>
|
||||
<div class="value">{{ data.inbounds|length }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Clients</div>
|
||||
<div class="value">{{ data.clients|length }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Xray</div>
|
||||
<div class="value" style="font-size:1.1rem">
|
||||
{% if data.status and data.status.xray %}
|
||||
{% if data.status.xray.state %}running{% else %}stopped{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:1rem">
|
||||
<div class="panel-head"><strong>Добавить клиента через API</strong></div>
|
||||
<div class="panel-body">
|
||||
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="form-grid">
|
||||
<label>Email / имя
|
||||
<input name="email" placeholder="user1" required />
|
||||
</label>
|
||||
<label>Inbound
|
||||
<select name="inbound_id" required>
|
||||
{% for ib in data.inbounds %}
|
||||
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or ib.protocol }} :{{ ib.port }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn btn-accent" type="submit">Создать</button>
|
||||
<label>Лимит GB (0 = безлимит)
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:1rem">
|
||||
<div class="panel-head"><strong>Inbounds</strong></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Remark</th>
|
||||
<th>Protocol</th>
|
||||
<th>Port</th>
|
||||
<th>Enable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ib in data.inbounds %}
|
||||
<tr>
|
||||
<td>{{ ib.id }}</td>
|
||||
<td>{{ ib.remark or '—' }}</td>
|
||||
<td><span class="badge badge-proto">{{ ib.protocol }}</span></td>
|
||||
<td>{{ ib.port }}</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="5" class="muted">Нет inbound’ов</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head"><strong>Clients</strong></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Enable</th>
|
||||
<th>Inbounds</th>
|
||||
<th>Traffic</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in data.clients %}
|
||||
<tr>
|
||||
<td>{{ c.email }}</td>
|
||||
<td>{% if c.enable %}<span class="badge badge-ok">on</span>{% else %}<span class="badge badge-off">off</span>{% endif %}</td>
|
||||
<td>{{ c.inboundIds or c.inbound_ids or '—' }}</td>
|
||||
<td class="muted">
|
||||
{% if c.traffic %}
|
||||
↑ {{ c.traffic.up or 0 }} / ↓ {{ c.traffic.down or 0 }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="muted">Нет клиентов или endpoint /clients/list недоступен</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user