mirror of
https://github.com/andrey271192/domen_hydra.git
synced 2026-09-20 14:42:00 +00:00
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:
@@ -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`).
|
||||||
|
|
||||||
### Вкладка «Настройки»
|
### Вкладка «Настройки»
|
||||||
|
|
||||||
|
|||||||
122
server/main.py
122
server/main.py
@@ -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,10 +183,19 @@ 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"])}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)} }
|
||||||
|
|||||||
Reference in New Issue
Block a user