import io import zipfile import qrcode from fastapi import APIRouter, Depends, Form, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response, StreamingResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel, XuiPanelInbound, XuiSiteClient from app.services import vpn as vpn_service from app.services import xui_panels as xui_service router = APIRouter(prefix="/admin", tags=["admin"]) async def require_admin(request: Request, db: AsyncSession = Depends(get_db)) -> AdminUser | RedirectResponse: admin_id = request.session.get("admin_id") if not admin_id: return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER) user = await db.get(AdminUser, admin_id) if not user or not user.is_active: request.session.clear() return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER) return user def _is_redirect(value: object) -> bool: return isinstance(value, RedirectResponse) @router.get("", response_class=HTMLResponse) async def dashboard(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)): if _is_redirect(admin): return admin xui_panels = await xui_service.list_panels(db) xui_clients_total = await db.scalar(select(func.count()).select_from(XuiSiteClient)) or 0 user_inbounds_total = await db.scalar( select(func.count()) .select_from(XuiPanelInbound) .where(XuiPanelInbound.is_available_for_users.is_(True)) ) 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( "admin/dashboard.html", { "request": request, "admin": admin, "xui_panels": xui_panels, "xui_clients_total": xui_clients_total, "user_inbounds_total": user_inbounds_total, "user_inbounds_by_panel": user_inbounds_by_panel, "app_name": request.app.state.settings.app_name, }, ) @router.get("/servers", response_class=HTMLResponse) async def servers_page(admin=Depends(require_admin)): """WG/AWG servers UI временно скрыт — редирект на клиентов 3x-ui.""" if _is_redirect(admin): return admin return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) @router.post("/servers") async def create_server( name: str = Form(""), protocol: str = Form(...), ssh_host: str = Form(...), ssh_port: int = Form(22), ssh_username: str = Form(...), ssh_password: str = Form(""), ssh_private_key: str = Form(""), public_host: str = Form(""), public_port: str = Form(""), dns: str = Form("1.1.1.1"), db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin try: port_value = int(public_port) if public_port.strip() else None await vpn_service.create_server( db, name=name, protocol=protocol, ssh_host=ssh_host, ssh_port=ssh_port, ssh_username=ssh_username, ssh_password=ssh_password, ssh_private_key=ssh_private_key, public_host=public_host or None, public_port=port_value, dns=dns, ) except Exception as exc: # noqa: BLE001 return RedirectResponse( f"/admin/servers?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) return RedirectResponse("/admin/servers?flash=created", status_code=status.HTTP_303_SEE_OTHER) @router.post("/servers/{server_id}/sync") async def sync_server( server_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin await vpn_service.sync_server_config(db, server_id) return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER) @router.post("/servers/{server_id}/check-ssh") async def check_ssh( server_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin try: await vpn_service.check_server_ssh(db, server_id) except Exception as exc: # noqa: BLE001 return RedirectResponse( f"/admin/servers?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) return RedirectResponse("/admin/servers?flash=ssh_ok", status_code=status.HTTP_303_SEE_OTHER) @router.post("/servers/{server_id}/install-vpn") async def install_vpn( server_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin try: await vpn_service.install_vpn_on_server(db, server_id) return RedirectResponse( "/admin/servers?flash=vpn_installed", status_code=status.HTTP_303_SEE_OTHER, ) except Exception as exc: # noqa: BLE001 return RedirectResponse( f"/admin/servers?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) @router.post("/servers/{server_id}/delete") async def delete_server( server_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin server = await db.get(VpnServer, server_id) if server: await db.delete(server) await db.commit() return RedirectResponse("/admin/servers?flash=deleted", status_code=status.HTTP_303_SEE_OTHER) @router.post("/servers/{server_id}") async def update_server( server_id: int, name: str = Form(""), public_host: str = Form(...), public_port: int = Form(...), dns: str = Form("1.1.1.1"), ssh_host: str = Form(""), ssh_port: int = Form(22), ssh_username: str = Form(""), ssh_password: str = Form(""), ssh_private_key: str = Form(""), clear_ssh_password: str = Form(""), clear_ssh_private_key: str = Form(""), db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin try: await vpn_service.update_server_settings( db, server_id, name=name or None, public_host=public_host, public_port=public_port, dns=dns, ssh_host=ssh_host, ssh_port=ssh_port, ssh_username=ssh_username, ssh_password=ssh_password, ssh_private_key=ssh_private_key, clear_ssh_password=clear_ssh_password == "1", clear_ssh_private_key=clear_ssh_private_key == "1", ) except Exception as exc: # noqa: BLE001 return RedirectResponse( f"/admin/servers?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) return RedirectResponse("/admin/servers?flash=saved", status_code=status.HTTP_303_SEE_OTHER) @router.get("/clients", response_class=HTMLResponse) async def clients_page( request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin xui_panels = await xui_service.list_panels(db) xui_clients = await xui_service.list_all_site_clients(db) 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 ] panels_with_inbounds = [p for p in xui_panels if user_inbounds_by_panel.get(p.id)] return request.app.state.templates.TemplateResponse( "admin/clients.html", { "request": request, "admin": admin, "xui_panels": xui_panels, "xui_clients": xui_clients, "user_inbounds_by_panel": user_inbounds_by_panel, "panels_with_inbounds": panels_with_inbounds, "app_name": request.app.state.settings.app_name, "flash": request.query_params.get("flash"), }, ) @router.post("/clients/xui") async def create_xui_client_from_clients( request: Request, panel_id: int = Form(...), protocol: str = Form(...), email: str = Form(...), inbound_id: int = Form(...), total_gb: int = Form(0), limit_ip: int = Form(0), expiry_days: int = Form(30), start_after_first_use: str = Form(""), flow: str = Form(""), comment: str = Form(""), db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin panel = await db.get(XuiPanel, panel_id) if not panel: return RedirectResponse( "/admin/clients?flash=error:Панель не найдена", status_code=status.HTTP_303_SEE_OTHER, ) try: result = await xui_service.add_xui_client( db, panel, protocol=protocol, email=email.strip(), inbound_id=inbound_id, total_gb=total_gb, limit_ip=limit_ip, expiry_days=expiry_days, start_after_first_use=start_after_first_use == "1", flow=flow.strip(), comment=comment.strip(), require_user_available=True, ) except Exception as exc: # noqa: BLE001 return RedirectResponse( f"/admin/clients?flash=error:{exc}", status_code=status.HTTP_303_SEE_OTHER, ) site_id = result.get("site_client_id") if site_id: return RedirectResponse( f"/admin/clients/xui/{site_id}?flash=created", status_code=status.HTTP_303_SEE_OTHER, ) return RedirectResponse( "/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER, ) @router.get("/clients/xui/{site_client_id}", response_class=HTMLResponse) async def xui_site_client_detail( site_client_id: int, request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin row = await xui_service.get_site_client(db, site_client_id) if not row: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) stem, config = await xui_service.site_client_download_payload(row) return request.app.state.templates.TemplateResponse( "admin/xui_client_detail.html", { "request": request, "admin": admin, "client": row, "config": config, "filename_stem": stem, "app_name": request.app.state.settings.app_name, "flash": request.query_params.get("flash"), }, ) @router.get("/clients/xui/{site_client_id}/config") async def xui_site_client_config( site_client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin row = await xui_service.get_site_client(db, site_client_id) if not row: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) stem, config = await xui_service.site_client_download_payload(row) if not config: return RedirectResponse( f"/admin/clients/xui/{site_client_id}?flash=error:Нет конфига", status_code=status.HTTP_303_SEE_OTHER, ) ext = "conf" if row.protocol == "wireguard" else "txt" return Response( content=config, media_type="text/plain", headers={"Content-Disposition": f'attachment; filename="{stem}.{ext}"'}, ) @router.get("/clients/xui/{site_client_id}/qr") async def xui_site_client_qr( site_client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin row = await xui_service.get_site_client(db, site_client_id) if not row: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) _, config = await xui_service.site_client_download_payload(row) if not config: return Response(status_code=404) img = qrcode.make(config) buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return StreamingResponse(buf, media_type="image/png") @router.get("/clients/xui/{site_client_id}/zip") async def xui_site_client_zip( site_client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin row = await xui_service.get_site_client(db, site_client_id) if not row: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) stem, config = await xui_service.site_client_download_payload(row) if not config: return RedirectResponse( f"/admin/clients/xui/{site_client_id}?flash=error:Нет конфига", status_code=status.HTTP_303_SEE_OTHER, ) ext = "conf" if row.protocol == "wireguard" else "txt" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"{stem}.{ext}", config) if row.protocol == "wireguard": zf.writestr( f"{stem}-info.txt", "\n".join( [ f"Email: {row.email}", f"Protocol: {row.protocol}", f"Address: {row.address or ''}", f"Endpoint: {row.endpoint or ''}", f"DNS: {row.dns or ''}", f"MTU: {row.mtu or ''}", f"Panel: {row.panel.name if row.panel else ''}", "", "Import the .conf into WireGuard / AmneziaWG, or scan the QR on the panel.", "", ] ), ) else: zf.writestr( f"{stem}-info.txt", "\n".join( [ f"Email: {row.email}", f"Protocol: {row.protocol}", f"UUID: {row.uuid or ''}", f"Flow: {row.flow or ''}", "", "Import the link into a VLESS client, or scan the QR on the panel.", "", ] ), ) buf.seek(0) return Response( content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{stem}.zip"'}, ) @router.post("/clients/{client_id}/toggle") async def toggle_client( client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin client = await db.get(VpnClient, client_id) if client: await vpn_service.set_client_enabled(db, client_id, not client.is_enabled) return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) @router.post("/clients/{client_id}/delete") async def remove_client( client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin await vpn_service.delete_client(db, client_id) return RedirectResponse("/admin/clients?flash=deleted", status_code=status.HTTP_303_SEE_OTHER) @router.get("/clients/{client_id}", response_class=HTMLResponse) async def client_detail( client_id: int, request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin result = await db.execute( select(VpnClient).options(selectinload(VpnClient.server)).where(VpnClient.id == client_id) ) client = result.scalar_one_or_none() if not client: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) config = await vpn_service.get_client_config(db, client_id) return request.app.state.templates.TemplateResponse( "admin/client_detail.html", { "request": request, "admin": admin, "client": client, "config": config, "app_name": request.app.state.settings.app_name, }, ) @router.get("/clients/{client_id}/config") async def download_config( client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin client = await db.get(VpnClient, client_id) if not client: return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER) config = await vpn_service.get_client_config(db, client_id) filename = f"{client.name}.conf" return Response( content=config, media_type="text/plain", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.get("/clients/{client_id}/qr") async def client_qr( client_id: int, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): if _is_redirect(admin): return admin config = await vpn_service.get_client_config(db, client_id) img = qrcode.make(config) buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return StreamingResponse(buf, media_type="image/png")