mirror of
https://github.com/andrey271192/keenetic_ssh-web.git
synced 2026-09-20 14:42:01 +00:00
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:
@@ -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
17
app.py
@@ -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
35
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:
|
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
|
||||||
|
|||||||
12
install.sh
12
install.sh
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user