first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 20:36:00 +03:00
co-authored by Cursor
commit bbff3cb10b
35 changed files with 2054 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""VpnPanel package."""
+41
View File
@@ -0,0 +1,41 @@
import asyncio
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models import AdminUser
from app.security import hash_password
from app.services.vpn import ensure_default_servers
async def bootstrap() -> None:
async with SessionLocal() as session:
result = await session.execute(
select(AdminUser).where(AdminUser.username == settings.admin_username)
)
admin = result.scalar_one_or_none()
if admin is None:
session.add(
AdminUser(
username=settings.admin_username,
email=settings.admin_email,
password_hash=hash_password(settings.admin_password),
is_active=True,
)
)
await session.commit()
print(f"Admin user '{settings.admin_username}' created from ENV")
else:
# Keep password in sync with ENV on boot (convenient for Dokploy)
admin.email = settings.admin_email
admin.password_hash = hash_password(settings.admin_password)
admin.is_active = True
await session.commit()
print(f"Admin user '{settings.admin_username}' synced from ENV")
await ensure_default_servers(session)
if __name__ == "__main__":
asyncio.run(bootstrap())
+40
View File
@@ -0,0 +1,40 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "VpnPanel"
secret_key: str = "change-me"
debug: bool = False
admin_username: str = "admin"
admin_password: str = "change-me"
admin_email: str = "admin@example.com"
database_url: str = "postgresql+asyncpg://vpn:vpn@postgres:5432/vpn_panel"
public_host: str = "127.0.0.1"
public_port: int = 51820
awg_public_port: int = 51821
wg_interface: str = "wg0"
awg_interface: str = "awg0"
wg_subnet: str = "10.8.0.0/24"
awg_subnet: str = "10.9.0.0/24"
wg_dns: str = "1.1.1.1"
server_private_key: str = ""
server_public_key: str = ""
awg_server_private_key: str = ""
awg_server_public_key: str = ""
wg_config_dir: str = "/data/wireguard"
awg_config_dir: str = "/data/amneziawg"
session_cookie: str = "vpn_panel_session"
session_max_age: int = 60 * 60 * 24 * 7
session_https_only: bool = False
settings = Settings()
+19
View File
@@ -0,0 +1,19 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=settings.debug, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
+48
View File
@@ -0,0 +1,48 @@
from collections.abc import Callable
from typing import Any
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import get_db
from app.models import AdminUser
from app.security import verify_password
def install_session_middleware(app: Any) -> None:
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
session_cookie=settings.session_cookie,
max_age=settings.session_max_age,
same_site="lax",
https_only=settings.session_https_only,
)
async def authenticate_admin(session: AsyncSession, username: str, password: str) -> AdminUser | None:
result = await session.execute(select(AdminUser).where(AdminUser.username == username))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
async def get_current_admin(request: Request, db: AsyncSession = Depends(get_db)) -> AdminUser:
admin_id = request.session.get("admin_id")
if not admin_id:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
user = await db.get(AdminUser, admin_id)
if not user or not user.is_active:
request.session.clear()
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
return user
def login_required(view: Callable):
return view
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import settings
from app.deps import install_session_middleware
from app.routers import admin, auth, public
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, docs_url="/api/docs" if settings.debug else None)
install_session_middleware(app)
base = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(base / "templates"))
app.state.templates = templates
app.state.settings = settings
app.mount("/static", StaticFiles(directory=str(base / "static")), name="static")
app.include_router(public.router)
app.include_router(auth.router)
app.include_router(admin.router)
return app
app = create_app()
+79
View File
@@ -0,0 +1,79 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Protocol(str, Enum):
WIREGUARD = "wireguard"
AWG2 = "awg2"
class UserRole(str, Enum):
ADMIN = "admin"
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default=UserRole.ADMIN.value)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class VpnServer(Base):
__tablename__ = "vpn_servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120))
protocol: Mapped[str] = mapped_column(String(32), index=True)
public_host: Mapped[str] = mapped_column(String(255))
public_port: Mapped[int] = mapped_column(Integer)
interface_name: Mapped[str] = mapped_column(String(32))
subnet: Mapped[str] = mapped_column(String(64))
dns: Mapped[str] = mapped_column(String(255), default="1.1.1.1")
server_private_key: Mapped[str] = mapped_column(Text)
server_public_key: Mapped[str] = mapped_column(Text)
# AmneziaWG 2.0 obfuscation params (ignored for plain WireGuard)
jc: Mapped[int | None] = mapped_column(Integer, nullable=True)
jmin: Mapped[int | None] = mapped_column(Integer, nullable=True)
jmax: Mapped[int | None] = mapped_column(Integer, nullable=True)
s1: Mapped[int | None] = mapped_column(Integer, nullable=True)
s2: Mapped[int | None] = mapped_column(Integer, nullable=True)
h1: Mapped[int | None] = mapped_column(Integer, nullable=True)
h2: Mapped[int | None] = mapped_column(Integer, nullable=True)
h3: Mapped[int | None] = mapped_column(Integer, nullable=True)
h4: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
clients: Mapped[list["VpnClient"]] = relationship(back_populates="server", cascade="all, delete-orphan")
class VpnClient(Base):
__tablename__ = "vpn_clients"
__table_args__ = (UniqueConstraint("server_id", "name", name="uq_client_name_per_server"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
server_id: Mapped[int] = mapped_column(ForeignKey("vpn_servers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
private_key: Mapped[str] = mapped_column(Text)
public_key: Mapped[str] = mapped_column(Text)
preshared_key: Mapped[str] = mapped_column(Text)
address: Mapped[str] = mapped_column(String(64))
allowed_ips: Mapped[str] = mapped_column(String(255), default="0.0.0.0/0, ::/0")
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
server: Mapped[VpnServer] = relationship(back_populates="clients")
+3
View File
@@ -0,0 +1,3 @@
from app.routers import admin, auth, public
__all__ = ["admin", "auth", "public"]
+250
View File
@@ -0,0 +1,250 @@
import io
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
from app.services import vpn as vpn_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
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
clients_total = await db.scalar(select(func.count()).select_from(VpnClient)) or 0
clients_active = await db.scalar(
select(func.count()).select_from(VpnClient).where(VpnClient.is_enabled.is_(True))
) or 0
return request.app.state.templates.TemplateResponse(
"admin/dashboard.html",
{
"request": request,
"admin": admin,
"servers": servers,
"clients_total": clients_total,
"clients_active": clients_active,
"app_name": request.app.state.settings.app_name,
},
)
@router.get("/servers", response_class=HTMLResponse)
async def servers_page(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)):
if _is_redirect(admin):
return admin
servers = (
await db.execute(
select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id)
)
).scalars().all()
return request.app.state.templates.TemplateResponse(
"admin/servers.html",
{
"request": request,
"admin": admin,
"servers": servers,
"app_name": request.app.state.settings.app_name,
},
)
@router.post("/servers/{server_id}/sync")
async def sync_server(
server_id: int,
request: Request,
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}")
async def update_server(
server_id: int,
public_host: str = Form(...),
public_port: int = Form(...),
dns: str = Form("1.1.1.1"),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
server = await db.get(VpnServer, server_id)
if server:
server.public_host = public_host.strip()
server.public_port = public_port
server.dns = dns.strip() or "1.1.1.1"
await db.commit()
await vpn_service.sync_server_config(db, server_id)
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/clients", response_class=HTMLResponse)
async def clients_page(
request: Request,
protocol: str | None = None,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc())
if protocol:
query = query.join(VpnServer).where(VpnServer.protocol == protocol)
clients = (await db.execute(query)).scalars().all()
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
return request.app.state.templates.TemplateResponse(
"admin/clients.html",
{
"request": request,
"admin": admin,
"clients": clients,
"servers": servers,
"filter_protocol": protocol,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name,
"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/{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")
+47
View File
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import authenticate_admin
router = APIRouter(prefix="/admin", tags=["auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
if request.session.get("admin_id"):
return RedirectResponse("/admin", status_code=status.HTTP_303_SEE_OTHER)
return request.app.state.templates.TemplateResponse(
"admin/login.html",
{"request": request, "error": None, "app_name": request.app.state.settings.app_name},
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
user = await authenticate_admin(db, username.strip(), password)
if not user:
return request.app.state.templates.TemplateResponse(
"admin/login.html",
{
"request": request,
"error": "Неверный логин или пароль",
"app_name": request.app.state.settings.app_name,
},
status_code=400,
)
request.session["admin_id"] = user.id
request.session["admin_username"] = user.username
return RedirectResponse("/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/admin/login", status_code=status.HTTP_303_SEE_OTHER)
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter(tags=["public"])
@router.get("/", response_class=HTMLResponse)
async def landing(request: Request):
return request.app.state.templates.TemplateResponse(
"landing.html",
{
"request": request,
"app_name": request.app.state.settings.app_name,
},
)
@router.get("/health")
async def health():
return {"status": "ok"}
+11
View File
@@ -0,0 +1,11 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
+3
View File
@@ -0,0 +1,3 @@
from app.services import crypto, vpn
__all__ = ["crypto", "vpn"]
+171
View File
@@ -0,0 +1,171 @@
"""WireGuard-compatible key generation and AmneziaWG config rendering."""
from __future__ import annotations
import base64
import ipaddress
import secrets
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
@dataclass
class KeyPair:
private_key: str
public_key: str
def generate_keypair() -> KeyPair:
private = X25519PrivateKey.generate()
private_bytes = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
public_bytes = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return KeyPair(
private_key=base64.b64encode(private_bytes).decode("ascii"),
public_key=base64.b64encode(public_bytes).decode("ascii"),
)
def generate_preshared_key() -> str:
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
def next_client_ip(subnet: str, used_addresses: list[str]) -> str:
network = ipaddress.ip_network(subnet, strict=False)
used = set()
for addr in used_addresses:
ip = addr.split("/")[0]
used.add(ip)
# .1 is reserved for server
for host in network.hosts():
if str(host).endswith(".0") or str(host).endswith(".255"):
continue
if str(host) == str(network.network_address + 1):
continue
if str(host) not in used:
return f"{host}/32"
raise RuntimeError("No free IP addresses left in subnet")
def server_address(subnet: str) -> str:
network = ipaddress.ip_network(subnet, strict=False)
return f"{network.network_address + 1}/{network.prefixlen}"
def render_client_config(
*,
protocol: str,
client_private_key: str,
client_address: str,
dns: str,
server_public_key: str,
preshared_key: str,
endpoint_host: str,
endpoint_port: int,
allowed_ips: str,
jc: int | None = None,
jmin: int | None = None,
jmax: int | None = None,
s1: int | None = None,
s2: int | None = None,
h1: int | None = None,
h2: int | None = None,
h3: int | None = None,
h4: int | None = None,
) -> str:
lines = [
"[Interface]",
f"PrivateKey = {client_private_key}",
f"Address = {client_address}",
f"DNS = {dns}",
]
if protocol == "awg2":
# AmneziaWG 2.0 obfuscation parameters
lines.extend(
[
f"Jc = {jc if jc is not None else 4}",
f"Jmin = {jmin if jmin is not None else 40}",
f"Jmax = {jmax if jmax is not None else 70}",
f"S1 = {s1 if s1 is not None else 0}",
f"S2 = {s2 if s2 is not None else 0}",
f"H1 = {h1 if h1 is not None else 1}",
f"H2 = {h2 if h2 is not None else 2}",
f"H3 = {h3 if h3 is not None else 3}",
f"H4 = {h4 if h4 is not None else 4}",
]
)
lines.extend(
[
"",
"[Peer]",
f"PublicKey = {server_public_key}",
f"PresharedKey = {preshared_key}",
f"Endpoint = {endpoint_host}:{endpoint_port}",
f"AllowedIPs = {allowed_ips}",
"PersistentKeepalive = 25",
"",
]
)
return "\n".join(lines)
def render_server_config(
*,
protocol: str,
interface_name: str,
private_key: str,
address: str,
listen_port: int,
peers: list[dict],
jc: int | None = None,
jmin: int | None = None,
jmax: int | None = None,
s1: int | None = None,
s2: int | None = None,
h1: int | None = None,
h2: int | None = None,
h3: int | None = None,
h4: int | None = None,
) -> str:
lines = [
f"# Auto-generated by VpnPanel for {interface_name}",
"[Interface]",
f"PrivateKey = {private_key}",
f"Address = {address}",
f"ListenPort = {listen_port}",
"SaveConfig = false",
]
if protocol == "awg2":
lines.extend(
[
f"Jc = {jc if jc is not None else 4}",
f"Jmin = {jmin if jmin is not None else 40}",
f"Jmax = {jmax if jmax is not None else 70}",
f"S1 = {s1 if s1 is not None else 0}",
f"S2 = {s2 if s2 is not None else 0}",
f"H1 = {h1 if h1 is not None else 1}",
f"H2 = {h2 if h2 is not None else 2}",
f"H3 = {h3 if h3 is not None else 3}",
f"H4 = {h4 if h4 is not None else 4}",
]
)
for peer in peers:
lines.extend(
[
"",
f"# {peer['name']}",
"[Peer]",
f"PublicKey = {peer['public_key']}",
f"PresharedKey = {peer['preshared_key']}",
f"AllowedIPs = {peer['address']}",
]
)
lines.append("")
return "\n".join(lines)
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.models import Protocol, VpnClient, VpnServer
from app.services.crypto import (
generate_keypair,
generate_preshared_key,
next_client_ip,
render_client_config,
render_server_config,
server_address,
)
async def ensure_default_servers(session: AsyncSession) -> None:
existing = (await session.execute(select(VpnServer))).scalars().all()
by_protocol = {s.protocol: s for s in existing}
if Protocol.WIREGUARD.value not in by_protocol:
keys = generate_keypair()
private = settings.server_private_key or keys.private_key
public = settings.server_public_key or keys.public_key
if settings.server_private_key and settings.server_public_key:
private, public = settings.server_private_key, settings.server_public_key
elif settings.server_private_key:
private = settings.server_private_key
# derive not available without raw parse; keep generated public if only private set
session.add(
VpnServer(
name="WireGuard",
protocol=Protocol.WIREGUARD.value,
public_host=settings.public_host,
public_port=settings.public_port,
interface_name=settings.wg_interface,
subnet=settings.wg_subnet,
dns=settings.wg_dns,
server_private_key=private,
server_public_key=public,
)
)
if Protocol.AWG2.value not in by_protocol:
keys = generate_keypair()
private = settings.awg_server_private_key or keys.private_key
public = settings.awg_server_public_key or keys.public_key
session.add(
VpnServer(
name="AmneziaWG 2.0",
protocol=Protocol.AWG2.value,
public_host=settings.public_host,
public_port=settings.awg_public_port,
interface_name=settings.awg_interface,
subnet=settings.awg_subnet,
dns=settings.wg_dns,
server_private_key=private,
server_public_key=public,
jc=4,
jmin=40,
jmax=70,
s1=0,
s2=0,
h1=1,
h2=2,
h3=3,
h4=4,
)
)
await session.commit()
async def create_client(
session: AsyncSession,
*,
server_id: int,
name: str,
notes: str | None = None,
allowed_ips: str = "0.0.0.0/0, ::/0",
) -> VpnClient:
server = await session.get(VpnServer, server_id)
if not server:
raise ValueError("Server not found")
used = (
await session.execute(select(VpnClient.address).where(VpnClient.server_id == server_id))
).scalars().all()
address = next_client_ip(server.subnet, list(used))
keys = generate_keypair()
client = VpnClient(
server_id=server_id,
name=name.strip(),
private_key=keys.private_key,
public_key=keys.public_key,
preshared_key=generate_preshared_key(),
address=address,
allowed_ips=allowed_ips,
notes=notes,
is_enabled=True,
)
session.add(client)
await session.commit()
await session.refresh(client)
await sync_server_config(session, server_id)
return client
async def set_client_enabled(session: AsyncSession, client_id: int, enabled: bool) -> VpnClient:
client = await session.get(VpnClient, client_id)
if not client:
raise ValueError("Client not found")
client.is_enabled = enabled
await session.commit()
await session.refresh(client)
await sync_server_config(session, client.server_id)
return client
async def delete_client(session: AsyncSession, client_id: int) -> None:
client = await session.get(VpnClient, client_id)
if not client:
raise ValueError("Client not found")
server_id = client.server_id
await session.delete(client)
await session.commit()
await sync_server_config(session, server_id)
async def get_client_config(session: AsyncSession, client_id: int) -> str:
result = await session.execute(
select(VpnClient).options(selectinload(VpnClient.server)).where(VpnClient.id == client_id)
)
client = result.scalar_one_or_none()
if not client:
raise ValueError("Client not found")
server = client.server
return render_client_config(
protocol=server.protocol,
client_private_key=client.private_key,
client_address=client.address,
dns=server.dns,
server_public_key=server.server_public_key,
preshared_key=client.preshared_key,
endpoint_host=server.public_host,
endpoint_port=server.public_port,
allowed_ips=client.allowed_ips,
jc=server.jc,
jmin=server.jmin,
jmax=server.jmax,
s1=server.s1,
s2=server.s2,
h1=server.h1,
h2=server.h2,
h3=server.h3,
h4=server.h4,
)
async def sync_server_config(session: AsyncSession, server_id: int) -> Path:
result = await session.execute(
select(VpnServer).options(selectinload(VpnServer.clients)).where(VpnServer.id == server_id)
)
server = result.scalar_one()
peers = [
{
"name": c.name,
"public_key": c.public_key,
"preshared_key": c.preshared_key,
"address": c.address if c.address.endswith("/32") else f"{c.address.split('/')[0]}/32",
}
for c in server.clients
if c.is_enabled
]
content = render_server_config(
protocol=server.protocol,
interface_name=server.interface_name,
private_key=server.server_private_key,
address=server_address(server.subnet),
listen_port=server.public_port,
peers=peers,
jc=server.jc,
jmin=server.jmin,
jmax=server.jmax,
s1=server.s1,
s2=server.s2,
h1=server.h1,
h2=server.h2,
h3=server.h3,
h4=server.h4,
)
base = Path(settings.wg_config_dir if server.protocol == Protocol.WIREGUARD.value else settings.awg_config_dir)
base.mkdir(parents=True, exist_ok=True)
path = base / f"{server.interface_name}.conf"
path.write_text(content, encoding="utf-8")
return path
+304
View File
@@ -0,0 +1,304 @@
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--ink: #121417;
--muted: #5c6670;
--line: #e3e8ee;
--accent: #0f766e;
--accent-ink: #ffffff;
--danger: #b42318;
--ok: #067647;
--shadow: 0 10px 30px rgba(18, 20, 23, 0.06);
--radius: 14px;
--font: "Segoe UI", "IBM Plex Sans", "Noto Sans", sans-serif;
--display: "Georgia", "Times New Roman", serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font);
color: var(--ink);
background:
radial-gradient(1200px 500px at 10% -10%, rgba(15, 118, 110, 0.12), transparent 60%),
radial-gradient(900px 400px at 100% 0%, rgba(18, 20, 23, 0.05), transparent 55%),
var(--bg);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
button, input, select, textarea { font: inherit; }
.container { width: min(1100px, calc(100% - 2rem)); margin: 0 auto; }
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 0;
}
.brand {
display: flex;
align-items: center;
gap: 0.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(145deg, #0f766e, #134e4a);
color: white;
display: grid;
place-items: center;
font-size: 0.95rem;
}
.nav { display: flex; gap: 1rem; align-items: center; color: var(--muted); font-size: 0.92rem; }
.hero {
padding: 4.5rem 0 3.5rem;
text-align: center;
}
.hero h1 {
font-family: var(--display);
font-size: clamp(2.2rem, 5vw, 3.6rem);
line-height: 1.1;
margin: 0 0 1rem;
letter-spacing: -0.03em;
}
.hero p {
max-width: 560px;
margin: 0 auto 1.6rem;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.55;
}
.cta-row { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid transparent;
border-radius: 999px;
padding: 0.7rem 1.2rem;
cursor: pointer;
transition: 0.15s ease;
}
.btn-primary { background: var(--ink); color: white; }
.btn-primary:hover { transform: translateY(-1px); }
.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink); }
.btn-accent { background: var(--accent); color: var(--accent-ink); }
.btn-danger { background: #fff5f5; color: var(--danger); border-color: #f3d1d1; }
.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.88rem; }
.section { padding: 2.5rem 0 3.5rem; }
.section h2 {
font-family: var(--display);
font-size: 1.8rem;
margin: 0 0 0.5rem;
}
.section .lead { color: var(--muted); margin: 0 0 1.5rem; }
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 800px) {
.grid-3 { grid-template-columns: 1fr; }
}
.feature {
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.2rem;
}
.feature h3 { margin: 0 0 0.4rem; font-size: 1.05rem; }
.feature p { margin: 0; color: var(--muted); line-height: 1.45; font-size: 0.95rem; }
.site-footer {
border-top: 1px solid var(--line);
padding: 1.5rem 0 2rem;
color: var(--muted);
font-size: 0.9rem;
}
/* Admin */
.admin-shell { display: grid; grid-template-columns: 230px 1fr; min-height: 100vh; }
.sidebar {
background: #0f1418;
color: #d7dee5;
padding: 1.25rem;
}
.sidebar .brand { color: white; margin-bottom: 2rem; }
.sidebar nav { display: flex; flex-direction: column; gap: 0.35rem; }
.sidebar a {
padding: 0.65rem 0.8rem;
border-radius: 10px;
color: #aeb8c2;
}
.sidebar a:hover, .sidebar a.active { background: rgba(255,255,255,0.08); color: white; }
.sidebar .meta { margin-top: auto; padding-top: 2rem; color: #7d8893; font-size: 0.85rem; }
.admin-main { padding: 1.5rem; }
.admin-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.admin-top h1 { margin: 0; font-size: 1.5rem; letter-spacing: -0.02em; }
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 900px) {
.admin-shell { grid-template-columns: 1fr; }
.stats { grid-template-columns: 1fr; }
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1rem 1.1rem;
box-shadow: var(--shadow);
}
.stat .label { color: var(--muted); font-size: 0.85rem; }
.stat .value { font-size: 1.8rem; font-weight: 700; margin-top: 0.2rem; }
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--line);
}
.panel-body { padding: 1.1rem; }
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
font-size: 0.94rem;
vertical-align: middle;
}
th { color: var(--muted); font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em; }
tr:last-child td { border-bottom: none; }
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.78rem;
border: 1px solid var(--line);
background: #f8fafb;
}
.badge-ok { color: var(--ok); border-color: #abefc6; background: #ecfdf3; }
.badge-off { color: var(--muted); }
.badge-proto { color: #175cd3; border-color: #b2ddff; background: #eff8ff; }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.7rem;
align-items: end;
}
@media (max-width: 800px) {
.form-grid { grid-template-columns: 1fr; }
}
label { display: grid; gap: 0.35rem; font-size: 0.88rem; color: var(--muted); }
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
background: white;
color: var(--ink);
}
input:focus, select:focus, textarea:focus {
outline: 2px solid rgba(15, 118, 110, 0.25);
border-color: var(--accent);
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
}
.login-card {
width: min(420px, 100%);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.login-card h1 { margin: 0.4rem 0 0.2rem; font-size: 1.5rem; }
.login-card p { margin: 0 0 1.2rem; color: var(--muted); }
.stack { display: grid; gap: 0.8rem; }
.error {
background: #fef3f2;
color: var(--danger);
border: 1px solid #fecdca;
border-radius: 10px;
padding: 0.7rem 0.8rem;
margin-bottom: 0.8rem;
}
.flash {
background: #ecfdf3;
color: var(--ok);
border: 1px solid #abefc6;
border-radius: 10px;
padding: 0.7rem 0.8rem;
margin-bottom: 1rem;
}
.flash-error { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
.actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.84rem;
white-space: pre-wrap;
background: #0f1418;
color: #e8eef3;
border-radius: 12px;
padding: 1rem;
overflow: auto;
}
.qr {
width: 220px;
height: 220px;
border: 1px solid var(--line);
border-radius: 12px;
background: white;
padding: 0.5rem;
}
.detail-grid {
display: grid;
grid-template-columns: 1.4fr 0.8fr;
gap: 1rem;
}
@media (max-width: 900px) {
.detail-grid { grid-template-columns: 1fr; }
}
.muted { color: var(--muted); }
.inline-form { display: inline; }
+33
View File
@@ -0,0 +1,33 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ client.name }}{% endblock %}
{% block admin_content %}
<div class="admin-top">
<div>
<h1>{{ client.name }}</h1>
<div class="muted">{{ client.server.name }} · {{ client.server.protocol }} · {{ client.address }}</div>
</div>
<div class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/clients">Назад</a>
<a class="btn btn-sm btn-accent" href="/admin/clients/{{ client.id }}/config">Скачать .conf</a>
</div>
</div>
<div class="detail-grid">
<div class="panel">
<div class="panel-head"><strong>Конфиг</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/{{ client.id }}/qr" alt="QR config" />
<p class="muted" style="margin-top:0.8rem">Отсканируйте в клиенте WireGuard / AmneziaWG.</p>
{% if client.notes %}
<p><strong>Заметка:</strong> {{ client.notes }}</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Клиенты{% endblock %}
{% block admin_content %}
<div class="admin-top">
<h1>Клиенты</h1>
<div class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/clients">Все</a>
<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>
</div>
</div>
{% if flash %}
{% if flash.startswith('error:') %}
<div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'created' %}
<div class="flash">Клиент создан</div>
{% elif flash == 'deleted' %}
<div class="flash">Клиент удалён</div>
{% endif %}
{% endif %}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Новый доступ</strong></div>
<div class="panel-body">
<form method="post" action="/admin/clients" class="form-grid">
<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>
{% endfor %}
</select>
</label>
<button class="btn btn-accent" type="submit">Создать</button>
<label style="grid-column: 1 / -1">Заметка
<input name="notes" placeholder="опционально" />
</label>
</form>
</div>
</div>
<div class="panel">
<table>
<thead>
<tr>
<th>Имя</th>
<th>Протокол</th>
<th>IP</th>
<th>Статус</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in clients %}
<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>
{% if c.is_enabled %}
<span class="badge badge-ok">on</span>
{% else %}
<span class="badge badge-off">off</span>
{% endif %}
</td>
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
<td class="actions">
<a class="btn btn-sm btn-ghost" href="/admin/clients/{{ c.id }}">Открыть</a>
<form method="post" action="/admin/clients/{{ c.id }}/toggle" class="inline-form">
<button class="btn btn-sm btn-ghost" type="submit">{% if c.is_enabled %}Выкл{% else %}Вкл{% endif %}</button>
</form>
<form method="post" action="/admin/clients/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить клиента?')">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Клиентов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Дашборд{% endblock %}
{% block admin_content %}
<div class="admin-top">
<h1>Дашборд</h1>
</div>
<div class="stats">
<div class="stat">
<div class="label">Серверы</div>
<div class="value">{{ servers|length }}</div>
</div>
<div class="stat">
<div class="label">Клиенты</div>
<div class="value">{{ clients_total }}</div>
</div>
<div class="stat">
<div class="label">Активные</div>
<div class="value">{{ clients_active }}</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<strong>Протоколы</strong>
<a class="btn btn-sm btn-accent" href="/admin/clients">Управлять клиентами</a>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Протокол</th>
<th>Endpoint</th>
<th>Подсеть</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for s in servers %}
<tr>
<td>{{ s.name }}</td>
<td><span class="badge badge-proto">{{ s.protocol }}</span></td>
<td class="mono" style="background:transparent;color:inherit;padding:0">{{ s.public_host }}:{{ s.public_port }}</td>
<td>{{ s.subnet }}</td>
<td>
{% if s.is_enabled %}
<span class="badge badge-ok">enabled</span>
{% else %}
<span class="badge badge-off">disabled</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Серверы ещё не созданы</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}{% block admin_title %}Админка{% endblock %} — {{ app_name }}{% endblock %}
{% block body %}
<div class="admin-shell">
<aside class="sidebar">
<a class="brand" href="/admin">
<span class="brand-mark">V</span>
<span>{{ app_name }}</span>
</a>
<nav>
<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/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a>
<a href="/" target="_blank">Сайт</a>
</nav>
<div class="meta">
<div>{{ admin.username }}</div>
<form class="inline-form" method="post" action="/admin/logout" style="margin-top:0.6rem">
<button class="btn btn-ghost btn-sm" type="submit" style="color:#d7dee5;border-color:#2a343c">Выйти</button>
</form>
</div>
</aside>
<main class="admin-main">
{% block admin_content %}{% endblock %}
</main>
</div>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Вход — {{ app_name }}{% endblock %}
{% block body %}
<div class="login-wrap">
<div class="login-card">
<div class="brand">
<span class="brand-mark">V</span>
<span>{{ app_name }}</span>
</div>
<h1>Вход в админку</h1>
<p>Логин и пароль задаются в переменных окружения.</p>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/admin/login" class="stack">
<label>Логин
<input name="username" autocomplete="username" required />
</label>
<label>Пароль
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button class="btn btn-primary" type="submit">Войти</button>
</form>
</div>
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Серверы{% endblock %}
{% block admin_content %}
<div class="admin-top">
<h1>Серверы</h1>
</div>
{% for s in servers %}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head">
<strong>{{ s.name }} <span class="badge badge-proto">{{ s.protocol }}</span></strong>
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
<button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button>
</form>
</div>
<div class="panel-body">
<p class="muted" style="margin-top:0">Интерфейс <code>{{ s.interface_name }}</code> · подсеть <code>{{ s.subnet }}</code> · клиентов: {{ s.clients|length }}</p>
<p><code>Public key:</code> <span class="muted">{{ s.server_public_key }}</span></p>
<form method="post" action="/admin/servers/{{ s.id }}" class="form-grid">
<label>Public host
<input name="public_host" value="{{ s.public_host }}" required />
</label>
<label>Port
<input type="number" name="public_port" value="{{ s.public_port }}" required />
</label>
<button class="btn btn-accent" type="submit">Сохранить</button>
<label style="grid-column: 1 / -1">DNS
<input name="dns" value="{{ s.dns }}" />
</label>
</form>
</div>
</div>
{% endfor %}
<p class="muted">
Конфиги пишутся в volume: <code>/data/wireguard</code> и <code>/data/amneziawg</code>.
Подключите их к WG/AWG контейнерам на том же хосте.
</p>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/app.css" />
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ app_name }} — VPN панель{% endblock %}
{% block body %}
<header class="container site-header">
<a class="brand" href="/">
<span class="brand-mark">V</span>
<span>{{ app_name }}</span>
</a>
<nav class="nav">
<a href="#features">Возможности</a>
<a href="/admin/login">Админка</a>
</nav>
</header>
<main>
<section class="hero container">
<h1>Веб‑панель для WireGuard и AmneziaWG 2.0</h1>
<p>
Управляйте VPN‑сервером через браузер: создавайте доступы, выдавайте конфиги и QR‑коды.
Без десктопного клиента — обычный сайт на вашем VPS.
</p>
<div class="cta-row">
<a class="btn btn-primary" href="/admin/login">Открыть админку</a>
<a class="btn btn-ghost" href="#features">Как это устроено</a>
</div>
</section>
<section class="section container" id="features">
<h2>Один интерфейс для доступов</h2>
<p class="lead">Сначала WireGuard и AWG 2.0. Остальные протоколы подключим позже.</p>
<div class="grid-3">
<article class="feature">
<h3>Клиенты</h3>
<p>Создавайте, отключайте и удаляйте доступы. IP выдаётся автоматически из подсети сервера.</p>
</article>
<article class="feature">
<h3>Конфиг и QR</h3>
<p>Скачайте .conf или отсканируйте QR — готово к подключению в официальных клиентах.</p>
</article>
<article class="feature">
<h3>Dokploy + Postgres</h3>
<p>Деплой через Docker Compose. Админ задаётся в ENV при первом запуске.</p>
</article>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container">{{ app_name }} · WireGuard / AmneziaWG 2.0</div>
</footer>
{% endblock %}