feat: reverse SSH tunnel for routers without public IP

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 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-04-27 20:57:05 +03:00
parent 80c7859f2d
commit d48b7b2023
5 changed files with 182 additions and 3 deletions

View File

@@ -7,3 +7,11 @@ ADMIN_PASSWORD=change-me
# Дефолт для Keenetic API, если у роутера не заданы поля логин/пароль и нет user:pass@ в URL # Дефолт для Keenetic API, если у роутера не заданы поля логин/пароль и нет user:pass@ в URL
KEENETIC_LOGIN=admin KEENETIC_LOGIN=admin
KEENETIC_PASSWORD= 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

View File

@@ -16,3 +16,10 @@ ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip()
# Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым) # Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым)
KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip() KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip()
KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").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")

View File

@@ -256,6 +256,89 @@ async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Hea
return r 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}") @app.post("/api/test-router/{rid}")
async def test_router(rid: str, x_admin_password: str = Header("")): async def test_router(rid: str, x_admin_password: str = Header("")):
_chk(x_admin_password) _chk(x_admin_password)

View File

@@ -18,6 +18,7 @@ class RouterSpec(BaseModel):
enabled: bool = True enabled: bool = True
keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI") keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI")
keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI") keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI")
tunnel_port: int | None = Field(default=None, description="Порт обратного SSH-тоннеля на VPS (None = тоннель не настроен)")
class StoreData(BaseModel): class StoreData(BaseModel):

View File

@@ -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{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-bg.on{display:flex}
#auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)} #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,#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{display:flex} #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} #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} #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} .if-row{cursor:pointer}
@@ -148,6 +152,22 @@
</div> </div>
</div></div> </div></div>
<div id="tunnel-modal"><div id="tunnel-box">
<h3 style="margin-bottom:6px;font-size:16px">Обратный SSH-тоннель</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:14px">Для роутеров без белого IP. Скопируй команду и выполни в SSH на роутере — он сам подключится к VPS и откроет порт.</p>
<div style="font-size:12px;color:var(--mu);margin-bottom:4px">Команда для SSH роутера:</div>
<div class="tunnel-cmd" id="tunnel-cmd-text"></div>
<button class="btn btn-d" style="font-size:11px;padding:6px 14px;margin-bottom:14px" onclick="copyTunnelCmd()">📋 Скопировать команду</button>
<div style="font-size:12px;color:var(--mu);margin-bottom:4px">После запуска тоннеля — новый RCI URL для этого роутера:</div>
<div class="tunnel-url" id="tunnel-url-text"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px">
<button class="btn btn-b" onclick="applyTunnelUrl()">✓ Применить URL автоматически</button>
<button class="btn btn-d" onclick="closeTunnelModal()">Закрыть</button>
<button class="btn" style="background:#7c3aed22;color:#a78bfa;border:1px solid #7c3aed44;margin-left:auto;font-size:11px;padding:6px 12px" onclick="removeTunnel()">✕ Сбросить тоннель</button>
</div>
<div id="tunnel-msg" style="margin-top:10px;font-size:12px;display:none"></div>
</div></div>
<div id="ifscan-modal"><div id="ifscan-box"> <div id="ifscan-modal"><div id="ifscan-box">
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3> <h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p> <p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
@@ -250,8 +270,9 @@ function paint(){
<td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td> <td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td>
<td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td> <td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td>
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange='toggleEn(${JSON.stringify(iid)},this.checked)'/></td> <td><input type="checkbox" ${ro.enabled?'checked':''} onchange='toggleEn(${JSON.stringify(iid)},this.checked)'/></td>
<td><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='openEditRouter(${JSON.stringify(iid)})'>Изм.</button> <td style="white-space:nowrap"><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='openEditRouter(${JSON.stringify(iid)})'>Изм.</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='testR(${JSON.stringify(iid)})'>Тест</button> <button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='testR(${JSON.stringify(iid)})'>Тест</button>
<button type="button" class="btn" style="padding:4px 10px;font-size:11px;background:${ro.tunnel_port?'#7c3aed':'#3f3f46'};color:#fff" onclick='tunnelR(${JSON.stringify(iid)})' title="${ro.tunnel_port?'Тоннель активен — порт '+ro.tunnel_port:'Настроить обратный SSH-тоннель'}">⇄${ro.tunnel_port?' :'+ro.tunnel_port:' Тоннель'}</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='delR(${JSON.stringify(iid)})'>✕</button></td> <button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick='delR(${JSON.stringify(iid)})'>✕</button></td>
</tr>`; </tr>`;
}).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>'; }).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>';
@@ -388,6 +409,65 @@ async function applySel(){
await load(); 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()=>{ (async()=>{
const p=sessionStorage.getItem('kdns_pw'); const p=sessionStorage.getItem('kdns_pw');
if(p){ if(p){