Amnezia-style SSH server connect with verify, ping and server_info

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 21:30:43 +03:00
co-authored by Cursor
parent 088f02cdbe
commit 321a5b2504
9 changed files with 334 additions and 40 deletions
+31
View File
@@ -0,0 +1,31 @@
"""SSH status fields for vpn_servers
Revision ID: 0003_ssh_status
Revises: 0002_ssh_fields
Create Date: 2026-07-25
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0003_ssh_status"
down_revision: Union[str, None] = "0002_ssh_fields"
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_server_info", sa.Text(), nullable=True))
op.add_column(
"vpn_servers",
sa.Column("ssh_reachable", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.add_column("vpn_servers", sa.Column("ssh_last_checked", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("vpn_servers", "ssh_last_checked")
op.drop_column("vpn_servers", "ssh_reachable")
op.drop_column("vpn_servers", "ssh_server_info")
+3
View File
@@ -47,6 +47,9 @@ class VpnServer(Base):
ssh_username: Mapped[str | None] = mapped_column(String(128), nullable=True) ssh_username: Mapped[str | None] = mapped_column(String(128), nullable=True)
ssh_password: Mapped[str | None] = mapped_column(Text, nullable=True) ssh_password: Mapped[str | None] = mapped_column(Text, nullable=True)
ssh_private_key: Mapped[str | None] = mapped_column(Text, nullable=True) ssh_private_key: Mapped[str | None] = mapped_column(Text, nullable=True)
ssh_server_info: Mapped[str | None] = mapped_column(Text, nullable=True)
ssh_reachable: Mapped[bool] = mapped_column(Boolean, default=False)
ssh_last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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)
+20
View File
@@ -62,12 +62,14 @@ async def servers_page(request: Request, db: AsyncSession = Depends(get_db), adm
select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id) select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id)
) )
).scalars().all() ).scalars().all()
probes = await vpn_service.probe_servers(list(servers))
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/servers.html", "admin/servers.html",
{ {
"request": request, "request": request,
"admin": admin, "admin": admin,
"servers": servers, "servers": servers,
"probes": probes,
"protocols": Protocol, "protocols": Protocol,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"), "flash": request.query_params.get("flash"),
@@ -127,6 +129,24 @@ 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}/check-ssh")
async def check_ssh(
server_id: int,
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
try:
await vpn_service.check_server_ssh(db, server_id)
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=ssh_ok", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/servers/{server_id}/delete") @router.post("/servers/{server_id}/delete")
async def delete_server( async def delete_server(
server_id: int, server_id: int,
+2 -2
View File
@@ -1,3 +1,3 @@
from app.services import crypto, vpn from app.services import crypto, vpn, ssh
__all__ = ["crypto", "vpn"] __all__ = ["crypto", "vpn", "ssh"]
+153
View File
@@ -0,0 +1,153 @@
"""SSH manager — similar to Amnezia-Web-Panel (Paramiko)."""
from __future__ import annotations
import io
import logging
import socket
from dataclasses import dataclass
import paramiko
logger = logging.getLogger(__name__)
@dataclass
class SSHProbeResult:
online: bool
latency_ms: int | None = None
error: str | None = None
class SSHManager:
"""SSH connection + command execution on a remote VPS."""
def __init__(
self,
host: str,
port: int = 22,
username: str = "root",
password: str | None = None,
private_key: str | None = None,
):
self.host = host
self.port = int(port)
self.username = username
self.password = password or None
self.private_key = private_key or None
self.client: paramiko.SSHClient | None = None
self._is_root = username == "root"
def _load_pkey(self) -> paramiko.PKey:
assert self.private_key is not None
key_file = io.StringIO(self.private_key.strip())
errors: list[str] = []
for key_cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
key_file.seek(0)
try:
return key_cls.from_private_key(key_file)
except Exception as exc: # noqa: BLE001
errors.append(f"{key_cls.__name__}: {exc}")
raise ValueError("Не удалось прочитать приватный ключ (" + "; ".join(errors[:2]) + ")")
def connect(self, timeout: int = 15) -> bool:
if not self.password and not self.private_key:
raise ValueError("Нужен пароль или приватный ключ SSH")
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kwargs: dict = {
"hostname": self.host,
"port": self.port,
"username": self.username,
"timeout": timeout,
"banner_timeout": timeout,
"auth_timeout": timeout,
"allow_agent": False,
"look_for_keys": False,
}
if self.private_key:
kwargs["pkey"] = self._load_pkey()
else:
kwargs["password"] = self.password
self.client.connect(**kwargs)
return True
def disconnect(self) -> None:
if self.client:
self.client.close()
self.client = None
def run_command(self, command: str, timeout: int = 60) -> tuple[str, str, int]:
if not self.client:
raise ConnectionError("Not connected to server")
logger.info("SSH %s: %s", self.host, command[:120])
_stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
stdout.channel.settimeout(timeout)
stderr.channel.settimeout(timeout)
try:
exit_code = stdout.channel.recv_exit_status()
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
except Exception as exc: # noqa: BLE001
return "", str(exc), -1
return out, err, exit_code
def run_sudo_command(self, command: str, timeout: int = 60) -> tuple[str, str, int]:
clean = command.strip()
if clean.startswith("sudo "):
clean = clean[5:]
if self._is_root:
return self.run_command(clean, timeout=timeout)
if self.password:
escaped = self.password.replace("'", "'\\''")
full = f"echo '{escaped}' | sudo -S -p '' {clean}"
else:
full = f"sudo {clean}"
return self.run_command(full, timeout=timeout)
def test_connection(self) -> str:
out, err, code = self.run_command(
"uname -sr && cat /etc/os-release 2>/dev/null | head -2"
)
if code != 0 and not out:
raise ConnectionError(err or f"SSH test failed (code {code})")
return out
def __enter__(self) -> SSHManager:
self.connect()
return self
def __exit__(self, *args) -> None:
self.disconnect()
def probe_tcp(host: str, port: int, timeout: float = 2.0) -> SSHProbeResult:
"""Non-blocking-ish TCP connect probe (run via asyncio.to_thread)."""
import time
started = time.perf_counter()
try:
with socket.create_connection((host, int(port)), timeout=timeout):
latency = int((time.perf_counter() - started) * 1000)
return SSHProbeResult(online=True, latency_ms=latency)
except OSError as exc:
return SSHProbeResult(online=False, error=str(exc))
def verify_ssh(
host: str,
port: int,
username: str,
password: str | None = None,
private_key: str | None = None,
) -> str:
"""Connect, run test command, disconnect. Returns server_info text."""
ssh = SSHManager(host, port, username, password, private_key)
try:
ssh.connect()
return ssh.test_connection()
finally:
ssh.disconnect()
+61
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -16,6 +18,7 @@ from app.services.crypto import (
render_server_config, render_server_config,
server_address, server_address,
) )
from app.services.ssh import probe_tcp, verify_ssh
async def ensure_default_servers(session: AsyncSession) -> None: async def ensure_default_servers(session: AsyncSession) -> None:
@@ -137,6 +140,14 @@ async def create_server(
if not username: if not username:
raise ValueError("Укажите SSH логин") raise ValueError("Укажите SSH логин")
# Like Amnezia-Web-Panel: verify SSH before saving
try:
server_info = await asyncio.to_thread(
verify_ssh, host, ssh_port or 22, username, password, private_key
)
except Exception as exc: # noqa: BLE001
raise ValueError(f"Подключение не удалось: {exc}") from exc
defaults = _defaults_for_protocol(protocol) defaults = _defaults_for_protocol(protocol)
keys = generate_keypair() keys = generate_keypair()
# Unique interface per server id is assigned after insert; use temp then update # Unique interface per server id is assigned after insert; use temp then update
@@ -150,6 +161,7 @@ async def create_server(
raise ValueError("Слишком много серверов для авто-подсети") raise ValueError("Слишком много серверов для авто-подсети")
subnet = f"10.{octet}.0.0/24" subnet = f"10.{octet}.0.0/24"
now = datetime.now(timezone.utc)
server = VpnServer( server = VpnServer(
name=name.strip() or host, name=name.strip() or host,
protocol=protocol, protocol=protocol,
@@ -165,6 +177,9 @@ async def create_server(
ssh_username=username, ssh_username=username,
ssh_password=password, ssh_password=password,
ssh_private_key=private_key, ssh_private_key=private_key,
ssh_server_info=server_info,
ssh_reachable=True,
ssh_last_checked=now,
jc=defaults["jc"], jc=defaults["jc"],
jmin=defaults["jmin"], jmin=defaults["jmin"],
jmax=defaults["jmax"], jmax=defaults["jmax"],
@@ -183,6 +198,52 @@ async def create_server(
return server return server
async def check_server_ssh(session: AsyncSession, server_id: int) -> VpnServer:
server = await session.get(VpnServer, server_id)
if not server:
raise ValueError("Server not found")
if not server.ssh_host or not server.ssh_username:
raise ValueError("SSH данные не заданы")
if not server.ssh_password and not server.ssh_private_key:
raise ValueError("Нет пароля и ключа SSH")
now = datetime.now(timezone.utc)
try:
info = await asyncio.to_thread(
verify_ssh,
server.ssh_host,
server.ssh_port or 22,
server.ssh_username,
server.ssh_password,
server.ssh_private_key,
)
server.ssh_server_info = info
server.ssh_reachable = True
server.ssh_last_checked = now
except Exception as exc: # noqa: BLE001
server.ssh_reachable = False
server.ssh_last_checked = now
await session.commit()
raise ValueError(f"Подключение не удалось: {exc}") from exc
await session.commit()
await session.refresh(server)
return server
async def probe_servers(servers: list[VpnServer]) -> dict[int, dict]:
"""TCP ping SSH ports for UI online indicators."""
async def one(s: VpnServer) -> tuple[int, dict]:
if not s.ssh_host:
return s.id, {"online": False, "latency_ms": None}
result = await asyncio.to_thread(probe_tcp, s.ssh_host, s.ssh_port or 22)
return s.id, {"online": result.online, "latency_ms": result.latency_ms}
pairs = await asyncio.gather(*(one(s) for s in servers))
return dict(pairs)
async def update_server_settings( async def update_server_settings(
session: AsyncSession, session: AsyncSession,
server_id: int, server_id: int,
+15
View File
@@ -243,6 +243,21 @@ tr:last-child td { border-bottom: none; }
font-size: 0.88rem; font-size: 0.88rem;
} }
.check input { width: auto; } .check input { width: auto; }
.ping {
display: inline-block;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
margin-right: 0.35rem;
vertical-align: middle;
}
.ping-on { background: var(--ok); box-shadow: 0 0 0 3px rgba(6, 118, 71, 0.15); }
.ping-off { background: #98a2b3; }
.server-info {
margin: 0 0 1rem;
max-height: 120px;
font-size: 0.8rem;
}
textarea { textarea {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
+48 -37
View File
@@ -9,53 +9,59 @@
{% if flash.startswith('error:') %} {% if flash.startswith('error:') %}
<div class="flash flash-error">{{ flash[6:] }}</div> <div class="flash flash-error">{{ flash[6:] }}</div>
{% elif flash == 'created' %} {% elif flash == 'created' %}
<div class="flash">Сервер добавлен</div> <div class="flash">Сервер подключён по SSH и сохранён</div>
{% elif flash == 'saved' %} {% elif flash == 'saved' %}
<div class="flash">Сохранено</div> <div class="flash">Сохранено</div>
{% elif flash == 'deleted' %} {% elif flash == 'deleted' %}
<div class="flash">Сервер удалён</div> <div class="flash">Сервер удалён</div>
{% elif flash == 'ssh_ok' %}
<div class="flash">SSH проверка успешна</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="panel" style="margin-bottom:1.25rem"> <div class="panel" style="margin-bottom:1.25rem">
<div class="panel-head"><strong>Добавить сервер</strong></div> <div class="panel-head"><strong>Подключить сервер</strong></div>
<div class="panel-body"> <div class="panel-body">
<p class="muted" style="margin-top:0">
Как в Amnezia-Web-Panel: укажите IP/домен, логин и пароль или ключ.
Панель сначала проверит SSH, затем сохранит сервер.
</p>
<form method="post" action="/admin/servers" class="stack"> <form method="post" action="/admin/servers" class="stack">
<div class="form-grid-2"> <div class="form-grid-2">
<label>Название <label>Название (опционально)
<input name="name" placeholder="Мой VPS" /> <input name="name" placeholder="Мой VPS" />
</label> </label>
<label>Протокол <label>Протокол
<select name="protocol" required> <select name="protocol" required>
<option value="wireguard">WireGuard</option>
<option value="awg2">AmneziaWG 2.0</option> <option value="awg2">AmneziaWG 2.0</option>
<option value="wireguard">WireGuard</option>
</select> </select>
</label> </label>
</div> </div>
<h3 class="form-section">SSH подключение</h3> <h3 class="form-section">SSH доступ</h3>
<div class="form-grid-2"> <div class="form-grid-2">
<label>Host <label>Host / IP
<input name="ssh_host" placeholder="1.2.3.4 или vps.example.com" required /> <input name="ssh_host" placeholder="1.2.3.4" required autocomplete="off" />
</label> </label>
<label>SSH порт <label>SSH порт
<input type="number" name="ssh_port" value="22" required /> <input type="number" name="ssh_port" value="22" required />
</label> </label>
<label>Логин <label>Логин
<input name="ssh_username" value="root" required /> <input name="ssh_username" value="root" required autocomplete="username" />
</label> </label>
<label>Пароль <label>Пароль
<input type="password" name="ssh_password" placeholder="или оставьте пустым, если ключ" autocomplete="new-password" /> <input type="password" name="ssh_password" placeholder="если без ключа" autocomplete="new-password" />
</label> </label>
</div> </div>
<label>Приватный ключ (опционально, вместо пароля) <label>Или приватный ключ
<textarea name="ssh_private_key" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"></textarea> <textarea name="ssh_private_key" rows="5" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</label> </label>
<p class="muted" style="margin:0">Нужен пароль <em>или</em> приватный ключ.</p> <p class="muted" style="margin:0">Нужен пароль <em>или</em> приватный ключ. Сервер Ubuntu 20.04/22.04/24.04.</p>
<h3 class="form-section">VPN endpoint</h3> <h3 class="form-section">VPN endpoint</h3>
<div class="form-grid-2"> <div class="form-grid-2">
<label>Public host (если пусто — как SSH host) <label>Public host (пусто = SSH host)
<input name="public_host" placeholder="тот же IP/домен" /> <input name="public_host" placeholder="тот же IP/домен" />
</label> </label>
<label>VPN порт <label>VPN порт
@@ -65,18 +71,27 @@
<input name="dns" value="1.1.1.1" /> <input name="dns" value="1.1.1.1" />
</label> </label>
</div> </div>
<button class="btn btn-accent" type="submit">Добавить сервер</button> <button class="btn btn-accent" type="submit">Проверить SSH и добавить</button>
</form> </form>
</div> </div>
</div> </div>
{% for s in servers %} {% for s in servers %}
{% set probe = probes.get(s.id, {}) %}
<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>
<span class="ping {% if probe.get('online') %}ping-on{% else %}ping-off{% endif %}" title="{% if probe.get('online') %}online {{ probe.get('latency_ms') }}ms{% else %}offline{% endif %}"></span>
{{ s.name }}
<span class="badge badge-proto">{{ s.protocol }}</span>
{% if s.ssh_reachable %}<span class="badge badge-ok">SSH ok</span>{% else %}<span class="badge badge-off">SSH ?</span>{% endif %}
</strong>
<div class="actions"> <div class="actions">
<form method="post" action="/admin/servers/{{ s.id }}/check-ssh" class="inline-form">
<button class="btn btn-sm btn-ghost" type="submit">Проверить SSH</button>
</form>
<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('Удалить сервер и всех клиентов?')"> <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> <button class="btn btn-sm btn-danger" type="submit">Удалить</button>
@@ -85,16 +100,16 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<p class="muted" style="margin-top:0"> <p class="muted" style="margin-top:0">
Интерфейс <code>{{ s.interface_name }}</code> · подсеть <code>{{ s.subnet }}</code> · клиентов: {{ s.clients|length }} {{ s.ssh_username or '—' }}@{{ s.ssh_host or '—' }}:{{ s.ssh_port or 22 }}
{% if s.ssh_host %} · VPN {{ s.public_host }}:{{ s.public_port }}
· SSH · {{ s.interface_name }} / {{ s.subnet }}
{% if s.ssh_private_key %}<span class="badge badge-ok">ключ</span>{% endif %} · клиентов: {{ s.clients|length }}
{% if s.ssh_password %}<span class="badge badge-ok">пароль</span>{% endif %} {% if probe.get('latency_ms') is not none %}· ping {{ probe.get('latency_ms') }} ms{% endif %}
{% else %}
· <span class="badge badge-off">SSH не задан</span>
{% endif %}
</p> </p>
<p><code>Public key:</code> <span class="muted">{{ s.server_public_key }}</span></p>
{% if s.ssh_server_info %}
<pre class="mono server-info">{{ s.ssh_server_info }}</pre>
{% endif %}
<form method="post" action="/admin/servers/{{ s.id }}" class="stack"> <form method="post" action="/admin/servers/{{ s.id }}" class="stack">
<div class="form-grid-2"> <div class="form-grid-2">
@@ -112,37 +127,33 @@
</label> </label>
</div> </div>
<h3 class="form-section">SSH подключение</h3> <h3 class="form-section">SSH доступ</h3>
<div class="form-grid-2"> <div class="form-grid-2">
<label>Host <label>Host
<input name="ssh_host" value="{{ s.ssh_host or '' }}" placeholder="1.2.3.4" /> <input name="ssh_host" value="{{ s.ssh_host or '' }}" required />
</label> </label>
<label>SSH порт <label>SSH порт
<input type="number" name="ssh_port" value="{{ s.ssh_port or 22 }}" /> <input type="number" name="ssh_port" value="{{ s.ssh_port or 22 }}" />
</label> </label>
<label>Логин <label>Логин
<input name="ssh_username" value="{{ s.ssh_username or '' }}" placeholder="root" /> <input name="ssh_username" value="{{ s.ssh_username or '' }}" required />
</label> </label>
<label>Новый пароль <label>Новый пароль
<input type="password" name="ssh_password" placeholder="{% if s.ssh_password %}сохранён — введите, чтобы заменить{% else %}не задан{% endif %}" autocomplete="new-password" /> <input type="password" name="ssh_password" placeholder="{% if s.ssh_password %}сохранён — введите новый{% else %}не задан{% endif %}" autocomplete="new-password" />
</label> </label>
</div> </div>
<label>Новый приватный ключ <label>Новый приватный ключ
<textarea name="ssh_private_key" rows="4" placeholder="{% if s.ssh_private_key %}ключ сохранён — вставьте новый, чтобы заменить{% else %}не задан{% endif %}"></textarea> <textarea name="ssh_private_key" rows="4" placeholder="{% if s.ssh_private_key %}ключ сохранён — вставьте новый{% else %}не задан{% endif %}"></textarea>
</label> </label>
<div class="actions"> <div class="actions">
<label class="check"> <label class="check"><input type="checkbox" name="clear_ssh_password" value="1" /> очистить пароль</label>
<input type="checkbox" name="clear_ssh_password" value="1" /> очистить пароль <label class="check"><input type="checkbox" name="clear_ssh_private_key" value="1" /> очистить ключ</label>
</label>
<label class="check">
<input type="checkbox" name="clear_ssh_private_key" value="1" /> очистить ключ
</label>
</div> </div>
<button class="btn btn-accent" type="submit">Сохранить</button> <button class="btn btn-accent" type="submit">Сохранить</button>
</form> </form>
</div> </div>
</div> </div>
{% else %} {% else %}
<p class="muted">Серверов пока нет — добавьте первый через форму выше.</p> <p class="muted">Серверов пока нет — подключите первый VPS через форму выше.</p>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
+1 -1
View File
@@ -12,4 +12,4 @@ itsdangerous==2.2.0
qrcode[pil]==8.0 qrcode[pil]==8.0
httpx==0.28.1 httpx==0.28.1
greenlet==3.1.1 greenlet==3.1.1
cryptography==44.0.0 paramiko==3.5.0