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