From 708b4b04dced81d783101030f2b80210a8846d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Tue, 28 Apr 2026 09:57:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20reverse=20SSH=20tunnel=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=80=D0=BE=D1=83=D1=82=D0=B5=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B1=D0=B5=D0=BB=D0=BE=D0=B3=D0=BE=20IP?= =?UTF-8?q?=20+=20README=20=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/config.py: VPS_SSH_HOST, VPS_SSH_PORT, VPS_SSH_USER, TUNNEL_PORT_START - server/.env.example: секция тоннеля - server/main.py: _gen_ed25519_keypair, _add_pubkey_to_authorized_keys; _ssh_on_router и _push_one_router поддерживают tunnel_port; эндпоинты tunnel-cmd, tunnel-script (одноразовый токен 10 мин), tunnel-status, DELETE tunnel - index.html: кнопка ⇄ у каждого роутера + подсветка тоннельного порта, кнопка ⇄ Тоннель в форме добавления, модалка с curl-командой, копирование (clipboard + execCommand fallback), проверка связи - README: полная переработка — туннель, логины/пароли, ошибки, советы Co-Authored-By: Claude Sonnet 4.6 --- server/.env.example | 7 + server/config.py | 6 + server/main.py | 274 +++++++++++++++++++++++++++++++++--- server/templates/index.html | 80 ++++++++++- 4 files changed, 346 insertions(+), 21 deletions(-) diff --git a/server/.env.example b/server/.env.example index 05fd5d0..02b4058 100644 --- a/server/.env.example +++ b/server/.env.example @@ -7,3 +7,10 @@ ADMIN_PASSWORD=ВАШ_НАДЁЖНЫЙ_ПАРОЛЬ_ВЕБ SSH_USER=root SSH_PASS=ПАРОЛЬ_SSH_РОУТЕРА + +# Reverse SSH tunnel — для роутеров без белого IP +# Если VPS_SSH_HOST задан — в UI появляется кнопка «⇄ Тоннель» у каждого роутера +VPS_SSH_HOST= +VPS_SSH_PORT=22 +VPS_SSH_USER=root +TUNNEL_PORT_START=20100 diff --git a/server/config.py b/server/config.py index 76c2759..eef6eb4 100644 --- a/server/config.py +++ b/server/config.py @@ -14,6 +14,12 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") SSH_USER = os.getenv("SSH_USER", "root") SSH_PASS = os.getenv("SSH_PASS", "keenetic") +# Reverse SSH tunnel — для роутеров без белого IP +VPS_SSH_HOST = (os.getenv("VPS_SSH_HOST") or "").strip() +VPS_SSH_PORT = int(os.getenv("VPS_SSH_PORT") or "22") +VPS_SSH_USER = (os.getenv("VPS_SSH_USER") or "root").strip() +TUNNEL_PORT_START = int(os.getenv("TUNNEL_PORT_START") or "20100") + HYDRA_FILE = DATA_DIR / "hydra_config.json" ROUTERS_FILE = DATA_DIR / "routers.json" diff --git a/server/main.py b/server/main.py index 8bb6ef1..b198202 100644 --- a/server/main.py +++ b/server/main.py @@ -1,11 +1,17 @@ """HydraRoute Domain Manager — standalone web server.""" import asyncio import logging +import os +import secrets +import socket import subprocess +import tempfile +import time +from pathlib import Path from urllib.parse import urlparse from fastapi import FastAPI, Request, Header, HTTPException -from fastapi.responses import HTMLResponse, Response +from fastapi.responses import HTMLResponse, PlainTextResponse, Response from pydantic import BaseModel from . import config from .database import load_json, save_json @@ -46,10 +52,17 @@ def _normalize_router_ip(value: str) -> str: async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tuple[int, str, str]: - """Выполнить команду на роутере по SSH. Возвращает (код, stdout, stderr).""" - ip = (rcfg.get("ip") or "").strip() - if not ip: - return 1, "", "нет IP (нужен SSH: IP или hostname без https://)" + """Выполнить команду на роутере по SSH (прямо или через тоннель). Возвращает (код, stdout, stderr).""" + tunnel_port = rcfg.get("tunnel_port") + if tunnel_port: + ssh_host = "127.0.0.1" + extra_args = ["-p", str(int(tunnel_port))] + else: + ip = (rcfg.get("ip") or "").strip() + if not ip: + return 1, "", "нет IP и нет тоннеля (добавь IP или настрой тоннель)" + ssh_host = ip + extra_args = [] user = rcfg.get("user") or config.SSH_USER pwd = rcfg.get("password") or config.SSH_PASS try: @@ -58,7 +71,8 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl [ "sshpass", "-p", pwd, "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12", - f"{user}@{ip}", remote_cmd, + *extra_args, + f"{user}@{ssh_host}", remote_cmd, ], capture_output=True, text=True, @@ -70,10 +84,11 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict: - """Скачать с manager domain.conf + ip.list на роутер по SSH и neo restart.""" + """Скачать с manager domain.conf + ip.list на роутер по SSH (прямо или через тоннель) и neo restart.""" ip = rcfg.get("ip", "") - if not ip: - return {"router": router_key, "ok": False, "msg": "нет IP"} + tunnel_port = rcfg.get("tunnel_port") + if not ip and not tunnel_port: + return {"router": router_key, "ok": False, "msg": "нет IP и нет тоннеля"} user = rcfg.get("user") or config.SSH_USER pwd = rcfg.get("password") or config.SSH_PASS cmd = ( @@ -81,18 +96,16 @@ async def _push_one_router(server_url: str, router_key: str, rcfg: dict) -> dict f"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && " f"neo restart" ) + if tunnel_port: + ssh_cmd = ["sshpass", "-p", pwd, "ssh", + "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", + "-p", str(int(tunnel_port)), f"{user}@127.0.0.1", cmd] + else: + ssh_cmd = ["sshpass", "-p", pwd, "ssh", + "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", + f"{user}@{ip}", cmd] try: - r = await asyncio.to_thread( - subprocess.run, - [ - "sshpass", "-p", pwd, "ssh", - "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", - f"{user}@{ip}", cmd, - ], - capture_output=True, - text=True, - timeout=60, - ) + r = await asyncio.to_thread(subprocess.run, ssh_cmd, capture_output=True, text=True, timeout=60) tail = ((r.stdout or "") + (r.stderr or ""))[:400] return { "router": router_key, @@ -283,3 +296,224 @@ async def delete_router(name: str, x_admin_password: str = Header("")): R.pop(_router_key(name), None) save_json(config.ROUTERS_FILE, R) return {"ok": True} + + +# ── Tunnel helpers ──────────────────────────────────────────────────────────── + +def _gen_ed25519_keypair(name: str) -> tuple[str, str]: + """Генерировать ed25519 keypair на VPS через ssh-keygen. Возвращает (private_pem, public_openssh).""" + with tempfile.TemporaryDirectory() as tmpdir: + keyfile = os.path.join(tmpdir, "k") + subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-f", keyfile, "-N", "", "-q", + "-C", f"hydra-tunnel-{name}"], + check=True, timeout=10, + ) + priv = Path(keyfile).read_text() + pub = Path(keyfile + ".pub").read_text().strip() + return priv, pub + + +def _add_pubkey_to_authorized_keys(name: str, pubkey: str) -> None: + """Добавить pubkey в ~/.ssh/authorized_keys (де-дуп по комменту hydra-tunnel-{name}).""" + auth_dir = Path.home() / ".ssh" + auth_dir.mkdir(mode=0o700, exist_ok=True) + auth_path = auth_dir / "authorized_keys" + comment = f"hydra-tunnel-{name}" + lines: list[str] = [] + if auth_path.exists(): + lines = [l for l in auth_path.read_text().splitlines() if comment not in l and l.strip()] + lines.append(pubkey) + auth_path.write_text("\n".join(lines) + "\n") + auth_path.chmod(0o600) + try: + auth_dir.chmod(0o700) + except OSError: + pass + + +# ── Tunnel endpoints ────────────────────────────────────────────────────────── + +@app.get("/api/routers/{name}/tunnel-cmd") +async def tunnel_cmd(name: str, x_admin_password: str = Header("")): + """Назначить порт, сгенерить keypair, вернуть curl|sh команду для роутера.""" + _chk(x_admin_password) + if not config.VPS_SSH_HOST: + raise HTTPException(400, "VPS_SSH_HOST не задан в server/.env — укажи публичный IP/домен VPS") + + key = _router_key(name) + R = load_json(config.ROUTERS_FILE, {}) + if key not in R: + raise HTTPException(404, "Роутер не найден") + rcfg = dict(R[key]) + + # Порт + if rcfg.get("tunnel_port"): + port = int(rcfg["tunnel_port"]) + else: + used = {int(v.get("tunnel_port")) for v in R.values() if v.get("tunnel_port")} + port = config.TUNNEL_PORT_START + while port in used: + port += 1 + rcfg["tunnel_port"] = port + + # Keypair (один раз на роутер) + if not rcfg.get("tunnel_priv_key") or not rcfg.get("tunnel_pub_key"): + try: + priv, pub = await asyncio.to_thread(_gen_ed25519_keypair, name) + except FileNotFoundError as e: + raise HTTPException(500, "ssh-keygen не найден на VPS — установи openssh-client") from e + except subprocess.CalledProcessError as e: + raise HTTPException(500, f"ssh-keygen упал: {e}") from e + rcfg["tunnel_priv_key"] = priv + rcfg["tunnel_pub_key"] = pub + await asyncio.to_thread(_add_pubkey_to_authorized_keys, name, pub) + + # Одноразовый токен (10 мин) + reg_token = secrets.token_urlsafe(32) + rcfg["tunnel_reg_token"] = reg_token + rcfg["tunnel_reg_token_exp"] = int(time.time()) + 600 + + R[key] = rcfg + save_json(config.ROUTERS_FILE, R) + + http_url = f"http://{config.VPS_SSH_HOST}:{config.PORT}" + one_liner = f"curl -fsS '{http_url}/api/routers/{name}/tunnel-script?token={reg_token}' | sh" + + return {"tunnel_port": port, "cmd": one_liner} + + +@app.get("/api/routers/{name}/tunnel-script") +async def tunnel_script(name: str, token: str): + """Установочный скрипт для роутера (с приватным ключом внутри). Auth — одноразовый токен.""" + key = _router_key(name) + R = load_json(config.ROUTERS_FILE, {}) + if key not in R: + raise HTTPException(404, "Роутер не найден") + rcfg = dict(R[key]) + + saved = rcfg.get("tunnel_reg_token") + if not saved or not secrets.compare_digest(saved, token or ""): + raise HTTPException(403, "Неверный или израсходованный токен") + if time.time() > int(rcfg.get("tunnel_reg_token_exp") or 0): + raise HTTPException(403, "Токен истёк (10 мин). Открой модалку заново.") + if not rcfg.get("tunnel_priv_key") or not rcfg.get("tunnel_port"): + raise HTTPException(500, "Тоннель не подготовлен — открой модалку заново") + + # Токен одноразовый — расходуем + rcfg.pop("tunnel_reg_token", None) + rcfg.pop("tunnel_reg_token_exp", None) + R[key] = rcfg + save_json(config.ROUTERS_FILE, R) + + port = int(rcfg["tunnel_port"]) + priv_key = rcfg["tunnel_priv_key"].strip() + vps_host = config.VPS_SSH_HOST + vps_port = config.VPS_SSH_PORT + vps_user = config.VPS_SSH_USER + + script = f"""#!/bin/sh +set -e +export PATH="/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH" + +echo '[1/4] autossh...' +opkg install autossh openssh-client 2>/dev/null || true +command -v autossh >/dev/null 2>&1 || {{ echo 'ОШИБКА: autossh не установлен. Запусти opkg update и повтори.'; exit 1; }} + +echo '[2/4] Приватный ключ...' +mkdir -p /opt/etc +cat > /opt/etc/hydra_tk <<'KEYEOF' +{priv_key} +KEYEOF +chmod 600 /opt/etc/hydra_tk + +echo '[3/4] Скрипт тоннеля + автозапуск...' +cat > /opt/bin/hydra_tun <<'RUNEOF' +#!/bin/sh +PATH="/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH" +export AUTOSSH_GATETIME=0 +export AUTOSSH_LOGFILE=/tmp/hydra_tun.log +exec autossh -M 0 \\ + -i /opt/etc/hydra_tk \\ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\ + -o ExitOnForwardFailure=yes -o IdentitiesOnly=yes \\ + -N -R {port}:localhost:22 {vps_user}@{vps_host} -p {vps_port} +RUNEOF +chmod +x /opt/bin/hydra_tun + +cat > /opt/etc/init.d/S99hydra_tun <<'INITEOF' +#!/bin/sh +case "$1" in + start) killall -0 autossh 2>/dev/null || ( nohup /opt/bin/hydra_tun /dev/null 2>&1 & ) ;; + stop) killall autossh 2>/dev/null ;; + restart) killall autossh 2>/dev/null; sleep 1; ( nohup /opt/bin/hydra_tun /dev/null 2>&1 & ) ;; +esac +INITEOF +chmod +x /opt/etc/init.d/S99hydra_tun + +echo '[4/4] Запуск...' +killall autossh 2>/dev/null || true +sleep 1 +rm -f /tmp/hydra_tun.log +if command -v setsid >/dev/null 2>&1; then + setsid /opt/bin/hydra_tun /dev/null 2>&1 & +else + ( nohup /opt/bin/hydra_tun /dev/null 2>&1 & ) +fi +sleep 5 +if killall -0 autossh 2>/dev/null; then + echo + echo '=== OK ===' + echo 'Тоннель: localhost:22 (роутер) -> VPS:{port}' + echo 'Лог: /tmp/hydra_tun.log' + echo 'Возвращайся в браузер и жми "Проверить связь"' +else + echo + echo '=== ОШИБКА: autossh не запустился в фоне ===' + echo '--- /tmp/hydra_tun.log ---' + cat /tmp/hydra_tun.log 2>/dev/null || echo '(лог пустой)' + echo '-------------------------' + echo 'Тест вручную: /opt/bin/hydra_tun (Ctrl+C для выхода)' + exit 1 +fi +""" + return PlainTextResponse(script, media_type="text/plain; charset=utf-8") + + +@app.get("/api/routers/{name}/tunnel-status") +async def tunnel_status(name: str, x_admin_password: str = Header("")): + """Проверить: слушает ли тоннельный порт на localhost VPS.""" + _chk(x_admin_password) + key = _router_key(name) + R = load_json(config.ROUTERS_FILE, {}) + if key not in R: + raise HTTPException(404, "Роутер не найден") + port = R[key].get("tunnel_port") + if not port: + return {"active": False, "reason": "tunnel_port не назначен"} + + def _check() -> bool: + try: + with socket.create_connection(("127.0.0.1", int(port)), timeout=2): + return True + except OSError: + return False + + active = await asyncio.to_thread(_check) + return {"active": active, "tunnel_port": port} + + +@app.delete("/api/routers/{name}/tunnel") +async def tunnel_remove(name: str, x_admin_password: str = Header("")): + """Снять тоннельный порт с роутера.""" + _chk(x_admin_password) + key = _router_key(name) + R = load_json(config.ROUTERS_FILE, {}) + if key not in R: + raise HTTPException(404, "Роутер не найден") + rcfg = dict(R[key]) + rcfg.pop("tunnel_port", None) + R[key] = rcfg + save_json(config.ROUTERS_FILE, R) + return {"ok": True} diff --git a/server/templates/index.html b/server/templates/index.html index fab4548..6ea39e2 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -208,6 +208,7 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)} +
@@ -218,6 +219,27 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)} + + +