Add SSH host/login/password/key when creating VPN servers

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 21:23:12 +03:00
co-authored by Cursor
parent e8fb5ca00c
commit 088f02cdbe
7 changed files with 442 additions and 34 deletions
+32
View File
@@ -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")
-3
View File
@@ -6,7 +6,6 @@ from app.config import settings
from app.database import SessionLocal from app.database import SessionLocal
from app.models import AdminUser from app.models import AdminUser
from app.security import hash_password from app.security import hash_password
from app.services.vpn import ensure_default_servers
async def bootstrap() -> None: async def bootstrap() -> None:
@@ -34,8 +33,6 @@ async def bootstrap() -> None:
await session.commit() await session.commit()
print(f"Admin user '{settings.admin_username}' synced from ENV") print(f"Admin user '{settings.admin_username}' synced from ENV")
await ensure_default_servers(session)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(bootstrap()) asyncio.run(bootstrap())
+6
View File
@@ -41,6 +41,12 @@ class VpnServer(Base):
dns: Mapped[str] = mapped_column(String(255), default="1.1.1.1") dns: Mapped[str] = mapped_column(String(255), default="1.1.1.1")
server_private_key: Mapped[str] = mapped_column(Text) server_private_key: Mapped[str] = mapped_column(Text)
server_public_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) # AmneziaWG 2.0 obfuscation params (ignored for plain WireGuard)
jc: Mapped[int | None] = mapped_column(Integer, nullable=True) jc: Mapped[int | None] = mapped_column(Integer, nullable=True)
jmin: Mapped[int | None] = mapped_column(Integer, nullable=True) jmin: Mapped[int | None] = mapped_column(Integer, nullable=True)
+89 -11
View File
@@ -68,15 +68,56 @@ async def servers_page(request: Request, db: AsyncSession = Depends(get_db), adm
"request": request, "request": request,
"admin": admin, "admin": admin,
"servers": servers, "servers": servers,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name, "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") @router.post("/servers/{server_id}/sync")
async def sync_server( async def sync_server(
server_id: int, server_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
@@ -86,12 +127,9 @@ async def sync_server(
return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse("/admin/servers", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/servers/{server_id}") @router.post("/servers/{server_id}/delete")
async def update_server( async def delete_server(
server_id: int, server_id: int,
public_host: str = Form(...),
public_port: int = Form(...),
dns: str = Form("1.1.1.1"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
@@ -99,12 +137,52 @@ async def update_server(
return admin return admin
server = await db.get(VpnServer, server_id) server = await db.get(VpnServer, server_id)
if server: if server:
server.public_host = public_host.strip() await db.delete(server)
server.public_port = public_port
server.dns = dns.strip() or "1.1.1.1"
await db.commit() await db.commit()
await vpn_service.sync_server_config(db, server_id) return RedirectResponse("/admin/servers?flash=deleted", status_code=status.HTTP_303_SEE_OTHER)
return RedirectResponse("/admin/servers", 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) @router.get("/clients", response_class=HTMLResponse)
+157 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -75,6 +75,162 @@ async def ensure_default_servers(session: AsyncSession) -> None:
await session.commit() 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( async def create_client(
session: AsyncSession, session: AsyncSession,
*, *,
+31 -1
View File
@@ -221,8 +221,38 @@ tr:last-child td { border-bottom: none; }
gap: 0.7rem; gap: 0.7rem;
align-items: end; align-items: end;
} }
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
align-items: end;
}
@media (max-width: 800px) { @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); } label { display: grid; gap: 0.35rem; font-size: 0.88rem; color: var(--muted); }
input, select, textarea { input, select, textarea {
+120 -11
View File
@@ -5,35 +5,144 @@
<h1>Серверы</h1> <h1>Серверы</h1>
</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 == 'saved' %}
<div class="flash">Сохранено</div>
{% elif flash == 'deleted' %}
<div class="flash">Сервер удалён</div>
{% endif %}
{% endif %}
<div class="panel" style="margin-bottom:1.25rem">
<div class="panel-head"><strong>Добавить сервер</strong></div>
<div class="panel-body">
<form method="post" action="/admin/servers" class="stack">
<div class="form-grid-2">
<label>Название
<input name="name" placeholder="Мой VPS" />
</label>
<label>Протокол
<select name="protocol" required>
<option value="wireguard">WireGuard</option>
<option value="awg2">AmneziaWG 2.0</option>
</select>
</label>
</div>
<h3 class="form-section">SSH подключение</h3>
<div class="form-grid-2">
<label>Host
<input name="ssh_host" placeholder="1.2.3.4 или vps.example.com" required />
</label>
<label>SSH порт
<input type="number" name="ssh_port" value="22" required />
</label>
<label>Логин
<input name="ssh_username" value="root" required />
</label>
<label>Пароль
<input type="password" name="ssh_password" placeholder="или оставьте пустым, если ключ" autocomplete="new-password" />
</label>
</div>
<label>Приватный ключ (опционально, вместо пароля)
<textarea name="ssh_private_key" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"></textarea>
</label>
<p class="muted" style="margin:0">Нужен пароль <em>или</em> приватный ключ.</p>
<h3 class="form-section">VPN endpoint</h3>
<div class="form-grid-2">
<label>Public host (если пусто — как SSH host)
<input name="public_host" placeholder="тот же IP/домен" />
</label>
<label>VPN порт
<input type="number" name="public_port" placeholder="авто" />
</label>
<label>DNS
<input name="dns" value="1.1.1.1" />
</label>
</div>
<button class="btn btn-accent" type="submit">Добавить сервер</button>
</form>
</div>
</div>
{% for s in servers %} {% for s in servers %}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"> <div class="panel-head">
<strong>{{ s.name }} <span class="badge badge-proto">{{ s.protocol }}</span></strong> <strong>{{ s.name }} <span class="badge badge-proto">{{ s.protocol }}</span></strong>
<div class="actions">
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form"> <form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
<button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button> <button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button>
</form> </form>
<form method="post" action="/admin/servers/{{ s.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить сервер и всех клиентов?')">
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
</form>
</div>
</div> </div>
<div class="panel-body"> <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 class="muted" style="margin-top:0">
Интерфейс <code>{{ s.interface_name }}</code> · подсеть <code>{{ s.subnet }}</code> · клиентов: {{ s.clients|length }}
{% if s.ssh_host %}
· SSH
{% if s.ssh_private_key %}<span class="badge badge-ok">ключ</span>{% endif %}
{% if s.ssh_password %}<span class="badge badge-ok">пароль</span>{% endif %}
{% else %}
· <span class="badge badge-off">SSH не задан</span>
{% endif %}
</p>
<p><code>Public key:</code> <span class="muted">{{ s.server_public_key }}</span></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">
<form method="post" action="/admin/servers/{{ s.id }}" class="stack">
<div class="form-grid-2">
<label>Название
<input name="name" value="{{ s.name }}" />
</label>
<label>DNS
<input name="dns" value="{{ s.dns }}" />
</label>
<label>Public host <label>Public host
<input name="public_host" value="{{ s.public_host }}" required /> <input name="public_host" value="{{ s.public_host }}" required />
</label> </label>
<label>Port <label>VPN порт
<input type="number" name="public_port" value="{{ s.public_port }}" required /> <input type="number" name="public_port" value="{{ s.public_port }}" required />
</label> </label>
<button class="btn btn-accent" type="submit">Сохранить</button> </div>
<label style="grid-column: 1 / -1">DNS
<input name="dns" value="{{ s.dns }}" /> <h3 class="form-section">SSH подключение</h3>
<div class="form-grid-2">
<label>Host
<input name="ssh_host" value="{{ s.ssh_host or '' }}" placeholder="1.2.3.4" />
</label> </label>
<label>SSH порт
<input type="number" name="ssh_port" value="{{ s.ssh_port or 22 }}" />
</label>
<label>Логин
<input name="ssh_username" value="{{ s.ssh_username or '' }}" placeholder="root" />
</label>
<label>Новый пароль
<input type="password" name="ssh_password" placeholder="{% if s.ssh_password %}сохранён — введите, чтобы заменить{% else %}не задан{% endif %}" autocomplete="new-password" />
</label>
</div>
<label>Новый приватный ключ
<textarea name="ssh_private_key" rows="4" placeholder="{% if s.ssh_private_key %}ключ сохранён — вставьте новый, чтобы заменить{% else %}не задан{% endif %}"></textarea>
</label>
<div class="actions">
<label class="check">
<input type="checkbox" name="clear_ssh_password" value="1" /> очистить пароль
</label>
<label class="check">
<input type="checkbox" name="clear_ssh_private_key" value="1" /> очистить ключ
</label>
</div>
<button class="btn btn-accent" type="submit">Сохранить</button>
</form> </form>
</div> </div>
</div> </div>
{% else %}
<p class="muted">Серверов пока нет — добавьте первый через форму выше.</p>
{% endfor %} {% endfor %}
<p class="muted">
Конфиги пишутся в volume: <code>/data/wireguard</code> и <code>/data/amneziawg</code>.
Подключите их к WG/AWG контейнерам на том же хосте.
</p>
{% endblock %} {% endblock %}