diff --git a/.env.example b/.env.example index a71d135..5bf0d9d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ HOST=0.0.0.0 PORT=8001 -# Если строка пустая или ключ убран — будет пароль admin + +# Вход на веб-страницу сервиса (:8001). Если пусто — admin ADMIN_PASSWORD=change-me -# Один логин/пароль для всех роутеров (KeenDNS HTTP Proxy → RCI) +# Дефолт для Keenetic API, если у роутера не заданы поля логин/пароль и нет user:pass@ в URL KEENETIC_LOGIN=admin KEENETIC_PASSWORD= diff --git a/README.md b/README.md index ab6db0f..f21273f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - KeeneticOS **≥ 5.0.1** (DNS-based routes). - Доступ к RCI с VPS: **KeenDNS** + **HTTP Proxy** для API (четвёртый уровень `rci.…`, порт **79**): [инструкция Keenetic](https://support.keenetic.com/hero/kn-1012/en/55035-using-api-methods-through-the-http-proxy-service.html). -- Пользователю роутера выданы права на **HTTP Proxy**; логин/пароль одинаковые для всех legacy-роутеров (задаются в `.env` сервиса). +- Пользователю роутера выданы права на **HTTP Proxy**. Учётка API: по умолчанию **`KEENETIC_*` в `.env`**, либо у каждого роутера свои поля / один раз URL `http(s)://логин:пароль@хост:порт` (при сохранении логин/пароль переносятся в поля). ## Установка (Ubuntu) @@ -18,7 +18,7 @@ git clone https://github.com/andrey271192/keenetic-dns-routes.git /opt/keenetic-dns-routes cd /opt/keenetic-dns-routes sudo bash install.sh -nano .env # ADMIN_PASSWORD, KEENETIC_LOGIN, KEENETIC_PASSWORD +nano .env # ADMIN_PASSWORD + при желании KEENETIC_LOGIN / KEENETIC_PASSWORD (дефолт для роутеров без своих полей) sudo systemctl restart keenetic-dns-routes ``` @@ -26,9 +26,9 @@ sudo systemctl restart keenetic-dns-routes ## Настройка -1. В **Interface ID** для US/RU укажи внутреннее имя интерфейса Keenetic (как в CLI: `Wireguard0`, `GigabitEthernet0`, `PPPoE0` и т.д.). Узнать можно в веб-интерфейсе или через `show interface` / утилиту [gokeenapi](https://github.com/Noksa/gokeenapi) `show-interfaces`. +1. В **Interface ID** для US/RU — имя интерфейса (`Wireguard0`, `PPPoE0`…). Кнопка **«Сканировать…»** подгружает список с роутера; опция **«Только WireGuard»** сужает выбор до WG-туннелей. 2. В списках — **одна строка = один домен или IPv4/IPv6/CIDR**. Пустые строки и строки с `#` в начале игнорируются. -3. Добавь роутеры: **RCI URL** вида `http://rci.имя.keenetic.pro:79` (без слэша в конце). +3. Добавь роутеры: **base URL** прокси KeenDNS (`http(s)://хост:порт`, без пути `/rci/...`). Логин/пароль — в полях или в URL `логин:пароль@хост`; если пусто — из `KEENETIC_*` в `.env`. 4. **Сохранить на сервер** — только JSON на VPS. 5. **Применить на всех legacy** или отметь галочками и **Только на выбранных** — пошлёт на каждый RCI дифф: удалит лишние `include`, добавит новые, обновит `dns-proxy route` при смене интерфейса, в конце `system configuration save`. @@ -39,8 +39,9 @@ sudo systemctl restart keenetic-dns-routes - `PUT /api/data` — полное или частичное обновление (`groups` и/или `routers`). - `POST /api/groups/{US|RU}/lines` — тело `{"add":["a.com"],"remove":["b.com"]}`: правка списка **на сервере** без пересылки всего textarea (порядок: сначала удаления, затем добавления в конец). - `POST /api/apply` — `{"mode":"all"|"selected","router_ids":["id1"]}`. -- `GET /api/keenetic-env` — логин Keenetic и флаг «пароль задан» (сам пароль не отдаётся). -- `GET /api/routers/{id}/interfaces` — список интерфейсов с роутера (`GET /rci/show/interface`), для подбора **Interface ID**. +- `GET /api/keenetic-env` — дефолтный логин из `.env` и флаг «KEENETIC_PASSWORD задан». +- `GET /api/routers/{id}/interfaces` — список интерфейсов (`GET /rci/show/interface`); query `wireguard_only=1` — только WireGuard. +- `PATCH /api/routers/{id}` — правка имени, URL, `keenetic_login` / `keenetic_password`. ## Ограничения @@ -50,9 +51,19 @@ sudo systemctl restart keenetic-dns-routes ## Обновление ```bash -cd /opt/keenetic-dns-routes && git pull && sudo systemctl restart keenetic-dns-routes +cd /opt/keenetic-dns-routes && sudo bash update.sh ``` +Если **`update.sh: No such file or directory`**: подтяни свежий `install.sh` с репозитория и один раз выполни `sudo bash install.sh` — он **создаст** `update.sh`, если файла нет. Либо вручную: + +```bash +cd /opt/keenetic-dns-routes && git pull --ff-only +source venv/bin/activate && pip install -r requirements.txt +sudo systemctl restart keenetic-dns-routes +``` + +Без git: скопируй каталог проекта поверх, затем снова `sudo bash update.sh` или команды выше. + ## Связь Проект рядом по смыслу с [keenetic-unified](https://github.com/andrey271192/keenetic-unified) (Neo + дашборд) и [domen_hydra](https://github.com/andrey271192/domen_hydra) (только Neo-конфиг), но предназначен **только** для встроенной DNS-маршрутизации без Neo. diff --git a/app/config.py b/app/config.py index c0a5ec3..add05e1 100644 --- a/app/config.py +++ b/app/config.py @@ -13,6 +13,6 @@ HOST = os.getenv("HOST", "0.0.0.0") PORT = int(os.getenv("PORT", "8001")) # Пустая строка в .env (ADMIN_PASSWORD=) не даёт дефолт через getenv — явно подставляем admin ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip() - +# Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым) KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip() KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").strip() diff --git a/app/main.py b/app/main.py index f361e85..b7cc7c0 100644 --- a/app/main.py +++ b/app/main.py @@ -5,14 +5,14 @@ import asyncio import logging from contextlib import asynccontextmanager from pathlib import Path - -from fastapi import FastAPI, Header, HTTPException +from fastapi import FastAPI, Header, HTTPException, Query from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from . import config from .models import ApplyRequest, AuthBody, RouterSpec, StoreData from .rci import KeeneticRCI, KeeneticRCIError, test_connection +from .rci_url import parse_rci_url, sanitize_router_dict from .store import ensure_store, load_store, new_router_id, save_store logging.basicConfig( @@ -28,6 +28,38 @@ def _chk(pwd: str) -> None: raise HTTPException(401, "Неверный пароль") +def router_rci_context(r: dict) -> tuple[str, str, str]: + """Чистый base URL и логин/пароль: поля роутера → user:pass@ в URL → .env (KEENETIC_*).""" + base, u_url, p_url = parse_rci_url(r.get("rci_base_url") or "") + login = ( + (r.get("keenetic_login") or "").strip() + or u_url + or (config.KEENETIC_LOGIN or "").strip() + ) + password = ( + (r.get("keenetic_password") or "").strip() + or p_url + or (config.KEENETIC_PASSWORD or "").strip() + ) + return base, login, password + + +def _require_router_rci_creds(r: dict) -> tuple[str, str, str]: + base, login, password = router_rci_context(r) + if not base: + raise HTTPException( + 400, + "Некорректный base URL прокси (нужен http(s)://хост:порт, при необходимости с user:pass@).", + ) + if not login or not password: + raise HTTPException( + 400, + "Нет логина/пароля для API: укажи у роутера, или в URL " + "http(s)://логин:пароль@хост:порт, или задай KEENETIC_LOGIN и KEENETIC_PASSWORD в .env.", + ) + return base, login, password + + @asynccontextmanager async def lifespan(app: FastAPI): ensure_store() @@ -39,7 +71,11 @@ app = FastAPI(title="Keenetic DNS Routes", version="1.0", lifespan=lifespan) @app.get("/", response_class=HTMLResponse) async def index(): - return (TPL / "index.html").read_text(encoding="utf-8") + html = (TPL / "index.html").read_text(encoding="utf-8") + return HTMLResponse( + content=html, + headers={"Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache"}, + ) @app.post("/api/auth") @@ -52,40 +88,63 @@ async def api_auth(b: AuthBody): @app.get("/api/data") async def get_data(x_admin_password: str = Header("")): _chk(x_admin_password) - return load_store() + cur = load_store() + if cur.get("routers"): + cur = { + **cur, + "routers": [ + sanitize_router_dict(dict(r)) if isinstance(r, dict) else r + for r in cur["routers"] + ], + } + return cur @app.get("/api/keenetic-env") async def keenetic_env(x_admin_password: str = Header("")): - """Логин и факт наличия пароля (сам пароль в ответ не кладём — только из .env на сервере).""" + """Дефолт из .env для роутеров без своих полей (пароль не отдаём).""" _chk(x_admin_password) return { + "mode": "env_or_router", "login": config.KEENETIC_LOGIN, "password_configured": bool(config.KEENETIC_PASSWORD), - "hint": "Пароль смотри только в server/.env (KEENETIC_PASSWORD); в браузер не передаётся.", + "hint": "Два варианта: (1) KEENETIC_LOGIN / KEENETIC_PASSWORD в .env — для роутеров без своих полей; " + "(2) у каждого роутера свои поля или один раз URL http(s)://логин:пароль@хост:порт (учётка уйдёт в поля).", } @app.get("/api/routers/{rid}/interfaces") -async def router_interfaces(rid: str, x_admin_password: str = Header("")): +async def router_interfaces( + rid: str, + wireguard_only: bool = Query(False, description="Только интерфейсы с type Wireguard"), + x_admin_password: str = Header(""), +): _chk(x_admin_password) - if not config.KEENETIC_PASSWORD: - raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env") cur = load_store() r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None) if not r: raise HTTPException(404, "Роутер не найден") + r = sanitize_router_dict(r) + _require_router_rci_creds(r) def _run(): - k = KeeneticRCI( - r["rci_base_url"], config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD - ) + base, lg, pw = router_rci_context(r) + k = KeeneticRCI(base, lg, pw) return k.list_interfaces() try: items = await asyncio.to_thread(_run) except KeeneticRCIError as e: raise HTTPException(502, str(e)) from e + except Exception as e: + logger.exception("router_interfaces rid=%s", rid) + raise HTTPException(502, f"RCI: {e}") from e + if wireguard_only: + items = [ + it + for it in items + if "wireguard" in str(it.get("type") or "").lower() + ] return {"interfaces": items} @@ -101,7 +160,10 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")): if b.groups is not None: cur["groups"] = b.groups if b.routers is not None: - cur["routers"] = b.routers + cur["routers"] = [ + sanitize_router_dict(dict(x)) if isinstance(x, dict) else x + for x in b.routers + ] try: StoreData.from_json(cur) except Exception as e: @@ -113,17 +175,31 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")): class AddRouterBody(BaseModel): name: str = Field(..., min_length=1) rci_base_url: str = Field(..., min_length=8) + keenetic_login: str = "" + keenetic_password: str = "" @app.post("/api/routers") async def add_router(b: AddRouterBody, x_admin_password: str = Header("")): _chk(x_admin_password) cur = load_store() + base, u_url, p_url = parse_rci_url(b.rci_base_url) + login = (b.keenetic_login or "").strip() or u_url or config.KEENETIC_LOGIN + password = (b.keenetic_password or "").strip() or p_url or config.KEENETIC_PASSWORD + if not base: + raise HTTPException(400, "Некорректный URL прокси") + if not login or not password: + raise HTTPException( + 400, + "Нужны логин и пароль: поля ниже, или в URL http(s)://логин:пароль@хост:порт, или KEENETIC_* в .env", + ) r = RouterSpec( id=new_router_id(), name=b.name.strip(), - rci_base_url=b.rci_base_url.strip().rstrip("/"), + rci_base_url=base, enabled=True, + keenetic_login=login, + keenetic_password=password, ) lst = list(cur.get("routers") or []) lst.append(r.model_dump()) @@ -141,21 +217,58 @@ async def del_router(rid: str, x_admin_password: str = Header("")): return {"ok": True} +class PatchRouterBody(BaseModel): + name: str | None = None + rci_base_url: str | None = None + keenetic_login: str | None = None + keenetic_password: str | None = None + enabled: bool | None = None + + +@app.patch("/api/routers/{rid}") +async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Header("")): + _chk(x_admin_password) + cur = load_store() + lst = list(cur.get("routers") or []) + idx = next((i for i, x in enumerate(lst) if x.get("id") == rid), -1) + if idx < 0: + raise HTTPException(404, "Роутер не найден") + r = dict(lst[idx]) + patch = b.model_dump(exclude_unset=True) + if "name" in patch and patch["name"] is not None: + r["name"] = str(patch["name"]).strip() or r["name"] + if "rci_base_url" in patch and patch["rci_base_url"] is not None: + r["rci_base_url"] = str(patch["rci_base_url"]).strip() + if "keenetic_login" in patch and patch["keenetic_login"] is not None: + r["keenetic_login"] = str(patch["keenetic_login"]).strip() + if "keenetic_password" in patch and patch["keenetic_password"] is not None: + r["keenetic_password"] = str(patch["keenetic_password"]) + if "enabled" in patch and patch["enabled"] is not None: + r["enabled"] = bool(patch["enabled"]) + r = sanitize_router_dict(r) + lst[idx] = r + cur["routers"] = lst + try: + StoreData.from_json(cur) + except Exception as e: + raise HTTPException(400, str(e)) from e + save_store(cur) + return r + + @app.post("/api/test-router/{rid}") async def test_router(rid: str, x_admin_password: str = Header("")): _chk(x_admin_password) - if not config.KEENETIC_PASSWORD: - raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env") cur = load_store() r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None) if not r: raise HTTPException(404, "Роутер не найден") - ok, msg = await asyncio.to_thread( - test_connection, - r["rci_base_url"], - config.KEENETIC_LOGIN, - config.KEENETIC_PASSWORD, - ) + r = sanitize_router_dict(r) + try: + base, lg, pw = _require_router_rci_creds(r) + except HTTPException: + raise + ok, msg = await asyncio.to_thread(test_connection, base, lg, pw) return {"ok": ok, "message": msg} @@ -197,8 +310,6 @@ async def patch_group_lines( @app.post("/api/apply") async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")): _chk(x_admin_password) - if not config.KEENETIC_PASSWORD: - raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env") cur = load_store() data = StoreData.from_json(cur) routers = data.routers @@ -211,15 +322,32 @@ async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")): if not routers: raise HTTPException(400, "Нет роутеров для применения") + raw_by_id = { + x.get("id"): sanitize_router_dict(dict(x)) + for x in (cur.get("routers") or []) + if isinstance(x, dict) and x.get("id") + } + for r in routers: + raw = raw_by_id.get(r.id) + if not raw: + raise HTTPException(400, f"Роутер {r.name}: нет записи в хранилище") + try: + _require_router_rci_creds(raw) + except HTTPException as e: + raise HTTPException( + 400, + f"{r.name}: нет логина/пароля — поля роутера, или URL user:pass@, или KEENETIC_* в .env.", + ) from e + groups_dump = {k: v.model_dump() for k, v in data.groups.items()} group_keys = tuple(data.groups.keys()) results: list[dict] = [] def _one(r: RouterSpec) -> dict: - k = KeeneticRCI( - r.rci_base_url, config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD - ) + raw = raw_by_id.get(r.id) or {} + base, lg, pw = router_rci_context(raw) + k = KeeneticRCI(base, lg, pw) try: log = k.apply_groups(groups_dump, group_names=group_keys) return {"router": r.name, "id": r.id, "ok": True, "log": log} diff --git a/app/models.py b/app/models.py index f888f46..78a1181 100644 --- a/app/models.py +++ b/app/models.py @@ -13,9 +13,11 @@ class RouterSpec(BaseModel): name: str rci_base_url: str = Field( ..., - description="Например http://rci.home.keenetic.pro:79 (KeenDNS HTTP Proxy)", + description="Базовый URL HTTP Proxy (http(s)://хост:порт), без путей /rci/...", ) enabled: bool = True + keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI") + keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI") class StoreData(BaseModel): @@ -34,9 +36,11 @@ class StoreData(BaseModel): if isinstance(v, dict): g[k] = GroupSpec(**v) rlist = [] + from .rci_url import sanitize_router_dict + for r in raw.get("routers") or []: if isinstance(r, dict) and r.get("id"): - rlist.append(RouterSpec(**r)) + rlist.append(RouterSpec(**sanitize_router_dict(r))) return cls(groups=g, routers=rlist) def to_json(self) -> dict[str, Any]: diff --git a/app/rci.py b/app/rci.py index 1608eb5..88c9cd8 100644 --- a/app/rci.py +++ b/app/rci.py @@ -5,6 +5,7 @@ import hashlib import logging import re from typing import Any +from urllib.parse import urlparse import httpx @@ -51,7 +52,15 @@ class KeeneticRCIError(RuntimeError): class KeeneticRCI: def __init__(self, base_url: str, login: str, password: str): - self.base_url = base_url.rstrip("/") + bu = base_url.rstrip("/") + # user:pass@ в base_url ломает httpx (дубли с NDMS-auth) и даёт 500 / странные ответы + rest = bu.split("://", 1)[-1] if "://" in bu else bu + if "@" in rest: + raise KeeneticRCIError( + "В base URL не должно быть user:pass@ — сохрани роутер ещё раз " + "(логин/пароль только в полях или перенесутся из URL при сохранении)." + ) + self.base_url = bu self.login = login self.password = password self._client: httpx.Client | None = None @@ -68,27 +77,49 @@ class KeeneticRCI: r = client.get("/auth") if r.status_code == 200: return - if r.status_code != 401: + if r.status_code == 404: + raise KeeneticRCIError( + "/auth HTTP 404: на этом адресе нет NDMS /auth — проверь хост и порт прокси " + "(часто нужен явный порт, например :81 или :443 для https), без лишнего пути в base URL." + ) + # 401 — стандартный challenge; 403 иногда даёт прокси до входа, но с теми же заголовками + if r.status_code not in (401, 403): raise KeeneticRCIError(f"/auth HTTP {r.status_code}") realm = r.headers.get("X-NDM-Realm", "") or r.headers.get("x-ndm-realm", "") challenge = r.headers.get("X-NDM-Challenge", "") or r.headers.get("x-ndm-challenge", "") set_cookie = r.headers.get("Set-Cookie") or r.headers.get("set-cookie") or "" - cookie = set_cookie.split(";")[0].strip() - if not realm or not challenge or not cookie: + cookie_pair = set_cookie.split(";")[0].strip() + if not realm or not challenge or not cookie_pair: + if r.status_code == 403: + raise KeeneticRCIError( + "/auth HTTP 403 без NDMS challenge: доступ к HTTP Proxy с IP этого сервера " + "запрещён в настройках роутера, либо открыт не тот сервис. " + "В веб-интерфейсе Keenetic: разрешённые адреса для API / прокси — добавь IP VPS." + ) raise KeeneticRCIError("Нет заголовков X-NDM-Realm / Challenge или Set-Cookie") md5_hex = hashlib.md5( f"{self.login}:{realm}:{self.password}".encode() ).hexdigest() sha_hex = hashlib.sha256(f"{challenge}{md5_hex}".encode()).hexdigest() - client.headers["Cookie"] = cookie + # Не писать Cookie в headers вручную — httpx иначе не подмешивает новую сессию из + # Set-Cookie после успешного POST /auth, и /rci/* отвечает 401. + client.cookies.update(r.cookies) + if not client.cookies and "=" in cookie_pair: + host = urlparse(self.base_url).hostname or "" + name, _, value = cookie_pair.partition("=") + client.cookies.set(name.strip(), value.strip(), domain=host) r2 = client.post( "/auth", json={"login": self.login, "password": sha_hex}, ) if r2.status_code in (401, 403): - raise KeeneticRCIError("Неверный логин или пароль Keenetic") + raise KeeneticRCIError( + "Неверный логин или пароль Keenetic (POST /auth). " + "Проверь учётку с доступом к HTTP Proxy / API и base URL (хост и порт как в настройках KeenDNS)." + ) if r2.status_code not in (200, 201, 202): raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}") + client.cookies.update(r2.cookies) def list_interfaces(self) -> list[dict[str, Any]]: """GET /rci/show/interface — id, type, description, state (как gokeenapi).""" @@ -97,7 +128,13 @@ class KeeneticRCI: r = client.get("/rci/show/interface") if r.status_code != 200: raise KeeneticRCIError(f"show/interface HTTP {r.status_code}") - data = r.json() + try: + data = r.json() + except ValueError as e: + raise KeeneticRCIError( + f"show/interface: ответ не JSON (возможно неверный URL прокси). " + f"Начало тела: {r.text[:160]!r}" + ) from e if not isinstance(data, dict): raise KeeneticRCIError("show/interface: ожидался объект JSON") rows: list[dict[str, Any]] = [] @@ -268,8 +305,17 @@ def test_connection(base_url: str, login: str, password: str) -> tuple[bool, str k._auth(c) r = c.get("/rci/show/version") if r.status_code != 200: + if r.status_code == 401: + return ( + False, + "RCI /rci/show/version → 401: сессия не принята " + "(часто неверный логин/пароль или устаревший клиент; обновите сервис).", + ) return False, f"version HTTP {r.status_code}" - j = r.json() + try: + j = r.json() + except ValueError: + return False, f"version: ответ не JSON (проверь URL прокси): {r.text[:120]!r}" title = j.get("title") or j.get("Title") or "?" return True, str(title) except Exception as e: diff --git a/app/rci_url.py b/app/rci_url.py new file mode 100644 index 0000000..0bef1dd --- /dev/null +++ b/app/rci_url.py @@ -0,0 +1,41 @@ +"""Разбор RCI base URL: отдельно host:port и учётные данные из user:pass@.""" +from __future__ import annotations + +from urllib.parse import unquote, urlparse + + +def parse_rci_url(url: str) -> tuple[str, str, str]: + """ + Возвращает (чистый base без userinfo, логин из URL или "", пароль из URL или ""). + Поддерживает http(s)://user:pass@host:port/... + """ + u = (url or "").strip().rstrip("/") + if not u: + return "", "", "" + p = urlparse(u.replace(" ", "")) + scheme = (p.scheme or "http").lower() + if scheme not in ("http", "https"): + scheme = "http" + user = unquote(p.username) if p.username else "" + pw = unquote(p.password) if p.password else "" + host = p.hostname + if not host: + return "", user, pw + port = p.port + netloc = f"{host}:{port}" if port else host + base = f"{scheme}://{netloc}" + return base, user, pw + + +def sanitize_router_dict(r: dict) -> dict: + """Убирает user:pass из rci_base_url и при необходимости переносит в keenetic_*.""" + out = dict(r) + raw = str(out.get("rci_base_url") or "") + base, u_url, p_url = parse_rci_url(raw) + if base: + out["rci_base_url"] = base + if not (out.get("keenetic_login") or "").strip() and u_url: + out["keenetic_login"] = u_url + if not (out.get("keenetic_password") or "").strip() and p_url: + out["keenetic_password"] = p_url + return out diff --git a/install.sh b/install.sh index 5ec6710..7adc80e 100755 --- a/install.sh +++ b/install.sh @@ -6,8 +6,36 @@ apt-get update -qq && apt-get install -y python3 python3-venv python3-pip python3 -m venv venv source venv/bin/activate pip install -q -r requirements.txt -if [ ! -f .env ]; then cp .env.example .env && echo "Создан .env — задай ADMIN_PASSWORD и KEENETIC_PASSWORD"; fi +if [ ! -f .env ]; then cp .env.example .env && echo "Создан .env — задай ADMIN_PASSWORD; при желании KEENETIC_* как дефолт для роутеров."; fi mkdir -p data + +# Старые клоны без update.sh в репозитории — создаём рядом с install.sh +if [ ! -f "$DIR/update.sh" ]; then + cat >"$DIR/update.sh" <<'EOS' +#!/bin/bash +# Обновление keenetic-dns-routes: git pull, pip, перезапуск systemd. +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" +echo "==> keenetic-dns-routes: $DIR" +if [ -d .git ]; then git pull --ff-only; else echo "(!) Нет .git — скопируй файлы проекта поверх, затем снова update.sh."; fi +if [ ! -d venv ]; then python3 -m venv venv; fi +# shellcheck source=/dev/null +source venv/bin/activate +pip install -q -r requirements.txt +if [ "$(id -u)" = 0 ] && [ -f /etc/systemd/system/keenetic-dns-routes.service ]; then + systemctl daemon-reload + systemctl restart keenetic-dns-routes + echo "==> systemd: keenetic-dns-routes перезапущен" +else + echo "Перезапуск (root): sudo systemctl restart keenetic-dns-routes" +fi +echo "==> Готово. Обнови страницу в браузере (Ctrl+F5)." +EOS + chmod +x "$DIR/update.sh" + echo "Создан $DIR/update.sh" +fi + if [ "$(id -u)" = 0 ]; then sed "s|WorkingDirectory=.*|WorkingDirectory=$DIR|" keenetic-dns-routes.service | \ sed "s|ExecStart=.*|ExecStart=$DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001|" \ @@ -21,3 +49,4 @@ else echo " cd $DIR && source venv/bin/activate && uvicorn app.main:app --host 0.0.0.0 --port 8001" fi echo "Интерфейс: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1):8001" +echo "Дальнейшее обновление кода: cd $DIR && sudo bash update.sh" diff --git a/templates/index.html b/templates/index.html index 0ac1b3a..9ed03d9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,9 +38,10 @@ #auth-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:100;align-items:center;justify-content:center} #auth-bg.on{display:flex} #auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)} - #ifscan-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px} - #ifscan-modal.on{display:flex} + #ifscan-modal,#edit-router-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px} + #ifscan-modal.on,#edit-router-modal.on{display:flex} #ifscan-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:900px;width:100%;max-height:88vh;overflow:auto;padding:18px} + #edit-router-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:520px;width:100%;padding:18px} .if-row{cursor:pointer} .if-row:hover{background:rgba(59,130,246,.12)} @@ -48,7 +49,8 @@

