fix(keenetic): preserve URL scheme/ports and IPv4 polling

Use web_url scheme and host:port for API base URL instead of
forcing https. Force IPv4 in aiohttp to avoid IPv6 hangs on
KeenDNS. Sync canonical 24-router list with Russian names.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-22 00:52:11 +03:00
parent de99b60910
commit 746ecfc343
5 changed files with 232 additions and 87 deletions

View File

@@ -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__":

View File

@@ -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())

View File

@@ -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__":

View File

@@ -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}")

View File

@@ -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/<command> 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."""