From d48b7b2023e4e17879ed724964fa85aa0ba50e93 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: Mon, 27 Apr 2026 20:57:05 +0300 Subject: [PATCH] feat: reverse SSH tunnel for routers without public IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: «⇄ Тоннель» button next to «Тест» — opens modal with autossh install command for the router and an auto-applied localhost RCI URL. Backend: GET /api/routers/{id}/tunnel-cmd allocates a free port (starts at TUNNEL_PORT_START) and returns the shell command; DELETE /api/routers/{id}/tunnel clears the assignment. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 8 +++++ app/config.py | 7 ++++ app/main.py | 83 ++++++++++++++++++++++++++++++++++++++++++ app/models.py | 1 + templates/index.html | 86 ++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 5bf0d9d..ddc8669 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,11 @@ ADMIN_PASSWORD=change-me # Дефолт для Keenetic API, если у роутера не заданы поля логин/пароль и нет user:pass@ в URL KEENETIC_LOGIN=admin KEENETIC_PASSWORD= + +# Reverse SSH tunnel — для роутеров без белого IP +# Если эти поля заполнены, в UI появляется кнопка «⇄ Тоннель» рядом с «Тест» +VPS_SSH_HOST= +VPS_SSH_PORT=22 +VPS_SSH_USER=root +VPS_SSH_PASS= +TUNNEL_PORT_START=20100 diff --git a/app/config.py b/app/config.py index add05e1..26e6ceb 100644 --- a/app/config.py +++ b/app/config.py @@ -16,3 +16,10 @@ ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip() # Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым) KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip() KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").strip() + +# 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() +VPS_SSH_PASS = (os.getenv("VPS_SSH_PASS") or "").strip() +TUNNEL_PORT_START = int(os.getenv("TUNNEL_PORT_START") or "20100") diff --git a/app/main.py b/app/main.py index b7cc7c0..0f57d11 100644 --- a/app/main.py +++ b/app/main.py @@ -256,6 +256,89 @@ async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Hea return r +@app.get("/api/routers/{rid}/tunnel-cmd") +async def tunnel_cmd(rid: str, x_admin_password: str = Header("")): + """Назначить порт тоннеля и вернуть команду установки для роутера.""" + _chk(x_admin_password) + if not config.VPS_SSH_HOST: + raise HTTPException(400, "VPS_SSH_HOST не задан в .env — укажи публичный IP/домен VPS") + if not config.VPS_SSH_PASS: + raise HTTPException(400, "VPS_SSH_PASS не задан в .env — укажи пароль SSH для VPS") + + cur = load_store() + routers = list(cur.get("routers") or []) + idx = next((i for i, x in enumerate(routers) if x.get("id") == rid), -1) + if idx < 0: + raise HTTPException(404, "Роутер не найден") + + r = dict(routers[idx]) + + # Переиспользовать уже назначенный порт или выдать новый + if r.get("tunnel_port"): + port = int(r["tunnel_port"]) + else: + used = {int(x.get("tunnel_port")) for x in routers if x.get("tunnel_port")} + port = config.TUNNEL_PORT_START + while port in used: + port += 1 + r["tunnel_port"] = port + routers[idx] = r + cur["routers"] = routers + save_store(cur) + + vps_host = config.VPS_SSH_HOST + vps_port = config.VPS_SSH_PORT + vps_user = config.VPS_SSH_USER + vps_pass = config.VPS_SSH_PASS.replace("'", "'\\''") + + cmd = ( + f"export PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n\n" + f"# Установить зависимости\n" + f"opkg install autossh sshpass 2>/dev/null; true\n\n" + f"# Создать скрипт тоннеля\n" + f"cat > /opt/bin/kdns_tunnel.sh << 'ENDSCRIPT'\n" + f"#!/bin/sh\n" + f"PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n" + f"exec sshpass -p '{vps_pass}' autossh -M 0 \\\n" + f" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\\n" + f" -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\\n" + f" -N -R {port}:localhost:81 {vps_user}@{vps_host} -p {vps_port}\n" + f"ENDSCRIPT\n" + f"chmod +x /opt/bin/kdns_tunnel.sh\n\n" + f"# Добавить в cron (запуск если не работает, каждые 3 мин)\n" + f"(crontab -l 2>/dev/null | grep -v kdns_tunnel; " + f"echo '*/3 * * * * pgrep -f kdns_tunnel.sh || /opt/bin/kdns_tunnel.sh &') | crontab -\n\n" + f"# Запустить сейчас\n" + f"pkill -f kdns_tunnel.sh 2>/dev/null; sleep 1\n" + f"nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 &\n\n" + f"echo \"Тоннель запущен: порт 81 → VPS:{port}\"\n" + f"echo \"URL для платформы: http://localhost:{port}\"" + ) + + return { + "tunnel_port": port, + "rci_url": f"http://localhost:{port}", + "cmd": cmd, + } + + +@app.delete("/api/routers/{rid}/tunnel") +async def tunnel_remove(rid: str, x_admin_password: str = Header("")): + """Снять назначение тоннельного порта с роутера.""" + _chk(x_admin_password) + cur = load_store() + routers = list(cur.get("routers") or []) + idx = next((i for i, x in enumerate(routers) if x.get("id") == rid), -1) + if idx < 0: + raise HTTPException(404, "Роутер не найден") + r = dict(routers[idx]) + r.pop("tunnel_port", None) + routers[idx] = r + cur["routers"] = routers + save_store(cur) + return {"ok": True} + + @app.post("/api/test-router/{rid}") async def test_router(rid: str, x_admin_password: str = Header("")): _chk(x_admin_password) diff --git a/app/models.py b/app/models.py index 78a1181..5edb828 100644 --- a/app/models.py +++ b/app/models.py @@ -18,6 +18,7 @@ class RouterSpec(BaseModel): enabled: bool = True keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI") keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI") + tunnel_port: int | None = Field(default=None, description="Порт обратного SSH-тоннеля на VPS (None = тоннель не настроен)") class StoreData(BaseModel): diff --git a/templates/index.html b/templates/index.html index ef04733..9a9fdd1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,8 +38,12 @@ #auth-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:100;align-items:center;justify-content:center} #auth-bg.on{display:flex} #auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)} - #ifscan-modal,#edit-router-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px} - #ifscan-modal.on,#edit-router-modal.on{display:flex} + #ifscan-modal,#edit-router-modal,#tunnel-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px} + #ifscan-modal.on,#edit-router-modal.on,#tunnel-modal.on{display:flex} + #tunnel-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:680px;width:100%;padding:22px} + .tunnel-cmd{background:#111;border:1px solid var(--bd);border-radius:10px;padding:14px;font-family:ui-monospace,monospace;font-size:11px;line-height:1.6;color:#a3e635;white-space:pre;overflow-x:auto;max-height:260px;overflow-y:auto;margin:10px 0} + .tunnel-url{background:#111;border:1px solid #22c55e44;border-radius:8px;padding:10px 14px;font-family:ui-monospace,monospace;font-size:13px;color:var(--ok);margin:8px 0} + .btn-tunnel{background:#7c3aed;color:#fff} #ifscan-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:900px;width:100%;max-height:88vh;overflow:auto;padding:18px} #edit-router-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:520px;width:100%;padding:18px} .if-row{cursor:pointer} @@ -148,6 +152,22 @@ +
+

