diff --git a/alembic/versions/0008_xui_client_config.py b/alembic/versions/0008_xui_client_config.py new file mode 100644 index 0000000..2898e5c --- /dev/null +++ b/alembic/versions/0008_xui_client_config.py @@ -0,0 +1,34 @@ +"""Store WireGuard config fields for site-created 3x-ui clients + +Revision ID: 0008_xui_client_config +Revises: 0007_xui_panel_inbounds +Create Date: 2026-07-25 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0008_xui_client_config" +down_revision: Union[str, None] = "0007_xui_panel_inbounds" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("xui_site_clients", sa.Column("address", sa.String(length=64), nullable=True)) + op.add_column("xui_site_clients", sa.Column("endpoint", sa.String(length=255), nullable=True)) + op.add_column("xui_site_clients", sa.Column("dns", sa.String(length=255), nullable=True)) + op.add_column("xui_site_clients", sa.Column("mtu", sa.Integer(), nullable=True)) + op.add_column("xui_site_clients", sa.Column("server_public_key", sa.Text(), nullable=True)) + op.add_column("xui_site_clients", sa.Column("config_text", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("xui_site_clients", "config_text") + op.drop_column("xui_site_clients", "server_public_key") + op.drop_column("xui_site_clients", "mtu") + op.drop_column("xui_site_clients", "dns") + op.drop_column("xui_site_clients", "endpoint") + op.drop_column("xui_site_clients", "address") diff --git a/app/models.py b/app/models.py index 2026701..a63a60a 100644 --- a/app/models.py +++ b/app/models.py @@ -156,6 +156,12 @@ class XuiSiteClient(Base): private_key: Mapped[str | None] = mapped_column(Text, nullable=True) public_key: Mapped[str | None] = mapped_column(Text, nullable=True) link: Mapped[str | None] = mapped_column(Text, nullable=True) + address: Mapped[str | None] = mapped_column(String(64), nullable=True) + endpoint: Mapped[str | None] = mapped_column(String(255), nullable=True) + dns: Mapped[str | None] = mapped_column(String(255), nullable=True) + mtu: Mapped[int | None] = mapped_column(Integer, nullable=True) + server_public_key: Mapped[str | None] = mapped_column(Text, nullable=True) + config_text: Mapped[str | None] = mapped_column(Text, nullable=True) expiry_days: Mapped[int] = mapped_column(Integer, default=0) start_after_first_use: Mapped[bool] = mapped_column(Boolean, default=False) expiry_time: Mapped[int] = mapped_column(Integer, default=0) diff --git a/app/routers/admin.py b/app/routers/admin.py index 5e3cb2a..b47bb5d 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -1,4 +1,5 @@ import io +import zipfile import qrcode from fastapi import APIRouter, Depends, Form, Request, status @@ -286,7 +287,7 @@ async def create_xui_client_from_clients( status_code=status.HTTP_303_SEE_OTHER, ) try: - await xui_service.add_xui_client( + result = await xui_service.add_xui_client( db, panel, protocol=protocol, @@ -305,12 +306,153 @@ async def create_xui_client_from_clients( f"/admin/clients?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) + site_id = result.get("site_client_id") + if site_id: + return RedirectResponse( + f"/admin/clients/xui/{site_id}?flash=created", + status_code=status.HTTP_303_SEE_OTHER, + ) return RedirectResponse( "/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER, ) +@router.get("/clients/xui/{site_client_id}", response_class=HTMLResponse) +async def xui_site_client_detail( + site_client_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + row = await xui_service.get_site_client(db, site_client_id) + if not row: + return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) + stem, config = await xui_service.site_client_download_payload(row) + return request.app.state.templates.TemplateResponse( + "admin/xui_client_detail.html", + { + "request": request, + "admin": admin, + "client": row, + "config": config, + "filename_stem": stem, + "app_name": request.app.state.settings.app_name, + "flash": request.query_params.get("flash"), + }, + ) + + +@router.get("/clients/xui/{site_client_id}/config") +async def xui_site_client_config( + site_client_id: int, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + row = await xui_service.get_site_client(db, site_client_id) + if not row: + return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) + stem, config = await xui_service.site_client_download_payload(row) + if not config: + return RedirectResponse( + f"/admin/clients/xui/{site_client_id}?flash=error:Нет конфига", + status_code=status.HTTP_303_SEE_OTHER, + ) + ext = "conf" if row.protocol == "wireguard" else "txt" + return Response( + content=config, + media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="{stem}.{ext}"'}, + ) + + +@router.get("/clients/xui/{site_client_id}/qr") +async def xui_site_client_qr( + site_client_id: int, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + row = await xui_service.get_site_client(db, site_client_id) + if not row: + return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) + _, config = await xui_service.site_client_download_payload(row) + if not config: + return Response(status_code=404) + img = qrcode.make(config) + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return StreamingResponse(buf, media_type="image/png") + + +@router.get("/clients/xui/{site_client_id}/zip") +async def xui_site_client_zip( + site_client_id: int, + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + row = await xui_service.get_site_client(db, site_client_id) + if not row: + return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) + stem, config = await xui_service.site_client_download_payload(row) + if not config: + return RedirectResponse( + f"/admin/clients/xui/{site_client_id}?flash=error:Нет конфига", + status_code=status.HTTP_303_SEE_OTHER, + ) + ext = "conf" if row.protocol == "wireguard" else "txt" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{stem}.{ext}", config) + if row.protocol == "wireguard": + zf.writestr( + f"{stem}-info.txt", + "\n".join( + [ + f"Email: {row.email}", + f"Protocol: {row.protocol}", + f"Address: {row.address or ''}", + f"Endpoint: {row.endpoint or ''}", + f"DNS: {row.dns or ''}", + f"MTU: {row.mtu or ''}", + f"Panel: {row.panel.name if row.panel else ''}", + "", + "Import the .conf into WireGuard / AmneziaWG, or scan the QR on the panel.", + "", + ] + ), + ) + else: + zf.writestr( + f"{stem}-info.txt", + "\n".join( + [ + f"Email: {row.email}", + f"Protocol: {row.protocol}", + f"UUID: {row.uuid or ''}", + f"Flow: {row.flow or ''}", + "", + "Import the link into a VLESS client, or scan the QR on the panel.", + "", + ] + ), + ) + buf.seek(0) + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{stem}.zip"'}, + ) + + @router.post("/clients/{client_id}/toggle") async def toggle_client( client_id: int, diff --git a/app/routers/xui.py b/app/routers/xui.py index f362775..ff2f872 100644 --- a/app/routers/xui.py +++ b/app/routers/xui.py @@ -271,6 +271,11 @@ async def xui_add_client( "privateKey": result.get("privateKey"), "publicKey": result.get("publicKey"), "allowedIPs": result.get("allowedIPs") or [], + "address": result.get("address"), + "endpoint": result.get("endpoint"), + "dns": result.get("dns"), + "mtu": result.get("mtu"), + "config_text": result.get("config_text"), "links": result.get("links") or [], "inbound": result.get("inbound"), "expiry_days": result.get("expiry_days"), @@ -278,6 +283,12 @@ async def xui_add_client( "expiryTime": result.get("expiryTime"), } payload = quote(json.dumps(slim, ensure_ascii=False)) + site_id = result.get("site_client_id") + if site_id and result.get("protocol") == "wireguard" and result.get("config_text"): + return RedirectResponse( + f"/admin/clients/xui/{site_id}?flash=created", + status_code=status.HTTP_303_SEE_OTHER, + ) return RedirectResponse( f"/admin/xui/{panel_id}?flash=client_created&created={payload}", status_code=status.HTTP_303_SEE_OTHER, diff --git a/app/services/crypto.py b/app/services/crypto.py index 2dd2fd9..1525377 100644 --- a/app/services/crypto.py +++ b/app/services/crypto.py @@ -43,6 +43,50 @@ def generate_keypair() -> KeyPair: ) +def public_key_from_private(private_key_b64: str) -> str: + """Derive WireGuard/X25519 public key from a base64 private key.""" + raw = base64.b64decode(private_key_b64.strip()) + if len(raw) != 32: + raise ValueError("Invalid private key length") + private = X25519PrivateKey.from_private_bytes(raw) + public_bytes = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return base64.b64encode(public_bytes).decode("ascii") + + +def render_xui_wireguard_config( + *, + private_key: str, + address: str, + dns: str, + mtu: int, + server_public_key: str, + endpoint: str, + peer_allowed_ips: str = "0.0.0.0/0, ::/0", + remark: str = "", +) -> str: + addr = address if "/" in address else f"{address}/32" + lines = [ + "[Interface]", + f"PrivateKey = {private_key}", + f"Address = {addr}", + f"DNS = {dns}", + f"MTU = {mtu}", + "", + ] + if remark: + lines.append(f"# {remark}") + lines.extend( + [ + "[Peer]", + f"PublicKey = {server_public_key}", + f"AllowedIPs = {peer_allowed_ips}", + f"Endpoint = {endpoint}", + "", + ] + ) + return "\n".join(lines) + + def generate_preshared_key() -> str: return base64.b64encode(secrets.token_bytes(32)).decode("ascii") diff --git a/app/services/xui.py b/app/services/xui.py index 9484922..f5696da 100644 --- a/app/services/xui.py +++ b/app/services/xui.py @@ -2,14 +2,17 @@ from __future__ import annotations +import json import logging import secrets import string from typing import Any -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse import httpx +from app.services.crypto import public_key_from_private, render_xui_wireguard_config + logger = logging.getLogger(__name__) SUPPORTED_CLIENT_PROTOCOLS = ("vless", "wireguard") @@ -26,6 +29,100 @@ def _random_sub_id(length: int = 16) -> str: return "".join(secrets.choice(alphabet) for _ in range(length)) +def _as_dict(value: Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + +def _extract_client_allowed_ips(details: Any, email: str, inbound: dict) -> list[str]: + allowed: list[str] = [] + if isinstance(details, dict): + allowed = details.get("allowedIPs") or details.get("allowed_ips") or [] + if not allowed: + for key in ("client", "obj"): + nested = details.get(key) + if isinstance(nested, dict) and nested.get("allowedIPs"): + allowed = nested["allowedIPs"] + break + if allowed: + return [str(x) for x in allowed if x] + + settings = _as_dict(inbound.get("settings")) + for c in settings.get("clients") or []: + if not isinstance(c, dict): + continue + if str(c.get("email") or "") == email: + return [str(x) for x in (c.get("allowedIPs") or []) if x] + return [] + + +def build_wireguard_client_bundle( + *, + panel_base_url: str, + inbound: dict, + private_key: str, + public_key: str, + email: str, + allowed_ips: list[str], + remark: str = "", +) -> dict[str, Any]: + settings = _as_dict(inbound.get("settings")) + secret_key = str(settings.get("secretKey") or "").strip() + if not secret_key: + raise XuiApiError("У inbound WireGuard нет secretKey — не удалось собрать конфиг") + server_public_key = public_key_from_private(secret_key) + dns = str(settings.get("dns") or "").strip() or "1.1.1.1, 1.0.0.1" + mtu = int(settings.get("mtu") or 1420) + port = int(inbound.get("port") or 0) + host = urlparse(panel_base_url).hostname or "" + listen = str(inbound.get("listen") or "").strip() + if listen and listen not in {"0.0.0.0", "::", ""}: + host = listen + if not host or not port: + raise XuiApiError("Не удалось определить Endpoint (host:port) для WireGuard") + endpoint = f"{host}:{port}" + + address = "" + for item in allowed_ips: + s = str(item).strip() + if s and not s.startswith("0.0.0.0") and s not in {"::/0", "::"}: + address = s if "/" in s else f"{s}/32" + break + if not address and allowed_ips: + address = str(allowed_ips[0]) + if not address: + raise XuiApiError("3x-ui не выделил Address (allowedIPs) для клиента") + + peer_remark = remark or str(inbound.get("remark") or email) + config_text = render_xui_wireguard_config( + private_key=private_key, + address=address, + dns=dns, + mtu=mtu, + server_public_key=server_public_key, + endpoint=endpoint, + remark=peer_remark, + ) + return { + "address": address, + "dns": dns, + "mtu": mtu, + "endpoint": endpoint, + "server_public_key": server_public_key, + "config_text": config_text, + "allowedIPs": allowed_ips, + "privateKey": private_key, + "publicKey": public_key, + } + + class XuiClient: """ Client for MHSanaei/3x-ui REST API (v3.5.0). @@ -313,6 +410,7 @@ class XuiClient: expiry_time: int = 0, limit_ip: int = 0, comment: str = "", + endpoint_host: str | None = None, ) -> dict: inbound = await self.get_inbound(inbound_id) proto = str(inbound.get("protocol") or "").lower() @@ -339,16 +437,27 @@ class XuiClient: } await self.add_client_raw(client, [inbound_id]) details = await self.get_client(email) - allowed_ips = [] - if isinstance(details, dict): - allowed_ips = details.get("allowedIPs") or details.get("allowed_ips") or [] - if not allowed_ips: - # Client may be nested under inbounds / clients list - for key in ("client", "obj"): - nested = details.get(key) - if isinstance(nested, dict) and nested.get("allowedIPs"): - allowed_ips = nested["allowedIPs"] - break + # Re-fetch inbound so allocated allowedIPs are present in settings.clients + inbound = await self.get_inbound(inbound_id) + allowed_ips = _extract_client_allowed_ips(details, email, inbound) + + base = endpoint_host or self.base_url + # If endpoint_host is a bare hostname, wrap as URL for urlparse + if endpoint_host and "://" not in endpoint_host: + panel_url = f"https://{endpoint_host}" + else: + panel_url = base if "://" in str(base) else f"https://{base}" + + remark = str(inbound.get("remark") or email) + bundle = build_wireguard_client_bundle( + panel_base_url=panel_url, + inbound=inbound, + private_key=keys["privateKey"], + public_key=keys["publicKey"], + email=email, + allowed_ips=allowed_ips, + remark=remark, + ) return { "protocol": "wireguard", "email": email, @@ -356,6 +465,12 @@ class XuiClient: "privateKey": keys["privateKey"], "publicKey": keys["publicKey"], "allowedIPs": allowed_ips, + "address": bundle["address"], + "dns": bundle["dns"], + "mtu": bundle["mtu"], + "endpoint": bundle["endpoint"], + "server_public_key": bundle["server_public_key"], + "config_text": bundle["config_text"], "expiryTime": int(expiry_time), "details": details, "inbound": { diff --git a/app/services/xui_panels.py b/app/services/xui_panels.py index 56c0b8d..434b01b 100644 --- a/app/services/xui_panels.py +++ b/app/services/xui_panels.py @@ -287,6 +287,39 @@ async def list_all_site_clients(session: AsyncSession) -> list[XuiSiteClient]: return list(result.scalars().all()) +async def get_site_client(session: AsyncSession, site_client_id: int) -> XuiSiteClient | None: + from sqlalchemy.orm import selectinload + + result = await session.execute( + select(XuiSiteClient) + .options(selectinload(XuiSiteClient.panel)) + .where(XuiSiteClient.id == site_client_id) + ) + return result.scalar_one_or_none() + + +async def site_client_download_payload(row: XuiSiteClient) -> tuple[str, str]: + """Return (filename_stem, text content) for conf/link download.""" + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (row.email or "client")) + if row.protocol == "wireguard": + text = row.config_text or "" + if not text and row.private_key and row.address and row.server_public_key and row.endpoint: + from app.services.crypto import render_xui_wireguard_config + + text = render_xui_wireguard_config( + private_key=row.private_key, + address=row.address, + dns=row.dns or "1.1.1.1, 1.0.0.1", + mtu=row.mtu or 1420, + server_public_key=row.server_public_key, + endpoint=row.endpoint, + remark=row.inbound_remark or row.email, + ) + return safe, text + text = row.link or row.config_text or "" + return safe, text + + async def add_xui_client( session: AsyncSession, panel: XuiPanel, @@ -359,6 +392,14 @@ async def add_xui_client( row.private_key = result.get("privateKey") row.public_key = result.get("publicKey") row.link = links[0] if links else None + row.address = result.get("address") + row.endpoint = result.get("endpoint") + row.dns = result.get("dns") + row.mtu = result.get("mtu") + row.server_public_key = result.get("server_public_key") + row.config_text = result.get("config_text") + if not row.config_text and row.link: + row.config_text = row.link row.expiry_days = expiry_days row.start_after_first_use = start_after_first_use row.expiry_time = int(result.get("expiryTime") or expiry_time or 0) diff --git a/app/templates/admin/clients.html b/app/templates/admin/clients.html index 9ead12f..2cd83ea 100644 --- a/app/templates/admin/clients.html +++ b/app/templates/admin/clients.html @@ -168,6 +168,7 @@
{{ config }}
+ + {% if client.protocol == 'wireguard' %} + Отсканируйте в WireGuard / AmneziaWG или скачайте ZIP с `.conf`. + {% else %} + Отсканируйте в клиенте VLESS или скачайте ZIP со ссылкой. + {% endif %} +
+ {% if client.comment %} +Комментарий: {{ client.comment }}
+ {% endif %} +