diff --git a/app/routers/xui.py b/app/routers/xui.py index db65863..28ef9fe 100644 --- a/app/routers/xui.py +++ b/app/routers/xui.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +from urllib.parse import quote -from fastapi import APIRouter, Depends, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi import APIRouter, Depends, Form, Query, Request, status +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -79,7 +80,10 @@ async def xui_check( 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) + return RedirectResponse( + f"/admin/xui/{panel_id}?flash=ok", + status_code=status.HTTP_303_SEE_OTHER, + ) @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) +@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) async def xui_detail( panel_id: int, @@ -114,12 +138,13 @@ async def xui_detail( except Exception as exc: # noqa: BLE001 error = str(exc) - info = None - if panel.panel_info: + created = None + created_raw = request.query_params.get("created") + if created_raw: try: - info = json.loads(panel.panel_info) + created = json.loads(created_raw) except json.JSONDecodeError: - info = None + created = None return request.app.state.templates.TemplateResponse( "admin/xui_detail.html", @@ -128,7 +153,7 @@ async def xui_detail( "admin": admin, "panel": panel, "data": data, - "info": info, + "created": created, "error": error, "app_name": request.app.state.settings.app_name, "flash": request.query_params.get("flash"), @@ -139,10 +164,13 @@ async def xui_detail( @router.post("/{panel_id}/clients") async def xui_add_client( panel_id: int, + protocol: str = Form(...), email: str = Form(...), inbound_id: int = Form(...), total_gb: int = Form(0), limit_ip: int = Form(0), + flow: str = Form(""), + comment: str = Form(""), db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): @@ -152,19 +180,35 @@ async def xui_add_client( if not panel: return RedirectResponse("/admin/xui", status_code=status.HTTP_303_SEE_OTHER) try: - await xui_service.add_xui_client( + result = await xui_service.add_xui_client( panel, + protocol=protocol, email=email.strip(), - inbound_ids=[inbound_id], + inbound_id=inbound_id, total_gb=total_gb, 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 return RedirectResponse( f"/admin/xui/{panel_id}?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) - return RedirectResponse( - f"/admin/xui/{panel_id}?flash=client_created", - status_code=status.HTTP_303_SEE_OTHER, - ) diff --git a/app/services/xui.py b/app/services/xui.py index 0581373..25eb144 100644 --- a/app/services/xui.py +++ b/app/services/xui.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +import secrets +import string from typing import Any from urllib.parse import urljoin @@ -10,6 +12,8 @@ import httpx logger = logging.getLogger(__name__) +SUPPORTED_CLIENT_PROTOCOLS = ("vless", "wireguard") + class XuiApiError(Exception): def __init__(self, message: str, status_code: int | None = None): @@ -17,13 +21,18 @@ class XuiApiError(Exception): 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: """ 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) + Auth: + 1) API Token → Authorization: Bearer … + 2) Username/password session (+ CSRF when required) """ def __init__( @@ -75,9 +84,7 @@ class XuiClient: 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("/") @@ -94,12 +101,11 @@ class XuiClient: except Exception: # noqa: BLE001 pass - headers = {} + headers: dict[str, str] = {} 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( @@ -144,58 +150,214 @@ class XuiClient: 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 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]: 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_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]: 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 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]: - """Verify credentials and return summary for UI.""" status = await self.get_status() info: dict[str, Any] = {"status": status} try: @@ -203,17 +365,14 @@ class XuiClient: 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] + available = await self.list_available_inbounds( + protocols=SUPPORTED_CLIENT_PROTOCOLS, enabled_only=False + ) + info["inbounds_count"] = len(available) + info["inbounds"] = available[:100] + info["vless_inbounds"] = [i for i in available if str(i.get("protocol")).lower() == "vless"] + info["wireguard_inbounds"] = [ + i for i in available if str(i.get("protocol")).lower() == "wireguard" ] except Exception as exc: # noqa: BLE001 info["inbounds_error"] = str(exc) diff --git a/app/services/xui_panels.py b/app/services/xui_panels.py index e087dd0..73dc6e6 100644 --- a/app/services/xui_panels.py +++ b/app/services/xui_panels.py @@ -4,12 +4,28 @@ from __future__ import annotations import json from datetime import datetime, timezone +from typing import Any 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 +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]: @@ -47,7 +63,6 @@ async def create_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: @@ -62,13 +77,7 @@ async def check_panel(session: AsyncSession, panel_id: int) -> XuiPanel: 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: + async with _client_for(panel) as client: info = await client.test_connection() panel.is_reachable = True panel.last_error = None @@ -94,41 +103,72 @@ async def delete_panel(session: AsyncSession, panel_id: int) -> None: 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: +async def fetch_panel_data(panel: XuiPanel) -> dict[str, Any]: + async with _client_for(panel) as client: 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: clients = await client.list_clients() except XuiApiError: 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( panel: XuiPanel, *, + protocol: str, email: str, - inbound_ids: list[int], + inbound_id: int, total_gb: int = 0, limit_ip: int = 0, + flow: str = "", + comment: str = "", ) -> 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, - ) + proto = protocol.strip().lower() + bytes_limit = total_gb * 1024 * 1024 * 1024 if total_gb > 0 else 0 + async with _client_for(panel) as client: + if proto == "vless": + return await client.add_vless_client( + email=email, + inbound_id=inbound_id, + total_gb=bytes_limit, + limit_ip=limit_ip, + flow=flow, + comment=comment, + ) + 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") diff --git a/app/templates/admin/xui_detail.html b/app/templates/admin/xui_detail.html index 3d0737f..cfeb43e 100644 --- a/app/templates/admin/xui_detail.html +++ b/app/templates/admin/xui_detail.html @@ -8,6 +8,7 @@
Назад +
@@ -19,6 +20,8 @@
{{ flash[6:] }}
{% elif flash == 'client_created' %}
Клиент создан в 3x-ui
+ {% elif flash == 'ok' %} +
Данные обновлены
{% endif %} {% endif %} @@ -26,53 +29,122 @@
{{ error }}
{% endif %} +{% if created %} +
+
Новый клиент ({{ created.protocol }})
+
+

{{ created.email }} → inbound #{{ created.inbound_id }}

+ {% if created.protocol == 'vless' %} + {% if created.uuid %}

UUID: {{ created.uuid }}{% if created.flow %} · flow: {{ created.flow }}{% endif %}

{% endif %} + {% if created.links %} + {% for link in created.links %} +
{{ link }}
+ {% endfor %} + {% else %} +

Ссылка vless:// появится в 3x-ui (Clients → Copy URL).

+ {% endif %} + {% elif created.protocol == 'wireguard' %} +

Ключи клиента (конфиг соберите в 3x-ui или ниже):

+
PrivateKey = {{ created.privateKey }}
+PublicKey = {{ created.publicKey }}
+{% if created.inbound %}Endpoint port = {{ created.inbound.port }}{% endif %}
+ {% endif %} +
+
+{% endif %} + {% if data %}
-
Inbounds
+
Все inbound’ы
{{ data.inbounds|length }}
-
Clients
-
{{ data.clients|length }}
+
VLESS
+
{{ data.vless_inbounds|length }}
-
Xray
-
- {% if data.status and data.status.xray %} - {% if data.status.xray.state %}running{% else %}stopped{% endif %} - {% else %}—{% endif %} +
WireGuard
+
{{ data.wireguard_inbounds|length }}
+
+
+ +
+
+
Создать VLESS клиента
+
+
+ + + + +
+ + +
+ + +
+
+
+ +
+
Создать WireGuard клиента
+
+
+ + + +
+ + +
+ + +
-
Добавить клиента через API
-
-
- - - - - -
+
+ Доступные inbound’ы (VLESS / WireGuard) +
-
- -
-
Inbounds
@@ -83,17 +155,17 @@ - - {% for ib in data.inbounds %} + + {% for ib in data.available_inbounds %} - + {% else %} - + {% endfor %}
Enable
{{ ib.id }} {{ ib.remark or '—' }} {{ ib.protocol }} {{ ib.port }}{% if ib.enable %}on{% else %}off{% endif %}{% if ib.enable is not defined or ib.enable %}on{% else %}off{% endif %}
Нет inbound’ов
Нет VLESS/WireGuard inbound’ов
@@ -123,10 +195,84 @@ {% else %} - Нет клиентов или endpoint /clients/list недоступен + Нет клиентов {% endfor %}
+ + {% endif %} {% endblock %}