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:
@@ -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()
|
||||
|
||||
182
app/main.py
182
app/main.py
@@ -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}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
62
app/rci.py
62
app/rci.py
@@ -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
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
|
||||
Reference in New Issue
Block a user