diff --git a/alembic/versions/0002_ssh_fields.py b/alembic/versions/0002_ssh_fields.py new file mode 100644 index 0000000..e6ac85d --- /dev/null +++ b/alembic/versions/0002_ssh_fields.py @@ -0,0 +1,32 @@ +"""Add SSH connection fields to vpn_servers + +Revision ID: 0002_ssh_fields +Revises: 0001_initial +Create Date: 2026-07-25 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_ssh_fields" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("vpn_servers", sa.Column("ssh_host", sa.String(length=255), nullable=True)) + op.add_column("vpn_servers", sa.Column("ssh_port", sa.Integer(), nullable=False, server_default="22")) + op.add_column("vpn_servers", sa.Column("ssh_username", sa.String(length=128), nullable=True)) + op.add_column("vpn_servers", sa.Column("ssh_password", sa.Text(), nullable=True)) + op.add_column("vpn_servers", sa.Column("ssh_private_key", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("vpn_servers", "ssh_private_key") + op.drop_column("vpn_servers", "ssh_password") + op.drop_column("vpn_servers", "ssh_username") + op.drop_column("vpn_servers", "ssh_port") + op.drop_column("vpn_servers", "ssh_host") diff --git a/app/bootstrap.py b/app/bootstrap.py index e729332..3622f1e 100644 --- a/app/bootstrap.py +++ b/app/bootstrap.py @@ -6,7 +6,6 @@ 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: @@ -34,8 +33,6 @@ async def bootstrap() -> None: 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/models.py b/app/models.py index 5745421..0ddb0f4 100644 --- a/app/models.py +++ b/app/models.py @@ -41,6 +41,12 @@ class VpnServer(Base): 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) + # SSH access to the VPS + ssh_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + ssh_port: Mapped[int] = mapped_column(Integer, default=22) + ssh_username: Mapped[str | None] = mapped_column(String(128), nullable=True) + ssh_password: Mapped[str | None] = mapped_column(Text, nullable=True) + ssh_private_key: Mapped[str | None] = mapped_column(Text, nullable=True) # 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) diff --git a/app/routers/admin.py b/app/routers/admin.py index ac94658..8d31f43 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -68,15 +68,56 @@ async def servers_page(request: Request, db: AsyncSession = Depends(get_db), adm "request": request, "admin": admin, "servers": servers, + "protocols": Protocol, "app_name": request.app.state.settings.app_name, + "flash": request.query_params.get("flash"), }, ) +@router.post("/servers") +async def create_server( + name: str = Form(""), + protocol: str = Form(...), + ssh_host: str = Form(...), + ssh_port: int = Form(22), + ssh_username: str = Form(...), + ssh_password: str = Form(""), + ssh_private_key: str = Form(""), + public_host: str = Form(""), + public_port: str = Form(""), + dns: str = Form("1.1.1.1"), + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + try: + port_value = int(public_port) if public_port.strip() else None + await vpn_service.create_server( + db, + name=name, + protocol=protocol, + ssh_host=ssh_host, + ssh_port=ssh_port, + ssh_username=ssh_username, + ssh_password=ssh_password, + ssh_private_key=ssh_private_key, + public_host=public_host or None, + public_port=port_value, + dns=dns, + ) + except Exception as exc: # noqa: BLE001 + return RedirectResponse( + f"/admin/servers?flash=error:{exc}", + status_code=status.HTTP_303_SEE_OTHER, + ) + return RedirectResponse("/admin/servers?flash=created", status_code=status.HTTP_303_SEE_OTHER) + + @router.post("/servers/{server_id}/sync") async def sync_server( server_id: int, - request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin), ): @@ -86,12 +127,9 @@ async def sync_server( return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER) -@router.post("/servers/{server_id}") -async def update_server( +@router.post("/servers/{server_id}/delete") +async def delete_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), ): @@ -99,12 +137,52 @@ async def update_server( 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.delete(server) await db.commit() - await vpn_service.sync_server_config(db, server_id) - return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse("/admin/servers?flash=deleted", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/servers/{server_id}") +async def update_server( + server_id: int, + name: str = Form(""), + public_host: str = Form(...), + public_port: int = Form(...), + dns: str = Form("1.1.1.1"), + ssh_host: str = Form(""), + ssh_port: int = Form(22), + ssh_username: str = Form(""), + ssh_password: str = Form(""), + ssh_private_key: str = Form(""), + clear_ssh_password: str = Form(""), + clear_ssh_private_key: str = Form(""), + db: AsyncSession = Depends(get_db), + admin=Depends(require_admin), +): + if _is_redirect(admin): + return admin + try: + await vpn_service.update_server_settings( + db, + server_id, + name=name or None, + public_host=public_host, + public_port=public_port, + dns=dns, + ssh_host=ssh_host, + ssh_port=ssh_port, + ssh_username=ssh_username, + ssh_password=ssh_password, + ssh_private_key=ssh_private_key, + clear_ssh_password=clear_ssh_password == "1", + clear_ssh_private_key=clear_ssh_private_key == "1", + ) + except Exception as exc: # noqa: BLE001 + return RedirectResponse( + f"/admin/servers?flash=error:{exc}", + status_code=status.HTTP_303_SEE_OTHER, + ) + return RedirectResponse("/admin/servers?flash=saved", status_code=status.HTTP_303_SEE_OTHER) @router.get("/clients", response_class=HTMLResponse) diff --git a/app/services/vpn.py b/app/services/vpn.py index 90b9c4b..4e0dfd2 100644 --- a/app/services/vpn.py +++ b/app/services/vpn.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -75,6 +75,162 @@ async def ensure_default_servers(session: AsyncSession) -> None: await session.commit() +def _defaults_for_protocol(protocol: str) -> dict: + if protocol == Protocol.AWG2.value: + return { + "interface_name": settings.awg_interface, + "subnet": settings.awg_subnet, + "public_port": settings.awg_public_port, + "jc": 4, + "jmin": 40, + "jmax": 70, + "s1": 0, + "s2": 0, + "h1": 1, + "h2": 2, + "h3": 3, + "h4": 4, + } + return { + "interface_name": settings.wg_interface, + "subnet": settings.wg_subnet, + "public_port": settings.public_port, + "jc": None, + "jmin": None, + "jmax": None, + "s1": None, + "s2": None, + "h1": None, + "h2": None, + "h3": None, + "h4": None, + } + + +async def create_server( + session: AsyncSession, + *, + name: str, + protocol: str, + ssh_host: str, + ssh_username: str, + ssh_port: int = 22, + ssh_password: str | None = None, + ssh_private_key: str | None = None, + public_host: str | None = None, + public_port: int | None = None, + dns: str = "1.1.1.1", +) -> VpnServer: + protocol = protocol.strip().lower() + if protocol not in {Protocol.WIREGUARD.value, Protocol.AWG2.value}: + raise ValueError("Unsupported protocol") + + password = (ssh_password or "").strip() or None + private_key = (ssh_private_key or "").strip() or None + if not password and not private_key: + raise ValueError("Укажите пароль SSH или приватный ключ") + + host = ssh_host.strip() + if not host: + raise ValueError("Укажите SSH host") + username = ssh_username.strip() + if not username: + raise ValueError("Укажите SSH логин") + + defaults = _defaults_for_protocol(protocol) + keys = generate_keypair() + # Unique interface per server id is assigned after insert; use temp then update + existing_count = await session.scalar(select(func.count()).select_from(VpnServer)) or 0 + iface_base = "awg" if protocol == Protocol.AWG2.value else "wg" + interface_name = f"{iface_base}{existing_count}" + + # Unique subnet per server: 10.{8+n}.0.0/24 or 10.{9+n}.0.0/24 + octet = (8 if protocol == Protocol.WIREGUARD.value else 9) + int(existing_count) + if octet > 250: + raise ValueError("Слишком много серверов для авто-подсети") + subnet = f"10.{octet}.0.0/24" + + server = VpnServer( + name=name.strip() or host, + protocol=protocol, + public_host=(public_host or host).strip(), + public_port=public_port or int(defaults["public_port"]), + interface_name=interface_name, + subnet=subnet, + dns=(dns or "1.1.1.1").strip(), + server_private_key=keys.private_key, + server_public_key=keys.public_key, + ssh_host=host, + ssh_port=ssh_port or 22, + ssh_username=username, + ssh_password=password, + ssh_private_key=private_key, + jc=defaults["jc"], + jmin=defaults["jmin"], + jmax=defaults["jmax"], + s1=defaults["s1"], + s2=defaults["s2"], + h1=defaults["h1"], + h2=defaults["h2"], + h3=defaults["h3"], + h4=defaults["h4"], + is_enabled=True, + ) + session.add(server) + await session.commit() + await session.refresh(server) + await sync_server_config(session, server.id) + return server + + +async def update_server_settings( + session: AsyncSession, + server_id: int, + *, + name: str | None = None, + public_host: str, + public_port: int, + dns: str = "1.1.1.1", + ssh_host: str | None = None, + ssh_port: int = 22, + ssh_username: str | None = None, + ssh_password: str | None = None, + ssh_private_key: str | None = None, + clear_ssh_password: bool = False, + clear_ssh_private_key: bool = False, +) -> VpnServer: + server = await session.get(VpnServer, server_id) + if not server: + raise ValueError("Server not found") + + if name is not None and name.strip(): + server.name = name.strip() + server.public_host = public_host.strip() + server.public_port = public_port + server.dns = dns.strip() or "1.1.1.1" + + if ssh_host is not None: + server.ssh_host = ssh_host.strip() or None + server.ssh_port = ssh_port or 22 + if ssh_username is not None: + server.ssh_username = ssh_username.strip() or None + + if clear_ssh_password: + server.ssh_password = None + elif ssh_password is not None and ssh_password.strip(): + server.ssh_password = ssh_password.strip() + + if clear_ssh_private_key: + server.ssh_private_key = None + elif ssh_private_key is not None and ssh_private_key.strip(): + server.ssh_private_key = ssh_private_key.strip() + + await session.commit() + await session.refresh(server) + await sync_server_config(session, server_id) + return server + + async def create_client( session: AsyncSession, *, diff --git a/app/static/css/app.css b/app/static/css/app.css index ba0362e..366d5ca 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -221,8 +221,38 @@ tr:last-child td { border-bottom: none; } gap: 0.7rem; align-items: end; } +.form-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.7rem; + align-items: end; +} @media (max-width: 800px) { - .form-grid { grid-template-columns: 1fr; } + .form-grid, .form-grid-2 { grid-template-columns: 1fr; } +} +.form-section { + margin: 0.6rem 0 0; + font-size: 0.95rem; + letter-spacing: -0.01em; +} +.check { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--muted); + font-size: 0.88rem; +} +.check input { width: auto; } +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.65rem 0.75rem; + background: white; + color: var(--ink); + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.84rem; } label { display: grid; gap: 0.35rem; font-size: 0.88rem; color: var(--muted); } input, select, textarea { diff --git a/app/templates/admin/servers.html b/app/templates/admin/servers.html index 763ff65..4f2ed4f 100644 --- a/app/templates/admin/servers.html +++ b/app/templates/admin/servers.html @@ -5,35 +5,144 @@

Серверы

+{% if flash %} + {% if flash.startswith('error:') %} +
{{ flash[6:] }}
+ {% elif flash == 'created' %} +
Сервер добавлен
+ {% elif flash == 'saved' %} +
Сохранено
+ {% elif flash == 'deleted' %} +
Сервер удалён
+ {% endif %} +{% endif %} + +
+
Добавить сервер
+
+
+
+ + +
+ +

SSH подключение

+
+ + + + +
+ +

Нужен пароль или приватный ключ.

+ +

VPN endpoint

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

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

+

+ Интерфейс {{ s.interface_name }} · подсеть {{ s.subnet }} · клиентов: {{ s.clients|length }} + {% if s.ssh_host %} + · SSH + {% if s.ssh_private_key %}ключ{% endif %} + {% if s.ssh_password %}пароль{% endif %} + {% else %} + · SSH не задан + {% endif %} +

Public key: {{ s.server_public_key }}

-
- -
+{% else %} +

Серверов пока нет — добавьте первый через форму выше.

{% endfor %} - -

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

{% endblock %}