feat(routers): SSH test, fetch domain.conf/ip.list to import; normalize URL in IP field; clearer push errors

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-25 19:19:55 +03:00
parent 4e97268541
commit b5a109639b
3 changed files with 159 additions and 16 deletions

View File

@@ -18,7 +18,12 @@
### Вкладка «Роутеры»
Список роутеров с IP и SSH-паролем для массовой отправки конфига через `sshpass` + `curl` на каждый роутер.
Список роутеров: **IP** — это хост для **SSH** (не веб-URL KeenDNS). У каждой строки:
- **Тест** — проверка SSH, каталога `/opt/etc/HydraRoute` (или `/opt/etc/hydra`) и наличия `domain.conf` / `ip.list` на роутере.
- **С роутера** — скачать эти файлы по SSH и подставить во вкладку **«Импорт файлов»** (сохранение на сервер — отдельной кнопкой).
API: `POST /api/routers/{имя}/test`, `GET /api/routers/{имя}/fetch` (заголовок `X-Admin-Password`).
### Вкладка «Настройки»

View File

@@ -1,5 +1,9 @@
"""HydraRoute Domain Manager — standalone web server."""
import asyncio
import logging
import subprocess
from urllib.parse import urlparse
from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
@@ -17,6 +21,53 @@ def _chk(pwd: str):
if config.ADMIN_PASSWORD and pwd != config.ADMIN_PASSWORD:
raise HTTPException(401, "Неверный пароль")
def _router_key(name: str) -> str:
return name.strip().lower()
def _get_router_cfg(name: str) -> dict:
routers = load_json(config.ROUTERS_FILE, {})
key = _router_key(name)
if key not in routers:
raise HTTPException(404, "роутер не найден")
return routers[key]
def _normalize_router_ip(value: str) -> str:
"""Убрать https:// из поля IP: для SSH нужен хост или IP, не веб-URL."""
s = (value or "").strip()
if not s:
return ""
if "://" in s:
p = urlparse(s if s.startswith(("http://", "https://")) else "https://" + s)
return (p.hostname or "").strip() or s.split("/")[0].split("@")[-1].strip()
return s.split("/")[0].strip()
async def _ssh_on_router(rcfg: dict, remote_cmd: str, timeout: int = 45) -> tuple[int, str, str]:
"""Выполнить команду на роутере по SSH. Возвращает (код, stdout, stderr)."""
ip = (rcfg.get("ip") or "").strip()
if not ip:
return 1, "", "нет IP (нужен SSH: IP или hostname без https://)"
user = rcfg.get("user") or config.SSH_USER
pwd = rcfg.get("password") or config.SSH_PASS
try:
r = await asyncio.to_thread(
subprocess.run,
[
"sshpass", "-p", pwd,
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=12",
f"{user}@{ip}", remote_cmd,
],
capture_output=True,
text=True,
timeout=timeout,
)
return r.returncode, (r.stdout or ""), (r.stderr or "")
except Exception as e:
return 1, "", str(e)
# ── Pages ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
@@ -116,13 +167,14 @@ async def import_config(body: ImportBody, x_admin_password: str = Header("")):
@app.post("/api/push_all")
async def push_all(request: Request, x_admin_password: str = Header("")):
_chk(x_admin_password)
import asyncio, subprocess
routers = load_json(config.ROUTERS_FILE, {})
server_url = str(request.base_url).rstrip("/")
results = []
for name, rcfg in routers.items():
ip = rcfg.get("ip", "")
if not ip: results.append({"router":name,"ok":False,"msg":"нет IP"}); continue
if not ip:
results.append({"router": name, "ok": False, "msg": "нет IP"})
continue
user = rcfg.get("user") or config.SSH_USER
pwd = rcfg.get("password") or config.SSH_PASS
cmd = (
@@ -131,10 +183,19 @@ async def push_all(request: Request, x_admin_password: str = Header("")):
f"neo restart"
)
try:
r = await asyncio.to_thread(subprocess.run,
["sshpass","-p",pwd,"ssh","-o","StrictHostKeyChecking=no","-o","ConnectTimeout=10",
f"{user}@{ip}", cmd], capture_output=True, text=True, timeout=60)
results.append({"router":name,"ok":r.returncode==0,"msg":r.stdout[:200]})
r = await asyncio.to_thread(
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]
results.append({"router": name, "ok": r.returncode == 0, "msg": tail or ("ok" if r.returncode == 0 else "exit " + str(r.returncode))})
except Exception as e:
results.append({"router": name, "ok": False, "msg": str(e)})
return {"results": results, "ok": sum(1 for r in results if r["ok"]), "failed": sum(1 for r in results if not r["ok"])}
@@ -148,12 +209,57 @@ async def get_routers():
@app.post("/api/routers/{name}")
async def upsert_router(name: str, body: dict, x_admin_password: str = Header("")):
_chk(x_admin_password)
body = dict(body)
if isinstance(body.get("ip"), str):
body["ip"] = _normalize_router_ip(body["ip"])
R = load_json(config.ROUTERS_FILE, {})
R[name.strip().lower()] = body
R[_router_key(name)] = body
save_json(config.ROUTERS_FILE, R); return {"ok": True}
@app.post("/api/routers/{name}/test")
async def test_router(name: str, x_admin_password: str = Header("")):
"""Проверка SSH: echo + каталог HydraRoute + наличие файлов."""
_chk(x_admin_password)
rcfg = _get_router_cfg(name)
script = (
"echo HM_SSH_OK; (uname -n 2>/dev/null || hostname 2>/dev/null || echo unknown); "
"found=0; for d in /opt/etc/HydraRoute /opt/etc/hydra; do "
"if test -d \"$d\"; then found=1; echo HM_HR_DIR:$d; "
"if test -f \"$d/domain.conf\"; then echo HM_HAS_DOMAIN; else echo HM_NO_DOMAIN; fi; "
"if test -f \"$d/ip.list\"; then echo HM_HAS_IP; else echo HM_NO_IP; fi; "
"break; fi; done; "
"if test \"$found\" = 0; then echo HM_NO_HR_DIR; fi"
)
code, out, err = await _ssh_on_router(rcfg, script)
text = (out + (("\n" + err) if err.strip() else "")).strip()
ok = code == 0 and "HM_SSH_OK" in out
return {"ok": ok, "exit_code": code, "detail": text or (err or "пустой вывод")}
@app.get("/api/routers/{name}/fetch")
async def fetch_from_router(name: str, x_admin_password: str = Header("")):
"""Считать domain.conf и ip.list с роутера (HydraRoute или hydra)."""
_chk(x_admin_password)
rcfg = _get_router_cfg(name)
read_one = (
"sh -c 'for d in /opt/etc/HydraRoute /opt/etc/hydra; do "
"if test -f \"$d/{file}\"; then cat \"$d/{file}\"; exit 0; fi; done; exit 1'"
)
code_d, domain_conf, err_d = await _ssh_on_router(rcfg, read_one.format(file="domain.conf"))
code_i, ip_list, err_i = await _ssh_on_router(rcfg, read_one.format(file="ip.list"))
return {
"domain_conf": domain_conf,
"ip_list": ip_list,
"domain_ok": code_d == 0,
"ip_ok": code_i == 0,
"errors": {"domain": err_d if code_d else "", "ip": err_i if code_i else ""},
}
@app.delete("/api/routers/{name}")
async def delete_router(name: str, x_admin_password: str = Header("")):
_chk(x_admin_password)
R = load_json(config.ROUTERS_FILE, {})
R.pop(name, None); save_json(config.ROUTERS_FILE, R); return {"ok": True}
R.pop(_router_key(name), None)
save_json(config.ROUTERS_FILE, R)
return {"ok": True}

