Per-router RCI creds + .env fallback; parse user:pass@ URL; fix apply/checkbox (kdns-sel), save groups on toggle; update.sh

Made-with: Cursor
This commit is contained in:
Андрей Бобырев
2026-04-25 01:42:16 +03:00
parent ef946e37f2
commit 4f42da9a0e
10 changed files with 437 additions and 80 deletions

View File

@@ -13,6 +13,6 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8001"))
# Пустая строка в .env (ADMIN_PASSWORD=) не даёт дефолт через getenv — явно подставляем admin
ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip()
# Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым)
KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip()
KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").strip()

View File

@@ -5,14 +5,14 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException
from fastapi import FastAPI, Header, HTTPException, Query
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 .rci_url import parse_rci_url, sanitize_router_dict
from .store import ensure_store, load_store, new_router_id, save_store
logging.basicConfig(
@@ -28,6 +28,38 @@ def _chk(pwd: str) -> None:
raise HTTPException(401, "Неверный пароль")
def router_rci_context(r: dict) -> tuple[str, str, str]:
"""Чистый base URL и логин/пароль: поля роутера → user:pass@ в URL → .env (KEENETIC_*)."""
base, u_url, p_url = parse_rci_url(r.get("rci_base_url") or "")
login = (
(r.get("keenetic_login") or "").strip()
or u_url
or (config.KEENETIC_LOGIN or "").strip()
)
password = (
(r.get("keenetic_password") or "").strip()
or p_url
or (config.KEENETIC_PASSWORD or "").strip()
)
return base, login, password
def _require_router_rci_creds(r: dict) -> tuple[str, str, str]:
base, login, password = router_rci_context(r)
if not base:
raise HTTPException(
400,
"Некорректный base URL прокси (нужен http(s)://хост:порт, при необходимости с user:pass@).",
)
if not login or not password:
raise HTTPException(
400,
"Нет логина/пароля для API: укажи у роутера, или в URL "
"http(s)://логин:пароль@хост:порт, или задай KEENETIC_LOGIN и KEENETIC_PASSWORD в .env.",
)
return base, login, password
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_store()
@@ -39,7 +71,11 @@ 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")
html = (TPL / "index.html").read_text(encoding="utf-8")
return HTMLResponse(
content=html,
headers={"Cache-Control": "no-store, no-cache, must-revalidate", "Pragma": "no-cache"},
)
@app.post("/api/auth")
@@ -52,40 +88,63 @@ async def api_auth(b: AuthBody):
@app.get("/api/data")
async def get_data(x_admin_password: str = Header("")):
_chk(x_admin_password)
return load_store()
cur = load_store()
if cur.get("routers"):
cur = {
**cur,
"routers": [
sanitize_router_dict(dict(r)) if isinstance(r, dict) else r
for r in cur["routers"]
],
}
return cur
@app.get("/api/keenetic-env")
async def keenetic_env(x_admin_password: str = Header("")):
"""Логин и факт наличия пароля (сам пароль в ответ не кладём — только из .env на сервере)."""
"""Дефолт из .env для роутеров без своих полей (пароль не отдаём)."""
_chk(x_admin_password)
return {
"mode": "env_or_router",
"login": config.KEENETIC_LOGIN,
"password_configured": bool(config.KEENETIC_PASSWORD),
"hint": "Пароль смотри только в server/.env (KEENETIC_PASSWORD); в браузер не передаётся.",
"hint": "Два варианта: (1) KEENETIC_LOGIN / KEENETIC_PASSWORD в .env — для роутеров без своих полей; "
"(2) у каждого роутера свои поля или один раз URL http(s)://логин:пароль@хост:порт (учётка уйдёт в поля).",
}
@app.get("/api/routers/{rid}/interfaces")
async def router_interfaces(rid: str, x_admin_password: str = Header("")):
async def router_interfaces(
rid: str,
wireguard_only: bool = Query(False, description="Только интерфейсы с type Wireguard"),
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, "Роутер не найден")
r = sanitize_router_dict(r)
_require_router_rci_creds(r)
def _run():
k = KeeneticRCI(
r["rci_base_url"], config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD
)
base, lg, pw = router_rci_context(r)
k = KeeneticRCI(base, lg, pw)
return k.list_interfaces()
try:
items = await asyncio.to_thread(_run)
except KeeneticRCIError as e:
raise HTTPException(502, str(e)) from e
except Exception as e:
logger.exception("router_interfaces rid=%s", rid)
raise HTTPException(502, f"RCI: {e}") from e
if wireguard_only:
items = [
it
for it in items
if "wireguard" in str(it.get("type") or "").lower()
]
return {"interfaces": items}
@@ -101,7 +160,10 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")):
if b.groups is not None:
cur["groups"] = b.groups
if b.routers is not None:
cur["routers"] = b.routers
cur["routers"] = [
sanitize_router_dict(dict(x)) if isinstance(x, dict) else x
for x in b.routers
]
try:
StoreData.from_json(cur)
except Exception as e:
@@ -113,17 +175,31 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")):
class AddRouterBody(BaseModel):
name: str = Field(..., min_length=1)
rci_base_url: str = Field(..., min_length=8)
keenetic_login: str = ""
keenetic_password: str = ""
@app.post("/api/routers")
async def add_router(b: AddRouterBody, x_admin_password: str = Header("")):
_chk(x_admin_password)
cur = load_store()
base, u_url, p_url = parse_rci_url(b.rci_base_url)
login = (b.keenetic_login or "").strip() or u_url or config.KEENETIC_LOGIN
password = (b.keenetic_password or "").strip() or p_url or config.KEENETIC_PASSWORD
if not base:
raise HTTPException(400, "Некорректный URL прокси")
if not login or not password:
raise HTTPException(
400,
"Нужны логин и пароль: поля ниже, или в URL http(s)://логин:пароль@хост:порт, или KEENETIC_* в .env",
)
r = RouterSpec(
id=new_router_id(),
name=b.name.strip(),
rci_base_url=b.rci_base_url.strip().rstrip("/"),
rci_base_url=base,
enabled=True,
keenetic_login=login,
keenetic_password=password,
)
lst = list(cur.get("routers") or [])
lst.append(r.model_dump())
@@ -141,21 +217,58 @@ async def del_router(rid: str, x_admin_password: str = Header("")):
return {"ok": True}
class PatchRouterBody(BaseModel):
name: str | None = None
rci_base_url: str | None = None
keenetic_login: str | None = None
keenetic_password: str | None = None
enabled: bool | None = None
@app.patch("/api/routers/{rid}")
async def patch_router(rid: str, b: PatchRouterBody, x_admin_password: str = Header("")):
_chk(x_admin_password)
cur = load_store()
lst = list(cur.get("routers") or [])
idx = next((i for i, x in enumerate(lst) if x.get("id") == rid), -1)
if idx < 0:
raise HTTPException(404, "Роутер не найден")
r = dict(lst[idx])
patch = b.model_dump(exclude_unset=True)
if "name" in patch and patch["name"] is not None:
r["name"] = str(patch["name"]).strip() or r["name"]
if "rci_base_url" in patch and patch["rci_base_url"] is not None:
r["rci_base_url"] = str(patch["rci_base_url"]).strip()
if "keenetic_login" in patch and patch["keenetic_login"] is not None:
r["keenetic_login"] = str(patch["keenetic_login"]).strip()
if "keenetic_password" in patch and patch["keenetic_password"] is not None:
r["keenetic_password"] = str(patch["keenetic_password"])
if "enabled" in patch and patch["enabled"] is not None:
r["enabled"] = bool(patch["enabled"])
r = sanitize_router_dict(r)
lst[idx] = r
cur["routers"] = lst
try:
StoreData.from_json(cur)
except Exception as e:
raise HTTPException(400, str(e)) from e
save_store(cur)
return r
@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,
)
r = sanitize_router_dict(r)
try:
base, lg, pw = _require_router_rci_creds(r)
except HTTPException:
raise
ok, msg = await asyncio.to_thread(test_connection, base, lg, pw)
return {"ok": ok, "message": msg}
@@ -197,8 +310,6 @@ async def patch_group_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
@@ -211,15 +322,32 @@ async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")):
if not routers:
raise HTTPException(400, "Нет роутеров для применения")
raw_by_id = {
x.get("id"): sanitize_router_dict(dict(x))
for x in (cur.get("routers") or [])
if isinstance(x, dict) and x.get("id")
}
for r in routers:
raw = raw_by_id.get(r.id)
if not raw:
raise HTTPException(400, f"Роутер {r.name}: нет записи в хранилище")
try:
_require_router_rci_creds(raw)
except HTTPException as e:
raise HTTPException(
400,
f"{r.name}: нет логина/пароля — поля роутера, или URL user:pass@, или KEENETIC_* в .env.",
) from e
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
)
raw = raw_by_id.get(r.id) or {}
base, lg, pw = router_rci_context(raw)
k = KeeneticRCI(base, lg, pw)
try:
log = k.apply_groups(groups_dump, group_names=group_keys)
return {"router": r.name, "id": r.id, "ok": True, "log": log}

