From a63329af8141cd405a5e79325dd5d9c12d799810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 24 Apr 2026 11:37:24 +0300 Subject: [PATCH] =?UTF-8?q?Add=20HydraRoute=20Manager=20web=20server=20?= =?UTF-8?q?=E2=80=94=20standalone=20domain=20management=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .gitignore | 6 + README.md | 96 ++++++++++ hydra_update.sh | 13 ++ install_router.sh | 24 +++ server/.env.example | 7 + server/__init__.py | 0 server/config.py | 30 +++ server/database.py | 22 +++ server/hydra_manager.py | 60 ++++++ server/install.sh | 32 ++++ server/main.py | 159 ++++++++++++++++ server/models.py | 15 ++ server/requirements.txt | 4 + server/templates/index.html | 365 ++++++++++++++++++++++++++++++++++++ 14 files changed, 833 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 hydra_update.sh create mode 100644 install_router.sh create mode 100644 server/.env.example create mode 100644 server/__init__.py create mode 100644 server/config.py create mode 100644 server/database.py create mode 100644 server/hydra_manager.py create mode 100644 server/install.sh create mode 100644 server/main.py create mode 100644 server/models.py create mode 100644 server/requirements.txt create mode 100644 server/templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8eb08d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +server/data/ +server/.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..964f421 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# 🌐 HydraRoute Manager + +Веб-интерфейс для централизованного управления конфигурацией **HydraRoute Neo** на роутерах Keenetic. + +- Управление группами доменов и IP через браузер +- Включение / отключение групп одним переключателем +- Импорт `domain.conf` и `ip.list` с роутера +- Отправка конфига на все роутеры одной кнопкой +- Авторизация по паролю +- Скачивание файлов для роутера по HTTP + +--- + +## Установка сервера (Ubuntu 22/24) + +```bash +git clone https://github.com/andrey271192/domen_hydra.git /opt/domen-hydra +cd /opt/domen-hydra +bash server/install.sh +nano server/.env # задать ADMIN_PASSWORD +systemctl restart hydra-manager +``` + +Интерфейс откроется на `http://IP:8000` + +--- + +## Установка на роутер (Keenetic + Entware) + +```bash +export SERVER_URL="http://IP_СЕРВЕРА:8000" \ + && curl -fsSL https://raw.githubusercontent.com/andrey271192/domen_hydra/main/install_router.sh | sh +``` + +Роутер будет каждый день в 02:00 скачивать актуальный конфиг с сервера. + +Ручное обновление: +```bash +sh /opt/bin/hydra_update.sh +``` + +--- + +## Как использовать + +1. Открой `http://IP:8000` → введи пароль +2. **Импорт**: вставь `domain.conf` и `ip.list` с роутера → "Сохранить на сервер" +3. Редактируй группы доменов и IP прямо в браузере +4. Нажми **"📡 Обновить все роутеры"** — конфиг применится через SSH + +--- + +## Формат файлов + +**domain.conf:** +``` +##youtube +youtube.com,youtu.be,googlevideo.com/HydraRoute + +##avito +avito.ru,ozon.ru/RU +``` + +**ip.list:** +``` +##geoip:ru +/RU +geoip:ru +``` + +--- + +## Структура проекта + +``` +server/ + main.py — FastAPI приложение + config.py — настройки из .env + hydra_manager.py — парсинг/генерация domain.conf + ip.list + models.py — модели данных + database.py — работа с JSON + install.sh — установка на Ubuntu + .env.example — шаблон конфигурации + templates/ + index.html — веб-интерфейс + +install_router.sh — установка на роутер +``` + +--- + +## Обновление + +```bash +cd /opt/domen-hydra && git pull && systemctl restart hydra-manager +``` diff --git a/hydra_update.sh b/hydra_update.sh new file mode 100644 index 0000000..b03459d --- /dev/null +++ b/hydra_update.sh @@ -0,0 +1,13 @@ +#!/bin/sh +SERVER=$(cat /opt/etc/hydra_server_url 2>/dev/null) +[ -z "$SERVER" ] && echo "❌ /opt/etc/hydra_server_url не задан" && exit 1 + +HYDRA_DIR="/opt/etc/HydraRoute" +[ ! -d "$HYDRA_DIR" ] && HYDRA_DIR="/opt/etc/hydra" +[ ! -d "$HYDRA_DIR" ] && mkdir -p /opt/etc/HydraRoute && HYDRA_DIR="/opt/etc/HydraRoute" + +echo "$(date) Обновляю конфиг с $SERVER..." +curl -sf "$SERVER/hydra/domain.conf" -o "$HYDRA_DIR/domain.conf" && echo "✅ domain.conf" || echo "❌ domain.conf" +curl -sf "$SERVER/hydra/ip.list" -o "$HYDRA_DIR/ip.list" && echo "✅ ip.list" || echo "❌ ip.list" + +neo restart 2>/dev/null && echo "✅ neo restarted" || echo "⚠️ neo restart failed" diff --git a/install_router.sh b/install_router.sh new file mode 100644 index 0000000..d3f6e3d --- /dev/null +++ b/install_router.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e +echo "🌐 HydraRoute Manager — установка на роутер" +[ -z "$SERVER_URL" ] && printf "URL сервера (http://IP:8000): " && read SERVER_URL +[ -z "$SERVER_URL" ] && echo "❌ SERVER_URL обязателен" && exit 1 + +mkdir -p /opt/bin /opt/var/log /opt/var/run + +echo "$SERVER_URL" > /opt/etc/hydra_server_url + +REPO="https://raw.githubusercontent.com/andrey271192/domen_hydra/main" +curl -fsSL "$REPO/hydra_update.sh" -o /opt/bin/hydra_update.sh +chmod +x /opt/bin/hydra_update.sh + +# Add cron: update domains daily at 02:00 +CT="/tmp/cron_hydra" +crontab -l 2>/dev/null | grep -v hydra_update > "$CT" || true +echo "0 2 * * * /opt/bin/hydra_update.sh >> /opt/var/log/hydra_update.log 2>&1" >> "$CT" +crontab "$CT" && rm -f "$CT" + +# Run immediately +sh /opt/bin/hydra_update.sh + +echo "✅ Установлено. Обновление каждый день в 02:00" diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..536d063 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,7 @@ +HOST=0.0.0.0 +PORT=8000 +ADMIN_PASSWORD=admin + +# SSH defaults for all routers (can be overridden per-router) +SSH_USER=root +SSH_PASS=keenetic diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..76c2759 --- /dev/null +++ b/server/config.py @@ -0,0 +1,30 @@ +import os, json, logging +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger("hydra") + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8000")) +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") +SSH_USER = os.getenv("SSH_USER", "root") +SSH_PASS = os.getenv("SSH_PASS", "keenetic") + +HYDRA_FILE = DATA_DIR / "hydra_config.json" +ROUTERS_FILE = DATA_DIR / "routers.json" + +def ensure_data(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + defaults = { + HYDRA_FILE: {"version":"1.0","domain_groups":[],"ip_groups":[]}, + ROUTERS_FILE: {}, + } + for fp, default in defaults.items(): + if not fp.exists(): + fp.write_text(json.dumps(default, ensure_ascii=False, indent=2)) + +ensure_data() diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..cda2e70 --- /dev/null +++ b/server/database.py @@ -0,0 +1,22 @@ +import json, logging +from pathlib import Path +logger = logging.getLogger("hydra") + +def load_json(path: Path, default=None): + if default is None: default = {} + if not isinstance(path, Path): path = Path(path) + try: + if path.exists(): + text = path.read_text(encoding="utf-8") + if text.strip(): return json.loads(text) + return default + except Exception as e: + logger.error(f"Corrupt {path.name}: {e}"); return default + +def save_json(path: Path, data): + if not isinstance(path, Path): path = Path(path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + except Exception as e: + logger.error(f"Save fail {path}: {e}") diff --git a/server/hydra_manager.py b/server/hydra_manager.py new file mode 100644 index 0000000..56c84a1 --- /dev/null +++ b/server/hydra_manager.py @@ -0,0 +1,60 @@ +import hashlib +from .models import HydraConfig, DomainGroup, IpGroup +from . import config +from .database import load_json, save_json + +def load_hydra_config(): + d = load_json(config.HYDRA_FILE, {"version":"1.0","domain_groups":[],"ip_groups":[]}) + return HydraConfig(**d) + +def save_hydra_config(cfg): + save_json(config.HYDRA_FILE, cfg.model_dump()) + +def generate_domain_conf(cfg): + lines = [] + for g in cfg.domain_groups: + lines.append(f"##{g.name}") + e = ",".join(g.entries) + lines.append(f"{e}/{g.policy}" if g.enabled else f"{e}#/{g.policy}") + return "\n".join(lines)+"\n" if lines else "" + +def generate_ip_list(cfg): + lines = [] + for g in cfg.ip_groups: + lines.append(f"##{g.name}") + lines.append(f"/{g.policy}" if g.enabled else f"#/{g.policy}") + for e in g.entries: lines.append(e) + return "\n".join(lines)+"\n" if lines else "" + +def get_config_version(cfg): + return hashlib.sha256((generate_domain_conf(cfg)+generate_ip_list(cfg)).encode()).hexdigest()[:12] + +def parse_domain_conf(text): + groups=[]; cn=None + for line in text.strip().split("\n"): + line=line.strip() + if not line: continue + if line.startswith("##"): cn=line[2:].strip(); continue + if "/" in line and cn is not None: + en=True + if "#/" in line: p=line.split("#/",1); es=p[0]; pol=p[1]; en=False + else: p=line.rsplit("/",1); es=p[0]; pol=p[1] if len(p)>1 else "HydraRoute" + entries=[e.strip() for e in es.split(",") if e.strip()] + et="geosite" if any(e.startswith("geosite:") for e in entries) else "domain" + groups.append(DomainGroup(name=cn,entries=entries,policy=pol,entry_type=et,enabled=en)); cn=None + return groups + +def parse_ip_list(text): + groups=[]; cn=None; cp=None; ce=[]; en=True + def flush(): + if cn and cp: + et="geoip" if any(e.startswith("geoip:") for e in ce) else "ip" + groups.append(IpGroup(name=cn,entries=list(ce),policy=cp,entry_type=et,enabled=en)) + for line in text.strip().split("\n"): + line=line.strip() + if not line: continue + if line.startswith("##"): flush(); cn=line[2:].strip(); cp=None; ce=[]; en=True; continue + if line.startswith("#/"): en=False; cp=line[2:].strip(); continue + if line.startswith("/"): cp=line[1:].strip(); continue + if cp: ce.append(line) + flush(); return groups diff --git a/server/install.sh b/server/install.sh new file mode 100644 index 0000000..7047d27 --- /dev/null +++ b/server/install.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e +echo "🌐 HydraRoute Manager — установка" +cd "$(dirname "$0")/.." + +apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv sshpass + +python3 -m venv .venv +.venv/bin/pip install -q -r server/requirements.txt + +[ ! -f server/.env ] && cp server/.env.example server/.env && echo "⚠️ Настрой server/.env и перезапусти" + +cat > /etc/systemd/system/hydra-manager.service </dev/null || echo IP):8000" diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..25ea8e4 --- /dev/null +++ b/server/main.py @@ -0,0 +1,159 @@ +"""HydraRoute Domain Manager — standalone web server.""" +import logging +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, "Неверный пароль") + +# ── 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) + 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 + 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) + results.append({"router":name,"ok":r.returncode==0,"msg":r.stdout[:200]}) + 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) + R = load_json(config.ROUTERS_FILE, {}) + R[name.strip().lower()] = body + save_json(config.ROUTERS_FILE, R); return {"ok": True} + +@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} diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..0eed63d --- /dev/null +++ b/server/models.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import Literal + +class DomainGroup(BaseModel): + name: str; entries: list[str]; policy: str + entry_type: Literal["domain","geosite"] = "domain"; enabled: bool = True + +class IpGroup(BaseModel): + name: str; entries: list[str]; policy: str + entry_type: Literal["ip","geoip"] = "ip"; enabled: bool = True + +class HydraConfig(BaseModel): + version: str = "1.0" + domain_groups: list[DomainGroup] = [] + ip_groups: list[IpGroup] = [] diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..2a17bfc --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn>=0.30.0 +python-dotenv>=1.0.1 +pydantic>=2.11.0 diff --git a/server/templates/index.html b/server/templates/index.html new file mode 100644 index 0000000..c1b13b9 --- /dev/null +++ b/server/templates/index.html @@ -0,0 +1,365 @@ +HydraRoute Manager + + + + + + +
+ + +
+ +
+
+

🌐 Управление доменами

+
HydraRoute Neo · ...
+
+
+ + ↓ domain.conf + ↓ ip.list +
+
+
+ ⚠️ Есть несохранённые изменения — роутеры ещё не обновлены + +
+
+ +
+ + + + +
+ + +
+
+
+

📄 Группы доменов 0

+ +
+
+
+ + + +
+ +
+ + +
+
+
+
+
+
+

🔢 Группы IP 0

+ +
+
+
+ + + +
+ +
+ + +
+
+
+
+
+
+ + + + + + + + + + +