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:
@@ -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)
|
||||
|
||||
+143
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+126
-11
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
|
||||
<td>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
Reference in New Issue
Block a user