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: for d in devices:
r = await check(d) r = await check(d)
print(f"{d['name']:22} {r}") print(f"{d['name']:22} {r}")
await asyncio.sleep(0.3) await asyncio.sleep(1)
if __name__ == "__main__": 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 #!/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 json
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
# Authoritative list: Russian name, exact web URL, AnyDesk (optional)
IMPORT = [ 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"), ("Лилиана Лофт", "https://loftliliana.netcraze.pro", "1020687391"),
("Артем vtb126", "https://vtb126.netcraze.club", ""), ("Артем vtb126", "https://vtb126.netcraze.club", ""),
("Артем Квартира Подмосковный", "http://95.165.93.46:777", "1527495291"), ("Артем Квартира Подмосковный", "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("/") 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): def make_device(name, keenetic_url, login, password, anydesk="", added=None):
keenetic_url = normalize_web_url(keenetic_url) keenetic_url = normalize_web_url(keenetic_url)
host = host_from_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: with open(path) as f:
old = json.load(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} by_host = {d.get("host", ""): d for d in old}
path.with_suffix(".json.bak").write_text( path.with_suffix(".json.bak").write_text(
json.dumps(old, indent=2, ensure_ascii=False), encoding="utf-8" json.dumps(old, indent=2, ensure_ascii=False), encoding="utf-8"
@@ -72,25 +76,14 @@ def sync_file(path: Path, default_password: str):
result = [] result = []
for name, url, ad in IMPORT: for name, url, ad in IMPORT:
host = host_from_url(url) 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") login = prev.get("login", "admin")
password = prev.get("password") or default_password password = prev.get("password") or default_password
added = prev.get("added") added = prev.get("added")
result.append(make_device(name, url, login, password, ad, 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") 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__": if __name__ == "__main__":

View File

@@ -309,7 +309,7 @@ async def keenetic_monitor_loop():
await _refresh_device(dev) await _refresh_device(dev)
except Exception as e: except Exception as e:
logger.error(f"Keenetic refresh {dev['name']}: {e}") logger.error(f"Keenetic refresh {dev['name']}: {e}")
await asyncio.sleep(2) await asyncio.sleep(3)
except Exception as e: except Exception as e:
logger.error(f"Keenetic monitor loop error: {e}") logger.error(f"Keenetic monitor loop error: {e}")

View File

@@ -1,7 +1,10 @@
"""Keenetic router RCI API client for monitoring via KeenDNS.""" """Keenetic router RCI API client for monitoring via KeenDNS."""
import asyncio
import hashlib import hashlib
import logging import logging
import re
import socket
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -10,6 +13,8 @@ import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic") 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: def normalize_web_url(url: str) -> str:
@@ -22,24 +27,52 @@ def normalize_web_url(url: str) -> str:
return url.rstrip("/") 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: def build_api_base_url(host: str, web_url: str = "") -> str:
"""Build RCI API base URL from host and/or Keenetic web URL column.""" """Build RCI API base URL — prefer scheme/host/port from web_url."""
raw = (host or "").strip() if web_url:
if not raw and web_url:
parsed = urlparse(normalize_web_url(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: if not raw:
return "" return ""
if raw.startswith("http://") or raw.startswith("https://"): if raw.startswith("http://") or raw.startswith("https://"):
return raw.rstrip("/") return raw.rstrip("/")
domain = raw.split(":")[0].lower() scheme = "https" if is_keendns_host(raw) else "http"
is_keendns = any(m in domain for m in KEENDNS_MARKERS)
scheme = "https" if is_keendns else "http"
return f"{scheme}://{raw}".rstrip("/") 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: class KeeneticClient:
"""Async client for Keenetic router RCI API. """Async client for Keenetic router RCI API.
@@ -56,6 +89,8 @@ class KeeneticClient:
if not base: if not base:
raise ValueError("host or web_url required") raise ValueError("host or web_url required")
self.base_url = base self.base_url = base
parsed = urlparse(base)
self._host_key = parsed.netloc or host
self.login = login self.login = login
self.password = password self.password = password
self._session: Optional[aiohttp.ClientSession] = None self._session: Optional[aiohttp.ClientSession] = None
@@ -64,69 +99,114 @@ class KeeneticClient:
async def _get_session(self) -> aiohttp.ClientSession: async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed: 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) 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 return self._session
async def authenticate(self) -> bool: async def _reset_session(self):
"""Perform challenge-response authentication.""" 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 = "" self.last_error = ""
session = await self._get_session() session = await self._get_session()
auth_url = f"{self.base_url}/auth" auth_url = f"{self.base_url}/auth"
try: async with session.get(auth_url, ssl=False) as resp:
async with session.get(auth_url, ssl=False) as resp: if resp.status == 200:
if resp.status == 200: self._authenticated = True
self._authenticated = True return True
return True
if resp.status != 401: if resp.status != 401:
if resp.status in (400, 403): if resp.status in (400, 403):
self.last_error = "Wrong protocol or port (try http/https)" self.last_error = "Wrong protocol or port (try http/https)"
else: else:
self.last_error = f"Auth HTTP {resp.status}" self.last_error = f"Auth HTTP {resp.status}"
logger.error(f"Keenetic auth unexpected status: {resp.status} @ {self.base_url}") logger.error(
return False f"Keenetic auth unexpected status: {resp.status} @ {self.base_url}"
)
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 return False
except aiohttp.ClientConnectorError as e: challenge = resp.headers.get("X-NDM-Challenge", "")
self.last_error = "Cannot connect to router" realm = resp.headers.get("X-NDM-Realm", "")
logger.error(f"Keenetic auth error: {type(e).__name__}: {e}")
return False if not challenge or not realm:
except TimeoutError: self.last_error = "No auth challenge from router"
self.last_error = "Connection timeout" logger.error("Keenetic auth: missing challenge/realm headers")
logger.error(f"Keenetic auth timeout @ {self.base_url}") return False
return False
except Exception as e: md5_input = f"{self.login}:{realm}:{self.password}"
self.last_error = type(e).__name__ md5_hex = hashlib.md5(md5_input.encode("utf-8")).hexdigest()
logger.error(f"Keenetic auth error: {type(e).__name__}: {e}") 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 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]: async def rci_show(self, command: str, params: Optional[dict] = None) -> Optional[dict]:
"""GET /rci/show/<command> with optional query params.""" """GET /rci/show/<command> with optional query params."""
if not self._authenticated: if not self._authenticated:
@@ -159,18 +239,42 @@ class KeeneticClient:
if not await self.authenticate(): if not await self.authenticate():
return None return None
session = await self._get_session() retryable = (
aiohttp.ServerTimeoutError,
aiohttp.ClientOSError,
asyncio.TimeoutError,
TimeoutError,
)
url = f"{self.base_url}/rci/" url = f"{self.base_url}/rci/"
for attempt in range(AUTH_RETRIES):
try: try:
async with session.post(url, json=body, ssl=False) as resp: session = await self._get_session()
if resp.status == 200: async with session.post(url, json=body, ssl=False) as resp:
return await resp.json(content_type=None) if resp.status == 200:
logger.error(f"Keenetic RCI POST: HTTP {resp.status}") 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 return None
except Exception as e: except Exception as e:
logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}") logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}")
return None return None
return None
async def collect_metrics(self, cached_info: Optional[dict] = None) -> dict: async def collect_metrics(self, cached_info: Optional[dict] = None) -> dict:
"""Lightweight refresh: system + internet + VPN only.""" """Lightweight refresh: system + internet + VPN only."""