View File

@@ -201,9 +201,10 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
<div id="tab-routers" style="display:none">
<div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px"> Добавить роутер</h2>
<p style="font-size:12px;color:var(--muted);margin-bottom:10px">Поле <b>IP</b> — адрес для <b>SSH</b> (LAN, белый IP или DDNS), не веб-ссылка KeenDNS. После сохранения можно <b>Тест SSH</b> и <b>Снять с роутера</b>.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="text" id="r-name" placeholder="Имя (andrey)" style="width:130px">
<input type="text" id="r-ip" placeholder="IP (192.168.88.1)" style="width:160px">
<input type="text" id="r-ip" placeholder="IP или hostname SSH" style="min-width:160px;flex:1;max-width:240px">
<input type="text" id="r-user" value="root" style="width:80px">
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button>
@@ -213,6 +214,7 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}
<div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px">📋 Роутеры</h2>
<div id="routers-list"><span style="color:var(--muted)">Загрузка...</span></div>
<div id="router-ops-msg" class="push-log" style="display:none;margin-top:12px;max-height:200px"></div>
</div>
</div>
@@ -352,10 +354,40 @@ async function loadRouters(){
const R=await(await fetch('/api/routers')).json();
const el=document.getElementById('routers-list');
if(!Object.keys(R).length){ el.innerHTML='<span style="color:var(--muted)">Роутеры не добавлены</span>'; return; }
el.innerHTML=Object.entries(R).map(([n,c])=>`<div class="ri"><span><span class="n">${n}</span> — ${c.ip}</span><button class="btn btn-r" style="font-size:11px;padding:5px 10px" onclick="delRouter('${n}')">✗</button></div>`).join('');
el.innerHTML=Object.entries(R).map(([n,c])=>`<div class="ri" style="align-items:flex-start;gap:10px">
<div style="flex:1;min-width:0"><span class="n">${n}</span> <span style="color:var(--muted);font-size:12px">— ${c.ip||'—'}</span></div>
<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" onclick="testRouter(${JSON.stringify(n)})">🔌 Тест</button>
<button type="button" class="btn btn-b" style="font-size:11px;padding:5px 10px" onclick="pullRouter(${JSON.stringify(n)})">⬇ С роутера</button>
<button type="button" class="btn btn-r" style="font-size:11px;padding:5px 10px" onclick="delRouter(${JSON.stringify(n)})">✗</button>
</div></div>`).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 delRouter(n){ if(!confirm('Удалить '+n+'?'))return; await fetch('/api/routers/'+encodeURIComponent(n),{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
async function testRouter(n){
const log=document.getElementById('router-ops-msg');
log.style.display='block'; log.textContent='Проверка «'+n+'»…\n';
try{
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/test',{method:'POST',headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){ log.textContent='❌ '+(j.detail||r.status); return; }
log.textContent=(j.ok?'✅':'⚠️')+' «'+n+'»\n'+j.detail;
}catch(e){ log.textContent='❌ '+e; }
}
async function pullRouter(n){
if(!confirm('Считать domain.conf и ip.list с «'+n+'» и подставить во вкладку «Импорт»? На сервер не сохранится, пока не нажмёшь «Сохранить на сервер».')) return;
try{
const r=await fetch('/api/routers/'+encodeURIComponent(n)+'/fetch',{headers:{'X-Admin-Password':getPass()}});
const j=await r.json();
if(!r.ok){ sm('rm','err','❌ '+(j.detail||r.status)); return; }
if(!j.domain_ok && !j.ip_ok){ sm('rm','err','❌ На роутере не найдены файлы в /opt/etc/HydraRoute или /opt/etc/hydra'); return; }
document.getElementById('dc-text').value=j.domain_ok?(j.domain_conf||''):'';
document.getElementById('il-text').value=j.ip_ok?(j.ip_list||''):'';
showTab('import');
const parts=[]; if(j.domain_ok) parts.push('domain.conf'); if(j.ip_ok) parts.push('ip.list');
sm('im','ok','✅ Подставлено: '+parts.join(', ')+(j.domain_ok&&j.ip_ok?'':' (часть файлов не найдена — пустое поле)'));
}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/${n}`,{method:'POST',headers:authHdr(),body:JSON.stringify(b)}); sm('rm','ok','✅ '+n); loadRouters(); }catch(e){sm('rm','err','❌ '+e)} }
async function delRouter(n){ if(!confirm(`Удалить ${n}?`))return; await fetch(`/api/routers/${n}`,{method:'DELETE',headers:{'X-Admin-Password':getPass()}}); loadRouters(); }
// ── 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)} }