Initial: FastAPI DNS routes manager for legacy Keenetic (RCI, port 8001)

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-24 22:45:28 +03:00
commit 3e97743e07
14 changed files with 903 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
HOST=0.0.0.0
PORT=8001
ADMIN_PASSWORD=change-me
# Один логин/пароль для всех роутеров (KeenDNS HTTP Proxy → RCI)
KEENETIC_LOGIN=admin
KEENETIC_PASSWORD=

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.env
data/*.json
!data/.gitkeep

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# Keenetic DNS Routes
Мини-сервис для роутеров **без HydraRoute Neo**: централизованное редактирование списков **US / RU** (домены и IP/CIDR, по одной строке) и применение на роутеры через **NDM RCI** — те же `object-group fqdn` и `dns-proxy route`, что создаёт веб-интерфейс «Маршруты DNS» ([документация Keenetic](https://support.keenetic.com/carrier/kn-1711/en/51150-dns-based-routes.html)).
С **Keenetic Unified** и **domen_hydra** не смешивается: другой порт (**8001**), другие роутеры в своём списке, отдельный `data/store.json`.
Логика синхронизации списков с роутером совместима с подходом [gokeenapi](https://github.com/Noksa/gokeenapi) (`GET /rci/object-group/fqdn`, `GET /rci/dns-proxy/route`, `POST /rci/` с массивом `{"parse":"…"}`).
## Требования
- 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` сервиса).
## Установка (Ubuntu)
```bash
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
sudo systemctl restart keenetic-dns-routes
```
Интерфейс: `http://IP_СЕРВЕРА:8001`
## Настройка
1. В **Interface ID** для US/RU укажи внутреннее имя интерфейса Keenetic (как в CLI: `Wireguard0`, `GigabitEthernet0`, `PPPoE0` и т.д.). Узнать можно в веб-интерфейсе или через `show interface` / утилиту [gokeenapi](https://github.com/Noksa/gokeenapi) `show-interfaces`.
2. В списках — **одна строка = один домен или IPv4/IPv6/CIDR**. Пустые строки и строки с `#` в начале игнорируются.
3. Добавь роутеры: **RCI URL** вида `http://rci.имя.keenetic.pro:79` (без слэша в конце).
4. **Сохранить на сервер** — только JSON на VPS.
5. **Применить на всех legacy** или отметь галочками и **Только на выбранных** — пошлёт на каждый RCI дифф: удалит лишние `include`, добавит новые, обновит `dns-proxy route` при смене интерфейса, в конце `system configuration save`.
### API (скрипты)
Все запросы с заголовком `X-Admin-Password`.
- `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"]}`.
## Ограничения
- На один object-group Keenetic заводит лимит по числу записей (ориентир **~300** доменов на группу — как в gokeenapi). При превышении роутер может вернуть ошибку RCI.
- Строки без точки (не похожие на домен и не на IP/CIDR) отбрасываются при применении.
## Обновление
```bash
cd /opt/keenetic-dns-routes && git pull && sudo systemctl restart keenetic-dns-routes
```
## Связь
Проект рядом по смыслу с [keenetic-unified](https://github.com/andrey271192/keenetic-unified) (Neo + дашборд) и [domen_hydra](https://github.com/andrey271192/domen_hydra) (только Neo-конфиг), но предназначен **только** для встроенной DNS-маршрутизации без Neo.

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# keenetic-dns-routes

17
app/config.py Normal file
View File

@@ -0,0 +1,17 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
STORE_FILE = DATA_DIR / "store.json"
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8001"))
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
KEENETIC_LOGIN = os.getenv("KEENETIC_LOGIN", "admin")
KEENETIC_PASSWORD = os.getenv("KEENETIC_PASSWORD", "")

201
app/main.py Normal file
View File

@@ -0,0 +1,201 @@
"""Keenetic DNS Routes — встроенные списки KeeneticOS (без Neo), порт 8001."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException
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 .store import ensure_store, load_store, new_router_id, save_store
logging.basicConfig(
level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s"
)
logger = logging.getLogger("kdns")
TPL = Path(__file__).resolve().parent.parent / "templates"
def _chk(pwd: str) -> None:
if config.ADMIN_PASSWORD and pwd != config.ADMIN_PASSWORD:
raise HTTPException(401, "Неверный пароль")
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_store()
yield
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")
@app.post("/api/auth")
async def api_auth(b: AuthBody):
if config.ADMIN_PASSWORD and b.password == config.ADMIN_PASSWORD:
return {"ok": True}
raise HTTPException(401, "Wrong password")
@app.get("/api/data")
async def get_data(x_admin_password: str = Header("")):
_chk(x_admin_password)
return load_store()
class PutDataBody(BaseModel):
groups: dict[str, dict] | None = None
routers: list[dict] | None = None
@app.put("/api/data")
async def put_data(b: PutDataBody, x_admin_password: str = Header("")):
_chk(x_admin_password)
cur = load_store()
if b.groups is not None:
cur["groups"] = b.groups
if b.routers is not None:
cur["routers"] = b.routers
try:
StoreData.from_json(cur)
except Exception as e:
raise HTTPException(400, str(e)) from e
save_store(cur)
return {"ok": True}
class AddRouterBody(BaseModel):
name: str = Field(..., min_length=1)
rci_base_url: str = Field(..., min_length=8)
@app.post("/api/routers")
async def add_router(b: AddRouterBody, x_admin_password: str = Header("")):
_chk(x_admin_password)
cur = load_store()
r = RouterSpec(
id=new_router_id(),
name=b.name.strip(),
rci_base_url=b.rci_base_url.strip().rstrip("/"),
enabled=True,
)
lst = list(cur.get("routers") or [])
lst.append(r.model_dump())
cur["routers"] = lst
save_store(cur)
return r.model_dump()
@app.delete("/api/routers/{rid}")
async def del_router(rid: str, x_admin_password: str = Header("")):
_chk(x_admin_password)
cur = load_store()
cur["routers"] = [r for r in cur.get("routers") or [] if r.get("id") != rid]
save_store(cur)
return {"ok": True}
@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,
)
return {"ok": ok, "message": msg}
class GroupLinesPatch(BaseModel):
"""Инкрементально изменить строки группы на сервере (без полной перезаписи textarea)."""
add: list[str] = Field(default_factory=list)
remove: list[str] = Field(default_factory=list)
@app.post("/api/groups/{name}/lines")
async def patch_group_lines(
name: str, b: GroupLinesPatch, x_admin_password: str = Header("")
):
_chk(x_admin_password)
if name not in ("US", "RU"):
raise HTTPException(400, "Допустимы только группы US и RU")
cur = load_store()
groups = cur.setdefault("groups", {})
g = dict(groups.get(name) or {"interface_id": "", "lines": []})
lines = [str(x).strip() for x in (g.get("lines") or []) if str(x).strip()]
for rm in b.remove:
t = (rm or "").strip()
lines = [x for x in lines if x != t]
for ad in b.add:
t = (ad or "").strip()
if t and t not in lines:
lines.append(t)
g["lines"] = lines
groups[name] = g
try:
StoreData.from_json(cur)
except Exception as e:
raise HTTPException(400, str(e)) from e
save_store(cur)
return {"ok": True, "lines": 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
if b.mode == "selected":
sel = set(b.router_ids or [])
routers = [r for r in routers if r.id in sel]
else:
routers = [r for r in routers if r.enabled]
if not routers:
raise HTTPException(400, "Нет роутеров для применения")
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
)
try:
log = k.apply_groups(groups_dump, group_names=group_keys)
return {"router": r.name, "id": r.id, "ok": True, "log": log}
except KeeneticRCIError as e:
return {"router": r.name, "id": r.id, "ok": False, "error": str(e)}
except Exception as e:
logger.exception("apply %s", r.name)
return {"router": r.name, "id": r.id, "ok": False, "error": str(e)}
for r in routers:
results.append(await asyncio.to_thread(_one, r))
return {"results": results}

55
app/models.py Normal file
View File

@@ -0,0 +1,55 @@
from typing import Any
from pydantic import BaseModel, Field
class GroupSpec(BaseModel):
interface_id: str = ""
lines: list[str] = Field(default_factory=list)
class RouterSpec(BaseModel):
id: str
name: str
rci_base_url: str = Field(
...,
description="Например http://rci.home.keenetic.pro:79 (KeenDNS HTTP Proxy)",
)
enabled: bool = True
class StoreData(BaseModel):
groups: dict[str, GroupSpec] = Field(
default_factory=lambda: {
"US": GroupSpec(),
"RU": GroupSpec(),
}
)
routers: list[RouterSpec] = Field(default_factory=list)
@classmethod
def from_json(cls, raw: dict[str, Any]) -> "StoreData":
g: dict[str, GroupSpec] = {"US": GroupSpec(), "RU": GroupSpec()}
for k, v in (raw.get("groups") or {}).items():
if isinstance(v, dict):
g[k] = GroupSpec(**v)
rlist = []
for r in raw.get("routers") or []:
if isinstance(r, dict) and r.get("id"):
rlist.append(RouterSpec(**r))
return cls(groups=g, routers=rlist)
def to_json(self) -> dict[str, Any]:
return {
"groups": {k: v.model_dump() for k, v in self.groups.items()},
"routers": [r.model_dump() for r in self.routers],
}
class ApplyRequest(BaseModel):
mode: str = "all" # all | selected
router_ids: list[str] = Field(default_factory=list)
class AuthBody(BaseModel):
password: str

240
app/rci.py Normal file
View File

@@ -0,0 +1,240 @@
"""Keenetic NDMS RCI — авторизация как в gokeenapi / Keenetic Unified."""
from __future__ import annotations
import hashlib
import logging
import re
from typing import Any
import httpx
logger = logging.getLogger("kdns.rci")
_MAX_PARSE = 90 # ниже лимита gokeenapi (100), с запасом под save
def _norm_lines(lines: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for raw in lines:
s = raw.strip()
if not s or s.startswith("#"):
continue
if s in seen:
continue
seen.add(s)
out.append(s)
return out
def _is_ipish(s: str) -> bool:
if re.match(r"^\d{1,3}(\.\d{1,3}){3}(/\d+)?$", s):
return True
if "/" in s and re.match(r"^[0-9a-fA-F:.]+/\d+$", s):
return True
if re.match(r"^\d{1,3}(\.\d{1,3}){3}-\d{1,3}(\.\d{1,3}){3}$", s):
return True
return False
def _valid_entry(s: str) -> bool:
if _is_ipish(s):
return True
if "." in s and not s.startswith(".") and ".." not in s:
return True
return False
class KeeneticRCIError(RuntimeError):
pass
class KeeneticRCI:
def __init__(self, base_url: str, login: str, password: str):
self.base_url = base_url.rstrip("/")
self.login = login
self.password = password
self._client: httpx.Client | None = None
def _client_ctx(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
verify=False,
timeout=httpx.Timeout(60.0),
follow_redirects=True,
)
def _auth(self, client: httpx.Client) -> None:
r = client.get("/auth")
if r.status_code == 200:
return
if r.status_code != 401:
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:
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
r2 = client.post(
"/auth",
json={"login": self.login, "password": sha_hex},
)
if r2.status_code in (401, 403):
raise KeeneticRCIError("Неверный логин или пароль Keenetic")
if r2.status_code not in (200, 201, 202):
raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}")
def _parse_fqdn_response(self, data: dict[str, Any]) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for name, body in data.items():
if not isinstance(body, dict) or name.startswith("_"):
continue
inc = body.get("include") or body.get("Include") or []
addrs: list[str] = []
for item in inc:
if isinstance(item, dict):
a = item.get("address") or item.get("Address")
if a:
addrs.append(str(a))
elif item:
addrs.append(str(item))
out[name] = addrs
return out
def get_fqdn_groups(self, client: httpx.Client) -> dict[str, list[str]]:
r = client.get("/rci/object-group/fqdn")
if r.status_code != 200:
raise KeeneticRCIError(f"object-group/fqdn HTTP {r.status_code}: {r.text[:200]}")
data = r.json()
if not isinstance(data, dict):
raise KeeneticRCIError("object-group/fqdn: не JSON-объект")
return self._parse_fqdn_response(data)
def get_dns_routes(self, client: httpx.Client) -> dict[str, str]:
r = client.get("/rci/dns-proxy/route")
if r.status_code != 200:
raise KeeneticRCIError(f"dns-proxy/route HTTP {r.status_code}")
data = r.json()
if not isinstance(data, list):
raise KeeneticRCIError("dns-proxy/route: ожидался массив")
out: dict[str, str] = {}
for row in data:
if not isinstance(row, dict):
continue
g = row.get("group") or row.get("Group")
iface = row.get("interface") or row.get("Interface")
if g and iface:
out[str(g)] = str(iface)
return out
def _post_parse(self, client: httpx.Client, commands: list[str]) -> list[dict[str, Any]]:
all_resp: list[dict[str, Any]] = []
for i in range(0, len(commands), _MAX_PARSE):
chunk = commands[i : i + _MAX_PARSE]
body = [{"parse": c} for c in chunk]
r = client.post("/rci/", json=body)
if r.status_code != 200:
raise KeeneticRCIError(f"POST /rci/ HTTP {r.status_code}: {r.text[:500]}")
part = r.json()
if not isinstance(part, list):
raise KeeneticRCIError("POST /rci/: ответ не массив")
all_resp.extend(part)
for item in part:
p = item.get("parse") or item.get("Parse") or {}
for s in p.get("status") or p.get("Status") or []:
if not isinstance(s, dict):
continue
sv = (s.get("status") or s.get("Status") or "").lower()
if sv == "error":
raise KeeneticRCIError(
f"RCI: {s.get('code')} {s.get('ident', '')}{s.get('message', '')}"
)
return all_resp
def apply_groups(
self,
groups: dict[str, dict[str, Any]],
*,
group_names: tuple[str, ...] = ("US", "RU"),
) -> list[str]:
"""
Синхронизирует object-group fqdn + dns-proxy route для указанных групп.
Логика как в gokeenapi AddDnsRoutingGroups (инкрементально).
"""
log: list[str] = []
with self._client_ctx() as client:
self._auth(client)
existing = self.get_fqdn_groups(client)
routes = self.get_dns_routes(client)
cmds: list[str] = []
for gname in group_names:
spec = groups.get(gname) or {}
iface = (spec.get("interface_id") or "").strip()
raw_lines = spec.get("lines") or []
if not isinstance(raw_lines, list):
raw_lines = []
want = [x for x in _norm_lines([str(x) for x in raw_lines]) if _valid_entry(x)]
if not iface:
if want:
log.append(f"{gname}: пропуск — не задан interface_id")
continue
if not want:
log.append(f"{gname}: пропуск — пустой список строк")
continue
have = set(existing.get(gname, []))
want_set = set(want)
if gname not in existing:
cmds.append(f"object-group fqdn {gname}")
for ex in existing.get(gname, []):
if ex not in want_set:
cmds.append(f"no object-group fqdn {gname} include {ex}")
for w in want:
if w not in have:
cmds.append(f"object-group fqdn {gname} include {w}")
cur_if = routes.get(gname)
if cur_if != iface:
if cur_if:
cmds.append(f"no dns-proxy route object-group {gname} {cur_if}")
cmds.append(f"dns-proxy route object-group {gname} {iface} auto")
if not cmds:
log.append("Изменений нет (уже совпадает с роутером)")
return log
cmds.append("system configuration save")
logger.info("RCI %s команд на %s", len(cmds), self.base_url)
self._post_parse(client, cmds)
log.append(f"Применено команд: {len(cmds)}")
return log
def test_connection(base_url: str, login: str, password: str) -> tuple[bool, str]:
try:
with httpx.Client(
base_url=base_url.rstrip("/"),
verify=False,
timeout=httpx.Timeout(15.0),
follow_redirects=True,
) as c:
k = KeeneticRCI(base_url, login, password)
k._auth(c)
r = c.get("/rci/show/version")
if r.status_code != 200:
return False, f"version HTTP {r.status_code}"
j = r.json()
title = j.get("title") or j.get("Title") or "?"
return True, str(title)
except Exception as e:
return False, str(e)

43
app/store.py Normal file
View File

@@ -0,0 +1,43 @@
import json
import uuid
from pathlib import Path
from typing import Any
from . import config
def _default_store() -> dict[str, Any]:
return {
"groups": {
"US": {"interface_id": "", "lines": []},
"RU": {"interface_id": "", "lines": []},
},
"routers": [],
}
def ensure_store() -> None:
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
if not config.STORE_FILE.exists():
config.STORE_FILE.write_text(
json.dumps(_default_store(), ensure_ascii=False, indent=2), encoding="utf-8"
)
def load_store() -> dict[str, Any]:
ensure_store()
try:
return json.loads(config.STORE_FILE.read_text(encoding="utf-8"))
except Exception:
return _default_store()
def save_store(data: dict[str, Any]) -> None:
ensure_store()
config.STORE_FILE.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def new_router_id() -> str:
return str(uuid.uuid4())[:8]

0
data/.gitkeep Normal file
View File

23
install.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
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
mkdir -p data
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|" \
> /etc/systemd/system/keenetic-dns-routes.service
systemctl daemon-reload
systemctl enable keenetic-dns-routes
systemctl restart keenetic-dns-routes
echo "Сервис keenetic-dns-routes запущен"
else
echo "Запусти install.sh от root для systemd, или локально:"
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"

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Keenetic DNS Routes (legacy DNS-based routing)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/keenetic-dns-routes
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/keenetic-dns-routes/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.27,<1
httpx>=0.27,<1
python-dotenv>=1.0,<2
pydantic>=2.5,<3

235
templates/index.html Normal file
View File

@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Keenetic DNS Routes</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0b;--card:#161618;--bd:#2a2a2e;--tx:#f4f4f5;--mu:#71717a;--ac:#3b82f6;--ok:#22c55e;--er:#ef4444}
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--tx);min-height:100vh}
.hdr{padding:14px 20px;border-bottom:1px solid var(--bd);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.hdr h1{font-size:17px;font-weight:800}
.hdr small{color:var(--mu);font-weight:500}
.wrap{max-width:1100px;margin:0 auto;padding:20px}
.tabs{display:flex;gap:6px;margin-bottom:16px}
.tab{padding:8px 18px;border-radius:10px;border:1px solid var(--bd);background:var(--card);color:var(--mu);font-weight:600;cursor:pointer;font-size:13px}
.tab.on{background:var(--ac);color:#fff;border-color:var(--ac)}
.card{background:var(--card);border:1px solid var(--bd);border-radius:14px;padding:18px;margin-bottom:16px}
.card h2{font-size:14px;margin-bottom:10px}
label{display:block;font-size:11px;color:var(--mu);margin-bottom:4px;font-weight:600}
input[type=text],input[type=url],input[type=password],textarea{width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px 12px;color:var(--tx);font-size:13px;font-family:inherit}
textarea{min-height:220px;font-family:ui-monospace,monospace;font-size:12px;line-height:1.45;resize:vertical}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.row > div{flex:1;min-width:200px}
.btn{padding:9px 18px;border-radius:10px;border:none;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit}
.btn-b{background:var(--ac);color:#fff}
.btn-g{background:var(--ok);color:#0a0a0b}
.btn-d{background:#3f3f46;color:#fff}
.btn-o{background:#ea580c;color:#fff}
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th,.tbl td{padding:8px 6px;border-bottom:1px solid var(--bd);text-align:left}
.tbl th{color:var(--mu);font-size:11px;text-transform:uppercase}
.log{margin-top:10px;padding:12px;background:#111;border-radius:10px;font-size:12px;white-space:pre-wrap;max-height:240px;overflow-y:auto;display:none}
.log.show{display:block}
.pill{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:700}
.pill.ok{background:rgba(34,197,94,.15);color:var(--ok)}
.pill.bad{background:rgba(239,68,68,.15);color:var(--er)}
#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)}
</style>
</head>
<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>
<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>
</div></div>
<div class="hdr">
<div><h1>Keenetic DNS Routes</h1><small>Списки DNS-маршрутизации (без Neo) · порт 8001</small></div>
<button class="btn btn-d" onclick="logout()">Выйти</button>
</div>
<div class="wrap">
<div class="tabs">
<button class="tab on" data-t="US" onclick="setTab('US')">Список US</button>
<button class="tab" data-t="RU" onclick="setTab('RU')">Список RU</button>
</div>
<div class="card" id="panel-US">
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
<div class="row">
<div><label>Interface ID (например Wireguard0, PPPoE0)</label>
<input type="text" id="if-US" placeholder="Wireguard0"/></div>
</div>
<textarea id="tx-US" placeholder="youtube.com&#10;1.2.3.0/24"></textarea>
</div>
<div class="card" id="panel-RU" style="display:none">
<h2>RU — домены и IP/CIDR</h2>
<div class="row">
<div><label>Interface ID (например GigabitEthernet0)</label>
<input type="text" id="if-RU" placeholder="GigabitEthernet0"/></div>
</div>
<textarea id="tx-RU" placeholder="yandex.ru"></textarea>
</div>
<div class="row" style="align-items:center;margin-top:4px">
<input type="text" id="one-line" placeholder="Добавить одну строку в активную вкладку (US/RU)…" style="flex:1"/>
<button class="btn btn-d" type="button" onclick="appendCurrentTab()">+ В текст</button>
</div>
<div class="row">
<button class="btn btn-g" onclick="saveServer()">Сохранить на сервер</button>
<button class="btn btn-o" onclick="applyAll()">Применить на всех legacy</button>
<button class="btn btn-b" onclick="applySel()">Только на выбранных</button>
</div>
<div id="sv" style="font-size:12px;color:var(--mu);margin-bottom:8px"></div>
<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">Один логин/пароль Keenetic задаётся в <code>.env</code> на сервере (KEENETIC_LOGIN / KEENETIC_PASSWORD).</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><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>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
<script>
let ST={groups:{US:{interface_id:'',lines:[]},RU:{interface_id:'',lines:[]}},routers:[]};
function hdr(){return{'Content-Type':'application/json','X-Admin-Password':sessionStorage.getItem('kdns_pw')||''};}
async function doLogin(){
const p=document.getElementById('apw').value;
const e=document.getElementById('aerr');
e.style.display='none';
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){sessionStorage.setItem('kdns_pw',p);document.getElementById('auth-bg').classList.remove('on');await load();}
else{e.textContent='Неверный пароль';e.style.display='block';}
}
function logout(){sessionStorage.removeItem('kdns_pw');document.getElementById('auth-bg').classList.add('on');}
async function load(){
const r=await fetch('/api/data',{headers:hdr()});
if(r.status===401){logout();return;}
ST=await r.json();
paint();
}
function paint(){
for(const k of['US','RU']){
const g=ST.groups[k]||{};
document.getElementById('if-'+k).value=g.interface_id||'';
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>
<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>';
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');}
function setTab(t){
document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.t===t));
document.getElementById('panel-US').style.display=t==='US'?'block':'none';
document.getElementById('panel-RU').style.display=t==='RU'?'block':'none';
}
function activeGroup(){return(document.querySelector('.tab.on')||{}).dataset?.t||'US';}
function appendCurrentTab(){
const k=activeGroup();
const line=document.getElementById('one-line').value.trim();
if(!line)return;
const ta=document.getElementById('tx-'+k);
const v=ta.value.replace(/\s+$/,'');
ta.value=(v?v+'\n':'')+line;
document.getElementById('one-line').value='';
}
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);
groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines};
}
return groups;
}
async function saveServer(){
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;}
document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru');
await load();
}
async function addR(){
const name=document.getElementById('rn').value.trim();
const rci_base_url=document.getElementById('ru').value.trim();
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})});
if(r.status===401){logout();return;}
if(!r.ok){alert(await r.text());return;}
document.getElementById('rn').value='';document.getElementById('ru').value='';
await load();
}
async function delR(id){
if(!confirm('Удалить?'))return;
await fetch('/api/routers/'+id,{method:'DELETE',headers:hdr()});
await load();
}
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})});
}
async function testR(id){
const r=await fetch('/api/test-router/'+id,{method:'POST',headers:hdr()});
const j=await r.json();
alert(j.ok?'OK: '+j.message:'Ошибка: '+j.message);
}
function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');}
async function applyAll(){
await saveServer();
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'));
}
async function applySel(){
await saveServer();
const ids=[...document.querySelectorAll('.sel:checked')].map(c=>c.dataset.id);
if(!ids.length){alert('Отметьте галочками роутеры');return;}
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'));
}
(async()=>{
const p=sessionStorage.getItem('kdns_pw');
if(p){
const r=await fetch('/api/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:p})});
if(r.ok){document.getElementById('auth-bg').classList.remove('on');await load();}
else{sessionStorage.removeItem('kdns_pw');}
}
})();
</script>
</body>
</html>