mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-20 14:42:01 +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