auth: ROUTER_HOST как список + диагностика причин в логе

На Keenetic админка часто не слушает loopback — биндится только на
LAN-интерфейсах. Делаем ROUTER_HOST списком (через запятую), пробуем
все по порядку.

- auth.py: keenetic_validate_any() — обходит список, на каждом шаге
  пишет в лог конкретную причину отказа: unreachable / нет realm /
  POST HTTP <code> / неверный логин-пароль
- app.py: парсит ROUTER_HOST как список (csv); login fail логируется
- install.sh: при создании свежего .env подставляет в ROUTER_HOST
  обнаруженный LAN-IP, my.keenetic.net и loopback (через запятую)
- .env.example: тот же дефолт, комментарии обновлены
This commit is contained in:
Андрей Бобырев
2026-04-26 01:32:34 +03:00
parent 282f205759
commit d8ee078ef5
4 changed files with 56 additions and 16 deletions

View File

@@ -1,7 +1,9 @@
# --- Авторизация по логину/паролю от веб-админки роутера (рекомендуется) --- # --- Авторизация по логину/паролю от веб-админки роутера (рекомендуется) ---
# HTTP-адрес админки Keenetic. На самом роутере обычно подходит loopback. # HTTP-адрес(а) админки Keenetic для проверки логина/пароля.
# Если приложение крутится в той же LAN — поставьте http://192.168.1.1 # Можно перечислить через запятую — будет пробовать по порядку, пока кто-то не примет.
ROUTER_HOST=http://127.0.0.1 # Установщик сам подставит LAN-IP роутера, если запущен на нём.
# Часто loopback закрыт админкой Keenetic — основной кандидат это LAN-IP.
ROUTER_HOST=http://192.168.1.1,http://my.keenetic.net,http://127.0.0.1
# Логин по умолчанию в форме входа (можно изменить руками при логине) # Логин по умолчанию в форме входа (можно изменить руками при логине)
ROUTER_LOGIN=admin ROUTER_LOGIN=admin

17
app.py
View File

