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
+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:
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
+126 -11
View File
@@ -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": {
+41
View File
@@ -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)