From d8ee078ef52cd63ec1eb473118bd5dfab20c976c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Sun, 26 Apr 2026 01:32:34 +0300 Subject: [PATCH] =?UTF-8?q?auth:=20ROUTER=5FHOST=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20+=20=D0=B4=D0=B8=D0=B0?= =?UTF-8?q?=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=87=D0=B8=D0=BD=20=D0=B2=20=D0=BB=D0=BE=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На Keenetic админка часто не слушает loopback — биндится только на LAN-интерфейсах. Делаем ROUTER_HOST списком (через запятую), пробуем все по порядку. - auth.py: keenetic_validate_any() — обходит список, на каждом шаге пишет в лог конкретную причину отказа: unreachable / нет realm / POST HTTP / неверный логин-пароль - app.py: парсит ROUTER_HOST как список (csv); login fail логируется - install.sh: при создании свежего .env подставляет в ROUTER_HOST обнаруженный LAN-IP, my.keenetic.net и loopback (через запятую) - .env.example: тот же дефолт, комментарии обновлены --- .env.example | 8 +++++--- app.py | 17 ++++++++++------- auth.py | 35 ++++++++++++++++++++++++++++++----- install.sh | 12 +++++++++++- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index c5138da..ff5ffc9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ # --- Авторизация по логину/паролю от веб-админки роутера (рекомендуется) --- -# HTTP-адрес админки Keenetic. На самом роутере обычно подходит loopback. -# Если приложение крутится в той же LAN — поставьте http://192.168.1.1 -ROUTER_HOST=http://127.0.0.1 +# HTTP-адрес(а) админки Keenetic для проверки логина/пароля. +# Можно перечислить через запятую — будет пробовать по порядку, пока кто-то не примет. +# Установщик сам подставит 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 diff --git a/app.py b/app.py index ab047ca..1824006 100644 --- a/app.py +++ b/app.py @@ -10,7 +10,7 @@ from pathlib import Path 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 executor import run_items 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() 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_TIMEOUT = float(os.environ.get("ROUTER_TIMEOUT", "5") or "5") @@ -64,13 +65,14 @@ def _require(): def _validate_credentials(login: str, password: str) -> bool: - if ROUTER_HOST: + if ROUTER_HOSTS: try: - if keenetic_validate(ROUTER_HOST, login, password, timeout=ROUTER_TIMEOUT): + if keenetic_validate_any(ROUTER_HOSTS, login, password, timeout=ROUTER_TIMEOUT): return True except Exception: log.exception("router auth call failed") if WEB_PASSWORD and password == WEB_PASSWORD: + log.info("auth: вход по WEB_PASSWORD (мастер-пароль)") return True return False @@ -93,12 +95,12 @@ def create_app() -> Flask: if not _ip_allowed(): return jsonify({"ok": False, "error": "IP не разрешён"}), 403 info = { - "router_auth": bool(ROUTER_HOST), - "router_host": ROUTER_HOST, + "router_auth": bool(ROUTER_HOSTS), + "router_hosts": ROUTER_HOSTS, "default_login": ROUTER_LOGIN_DEFAULT, "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": _auth_ok(), **info}) @@ -112,6 +114,7 @@ def create_app() -> Flask: if not password: return jsonify({"ok": False, "error": "Пустой пароль"}), 400 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 token = issue_token() log.info("login ok: ip=%s login=%s", _client_ip(), login) diff --git a/auth.py b/auth.py index 9f8ac0a..a5c6b60 100644 --- a/auth.py +++ b/auth.py @@ -32,7 +32,11 @@ def _normalize_host(host: str) -> str: 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) if not base or not login or password is None: 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") with opener.open(req, timeout=timeout) as r: if r.status == 200: + log.info("auth: %s/auth вернул 200 без креденшелов — пускаем", base) return True realm = r.headers.get("X-NDM-Realm", "") or "" challenge = r.headers.get("X-NDM-Challenge", "") or "" except urllib.error.HTTPError as e: if e.code != 401: + log.info("auth: GET %s/auth вернул HTTP %d (ожидали 401)", base, e.code) return False realm = e.headers.get("X-NDM-Realm", "") or "" challenge = e.headers.get("X-NDM-Challenge", "") or "" 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 if not realm or not challenge: + log.warning("auth: %s/auth не вернул X-NDM-Realm/X-NDM-Challenge — это не Keenetic-админка?", base) return False md5 = hashlib.md5(f"{login}:{realm}:{password}".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", ) with opener.open(req2, timeout=timeout) as r2: - return 200 <= r2.status < 300 - except urllib.error.HTTPError: + if 200 <= r2.status < 300: + 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 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 +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() _sessions: dict[str, float] = {} _TTL = 8 * 3600 diff --git a/install.sh b/install.sh index 1e67762..7b78228 100644 --- a/install.sh +++ b/install.sh @@ -20,7 +20,17 @@ if [ ! -f "$INST/data/store.json" ]; then fi if [ ! -f "$INST/.env" ]; then 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 chmod +x "$INST/run.sh"