From ef946e37f2239d5ca517a9f2c543cb6fe0d9de12 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: Sat, 25 Apr 2026 00:45:55 +0300 Subject: [PATCH] feat: scan interfaces via RCI; show Keenetic login + password status (no secret in UI) Made-with: Cursor --- README.md | 2 ++ app/main.py | 34 ++++++++++++++++++ app/rci.py | 36 +++++++++++++++++++ templates/index.html | 82 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 149 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0bf5a29..ab6db0f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ sudo systemctl restart keenetic-dns-routes - `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"]}`. +- `GET /api/keenetic-env` — логин Keenetic и флаг «пароль задан» (сам пароль не отдаётся). +- `GET /api/routers/{id}/interfaces` — список интерфейсов с роутера (`GET /rci/show/interface`), для подбора **Interface ID**. ## Ограничения diff --git a/app/main.py b/app/main.py index 7aae4b9..f361e85 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,40 @@ async def get_data(x_admin_password: str = Header("")): return load_store() +@app.get("/api/keenetic-env") +async def keenetic_env(x_admin_password: str = Header("")): + """Логин и факт наличия пароля (сам пароль в ответ не кладём — только из .env на сервере).""" + _chk(x_admin_password) + return { + "login": config.KEENETIC_LOGIN, + "password_configured": bool(config.KEENETIC_PASSWORD), + "hint": "Пароль смотри только в server/.env (KEENETIC_PASSWORD); в браузер не передаётся.", + } + + +@app.get("/api/routers/{rid}/interfaces") +async def router_interfaces(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, "Роутер не найден") + + def _run(): + k = KeeneticRCI( + r["rci_base_url"], config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD + ) + return k.list_interfaces() + + try: + items = await asyncio.to_thread(_run) + except KeeneticRCIError as e: + raise HTTPException(502, str(e)) from e + return {"interfaces": items} + + class PutDataBody(BaseModel): groups: dict[str, dict] | None = None routers: list[dict] | None = None diff --git a/app/rci.py b/app/rci.py index b4bfe8c..1608eb5 100644 --- a/app/rci.py +++ b/app/rci.py @@ -90,6 +90,42 @@ class KeeneticRCI: if r2.status_code not in (200, 201, 202): raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}") + def list_interfaces(self) -> list[dict[str, Any]]: + """GET /rci/show/interface — id, type, description, state (как gokeenapi).""" + with self._client_ctx() as client: + self._auth(client) + r = client.get("/rci/show/interface") + if r.status_code != 200: + raise KeeneticRCIError(f"show/interface HTTP {r.status_code}") + data = r.json() + if not isinstance(data, dict): + raise KeeneticRCIError("show/interface: ожидался объект JSON") + rows: list[dict[str, Any]] = [] + for key, body in data.items(): + if not isinstance(body, dict) or str(key).startswith("_"): + continue + iid = str(body.get("id") or body.get("Id") or key) + typ = str(body.get("type") or body.get("Type") or "") + desc = str(body.get("description") or body.get("Description") or "") + state = str(body.get("state") or body.get("State") or "") + link = str(body.get("link") or body.get("Link") or "") + conn = str(body.get("connected") or body.get("Connected") or "") + addr = str(body.get("address") or body.get("Address") or "") + rows.append( + { + "id": iid, + "type": typ, + "description": desc, + "state": state, + "link": link, + "connected": conn, + "address": addr, + "label": f"{iid} — {desc or typ or 'интерфейс'}", + } + ) + rows.sort(key=lambda x: x["id"].lower()) + return rows + def _parse_fqdn_response(self, data: dict[str, Any]) -> dict[str, list[str]]: out: dict[str, list[str]] = {} for name, body in data.items(): diff --git a/templates/index.html b/templates/index.html index 7fb424a..0ac1b3a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,6 +38,11 @@ #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)} + #ifscan-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:200;align-items:center;justify-content:center;padding:16px} + #ifscan-modal.on{display:flex} + #ifscan-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:900px;width:100%;max-height:88vh;overflow:auto;padding:18px} + .if-row{cursor:pointer} + .if-row:hover{background:rgba(59,130,246,.12)} @@ -55,6 +60,13 @@
+ +
@@ -62,17 +74,19 @@

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

-
-
+
+
+