3x-ui: create VLESS/WireGuard clients and reload available inbounds
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+58
-14
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, Request, status
|
from fastapi import APIRouter, Depends, Form, Query, Request, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -79,7 +80,10 @@ async def xui_check(
|
|||||||
f"/admin/xui?flash=error:{exc}",
|
f"/admin/xui?flash=error:{exc}",
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
return RedirectResponse("/admin/xui?flash=ok", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse(
|
||||||
|
f"/admin/xui/{panel_id}?flash=ok",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{panel_id}/delete")
|
@router.post("/{panel_id}/delete")
|
||||||
@@ -94,6 +98,26 @@ async def xui_delete(
|
|||||||
return RedirectResponse("/admin/xui?flash=deleted", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse("/admin/xui?flash=deleted", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{panel_id}/inbounds")
|
||||||
|
async def xui_inbounds_api(
|
||||||
|
panel_id: int,
|
||||||
|
protocol: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin=Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""JSON: available inbounds for dropdown (optionally filtered by protocol)."""
|
||||||
|
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)
|
||||||
|
return JSONResponse({"success": True, "obj": items})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return JSONResponse({"success": False, "error": str(exc)}, status_code=400)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{panel_id}", response_class=HTMLResponse)
|
@router.get("/{panel_id}", response_class=HTMLResponse)
|
||||||
async def xui_detail(
|
async def xui_detail(
|
||||||
panel_id: int,
|
panel_id: int,
|
||||||
@@ -114,12 +138,13 @@ async def xui_detail(
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
|
|
||||||
info = None
|
created = None
|
||||||
if panel.panel_info:
|
created_raw = request.query_params.get("created")
|
||||||
|
if created_raw:
|
||||||
try:
|
try:
|
||||||
info = json.loads(panel.panel_info)
|
created = json.loads(created_raw)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
info = None
|
created = None
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
return request.app.state.templates.TemplateResponse(
|
||||||
"admin/xui_detail.html",
|
"admin/xui_detail.html",
|
||||||
@@ -128,7 +153,7 @@ async def xui_detail(
|
|||||||
"admin": admin,
|
"admin": admin,
|
||||||
"panel": panel,
|
"panel": panel,
|
||||||
"data": data,
|
"data": data,
|
||||||
"info": info,
|
"created": created,
|
||||||
"error": error,
|
"error": error,
|
||||||
"app_name": request.app.state.settings.app_name,
|
"app_name": request.app.state.settings.app_name,
|
||||||
"flash": request.query_params.get("flash"),
|
"flash": request.query_params.get("flash"),
|
||||||
@@ -139,10 +164,13 @@ async def xui_detail(
|
|||||||
@router.post("/{panel_id}/clients")
|
@router.post("/{panel_id}/clients")
|
||||||
async def xui_add_client(
|
async def xui_add_client(
|
||||||
panel_id: int,
|
panel_id: int,
|
||||||
|
protocol: str = Form(...),
|
||||||
email: str = Form(...),
|
email: str = Form(...),
|
||||||
inbound_id: int = Form(...),
|
inbound_id: int = Form(...),
|
||||||
total_gb: int = Form(0),
|
total_gb: int = Form(0),
|
||||||
limit_ip: int = Form(0),
|
limit_ip: int = Form(0),
|
||||||
|
flow: str = Form(""),
|
||||||
|
comment: str = Form(""),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
admin=Depends(require_admin),
|
admin=Depends(require_admin),
|
||||||
):
|
):
|
||||||
@@ -152,19 +180,35 @@ async def xui_add_client(
|
|||||||
if not panel:
|
if not panel:
|
||||||
return RedirectResponse("/admin/xui", status_code=status.HTTP_303_SEE_OTHER)
|
return RedirectResponse("/admin/xui", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
try:
|
try:
|
||||||
await xui_service.add_xui_client(
|
result = await xui_service.add_xui_client(
|
||||||
panel,
|
panel,
|
||||||
|
protocol=protocol,
|
||||||
email=email.strip(),
|
email=email.strip(),
|
||||||
inbound_ids=[inbound_id],
|
inbound_id=inbound_id,
|
||||||
total_gb=total_gb,
|
total_gb=total_gb,
|
||||||
limit_ip=limit_ip,
|
limit_ip=limit_ip,
|
||||||
|
flow=flow.strip(),
|
||||||
|
comment=comment.strip(),
|
||||||
|
)
|
||||||
|
# Keep redirect short: store essentials only
|
||||||
|
slim = {
|
||||||
|
"protocol": result.get("protocol"),
|
||||||
|
"email": result.get("email"),
|
||||||
|
"inbound_id": result.get("inbound_id"),
|
||||||
|
"uuid": result.get("uuid"),
|
||||||
|
"flow": result.get("flow"),
|
||||||
|
"privateKey": result.get("privateKey"),
|
||||||
|
"publicKey": result.get("publicKey"),
|
||||||
|
"links": result.get("links") or [],
|
||||||
|
"inbound": result.get("inbound"),
|
||||||
|
}
|
||||||
|
payload = quote(json.dumps(slim, ensure_ascii=False))
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/admin/xui/{panel_id}?flash=client_created&created={payload}",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/admin/xui/{panel_id}?flash=error:{exc}",
|
f"/admin/xui/{panel_id}?flash=error:{exc}",
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
return RedirectResponse(
|
|
||||||
f"/admin/xui/{panel_id}?flash=client_created",
|
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
|
||||||
)
|
|
||||||
|
|||||||
+210
-51
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -10,6 +12,8 @@ import httpx
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPPORTED_CLIENT_PROTOCOLS = ("vless", "wireguard")
|
||||||
|
|
||||||
|
|
||||||
class XuiApiError(Exception):
|
class XuiApiError(Exception):
|
||||||
def __init__(self, message: str, status_code: int | None = None):
|
def __init__(self, message: str, status_code: int | None = None):
|
||||||
@@ -17,13 +21,18 @@ class XuiApiError(Exception):
|
|||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _random_sub_id(length: int = 16) -> str:
|
||||||
|
alphabet = string.ascii_lowercase + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
class XuiClient:
|
class XuiClient:
|
||||||
"""
|
"""
|
||||||
Client for MHSanaei/3x-ui REST API (v3.5.0).
|
Client for MHSanaei/3x-ui REST API (v3.5.0).
|
||||||
|
|
||||||
Auth options:
|
Auth:
|
||||||
1) API Token — Settings → Security → API Token → Authorization: Bearer …
|
1) API Token → Authorization: Bearer …
|
||||||
2) Username/password session (with CSRF when required)
|
2) Username/password session (+ CSRF when required)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -75,9 +84,7 @@ class XuiClient:
|
|||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
async def login(self) -> None:
|
async def login(self) -> None:
|
||||||
"""Session login for cookie auth (when no API token)."""
|
|
||||||
assert self.username and self.password
|
assert self.username and self.password
|
||||||
# Warm-up cookies / CSRF from login page if present
|
|
||||||
csrf: str | None = None
|
csrf: str | None = None
|
||||||
try:
|
try:
|
||||||
warm = await self.client.get("/")
|
warm = await self.client.get("/")
|
||||||
@@ -94,12 +101,11 @@ class XuiClient:
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
headers = {}
|
headers: dict[str, str] = {}
|
||||||
if csrf:
|
if csrf:
|
||||||
headers["X-CSRF-Token"] = csrf
|
headers["X-CSRF-Token"] = csrf
|
||||||
|
|
||||||
payload = {"username": self.username, "password": self.password}
|
payload = {"username": self.username, "password": self.password}
|
||||||
# Try JSON first (v3), then form (older)
|
|
||||||
resp = await self.client.post("/login", json=payload, headers=headers)
|
resp = await self.client.post("/login", json=payload, headers=headers)
|
||||||
if resp.status_code == 403 and csrf:
|
if resp.status_code == 403 and csrf:
|
||||||
resp = await self.client.post(
|
resp = await self.client.post(
|
||||||
@@ -144,58 +150,214 @@ class XuiClient:
|
|||||||
data = await self._request("GET", "/panel/api/server/status")
|
data = await self._request("GET", "/panel/api/server/status")
|
||||||
return data.get("obj") if isinstance(data, dict) else data
|
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:
|
async def get_xray_version(self) -> Any:
|
||||||
data = await self._request("GET", "/panel/api/server/getXrayVersion")
|
data = await self._request("GET", "/panel/api/server/getXrayVersion")
|
||||||
return data.get("obj") if isinstance(data, dict) else data
|
return data.get("obj") if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
async def get_new_uuid(self) -> str:
|
||||||
|
data = await self._request("GET", "/panel/api/server/getNewUUID")
|
||||||
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
|
if isinstance(obj, str):
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict) and obj.get("uuid"):
|
||||||
|
return str(obj["uuid"])
|
||||||
|
raise XuiApiError("Не удалось получить UUID")
|
||||||
|
|
||||||
|
async def get_new_x25519(self) -> dict[str, str]:
|
||||||
|
data = await self._request("GET", "/panel/api/server/getNewX25519Cert")
|
||||||
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(obj, dict) or not obj.get("privateKey") or not obj.get("publicKey"):
|
||||||
|
raise XuiApiError("Не удалось получить X25519 ключи")
|
||||||
|
return {"privateKey": str(obj["privateKey"]), "publicKey": str(obj["publicKey"])}
|
||||||
|
|
||||||
async def list_inbounds(self) -> list[dict]:
|
async def list_inbounds(self) -> list[dict]:
|
||||||
data = await self._request("GET", "/panel/api/inbounds/list")
|
data = await self._request("GET", "/panel/api/inbounds/list")
|
||||||
obj = data.get("obj") if isinstance(data, dict) else data
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
return obj if isinstance(obj, list) else []
|
return obj if isinstance(obj, list) else []
|
||||||
|
|
||||||
|
async def list_inbound_options(self) -> list[dict]:
|
||||||
|
"""Lightweight inbound list for dropdowns (v3.5)."""
|
||||||
|
try:
|
||||||
|
data = await self._request("GET", "/panel/api/inbounds/options")
|
||||||
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return obj
|
||||||
|
except XuiApiError:
|
||||||
|
pass
|
||||||
|
# Fallback: slim projection from full list
|
||||||
|
inbounds = await self.list_inbounds()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": i.get("id"),
|
||||||
|
"remark": i.get("remark"),
|
||||||
|
"tag": i.get("tag"),
|
||||||
|
"protocol": i.get("protocol"),
|
||||||
|
"port": i.get("port"),
|
||||||
|
"enable": i.get("enable", True),
|
||||||
|
"tlsFlowCapable": False,
|
||||||
|
}
|
||||||
|
for i in inbounds
|
||||||
|
]
|
||||||
|
|
||||||
|
async def list_available_inbounds(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
protocols: tuple[str, ...] | list[str] | None = None,
|
||||||
|
enabled_only: bool = True,
|
||||||
|
) -> list[dict]:
|
||||||
|
options = await self.list_inbound_options()
|
||||||
|
result: list[dict] = []
|
||||||
|
wanted = {p.lower() for p in protocols} if protocols else None
|
||||||
|
for item in options:
|
||||||
|
proto = str(item.get("protocol") or "").lower()
|
||||||
|
if wanted and proto not in wanted:
|
||||||
|
continue
|
||||||
|
if enabled_only and item.get("enable") is False:
|
||||||
|
continue
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_inbound(self, inbound_id: int) -> dict:
|
||||||
|
data = await self._request("GET", f"/panel/api/inbounds/get/{inbound_id}")
|
||||||
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise XuiApiError(f"Inbound {inbound_id} not found")
|
||||||
|
return obj
|
||||||
|
|
||||||
async def list_clients(self) -> list[dict]:
|
async def list_clients(self) -> list[dict]:
|
||||||
data = await self._request("GET", "/panel/api/clients/list")
|
data = await self._request("GET", "/panel/api/clients/list")
|
||||||
obj = data.get("obj") if isinstance(data, dict) else data
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
return obj if isinstance(obj, list) else []
|
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:
|
async def get_client(self, email: str) -> dict:
|
||||||
data = await self._request("GET", f"/panel/api/clients/get/{email}")
|
data = await self._request("GET", f"/panel/api/clients/get/{email}")
|
||||||
return data.get("obj") if isinstance(data, dict) else data
|
return data.get("obj") if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
async def get_client_links(self, email: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
data = await self._request("GET", f"/panel/api/clients/links/{email}")
|
||||||
|
obj = data.get("obj") if isinstance(data, dict) else data
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [str(x) for x in obj]
|
||||||
|
except XuiApiError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def add_client_raw(self, client: dict, inbound_ids: list[int]) -> dict:
|
||||||
|
body = {"client": client, "inboundIds": inbound_ids}
|
||||||
|
return await self._request("POST", "/panel/api/clients/add", json=body)
|
||||||
|
|
||||||
|
async def add_vless_client(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
inbound_id: int,
|
||||||
|
total_gb: int = 0,
|
||||||
|
expiry_time: int = 0,
|
||||||
|
limit_ip: int = 0,
|
||||||
|
flow: str = "",
|
||||||
|
comment: str = "",
|
||||||
|
) -> dict:
|
||||||
|
inbound = await self.get_inbound(inbound_id)
|
||||||
|
proto = str(inbound.get("protocol") or "").lower()
|
||||||
|
if proto != "vless":
|
||||||
|
raise XuiApiError(f"Inbound #{inbound_id} — не VLESS (protocol={proto})")
|
||||||
|
|
||||||
|
uuid = await self.get_new_uuid()
|
||||||
|
# Auto vision flow for Reality TCP if caller left flow empty
|
||||||
|
if not flow:
|
||||||
|
try:
|
||||||
|
stream = inbound.get("streamSettings") or {}
|
||||||
|
if isinstance(stream, str):
|
||||||
|
import json
|
||||||
|
|
||||||
|
stream = json.loads(stream)
|
||||||
|
network = str(stream.get("network") or "tcp").lower()
|
||||||
|
security = str(stream.get("security") or "").lower()
|
||||||
|
if network == "tcp" and security in {"reality", "tls"}:
|
||||||
|
flow = "xtls-rprx-vision"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
flow = ""
|
||||||
|
|
||||||
|
client = {
|
||||||
|
"id": uuid,
|
||||||
|
"email": email,
|
||||||
|
"enable": True,
|
||||||
|
"flow": flow,
|
||||||
|
"totalGB": total_gb,
|
||||||
|
"expiryTime": expiry_time,
|
||||||
|
"limitIp": limit_ip,
|
||||||
|
"tgId": 0,
|
||||||
|
"subId": _random_sub_id(),
|
||||||
|
"comment": comment,
|
||||||
|
}
|
||||||
|
await self.add_client_raw(client, [inbound_id])
|
||||||
|
details = await self.get_client(email)
|
||||||
|
links = await self.get_client_links(email)
|
||||||
|
return {
|
||||||
|
"protocol": "vless",
|
||||||
|
"email": email,
|
||||||
|
"inbound_id": inbound_id,
|
||||||
|
"uuid": uuid,
|
||||||
|
"flow": flow,
|
||||||
|
"details": details,
|
||||||
|
"links": links,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def add_wireguard_client(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
inbound_id: int,
|
||||||
|
total_gb: int = 0,
|
||||||
|
expiry_time: int = 0,
|
||||||
|
limit_ip: int = 0,
|
||||||
|
comment: str = "",
|
||||||
|
) -> dict:
|
||||||
|
inbound = await self.get_inbound(inbound_id)
|
||||||
|
proto = str(inbound.get("protocol") or "").lower()
|
||||||
|
if proto != "wireguard":
|
||||||
|
raise XuiApiError(f"Inbound #{inbound_id} — не WireGuard (protocol={proto})")
|
||||||
|
|
||||||
|
keys = await self.get_new_x25519()
|
||||||
|
client = {
|
||||||
|
"email": email,
|
||||||
|
"enable": True,
|
||||||
|
"privateKey": keys["privateKey"],
|
||||||
|
"publicKey": keys["publicKey"],
|
||||||
|
"preSharedKey": "",
|
||||||
|
"allowedIPs": ["0.0.0.0/0", "::/0"],
|
||||||
|
"keepAlive": 0,
|
||||||
|
"totalGB": total_gb,
|
||||||
|
"expiryTime": expiry_time,
|
||||||
|
"limitIp": limit_ip,
|
||||||
|
"tgId": 0,
|
||||||
|
"subId": _random_sub_id(),
|
||||||
|
"comment": comment,
|
||||||
|
}
|
||||||
|
await self.add_client_raw(client, [inbound_id])
|
||||||
|
details = await self.get_client(email)
|
||||||
|
# WG has no vless:// link — expose keys for conf building
|
||||||
|
return {
|
||||||
|
"protocol": "wireguard",
|
||||||
|
"email": email,
|
||||||
|
"inbound_id": inbound_id,
|
||||||
|
"privateKey": keys["privateKey"],
|
||||||
|
"publicKey": keys["publicKey"],
|
||||||
|
"details": details,
|
||||||
|
"inbound": {
|
||||||
|
"id": inbound.get("id"),
|
||||||
|
"remark": inbound.get("remark"),
|
||||||
|
"port": inbound.get("port"),
|
||||||
|
"listen": inbound.get("listen"),
|
||||||
|
},
|
||||||
|
"links": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete_client(self, email: str) -> dict:
|
||||||
|
return await self._request("POST", f"/panel/api/clients/del/{email}")
|
||||||
|
|
||||||
async def test_connection(self) -> dict[str, Any]:
|
async def test_connection(self) -> dict[str, Any]:
|
||||||
"""Verify credentials and return summary for UI."""
|
|
||||||
status = await self.get_status()
|
status = await self.get_status()
|
||||||
info: dict[str, Any] = {"status": status}
|
info: dict[str, Any] = {"status": status}
|
||||||
try:
|
try:
|
||||||
@@ -203,17 +365,14 @@ class XuiClient:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
info["xray_version_error"] = str(exc)
|
info["xray_version_error"] = str(exc)
|
||||||
try:
|
try:
|
||||||
inbounds = await self.list_inbounds()
|
available = await self.list_available_inbounds(
|
||||||
info["inbounds_count"] = len(inbounds)
|
protocols=SUPPORTED_CLIENT_PROTOCOLS, enabled_only=False
|
||||||
info["inbounds"] = [
|
)
|
||||||
{
|
info["inbounds_count"] = len(available)
|
||||||
"id": i.get("id"),
|
info["inbounds"] = available[:100]
|
||||||
"remark": i.get("remark"),
|
info["vless_inbounds"] = [i for i in available if str(i.get("protocol")).lower() == "vless"]
|
||||||
"protocol": i.get("protocol"),
|
info["wireguard_inbounds"] = [
|
||||||
"port": i.get("port"),
|
i for i in available if str(i.get("protocol")).lower() == "wireguard"
|
||||||
"enable": i.get("enable"),
|
|
||||||
}
|
|
||||||
for i in inbounds[:50]
|
|
||||||
]
|
]
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
info["inbounds_error"] = str(exc)
|
info["inbounds_error"] = str(exc)
|
||||||
|
|||||||
+73
-33
@@ -4,12 +4,28 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import XuiPanel
|
from app.models import XuiPanel
|
||||||
from app.services.xui import XuiClient, XuiApiError, normalize_base_url
|
from app.services.xui import (
|
||||||
|
SUPPORTED_CLIENT_PROTOCOLS,
|
||||||
|
XuiApiError,
|
||||||
|
XuiClient,
|
||||||
|
normalize_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_for(panel: XuiPanel) -> XuiClient:
|
||||||
|
return XuiClient(
|
||||||
|
panel.base_url,
|
||||||
|
username=panel.username,
|
||||||
|
password=panel.password,
|
||||||
|
api_token=panel.api_token,
|
||||||
|
verify_ssl=panel.verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_panels(session: AsyncSession) -> list[XuiPanel]:
|
async def list_panels(session: AsyncSession) -> list[XuiPanel]:
|
||||||
@@ -47,7 +63,6 @@ async def create_panel(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(panel)
|
await session.refresh(panel)
|
||||||
|
|
||||||
# Verify immediately
|
|
||||||
await check_panel(session, panel.id)
|
await check_panel(session, panel.id)
|
||||||
await session.refresh(panel)
|
await session.refresh(panel)
|
||||||
if not panel.is_reachable:
|
if not panel.is_reachable:
|
||||||
@@ -62,13 +77,7 @@ async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel:
|
|||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
async with XuiClient(
|
async with _client_for(panel) as client:
|
||||||
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()
|
info = await client.test_connection()
|
||||||
panel.is_reachable = True
|
panel.is_reachable = True
|
||||||
panel.last_error = None
|
panel.last_error = None
|
||||||
@@ -94,41 +103,72 @@ async def delete_panel(session: AsyncSession, panel_id: int) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_panel_data(panel: XuiPanel) -> dict:
|
async def fetch_panel_data(panel: XuiPanel) -> dict[str, Any]:
|
||||||
async with XuiClient(
|
async with _client_for(panel) as client:
|
||||||
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()
|
status = await client.get_status()
|
||||||
inbounds = await client.list_inbounds()
|
all_inbounds = await client.list_inbounds()
|
||||||
|
available = await client.list_available_inbounds(
|
||||||
|
protocols=SUPPORTED_CLIENT_PROTOCOLS, enabled_only=False
|
||||||
|
)
|
||||||
|
vless_inbounds = [i for i in available if str(i.get("protocol")).lower() == "vless"]
|
||||||
|
wg_inbounds = [i for i in available if str(i.get("protocol")).lower() == "wireguard"]
|
||||||
try:
|
try:
|
||||||
clients = await client.list_clients()
|
clients = await client.list_clients()
|
||||||
except XuiApiError:
|
except XuiApiError:
|
||||||
clients = []
|
clients = []
|
||||||
return {"status": status, "inbounds": inbounds, "clients": clients}
|
return {
|
||||||
|
"status": status,
|
||||||
|
"inbounds": all_inbounds,
|
||||||
|
"available_inbounds": available,
|
||||||
|
"vless_inbounds": vless_inbounds,
|
||||||
|
"wireguard_inbounds": wg_inbounds,
|
||||||
|
"clients": clients,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_available_inbounds(
|
||||||
|
panel: XuiPanel,
|
||||||
|
*,
|
||||||
|
protocol: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
protocols: tuple[str, ...]
|
||||||
|
if protocol:
|
||||||
|
protocols = (protocol.lower(),)
|
||||||
|
else:
|
||||||
|
protocols = SUPPORTED_CLIENT_PROTOCOLS
|
||||||
|
async with _client_for(panel) as client:
|
||||||
|
return await client.list_available_inbounds(protocols=protocols, enabled_only=True)
|
||||||
|
|
||||||
|
|
||||||
async def add_xui_client(
|
async def add_xui_client(
|
||||||
panel: XuiPanel,
|
panel: XuiPanel,
|
||||||
*,
|
*,
|
||||||
|
protocol: str,
|
||||||
email: str,
|
email: str,
|
||||||
inbound_ids: list[int],
|
inbound_id: int,
|
||||||
total_gb: int = 0,
|
total_gb: int = 0,
|
||||||
limit_ip: int = 0,
|
limit_ip: int = 0,
|
||||||
|
flow: str = "",
|
||||||
|
comment: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
async with XuiClient(
|
proto = protocol.strip().lower()
|
||||||
panel.base_url,
|
bytes_limit = total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0
|
||||||
username=panel.username,
|
async with _client_for(panel) as client:
|
||||||
password=panel.password,
|
if proto == "vless":
|
||||||
api_token=panel.api_token,
|
return await client.add_vless_client(
|
||||||
verify_ssl=panel.verify_ssl,
|
email=email,
|
||||||
) as client:
|
inbound_id=inbound_id,
|
||||||
return await client.add_client(
|
total_gb=bytes_limit,
|
||||||
email=email,
|
limit_ip=limit_ip,
|
||||||
inbound_ids=inbound_ids,
|
flow=flow,
|
||||||
total_gb=total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0,
|
comment=comment,
|
||||||
limit_ip=limit_ip,
|
)
|
||||||
)
|
if proto == "wireguard":
|
||||||
|
return await client.add_wireguard_client(
|
||||||
|
email=email,
|
||||||
|
inbound_id=inbound_id,
|
||||||
|
total_gb=bytes_limit,
|
||||||
|
limit_ip=limit_ip,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
raise ValueError("Поддерживаются только vless и wireguard")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-sm btn-ghost" href="/admin/xui">Назад</a>
|
<a class="btn btn-sm btn-ghost" href="/admin/xui">Назад</a>
|
||||||
|
<button class="btn btn-sm btn-ghost" type="button" id="btn-reload-inbounds">Загрузить inbound’ы</button>
|
||||||
<form method="post" action="/admin/xui/{{ panel.id }}/check" class="inline-form">
|
<form method="post" action="/admin/xui/{{ panel.id }}/check" class="inline-form">
|
||||||
<button class="btn btn-sm btn-accent" type="submit">Обновить API</button>
|
<button class="btn btn-sm btn-accent" type="submit">Обновить API</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
<div class="flash flash-error">{{ flash[6:] }}</div>
|
<div class="flash flash-error">{{ flash[6:] }}</div>
|
||||||
{% elif flash == 'client_created' %}
|
{% elif flash == 'client_created' %}
|
||||||
<div class="flash">Клиент создан в 3x-ui</div>
|
<div class="flash">Клиент создан в 3x-ui</div>
|
||||||
|
{% elif flash == 'ok' %}
|
||||||
|
<div class="flash">Данные обновлены</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -26,53 +29,122 @@
|
|||||||
<div class="flash flash-error">{{ error }}</div>
|
<div class="flash flash-error">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if created %}
|
||||||
|
<div class="panel" style="margin-bottom:1rem">
|
||||||
|
<div class="panel-head"><strong>Новый клиент ({{ created.protocol }})</strong></div>
|
||||||
|
<div class="panel-body stack">
|
||||||
|
<p style="margin:0"><strong>{{ created.email }}</strong> → inbound #{{ created.inbound_id }}</p>
|
||||||
|
{% if created.protocol == 'vless' %}
|
||||||
|
{% if created.uuid %}<p class="muted" style="margin:0">UUID: <code>{{ created.uuid }}</code>{% if created.flow %} · flow: {{ created.flow }}{% endif %}</p>{% endif %}
|
||||||
|
{% if created.links %}
|
||||||
|
{% for link in created.links %}
|
||||||
|
<pre class="mono">{{ link }}</pre>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Ссылка vless:// появится в 3x-ui (Clients → Copy URL).</p>
|
||||||
|
{% endif %}
|
||||||
|
{% elif created.protocol == 'wireguard' %}
|
||||||
|
<p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p>
|
||||||
|
<pre class="mono">PrivateKey = {{ created.privateKey }}
|
||||||
|
PublicKey = {{ created.publicKey }}
|
||||||
|
{% if created.inbound %}Endpoint port = {{ created.inbound.port }}{% endif %}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if data %}
|
{% if data %}
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="label">Inbounds</div>
|
<div class="label">Все inbound’ы</div>
|
||||||
<div class="value">{{ data.inbounds|length }}</div>
|
<div class="value">{{ data.inbounds|length }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="label">Clients</div>
|
<div class="label">VLESS</div>
|
||||||
<div class="value">{{ data.clients|length }}</div>
|
<div class="value">{{ data.vless_inbounds|length }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="label">Xray</div>
|
<div class="label">WireGuard</div>
|
||||||
<div class="value" style="font-size:1.1rem">
|
<div class="value">{{ data.wireguard_inbounds|length }}</div>
|
||||||
{% if data.status and data.status.xray %}
|
</div>
|
||||||
{% if data.status.xray.state %}running{% else %}stopped{% endif %}
|
</div>
|
||||||
{% else %}—{% endif %}
|
|
||||||
|
<div class="detail-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head"><strong>Создать VLESS клиента</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="stack">
|
||||||
|
<input type="hidden" name="protocol" value="vless" />
|
||||||
|
<label>Email / имя
|
||||||
|
<input name="email" placeholder="user-vless" required />
|
||||||
|
</label>
|
||||||
|
<label>Inbound VLESS
|
||||||
|
<select name="inbound_id" id="select-vless" required>
|
||||||
|
{% for ib in data.vless_inbounds %}
|
||||||
|
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or 'vless' }} :{{ ib.port }}{% if ib.enable is defined and not ib.enable %} (off){% endif %}</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="" disabled selected>Нет VLESS inbound’ов — нажмите «Загрузить»</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Flow (пусто = авто vision для Reality/TLS)
|
||||||
|
<input name="flow" placeholder="xtls-rprx-vision" />
|
||||||
|
</label>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<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>Комментарий
|
||||||
|
<input name="comment" />
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-accent" type="submit" {% if not data.vless_inbounds %}disabled{% endif %}>Создать VLESS</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head"><strong>Создать WireGuard клиента</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="stack">
|
||||||
|
<input type="hidden" name="protocol" value="wireguard" />
|
||||||
|
<label>Email / имя
|
||||||
|
<input name="email" placeholder="user-wg" required />
|
||||||
|
</label>
|
||||||
|
<label>Inbound WireGuard
|
||||||
|
<select name="inbound_id" id="select-wg" required>
|
||||||
|
{% for ib in data.wireguard_inbounds %}
|
||||||
|
<option value="{{ ib.id }}">#{{ ib.id }} {{ ib.remark or 'wireguard' }} :{{ ib.port }}{% if ib.enable is defined and not ib.enable %} (off){% endif %}</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="" disabled selected>Нет WireGuard inbound’ов — нажмите «Загрузить»</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="form-grid-2">
|
||||||
|
<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>Комментарий
|
||||||
|
<input name="comment" />
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-accent" type="submit" {% if not data.wireguard_inbounds %}disabled{% endif %}>Создать WireGuard</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel" style="margin-bottom:1rem">
|
<div class="panel" style="margin-bottom:1rem">
|
||||||
<div class="panel-head"><strong>Добавить клиента через API</strong></div>
|
<div class="panel-head">
|
||||||
<div class="panel-body">
|
<strong>Доступные inbound’ы (VLESS / WireGuard)</strong>
|
||||||
<form method="post" action="/admin/xui/{{ panel.id }}/clients" class="form-grid">
|
<span class="muted" id="inbounds-status"></span>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="panel" style="margin-bottom:1rem">
|
|
||||||
<div class="panel-head"><strong>Inbounds</strong></div>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -83,17 +155,17 @@
|
|||||||
<th>Enable</th>
|
<th>Enable</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="inbounds-table-body">
|
||||||
{% for ib in data.inbounds %}
|
{% for ib in data.available_inbounds %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ ib.id }}</td>
|
<td>{{ ib.id }}</td>
|
||||||
<td>{{ ib.remark or '—' }}</td>
|
<td>{{ ib.remark or '—' }}</td>
|
||||||
<td><span class="badge badge-proto">{{ ib.protocol }}</span></td>
|
<td><span class="badge badge-proto">{{ ib.protocol }}</span></td>
|
||||||
<td>{{ ib.port }}</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>
|
<td>{% if ib.enable is not defined or ib.enable %}<span class="badge badge-ok">on</span>{% else %}<span class="badge badge-off">off</span>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="5" class="muted">Нет inbound’ов</td></tr>
|
<tr><td colspan="5" class="muted">Нет VLESS/WireGuard inbound’ов</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -123,10 +195,84 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="4" class="muted">Нет клиентов или endpoint /clients/list недоступен</td></tr>
|
<tr><td colspan="4" class="muted">Нет клиентов</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const panelId = {{ panel.id }};
|
||||||
|
const btn = document.getElementById('btn-reload-inbounds');
|
||||||
|
const statusEl = document.getElementById('inbounds-status');
|
||||||
|
const tbody = document.getElementById('inbounds-table-body');
|
||||||
|
const selectVless = document.getElementById('select-vless');
|
||||||
|
const selectWg = document.getElementById('select-wg');
|
||||||
|
|
||||||
|
function fillSelect(select, items, emptyLabel) {
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.disabled = true;
|
||||||
|
opt.selected = true;
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = emptyLabel;
|
||||||
|
select.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach((ib) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ib.id;
|
||||||
|
const off = (ib.enable === false) ? ' (off)' : '';
|
||||||
|
opt.textContent = `#${ib.id} ${ib.remark || ib.protocol} :${ib.port}${off}`;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadInbounds() {
|
||||||
|
statusEl.textContent = 'загрузка…';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/admin/xui/${panelId}/inbounds`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'fail');
|
||||||
|
const items = data.obj || [];
|
||||||
|
const vless = items.filter((i) => String(i.protocol).toLowerCase() === 'vless');
|
||||||
|
const wg = items.filter((i) => String(i.protocol).toLowerCase() === 'wireguard');
|
||||||
|
|
||||||
|
fillSelect(selectVless, vless, 'Нет VLESS inbound’ов');
|
||||||
|
fillSelect(selectWg, wg, 'Нет WireGuard inbound’ов');
|
||||||
|
|
||||||
|
const vlessBtn = selectVless && selectVless.closest('form').querySelector('button[type=submit]');
|
||||||
|
const wgBtn = selectWg && selectWg.closest('form').querySelector('button[type=submit]');
|
||||||
|
if (vlessBtn) vlessBtn.disabled = vless.length === 0;
|
||||||
|
if (wgBtn) wgBtn.disabled = wg.length === 0;
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="muted">Нет VLESS/WireGuard inbound’ов</td></tr>';
|
||||||
|
} else {
|
||||||
|
items.forEach((ib) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const on = ib.enable === false
|
||||||
|
? '<span class="badge badge-off">off</span>'
|
||||||
|
: '<span class="badge badge-ok">on</span>';
|
||||||
|
tr.innerHTML = `<td>${ib.id}</td><td>${ib.remark || '—'}</td><td><span class="badge badge-proto">${ib.protocol}</span></td><td>${ib.port}</td><td>${on}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
statusEl.textContent = `загружено: ${items.length} (VLESS ${vless.length}, WG ${wg.length})`;
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = 'ошибка: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', reloadInbounds);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user