Обратный SSH-тоннель

+

Для роутеров без белого IP. Скопируй команду и выполни в SSH на роутере — он сам подключится к VPS и откроет порт.

+
Команда для SSH роутера:
+
+ +
После запуска тоннеля — новый RCI URL для этого роутера:
+
+
+ + + +
+ +
+

Интерфейсы с роутера (RCI)

Выбери роутер → «Загрузить». Строка = то, что вписывается в Interface ID для текущей вкладки (US или RU). Обычно для VPN — Wireguard0 / OpenVPN0, для провайдера — PPPoE0 / GigabitEthernet0 и т.п.

@@ -250,8 +270,9 @@ function paint(){ ${fromEnv?'из .env':esc(lo)} ${fromEnv?'из .env':(hasPw?'есть':'нет')} - + + `; }).join('')||'Нет роутеров'; @@ -388,6 +409,65 @@ async function applySel(){ await load(); } +let _tunnelRouterId=null; +async function tunnelR(id){ + _tunnelRouterId=id; + const msg=document.getElementById('tunnel-msg'); + msg.style.display='none'; + document.getElementById('tunnel-cmd-text').textContent='Загрузка…'; + document.getElementById('tunnel-url-text').textContent=''; + document.getElementById('tunnel-modal').classList.add('on'); + const r=await fetch(`/api/routers/${encodeURIComponent(id)}/tunnel-cmd`,{headers:hdr()}); + if(r.status===401){logout();return;} + if(!r.ok){ + const j=await r.json().catch(()=>({})); + document.getElementById('tunnel-cmd-text').textContent='Ошибка: '+(j.detail||r.statusText); + return; + } + const j=await r.json(); + document.getElementById('tunnel-cmd-text').textContent=j.cmd||''; + document.getElementById('tunnel-url-text').textContent=j.rci_url||''; +} +function closeTunnelModal(){ + document.getElementById('tunnel-modal').classList.remove('on'); + _tunnelRouterId=null; +} +function copyTunnelCmd(){ + const txt=document.getElementById('tunnel-cmd-text').textContent; + navigator.clipboard.writeText(txt).then(()=>{ + const btn=event.target;btn.textContent='✓ Скопировано';setTimeout(()=>{btn.textContent='📋 Скопировать команду';},2000); + }).catch(()=>alert('Не удалось скопировать')); +} +async function applyTunnelUrl(){ + if(!_tunnelRouterId)return; + const url=document.getElementById('tunnel-url-text').textContent.trim(); + if(!url)return; + const msg=document.getElementById('tunnel-msg'); + const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}`,{method:'PATCH',headers:{...hdr(),'Content-Type':'application/json'},body:JSON.stringify({rci_base_url:url})}); + if(r.status===401){logout();return;} + if(r.ok){ + msg.style.color='var(--ok)';msg.textContent='✓ URL обновлён';msg.style.display='block'; + await load(); + } else { + const j=await r.json().catch(()=>({})); + msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block'; + } +} +async function removeTunnel(){ + if(!_tunnelRouterId)return; + const msg=document.getElementById('tunnel-msg'); + const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}/tunnel`,{method:'DELETE',headers:hdr()}); + if(r.status===401){logout();return;} + if(r.ok){ + msg.style.color='var(--ok)';msg.textContent='✓ Тоннель сброшен';msg.style.display='block'; + await load(); + setTimeout(closeTunnelModal,1200); + } else { + const j=await r.json().catch(()=>({})); + msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block'; + } +} + (async()=>{ const p=sessionStorage.getItem('kdns_pw'); if(p){