diff --git a/alembic/versions/0003_ssh_status.py b/alembic/versions/0003_ssh_status.py new file mode 100644 index 0000000..d20d8db --- /dev/null +++ b/alembic/versions/0003_ssh_status.py @@ -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") diff --git a/app/models.py b/app/models.py index 0ddb0f4..1b43055 100644 --- a/app/models.py +++ b/app/models.py @@ -47,6 +47,9 @@ class VpnServer(Base): 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) + 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) jc: Mapped[int | None] = mapped_column(Integer, nullable=True) jmin: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/app/routers/admin.py b/app/routers/admin.py index 8d31f43..c175a2d 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -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) ) ).scalars().all() + probes = await vpn_service.probe_servers(list(servers)) return request.app.state.templates.TemplateResponse( "admin/servers.html", { "request": request, "admin": admin, "servers": servers, + "probes": probes, "protocols": Protocol, "app_name": request.app.state.settings.app_name, "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) +@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") async def delete_server( server_id: int, diff --git a/app/services/__init__.py b/app/services/__init__.py index cd7a1f4..4caf32d 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,3 +1,3 @@ -from app.services import crypto, vpn +from app.services import crypto, vpn, ssh -__all__ = ["crypto", "vpn"] +__all__ = ["crypto", "vpn", "ssh"] diff --git a/app/services/ssh.py b/app/services/ssh.py new file mode 100644 index 0000000..e283309 --- /dev/null +++ b/app/services/ssh.py @@ -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() diff --git a/app/services/vpn.py b/app/services/vpn.py index 4e0dfd2..9287d55 100644 --- a/app/services/vpn.py +++ b/app/services/vpn.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +from datetime import datetime, timezone from pathlib import Path from sqlalchemy import func, select @@ -16,6 +18,7 @@ from app.services.crypto import ( render_server_config, server_address, ) +from app.services.ssh import probe_tcp, verify_ssh async def ensure_default_servers(session: AsyncSession) -> None: @@ -137,6 +140,14 @@ async def create_server( if not username: 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) keys = generate_keypair() # Unique interface per server id is assigned after insert; use temp then update @@ -150,6 +161,7 @@ async def create_server( raise ValueError("Слишком много серверов для авто-подсети") subnet = f"10.{octet}.0.0/24" + now = datetime.now(timezone.utc) server = VpnServer( name=name.strip() or host, protocol=protocol, @@ -165,6 +177,9 @@ async def create_server( ssh_username=username, ssh_password=password, ssh_private_key=private_key, + ssh_server_info=server_info, + ssh_reachable=True, + ssh_last_checked=now, jc=defaults["jc"], jmin=defaults["jmin"], jmax=defaults["jmax"], @@ -183,6 +198,52 @@ async def create_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( session: AsyncSession, server_id: int, diff --git a/app/static/css/app.css b/app/static/css/app.css index 366d5ca..09a5d4f 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -243,6 +243,21 @@ tr:last-child td { border-bottom: none; } font-size: 0.88rem; } .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 { width: 100%; border: 1px solid var(--line); diff --git a/app/templates/admin/servers.html b/app/templates/admin/servers.html index 4f2ed4f..3fc7e4f 100644 --- a/app/templates/admin/servers.html +++ b/app/templates/admin/servers.html @@ -9,53 +9,59 @@ {% if flash.startswith('error:') %}
+ Как в Amnezia-Web-Panel: укажите IP/домен, логин и пароль или ключ. + Панель сначала проверит SSH, затем сохранит сервер. +
- Интерфейс {{ s.interface_name }} · подсеть {{ s.subnet }} · клиентов: {{ s.clients|length }}
- {% if s.ssh_host %}
- · SSH
- {% if s.ssh_private_key %}ключ{% endif %}
- {% if s.ssh_password %}пароль{% endif %}
- {% else %}
- · SSH не задан
- {% endif %}
+ {{ s.ssh_username or '—' }}@{{ s.ssh_host or '—' }}:{{ s.ssh_port or 22 }}
+ · VPN {{ s.public_host }}:{{ s.public_port }}
+ · {{ s.interface_name }} / {{ s.subnet }}
+ · клиентов: {{ s.clients|length }}
+ {% if probe.get('latency_ms') is not none %}· ping {{ probe.get('latency_ms') }} ms{% endif %}
Public key: {{ s.server_public_key }}
{{ s.ssh_server_info }}
+ {% endif %}
Серверов пока нет — добавьте первый через форму выше.
+Серверов пока нет — подключите первый VPS через форму выше.
{% endfor %} {% endblock %} diff --git a/requirements.txt b/requirements.txt index 9ec57ae..5a4a35c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,4 @@ itsdangerous==2.2.0 qrcode[pil]==8.0 httpx==0.28.1 greenlet==3.1.1 -cryptography==44.0.0 +paramiko==3.5.0