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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
-3
View File
@@ -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())
+6
View File
@@ -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)
+89 -11
View File
@@ -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)
+157 -1
View File
@@ -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,
*,
+31 -1
View File
@@ -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 {
+127 -18
View File
@@ -5,35 +5,144 @@
<h1>Серверы</h1>
</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 %}
<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 class="actions">
<form method="post" action="/admin/servers/{{ s.id }}/sync" class="inline-form">
<button class="btn btn-sm btn-ghost" type="submit">Синхронизировать conf</button>
</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 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>
<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 />
<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
<input name="public_host" value="{{ s.public_host }}" required />
</label>
<label>VPN порт
<input type="number" name="public_port" value="{{ s.public_port }}" required />
</label>
</div>
<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>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>
<label style="grid-column: 1 / -1">DNS
<input name="dns" value="{{ s.dns }}" />
</label>
</form>
</div>
</div>
{% else %}
<p class="muted">Серверов пока нет — добавьте первый через форму выше.</p>
{% endfor %}
<p class="muted">
Конфиги пишутся в volume: <code>/data/wireguard</code> и <code>/data/amneziawg</code>.
Подключите их к WG/AWG контейнерам на том же хосте.
</p>
{% endblock %}