commit bbff3cb10b538fb5db94991c6cb5aaa10bc2d913 Author: test2 Date: Sat Jul 25 20:36:00 2026 +0300 first commit Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45f1c39 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73145e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +data/ +*.conf +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddbeefd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cff6af2 --- /dev/null +++ b/README.md @@ -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) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..27e668c --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..723ae49 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..b5a8568 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -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") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ef69de0 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""VpnPanel package.""" diff --git a/app/bootstrap.py b/app/bootstrap.py new file mode 100644 index 0000000..e729332 --- /dev/null +++ b/app/bootstrap.py @@ -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()) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..581a315 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..7f935db --- /dev/null +++ b/app/database.py @@ -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 diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000..39a2be9 --- /dev/null +++ b/app/deps.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f47e260 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5745421 --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..113c7b0 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,3 @@ +from app.routers import admin, auth, public + +__all__ = ["admin", "auth", "public"] diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..ac94658 --- /dev/null +++ b/app/routers/admin.py @@ -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") diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..01dc05d --- /dev/null +++ b/app/routers/auth.py @@ -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) diff --git a/app/routers/public.py b/app/routers/public.py new file mode 100644 index 0000000..d85e58f --- /dev/null +++ b/app/routers/public.py @@ -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"} diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..2a06f8d --- /dev/null +++ b/app/security.py @@ -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) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..cd7a1f4 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,3 @@ +from app.services import crypto, vpn + +__all__ = ["crypto", "vpn"] diff --git a/app/services/crypto.py b/app/services/crypto.py new file mode 100644 index 0000000..4ddd8ad --- /dev/null +++ b/app/services/crypto.py @@ -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) diff --git a/app/services/vpn.py b/app/services/vpn.py new file mode 100644 index 0000000..90b9c4b --- /dev/null +++ b/app/services/vpn.py @@ -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 diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..ba0362e --- /dev/null +++ b/app/static/css/app.css @@ -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; } diff --git a/app/templates/admin/client_detail.html b/app/templates/admin/client_detail.html new file mode 100644 index 0000000..a4805c7 --- /dev/null +++ b/app/templates/admin/client_detail.html @@ -0,0 +1,33 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}{{ client.name }}{% endblock %} +{% block admin_content %} +
+
+

{{ client.name }}

+
{{ client.server.name }} · {{ client.server.protocol }} · {{ client.address }}
+
+ +
+ +
+
+
Конфиг
+
+
{{ config }}
+
+
+
+
QR‑код
+
+ QR config +

Отсканируйте в клиенте WireGuard / AmneziaWG.

+ {% if client.notes %} +

Заметка: {{ client.notes }}

+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/admin/clients.html b/app/templates/admin/clients.html new file mode 100644 index 0000000..b70fe5b --- /dev/null +++ b/app/templates/admin/clients.html @@ -0,0 +1,87 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}Клиенты{% endblock %} +{% block admin_content %} +
+

Клиенты

+ +
+ +{% if flash %} + {% if flash.startswith('error:') %} +
{{ flash[6:] }}
+ {% elif flash == 'created' %} +
Клиент создан
+ {% elif flash == 'deleted' %} +
Клиент удалён
+ {% endif %} +{% endif %} + +
+
Новый доступ
+
+
+ + + + +
+
+
+ +
+ + + + + + + + + + + + + {% for c in clients %} + + + + + + + + + {% else %} + + {% endfor %} + +
ИмяПротоколIPСтатусСоздан
{{ c.name }}{{ c.server.protocol }}{{ c.address }} + {% if c.is_enabled %} + on + {% else %} + off + {% endif %} + {{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }} + Открыть +
+ +
+
+ +
+
Клиентов пока нет
+
+{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..e5e59c7 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,59 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}Дашборд{% endblock %} +{% block admin_content %} +
+

Дашборд

+
+ +
+
+
Серверы
+
{{ servers|length }}
+
+
+
Клиенты
+
{{ clients_total }}
+
+
+
Активные
+
{{ clients_active }}
+
+
+ +
+
+ Протоколы + Управлять клиентами +
+ + + + + + + + + + + + {% for s in servers %} + + + + + + + + {% else %} + + {% endfor %} + +
ИмяПротоколEndpointПодсетьСтатус
{{ s.name }}{{ s.protocol }}{{ s.public_host }}:{{ s.public_port }}{{ s.subnet }} + {% if s.is_enabled %} + enabled + {% else %} + disabled + {% endif %} +
Серверы ещё не созданы
+
+{% endblock %} diff --git a/app/templates/admin/layout.html b/app/templates/admin/layout.html new file mode 100644 index 0000000..e5c70ca --- /dev/null +++ b/app/templates/admin/layout.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{% block admin_title %}Админка{% endblock %} — {{ app_name }}{% endblock %} +{% block body %} +
+ +
+ {% block admin_content %}{% endblock %} +
+
+{% endblock %} diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..aa87b20 --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Вход — {{ app_name }}{% endblock %} +{% block body %} + +{% endblock %} diff --git a/app/templates/admin/servers.html b/app/templates/admin/servers.html new file mode 100644 index 0000000..763ff65 --- /dev/null +++ b/app/templates/admin/servers.html @@ -0,0 +1,39 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}Серверы{% endblock %} +{% block admin_content %} +
+

Серверы

+
+ +{% for s in servers %} +
+
+ {{ s.name }} {{ s.protocol }} +
+ +
+
+
+

Интерфейс {{ s.interface_name }} · подсеть {{ s.subnet }} · клиентов: {{ s.clients|length }}

+

Public key: {{ s.server_public_key }}

+
+ + + + +
+
+
+{% endfor %} + +

+ Конфиги пишутся в volume: /data/wireguard и /data/amneziawg. + Подключите их к WG/AWG контейнерам на том же хосте. +

+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..d59d2c5 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,12 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + {% block body %}{% endblock %} + + diff --git a/app/templates/landing.html b/app/templates/landing.html new file mode 100644 index 0000000..a306013 --- /dev/null +++ b/app/templates/landing.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ app_name }} — VPN панель{% endblock %} +{% block body %} + + +
+
+

Веб‑панель для WireGuard и AmneziaWG 2.0

+

+ Управляйте VPN‑сервером через браузер: создавайте доступы, выдавайте конфиги и QR‑коды. + Без десктопного клиента — обычный сайт на вашем VPS. +

+ +
+ +
+

Один интерфейс для доступов

+

Сначала WireGuard и AWG 2.0. Остальные протоколы подключим позже.

+
+
+

Клиенты

+

Создавайте, отключайте и удаляйте доступы. IP выдаётся автоматически из подсети сервера.

+
+
+

Конфиг и QR

+

Скачайте .conf или отсканируйте QR — готово к подключению в официальных клиентах.

+
+
+

Dokploy + Postgres

+

Деплой через Docker Compose. Админ задаётся в ENV при первом запуске.

+
+
+
+
+ + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a31741a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30c0faa --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..7c87289 --- /dev/null +++ b/scripts/entrypoint.sh @@ -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 "$@"