3x-ui: create VLESS/WireGuard clients and reload available inbounds

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 22:09:59 +03:00
co-authored by Cursor
parent b67e7b80ba
commit 1626c16df0
4 changed files with 525 additions and 136 deletions
+58 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+184 -38
View File
@@ -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 %}