mirror of
https://github.com/andrey271192/keenetic-dns-routes.git
synced 2026-09-20 14:42:01 +00:00
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:
@@ -1,8 +1,9 @@
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8001
|
PORT=8001
|
||||||
# Если строка пустая или ключ убран — будет пароль admin
|
|
||||||
|
# Вход на веб-страницу сервиса (:8001). Если пусто — admin
|
||||||
ADMIN_PASSWORD=change-me
|
ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
# Один логин/пароль для всех роутеров (KeenDNS HTTP Proxy → RCI)
|
# Дефолт для Keenetic API, если у роутера не заданы поля логин/пароль и нет user:pass@ в URL
|
||||||
KEENETIC_LOGIN=admin
|
KEENETIC_LOGIN=admin
|
||||||
KEENETIC_PASSWORD=
|
KEENETIC_PASSWORD=
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- KeeneticOS **≥ 5.0.1** (DNS-based routes).
|
- 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).
|
- Доступ к 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` сервиса).
|
- Пользователю роутера выданы права на **HTTP Proxy**. Учётка API: по умолчанию **`KEENETIC_*` в `.env`**, либо у каждого роутера свои поля / один раз URL `http(s)://логин:пароль@хост:порт` (при сохранении логин/пароль переносятся в поля).
|
||||||
|
|
||||||
## Установка (Ubuntu)
|
## Установка (Ubuntu)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
git clone https://github.com/andrey271192/keenetic-dns-routes.git /opt/keenetic-dns-routes
|
git clone https://github.com/andrey271192/keenetic-dns-routes.git /opt/keenetic-dns-routes
|
||||||
cd /opt/keenetic-dns-routes
|
cd /opt/keenetic-dns-routes
|
||||||
sudo bash install.sh
|
sudo bash install.sh
|
||||||
nano .env # ADMIN_PASSWORD, KEENETIC_LOGIN, KEENETIC_PASSWORD
|
nano .env # ADMIN_PASSWORD + при желании KEENETIC_LOGIN / KEENETIC_PASSWORD (дефолт для роутеров без своих полей)
|
||||||
sudo systemctl restart keenetic-dns-routes
|
sudo systemctl restart keenetic-dns-routes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ sudo systemctl restart keenetic-dns-routes
|
|||||||
|
|
||||||
## Настройка
|
## Настройка
|
||||||
|
|
||||||
1. В **Interface ID** для US/RU укажи внутреннее имя интерфейса Keenetic (как в CLI: `Wireguard0`, `GigabitEthernet0`, `PPPoE0` и т.д.). Узнать можно в веб-интерфейсе или через `show interface` / утилиту [gokeenapi](https://github.com/Noksa/gokeenapi) `show-interfaces`.
|
1. В **Interface ID** для US/RU — имя интерфейса (`Wireguard0`, `PPPoE0`…). Кнопка **«Сканировать…»** подгружает список с роутера; опция **«Только WireGuard»** сужает выбор до WG-туннелей.
|
||||||
2. В списках — **одна строка = один домен или IPv4/IPv6/CIDR**. Пустые строки и строки с `#` в начале игнорируются.
|
2. В списках — **одна строка = один домен или IPv4/IPv6/CIDR**. Пустые строки и строки с `#` в начале игнорируются.
|
||||||
3. Добавь роутеры: **RCI URL** вида `http://rci.имя.keenetic.pro:79` (без слэша в конце).
|
3. Добавь роутеры: **base URL** прокси KeenDNS (`http(s)://хост:порт`, без пути `/rci/...`). Логин/пароль — в полях или в URL `логин:пароль@хост`; если пусто — из `KEENETIC_*` в `.env`.
|
||||||
4. **Сохранить на сервер** — только JSON на VPS.
|
4. **Сохранить на сервер** — только JSON на VPS.
|
||||||
5. **Применить на всех legacy** или отметь галочками и **Только на выбранных** — пошлёт на каждый RCI дифф: удалит лишние `include`, добавит новые, обновит `dns-proxy route` при смене интерфейса, в конце `system configuration save`.
|
5. **Применить на всех legacy** или отметь галочками и **Только на выбранных** — пошлёт на каждый RCI дифф: удалит лишние `include`, добавит новые, обновит `dns-proxy route` при смене интерфейса, в конце `system configuration save`.
|
||||||
|
|
||||||
@@ -39,8 +39,9 @@ sudo systemctl restart keenetic-dns-routes
|
|||||||
- `PUT /api/data` — полное или частичное обновление (`groups` и/или `routers`).
|
- `PUT /api/data` — полное или частичное обновление (`groups` и/или `routers`).
|
||||||
- `POST /api/groups/{US|RU}/lines` — тело `{"add":["a.com"],"remove":["b.com"]}`: правка списка **на сервере** без пересылки всего textarea (порядок: сначала удаления, затем добавления в конец).
|
- `POST /api/groups/{US|RU}/lines` — тело `{"add":["a.com"],"remove":["b.com"]}`: правка списка **на сервере** без пересылки всего textarea (порядок: сначала удаления, затем добавления в конец).
|
||||||
- `POST /api/apply` — `{"mode":"all"|"selected","router_ids":["id1"]}`.
|
- `POST /api/apply` — `{"mode":"all"|"selected","router_ids":["id1"]}`.
|
||||||
- `GET /api/keenetic-env` — логин Keenetic и флаг «пароль задан» (сам пароль не отдаётся).
|
- `GET /api/keenetic-env` — дефолтный логин из `.env` и флаг «KEENETIC_PASSWORD задан».
|
||||||
- `GET /api/routers/{id}/interfaces` — список интерфейсов с роутера (`GET /rci/show/interface`), для подбора **Interface ID**.
|
- `GET /api/routers/{id}/interfaces` — список интерфейсов (`GET /rci/show/interface`); query `wireguard_only=1` — только WireGuard.
|
||||||
|
- `PATCH /api/routers/{id}` — правка имени, URL, `keenetic_login` / `keenetic_password`.
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
@@ -50,9 +51,19 @@ sudo systemctl restart keenetic-dns-routes
|
|||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/keenetic-dns-routes && git pull && sudo systemctl restart keenetic-dns-routes
|
cd /opt/keenetic-dns-routes && sudo bash update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Если **`update.sh: No such file or directory`**: подтяни свежий `install.sh` с репозитория и один раз выполни `sudo bash install.sh` — он **создаст** `update.sh`, если файла нет. Либо вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/keenetic-dns-routes && git pull --ff-only
|
||||||
|
source venv/bin/activate && pip install -r requirements.txt
|
||||||
|
sudo systemctl restart keenetic-dns-routes
|
||||||
|
```
|
||||||
|
|
||||||
|
Без git: скопируй каталог проекта поверх, затем снова `sudo bash update.sh` или команды выше.
|
||||||
|
|
||||||
## Связь
|
## Связь
|
||||||
|
|
||||||
Проект рядом по смыслу с [keenetic-unified](https://github.com/andrey271192/keenetic-unified) (Neo + дашборд) и [domen_hydra](https://github.com/andrey271192/domen_hydra) (только Neo-конфиг), но предназначен **только** для встроенной DNS-маршрутизации без Neo.
|
Проект рядом по смыслу с [keenetic-unified](https://github.com/andrey271192/keenetic-unified) (Neo + дашборд) и [domen_hydra](https://github.com/andrey271192/domen_hydra) (только Neo-конфиг), но предназначен **только** для встроенной DNS-маршрутизации без Neo.
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ HOST = os.getenv("HOST", "0.0.0.0")
|
|||||||
PORT = int(os.getenv("PORT", "8001"))
|
PORT = int(os.getenv("PORT", "8001"))
|
||||||
# Пустая строка в .env (ADMIN_PASSWORD=) не даёт дефолт через getenv — явно подставляем admin
|
# Пустая строка в .env (ADMIN_PASSWORD=) не даёт дефолт через getenv — явно подставляем admin
|
||||||
ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip()
|
ADMIN_PASSWORD = (os.getenv("ADMIN_PASSWORD") or "admin").strip()
|
||||||
|
# Дефолт для роутеров без своих keenetic_* и без user:pass@ в URL (можно оставить пустым)
|
||||||
KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip()
|
KEENETIC_LOGIN = (os.getenv("KEENETIC_LOGIN") or "admin").strip()
|
||||||
KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").strip()
|
KEENETIC_PASSWORD = (os.getenv("KEENETIC_PASSWORD") or "").strip()
|
||||||
|
|||||||
182
app/main.py
182
app/main.py
@@ -5,14 +5,14 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from fastapi import FastAPI, Header, HTTPException, Query
|
||||||
from fastapi import FastAPI, Header, HTTPException
|
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .models import ApplyRequest, AuthBody, RouterSpec, StoreData
|
from .models import ApplyRequest, AuthBody, RouterSpec, StoreData
|
||||||
from .rci import KeeneticRCI, KeeneticRCIError, test_connection
|
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
|
from .store import ensure_store, load_store, new_router_id, save_store
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -28,6 +28,38 @@ def _chk(pwd: str) -> None:
|
|||||||
raise HTTPException(401, "Неверный пароль")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
ensure_store()
|
ensure_store()
|
||||||
@@ -39,7 +71,11 @@ app = FastAPI(title="Keenetic DNS Routes", version="1.0", lifespan=lifespan)
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index():
|
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")
|
@app.post("/api/auth")
|
||||||
@@ -52,40 +88,63 @@ async def api_auth(b: AuthBody):
|
|||||||
@app.get("/api/data")
|
@app.get("/api/data")
|
||||||
async def get_data(x_admin_password: str = Header("")):
|
async def get_data(x_admin_password: str = Header("")):
|
||||||
_chk(x_admin_password)
|
_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")
|
@app.get("/api/keenetic-env")
|
||||||
async def keenetic_env(x_admin_password: str = Header("")):
|
async def keenetic_env(x_admin_password: str = Header("")):
|
||||||
"""Логин и факт наличия пароля (сам пароль в ответ не кладём — только из .env на сервере)."""
|
"""Дефолт из .env для роутеров без своих полей (пароль не отдаём)."""
|
||||||
_chk(x_admin_password)
|
_chk(x_admin_password)
|
||||||
return {
|
return {
|
||||||
|
"mode": "env_or_router",
|
||||||
"login": config.KEENETIC_LOGIN,
|
"login": config.KEENETIC_LOGIN,
|
||||||
"password_configured": bool(config.KEENETIC_PASSWORD),
|
"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")
|
@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)
|
_chk(x_admin_password)
|
||||||
if not config.KEENETIC_PASSWORD:
|
|
||||||
raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env")
|
|
||||||
cur = load_store()
|
cur = load_store()
|
||||||
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
|
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
|
||||||
if not r:
|
if not r:
|
||||||
raise HTTPException(404, "Роутер не найден")
|
raise HTTPException(404, "Роутер не найден")
|
||||||
|
r = sanitize_router_dict(r)
|
||||||
|
_require_router_rci_creds(r)
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
k = KeeneticRCI(
|
base, lg, pw = router_rci_context(r)
|
||||||
r["rci_base_url"], config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD
|
k = KeeneticRCI(base, lg, pw)
|
||||||
)
|
|
||||||
return k.list_interfaces()
|
return k.list_interfaces()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
items = await asyncio.to_thread(_run)
|
items = await asyncio.to_thread(_run)
|
||||||
except KeeneticRCIError as e:
|
except KeeneticRCIError as e:
|
||||||
raise HTTPException(502, str(e)) from 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}
|
return {"interfaces": items}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +160,10 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")):
|
|||||||
if b.groups is not None:
|
if b.groups is not None:
|
||||||
cur["groups"] = b.groups
|
cur["groups"] = b.groups
|
||||||
if b.routers is not None:
|
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:
|
try:
|
||||||
StoreData.from_json(cur)
|
StoreData.from_json(cur)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -113,17 +175,31 @@ async def put_data(b: PutDataBody, x_admin_password: str = Header("")):
|
|||||||
class AddRouterBody(BaseModel):
|
class AddRouterBody(BaseModel):
|
||||||
name: str = Field(..., min_length=1)
|
name: str = Field(..., min_length=1)
|
||||||
rci_base_url: str = Field(..., min_length=8)
|
rci_base_url: str = Field(..., min_length=8)
|
||||||
|
keenetic_login: str = ""
|
||||||
|
keenetic_password: str = ""
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/routers")
|
@app.post("/api/routers")
|
||||||
async def add_router(b: AddRouterBody, x_admin_password: str = Header("")):
|
async def add_router(b: AddRouterBody, x_admin_password: str = Header("")):
|
||||||
_chk(x_admin_password)
|
_chk(x_admin_password)
|
||||||
cur = load_store()
|
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(
|
r = RouterSpec(
|
||||||
id=new_router_id(),
|
id=new_router_id(),
|
||||||
name=b.name.strip(),
|
name=b.name.strip(),
|
||||||
rci_base_url=b.rci_base_url.strip().rstrip("/"),
|
rci_base_url=base,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
|
keenetic_login=login,
|
||||||
|
keenetic_password=password,
|
||||||
)
|
)
|
||||||
lst = list(cur.get("routers") or [])
|
lst = list(cur.get("routers") or [])
|
||||||
lst.append(r.model_dump())
|
lst.append(r.model_dump())
|
||||||
@@ -141,21 +217,58 @@ async def del_router(rid: str, x_admin_password: str = Header("")):
|
|||||||
return {"ok": True}
|
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}")
|
@app.post("/api/test-router/{rid}")
|
||||||
async def test_router(rid: str, x_admin_password: str = Header("")):
|
async def test_router(rid: str, x_admin_password: str = Header("")):
|
||||||
_chk(x_admin_password)
|
_chk(x_admin_password)
|
||||||
if not config.KEENETIC_PASSWORD:
|
|
||||||
raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env")
|
|
||||||
cur = load_store()
|
cur = load_store()
|
||||||
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
|
r = next((x for x in cur.get("routers") or [] if x.get("id") == rid), None)
|
||||||
if not r:
|
if not r:
|
||||||
raise HTTPException(404, "Роутер не найден")
|
raise HTTPException(404, "Роутер не найден")
|
||||||
ok, msg = await asyncio.to_thread(
|
r = sanitize_router_dict(r)
|
||||||
test_connection,
|
try:
|
||||||
r["rci_base_url"],
|
base, lg, pw = _require_router_rci_creds(r)
|
||||||
config.KEENETIC_LOGIN,
|
except HTTPException:
|
||||||
config.KEENETIC_PASSWORD,
|
raise
|
||||||
)
|
ok, msg = await asyncio.to_thread(test_connection, base, lg, pw)
|
||||||
return {"ok": ok, "message": msg}
|
return {"ok": ok, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
@@ -197,8 +310,6 @@ async def patch_group_lines(
|
|||||||
@app.post("/api/apply")
|
@app.post("/api/apply")
|
||||||
async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")):
|
async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")):
|
||||||
_chk(x_admin_password)
|
_chk(x_admin_password)
|
||||||
if not config.KEENETIC_PASSWORD:
|
|
||||||
raise HTTPException(400, "Задайте KEENETIC_PASSWORD в .env")
|
|
||||||
cur = load_store()
|
cur = load_store()
|
||||||
data = StoreData.from_json(cur)
|
data = StoreData.from_json(cur)
|
||||||
routers = data.routers
|
routers = data.routers
|
||||||
@@ -211,15 +322,32 @@ async def apply_dns(b: ApplyRequest, x_admin_password: str = Header("")):
|
|||||||
if not routers:
|
if not routers:
|
||||||
raise HTTPException(400, "Нет роутеров для применения")
|
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()}
|
groups_dump = {k: v.model_dump() for k, v in data.groups.items()}
|
||||||
group_keys = tuple(data.groups.keys())
|
group_keys = tuple(data.groups.keys())
|
||||||
|
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
|
|
||||||
def _one(r: RouterSpec) -> dict:
|
def _one(r: RouterSpec) -> dict:
|
||||||
k = KeeneticRCI(
|
raw = raw_by_id.get(r.id) or {}
|
||||||
r.rci_base_url, config.KEENETIC_LOGIN, config.KEENETIC_PASSWORD
|
base, lg, pw = router_rci_context(raw)
|
||||||
)
|
k = KeeneticRCI(base, lg, pw)
|
||||||
try:
|
try:
|
||||||
log = k.apply_groups(groups_dump, group_names=group_keys)
|
log = k.apply_groups(groups_dump, group_names=group_keys)
|
||||||
return {"router": r.name, "id": r.id, "ok": True, "log": log}
|
return {"router": r.name, "id": r.id, "ok": True, "log": log}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ class RouterSpec(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
rci_base_url: str = Field(
|
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
|
enabled: bool = True
|
||||||
|
keenetic_login: str = Field(default="", description="Логин Keenetic для HTTP Proxy / RCI")
|
||||||
|
keenetic_password: str = Field(default="", description="Пароль Keenetic для RCI")
|
||||||
|
|
||||||
|
|
||||||
class StoreData(BaseModel):
|
class StoreData(BaseModel):
|
||||||
@@ -34,9 +36,11 @@ class StoreData(BaseModel):
|
|||||||
if isinstance(v, dict):
|
if isinstance(v, dict):
|
||||||
g[k] = GroupSpec(**v)
|
g[k] = GroupSpec(**v)
|
||||||
rlist = []
|
rlist = []
|
||||||
|
from .rci_url import sanitize_router_dict
|
||||||
|
|
||||||
for r in raw.get("routers") or []:
|
for r in raw.get("routers") or []:
|
||||||
if isinstance(r, dict) and r.get("id"):
|
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)
|
return cls(groups=g, routers=rlist)
|
||||||
|
|
||||||
def to_json(self) -> dict[str, Any]:
|
def to_json(self) -> dict[str, Any]:
|
||||||
|
|||||||
62
app/rci.py
62
app/rci.py
@@ -5,6 +5,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -51,7 +52,15 @@ class KeeneticRCIError(RuntimeError):
|
|||||||
|
|
||||||
class KeeneticRCI:
|
class KeeneticRCI:
|
||||||
def __init__(self, base_url: str, login: str, password: str):
|
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.login = login
|
||||||
self.password = password
|
self.password = password
|
||||||
self._client: httpx.Client | None = None
|
self._client: httpx.Client | None = None
|
||||||
@@ -68,27 +77,49 @@ class KeeneticRCI:
|
|||||||
r = client.get("/auth")
|
r = client.get("/auth")
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
return
|
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}")
|
raise KeeneticRCIError(f"/auth HTTP {r.status_code}")
|
||||||
realm = r.headers.get("X-NDM-Realm", "") or r.headers.get("x-ndm-realm", "")
|
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", "")
|
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 ""
|
set_cookie = r.headers.get("Set-Cookie") or r.headers.get("set-cookie") or ""
|
||||||
cookie = set_cookie.split(";")[0].strip()
|
cookie_pair = set_cookie.split(";")[0].strip()
|
||||||
if not realm or not challenge or not cookie:
|
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")
|
raise KeeneticRCIError("Нет заголовков X-NDM-Realm / Challenge или Set-Cookie")
|
||||||
md5_hex = hashlib.md5(
|
md5_hex = hashlib.md5(
|
||||||
f"{self.login}:{realm}:{self.password}".encode()
|
f"{self.login}:{realm}:{self.password}".encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
sha_hex = hashlib.sha256(f"{challenge}{md5_hex}".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(
|
r2 = client.post(
|
||||||
"/auth",
|
"/auth",
|
||||||
json={"login": self.login, "password": sha_hex},
|
json={"login": self.login, "password": sha_hex},
|
||||||
)
|
)
|
||||||
if r2.status_code in (401, 403):
|
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):
|
if r2.status_code not in (200, 201, 202):
|
||||||
raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}")
|
raise KeeneticRCIError(f"POST /auth HTTP {r2.status_code}")
|
||||||
|
client.cookies.update(r2.cookies)
|
||||||
|
|
||||||
def list_interfaces(self) -> list[dict[str, Any]]:
|
def list_interfaces(self) -> list[dict[str, Any]]:
|
||||||
"""GET /rci/show/interface — id, type, description, state (как gokeenapi)."""
|
"""GET /rci/show/interface — id, type, description, state (как gokeenapi)."""
|
||||||
@@ -97,7 +128,13 @@ class KeeneticRCI:
|
|||||||
r = client.get("/rci/show/interface")
|
r = client.get("/rci/show/interface")
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
raise KeeneticRCIError(f"show/interface HTTP {r.status_code}")
|
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):
|
if not isinstance(data, dict):
|
||||||
raise KeeneticRCIError("show/interface: ожидался объект JSON")
|
raise KeeneticRCIError("show/interface: ожидался объект JSON")
|
||||||
rows: list[dict[str, Any]] = []
|
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)
|
k._auth(c)
|
||||||
r = c.get("/rci/show/version")
|
r = c.get("/rci/show/version")
|
||||||
if r.status_code != 200:
|
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}"
|
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 "?"
|
title = j.get("title") or j.get("Title") or "?"
|
||||||
return True, str(title)
|
return True, str(title)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
41
app/rci_url.py
Normal file
41
app/rci_url.py
Normal 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
|
||||||
31
install.sh
31
install.sh
@@ -6,8 +6,36 @@ apt-get update -qq && apt-get install -y python3 python3-venv python3-pip
|
|||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -q -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
if [ ! -f .env ]; then cp .env.example .env && echo "Создан .env — задай ADMIN_PASSWORD и KEENETIC_PASSWORD"; fi
|
if [ ! -f .env ]; then cp .env.example .env && echo "Создан .env — задай ADMIN_PASSWORD; при желании KEENETIC_* как дефолт для роутеров."; fi
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
|
||||||
|
# Старые клоны без update.sh в репозитории — создаём рядом с install.sh
|
||||||
|
if [ ! -f "$DIR/update.sh" ]; then
|
||||||
|
cat >"$DIR/update.sh" <<'EOS'
|
||||||
|
#!/bin/bash
|
||||||
|
# Обновление keenetic-dns-routes: git pull, pip, перезапуск systemd.
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
echo "==> keenetic-dns-routes: $DIR"
|
||||||
|
if [ -d .git ]; then git pull --ff-only; else echo "(!) Нет .git — скопируй файлы проекта поверх, затем снова update.sh."; fi
|
||||||
|
if [ ! -d venv ]; then python3 -m venv venv; fi
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
if [ "$(id -u)" = 0 ] && [ -f /etc/systemd/system/keenetic-dns-routes.service ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart keenetic-dns-routes
|
||||||
|
echo "==> systemd: keenetic-dns-routes перезапущен"
|
||||||
|
else
|
||||||
|
echo "Перезапуск (root): sudo systemctl restart keenetic-dns-routes"
|
||||||
|
fi
|
||||||
|
echo "==> Готово. Обнови страницу в браузере (Ctrl+F5)."
|
||||||
|
EOS
|
||||||
|
chmod +x "$DIR/update.sh"
|
||||||
|
echo "Создан $DIR/update.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$(id -u)" = 0 ]; then
|
if [ "$(id -u)" = 0 ]; then
|
||||||
sed "s|WorkingDirectory=.*|WorkingDirectory=$DIR|" keenetic-dns-routes.service | \
|
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|" \
|
sed "s|ExecStart=.*|ExecStart=$DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001|" \
|
||||||
@@ -21,3 +49,4 @@ else
|
|||||||
echo " cd $DIR && source venv/bin/activate && uvicorn app.main:app --host 0.0.0.0 --port 8001"
|
echo " cd $DIR && source venv/bin/activate && uvicorn app.main:app --host 0.0.0.0 --port 8001"
|
||||||
fi
|
fi
|
||||||
echo "Интерфейс: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1):8001"
|
echo "Интерфейс: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1):8001"
|
||||||
|
echo "Дальнейшее обновление кода: cd $DIR && sudo bash update.sh"
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
#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{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-bg.on{display:flex}
|
||||||
#auth-box{background:var(--card);border:1px solid var(--bd);padding:28px;border-radius:16px;width:min(360px,92vw)}
|
#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,#edit-router-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-modal.on,#edit-router-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}
|
#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}
|
||||||
|
#edit-router-box{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:520px;width:100%;padding:18px}
|
||||||
.if-row{cursor:pointer}
|
.if-row{cursor:pointer}
|
||||||
.if-row:hover{background:rgba(59,130,246,.12)}
|
.if-row:hover{background:rgba(59,130,246,.12)}
|
||||||
</style>
|
</style>
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="auth-bg" class="on"><div id="auth-box">
|
<div id="auth-bg" class="on"><div id="auth-box">
|
||||||
<h2 style="margin-bottom:12px;font-size:18px">Keenetic DNS Routes</h2>
|
<h2 style="margin-bottom:12px;font-size:18px">Keenetic DNS Routes</h2>
|
||||||
<label>Пароль веб-интерфейса</label>
|
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Сюда только <b>ADMIN_PASSWORD</b> из <code>.env</code> (вход на эту страницу). Для API роутеров: поля ниже или <code>KEENETIC_*</code> в <code>.env</code>, или <code>логин:пароль@</code> в URL.</p>
|
||||||
|
<label>Пароль веб-интерфейса (ADMIN_PASSWORD)</label>
|
||||||
<input type="password" id="apw" autocomplete="current-password" style="margin-bottom:12px" onkeydown="if(event.key==='Enter')doLogin()"/>
|
<input type="password" id="apw" autocomplete="current-password" style="margin-bottom:12px" onkeydown="if(event.key==='Enter')doLogin()"/>
|
||||||
<div id="aerr" style="color:var(--er);font-size:12px;margin-bottom:8px;display:none"></div>
|
<div id="aerr" style="color:var(--er);font-size:12px;margin-bottom:8px;display:none"></div>
|
||||||
<button class="btn btn-b" style="width:100%" onclick="doLogin()">Войти</button>
|
<button class="btn btn-b" style="width:100%" onclick="doLogin()">Войти</button>
|
||||||
@@ -61,10 +63,11 @@
|
|||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div class="card" id="cred-card" style="display:none">
|
<div class="card" id="cred-card" style="display:none">
|
||||||
<h2>Учётная запись Keenetic (RCI)</h2>
|
<h2>Учётные данные Keenetic (API)</h2>
|
||||||
|
<p style="font-size:11px;color:var(--mu);margin-bottom:8px;line-height:1.5">Два варианта по умолчанию: <b>KEENETIC_LOGIN / KEENETIC_PASSWORD</b> в <code>.env</code> на сервере <b>или</b> свои логин/пароль (или <code>http(s)://логин:пароль@хост:порт</code>) у каждого роутера. Пароль входа на эту страницу — только <code>ADMIN_PASSWORD</code>.</p>
|
||||||
<p id="cred-line" style="font-size:14px;margin-bottom:6px"></p>
|
<p id="cred-line" style="font-size:14px;margin-bottom:6px"></p>
|
||||||
<p id="cred-hint" style="font-size:11px;color:var(--mu);line-height:1.5"></p>
|
<p id="cred-hint" style="font-size:11px;color:var(--mu);line-height:1.5"></p>
|
||||||
<p style="font-size:11px;color:var(--mu);margin-top:8px">Пароль в этот экран <b>не передаётся</b> — только в <code>/opt/keenetic-dns-routes/.env</code> как <code>KEENETIC_PASSWORD</code>. Для KeenDNS API часто нужен URL вида <code>http://rci.имя.keenetic.pro:79</code>, а не HTTPS веб-морды.</p>
|
<p style="font-size:11px;color:var(--mu);margin-top:8px">В URL можно указать <code>логин:пароль@</code> перед хостом — при сохранении они переносятся в поля, в адресе остаётся только <code>http(s)://хост:порт</code>. Пути вида <code>/rci/...</code> в base URL не указывай — их добавляет сервис.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -76,19 +79,19 @@
|
|||||||
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
|
<h2>US — домены и IP/CIDR (одна строка = одна запись)</h2>
|
||||||
<div class="row" style="align-items:flex-end">
|
<div class="row" style="align-items:flex-end">
|
||||||
<div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0…)</label>
|
<div style="flex:1"><label>Interface ID (как в RCI: Wireguard0, PPPoE0…)</label>
|
||||||
<input type="text" id="if-US" placeholder="Wireguard0"/></div>
|
<input type="text" id="if-US" placeholder="Wireguard0" oninput="ST.groups=readForm()"/></div>
|
||||||
<div><label> </label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
|
<div><label> </label><button type="button" class="btn btn-d" onclick="openIfScan('US')">Сканировать…</button></div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="tx-US" placeholder="youtube.com 1.2.3.0/24"></textarea>
|
<textarea id="tx-US" placeholder="youtube.com 1.2.3.0/24" oninput="ST.groups=readForm()"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" id="panel-RU" style="display:none">
|
<div class="card" id="panel-RU" style="display:none">
|
||||||
<h2>RU — домены и IP/CIDR</h2>
|
<h2>RU — домены и IP/CIDR</h2>
|
||||||
<div class="row" style="align-items:flex-end">
|
<div class="row" style="align-items:flex-end">
|
||||||
<div style="flex:1"><label>Interface ID</label>
|
<div style="flex:1"><label>Interface ID</label>
|
||||||
<input type="text" id="if-RU" placeholder="GigabitEthernet0"/></div>
|
<input type="text" id="if-RU" placeholder="GigabitEthernet0" oninput="ST.groups=readForm()"/></div>
|
||||||
<div><label> </label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
|
<div><label> </label><button type="button" class="btn btn-d" onclick="openIfScan('RU')">Сканировать…</button></div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="tx-RU" placeholder="yandex.ru"></textarea>
|
<textarea id="tx-RU" placeholder="yandex.ru" oninput="ST.groups=readForm()"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="align-items:center;margin-top:4px">
|
<div class="row" style="align-items:center;margin-top:4px">
|
||||||
@@ -105,21 +108,40 @@
|
|||||||
<div class="log" id="log"></div>
|
<div class="log" id="log"></div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px">
|
<div class="card" style="margin-top:24px">
|
||||||
<h2>Роутеры (KeenDNS → RCI base URL)</h2>
|
<h2>Роутеры (KeenDNS → base URL прокси)</h2>
|
||||||
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">Логин/пароль для входа в RCI — в <code>.env</code> на сервере. Сводка сверху на странице; пароль в браузер не выводится.</p>
|
<p style="font-size:12px;color:var(--mu);margin-bottom:12px">У каждого роутера: <b>base URL</b> + при необходимости <b>логин/пароль</b>, либо сразу URL <code>http(s)://логин:пароль@хост:порт</code>. Если поля логина/пароля пустые — берутся <code>KEENETIC_*</code> из <code>.env</code> на сервере.</p>
|
||||||
|
<p style="font-size:11px;color:var(--mu);margin-bottom:10px;line-height:1.45">Укажи тот же <b>хост и порт</b>, что в KeenDNS для HTTP Proxy (часто не стандартный порт). Для <code>http</code> без порта запрос уйдёт на <b>80</b> и может быть <code>/auth 404</code>. <code>/auth 403</code> — проверь разрешённые IP для API на роутере.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
|
<div style="min-width:120px"><label>Имя</label><input type="text" id="rn" placeholder="Дача"/></div>
|
||||||
<div style="flex:2"><label>RCI URL</label><input type="url" id="ru" placeholder="http://rci.home.keenetic.pro:79"/></div>
|
<div style="flex:2;min-width:220px"><label>Base URL прокси</label><input type="url" id="ru" placeholder="https://homesmart.netcraze.pro:81"/></div>
|
||||||
|
<div style="min-width:100px"><label>Логин (если не .env)</label><input type="text" id="rl" placeholder="пусто = из .env"/></div>
|
||||||
|
<div style="min-width:120px"><label>Пароль (если не .env)</label><input type="password" id="rp" placeholder="пусто = из .env" autocomplete="new-password"/></div>
|
||||||
<div><label> </label><button class="btn btn-b" onclick="addR()">+ Добавить</button></div>
|
<div><label> </label><button class="btn btn-b" onclick="addR()">+ Добавить</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<table class="tbl" id="rtbl"><thead><tr>
|
<table class="tbl" id="rtbl"><thead><tr>
|
||||||
<th style="width:36px">✓</th><th>Имя</th><th>RCI base URL</th><th>Вкл</th><th></th>
|
<th style="width:36px" title="Выбор для кнопки «Только на выбранных»">☑</th><th>Имя</th><th>Base URL</th><th>Логин</th><th>Пароль</th><th title="Включить/выключить роутер для «Применить на всех»">Вкл</th><th></th>
|
||||||
</tr></thead><tbody></tbody></table>
|
</tr></thead><tbody></tbody></table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="edit-router-modal"><div id="edit-router-box">
|
||||||
|
<h3 style="margin-bottom:12px;font-size:16px">Роутер</h3>
|
||||||
|
<input type="hidden" id="er-id"/>
|
||||||
|
<div class="row"><div style="flex:1"><label>Имя</label><input type="text" id="er-name"/></div></div>
|
||||||
|
<div class="row"><div style="flex:1"><label>Base URL прокси</label><input type="url" id="er-url" placeholder="https://хост:порт"/></div></div>
|
||||||
|
<div class="row"><div style="flex:1"><label>Keenetic логин</label><input type="text" id="er-login" placeholder="логин RCI"/></div></div>
|
||||||
|
<div class="row"><div style="flex:1"><label>Keenetic пароль</label><input type="password" id="er-pass" placeholder="пусто = не менять" autocomplete="new-password"/></div></div>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:12px">
|
||||||
|
<input type="checkbox" id="er-clear-pass"/> Очистить пароль (потом задай заново или в URL user:pass@)
|
||||||
|
</label>
|
||||||
|
<div class="row" style="margin-bottom:0">
|
||||||
|
<button type="button" class="btn btn-b" onclick="saveEditRouter()">Сохранить</button>
|
||||||
|
<button type="button" class="btn btn-d" onclick="closeEditRouter()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
<div id="ifscan-modal"><div id="ifscan-box">
|
<div id="ifscan-modal"><div id="ifscan-box">
|
||||||
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
|
<h3 style="margin-bottom:12px;font-size:16px">Интерфейсы с роутера (RCI)</h3>
|
||||||
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
|
<p style="font-size:12px;color:var(--mu);margin-bottom:10px">Выбери роутер → «Загрузить». Строка = то, что вписывается в <b>Interface ID</b> для текущей вкладки (US или RU). Обычно для VPN — <code>Wireguard0</code> / <code>OpenVPN0</code>, для провайдера — <code>PPPoE0</code> / <code>GigabitEthernet0</code> и т.п.</p>
|
||||||
@@ -128,6 +150,9 @@
|
|||||||
<select id="ifscan-router" style="width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px;color:var(--tx);font-size:13px"></select></div>
|
<select id="ifscan-router" style="width:100%;background:#111;border:1px solid var(--bd);border-radius:10px;padding:10px;color:var(--tx);font-size:13px"></select></div>
|
||||||
<div><label> </label><button type="button" class="btn btn-b" onclick="runIfScan()">Загрузить</button></div>
|
<div><label> </label><button type="button" class="btn btn-b" onclick="runIfScan()">Загрузить</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--mu);cursor:pointer;margin-bottom:10px">
|
||||||
|
<input type="checkbox" id="ifscan-wg-only"/> Только WireGuard (удобно выбрать туннель US/RU)
|
||||||
|
</label>
|
||||||
<div id="ifscan-list" style="font-size:12px"></div>
|
<div id="ifscan-list" style="font-size:12px"></div>
|
||||||
<button type="button" class="btn btn-d" style="margin-top:14px" onclick="closeIfScan()">Закрыть</button>
|
<button type="button" class="btn btn-d" style="margin-top:14px" onclick="closeIfScan()">Закрыть</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
@@ -152,8 +177,8 @@ async function loadCred(){
|
|||||||
if(!r.ok)return;
|
if(!r.ok)return;
|
||||||
const j=await r.json();
|
const j=await r.json();
|
||||||
document.getElementById('cred-card').style.display='block';
|
document.getElementById('cred-card').style.display='block';
|
||||||
const pw=j.password_configured?'<span class="pill ok">пароль в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD пустой</span>';
|
const pw=j.password_configured?'<span class="pill ok">KEENETIC_PASSWORD в .env задан</span>':'<span class="pill bad">KEENETIC_PASSWORD в .env пустой</span>';
|
||||||
document.getElementById('cred-line').innerHTML='Логин: <b>'+esc(j.login)+'</b> '+pw;
|
document.getElementById('cred-line').innerHTML='Дефолт из .env: логин <b>'+esc(j.login||'')+'</b> '+pw;
|
||||||
document.getElementById('cred-hint').textContent=j.hint||'';
|
document.getElementById('cred-hint').textContent=j.hint||'';
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
@@ -175,7 +200,9 @@ async function runIfScan(){
|
|||||||
const L=document.getElementById('ifscan-list');
|
const L=document.getElementById('ifscan-list');
|
||||||
if(!id){L.innerHTML='<span style="color:var(--er)">Выбери роутера</span>';return;}
|
if(!id){L.innerHTML='<span style="color:var(--er)">Выбери роутера</span>';return;}
|
||||||
L.innerHTML='Загрузка…';
|
L.innerHTML='Загрузка…';
|
||||||
const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces',{headers:hdr()});
|
const wg=document.getElementById('ifscan-wg-only').checked;
|
||||||
|
const q=wg?'?wireguard_only=1':'';
|
||||||
|
const r=await fetch('/api/routers/'+encodeURIComponent(id)+'/interfaces'+q,{headers:hdr()});
|
||||||
if(r.status===401){logout();return;}
|
if(r.status===401){logout();return;}
|
||||||
if(!r.ok){L.innerHTML='<span style="color:var(--er)">'+(await r.text())+'</span>';return;}
|
if(!r.ok){L.innerHTML='<span style="color:var(--er)">'+(await r.text())+'</span>';return;}
|
||||||
const j=await r.json();
|
const j=await r.json();
|
||||||
@@ -205,14 +232,45 @@ function paint(){
|
|||||||
document.getElementById('tx-'+k).value=(g.lines||[]).join('\n');
|
document.getElementById('tx-'+k).value=(g.lines||[]).join('\n');
|
||||||
}
|
}
|
||||||
const tb=document.querySelector('#rtbl tbody');
|
const tb=document.querySelector('#rtbl tbody');
|
||||||
tb.innerHTML=(ST.routers||[]).map(ro=>`<tr>
|
tb.innerHTML=(ST.routers||[]).map(ro=>{
|
||||||
<td><input type="checkbox" class="sel" data-id="${ro.id}"/></td>
|
const lo=String(ro.keenetic_login||'').trim();
|
||||||
|
const hasPw=String(ro.keenetic_password||'').length>0;
|
||||||
|
const fromEnv=!lo&&!hasPw;
|
||||||
|
const iid=String(ro.id||'');
|
||||||
|
return `<tr>
|
||||||
|
<td><input type="checkbox" class="kdns-sel" data-id="${esc(iid)}" title="Выбор для «Только на выбранных»"/></td>
|
||||||
<td>${esc(ro.name)}</td>
|
<td>${esc(ro.name)}</td>
|
||||||
<td style="font-size:11px;word-break:break-all">${esc(ro.rci_base_url)}</td>
|
<td style="font-size:11px;word-break:break-all">${esc(ro.rci_base_url)}</td>
|
||||||
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange="toggleEn('${ro.id}',this.checked)"/></td>
|
<td style="font-size:11px">${fromEnv?'<span style="color:var(--mu)">из .env</span>':esc(lo)}</td>
|
||||||
<td><button class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="testR('${ro.id}')">Тест</button>
|
<td style="font-size:11px">${fromEnv?'<span class="pill ok">из .env</span>':(hasPw?'<span class="pill ok">есть</span>':'<span class="pill bad">нет</span>')}</td>
|
||||||
<button class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="delR('${ro.id}')">✕</button></td>
|
<td><input type="checkbox" ${ro.enabled?'checked':''} onchange="toggleEn(${JSON.stringify(iid)},this.checked)"/></td>
|
||||||
</tr>`).join('')||'<tr><td colspan="5" style="color:var(--mu)">Нет роутеров</td></tr>';
|
<td><button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="openEditRouter(${JSON.stringify(iid)})">Изм.</button>
|
||||||
|
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="testR(${JSON.stringify(iid)})">Тест</button>
|
||||||
|
<button type="button" class="btn btn-d" style="padding:4px 10px;font-size:11px" onclick="delR(${JSON.stringify(iid)})">✕</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('')||'<tr><td colspan="7" style="color:var(--mu)">Нет роутеров</td></tr>';
|
||||||
|
}
|
||||||
|
function openEditRouter(id){
|
||||||
|
const ro=(ST.routers||[]).find(x=>x.id===id);if(!ro)return;
|
||||||
|
document.getElementById('er-id').value=ro.id;
|
||||||
|
document.getElementById('er-name').value=ro.name||'';
|
||||||
|
document.getElementById('er-url').value=ro.rci_base_url||'';
|
||||||
|
document.getElementById('er-login').value=ro.keenetic_login||'';
|
||||||
|
document.getElementById('er-pass').value='';
|
||||||
|
document.getElementById('er-clear-pass').checked=false;
|
||||||
|
document.getElementById('edit-router-modal').classList.add('on');
|
||||||
|
}
|
||||||
|
function closeEditRouter(){document.getElementById('edit-router-modal').classList.remove('on');}
|
||||||
|
async function saveEditRouter(){
|
||||||
|
const id=document.getElementById('er-id').value;
|
||||||
|
const body={name:document.getElementById('er-name').value.trim(),rci_base_url:document.getElementById('er-url').value.trim(),keenetic_login:document.getElementById('er-login').value.trim()};
|
||||||
|
if(document.getElementById('er-clear-pass').checked)body.keenetic_password='';
|
||||||
|
else{const pw=document.getElementById('er-pass').value;if(pw)body.keenetic_password=pw;}
|
||||||
|
const r=await fetch('/api/routers/'+encodeURIComponent(id),{method:'PATCH',headers:hdr(),body:JSON.stringify(body)});
|
||||||
|
if(r.status===401){logout();return;}
|
||||||
|
if(!r.ok){alert(await r.text());return;}
|
||||||
|
closeEditRouter();
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');}
|
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/"/g,'"');}
|
||||||
|
|
||||||
@@ -230,34 +288,47 @@ function appendCurrentTab(){
|
|||||||
const v=ta.value.replace(/\s+$/,'');
|
const v=ta.value.replace(/\s+$/,'');
|
||||||
ta.value=(v?v+'\n':'')+line;
|
ta.value=(v?v+'\n':'')+line;
|
||||||
document.getElementById('one-line').value='';
|
document.getElementById('one-line').value='';
|
||||||
|
ST.groups=readForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function readForm(){
|
function readForm(){
|
||||||
const groups={};
|
const groups={};
|
||||||
for(const k of['US','RU']){
|
for(const k of['US','RU']){
|
||||||
const lines=document.getElementById('tx-'+k).value.split('\n').map(s=>s.trim()).filter(Boolean);
|
const raw=document.getElementById('tx-'+k).value||'';
|
||||||
|
const lines=raw.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
|
||||||
groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines};
|
groups[k]={interface_id:document.getElementById('if-'+k).value.trim(),lines};
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveServer(){
|
/**
|
||||||
|
* @param {{skipLoad?:boolean}} opts — skipLoad: не перезагружать страницу (нужно перед «Только на выбранных»,
|
||||||
|
* иначе load() сбрасывает чекбоксы выбора; или перед добавлением роутера, чтобы сначала сохранить US/RU).
|
||||||
|
*/
|
||||||
|
async function saveServer(opts){
|
||||||
|
const skipLoad=opts&&opts.skipLoad;
|
||||||
const groups=readForm();
|
const groups=readForm();
|
||||||
const r=await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
|
const r=await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
|
||||||
if(r.status===401){logout();return;}
|
if(r.status===401){logout();return;}
|
||||||
if(!r.ok){alert(await r.text());return;}
|
if(!r.ok){alert(await r.text());return;}
|
||||||
|
ST.groups=groups;
|
||||||
document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru');
|
document.getElementById('sv').textContent='Сохранено '+new Date().toLocaleTimeString('ru');
|
||||||
await load();
|
if(!skipLoad) await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addR(){
|
async function addR(){
|
||||||
const name=document.getElementById('rn').value.trim();
|
const name=document.getElementById('rn').value.trim();
|
||||||
const rci_base_url=document.getElementById('ru').value.trim();
|
const rci_base_url=document.getElementById('ru').value.trim();
|
||||||
|
const keenetic_login=document.getElementById('rl').value.trim();
|
||||||
|
const keenetic_password=document.getElementById('rp').value;
|
||||||
if(!name||!rci_base_url){alert('Имя и URL');return;}
|
if(!name||!rci_base_url){alert('Имя и URL');return;}
|
||||||
const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url})});
|
// Сначала сохраняем US/RU с сервера в store, иначе после load() пропадут несохранённые домены
|
||||||
|
await saveServer({skipLoad:true});
|
||||||
|
const r=await fetch('/api/routers',{method:'POST',headers:hdr(),body:JSON.stringify({name,rci_base_url,keenetic_login,keenetic_password})});
|
||||||
if(r.status===401){logout();return;}
|
if(r.status===401){logout();return;}
|
||||||
if(!r.ok){alert(await r.text());return;}
|
if(!r.ok){alert(await r.text());return;}
|
||||||
document.getElementById('rn').value='';document.getElementById('ru').value='';
|
document.getElementById('rn').value='';document.getElementById('ru').value='';
|
||||||
|
document.getElementById('rl').value='';document.getElementById('rp').value='';
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
async function delR(id){
|
async function delR(id){
|
||||||
@@ -267,7 +338,9 @@ async function delR(id){
|
|||||||
}
|
}
|
||||||
async function toggleEn(id,en){
|
async function toggleEn(id,en){
|
||||||
ST.routers=(ST.routers||[]).map(x=>x.id===id?{...x,enabled:en}:x);
|
ST.routers=(ST.routers||[]).map(x=>x.id===id?{...x,enabled:en}:x);
|
||||||
await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({routers:ST.routers})});
|
const groups=readForm();
|
||||||
|
ST.groups=groups;
|
||||||
|
await fetch('/api/data',{method:'PUT',headers:hdr(),body:JSON.stringify({groups,routers:ST.routers})});
|
||||||
}
|
}
|
||||||
async function testR(id){
|
async function testR(id){
|
||||||
const r=await fetch('/api/test-router/'+id,{method:'POST',headers:hdr()});
|
const r=await fetch('/api/test-router/'+id,{method:'POST',headers:hdr()});
|
||||||
@@ -278,20 +351,25 @@ async function testR(id){
|
|||||||
function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');}
|
function showLog(t){const L=document.getElementById('log');L.textContent=t;L.classList.add('show');}
|
||||||
|
|
||||||
async function applyAll(){
|
async function applyAll(){
|
||||||
await saveServer();
|
await saveServer({skipLoad:true});
|
||||||
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'all'})});
|
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'all'})});
|
||||||
if(r.status===401){logout();return;}
|
if(r.status===401){logout();return;}
|
||||||
const j=await r.json();
|
const j=await r.json();
|
||||||
showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n'));
|
showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n'));
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
async function applySel(){
|
async function applySel(){
|
||||||
await saveServer();
|
const ids=[...document.querySelectorAll('#rtbl input.kdns-sel:checked')].map(c=>c.getAttribute('data-id')).filter(Boolean);
|
||||||
const ids=[...document.querySelectorAll('.sel:checked')].map(c=>c.dataset.id);
|
if(!ids.length){
|
||||||
if(!ids.length){alert('Отметьте галочками роутеры');return;}
|
alert('Отметьте чекбоксы в первой колонке таблицы «Роутеры» (слева), не переключатель «Вкл». Обнови страницу (Ctrl+F5), если не помогло.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveServer({skipLoad:true});
|
||||||
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'selected',router_ids:ids})});
|
const r=await fetch('/api/apply',{method:'POST',headers:hdr(),body:JSON.stringify({mode:'selected',router_ids:ids})});
|
||||||
if(r.status===401){logout();return;}
|
if(r.status===401){logout();return;}
|
||||||
const j=await r.json();
|
const j=await r.json();
|
||||||
showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n'));
|
showLog((j.results||[]).map(x=>x.ok?`✓ ${x.router}\n${(x.log||[]).join('\n')}`:`✗ ${x.router}: ${x.error}`).join('\n\n'));
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
|
|||||||
19
update.sh
Executable file
19
update.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Обновление keenetic-dns-routes: git pull, pip, перезапуск systemd.
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
echo "==> keenetic-dns-routes: $DIR"
|
||||||
|
if [ -d .git ]; then git pull --ff-only; else echo "(!) Нет .git — скопируй файлы проекта поверх, затем снова update.sh."; fi
|
||||||
|
if [ ! -d venv ]; then python3 -m venv venv; fi
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
if [ "$(id -u)" = 0 ] && [ -f /etc/systemd/system/keenetic-dns-routes.service ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart keenetic-dns-routes
|
||||||
|
echo "==> systemd: keenetic-dns-routes перезапущен"
|
||||||
|
else
|
||||||
|
echo "Перезапуск (root): sudo systemctl restart keenetic-dns-routes"
|
||||||
|
fi
|
||||||
|
echo "==> Готово. Обнови страницу в браузере (Ctrl+F5)."
|
||||||
Reference in New Issue
Block a user