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

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:
"""Проверяет связку 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