Keenetic DNS Routes

- +

Сюда только ADMIN_PASSWORD из .env (вход на эту страницу). Для API роутеров: поля ниже или KEENETIC_* в .env, или логин:пароль@ в URL.

+ @@ -61,10 +63,11 @@
@@ -76,19 +79,19 @@

US — домены и IP/CIDR (одна строка = одна запись)

-
+
- +
- +
@@ -105,21 +108,40 @@
-

Роутеры (KeenDNS → RCI base URL)

-

Логин/пароль для входа в RCI — в .env на сервере. Сводка сверху на странице; пароль в браузер не выводится.

+

Роутеры (KeenDNS → base URL прокси)

+

У каждого роутера: base URL + при необходимости логин/пароль, либо сразу URL http(s)://логин:пароль@хост:порт. Если поля логина/пароля пустые — берутся KEENETIC_* из .env на сервере.

+

Укажи тот же хост и порт, что в KeenDNS для HTTP Proxy (часто не стандартный порт). Для http без порта запрос уйдёт на 80 и может быть /auth 404. /auth 403 — проверь разрешённые IP для API на роутере.

-
-
+
+
+
+
- +
ИмяRCI base URLВклИмяBase URLЛогинПарольВкл
+
+

Роутер

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

Интерфейсы с роутера (RCI)

