Compare commits

4 Commits
12 changed files with 574 additions and 172 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)
+181 -85
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
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel, XuiPanelInbound, XuiSiteClient
from app.services import vpn as vpn_service from app.services import vpn as vpn_service
from app.services import xui_panels as xui_service from app.services import xui_panels as xui_service
@@ -35,47 +36,41 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), admin=
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all() xui_panels = await xui_service.list_panels(db)
clients_total = await db.scalar(select(func.count()).select_from(VpnClient)) or 0 xui_clients_total = await db.scalar(select(func.count()).select_from(XuiSiteClient)) or 0
clients_active = await db.scalar( user_inbounds_total = await db.scalar(
select(func.count()).select_from(VpnClient).where(VpnClient.is_enabled.is_(True)) select(func.count())
.select_from(XuiPanelInbound)
.where(XuiPanelInbound.is_available_for_users.is_(True))
) or 0 ) or 0
user_inbounds_by_panel: dict[int, list] = {}
for panel in xui_panels:
rows = await xui_service.list_user_inbounds(db, panel.id)
user_inbounds_by_panel[panel.id] = [
{"inbound_id": r.inbound_id, "protocol": r.protocol, "remark": r.remark, "port": r.port}
for r in rows
]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/dashboard.html", "admin/dashboard.html",
{ {
"request": request, "request": request,
"admin": admin, "admin": admin,
"servers": servers, "xui_panels": xui_panels,
"clients_total": clients_total, "xui_clients_total": xui_clients_total,
"clients_active": clients_active, "user_inbounds_total": user_inbounds_total,
"user_inbounds_by_panel": user_inbounds_by_panel,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
}, },
) )
@router.get("/servers", response_class=HTMLResponse) @router.get("/servers", response_class=HTMLResponse)
async def servers_page(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)): async def servers_page(admin=Depends(require_admin)):
"""WG/AWG servers UI временно скрыт — редирект на клиентов 3x-ui."""
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
servers = ( return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER)
await db.execute(
select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id)
)
).scalars().all()
probes = await vpn_service.probe_servers(list(servers))
return request.app.state.templates.TemplateResponse(
"admin/servers.html",
{
"request": request,
"admin": admin,
"servers": servers,
"probes": probes,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"),
},
)
@router.post("/servers") @router.post("/servers")
@@ -230,83 +225,43 @@ async def update_server(
@router.get("/clients", response_class=HTMLResponse) @router.get("/clients", response_class=HTMLResponse)
async def clients_page( async def clients_page(
request: Request, request: Request,
protocol: str | None = None,
source: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
tab = (source or protocol or "wg").lower()
if tab in ("wireguard", "awg2"):
tab = "wg"
if tab not in ("wg", "xui"):
tab = "wg"
query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc())
if protocol in ("wireguard", "awg2"):
query = query.join(VpnServer).where(VpnServer.protocol == protocol)
tab = "wg"
clients = (await db.execute(query)).scalars().all()
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
xui_panels = await xui_service.list_panels(db) xui_panels = await xui_service.list_panels(db)
xui_clients = await xui_service.list_all_site_clients(db) if tab == "xui" else [] xui_clients = await xui_service.list_all_site_clients(db)
user_inbounds_by_panel: dict[int, list] = {} user_inbounds_by_panel: dict[int, list] = {}
if tab == "xui": for panel in xui_panels:
for panel in xui_panels: rows = await xui_service.list_user_inbounds(db, panel.id)
rows = await xui_service.list_user_inbounds(db, panel.id) user_inbounds_by_panel[panel.id] = [
user_inbounds_by_panel[panel.id] = [ {
{ "inbound_id": r.inbound_id,
"inbound_id": r.inbound_id, "protocol": r.protocol,
"protocol": r.protocol, "remark": r.remark,
"remark": r.remark, "port": r.port,
"port": r.port, }
} for r in rows
for r in rows ]
] panels_with_inbounds = [p for p in xui_panels if user_inbounds_by_panel.get(p.id)]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/clients.html", "admin/clients.html",
{ {
"request": request, "request": request,
"admin": admin, "admin": admin,
"clients": clients,
"servers": servers,
"xui_panels": xui_panels, "xui_panels": xui_panels,
"xui_clients": xui_clients, "xui_clients": xui_clients,
"user_inbounds_by_panel": user_inbounds_by_panel, "user_inbounds_by_panel": user_inbounds_by_panel,
"tab": tab, "panels_with_inbounds": panels_with_inbounds,
"filter_protocol": protocol,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"), "flash": request.query_params.get("flash"),
}, },
) )
@router.post("/clients")
async def create_client(
request: Request,
name: str = Form(...),
server_id: int = Form(...),
notes: str = Form(""),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
try:
await vpn_service.create_client(db, server_id=server_id, name=name, notes=notes or None)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/clients?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse("/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/clients/xui") @router.post("/clients/xui")
async def create_xui_client_from_clients( async def create_xui_client_from_clients(
request: Request, request: Request,
@@ -328,11 +283,11 @@ async def create_xui_client_from_clients(
panel = await db.get(XuiPanel, panel_id) panel = await db.get(XuiPanel, panel_id)
if not panel: if not panel:
return RedirectResponse( return RedirectResponse(
"/admin/clients?source=xui&flash=error:Панель не найдена", "/admin/clients?flash=error:Панель не найдена",
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,
@@ -348,15 +303,156 @@ async def create_xui_client_from_clients(
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return RedirectResponse( return RedirectResponse(
f"/admin/clients?source=xui&flash=error:{exc}", 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, status_code=status.HTTP_303_SEE_OTHER,
) )
return RedirectResponse( return RedirectResponse(
"/admin/clients?source=xui&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,
+12
View File
@@ -270,6 +270,12 @@ async def xui_add_client(
"flow": result.get("flow"), "flow": result.get("flow"),
"privateKey": result.get("privateKey"), "privateKey": result.get("privateKey"),
"publicKey": result.get("publicKey"), "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 [], "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"),
@@ -277,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")
+130 -2
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()
@@ -320,13 +418,15 @@ class XuiClient:
raise XuiApiError(f"Inbound #{inbound_id} — не WireGuard (protocol={proto})") raise XuiApiError(f"Inbound #{inbound_id} — не WireGuard (protocol={proto})")
keys = await self.get_new_x25519() keys = await self.get_new_x25519()
# Leave allowedIPs empty — 3x-ui allocates a unique peer address
# (e.g. 10.0.0.3/32). Sending 0.0.0.0/0 collides with other clients.
client = { client = {
"email": email, "email": email,
"enable": True, "enable": True,
"privateKey": keys["privateKey"], "privateKey": keys["privateKey"],
"publicKey": keys["publicKey"], "publicKey": keys["publicKey"],
"preSharedKey": "", "preSharedKey": "",
"allowedIPs": ["0.0.0.0/0", "::/0"], "allowedIPs": [],
"keepAlive": 0, "keepAlive": 0,
"totalGB": total_gb, "totalGB": total_gb,
"expiryTime": int(expiry_time), "expiryTime": int(expiry_time),
@@ -337,12 +437,40 @@ 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)
# 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 { return {
"protocol": "wireguard", "protocol": "wireguard",
"email": email, "email": email,
"inbound_id": inbound_id, "inbound_id": inbound_id,
"privateKey": keys["privateKey"], "privateKey": keys["privateKey"],
"publicKey": keys["publicKey"], "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), "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)
+47 -63
View File
@@ -4,12 +4,7 @@
<div class="admin-top"> <div class="admin-top">
<h1>Клиенты</h1> <h1>Клиенты</h1>
<div class="actions"> <div class="actions">
<a class="btn btn-sm {% if tab == 'wg' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients">WireGuard / AWG</a> <a class="btn btn-sm btn-accent" href="/admin/xui">Управление 3x-ui</a>
<a class="btn btn-sm {% if tab == 'xui' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients?source=xui">3x-ui</a>
{% if tab == 'wg' %}
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=wireguard">WireGuard</a>
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=awg2">AWG 2.0</a>
{% endif %}
</div> </div>
</div> </div>
@@ -23,90 +18,77 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if tab == 'wg' %}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Новый доступ (WireGuard / AWG)</strong></div> <div class="panel-head">
<div class="panel-body"> <strong>Доступные серверы 3x-ui</strong>
<form method="post" action="/admin/clients" class="form-grid"> <span class="muted">добавленные панели и inbound’ы, включённые для пользователей</span>
<label>Имя
<input name="name" placeholder="phone / laptop" required />
</label>
<label>Сервер / протокол
<select name="server_id" required>
{% for s in servers %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.protocol }})</option>
{% else %}
<option value="" disabled selected>Нет серверов</option>
{% endfor %}
</select>
</label>
<button class="btn btn-accent" type="submit" {% if not servers %}disabled{% endif %}>Создать</button>
<label style="grid-column: 1 / -1">Заметка
<input name="notes" placeholder="опционально" />
</label>
</form>
</div> </div>
</div> {% if not xui_panels %}
<div class="panel-body">
<div class="panel"> <p class="muted" style="margin:0">Нет добавленных панелей. Подключите 3x-ui на странице <a href="/admin/xui">3x-ui</a>.</p>
</div>
{% else %}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Сервер</th>
<th>Протокол</th>
<th>IP</th>
<th>Статус</th> <th>Статус</th>
<th>Создан</th> <th>Inbound’ы для пользователей</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for c in clients %} {% for p in xui_panels %}
{% set inbounds = user_inbounds_by_panel.get(p.id, []) %}
<tr> <tr>
<td><a href="/admin/clients/{{ c.id }}">{{ c.name }}</a></td>
<td><span class="badge badge-proto">{{ c.server.protocol }}</span></td>
<td>{{ c.address }}</td>
<td> <td>
{% if c.is_enabled %} <strong>{{ p.name }}</strong>
<span class="badge badge-ok">on</span> <div class="muted" style="font-size:.8rem;word-break:break-all">{{ p.base_url }}</div>
</td>
<td>
{% if p.is_reachable %}
<span class="badge badge-ok">online</span>
{% else %} {% else %}
<span class="badge badge-off">off</span> <span class="badge badge-off">offline</span>
{% endif %} {% endif %}
</td> </td>
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td> <td>
<td class="actions"> {% if inbounds %}
<a class="btn btn-sm btn-ghost" href="/admin/clients/{{ c.id }}">Открыть</a> <div style="display:flex;flex-wrap:wrap;gap:.35rem">
<form method="post" action="/admin/clients/{{ c.id }}/toggle" class="inline-form"> {% for ib in inbounds %}
<button class="btn btn-sm btn-ghost" type="submit">{% if c.is_enabled %}Выкл{% else %}Вкл{% endif %}</button> <span class="badge badge-proto" title="port {{ ib.port or '?' }}">
</form> #{{ ib.inbound_id }} {{ ib.remark or ib.protocol }} · {{ ib.protocol }}{% if ib.port %}:{{ ib.port }}{% endif %}
<form method="post" action="/admin/clients/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить клиента?')"> </span>
<button class="btn btn-sm btn-danger" type="submit">Удалить</button> {% endfor %}
</form> </div>
{% else %}
<span class="muted">нет включённых — откройте панель и отметьте inbound’ы</span>
{% endif %}
</td>
<td>
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ p.id }}">Настроить</a>
</td> </td>
</tr> </tr>
{% else %}
<tr><td colspan="6" class="muted">Клиентов пока нет</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
</div> </div>
{% else %}
{# ——— 3x-ui ——— #}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"> <div class="panel-head">
<strong>Новый клиент 3x-ui</strong> <strong>Новый клиент</strong>
<span class="muted">только inbound’ы, отмеченные на сервере как доступные пользователям</span> <span class="muted">только по включённым inbound’ам</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% if not xui_panels %} {% if not panels_with_inbounds %}
<p class="muted" style="margin:0">Сначала добавьте панель на странице <a href="/admin/xui">3x-ui</a>.</p> <p class="muted" style="margin:0">Нет серверов с включёнными inbound’ами. Добавьте панель в <a href="/admin/xui">3x-ui</a> и отметьте inbound’ы «для пользователей».</p>
{% else %} {% else %}
<form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form"> <form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form">
<div class="form-grid"> <div class="form-grid">
<label>Сервер 3x-ui <label>Сервер 3x-ui
<select name="panel_id" id="xui-panel" required> <select name="panel_id" id="xui-panel" required>
{% for p in xui_panels %} {% for p in panels_with_inbounds %}
<option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option> <option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
@@ -147,15 +129,15 @@
<label>Комментарий <label>Комментарий
<input name="comment" /> <input name="comment" />
</label> </label>
<button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать в 3x-ui</button> <button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать</button>
<p class="muted" id="xui-inbound-hint" style="margin:0">Нет доступных inbound’ов — откройте сервер в <a href="/admin/xui">3x-ui</a> и отметьте нужные.</p> <p class="muted" id="xui-inbound-hint" style="margin:0">Для выбранного протокола нет включённых inbound’ов.</p>
</form> </form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-head"><strong>Клиенты, созданные через сайт (3x-ui)</strong></div> <div class="panel-head"><strong>Клиенты через сайт</strong></div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -186,17 +168,19 @@
<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>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="7" class="muted">Клиентов 3x-ui пока нет</td></tr> <tr><td colspan="7" class="muted">Клиентов пока нет</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if panels_with_inbounds %}
<script> <script>
(function () { (function () {
const panelSelect = document.getElementById('xui-panel'); const panelSelect = document.getElementById('xui-panel');
+18 -20
View File
@@ -7,51 +7,49 @@
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
<div class="label">Серверы</div> <div class="label">Панели 3x-ui</div>
<div class="value">{{ servers|length }}</div> <div class="value">{{ xui_panels|length }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Клиенты</div> <div class="label">Клиенты (сайт)</div>
<div class="value">{{ clients_total }}</div> <div class="value">{{ xui_clients_total }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Активные</div> <div class="label">Inbound’ы для пользователей</div>
<div class="value">{{ clients_active }}</div> <div class="value">{{ user_inbounds_total }}</div>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<strong>Протоколы</strong> <strong>Серверы 3x-ui</strong>
<a class="btn btn-sm btn-accent" href="/admin/clients">Управлять клиентами</a> <a class="btn btn-sm btn-accent" href="/admin/clients">Клиенты</a>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Протокол</th> <th>URL</th>
<th>Endpoint</th>
<th>Подсеть</th>
<th>Статус</th> <th>Статус</th>
<th>Inbound’ы</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for s in servers %} {% for p in xui_panels %}
<tr> <tr>
<td>{{ s.name }}</td> <td><a href="/admin/xui/{{ p.id }}">{{ p.name }}</a></td>
<td><span class="badge badge-proto">{{ s.protocol }}</span></td> <td class="muted" style="word-break:break-all">{{ p.base_url }}</td>
<td class="mono" style="background:transparent;color:inherit;padding:0">{{ s.public_host }}:{{ s.public_port }}</td>
<td>{{ s.subnet }}</td>
<td> <td>
{% if s.is_enabled %} {% if p.is_reachable %}
<span class="badge badge-ok">enabled</span> <span class="badge badge-ok">online</span>
{% else %} {% else %}
<span class="badge badge-off">disabled</span> <span class="badge badge-off">offline</span>
{% endif %} {% endif %}
</td> </td>
<td class="muted">{{ user_inbounds_by_panel.get(p.id, [])|length }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="5" class="muted">Серверы ещё не созданы</td></tr> <tr><td colspan="4" class="muted">Панели ещё не добавлены — <a href="/admin/xui">подключить 3x-ui</a></td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
-1
View File
@@ -9,7 +9,6 @@
</a> </a>
<nav> <nav>
<a href="/admin" class="{% if request.url.path == '/admin' %}active{% endif %}">Дашборд</a> <a href="/admin" class="{% if request.url.path == '/admin' %}active{% endif %}">Дашборд</a>
<a href="/admin/servers" class="{% if '/servers' in request.url.path %}active{% endif %}">Серверы</a>
<a href="/admin/xui" class="{% if '/xui' in request.url.path %}active{% endif %}">3x-ui</a> <a href="/admin/xui" class="{% if '/xui' in request.url.path %}active{% endif %}">3x-ui</a>
<a href="/admin/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a> <a href="/admin/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a>
<a href="/" target="_blank">Сайт</a> <a href="/" target="_blank">Сайт</a>
@@ -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 %}
+2 -1
View File
@@ -63,7 +63,8 @@
<p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p> <p class="muted" style="margin:0">Ключи клиента (конфиг соберите в 3x-ui или ниже):</p>
<pre class="mono">PrivateKey = {{ created.privateKey }} <pre class="mono">PrivateKey = {{ created.privateKey }}
PublicKey = {{ created.publicKey }} PublicKey = {{ created.publicKey }}
{% if created.inbound %}Endpoint port = {{ created.inbound.port }}{% endif %}</pre> {% if created.allowedIPs %}AllowedIPs = {{ created.allowedIPs | join(', ') }}
{% endif %}{% if created.inbound %}Endpoint port = {{ created.inbound.port }}{% endif %}</pre>
{% endif %} {% endif %}
</div> </div>
</div> </div>