@@ -10,7 +10,7 @@ from pathlib import Path
from flask import Flask, Response, jsonify, request from flask import Flask, Response, jsonify, request
from auth import is_token_valid, issue_token, keenetic_validate, revoke_token from auth import is_token_valid, issue_token, keenetic_validate_any, revoke_token
from brand import inject_brand from brand import inject_brand
from executor import run_items from executor import run_items
from store import load_store, new_item, save_store from store import load_store, new_item, save_store
@@ -26,7 +26,8 @@ ALLOWED_IPS_RAW = os.environ.get("ALLOWED_IPS", "").strip()
ALLOWED_IPS = {x.strip() for x in ALLOWED_IPS_RAW.split(",") if x.strip()} if ALLOWED_IPS_RAW else set() ALLOWED_IPS = {x.strip() for x in ALLOWED_IPS_RAW.split(",") if x.strip()} if ALLOWED_IPS_RAW else set()
CMD_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "300")) CMD_TIMEOUT = int(os.environ.get("CMD_TIMEOUT", "300"))
ROUTER_HOST = os.environ.get("ROUTER_HOST", "http://127.0.0.1").strip() ROUTER_HOST_RAW = os.environ.get("ROUTER_HOST", "http://127.0.0.1").strip()
ROUTER_HOSTS = [h.strip() for h in ROUTER_HOST_RAW.split(",") if h.strip()]
ROUTER_LOGIN_DEFAULT = os.environ.get("ROUTER_LOGIN", "admin").strip() or "admin" ROUTER_LOGIN_DEFAULT = os.environ.get("ROUTER_LOGIN", "admin").strip() or "admin"
ROUTER_TIMEOUT = float(os.environ.get("ROUTER_TIMEOUT", "5") or "5") ROUTER_TIMEOUT = float(os.environ.get("ROUTER_TIMEOUT", "5") or "5")
@@ -64,13 +65,14 @@ def _require():
def _validate_credentials(login: str, password: str) -> bool: def _validate_credentials(login: str, password: str) -> bool:
if ROUTER_HOST: if ROUTER_HOSTS:
try: try:
if keenetic_validate(ROUTER_HOST, login, password, timeout=ROUTER_TIMEOUT): if keenetic_validate_any(ROUTER_HOSTS, login, password, timeout=ROUTER_TIMEOUT):
return True return True
except Exception: except Exception:
log.exception("router auth call failed") log.exception("router auth call failed")
if WEB_PASSWORD and password == WEB_PASSWORD: if WEB_PASSWORD and password == WEB_PASSWORD:
log.info("auth: вход по WEB_PASSWORD (мастер-пароль)")
return True return True
return False return False
@@ -93,12 +95,12 @@ def create_app() -> Flask:
if not _ip_allowed(): if not _ip_allowed():
return jsonify({"ok": False, "error": "IP не разрешён"}), 403 return jsonify({"ok": False, "error": "IP не разрешён"}), 403
info = { info = {
"router_auth": bool(ROUTER_HOST), "router_auth": bool(ROUTER_HOSTS),
"router_host": ROUTER_HOST, "router_hosts": ROUTER_HOSTS,
"default_login": ROUTER_LOGIN_DEFAULT, "default_login": ROUTER_LOGIN_DEFAULT,
"password_fallback": bool(WEB_PASSWORD), "password_fallback": bool(WEB_PASSWORD),
} }
if not ROUTER_HOST and not WEB_PASSWORD: if not ROUTER_HOSTS and not WEB_PASSWORD:
return jsonify({"ok": False, "error": "Задайте ROUTER_HOST или WEB_PASSWORD в .env", **info}), 503 return jsonify({"ok": False, "error": "Задайте ROUTER_HOST или WEB_PASSWORD в .env", **info}), 503
return jsonify({"ok": _auth_ok(), **info}) return jsonify({"ok": _auth_ok(), **info})
@@ -112,6 +114,7 @@ def create_app() -> Flask:
if not password: if not password:
return jsonify({"ok": False, "error": "Пустой пароль"}), 400 return jsonify({"ok": False, "error": "Пустой пароль"}), 400
if not _validate_credentials(login, password): if not _validate_credentials(login, password):
log.warning("login fail: ip=%s login=%s router_hosts=%s", _client_ip(), login, ROUTER_HOSTS)
return jsonify({"ok": False, "error": "Неверный логин или пароль"}), 401 return jsonify({"ok": False, "error": "Неверный логин или пароль"}), 401
token = issue_token() token = issue_token()
log.info("login ok: ip=%s login=%s", _client_ip(), login) log.info("login ok: ip=%s login=%s", _client_ip(), login)

35
auth.py
View File

