Amnezia-style SSH server connect with verify, ping and server_info
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from app.services import crypto, vpn
|
from app.services import crypto, vpn, ssh
|
||||||
|
|
||||||
__all__ = ["crypto", "vpn"]
|
__all__ = ["crypto", "vpn", "ssh"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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----- ... -----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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user