diff --git a/README.md b/README.md index 2ab1766..5c2b646 100644 --- a/README.md +++ b/README.md @@ -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`). ### Вкладка «Настройки» diff --git a/server/main.py b/server/main.py index 25ea8e4..bf5c793 100644 --- a/server/main.py +++ b/server/main.py @@ -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,27 +167,37 @@ 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 + ip = rcfg.get("ip", "") + 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 + pwd = rcfg.get("password") or config.SSH_PASS cmd = ( f"curl -sf '{server_url}/hydra/domain.conf' -o /opt/etc/HydraRoute/domain.conf && " f"curl -sf '{server_url}/hydra/ip.list' -o /opt/etc/HydraRoute/ip.list && " 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)}) + 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"])} # ── Routers CRUD ────────────────────────────────────────────────────────────── @@ -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} diff --git a/server/templates/index.html b/server/templates/index.html index c1b13b9..c5c6ee7 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -201,9 +201,10 @@ input[type=text]:focus,input[type=password]:focus{border-color:var(--accent)}