@@ -32,7 +32,11 @@ def _normalize_host(host: str) -> str:
def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0) -> bool: def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0) -> bool:
"""Проверяет связку login/password против админки Keenetic по NDM-протоколу.""" """Проверяет связку login/password против одной админки Keenetic по NDM-протоколу.
Логи пишут конкретную причину (unreachable / wrong code / no realm / bad password),
чтобы при отказе была видна причина в /opt/var/log/keenetic-ssh-web.log.
"""
base = _normalize_host(host) base = _normalize_host(host)
if not base or not login or password is None: if not base or not login or password is None:
return False return False
@@ -44,18 +48,21 @@ def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0
req = urllib.request.Request(base + "/auth", method="GET") req = urllib.request.Request(base + "/auth", method="GET")
with opener.open(req, timeout=timeout) as r: with opener.open(req, timeout=timeout) as r:
if r.status == 200: if r.status == 200:
log.info("auth: %s/auth вернул 200 без креденшелов — пускаем", base)
return True return True
realm = r.headers.get("X-NDM-Realm", "") or "" realm = r.headers.get("X-NDM-Realm", "") or ""
challenge = r.headers.get("X-NDM-Challenge", "") or "" challenge = r.headers.get("X-NDM-Challenge", "") or ""
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code != 401: if e.code != 401:
log.info("auth: GET %s/auth вернул HTTP %d (ожидали 401)", base, e.code)
return False return False
realm = e.headers.get("X-NDM-Realm", "") or "" realm = e.headers.get("X-NDM-Realm", "") or ""
challenge = e.headers.get("X-NDM-Challenge", "") or "" challenge = e.headers.get("X-NDM-Challenge", "") or ""
except (urllib.error.URLError, OSError) as e: except (urllib.error.URLError, OSError) as e:
log.warning("auth: router unreachable %s: %s", base, e) log.warning("auth: %s недоступен: %s", base, e)
return False return False
if not realm or not challenge: if not realm or not challenge:
log.warning("auth: %s/auth не вернул X-NDM-Realm/X-NDM-Challenge — это не Keenetic-админка?", base)
return False return False
md5 = hashlib.md5(f"{login}:{realm}:{password}".encode("utf-8")).hexdigest() md5 = hashlib.md5(f"{login}:{realm}:{password}".encode("utf-8")).hexdigest()
sha = hashlib.sha256((challenge + md5).encode("utf-8")).hexdigest() sha = hashlib.sha256((challenge + md5).encode("utf-8")).hexdigest()
@@ -68,14 +75,32 @@ def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0
method="POST", method="POST",
) )
with opener.open(req2, timeout=timeout) as r2: with opener.open(req2, timeout=timeout) as r2:
return 200 <= r2.status < 300 if 200 <= r2.status < 300:
except urllib.error.HTTPError: log.info("auth: %s принял login=%s", base, login)
return True
log.info("auth: %s вернул HTTP %d", base, r2.status)
return False
except urllib.error.HTTPError as e:
log.info("auth: %s POST HTTP %d (неверный логин/пароль?)", base, e.code)
return False return False
except (urllib.error.URLError, OSError) as e: except (urllib.error.URLError, OSError) as e:
log.warning("auth: post failed %s: %s", base, e) log.warning("auth: %s POST не удался: %s", base, e)
return False return False
def keenetic_validate_any(hosts: list[str], login: str, password: str, timeout: float = 5.0) -> bool:
"""Пробует каждый host из списка по очереди; True, если хотя бы один принял."""
for h in hosts:
if not h:
continue
try:
if keenetic_validate(h, login, password, timeout=timeout):
return True
except Exception:
log.exception("auth: ошибка при проверке %s", h)
return False
_lock = threading.Lock() _lock = threading.Lock()
_sessions: dict[str, float] = {} _sessions: dict[str, float] = {}
_TTL = 8 * 3600 _TTL = 8 * 3600

View File

@@ -20,7 +20,17 @@ if [ ! -f "$INST/data/store.json" ]; then
fi fi
if [ ! -f "$INST/.env" ]; then if [ ! -f "$INST/.env" ]; then
cp -f "$ROOT/.env.example" "$INST/.env" cp -f "$ROOT/.env.example" "$INST/.env"
echo "!!! Создан $INST/.env — задайте WEB_PASSWORD и при необходимости ALLOWED_IPS" # Подставляем в свежий .env обнаруженный LAN-IP роутера в ROUTER_HOST,
# чтобы клиент не оставался на одном лишь loopback (часто закрыт у Keenetic).
LAN_IP_GUESS="$(
ip -4 -o addr show 2>/dev/null \
| awk '$4 ~ /^(192\.168|10|172\.(1[6-9]|2[0-9]|3[01]))\..*\.1\// {sub("/.*","",$4); print $4; exit}'
)"
if [ -n "$LAN_IP_GUESS" ]; then
sed -i "s|^ROUTER_HOST=.*|ROUTER_HOST=http://${LAN_IP_GUESS},http://my.keenetic.net,http://127.0.0.1|" "$INST/.env" 2>/dev/null || true
echo "==> .env: ROUTER_HOST подставлен LAN-IP $LAN_IP_GUESS (можно изменить в $INST/.env)"
fi
echo "!!! Создан $INST/.env — при необходимости задайте WEB_PASSWORD/ALLOWED_IPS"
fi fi
chmod +x "$INST/run.sh" chmod +x "$INST/run.sh"