first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 20:36:00 +03:00
co-authored by Cursor
commit bbff3cb10b
35 changed files with 2054 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Application
APP_NAME=VpnPanel
SECRET_KEY=change-me-to-a-long-random-string
DEBUG=false
SESSION_HTTPS_ONLY=false
# Admin bootstrap (created on first start if missing)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-strong-password
ADMIN_EMAIL=admin@example.com
# PostgreSQL
DATABASE_URL=postgresql+asyncpg://vpn:vpn@postgres:5432/vpn_panel
# Public endpoint used in client configs
PUBLIC_HOST=vpn.example.com
PUBLIC_PORT=51820
AWG_PUBLIC_PORT=51821
# WireGuard / AmneziaWG interface defaults
WG_INTERFACE=wg0
AWG_INTERFACE=awg0
WG_SUBNET=10.8.0.0/24
AWG_SUBNET=10.9.0.0/24
WG_DNS=1.1.1.1
SERVER_PRIVATE_KEY=
SERVER_PUBLIC_KEY=
AWG_SERVER_PRIVATE_KEY=
AWG_SERVER_PUBLIC_KEY=
# Paths inside container (shared volumes)
WG_CONFIG_DIR=/data/wireguard
AWG_CONFIG_DIR=/data/amneziawg
+12
View File
@@ -0,0 +1,12 @@
.env
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
data/
*.conf
.DS_Store
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini .
COPY scripts ./scripts
RUN sed -i 's/\r$//' /app/scripts/entrypoint.sh && chmod +x /app/scripts/entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+86
View File
@@ -0,0 +1,86 @@
# VpnPanel
Веб‑панель (сайт) для управления **WireGuard** и **AmneziaWG 2.0** на VPS.
В отличие от BandjuPanel это не десктопное приложение через SSH, а обычный веб‑сайт с админкой, который крутится на сервере (Dokploy / Docker Compose) и хранит данные в PostgreSQL.
## Что уже есть
- Лендинг `/`
- Админка `/admin` (логин из ENV)
- Серверы WireGuard и AWG 2.0 (создаются при старте)
- Клиенты: создать / вкл-выкл / удалить
- Скачивание `.conf` и QR‑код
- Запись серверных конфигов в volumes `/data/wireguard` и `/data/amneziawg`
- PostgreSQL + Alembic миграции
- Docker Compose под Dokploy
## Быстрый старт (Dokploy)
1. Создайте приложение типа **Docker Compose** и укажите этот репозиторий/папку.
2. Задайте переменные окружения (минимум):
```env
SECRET_KEY=очень-длинный-секрет
ADMIN_USERNAME=admin
ADMIN_PASSWORD=надежный-пароль
ADMIN_EMAIL=admin@example.com
PUBLIC_HOST=ваш-домен-или-ip
POSTGRES_PASSWORD=надежный-пароль-бд
```
3. Задеплойте. Панель: `http://HOST:8000` (или через Traefik/домен Dokploy).
4. Войдите в `/admin/login` логином/паролем из ENV.
Админ при каждом старте **синхронизируется из ENV** — удобно менять пароль через Dokploy без SQL.
## Локально
```bash
cp .env.example .env
docker compose up --build
```
Откройте http://localhost:8000
## Как связаны VPN‑демоны
Панель:
1. Хранит ключи и пиров в Postgres
2. Генерирует клиентские конфиги (и AWG‑параметры Jc/Jmin/Jmax/S1/S2/H1H4)
3. Пишет серверный `wg0.conf` / `awg0.conf` в shared volume
Дальше volume монтируется в контейнер WireGuard / AmneziaWG (или на хост), и интерфейс поднимается отдельно. Это сделано специально: панель — веб‑сайт, а VPN‑ядро можно менять независимо.
Пример идеи для следующего шага (не включено в MVP):
```yaml
# wireguard:
# image: linuxserver/wireguard
# volumes:
# - wg_data:/config
# ports:
# - "51820:51820/udp"
```
## Структура
```
app/
main.py # FastAPI
models.py # Admin, VpnServer, VpnClient
routers/ # public + admin UI
services/ # ключи, конфиги WG/AWG
templates/ # HTML сайта и админки
docker-compose.yml
Dockerfile
alembic/ # миграции
```
## Дальше
- Редактирование endpoint/DNS сервера из админки
- Подключение XRay / Hysteria2 / MTProto
- Статистика handshake / трафика
- Пользовательский кабинет (не только admin)
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+56
View File
@@ -0,0 +1,56 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.database import Base
from app import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", settings.database_url)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+81
View File
@@ -0,0 +1,81 @@
"""Initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-07-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"admin_users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=32), nullable=False, server_default="admin"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_admin_users_username", "admin_users", ["username"], unique=True)
op.create_unique_constraint("uq_admin_users_email", "admin_users", ["email"])
op.create_table(
"vpn_servers",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("protocol", sa.String(length=32), nullable=False),
sa.Column("public_host", sa.String(length=255), nullable=False),
sa.Column("public_port", sa.Integer(), nullable=False),
sa.Column("interface_name", sa.String(length=32), nullable=False),
sa.Column("subnet", sa.String(length=64), nullable=False),
sa.Column("dns", sa.String(length=255), nullable=False, server_default="1.1.1.1"),
sa.Column("server_private_key", sa.Text(), nullable=False),
sa.Column("server_public_key", sa.Text(), nullable=False),
sa.Column("jc", sa.Integer(), nullable=True),
sa.Column("jmin", sa.Integer(), nullable=True),
sa.Column("jmax", sa.Integer(), nullable=True),
sa.Column("s1", sa.Integer(), nullable=True),
sa.Column("s2", sa.Integer(), nullable=True),
sa.Column("h1", sa.Integer(), nullable=True),
sa.Column("h2", sa.Integer(), nullable=True),
sa.Column("h3", sa.Integer(), nullable=True),
sa.Column("h4", sa.Integer(), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
)
op.create_index("ix_vpn_servers_protocol", "vpn_servers", ["protocol"])
op.create_table(
"vpn_clients",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("server_id", sa.Integer(), sa.ForeignKey("vpn_servers.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("private_key", sa.Text(), nullable=False),
sa.Column("public_key", sa.Text(), nullable=False),
sa.Column("preshared_key", sa.Text(), nullable=False),
sa.Column("address", sa.String(length=64), nullable=False),
sa.Column("allowed_ips", sa.String(length=255), nullable=False, server_default="0.0.0.0/0, ::/0"),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("server_id", "name", name="uq_client_name_per_server"),
)
op.create_index("ix_vpn_clients_server_id", "vpn_clients", ["server_id"])
def downgrade() -> None:
op.drop_table("vpn_clients")
op.drop_table("vpn_servers")
op.drop_table("admin_users")
+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 %}
+41
View File
@@ -0,0 +1,41 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-vpn}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-vpn}
POSTGRES_DB: ${POSTGRES_DB:-vpn_panel}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vpn} -d ${POSTGRES_DB:-vpn_panel}"]
interval: 5s
timeout: 5s
retries: 10
panel:
build: .
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "${PANEL_PORT:-8000}:8000"
env_file:
- .env
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vpn}:${POSTGRES_PASSWORD:-vpn}@postgres:5432/${POSTGRES_DB:-vpn_panel}
volumes:
- wg_data:/data/wireguard
- awg_data:/data/amneziawg
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"]
interval: 15s
timeout: 5s
retries: 5
volumes:
postgres_data:
wg_data:
awg_data:
+16
View File
@@ -0,0 +1,16 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-multipart==0.0.20
jinja2==3.1.5
itsdangerous==2.2.0
qrcode[pil]==8.0
httpx==0.28.1
greenlet==3.1.1
cryptography==44.0.0
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -e
echo "Waiting for database..."
python - <<'PY'
import asyncio
import os
import sys
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
async def wait_db(retries: int = 40) -> None:
url = os.environ["DATABASE_URL"]
engine = create_async_engine(url)
for i in range(retries):
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
await engine.dispose()
print("Database is ready")
return
except Exception as exc: # noqa: BLE001
print(f"DB not ready ({i + 1}/{retries}): {exc}")
await asyncio.sleep(1)
await engine.dispose()
sys.exit(1)
asyncio.run(wait_db())
PY
echo "Running migrations..."
alembic upgrade head
echo "Bootstrapping admin..."
python -m app.bootstrap
echo "Starting application..."
exec "$@"