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 logging
import socket
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Query
@@ -293,8 +294,9 @@ async def tunnel_cmd(rid: str, x_admin_password: str = Header("")):
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"# Установить зависимости (autossh, sshpass, cronie для watchdog)\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"cat > /opt/bin/kdns_tunnel.sh << 'ENDSCRIPT'\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"ENDSCRIPT\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"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"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"echo \"Тоннель запущен: порт 81 → VPS:{port}\"\n"
f"echo \"URL для платформы: http://localhost:{port}\""
@@ -339,6 +341,29 @@ async def tunnel_remove(rid: str, x_admin_password: str = Header("")):
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}")
async def test_router(rid: str, x_admin_password: str = Header("")):
_chk(x_admin_password)

View File

@@ -163,6 +163,7 @@
<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:#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>
</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';
}
}
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(){
if(!_tunnelRouterId)return;
const msg=document.getElementById('tunnel-msg');