"""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 from . import config from .database import load_json, save_json from .hydra_manager import (load_hydra_config, save_hydra_config, generate_domain_conf, generate_ip_list, get_config_version, parse_domain_conf, parse_ip_list) from .models import DomainGroup, IpGroup, HydraConfig logging.basicConfig(level=logging.INFO) app = FastAPI(title="HydraRoute Domain Manager") 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) async def index(): with open(config.BASE_DIR / "templates" / "index.html", encoding="utf-8") as f: return f.read() # ── Auth ───────────────────────────────────────────────────────────────────── @app.get("/api/auth") async def auth(x_admin_password: str = Header("")): _chk(x_admin_password); return {"ok": True} @app.post("/api/set_password") async def set_password(body: dict, x_admin_password: str = Header("")): _chk(x_admin_password) new_pwd = (body.get("password") or "").strip() if len(new_pwd) < 4: raise HTTPException(400, "Минимум 4 символа") import re env_path = config.BASE_DIR / ".env" if env_path.exists(): txt = env_path.read_text() txt = re.sub(r"ADMIN_PASSWORD=.*", f"ADMIN_PASSWORD={new_pwd}", txt) if "ADMIN_PASSWORD" in txt else txt + f"\nADMIN_PASSWORD={new_pwd}\n" env_path.write_text(txt) config.ADMIN_PASSWORD = new_pwd return {"ok": True} # ── HydraRoute files (served to routers) ───────────────────────────────────── @app.get("/hydra/domain.conf") async def domain_conf(): return Response(content=generate_domain_conf(load_hydra_config()), media_type="text/plain") @app.get("/hydra/ip.list") async def ip_list(): return Response(content=generate_ip_list(load_hydra_config()), media_type="text/plain") @app.get("/hydra/version") async def version(): return Response(content=get_config_version(load_hydra_config()), media_type="text/plain") @app.get("/hydra/config") async def hydra_config(): return load_hydra_config().model_dump() # ── Domain groups ───────────────────────────────────────────────────────────── @app.post("/api/domain-group") async def upsert_domain_group(g: DomainGroup, x_admin_password: str = Header("")): _chk(x_admin_password) cfg = load_hydra_config() cfg.domain_groups = [x for x in cfg.domain_groups if x.name != g.name] cfg.domain_groups.append(g) save_hydra_config(cfg); return {"ok": True} @app.delete("/api/domain-group/{name}") async def delete_domain_group(name: str, x_admin_password: str = Header("")): _chk(x_admin_password) cfg = load_hydra_config() cfg.domain_groups = [x for x in cfg.domain_groups if x.name != name] save_hydra_config(cfg); return {"ok": True} # ── IP groups ───────────────────────────────────────────────────────────────── @app.post("/api/ip-group") async def upsert_ip_group(g: IpGroup, x_admin_password: str = Header("")): _chk(x_admin_password) cfg = load_hydra_config() cfg.ip_groups = [x for x in cfg.ip_groups if x.name != g.name] cfg.ip_groups.append(g) save_hydra_config(cfg); return {"ok": True} @app.delete("/api/ip-group/{name}") async def delete_ip_group(name: str, x_admin_password: str = Header("")): _chk(x_admin_password) cfg = load_hydra_config() cfg.ip_groups = [x for x in cfg.ip_groups if x.name != name] save_hydra_config(cfg); return {"ok": True} # ── Import ──────────────────────────────────────────────────────────────────── class ImportBody(BaseModel): domain_conf: str = ""; ip_list: str = "" @app.post("/api/import") async def import_config(body: ImportBody, x_admin_password: str = Header("")): _chk(x_admin_password) cfg = load_hydra_config() if body.domain_conf.strip(): cfg.domain_groups = parse_domain_conf(body.domain_conf) if body.ip_list.strip(): cfg.ip_groups = parse_ip_list(body.ip_list) save_hydra_config(cfg); return {"ok": True, "domains": len(cfg.domain_groups), "ips": len(cfg.ip_groups)} # ── Push to all routers via SSH ─────────────────────────────────────────────── @app.post("/api/push_all") async def push_all(request: Request, x_admin_password: str = Header("")): _chk(x_admin_password) 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 user = rcfg.get("user") or config.SSH_USER 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, ) 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"])} # ── Routers CRUD ────────────────────────────────────────────────────────────── @app.get("/api/routers") async def get_routers(): return load_json(config.ROUTERS_FILE, {}) @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[_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(_router_key(name), None) save_json(config.ROUTERS_FILE, R) return {"ok": True}