Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef3562860 | ||
|
|
9ae64d1bd1 | ||
|
|
a0219ad839 | ||
|
|
170927b8f8 |
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
+170
-74
@@ -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,31 +225,15 @@ 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] = [
|
||||||
@@ -266,47 +245,23 @@ async def clients_page(
|
|||||||
}
|
}
|
||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<strong>Доступные серверы 3x-ui</strong>
|
||||||
|
<span class="muted">добавленные панели и inbound’ы, включённые для пользователей</span>
|
||||||
|
</div>
|
||||||
|
{% if not xui_panels %}
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<form method="post" action="/admin/clients" class="form-grid">
|
<p class="muted" style="margin:0">Нет добавленных панелей. Подключите 3x-ui на странице <a href="/admin/xui">3x-ui</a>.</p>
|
||||||
<label>Имя
|
</div>
|
||||||
<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 %}
|
{% 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 class="panel">
|
|
||||||
<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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user