fix(tunnel): install cronie, start crond, killall watchdog; add tunnel-status endpoint

- opkg install now includes cronie so crond is actually present
- /opt/etc/init.d/S10crond start runs after install so cron watchdog fires
- cron watchdog switches from pgrep (not always in PATH) to killall -0 autossh
- killall autossh instead of pkill before re-launching (works in BusyBox)
- GET /api/routers/{rid}/tunnel-status: checks if tunnel port is listening on VPS localhost
- "⟳ Проверить связь" button in tunnel modal calls the new endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-04-27 21:54:34 +03:00
parent d48b7b2023
commit cc31234e91
2 changed files with 44 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import socket
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Query from fastapi import FastAPI, Header, HTTPException, Query
@@ -293,8 +294,9 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
cmd = ( cmd = (
f"export PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n\n" f"export PATH=\"/opt/bin:/opt/sbin:/bin:/sbin:/usr/bin:/usr/sbin:$PATH\"\n\n"
f"# Установить зависимости\n" f"# Установить зависимости (autossh, sshpass, cronie для watchdog)\n"
f"opkg install autossh sshpass 2>/dev/null; true\n\n" f"opkg install autossh sshpass cronie 2>/dev/null; true\n"
f"/opt/etc/init.d/S10crond start 2>/dev/null; true\n\n"
f"# Создать скрипт тоннеля\n" f"# Создать скрипт тоннеля\n"
f"cat > /opt/bin/kdns_tunnel.sh << 'ENDSCRIPT'\n" f"cat > /opt/bin/kdns_tunnel.sh << 'ENDSCRIPT'\n"
f"#!/bin/sh\n" f"#!/bin/sh\n"
@@ -305,11 +307,11 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
f" -N -R {port}:localhost:81 {vps_user}@{vps_host} -p {vps_port}\n" f" -N -R {port}:localhost:81 {vps_user}@{vps_host} -p {vps_port}\n"
f"ENDSCRIPT\n" f"ENDSCRIPT\n"
f"chmod +x /opt/bin/kdns_tunnel.sh\n\n" f"chmod +x /opt/bin/kdns_tunnel.sh\n\n"
f"# Добавить в cron (запуск если не работает, каждые 3 мин)\n" f"# Watchdog через cron: перезапуск каждые 3 мин если не работает\n"
f"(crontab -l 2>/dev/null | grep -v kdns_tunnel; " 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"echo '*/3 * * * * killall -0 autossh 2>/dev/null || /opt/bin/kdns_tunnel.sh &') | crontab -\n\n"
f"# Запустить сейчас\n" f"# Запустить сейчас\n"
f"pkill -f kdns_tunnel.sh 2>/dev/null; sleep 1\n" f"killall autossh 2>/dev/null; sleep 1\n"
f"nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 &\n\n" f"nohup /opt/bin/kdns_tunnel.sh >/dev/null 2>&1 &\n\n"
f"echo \"Тоннель запущен: порт 81 → VPS:{port}\"\n" f"echo \"Тоннель запущен: порт 81 → VPS:{port}\"\n"
f"echo \"URL для платформы: http://localhost:{port}\"" f"echo \"URL для платформы: http://localhost:{port}\""
@@ -339,6 +341,29 @@ async def tunnel_remove(rid: str, x_admin_password: str = Header("")):
return {"ok": True} return {"ok": True}
@app.get("/api/routers/{rid}/tunnel-status")
async def tunnel_status(rid: str, x_admin_password: str = Header("")):
"""Проверить: слушает ли тоннельный порт на localhost VPS прямо сейчас."""
_chk(x_admin_password)
cur = load_store()
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
if not r:
raise HTTPException(404, "Роутер не найден")
port = r.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.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

@@ -163,6 +163,7 @@
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px"> <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-b" onclick="applyTunnelUrl()">✓ Применить URL автоматически</button>
<button class="btn btn-d" onclick="closeTunnelModal()">Закрыть</button> <button class="btn btn-d" onclick="closeTunnelModal()">Закрыть</button>
<button class="btn" style="background:#0f2;color:#000;font-size:11px;padding:6px 12px" onclick="checkTunnelStatus()">⟳ Проверить связь</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> <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>
<div id="tunnel-msg" style="margin-top:10px;font-size:12px;display:none"></div> <div id="tunnel-msg" style="margin-top:10px;font-size:12px;display:none"></div>
@@ -453,6 +454,19 @@ async function applyTunnelUrl(){
msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block'; msg.style.color='var(--er)';msg.textContent='Ошибка: '+(j.detail||r.statusText);msg.style.display='block';
} }
} }
async function checkTunnelStatus(){
if(!_tunnelRouterId)return;
const msg=document.getElementById('tunnel-msg');
msg.style.color='var(--mu)';msg.textContent='Проверяю порт…';msg.style.display='block';
const r=await fetch(`/api/routers/${encodeURIComponent(_tunnelRouterId)}/tunnel-status`,{headers:hdr()});
if(r.status===401){logout();return;}
const j=await r.json().catch(()=>({}));
if(j.active){
msg.style.color='var(--ok)';msg.textContent=`✓ Тоннель активен — порт ${j.tunnel_port} слушает`;
} else {
msg.style.color='var(--er)';msg.textContent=`✗ Порт ${j.tunnel_port||'?'} не отвечает — запусти команду на роутере`;
}
}
async function removeTunnel(){ async function removeTunnel(){
if(!_tunnelRouterId)return; if(!_tunnelRouterId)return;
const msg=document.getElementById('tunnel-msg'); const msg=document.getElementById('tunnel-msg');