Add SSH host/login/password/key when creating VPN servers
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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----- ... -----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 %}
|
||||
|
||||
Reference in New Issue
Block a user