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.""" """HydraRoute Domain Manager — standalone web server."""
import asyncio
import logging import logging
import subprocess
from urllib.parse import urlparse
from fastapi import FastAPI, Request, Header, HTTPException from fastapi import FastAPI, Request, Header, HTTPException
from fastapi.responses import HTMLResponse, Response from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
@@ -17,6 +21,53 @@ def _chk(pwd: str):
if config.ADMIN_PASSWORD and pwd != config.ADMIN_PASSWORD: if config.ADMIN_PASSWORD and pwd != config.ADMIN_PASSWORD:
raise HTTPException(401, "Неверный пароль") 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 ──────────────────────────────────────────────────────────────────── # ── Pages ────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @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") @app.post("/api/push_all")
async def push_all(request: Request, x_admin_password: str = Header("")): async def push_all(request: Request, x_admin_password: str = Header("")):
_chk(x_admin_password) _chk(x_admin_password)
import asyncio, subprocess
routers = load_json(config.ROUTERS_FILE, {}) routers = load_json(config.ROUTERS_FILE, {})
server_url = str(request.base_url).rstrip("/") server_url = str(request.base_url).rstrip("/")
results = [] results = []
for name, rcfg in routers.items(): for name, rcfg in routers.items():
ip = rcfg.get("ip","") 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 user = rcfg.get("user") or config.SSH_USER
pwd = rcfg.get("password") or config.SSH_PASS pwd = rcfg.get("password") or config.SSH_PASS
cmd = ( cmd = (
@@ -131,12 +183,21 @@ async def push_all(request: Request, x_admin_password: str = Header("")):
f"neo restart" f"neo restart"
) )
try: try:
r = await asyncio.to_thread(subprocess.run, r = await asyncio.to_thread(
["sshpass","-p",pwd,"ssh","-o","StrictHostKeyChecking=no","-o","ConnectTimeout=10", subprocess.run,
f"{user}@{ip}", cmd], capture_output=True, text=True, timeout=60) [
results.append({"router":name,"ok":r.returncode==0,"msg":r.stdout[:200]}) "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: except Exception as e:
results.append({"router":name,"ok":False,"msg":str(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"])} return {"results": results, "ok": sum(1 for r in results if r["ok"]), "failed": sum(1 for r in results if not r["ok"])}
# ── Routers CRUD ────────────────────────────────────────────────────────────── # ── Routers CRUD ──────────────────────────────────────────────────────────────
@@ -148,12 +209,57 @@ async def get_routers():
@app.post("/api/routers/{name}") @app.post("/api/routers/{name}")
async def upsert_router(name: str, body: dict, x_admin_password: str = Header("")): async def upsert_router(name: str, body: dict, x_admin_password: str = Header("")):
_chk(x_admin_password) _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 = load_json(config.ROUTERS_FILE, {})
R[name.strip().lower()] = body R[_router_key(name)] = body
save_json(config.ROUTERS_FILE, R); return {"ok": True} 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}") @app.delete("/api/routers/{name}")
async def delete_router(name: str, x_admin_password: str = Header("")): async def delete_router(name: str, x_admin_password: str = Header("")):
_chk(x_admin_password) _chk(x_admin_password)
R = load_json(config.ROUTERS_FILE, {}) 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 id="tab-routers" style="display:none">
<div class="section"> <div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px"> Добавить роутер</h2> <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"> <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-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="text" id="r-user" value="root" style="width:80px">
<input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px"> <input type="password" id="r-pass" placeholder="SSH пароль" style="width:130px">
<button class="btn btn-b" onclick="addRouter()">+ Добавить</button> <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"> <div class="section">
<h2 style="font-size:14px;font-weight:700;margin-bottom:14px">📋 Роутеры</h2> <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="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>
</div> </div>
@@ -352,10 +354,40 @@ async function loadRouters(){
const R=await(await fetch('/api/routers')).json(); const R=await(await fetch('/api/routers')).json();
const el=document.getElementById('routers-list'); const el=document.getElementById('routers-list');
if(!Object.keys(R).length){ el.innerHTML='<span style="color:var(--muted)">Роутеры не добавлены</span>'; return; } 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 ────────────────────────────────────────────────────────────── // ── 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)} } 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)} }