Show WireGuard QR, full .conf and ZIP download after 3x-ui client create

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 22:51:19 +03:00
co-authored by Cursor
parent 9ae64d1bd1
commit 0ef3562860
9 changed files with 465 additions and 12 deletions
@@ -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")
+6
View File
@@ -156,6 +156,12 @@ class XuiSiteClient(Base):
private_key: Mapped[str | None] = mapped_column(Text, nullable=True) private_key: Mapped[str | None] = mapped_column(Text, nullable=True)
public_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) 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) expiry_days: Mapped[int] = mapped_column(Integer, default=0)
start_after_first_use: Mapped[bool] = mapped_column(Boolean, default=False) start_after_first_use: Mapped[bool] = mapped_column(Boolean, default=False)
expiry_time: Mapped[int] = mapped_column(Integer, default=0) expiry_time: Mapped[int] = mapped_column(Integer, default=0)
+143 -1
View File
@@ -1,4 +1,5 @@
import io import io
import zipfile
import qrcode import qrcode
from fastapi import APIRouter, Depends, Form, Request, status 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, status_code=status.HTTP_303_SEE_OTHER,
) )
try: try:
await xui_service.add_xui_client( result = await xui_service.add_xui_client(
db, db,
panel, panel,
protocol=protocol, protocol=protocol,
@@ -305,12 +306,153 @@ async def create_xui_client_from_clients(
f"/admin/clients?flash=error:{exc}", f"/admin/clients?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER, 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( return RedirectResponse(
"/admin/clients?flash=created", "/admin/clients?flash=created",
status_code=status.HTTP_303_SEE_OTHER, 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") @router.post("/clients/{client_id}/toggle")
async def toggle_client( async def toggle_client(
client_id: int, client_id: int,
+11
View File
@@ -271,6 +271,11 @@ async def xui_add_client(
"privateKey": result.get("privateKey"), "privateKey": result.get("privateKey"),
"publicKey": result.get("publicKey"), "publicKey": result.get("publicKey"),
"allowedIPs": result.get("allowedIPs") or [], "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 [], "links": result.get("links") or [],
"inbound": result.get("inbound"), "inbound": result.get("inbound"),
"expiry_days": result.get("expiry_days"), "expiry_days": result.get("expiry_days"),
@@ -278,6 +283,12 @@ async def xui_add_client(
"expiryTime": result.get("expiryTime"), "expiryTime": result.get("expiryTime"),
} }
payload = quote(json.dumps(slim, ensure_ascii=False)) 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( return RedirectResponse(
f"/admin/xui/{panel_id}?flash=client_created&created={payload}", f"/admin/xui/{panel_id}?flash=client_created&created={payload}",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
+44
View File
@@ -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: def generate_preshared_key() -> str:
return base64.b64encode(secrets.token_bytes(32)).decode("ascii") return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
+126 -11
View File
@@ -2,14 +2,17 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import secrets import secrets
import string import string
from typing import Any from typing import Any
from urllib.parse import urljoin from urllib.parse import urljoin, urlparse
import httpx import httpx
from app.services.crypto import public_key_from_private, render_xui_wireguard_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SUPPORTED_CLIENT_PROTOCOLS = ("vless", "wireguard") 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)) 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: class XuiClient:
""" """
Client for MHSanaei/3x-ui REST API (v3.5.0). Client for MHSanaei/3x-ui REST API (v3.5.0).
@@ -313,6 +410,7 @@ class XuiClient:
expiry_time: int = 0, expiry_time: int = 0,
limit_ip: int = 0, limit_ip: int = 0,
comment: str = "", comment: str = "",
endpoint_host: str | None = None,
) -> dict: ) -> dict:
inbound = await self.get_inbound(inbound_id) inbound = await self.get_inbound(inbound_id)
proto = str(inbound.get("protocol") or "").lower() proto = str(inbound.get("protocol") or "").lower()
@@ -339,16 +437,27 @@ class XuiClient:
} }
await self.add_client_raw(client, [inbound_id]) await self.add_client_raw(client, [inbound_id])
details = await self.get_client(email) details = await self.get_client(email)
allowed_ips = [] # Re-fetch inbound so allocated allowedIPs are present in settings.clients
if isinstance(details, dict): inbound = await self.get_inbound(inbound_id)
allowed_ips = details.get("allowedIPs") or details.get("allowed_ips") or [] allowed_ips = _extract_client_allowed_ips(details, email, inbound)
if not allowed_ips:
# Client may be nested under inbounds / clients list base = endpoint_host or self.base_url
for key in ("client", "obj"): # If endpoint_host is a bare hostname, wrap as URL for urlparse
nested = details.get(key) if endpoint_host and "://" not in endpoint_host:
if isinstance(nested, dict) and nested.get("allowedIPs"): panel_url = f"https://{endpoint_host}"
allowed_ips = nested["allowedIPs"] else:
break 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 { return {
"protocol": "wireguard", "protocol": "wireguard",
"email": email, "email": email,
@@ -356,6 +465,12 @@ class XuiClient:
"privateKey": keys["privateKey"], "privateKey": keys["privateKey"],
"publicKey": keys["publicKey"], "publicKey": keys["publicKey"],
"allowedIPs": allowed_ips, "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), "expiryTime": int(expiry_time),
"details": details, "details": details,
"inbound": { "inbound": {
+41
View File
@@ -287,6 +287,39 @@ async def list_all_site_clients(session: AsyncSession) -> list[XuiSiteClient]:
return list(result.scalars().all()) 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( async def add_xui_client(
session: AsyncSession, session: AsyncSession,
panel: XuiPanel, panel: XuiPanel,
@@ -359,6 +392,14 @@ async def add_xui_client(
row.private_key = result.get("privateKey") row.private_key = result.get("privateKey")
row.public_key = result.get("publicKey") row.public_key = result.get("publicKey")
row.link = links[0] if links else None 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.expiry_days = expiry_days
row.start_after_first_use = start_after_first_use row.start_after_first_use = start_after_first_use
row.expiry_time = int(result.get("expiryTime") or expiry_time or 0) row.expiry_time = int(result.get("expiryTime") or expiry_time or 0)
+1
View File
@@ -168,6 +168,7 @@
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td> <td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
<td> <td>
{% if c.panel %} {% if c.panel %}
<a class="btn btn-sm btn-accent" href="/admin/clients/xui/{{ c.id }}">Открыть</a>
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ c.panel_id }}">Панель</a> <a class="btn btn-sm btn-ghost" href="/admin/xui/{{ c.panel_id }}">Панель</a>
{% endif %} {% endif %}
</td> </td>
@@ -0,0 +1,59 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ client.email }}{% endblock %}
{% block admin_content %}
<div class="admin-top">
<div>
<h1>{{ client.email }}</h1>
<div class="muted">
{{ client.panel.name if client.panel else '3x-ui' }}
· <span class="badge badge-proto">{{ client.protocol }}</span>
{% if client.address %} · {{ client.address }}{% endif %}
{% if client.endpoint %} · {{ client.endpoint }}{% endif %}
</div>
</div>
<div class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/clients">Назад</a>
{% if config %}
<a class="btn btn-sm btn-ghost" href="/admin/clients/xui/{{ client.id }}/config">Скачать .{% if client.protocol == 'wireguard' %}conf{% else %}txt{% endif %}</a>
<a class="btn btn-sm btn-accent" href="/admin/clients/xui/{{ client.id }}/zip">Скачать ZIP</a>
{% endif %}
</div>
</div>
{% if flash %}
{% if flash.startswith('error:') %}
<div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'created' %}
<div class="flash">Клиент создан — отсканируйте QR или скачайте ZIP</div>
{% endif %}
{% endif %}
{% if not config %}
<div class="flash flash-error">Конфиг ещё не собран. Для VLESS скопируйте ссылку из 3x-ui; для WireGuard пересоздайте клиента.</div>
{% else %}
<div class="detail-grid">
<div class="panel">
<div class="panel-head"><strong>{% if client.protocol == 'wireguard' %}Конфиг WireGuard{% else %}Ссылка{% endif %}</strong></div>
<div class="panel-body">
<pre class="mono">{{ config }}</pre>
</div>
</div>
<div class="panel">
<div class="panel-head"><strong>QR‑код</strong></div>
<div class="panel-body">
<img class="qr" src="/admin/clients/xui/{{ client.id }}/qr" alt="QR config" />
<p class="muted" style="margin-top:0.8rem">
{% if client.protocol == 'wireguard' %}
Отсканируйте в WireGuard / AmneziaWG или скачайте ZIP с `.conf`.
{% else %}
Отсканируйте в клиенте VLESS или скачайте ZIP со ссылкой.
{% endif %}
</p>
{% if client.comment %}
<p><strong>Комментарий:</strong> {{ client.comment }}</p>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}