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:
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]
|
||||
Reference in New Issue
Block a user