From 3e97743e07d1d379632374c457ada73853e56a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 24 Apr 2026 22:45:28 +0300 Subject: [PATCH] Initial: FastAPI DNS routes manager for legacy Keenetic (RCI, port 8001) Made-with: Cursor --- .env.example | 7 ++ .gitignore | 6 + README.md | 56 +++++++++ app/__init__.py | 1 + app/config.py | 17 +++ app/main.py | 201 ++++++++++++++++++++++++++++++ app/models.py | 55 +++++++++ app/rci.py | 240 ++++++++++++++++++++++++++++++++++++ app/store.py | 43 +++++++ data/.gitkeep | 0 install.sh | 23 ++++ keenetic-dns-routes.service | 14 +++ requirements.txt | 5 + templates/index.html | 235 +++++++++++++++++++++++++++++++++++ 14 files changed, 903 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/rci.py create mode 100644 app/store.py create mode 100644 data/.gitkeep create mode 100755 install.sh create mode 100644 keenetic-dns-routes.service create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4d929c --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +HOST=0.0.0.0 +PORT=8001 +ADMIN_PASSWORD=change-me + +# Один логин/пароль для всех роутеров (KeenDNS HTTP Proxy → RCI) +KEENETIC_LOGIN=admin +KEENETIC_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb6102c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +.env +data/*.json +!data/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bf5a29 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d9a73cc --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# keenetic-dns-routes diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4f0ec58 --- /dev/null +++ b/app/config.py @@ -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", "") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..40e82b8 --- /dev/null +++ b/app/main.py @@ -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} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f888f46 --- /dev/null +++ b/app/models.py @@ -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 diff --git a/app/rci.py b/app/rci.py new file mode 100644 index 0000000..b4bfe8c --- /dev/null +++ b/app/rci.py @@ -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) diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..0b43d04 --- /dev/null +++ b/app/store.py @@ -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] diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5ec6710 --- /dev/null +++ b/install.sh @@ -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" diff --git a/keenetic-dns-routes.service b/keenetic-dns-routes.service new file mode 100644 index 0000000..802a6fe --- /dev/null +++ b/keenetic-dns-routes.service @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7659589 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7fb424a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,235 @@ + + + + + + Keenetic DNS Routes + + + +
+

Keenetic DNS Routes

+ + + + +
+ +
+

Keenetic DNS Routes

Списки DNS-маршрутизации (без Neo) · порт 8001
+ +
+ +
+
+ + +
+ +
+

US — домены и IP/CIDR (одна строка = одна запись)

+
+
+
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+
+ +
+

Роутеры (KeenDNS → RCI base URL)

+

Один логин/пароль Keenetic задаётся в .env на сервере (KEENETIC_LOGIN / KEENETIC_PASSWORD).

+
+
+
+
+
+
+ + +
ИмяRCI base URLВкл
+
+
+
+ + + +