View File

@@ -13,9 +13,11 @@ class RouterSpec(BaseModel):
name: str
rci_base_url: str = Field(
...,
description="Например http://rci.home.keenetic.pro:79 (KeenDNS HTTP Proxy)",
description="Базовый URL HTTP Proxy (http(s)://хост:порт), без путей /rci/...",
)
enabled: bool = True
keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI")
keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI")
class StoreData(BaseModel):
@@ -34,9 +36,11 @@ class StoreData(BaseModel):
if isinstance(v, dict):
g[k] = GroupSpec(**v)
rlist = []
from .rci_url import sanitize_router_dict
for r in raw.get("routers") or []:
if isinstance(r, dict) and r.get("id"):
rlist.append(RouterSpec(**r))
rlist.append(RouterSpec(**sanitize_router_dict(r)))
return cls(groups=g, routers=rlist)
def to_json(self) -> dict[str, Any]:

View File

@@ -5,6 +5,7 @@ import hashlib
import logging
import re
from typing import Any
from urllib.parse import urlparse
import httpx
@@ -51,7 +52,15 @@ class KeeneticRCIError(RuntimeError):
class KeeneticRCI:
def __init__(self, base_url: str, login: str, password: str):
self.base_url = base_url.rstrip("/")
bu = base_url.rstrip("/")
# user:pass@ в base_url ломает httpx (дубли с NDMS-auth) и даёт 500 / странные ответы
rest = bu.split("://", 1)[-1] if "://" in bu else bu
if "@" in rest:
raise KeeneticRCIError(
"В base URL не должно быть user:pass@ — сохрани роутер ещё раз "
"(логин/пароль только в полях или перенесутся из URL при сохранении)."
)
self.base_url = bu
self.login = login
self.password = password
self._client: httpx.Client | None = None
@@ -68,27 +77,49 @@ class KeeneticRCI:
r = client.get("/auth")
if r.status_code == 200:
return
if r.status_code != 401:
if r.status_code == 404:
raise KeeneticRCIError(
"/auth HTTP 404: на этом адресе нет NDMS /auth — проверь хост и порт прокси "
"(часто нужен явный порт, например :81 или :443 для https), без лишнего пути в base URL."
)
# 401 — стандартный challenge; 403 иногда даёт прокси до входа, но с теми же заголовками
if r.status_code not in (401, 403):
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:
cookie_pair = set_cookie.split(";")[0].strip()
if not realm or not challenge or not cookie_pair:
if r.status_code == 403:
raise KeeneticRCIError(
"/auth HTTP 403 без NDMS challenge: доступ к HTTP Proxy с IP этого сервера "
"запрещён в настройках роутера, либо открыт не тот сервис. "
"В веб-интерфейсе Keenetic: разрешённые адреса для API / прокси — добавь IP VPS."
)
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
# Не писать Cookie в headers вручную — httpx иначе не подмешивает новую сессию из
# Set-Cookie после успешного POST /auth, и /rci/* отвечает 401.
client.cookies.update(r.cookies)
if not client.cookies and "=" in cookie_pair:
host = urlparse(self.base_url).hostname or ""
name, _, value = cookie_pair.partition("=")
client.cookies.set(name.strip(), value.strip(), domain=host)
r2 = client.post(
"/auth",
json={"login": self.login, "password": sha_hex},
)
if r2.status_code in (401, 403):
raise KeeneticRCIError("Неверный логин или пароль Keenetic")
raise KeeneticRCIError(
"Неверный логин или пароль Keenetic (POST /auth). "
"Проверь учётку с доступом к HTTP Proxy / API и base URL (хост и порт как в настройках KeenDNS)."
)
if r2.status_code not in (200, 201, 202):
raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}")
client.cookies.update(r2.cookies)
def list_interfaces(self) -> list[dict[str, Any]]:
"""GET /rci/show/interface — id, type, description, state (как gokeenapi)."""
@@ -97,7 +128,13 @@ class KeeneticRCI:
r = client.get("/rci/show/interface")
if r.status_code != 200:
raise KeeneticRCIError(f"show/interface HTTP {r.status_code}")
data = r.json()
try:
data = r.json()
except ValueError as e:
raise KeeneticRCIError(
f"show/interface: ответ не JSON (возможно неверный URL прокси). "
f"Начало тела: {r.text[:160]!r}"
) from e
if not isinstance(data, dict):
raise KeeneticRCIError("show/interface: ожидался объект JSON")
rows: list[dict[str, Any]] = []
@@ -268,8 +305,17 @@ def test_connection(base_url: str, login: str, password: str) -> tuple[bool, str
k._auth(c)
r = c.get("/rci/show/version")
if r.status_code != 200:
if r.status_code == 401:
return (
False,
"RCI /rci/show/version → 401: сессия не принята "
"(часто неверный логин/пароль или устаревший клиент; обновите сервис).",
)
return False, f"version HTTP {r.status_code}"
j = r.json()
try:
j = r.json()
except ValueError:
return False, f"version: ответ не JSON (проверь URL прокси): {r.text[:120]!r}"
title = j.get("title") or j.get("Title") or "?"
return True, str(title)
except Exception as e:

41
app/rci_url.py Normal file
View File

@@ -0,0 +1,41 @@
"""Разбор RCI base URL: отдельно host:port и учётные данные из user:pass@."""
from __future__ import annotations
from urllib.parse import unquote, urlparse
def parse_rci_url(url: str) -> tuple[str, str, str]:
"""
Возвращает (чистый base без userinfo, логин из URL или "", пароль из URL или "").
Поддерживает http(s)://user:pass@host:port/...
"""
u = (url or "").strip().rstrip("/")
if not u:
return "", "", ""
p = urlparse(u.replace(" ", ""))
scheme = (p.scheme or "http").lower()
if scheme not in ("http", "https"):
scheme = "http"
user = unquote(p.username) if p.username else ""
pw = unquote(p.password) if p.password else ""
host = p.hostname
if not host:
return "", user, pw
port = p.port
netloc = f"{host}:{port}" if port else host
base = f"{scheme}://{netloc}"
return base, user, pw
def sanitize_router_dict(r: dict) -> dict:
"""Убирает user:pass из rci_base_url и при необходимости переносит в keenetic_*."""
out = dict(r)
raw = str(out.get("rci_base_url") or "")
base, u_url, p_url = parse_rci_url(raw)
if base:
out["rci_base_url"] = base
if not (out.get("keenetic_login") or "").strip() and u_url:
out["keenetic_login"] = u_url
if not (out.get("keenetic_password") or "").strip() and p_url:
out["keenetic_password"] = p_url
return out