fix(keenetic): switch RCI client from aiohttp to httpx

aiohttp hung on KeenDNS from VPS while httpx works (keenetic-unified
pattern). Align add/import/bulk URL parsing with parse_keenetic_web_url.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-22 15:37:32 +03:00
parent c64b455f9a
commit 91e675566f
3 changed files with 161 additions and 260 deletions

View File

@@ -8,6 +8,7 @@ websockets==12.0
python-telegram-bot==21.5 python-telegram-bot==21.5
psutil==6.0.0 psutil==6.0.0
aiohttp==3.10.0 aiohttp==3.10.0
httpx==0.27.2
pydantic==2.9.0 pydantic==2.9.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0

View File

@@ -50,12 +50,11 @@ def _host_from_url(url: str) -> str:
def _make_device(name: str, keenetic_url: str, login: str, password: str, def _make_device(name: str, keenetic_url: str, login: str, password: str,
anydesk: str = "") -> dict: anydesk: str = "") -> dict:
keenetic_url = (keenetic_url or "").strip() web_url, host = parse_keenetic_web_url(keenetic_url)
host = _host_from_url(keenetic_url) if keenetic_url else ""
return { return {
"name": name, "name": name,
"host": host, "host": host,
"web_url": normalize_web_url(keenetic_url) if keenetic_url else "", "web_url": web_url,
"anydesk": (anydesk or "").strip(), "anydesk": (anydesk or "").strip(),
"login": login, "login": login,
"password": password, "password": password,
@@ -115,19 +114,20 @@ async def keenetic_list(request: Request, user: str = Depends(require_auth)):
async def keenetic_add(request: Request, user: str = Depends(require_auth)): async def keenetic_add(request: Request, user: str = Depends(require_auth)):
body = await request.json() body = await request.json()
name = body.get("name", "").strip() name = body.get("name", "").strip()
host = body.get("host", "").strip() url_raw = (body.get("web_url") or body.get("host") or "").strip()
web_url = body.get("web_url", "").strip() if not name or not url_raw:
if not name or (not host and not web_url): return {"status": "error", "detail": "name and web_url (or host) required"}
return {"status": "error", "detail": "name and host (or web_url) required"}
if not host and web_url: try:
host = _host_from_url(web_url) web_url, host = parse_keenetic_web_url(url_raw)
except ValueError as e:
return {"status": "error", "detail": str(e)}
devices = _load_keenetic() devices = _load_keenetic()
device = { device = {
"name": name, "name": name,
"host": host, "host": host,
"web_url": normalize_web_url(web_url or host), "web_url": web_url,
"anydesk": body.get("anydesk", "").strip(), "anydesk": body.get("anydesk", "").strip(),
"login": body.get("login", "admin"), "login": body.get("login", "admin"),
"password": body.get("password", ""), "password": body.get("password", ""),
@@ -187,7 +187,11 @@ async def keenetic_import(request: Request, user: str = Depends(require_auth)):
keen_url = (row.get("keenetic") or row.get("url") or "").strip() keen_url = (row.get("keenetic") or row.get("url") or "").strip()
if not keen_url: if not keen_url:
continue continue
host = _host_from_url(keen_url) try:
web_url, host = parse_keenetic_web_url(keen_url)
except ValueError:
skipped.append(keen_url)
continue
addr = (row.get("address") or row.get("name") or "").strip() addr = (row.get("address") or row.get("name") or "").strip()
name = addr.capitalize() if addr else host.split(".")[0].capitalize() name = addr.capitalize() if addr else host.split(".")[0].capitalize()
base_name, counter = name, 2 base_name, counter = name, 2
@@ -197,7 +201,7 @@ async def keenetic_import(request: Request, user: str = Depends(require_auth)):
if host in existing_hosts: if host in existing_hosts:
skipped.append(host) skipped.append(host)
continue continue
device = _make_device(name, keen_url, login, password, row.get("anydesk", "")) device = _make_device(name, web_url, login, password, row.get("anydesk", ""))
devices.append(device) devices.append(device)
existing_hosts.add(host) existing_hosts.add(host)
existing_names.add(name) existing_names.add(name)
@@ -224,8 +228,11 @@ async def keenetic_add_bulk(request: Request, user: str = Depends(require_auth))
added, skipped = [], [] added, skipped = [], []
for raw in lines: for raw in lines:
keen_url = raw if "://" in raw else f"https://{raw}" try:
host = _host_from_url(keen_url) web_url, host = parse_keenetic_web_url(raw)
except ValueError:
skipped.append(raw)
continue
name = host.split(".")[0].capitalize() if "." in host else host name = host.split(".")[0].capitalize() if "." in host else host
base_name, counter = name, 2 base_name, counter = name, 2
while name in existing: while name in existing:
@@ -234,7 +241,7 @@ async def keenetic_add_bulk(request: Request, user: str = Depends(require_auth))
if host in existing_hosts: if host in existing_hosts:
skipped.append(host) skipped.append(host)
continue continue
device = _make_device(name, keen_url, login, password) device = _make_device(name, web_url, login, password)
devices.append(device) devices.append(device)
existing.add(name) existing.add(name)
existing_hosts.add(host) existing_hosts.add(host)

View File

@@ -4,28 +4,28 @@ import asyncio
import hashlib import hashlib
import logging import logging
import re import re
import socket
from typing import Optional from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import aiohttp import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic") KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic")
AUTH_RETRIES = 2 AUTH_RETRIES = 2
BLOCKED_IPS = frozenset({"0.0.0.0", "127.0.0.1"}) DEFAULT_TIMEOUT = 25.0
RCI_TIMEOUT = 45.0
_IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){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:
"""Ensure web UI URL has a scheme (https by default).""" """Ensure web UI URL has a scheme (https by default)."""
url = (url or "").strip() url = (url or "").strip().rstrip("/")
if not url: if not url:
return "" return ""
if not url.startswith("http://") and not url.startswith("https://"): if not url.startswith(("http://", "https://")):
url = "https://" + url url = "https://" + url
return url.rstrip("/") return url
def parse_keenetic_web_url(url: str) -> tuple[str, str]: def parse_keenetic_web_url(url: str) -> tuple[str, str]:
@@ -33,10 +33,7 @@ def parse_keenetic_web_url(url: str) -> tuple[str, str]:
raw = (url or "").strip() raw = (url or "").strip()
if not raw: if not raw:
raise ValueError("Укажите адрес KeenDNS") raise ValueError("Укажите адрес KeenDNS")
if "://" in raw: normalized = normalize_web_url(raw if "://" in raw else raw)
normalized = raw.rstrip("/")
else:
normalized = f"https://{raw}".rstrip("/")
parsed = urlparse(normalized) parsed = urlparse(normalized)
if not parsed.scheme or parsed.scheme not in ("http", "https"): if not parsed.scheme or parsed.scheme not in ("http", "https"):
raise ValueError("Адрес должен начинаться с http:// или https://") raise ValueError("Адрес должен начинаться с http:// или https://")
@@ -57,73 +54,81 @@ def is_keendns_host(host: str) -> bool:
return any(m in domain for m in KEENDNS_MARKERS) return any(m in domain for m in KEENDNS_MARKERS)
def client_timeout_for(host: str, *, probing: bool = False) -> aiohttp.ClientTimeout: def _default_port(scheme: str) -> int:
"""Timeouts tuned for KeenDNS vs direct IP.""" return 443 if scheme == "https" else 80
if probing:
return aiohttp.ClientTimeout(total=25, connect=4, sock_read=18)
if is_public_ip_host(host):
return aiohttp.ClientTimeout(total=20, connect=8, sock_read=12)
return aiohttp.ClientTimeout(total=45, connect=12, sock_read=30)
async def resolve_ipv4_addresses(hostname: str) -> list[str]: def resolve_api_base(web_url: str = "", host: str = "") -> str:
"""Resolve KeenDNS A records, skipping placeholder/blocked addresses.""" """Build RCI base URL (scheme + host + port) — keenetic-unified rci_base_url pattern."""
if not hostname or is_public_ip_host(hostname.split(":")[0]): raw = (web_url or host or "").strip()
return [] if not raw:
loop = asyncio.get_running_loop() return ""
if "://" not in raw:
scheme = "http" if is_public_ip_host(raw.split("/")[0]) else "https"
raw = f"{scheme}://{raw.lstrip('/')}"
try: try:
infos = await loop.getaddrinfo( parsed = urlparse(raw)
hostname, None, family=socket.AF_INET, type=socket.SOCK_STREAM, except Exception:
) return ""
except socket.gaierror: hostname = (parsed.hostname or "").strip()
return [] if not hostname:
result, seen = [], set() return ""
for info in infos: scheme = (parsed.scheme or "https").lower()
ip = info[4][0] if scheme not in ("http", "https"):
if ip in BLOCKED_IPS or ip in seen: scheme = "https"
continue port = parsed.port if parsed.port is not None else _default_port(scheme)
seen.add(ip) netloc = hostname if port == _default_port(scheme) else f"{hostname}:{port}"
result.append(ip) return f"{scheme}://{netloc}"
return result
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 — prefer scheme/host/port from web_url.""" """Prefer scheme/host/port from web_url over bare host."""
if web_url: if web_url:
parsed = urlparse(normalize_web_url(web_url)) base = resolve_api_base(web_url=web_url)
if parsed.scheme and parsed.netloc: if base:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/") return base
return resolve_api_base(host=host)
raw = (host or "").strip()
if not raw:
return ""
if raw.startswith("http://") or raw.startswith("https://"):
return raw.rstrip("/")
scheme = "https" if is_keendns_host(raw) else "http"
return f"{scheme}://{raw}".rstrip("/")
def _make_connector() -> aiohttp.TCPConnector: async def _authenticate(
"""IPv4-only: avoids aiohttp hanging on broken IPv6 for KeenDNS multi-A records.""" client: httpx.AsyncClient, base: str, login: str, password: str,
return aiohttp.TCPConnector( ) -> tuple[bool, str]:
family=socket.AF_INET, """Keenetic /auth challenge-response (keenetic-unified keenetic_rci pattern)."""
force_close=True, auth_url = f"{base.rstrip('/')}/auth"
enable_cleanup_closed=True, try:
ttl_dns_cache=30, resp = await client.get(auth_url)
) if resp.status_code == 200:
return True, ""
if resp.status_code != 401:
if resp.status_code in (400, 403):
return False, "Wrong protocol or port (try http/https)"
return False, f"Auth HTTP {resp.status_code}"
realm = resp.headers.get("X-NDM-Realm", "")
challenge = resp.headers.get("X-NDM-Challenge", "")
if not realm or not challenge:
return False, "No auth challenge from router"
md5_hex = hashlib.md5(f"{login}:{realm}:{password}".encode()).hexdigest()
sha_hex = hashlib.sha256(f"{challenge}{md5_hex}".encode()).hexdigest()
resp2 = await client.post(auth_url, json={"login": login, "password": sha_hex})
if resp2.status_code == 200:
return True, ""
return False, "Wrong login or password"
except httpx.TimeoutException:
return False, "timeout"
except httpx.ConnectError as exc:
err = str(exc).lower()
if "name or service not known" in err or "nodename nor servname" in err:
return False, "dns"
return False, "connect"
except Exception as exc:
logger.warning("Keenetic auth %s: %s", base, exc)
return False, type(exc).__name__
class KeeneticClient: class KeeneticClient:
"""Async client for Keenetic router RCI API. """Async client for Keenetic router RCI API via httpx (KeenDNS relay)."""
Auth flow:
1. GET /auth -> 401 with X-NDM-Challenge + X-NDM-Realm headers
2. Compute: md5(login:realm:password) -> sha256(challenge + md5_hex)
3. POST /auth {"login": ..., "password": sha256_hex}
4. Session cookie persists for subsequent requests
"""
def __init__(self, host: str, login: str = "admin", password: str = "", def __init__(self, host: str, login: str = "admin", password: str = "",
web_url: str = ""): web_url: str = ""):
@@ -133,56 +138,25 @@ class KeeneticClient:
self.base_url = base self.base_url = base
parsed = urlparse(base) parsed = urlparse(base)
self._host_key = parsed.netloc or host self._host_key = parsed.netloc or host
self._hostname = parsed.hostname or ""
self._port = parsed.port or (443 if parsed.scheme == "https" else 80)
self._scheme = parsed.scheme or "https"
self._netloc = parsed.netloc or host
self._active_target: Optional[str] = None
self._resolved_ips: list[str] = []
self.login = login self.login = login
self.password = password self.password = password
self._session: Optional[aiohttp.ClientSession] = None self._client: Optional[httpx.AsyncClient] = None
self._authenticated = False
self.last_error = "" self.last_error = ""
def _effective_base(self) -> str: async def _get_client(self, timeout: float = DEFAULT_TIMEOUT) -> httpx.AsyncClient:
if self._active_target: if self._client is None or self._client.is_closed:
port = f":{self._port}" if self._port not in (80, 443) else "" self._client = httpx.AsyncClient(
return f"{self._scheme}://{self._active_target}{port}"
return self.base_url.rstrip("/")
def _request_headers(self) -> dict:
headers = {"User-Agent": "VPS-Monitoring/1.0"}
if self._active_target:
headers["Host"] = self._netloc
return headers
async def _connection_targets(self) -> list[str]:
if is_public_ip_host(self._hostname):
return [self._hostname]
if not self._resolved_ips:
self._resolved_ips = await resolve_ipv4_addresses(self._hostname)
return self._resolved_ips + [self._hostname]
async def _get_session(self, *, probing: bool = False) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
timeout = client_timeout_for(self._host_key, probing=probing)
jar = aiohttp.CookieJar(unsafe=True)
self._session = aiohttp.ClientSession(
timeout=timeout, timeout=timeout,
cookie_jar=jar, verify=False,
connector=_make_connector(), follow_redirects=True,
version=aiohttp.HttpVersion11, headers={"User-Agent": "VPS-Monitoring/1.0"},
headers=self._request_headers(),
) )
return self._session return self._client
async def _reset_session(self, *, keep_auth: bool = False): async def close(self):
if self._session and not self._session.closed: if self._client and not self._client.is_closed:
await self._session.close() await self._client.aclose()
self._session = None self._client = None
if not keep_auth:
self._authenticated = False
def _timeout_error_message(self) -> str: def _timeout_error_message(self) -> str:
if is_public_ip_host(self._host_key): if is_public_ip_host(self._host_key):
@@ -192,171 +166,95 @@ class KeeneticClient:
) )
return "Connection timeout" return "Connection timeout"
async def _authenticate_once(self) -> bool:
"""Single auth attempt; tries each KeenDNS A record before giving up."""
self.last_error = ""
retryable = (
aiohttp.ServerTimeoutError,
aiohttp.ClientOSError,
asyncio.TimeoutError,
TimeoutError,
)
targets = await self._connection_targets()
last_err: Optional[Exception] = None
for target in targets:
await self._reset_session()
self._active_target = target if is_public_ip_host(target) else None
auth_url = f"{self._effective_base()}/auth"
try:
session = await self._get_session(probing=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)"
return False
if resp.status in (502, 503, 504):
continue
self.last_error = f"Auth HTTP {resp.status}"
logger.error(
f"Keenetic auth unexpected status: {resp.status} @ {auth_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()
session = await self._get_session()
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} @ {auth_url}")
return False
except aiohttp.ClientConnectorError as e:
last_err = e
err = str(e).lower()
if "name or service not known" in err or "nodename nor servname" in err:
self.last_error = "DNS не резолвится с VPS"
return False
continue
except retryable as e:
last_err = e
logger.debug(
f"Keenetic auth try failed @ {auth_url}: {type(e).__name__}"
)
continue
if last_err:
if isinstance(last_err, aiohttp.ClientConnectorError):
self.last_error = "Cannot connect to router"
else:
self.last_error = self._timeout_error_message()
else:
self.last_error = self._timeout_error_message()
return False
async def authenticate(self) -> bool: async def authenticate(self) -> bool:
"""Perform challenge-response authentication with retries.""" """Perform challenge-response authentication with retries."""
self.last_error = ""
for attempt in range(AUTH_RETRIES): for attempt in range(AUTH_RETRIES):
if attempt: client = await self._get_client()
self._resolved_ips = await resolve_ipv4_addresses(self._hostname) ok, err = await _authenticate(client, self.base_url, self.login, self.password)
await self._reset_session() if ok:
if await self._authenticate_once():
return True return True
if self.last_error == "DNS не резолвится с VPS": if err == "dns":
self.last_error = "DNS не резолвится с VPS"
return False return False
if err == "Wrong login or password":
self.last_error = err
return False
if err in ("Wrong protocol or port (try http/https)",) or err.startswith("Auth HTTP"):
self.last_error = err
return False
if err == "No auth challenge from router":
self.last_error = err
return False
if err == "connect":
self.last_error = "Cannot connect to router"
return False
if err == "timeout":
self.last_error = self._timeout_error_message()
else:
self.last_error = err or self._timeout_error_message()
if attempt + 1 < AUTH_RETRIES: if attempt + 1 < AUTH_RETRIES:
logger.warning( logger.warning(
f"Keenetic auth retry ({attempt + 1}/{AUTH_RETRIES}) " f"Keenetic auth retry ({attempt + 1}/{AUTH_RETRIES}) "
f"@ {self.base_url}: {self.last_error}" f"@ {self.base_url}: {self.last_error}"
) )
await self.close()
await asyncio.sleep(1.5 * (attempt + 1)) await asyncio.sleep(1.5 * (attempt + 1))
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 await self.authenticate():
if not await self.authenticate(): return None
return None
session = await self._get_session() client = await self._get_client(timeout=RCI_TIMEOUT)
path = command.replace(" ", "/") path = command.replace(" ", "/")
url = f"{self._effective_base()}/rci/show/{path}" url = f"{self.base_url}/rci/show/{path}"
try: try:
async with session.get(url, params=params, ssl=False) as resp: resp = await client.get(url, params=params)
if resp.status == 200: if resp.status_code == 200:
return await resp.json(content_type=None) return resp.json()
elif resp.status == 401: if resp.status_code == 401 and await self.authenticate():
self._authenticated = False resp = await client.get(url, params=params)
if await self.authenticate(): if resp.status_code == 200:
async with session.get(url, params=params, ssl=False) as resp2: return resp.json()
if resp2.status == 200: logger.error(f"Keenetic RCI {command}: HTTP {resp.status_code}")
return await resp2.json(content_type=None) return None
logger.error(f"Keenetic RCI {command}: HTTP {resp.status}")
return None
except Exception as e: except Exception as e:
logger.error(f"Keenetic RCI error ({command}): {type(e).__name__}: {e}") logger.error(f"Keenetic RCI error ({command}): {type(e).__name__}: {e}")
return None return None
async def rci_post(self, body: dict) -> Optional[dict]: async def rci_post(self, body: dict) -> Optional[dict]:
"""POST /rci/ with JSON body for batch commands.""" """POST /rci/ with JSON body for batch commands."""
if not self._authenticated: if not await self.authenticate():
if not await self.authenticate(): return None
return None
retryable = ( url = f"{self.base_url}/rci/"
aiohttp.ServerTimeoutError,
aiohttp.ClientOSError,
asyncio.TimeoutError,
TimeoutError,
)
url = f"{self._effective_base()}/rci/"
for attempt in range(AUTH_RETRIES): for attempt in range(AUTH_RETRIES):
try: try:
session = await self._get_session() client = await self._get_client(timeout=RCI_TIMEOUT)
async with session.post(url, json=body, ssl=False) as resp: resp = await client.post(url, json=body)
if resp.status == 200: if resp.status_code == 200:
return await resp.json(content_type=None) return resp.json()
if resp.status == 401: if resp.status_code == 401:
self._authenticated = False await self.close()
if await self.authenticate(): if not await self.authenticate():
continue return None
logger.error(f"Keenetic RCI POST: HTTP {resp.status}") continue
return None logger.error(f"Keenetic RCI POST: HTTP {resp.status_code}")
except retryable as e: return None
except httpx.TimeoutException:
logger.warning( logger.warning(
f"Keenetic RCI POST timeout ({attempt + 1}/{AUTH_RETRIES}) " f"Keenetic RCI POST timeout ({attempt + 1}/{AUTH_RETRIES}) @ {self.base_url}"
f"@ {self.base_url}: {type(e).__name__}"
) )
await self._reset_session() await self.close()
if not await self.authenticate(): if not await self.authenticate():
return None return None
if attempt + 1 < AUTH_RETRIES: if attempt + 1 < AUTH_RETRIES:
await asyncio.sleep(1.5 * (attempt + 1)) await asyncio.sleep(1.5 * (attempt + 1))
continue 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}")
@@ -477,9 +375,8 @@ class KeeneticClient:
async def collect_detail(self) -> Optional[dict]: async def collect_detail(self) -> Optional[dict]:
"""Heavy detail fetch: interfaces + connected clients.""" """Heavy detail fetch: interfaces + connected clients."""
if not self._authenticated: if not await self.authenticate():
if not await self.authenticate(): return None
return None
batch = await self.rci_post({ batch = await self.rci_post({
"show": { "show": {
@@ -531,7 +428,3 @@ class KeeneticClient:
}) })
return detail return detail
async def close(self):
if self._session and not self._session.closed:
await self._session.close()