mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-21 14:52:00 +00:00
Initial: FastAPI DNS routes manager for legacy Keenetic (RCI, port 8001)
Made-with: Cursor
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/*.json
|
||||||
|
!data/.gitkeep
|
||||||
56
README.md
Normal file
56
README.md
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# keenetic-dns-routes
|
||||||
17
app/config.py
Normal file
17
app/config.py
Normal 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
201
app/main.py
Normal 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
55
app/models.py
Normal 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
240
app/rci.py
Normal 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
43
app/store.py
Normal 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
0
data/.gitkeep
Normal file
23
install.sh
Executable file
23
install.sh
Executable 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"
|
||||||
14
keenetic-dns-routes.service
Normal file
14
keenetic-dns-routes.service
Normal 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
5
requirements.txt
Normal 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
235
templates/index.html
Normal 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 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> </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,'&').replace(/</g,'<').replace(/"/g,'"');}
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user