mirror of
https://github.com/andrey271192/keenetic_ssh-web.git
synced 2026-09-20 14:42:01 +00:00
- urllib теперь идёт на HTTPS с unverified context (Keenetic админка обычно с самоподписанным сертификатом) - .env по умолчанию содержит и HTTP, и HTTPS, и :81 для LAN-IP, плюс my.keenetic.net и loopback — пробуется всё подряд
151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
"""Аутентификация против HTTP-админки Keenetic (NDM) + сессионные токены.
|
||
|
||
Используется протокол NDM:
|
||
1) GET /auth → 401 + заголовки X-NDM-Realm и X-NDM-Challenge.
|
||
2) hash = sha256(challenge + md5(login:realm:password)).
|
||
3) POST /auth с JSON {"login": ..., "password": hash} → 200 OK при успехе.
|
||
|
||
Не требует внешних библиотек (urllib стандартной библиотеки).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import http.cookiejar
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import ssl
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
log = logging.getLogger("kssh.auth")
|
||
|
||
# У Keenetic админка на HTTPS обычно с самоподписанным сертификатом — отключаем проверку.
|
||
_SSL_CTX = ssl.create_default_context()
|
||
_SSL_CTX.check_hostname = False
|
||
_SSL_CTX.verify_mode = ssl.CERT_NONE
|
||
|
||
|
||
def _normalize_host(host: str) -> str:
|
||
h = (host or "").strip().rstrip("/")
|
||
if not h:
|
||
return ""
|
||
if not h.startswith(("http://", "https://")):
|
||
h = "http://" + h
|
||
return h
|
||
|
||
|
||
def keenetic_validate(host: str, login: str, password: str, timeout: float = 5.0) -> bool:
|
||
"""Проверяет связку 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
|
||
cj = http.cookiejar.CookieJar()
|
||
opener = urllib.request.build_opener(
|
||
urllib.request.HTTPCookieProcessor(cj),
|
||
urllib.request.HTTPSHandler(context=_SSL_CTX),
|
||
)
|
||
realm = ""
|
||
challenge = ""
|
||
try:
|
||
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: %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()
|
||
body = json.dumps({"login": login, "password": sha}).encode("utf-8")
|
||
try:
|
||
req2 = urllib.request.Request(
|
||
base + "/auth",
|
||
data=body,
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with opener.open(req2, timeout=timeout) as r2:
|
||
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: %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
|
||
|
||
|
||
def _gc_locked(now: float) -> None:
|
||
expired = [t for t, exp in _sessions.items() if exp < now]
|
||
for t in expired:
|
||
_sessions.pop(t, None)
|
||
|
||
|
||
def issue_token(ttl: int = _TTL) -> str:
|
||
now = time.time()
|
||
token = secrets.token_urlsafe(32)
|
||
with _lock:
|
||
_gc_locked(now)
|
||
_sessions[token] = now + ttl
|
||
return token
|
||
|
||
|
||
def is_token_valid(token: str, ttl: int = _TTL) -> bool:
|
||
if not token:
|
||
return False
|
||
now = time.time()
|
||
with _lock:
|
||
_gc_locked(now)
|
||
exp = _sessions.get(token)
|
||
if exp and exp >= now:
|
||
_sessions[token] = now + ttl
|
||
return True
|
||
return False
|
||
|
||
|
||
def revoke_token(token: str) -> None:
|
||
if not token:
|
||
return
|
||
with _lock:
|
||
_sessions.pop(token, None)
|