@@ -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
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
data/
|
||||||
|
*.conf
|
||||||
|
.DS_Store
|
||||||
+26
@@ -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"]
|
||||||
@@ -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/H1–H4)
|
||||||
|
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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""VpnPanel package."""
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.routers import admin, auth, public
|
||||||
|
|
||||||
|
__all__ = ["admin", "auth", "public"]
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services import crypto, vpn
|
||||||
|
|
||||||
|
__all__ = ["crypto", "vpn"]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
Reference in New Issue
Block a user