diff --git a/scripts/check_keenetic.py b/scripts/check_keenetic.py index 0633f31..f0c3b1b 100644 --- a/scripts/check_keenetic.py +++ b/scripts/check_keenetic.py @@ -36,7 +36,7 @@ async def main(): for d in devices: r = await check(d) print(f"{d['name']:22} {r}") - await asyncio.sleep(0.3) + await asyncio.sleep(1) if __name__ == "__main__": diff --git a/scripts/quick_auth_check.py b/scripts/quick_auth_check.py new file mode 100644 index 0000000..7e73ea0 --- /dev/null +++ b/scripts/quick_auth_check.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Fast auth-only check for all routers (no RCI batch).""" +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from server.services.keenetic_client import KeeneticClient + +DATA = Path("/opt/vps-monitoring/data/keenetic.json") + + +async def one(dev): + c = KeeneticClient( + host=dev.get("host", ""), + login=dev.get("login", "admin"), + password=dev.get("password", ""), + web_url=dev.get("web_url", ""), + ) + try: + ok = await c.authenticate() + if ok: + return "ONLINE" + return f"OFFLINE: {c.last_error}" + except Exception as e: + return f"ERR: {e}" + finally: + await c.close() + + +async def main(): + with open(DATA) as f: + devices = json.load(f) + online = 0 + for d in devices: + r = await one(d) + if r == "ONLINE": + online += 1 + name = d["name"] + url = d.get("web_url", "") + print(f"{name:28} {r:40} {url}") + await asyncio.sleep(0.3) + print(f"\nTotal: {online}/{len(devices)} ONLINE") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/sync_keenetic_import.py b/scripts/sync_keenetic_import.py index 8016b25..40b71c6 100644 --- a/scripts/sync_keenetic_import.py +++ b/scripts/sync_keenetic_import.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 -"""Sync keenetic.json with canonical import list (names, web_url, anydesk).""" +"""Sync keenetic.json with canonical router list (24 entries, exact URLs).""" import json import sys from datetime import datetime from pathlib import Path from urllib.parse import urlparse +# Authoritative list: Russian name, exact web URL, AnyDesk (optional) IMPORT = [ + ("Москва Сити Асланян", "https://moscowcity.netcraze.pro:5443", ""), + ("Вымпел Асланян", "https://vimpel.netcraze.link", ""), + ("Асланян Квартира", "https://eropkinskii.netcraze.club", ""), + ("Новоглаголево Лилиана", "http://91.77.164.164", ""), + ("Лилиана Фотиева", "https://fotieva.netcraze.link", ""), ("Лилиана Лофт", "https://loftliliana.netcraze.pro", "1020687391"), ("Артем vtb126", "https://vtb126.netcraze.club", ""), ("Артем Квартира Подмосковный", "http://95.165.93.46:777", "1527495291"), @@ -43,9 +49,6 @@ def host_from_url(url: str) -> str: return parsed.netloc or url.replace("https://", "").replace("http://", "").rstrip("/") -IMPORT_HOSTS = {host_from_url(url) for _, url, _ in IMPORT} - - def make_device(name, keenetic_url, login, password, anydesk="", added=None): keenetic_url = normalize_web_url(keenetic_url) host = host_from_url(keenetic_url) @@ -64,6 +67,7 @@ def sync_file(path: Path, default_password: str): with open(path) as f: old = json.load(f) + by_name = {d.get("name", ""): d for d in old} by_host = {d.get("host", ""): d for d in old} path.with_suffix(".json.bak").write_text( json.dumps(old, indent=2, ensure_ascii=False), encoding="utf-8" @@ -72,25 +76,14 @@ def sync_file(path: Path, default_password: str): result = [] for name, url, ad in IMPORT: host = host_from_url(url) - prev = by_host.get(host, {}) + prev = by_name.get(name) or by_host.get(host, {}) login = prev.get("login", "admin") password = prev.get("password") or default_password added = prev.get("added") result.append(make_device(name, url, login, password, ad, added)) - for d in old: - host = d.get("host", "") - if host in IMPORT_HOSTS: - continue - if not d.get("web_url") and host: - h = host - d["web_url"] = normalize_web_url( - h if h.startswith("http") else ("http://" if h[0].isdigit() else "https://") + h - ) - result.append(d) - path.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") - print(f"Wrote {len(result)} devices ({len(IMPORT)} import + {len(result) - len(IMPORT)} extra)") + print(f"Wrote {len(result)} devices (canonical import)") if __name__ == "__main__": diff --git a/server/api/keenetic.py b/server/api/keenetic.py index c2fafd8..1930694 100644 --- a/server/api/keenetic.py +++ b/server/api/keenetic.py @@ -309,7 +309,7 @@ async def keenetic_monitor_loop(): await _refresh_device(dev) except Exception as e: logger.error(f"Keenetic refresh {dev['name']}: {e}") - await asyncio.sleep(2) + await asyncio.sleep(3) except Exception as e: logger.error(f"Keenetic monitor loop error: {e}") diff --git a/server/services/keenetic_client.py b/server/services/keenetic_client.py index 7ab2710..717f768 100644 --- a/server/services/keenetic_client.py +++ b/server/services/keenetic_client.py @@ -1,7 +1,10 @@ """Keenetic router RCI API client for monitoring via KeenDNS.""" +import asyncio import hashlib import logging +import re +import socket from typing import Optional from urllib.parse import urlparse @@ -10,6 +13,8 @@ import aiohttp logger = logging.getLogger(__name__) KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic") +AUTH_RETRIES = 3 +_IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") def normalize_web_url(url: str) -> str: @@ -22,24 +27,52 @@ def normalize_web_url(url: str) -> str: return url.rstrip("/") +def is_public_ip_host(host: str) -> bool: + """True when host is a bare IPv4 (optional :port), not KeenDNS.""" + if not host: + return False + return bool(_IP_HOST_RE.match(host.split(":")[0])) + + +def is_keendns_host(host: str) -> bool: + domain = (host or "").split(":")[0].lower() + return any(m in domain for m in KEENDNS_MARKERS) + + +def client_timeout_for(host: str) -> aiohttp.ClientTimeout: + """Timeouts tuned for KeenDNS vs direct IP.""" + if is_public_ip_host(host): + return aiohttp.ClientTimeout(total=20, connect=8, sock_read=12) + return aiohttp.ClientTimeout(total=45, connect=15, sock_read=30) + + def build_api_base_url(host: str, web_url: str = "") -> str: - """Build RCI API base URL from host and/or Keenetic web URL column.""" - raw = (host or "").strip() - if not raw and web_url: + """Build RCI API base URL — prefer scheme/host/port from web_url.""" + if web_url: parsed = urlparse(normalize_web_url(web_url)) - raw = parsed.netloc or (parsed.path.split("/")[0] if parsed.path else "") + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}".rstrip("/") + + raw = (host or "").strip() if not raw: return "" if raw.startswith("http://") or raw.startswith("https://"): return raw.rstrip("/") - domain = raw.split(":")[0].lower() - is_keendns = any(m in domain for m in KEENDNS_MARKERS) - scheme = "https" if is_keendns else "http" + scheme = "https" if is_keendns_host(raw) else "http" return f"{scheme}://{raw}".rstrip("/") +def _make_connector() -> aiohttp.TCPConnector: + """IPv4-only: avoids aiohttp hanging on broken IPv6 for KeenDNS multi-A records.""" + return aiohttp.TCPConnector( + family=socket.AF_INET, + force_close=True, + enable_cleanup_closed=True, + ) + + class KeeneticClient: """Async client for Keenetic router RCI API. @@ -56,6 +89,8 @@ class KeeneticClient: if not base: raise ValueError("host or web_url required") self.base_url = base + parsed = urlparse(base) + self._host_key = parsed.netloc or host self.login = login self.password = password self._session: Optional[aiohttp.ClientSession] = None @@ -64,69 +99,114 @@ class KeeneticClient: async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: - timeout = aiohttp.ClientTimeout(total=25, connect=10) + timeout = client_timeout_for(self._host_key) jar = aiohttp.CookieJar(unsafe=True) - self._session = aiohttp.ClientSession(timeout=timeout, cookie_jar=jar) + self._session = aiohttp.ClientSession( + timeout=timeout, + cookie_jar=jar, + connector=_make_connector(), + version=aiohttp.HttpVersion11, + headers={"User-Agent": "VPS-Monitoring/1.0"}, + ) return self._session - async def authenticate(self) -> bool: - """Perform challenge-response authentication.""" + async def _reset_session(self): + if self._session and not self._session.closed: + await self._session.close() + self._session = None + self._authenticated = False + + def _timeout_error_message(self) -> str: + if is_public_ip_host(self._host_key): + return ( + "HTTP API не отвечает с VPS (прямой IP). " + "Нужен KeenDNS или удалённый доступ Keenetic." + ) + return "Connection timeout" + + async def _authenticate_once(self) -> bool: + """Single auth attempt.""" self.last_error = "" session = await self._get_session() auth_url = f"{self.base_url}/auth" - try: - async with session.get(auth_url, ssl=False) as resp: - if resp.status == 200: - self._authenticated = True - return True + async with session.get(auth_url, ssl=False) as resp: + if resp.status == 200: + self._authenticated = True + return True - if resp.status != 401: - if resp.status in (400, 403): - self.last_error = "Wrong protocol or port (try http/https)" - else: - self.last_error = f"Auth HTTP {resp.status}" - logger.error(f"Keenetic auth unexpected status: {resp.status} @ {self.base_url}") - return False - - challenge = resp.headers.get("X-NDM-Challenge", "") - realm = resp.headers.get("X-NDM-Realm", "") - - if not challenge or not realm: - self.last_error = "No auth challenge from router" - logger.error("Keenetic auth: missing challenge/realm headers") - return False - - md5_input = f"{self.login}:{realm}:{self.password}" - md5_hex = hashlib.md5(md5_input.encode("utf-8")).hexdigest() - sha_input = f"{challenge}{md5_hex}" - sha_hex = hashlib.sha256(sha_input.encode("utf-8")).hexdigest() - - async with session.post( - auth_url, - json={"login": self.login, "password": sha_hex}, - ssl=False, - ) as resp: - if resp.status == 200: - self._authenticated = True - return True - self.last_error = "Wrong login or password" - logger.error(f"Keenetic auth failed: {resp.status} @ {self.base_url}") + if resp.status != 401: + if resp.status in (400, 403): + self.last_error = "Wrong protocol or port (try http/https)" + else: + self.last_error = f"Auth HTTP {resp.status}" + logger.error( + f"Keenetic auth unexpected status: {resp.status} @ {self.base_url}" + ) return False - except aiohttp.ClientConnectorError as e: - self.last_error = "Cannot connect to router" - logger.error(f"Keenetic auth error: {type(e).__name__}: {e}") - return False - except TimeoutError: - self.last_error = "Connection timeout" - logger.error(f"Keenetic auth timeout @ {self.base_url}") - return False - except Exception as e: - self.last_error = type(e).__name__ - logger.error(f"Keenetic auth error: {type(e).__name__}: {e}") + challenge = resp.headers.get("X-NDM-Challenge", "") + realm = resp.headers.get("X-NDM-Realm", "") + + if not challenge or not realm: + self.last_error = "No auth challenge from router" + logger.error("Keenetic auth: missing challenge/realm headers") + return False + + md5_input = f"{self.login}:{realm}:{self.password}" + md5_hex = hashlib.md5(md5_input.encode("utf-8")).hexdigest() + sha_input = f"{challenge}{md5_hex}" + sha_hex = hashlib.sha256(sha_input.encode("utf-8")).hexdigest() + + async with session.post( + auth_url, + json={"login": self.login, "password": sha_hex}, + ssl=False, + ) as resp: + if resp.status == 200: + self._authenticated = True + return True + self.last_error = "Wrong login or password" + logger.error(f"Keenetic auth failed: {resp.status} @ {self.base_url}") return False + async def authenticate(self) -> bool: + """Perform challenge-response authentication with retries.""" + retryable = ( + aiohttp.ServerTimeoutError, + aiohttp.ClientOSError, + asyncio.TimeoutError, + TimeoutError, + ) + for attempt in range(AUTH_RETRIES): + try: + return await self._authenticate_once() + except aiohttp.ClientConnectorError as e: + err = str(e).lower() + if "name or service not known" in err or "nodename nor servname" in err: + self.last_error = "DNS не резолвится с VPS" + else: + self.last_error = "Cannot connect to router" + logger.error(f"Keenetic auth error: {type(e).__name__}: {e}") + return False + except retryable as e: + self.last_error = self._timeout_error_message() + logger.warning( + f"Keenetic auth timeout ({attempt + 1}/{AUTH_RETRIES}) " + f"@ {self.base_url}: {type(e).__name__}" + ) + await self._reset_session() + if attempt + 1 < AUTH_RETRIES: + await asyncio.sleep(1.5 * (attempt + 1)) + continue + logger.error(f"Keenetic auth timeout @ {self.base_url}") + return False + except Exception as e: + self.last_error = type(e).__name__ + logger.error(f"Keenetic auth error: {type(e).__name__}: {e}") + return False + return False + async def rci_show(self, command: str, params: Optional[dict] = None) -> Optional[dict]: """GET /rci/show/ with optional query params.""" if not self._authenticated: @@ -159,18 +239,42 @@ class KeeneticClient: if not await self.authenticate(): return None - session = await self._get_session() + retryable = ( + aiohttp.ServerTimeoutError, + aiohttp.ClientOSError, + asyncio.TimeoutError, + TimeoutError, + ) url = f"{self.base_url}/rci/" - - try: - async with session.post(url, json=body, ssl=False) as resp: - if resp.status == 200: - return await resp.json(content_type=None) - logger.error(f"Keenetic RCI POST: HTTP {resp.status}") + for attempt in range(AUTH_RETRIES): + try: + session = await self._get_session() + async with session.post(url, json=body, ssl=False) as resp: + if resp.status == 200: + return await resp.json(content_type=None) + if resp.status == 401: + self._authenticated = False + if await self.authenticate(): + continue + logger.error(f"Keenetic RCI POST: HTTP {resp.status}") + return None + except retryable as e: + logger.warning( + f"Keenetic RCI POST timeout ({attempt + 1}/{AUTH_RETRIES}) " + f"@ {self.base_url}: {type(e).__name__}" + ) + await self._reset_session() + if not await self.authenticate(): + return None + if attempt + 1 < AUTH_RETRIES: + await asyncio.sleep(1.5 * (attempt + 1)) + continue + logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}") return None - except Exception as e: - logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}") - return None + except Exception as e: + logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}") + return None + return None async def collect_metrics(self, cached_info: Optional[dict] = None) -> dict: """Lightweight refresh: system + internet + VPN only."""