Выбери роутер → «Загрузить». Строка = то, что вписывается в Interface ID для текущей вкладки (US или RU). Обычно для VPN — Wireguard0 / OpenVPN0, для провайдера — PPPoE0 / GigabitEthernet0 и т.п.

@@ -128,6 +150,9 @@
+
@@ -152,8 +177,8 @@ async function loadCred(){ if(!r.ok)return; const j=await r.json(); document.getElementById('cred-card').style.display='block'; - const pw=j.password_configured?'пароль в .env задан':'KEENETIC_PASSWORD пустой'; - document.getElementById('cred-line').innerHTML='Логин: '+esc(j.login)+'   '+pw; + const pw=j.password_configured?'KEENETIC_PASSWORD в .env задан':'KEENETIC_PASSWORD в .env пустой'; + document.getElementById('cred-line').innerHTML='Дефолт из .env: логин '+esc(j.login||'')+'   '+pw; document.getElementById('cred-hint').textContent=j.hint||''; }catch(_){} } @@ -175,7 +200,9 @@ async function runIfScan(){ const L=document.getElementById('ifscan-list'); if(!id){L.innerHTML='Выбери роутера';return;} L.innerHTML='Загрузка…'; - const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces',{headers:hdr()}); + const wg=document.getElementById('ifscan-wg-only').checked; + const q=wg?'?wireguard_only=1':''; + const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces'+q,{headers:hdr()}); if(r.status===401){logout();return;} if(!r.ok){L.innerHTML=''+(await r.text())+'';return;} const j=await r.json(); @@ -205,14 +232,45 @@ function paint(){ document.getElementById('tx-'+k).value=(g.lines||[]).join('\n'); } const tb=document.querySelector('#rtbl tbody'); - tb.innerHTML=(ST.routers||[]).map(ro=>` - + tb.innerHTML=(ST.routers||[]).map(ro=>{ + const lo=String(ro.keenetic_login||'').trim(); + const hasPw=String(ro.keenetic_password||'').length>0; + const fromEnv=!lo&&!hasPw; + const iid=String(ro.id||''); + return ` + ${esc(ro.name)} ${esc(ro.rci_base_url)} - - - - `).join('')||'Нет роутеров'; + ${fromEnv?'из .env':esc(lo)} + ${fromEnv?'из .env':(hasPw?'есть':'нет')} + + + + + `; + }).join('')||'Нет роутеров'; +} +function openEditRouter(id){ + const ro=(ST.routers||[]).find(x=>x.id===id);if(!ro)return; + document.getElementById('er-id').value=ro.id; + document.getElementById('er-name').value=ro.name||''; + document.getElementById('er-url').value=ro.rci_base_url||''; + document.getElementById('er-login').value=ro.keenetic_login||''; + document.getElementById('er-pass').value=''; + document.getElementById('er-clear-pass').checked=false; + document.getElementById('edit-router-modal').classList.add('on'); +} +function closeEditRouter(){document.getElementById('edit-router-modal').classList.remove('on');} +async function saveEditRouter(){ + const id=document.getElementById('er-id').value; + const body={name:document.getElementById('er-name').value.trim(),rci_base_url:document.getElementById('er-url').value.trim(),keenetic_login:document.getElementById('er-login').value.trim()}; + if(document.getElementById('er-clear-pass').checked)body.keenetic_password=''; + else{const pw=document.getElementById('er-pass').value;if(pw)body.keenetic_password=pw;} + const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'PATCH',headers:hdr(),body:JSON.stringify(body)}); + if(r.status===401){logout();return;} + if(!r.ok){alert(await r.text());return;} + closeEditRouter(); + await load(); } function esc(s){return String(s||'').replace(/&/g,'&').replace(/s.trim()).filter(Boolean); + const raw=document.getElementById('tx-'+k).value||''; + const lines=raw.split(/\r?\n/).map(s=>s.trim()).filter(Boolean); groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines}; } return groups; } -async function saveServer(){ +/** + * @param {{skipLoad?:boolean}} opts — skipLoad: не перезагружать страницу (нужно перед «Только на выбранных», + * иначе load() сбрасывает чекбоксы выбора; или перед добавлением роутера, чтобы сначала сохранить US/RU). + */ +async function saveServer(opts){ + const skipLoad=opts&&opts.skipLoad; const groups=readForm(); const r=await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})}); if(r.status===401){logout();return;} if(!r.ok){alert(await r.text());return;} + ST.groups=groups; document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru'); - await load(); + if(!skipLoad) await load(); } async function addR(){ const name=document.getElementById('rn').value.trim(); const rci_base_url=document.getElementById('ru').value.trim(); + const keenetic_login=document.getElementById('rl').value.trim(); + const keenetic_password=document.getElementById('rp').value; if(!name||!rci_base_url){alert('Имя и URL');return;} - const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url})}); + // Сначала сохраняем US/RU с сервера в store, иначе после load() пропадут несохранённые домены + await saveServer({skipLoad:true}); + const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url,keenetic_login,keenetic_password})}); if(r.status===401){logout();return;} if(!r.ok){alert(await r.text());return;} document.getElementById('rn').value='';document.getElementById('ru').value=''; + document.getElementById('rl').value='';document.getElementById('rp').value=''; await load(); } async function delR(id){ @@ -267,7 +338,9 @@ async function delR(id){ } async function toggleEn(id,en){ ST.routers=(ST.routers||[]).map(x=>x.id===id?{...x,enabled:en}:x); - await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({routers:ST.routers})}); + const groups=readForm(); + ST.groups=groups; + await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})}); } async function testR(id){ const r=await fetch('/api/test-router/'+id,{method:'POST',headers:hdr()}); @@ -278,20 +351,25 @@ async function testR(id){ function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');} async function applyAll(){ - await saveServer(); + await saveServer({skipLoad:true}); const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'all'})}); if(r.status===401){logout();return;} const j=await r.json(); showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n')); + await load(); } async function applySel(){ - await saveServer(); - const ids=[...document.querySelectorAll('.sel:checked')].map(c=>c.dataset.id); - if(!ids.length){alert('Отметьте галочками роутеры');return;} + const ids=[...document.querySelectorAll('#rtbl input.kdns-sel:checked')].map(c=>c.getAttribute('data-id')).filter(Boolean); + if(!ids.length){ + alert('Отметьте чекбоксы в первой колонке таблицы «Роутеры» (слева), не переключатель «Вкл». Обнови страницу (Ctrl+F5), если не помогло.'); + return; + } + await saveServer({skipLoad:true}); const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'selected',router_ids:ids})}); if(r.status===401){logout();return;} const j=await r.json(); showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n')); + await load(); } (async()=>{ diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..1026318 --- /dev/null +++ b/update.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Обновление keenetic-dns-routes: git pull, pip, перезапуск systemd. +set -euo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" +echo "==> keenetic-dns-routes: $DIR" +if [ -d .git ]; then git pull --ff-only; else echo "(!) Нет .git — скопируй файлы проекта поверх, затем снова update.sh."; fi +if [ ! -d venv ]; then python3 -m venv venv; fi +# shellcheck source=/dev/null +source venv/bin/activate +pip install -q -r requirements.txt +if [ "$(id -u)" = 0 ] && [ -f /etc/systemd/system/keenetic-dns-routes.service ]; then + systemctl daemon-reload + systemctl restart keenetic-dns-routes + echo "==> systemd: keenetic-dns-routes перезапущен" +else + echo "Перезапуск (root): sudo systemctl restart keenetic-dns-routes" +fi +echo "==> Готово. Обнови страницу в браузере (Ctrl+F5)."