mirror of
https://github.com/andrey271192/domen_hydra.git
synced 2026-09-20 14:42:00 +00:00
feat: reverse SSH tunnel для роутеров без белого IP + README переработан
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,10 @@ ADMIN_PASSWORD=ВАШ_НАДЁЖНЫЙ_ПАРОЛЬ_ВЕБ
|
|||||||
|
|
||||||
SSH_USER=root
|
SSH_USER=root
|
||||||
SSH_PASS=ПАРОЛЬ_SSH_РОУТЕРА
|
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
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
|
|||||||
SSH_USER = os.getenv("SSH_USER", "root")
|
SSH_USER = os.getenv("SSH_USER", "root")
|
||||||
SSH_PASS = os.getenv("SSH_PASS", "keenetic")
|
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"
|
HYDRA_FILE = DATA_DIR / "hydra_config.json"
|
||||||
ROUTERS_FILE = DATA_DIR / "routers.json"
|
ROUTERS_FILE = DATA_DIR / "routers.json"
|
||||||
|
|
||||||
|
|||||||
274
server/main.py
274
server/main.py
@@ -1,11 +1,17 @@
|
|||||||
"""HydraRoute Domain Manager — standalone web server."""
|
"""HydraRoute Domain Manager — standalone web server."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Header, HTTPException
|
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 pydantic import BaseModel
|
||||||
from . import config
|
from . import config
|
||||||
from .database import load_json, save_json
|
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]:
|
async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tuple[int, str, str]:
|
||||||
"""Выполнить команду на роутере по SSH. Возвращает (код, stdout, stderr)."""
|
"""Выполнить команду на роутере по SSH (прямо или через тоннель). Возвращает (код, stdout, stderr)."""
|
||||||
ip = (rcfg.get("ip") or "").strip()
|
tunnel_port = rcfg.get("tunnel_port")
|
||||||
if not ip:
|
if tunnel_port:
|
||||||
return 1, "", "нет IP (нужен SSH: IP или hostname без https://)"
|
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
|
user = rcfg.get("user") or config.SSH_USER
|
||||||
pwd = rcfg.get("password") or config.SSH_PASS
|
pwd = rcfg.get("password") or config.SSH_PASS
|
||||||
try:
|
try:
|
||||||
@@ -58,7 +71,8 @@ async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tupl
|
|||||||
[
|
[
|
||||||
"sshpass", "-p", pwd,
|
"sshpass", "-p", pwd,
|
||||||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12",
|
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12",
|
||||||
f"{user}@{ip}", remote_cmd,
|
*extra_args,
|
||||||
|
f"{user}@{ssh_host}", remote_cmd,
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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:
|
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", "")
|
ip = rcfg.get("ip", "")
|
||||||
if not ip:
|
tunnel_port = rcfg.get("tunnel_port")
|
||||||
return {"router": router_key, "ok": False, "msg": "нет IP"}
|
if not ip and not tunnel_port:
|
||||||
|
return {"router": router_key, "ok": False, "msg": "нет IP и нет тоннеля"}
|
||||||
user = rcfg.get("user") or config.SSH_USER
|
user = rcfg.get("user") or config.SSH_USER
|
||||||
pwd = rcfg.get("password") or config.SSH_PASS
|
pwd = rcfg.get("password") or config.SSH_PASS
|
||||||
cmd = (
|
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"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && "
|
||||||
f"neo restart"
|
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:
|
try:
|
||||||
r = await asyncio.to_thread(
|
r = await asyncio.to_thread(subprocess.run, ssh_cmd, capture_output=True, text=True, timeout=60)
|
||||||
subprocess.run,
|
|
||||||
[
|
|
||||||
"sshpass", "-p", pwd, "ssh",
|
|
||||||
"-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
|
||||||
f"{user}@{ip}", cmd,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
tail = ((r.stdout or "") + (r.stderr or ""))[:400]
|
tail = ((r.stdout or "") + (r.stderr or ""))[:400]
|
||||||
return {
|
return {
|
||||||
"router": router_key,
|
"router": router_key,
|
||||||
@@ -283,3 +296,224 @@ async def delete_router(name: str, x_admin_password: str = Header("")):
|
|||||||
R.pop(_router_key(name), None)
|
R.pop(_router_key(name), None)
|
||||||
save_json(config.ROUTERS_FILE, R)
|
save_json(config.ROUTERS_FILE, R)
|
||||||
return {"ok": True}
|
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 >/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 >/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 >/dev/null 2>&1 &
|
||||||
|
else
|
||||||
|
( nohup /opt/bin/hydra_tun </dev/null >/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}
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
|
|||||||
<input type="text" id="r-user" value="root" style="width:80px">
|
<input type="text" id="r-user" value="root" style="width:80px">
|
||||||
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
|
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
|
||||||
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button>
|
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button>
|
||||||
|
<button class="btn" style="background:var(--orange);color:#000" onclick="addAndTunnel()" title="Добавить роутер без IP и сразу открыть настройку тоннеля">⇄ Тоннель</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="rm" class="msg"></div>
|
<div id="rm" class="msg"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,6 +219,27 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TUNNEL MODAL -->
|
||||||
|
<div id="tun-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center">
|
||||||
|
<div style="background:var(--card);border-radius:18px;padding:24px;max-width:600px;width:94%;position:relative">
|
||||||
|
<button onclick="closeTunnel()" style="position:absolute;top:14px;right:16px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer">✕</button>
|
||||||
|
<h2 style="font-size:16px;font-weight:700;margin-bottom:4px">⇄ Обратный тоннель</h2>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-bottom:16px">Роутер: <b id="tun-rname"></b> · Порт VPS: <span id="tun-port" style="color:var(--green)">…</span></div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Скопируй команду и вставь в SSH-терминал <b>роутера</b> (там где <code style="background:var(--card2);padding:1px 4px;border-radius:4px">~ #</code>):</div>
|
||||||
|
<div style="position:relative;margin-bottom:12px">
|
||||||
|
<code id="tun-cmd" style="display:block;background:var(--card2);border-radius:10px;padding:12px;font-size:11px;word-break:break-all;min-height:40px;color:var(--text)">Генерирую...</code>
|
||||||
|
<button id="tun-copy-btn" onclick="copyTunnelCmd()" style="position:absolute;top:8px;right:8px;background:var(--accent);color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer">📋 Копировать</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="btn btn-b" onclick="checkTunnelStatus()" style="font-size:12px">⟳ Проверить связь</button>
|
||||||
|
<span id="tun-status" style="font-size:12px;color:var(--muted)"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px;font-size:11px;color:var(--muted)">
|
||||||
|
Если автозапуск не сработал — на роутере: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">/opt/bin/hydra_tun</code> (вручную) · лог: <code style="background:var(--card2);padding:1px 4px;border-radius:4px">cat /tmp/hydra_tun.log</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SETTINGS TAB -->
|
<!-- SETTINGS TAB -->
|
||||||
<div id="tab-settings" style="display:none">
|
<div id="tab-settings" style="display:none">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -363,6 +385,7 @@ function _escHtml(s){ return String(s==null?'':s).replace(/&/g,'&').replace(
|
|||||||
if(act==='test') await testRouter(n);
|
if(act==='test') await testRouter(n);
|
||||||
else if(act==='pull') await pullRouter(n);
|
else if(act==='pull') await pullRouter(n);
|
||||||
else if(act==='push') await pushRouter(n);
|
else if(act==='push') await pushRouter(n);
|
||||||
|
else if(act==='tunnel') openTunnel(n);
|
||||||
else if(act==='del') await delRouter(n);
|
else if(act==='del') await delRouter(n);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -373,16 +396,25 @@ async function loadRouters(){
|
|||||||
el.innerHTML=Object.entries(R).map(([n,c])=>{
|
el.innerHTML=Object.entries(R).map(([n,c])=>{
|
||||||
const enc=encodeURIComponent(n);
|
const enc=encodeURIComponent(n);
|
||||||
return `<div class="ri" style="align-items:flex-start;gap:10px">
|
return `<div class="ri" style="align-items:flex-start;gap:10px">
|
||||||
<div style="flex:1;min-width:0"><span class="n">${_escHtml(n)}</span> <span style="color:var(--muted);font-size:12px">— ${_escHtml(c.ip||'—')}</span></div>
|
<div style="flex:1;min-width:0"><span class="n">${_escHtml(n)}</span> <span style="font-size:12px">${c.tunnel_port?`<span style="color:var(--green)">⇄ тоннель :${c.tunnel_port}</span>`:`<span style="color:var(--muted)">— ${_escHtml(c.ip||'—')}</span>`}</span></div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end">
|
<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end">
|
||||||
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:5px 10px" data-r-act="test" data-router="${enc}">🔌 Тест</button>
|
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:5px 10px" data-r-act="test" data-router="${enc}">🔌 Тест</button>
|
||||||
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" data-r-act="pull" data-router="${enc}">⬇ С роутера</button>
|
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" data-r-act="pull" data-router="${enc}">⬇ С роутера</button>
|
||||||
<button type="button" class="btn btn-g" style="font-size:11px;padding:5px 10px" data-r-act="push" data-router="${enc}">📡 На роутер</button>
|
<button type="button" class="btn btn-g" style="font-size:11px;padding:5px 10px" data-r-act="push" data-router="${enc}">📡 На роутер</button>
|
||||||
|
<button type="button" class="btn" style="background:var(--orange);color:#000;font-size:11px;padding:5px 10px" data-r-act="tunnel" data-router="${enc}">⇄</button>
|
||||||
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" data-r-act="del" data-router="${enc}">✗</button>
|
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" data-r-act="del" data-router="${enc}">✗</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
async function addRouter(){ const n=document.getElementById('r-name').value.trim(); if(!n)return; const b={ip:document.getElementById('r-ip').value.trim(),user:document.getElementById('r-user').value||'root',password:document.getElementById('r-pass').value}; try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); sm('rm','ok','✅ '+n); loadRouters(); }catch(e){sm('rm','err','❌ '+e)} }
|
async function addRouter(){ const n=document.getElementById('r-name').value.trim(); if(!n)return; const b={ip:document.getElementById('r-ip').value.trim(),user:document.getElementById('r-user').value||'root',password:document.getElementById('r-pass').value}; try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); sm('rm','ok','✅ '+n); loadRouters(); }catch(e){sm('rm','err','❌ '+e)} }
|
||||||
|
async function addAndTunnel(){
|
||||||
|
const n=document.getElementById('r-name').value.trim();
|
||||||
|
if(!n){sm('rm','err','❌ Введи имя роутера');return;}
|
||||||
|
const pwd=document.getElementById('r-pass').value;
|
||||||
|
if(!pwd){sm('rm','err','❌ Введи SSH пароль роутера');return;}
|
||||||
|
const b={ip:'',user:document.getElementById('r-user').value||'root',password:pwd};
|
||||||
|
try{ await fetch('/api/routers/'+encodeURIComponent(n),{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); await loadRouters(); openTunnel(n); }catch(e){sm('rm','err','❌ '+e)}
|
||||||
|
}
|
||||||
async function delRouter(n){ if(!confirm('Удалить '+n+'?'))return; await fetch('/api/routers/'+encodeURIComponent(n),{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
|
async function delRouter(n){ if(!confirm('Удалить '+n+'?'))return; await fetch('/api/routers/'+encodeURIComponent(n),{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
|
||||||
async function testRouter(n){
|
async function testRouter(n){
|
||||||
const log=document.getElementById('router-ops-msg');
|
const log=document.getElementById('router-ops-msg');
|
||||||
@@ -419,6 +451,52 @@ async function pushRouter(n){
|
|||||||
}catch(e){ log.textContent='❌ '+e; }
|
}catch(e){ log.textContent='❌ '+e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TUNNEL ────────────────────────────────────────────────────────────────
|
||||||
|
let _tunName='';
|
||||||
|
function openTunnel(name){
|
||||||
|
_tunName=name;
|
||||||
|
document.getElementById('tun-rname').textContent=name;
|
||||||
|
document.getElementById('tun-port').textContent='…';
|
||||||
|
document.getElementById('tun-cmd').textContent='Генерирую команду...';
|
||||||
|
document.getElementById('tun-status').textContent='';
|
||||||
|
document.getElementById('tun-copy-btn').textContent='📋 Копировать';
|
||||||
|
document.getElementById('tun-modal').style.display='flex';
|
||||||
|
getTunnelCmd(name);
|
||||||
|
}
|
||||||
|
function closeTunnel(){ document.getElementById('tun-modal').style.display='none'; }
|
||||||
|
async function getTunnelCmd(name){
|
||||||
|
const el=document.getElementById('tun-cmd');
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/routers/'+encodeURIComponent(name)+'/tunnel-cmd',{headers:{'X-Admin-Password':getPass()}});
|
||||||
|
const j=await r.json();
|
||||||
|
if(!r.ok){el.textContent='❌ '+(j.detail||'Ошибка'); return;}
|
||||||
|
el.textContent=j.cmd;
|
||||||
|
document.getElementById('tun-port').textContent=j.tunnel_port;
|
||||||
|
}catch(e){el.textContent='❌ '+e;}
|
||||||
|
}
|
||||||
|
function copyTunnelCmd(){
|
||||||
|
const el=document.getElementById('tun-cmd');
|
||||||
|
const btn=document.getElementById('tun-copy-btn');
|
||||||
|
const text=el.textContent;
|
||||||
|
const done=()=>{btn.textContent='✓ Скопировано'; setTimeout(()=>btn.textContent='📋 Копировать',2000);};
|
||||||
|
const fail=()=>{
|
||||||
|
const range=document.createRange(); range.selectNodeContents(el);
|
||||||
|
const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
|
||||||
|
try{document.execCommand('copy');done();}catch{btn.textContent='Выдели текст → Cmd/Ctrl+C';}
|
||||||
|
};
|
||||||
|
if(navigator.clipboard){navigator.clipboard.writeText(text).then(done).catch(fail);}else{fail();}
|
||||||
|
}
|
||||||
|
async function checkTunnelStatus(){
|
||||||
|
const el=document.getElementById('tun-status');
|
||||||
|
el.textContent='Проверяю...'; el.style.color='var(--muted)';
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/routers/'+encodeURIComponent(_tunName)+'/tunnel-status',{headers:{'X-Admin-Password':getPass()}});
|
||||||
|
const j=await r.json();
|
||||||
|
el.textContent=j.active?'✅ Активен (порт '+j.tunnel_port+')':'❌ Не активен';
|
||||||
|
el.style.color=j.active?'var(--green)':'var(--red)';
|
||||||
|
}catch(e){el.textContent='❌ '+e; el.style.color='var(--red)';}
|
||||||
|
}
|
||||||
|
|
||||||
// ── SETTINGS ──────────────────────────────────────────────────────────────
|
// ── SETTINGS ──────────────────────────────────────────────────────────────
|
||||||
async function changePwd(){ const p1=document.getElementById('new-pwd').value; const p2=document.getElementById('new-pwd2').value; if(p1!==p2){sm('pwd-msg','err','❌ Пароли не совпадают');return;} if(p1.length<4){sm('pwd-msg','err','❌ Минимум 4 символа');return;} try{ const r=await fetch('/api/set_password',{method:'POST',headers:authHdr(),body:JSON.stringify({password:p1})}); if(r.ok){ sessionStorage.setItem('hm_pass',p1); sm('pwd-msg','ok','✅ Пароль сохранён'); document.getElementById('new-pwd').value=''; document.getElementById('new-pwd2').value=''; } }catch(e){sm('pwd-msg','err','❌ '+e)} }
|
async function changePwd(){ const p1=document.getElementById('new-pwd').value; const p2=document.getElementById('new-pwd2').value; if(p1!==p2){sm('pwd-msg','err','❌ Пароли не совпадают');return;} if(p1.length<4){sm('pwd-msg','err','❌ Минимум 4 символа');return;} try{ const r=await fetch('/api/set_password',{method:'POST',headers:authHdr(),body:JSON.stringify({password:p1})}); if(r.ok){ sessionStorage.setItem('hm_pass',p1); sm('pwd-msg','ok','✅ Пароль сохранён'); document.getElementById('new-pwd').value=''; document.getElementById('new-pwd2').value=''; } }catch(e){sm('pwd-msg','err','❌ '+e)} }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user