Per-router RCI creds + .env fallback; parse user:pass@ URL; fix apply/checkbox (kdns-sel), save groups on toggle; update.sh

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-25 01:42:16 +03:00
parent ef946e37f2
commit 4f42da9a0e
10 changed files with 437 additions and 80 deletions

View File

@@ -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=

View File

@@ -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.

View File

@@ -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()

View File

@@ -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}

View File

@@ -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]:

View File

@@ -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:

41
app/rci_url.py Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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)}
</style>
@@ -48,7 +49,8 @@
<body>
<div id="auth-bg" class="on"><div id="auth-box">
<h2 style="margin-bottom:12px;font-size:18px">Keenetic DNS Routes</h2>
<label>Пароль веб-интерфейса</label>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Сюда только <b>ADMIN_PASSWORD</b> из <code>.env</code> (вход на эту страницу). Для API роутеров: поля ниже или <code>KEENETIC_*</code> в <code>.env</code>, или <code>логин:пароль@</code> в URL.</p>
<label>Пароль веб-интерфейса (ADMIN_PASSWORD)</label>
<input type="password" id="apw" autocomplete="current-password" style="margin-bottom:12px" onkeydown="if(event.key==='Enter')doLogin()"/>
<div id="aerr" style="color:var(--er);font-size:12px;margin-bottom:8px;display:none"></div>
<button class="btn btn-b" style="width:100%" onclick="doLogin()">Войти</button>
@@ -61,10 +63,11 @@
<div class="wrap">
<div class="card" id="cred-card" style="display:none">
<h2>Учётная запись Keenetic (RCI)</h2>
<h2>Учётные данные Keenetic (API)</h2>
<p style="font-size:11px;color:var(--mu);margin-bottom:8px;line-height:1.5">Два варианта по умолчанию: <b>KEENETIC_LOGIN / KEENETIC_PASSWORD</b> в <code>.env</code> на сервере <b>или</b> свои логин/пароль (или <code>http(s)://логин:пароль@хост:порт</code>) у каждого роутера. Пароль входа на эту страницу — только <code>ADMIN_PASSWORD</code>.</p>
<p id="cred-line" style="font-size:14px;margin-bottom:6px"></p>
<p id="cred-hint" style="font-size:11px;color:var(--mu);line-height:1.5"></p>
<p style="font-size:11px;color:var(--mu);margin-top:8px">Пароль в этот экран <b>не передаётся</b> только в <code>/opt/keenetic-dns-routes/.env</code> как <code>KEENETIC_PASSWORD</code>. Для KeenDNS API часто нужен URL вида <code>http://rci.имя.keenetic.pro:79</code>, а не HTTPS веб-морды.</p>
<p style="font-size:11px;color:var(--mu);margin-top:8px">В URL можно указать <code>логин:пароль@</code> перед хостом — при сохранении они переносятся в поля, в адресе остаётся только <code>http(s)://хост:порт</code>. Пути вида <code>/rci/...</code> в base URL не указывай — их добавляет сервис.</p>
</div>
<div class="tabs">
@@ -76,19 +79,19 @@
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0…)</label>
<input type="text" id="if-US" placeholder="Wireguard0"/></div>
<input type="text" id="if-US" placeholder="Wireguard0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
</div>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24"></textarea>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="card" id="panel-RU" style="display:none">
<h2>RU — домены и IP/CIDR</h2>
<div class="row" style="align-items:flex-end">
<div style="flex:1"><label>Interface ID</label>
<input type="text" id="if-RU" placeholder="GigabitEthernet0"/></div>
<input type="text" id="if-RU" placeholder="GigabitEthernet0" oninput="ST.groups=readForm()"/></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
</div>
<textarea id="tx-RU" placeholder="yandex.ru"></textarea>
<textarea id="tx-RU" placeholder="yandex.ru" oninput="ST.groups=readForm()"></textarea>
</div>
<div class="row" style="align-items:center;margin-top:4px">
@@ -105,21 +108,40 @@
<div class="log" id="log"></div>
<div class="card" style="margin-top:24px">
<h2>Роутеры (KeenDNS → RCI base URL)</h2>
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">Логин/пароль для входа в RCI — в <code>.env</code> на сервере. Сводка сверху на странице; пароль в браузер не выводится.</p>
<h2>Роутеры (KeenDNS → base URL прокси)</h2>
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">У каждого роутера: <b>base URL</b> + при необходимости <b>логин/пароль</b>, либо сразу URL <code>http(s)://логин:пароль@хост:порт</code>. Если поля логина/пароля пустые — берутся <code>KEENETIC_*</code> из <code>.env</code> на сервере.</p>
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Укажи тот же <b>хост и порт</b>, что в KeenDNS для HTTP Proxy (часто не стандартный порт). Для <code>http</code> без порта запрос уйдёт на <b>80</b> и может быть <code>/auth 404</code>. <code>/auth 403</code> — проверь разрешённые IP для API на роутере.</p>
<div class="row">
<div><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
<div style="flex:2"><label>RCI URL</label><input type="url" id="ru" placeholder="http://rci.home.keenetic.pro:79"/></div>
<div style="min-width:120px"><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
<div style="flex:2;min-width:220px"><label>Base URL прокси</label><input type="url" id="ru" placeholder="https://homesmart.netcraze.pro:81"/></div>
<div style="min-width:100px"><label>Логин (если не .env)</label><input type="text" id="rl" placeholder="пусто = из .env"/></div>
<div style="min-width:120px"><label>Пароль (если не .env)</label><input type="password" id="rp" placeholder="пусто = из .env" autocomplete="new-password"/></div>
<div><label>&nbsp;</label><button class="btn btn-b" onclick="addR()">+ Добавить</button></div>
</div>
<div style="overflow-x:auto">
<table class="tbl" id="rtbl"><thead><tr>
<th style="width:36px"></th><th>Имя</th><th>RCI base URL</th><th>Вкл</th><th></th>
<th style="width:36px" title="Выбор для кнопки «Только на выбранных»"></th><th>Имя</th><th>Base URL</th><th>Логин</th><th>Пароль</th><th title="Включить/выключить роутер для «Применить на всех»">Вкл</th><th></th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
<div id="edit-router-modal"><div id="edit-router-box">
<h3 style="margin-bottom:12px;font-size:16px">Роутер</h3>
<input type="hidden" id="er-id"/>
<div class="row"><div style="flex:1"><label>Имя</label><input type="text" id="er-name"/></div></div>
<div class="row"><div style="flex:1"><label>Base URL прокси</label><input type="url" id="er-url" placeholder="https://хост:порт"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic логин</label><input type="text" id="er-login" placeholder="логин RCI"/></div></div>
<div class="row"><div style="flex:1"><label>Keenetic пароль</label><input type="password" id="er-pass" placeholder="пусто = не менять" autocomplete="new-password"/></div></div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:12px">
<input type="checkbox" id="er-clear-pass"/> Очистить пароль (потом задай заново или в URL user:pass@)
</label>
<div class="row" style="margin-bottom:0">
<button type="button" class="btn btn-b" onclick="saveEditRouter()">Сохранить</button>
<button type="button" class="btn btn-d" onclick="closeEditRouter()">Отмена</button>
</div>
</div></div>
<div id="ifscan-modal"><div id="ifscan-box">
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
@@ -128,6 +150,9 @@
<select id="ifscan-router" style="width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px;color:var(--tx);font-size:13px"></select></div>
<div><label>&nbsp;</label><button type="button" class="btn btn-b" onclick="runIfScan()">Загрузить</button></div>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:10px">
<input type="checkbox" id="ifscan-wg-only"/> Только WireGuard (удобно выбрать туннель US/RU)
</label>
<div id="ifscan-list" style="font-size:12px"></div>
<button type="button" class="btn btn-d" style="margin-top:14px" onclick="closeIfScan()">Закрыть</button>
</div></div>
@@ -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?'<span class="pill ok">пароль в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD пустой</span>';
document.getElementById('cred-line').innerHTML='Логин: <b>'+esc(j.login)+'</b> &nbsp; '+pw;
const pw=j.password_configured?'<span class="pill ok">KEENETIC_PASSWORD в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD в .env пустой</span>';
document.getElementById('cred-line').innerHTML='Дефолт из .env: логин <b>'+esc(j.login||'')+'</b> &nbsp; '+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='<span style="color:var(--er)">Выбери роутера</span>';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='<span style="color:var(--er)">'+(await r.text())+'</span>';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=>`<tr>
<td><input type="checkbox" class="sel" data-id="${ro.id}"/></td>
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 `<tr>
<td><input type="checkbox" class="kdns-sel" data-id="${esc(iid)}" title="Выбор для «Только на выбранных»"/></td>
<td>${esc(ro.name)}</td>
<td style="font-size:11px;word-break:break-all">${esc(ro.rci_base_url)}</td>
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange="toggleEn('${ro.id}',this.checked)"/></td>
<td><button class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="testR('${ro.id}')">Тест</button>
<button class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="delR('${ro.id}')">✕</button></td>
</tr>`).join('')||'<tr><td colspan="5" style="color:var(--mu)">Нет роутеров</td></tr>';
<td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td>
<td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td>
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange="toggleEn(${JSON.stringify(iid)},this.checked)"/></td>
<td><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="openEditRouter(${JSON.stringify(iid)})">Изм.</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="testR(${JSON.stringify(iid)})">Тест</button>
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="delR(${JSON.stringify(iid)})">✕</button></td>
</tr>`;
}).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>';
}
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,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');}
@@ -230,34 +288,47 @@ function appendCurrentTab(){
const v=ta.value.replace(/\s+$/,'');
ta.value=(v?v+'\n':'')+line;
document.getElementById('one-line').value='';
ST.groups=readForm();
}
function readForm(){
const groups={};
for(const k of['US','RU']){
const lines=document.getElementById('tx-'+k).value.split('\n').map(s=>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()=>{

19
update.sh Executable file
View File

@@ -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)."