@@ -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 %}
|
||||
Reference in New Issue
Block a user