mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
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>
431 lines
16 KiB
Python
431 lines
16 KiB
Python
"""Keenetic router RCI API client for monitoring via KeenDNS."""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
import re
|
||
from typing import Optional
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic")
|
||
AUTH_RETRIES = 2
|
||
DEFAULT_TIMEOUT = 25.0
|
||
RCI_TIMEOUT = 45.0
|
||
_IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||
|
||
|
||
def normalize_web_url(url: str) -> str:
|
||
"""Ensure web UI URL has a scheme (https by default)."""
|
||
url = (url or "").strip().rstrip("/")
|
||
if not url:
|
||
return ""
|
||
if not url.startswith(("http://", "https://")):
|
||
url = "https://" + url
|
||
return url
|
||
|
||
|
||
def parse_keenetic_web_url(url: str) -> tuple[str, str]:
|
||
"""Validate KeenDNS/web URL; return (web_url, host) with port in host."""
|
||
raw = (url or "").strip()
|
||
if not raw:
|
||
raise ValueError("Укажите адрес KeenDNS")
|
||
normalized = normalize_web_url(raw if "://" in raw else raw)
|
||
parsed = urlparse(normalized)
|
||
if not parsed.scheme or parsed.scheme not in ("http", "https"):
|
||
raise ValueError("Адрес должен начинаться с http:// или https://")
|
||
if not parsed.netloc or not parsed.hostname:
|
||
raise ValueError("Некорректный формат адреса")
|
||
return normalized, parsed.netloc
|
||
|
||
|
||
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 _default_port(scheme: str) -> int:
|
||
return 443 if scheme == "https" else 80
|
||
|
||
|
||
def resolve_api_base(web_url: str = "", host: str = "") -> str:
|
||
"""Build RCI base URL (scheme + host + port) — keenetic-unified rci_base_url pattern."""
|
||
raw = (web_url or host or "").strip()
|
||
if not raw:
|
||
return ""
|
||
if "://" not in raw:
|
||
scheme = "http" if is_public_ip_host(raw.split("/")[0]) else "https"
|
||
raw = f"{scheme}://{raw.lstrip('/')}"
|
||
try:
|
||
parsed = urlparse(raw)
|
||
except Exception:
|
||
return ""
|
||
hostname = (parsed.hostname or "").strip()
|
||
if not hostname:
|
||
return ""
|
||
scheme = (parsed.scheme or "https").lower()
|
||
if scheme not in ("http", "https"):
|
||
scheme = "https"
|
||
port = parsed.port if parsed.port is not None else _default_port(scheme)
|
||
netloc = hostname if port == _default_port(scheme) else f"{hostname}:{port}"
|
||
return f"{scheme}://{netloc}"
|
||
|
||
|
||
def build_api_base_url(host: str, web_url: str = "") -> str:
|
||
"""Prefer scheme/host/port from web_url over bare host."""
|
||
if web_url:
|
||
base = resolve_api_base(web_url=web_url)
|
||
if base:
|
||
return base
|
||
return resolve_api_base(host=host)
|
||
|
||
|
||
async def _authenticate(
|
||
client: httpx.AsyncClient, base: str, login: str, password: str,
|
||
) -> tuple[bool, str]:
|
||
"""Keenetic /auth challenge-response (keenetic-unified keenetic_rci pattern)."""
|
||
auth_url = f"{base.rstrip('/')}/auth"
|
||
try:
|
||
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:
|
||
"""Async client for Keenetic router RCI API via httpx (KeenDNS relay)."""
|
||
|
||
def __init__(self, host: str, login: str = "admin", password: str = "",
|
||
web_url: str = ""):
|
||
base = build_api_base_url(host, web_url)
|
||
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._client: Optional[httpx.AsyncClient] = None
|
||
self.last_error = ""
|
||
|
||
async def _get_client(self, timeout: float = DEFAULT_TIMEOUT) -> httpx.AsyncClient:
|
||
if self._client is None or self._client.is_closed:
|
||
self._client = httpx.AsyncClient(
|
||
timeout=timeout,
|
||
verify=False,
|
||
follow_redirects=True,
|
||
headers={"User-Agent": "VPS-Monitoring/1.0"},
|
||
)
|
||
return self._client
|
||
|
||
async def close(self):
|
||
if self._client and not self._client.is_closed:
|
||
await self._client.aclose()
|
||
self._client = None
|
||
|
||
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(self) -> bool:
|
||
"""Perform challenge-response authentication with retries."""
|
||
self.last_error = ""
|
||
for attempt in range(AUTH_RETRIES):
|
||
client = await self._get_client()
|
||
ok, err = await _authenticate(client, self.base_url, self.login, self.password)
|
||
if ok:
|
||
return True
|
||
if err == "dns":
|
||
self.last_error = "DNS не резолвится с VPS"
|
||
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:
|
||
logger.warning(
|
||
f"Keenetic auth retry ({attempt + 1}/{AUTH_RETRIES}) "
|
||
f"@ {self.base_url}: {self.last_error}"
|
||
)
|
||
await self.close()
|
||
await asyncio.sleep(1.5 * (attempt + 1))
|
||
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 await self.authenticate():
|
||
return None
|
||
|
||
client = await self._get_client(timeout=RCI_TIMEOUT)
|
||
path = command.replace(" ", "/")
|
||
url = f"{self.base_url}/rci/show/{path}"
|
||
|
||
try:
|
||
resp = await client.get(url, params=params)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
if resp.status_code == 401 and await self.authenticate():
|
||
resp = await client.get(url, params=params)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
logger.error(f"Keenetic RCI {command}: HTTP {resp.status_code}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"Keenetic RCI error ({command}): {type(e).__name__}: {e}")
|
||
return None
|
||
|
||
async def rci_post(self, body: dict) -> Optional[dict]:
|
||
"""POST /rci/ with JSON body for batch commands."""
|
||
if not await self.authenticate():
|
||
return None
|
||
|
||
url = f"{self.base_url}/rci/"
|
||
for attempt in range(AUTH_RETRIES):
|
||
try:
|
||
client = await self._get_client(timeout=RCI_TIMEOUT)
|
||
resp = await client.post(url, json=body)
|
||
if resp.status_code == 200:
|
||
return resp.json()
|
||
if resp.status_code == 401:
|
||
await self.close()
|
||
if not await self.authenticate():
|
||
return None
|
||
continue
|
||
logger.error(f"Keenetic RCI POST: HTTP {resp.status_code}")
|
||
return None
|
||
except httpx.TimeoutException:
|
||
logger.warning(
|
||
f"Keenetic RCI POST timeout ({attempt + 1}/{AUTH_RETRIES}) @ {self.base_url}"
|
||
)
|
||
await self.close()
|
||
if not await self.authenticate():
|
||
return None
|
||
if attempt + 1 < AUTH_RETRIES:
|
||
await asyncio.sleep(1.5 * (attempt + 1))
|
||
continue
|
||
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."""
|
||
result = {
|
||
"online": False,
|
||
"hostname": "",
|
||
"model": "",
|
||
"firmware": "",
|
||
"cpuload": 0,
|
||
"memtotal": 0,
|
||
"memfree": 0,
|
||
"mem_percent": 0,
|
||
"uptime": 0,
|
||
"uptime_str": "",
|
||
"internet": False,
|
||
"gateway_accessible": False,
|
||
"dns_accessible": False,
|
||
"vpn": [],
|
||
"clients_count": 0,
|
||
"wifi_clients": 0,
|
||
"wired_clients": 0,
|
||
"error": "",
|
||
}
|
||
|
||
if cached_info:
|
||
result["model"] = cached_info.get("model", "")
|
||
result["firmware"] = cached_info.get("firmware", "")
|
||
|
||
if not await self.authenticate():
|
||
result["error"] = self.last_error or "Authentication failed"
|
||
return result
|
||
|
||
result["online"] = True
|
||
|
||
batch_cmd = {
|
||
"show": {
|
||
"system": {},
|
||
"internet": {"status": {}},
|
||
"interface": {},
|
||
}
|
||
}
|
||
|
||
need_info = not result.get("model")
|
||
if need_info:
|
||
batch_cmd["show"]["version"] = {}
|
||
batch_cmd["show"]["defaults"] = {}
|
||
|
||
batch = await self.rci_post(batch_cmd)
|
||
|
||
if not batch or not isinstance(batch, dict):
|
||
result["error"] = "No data from router"
|
||
result["online"] = False
|
||
return result
|
||
|
||
show = batch.get("show", batch)
|
||
|
||
sys_data = show.get("system", {})
|
||
if sys_data:
|
||
result["hostname"] = sys_data.get("hostname", "")
|
||
result["cpuload"] = int(sys_data.get("cpuload", 0) or 0)
|
||
result["memtotal"] = int(sys_data.get("memtotal", 0) or 0)
|
||
result["memfree"] = int(sys_data.get("memfree", 0) or 0)
|
||
|
||
memtotal = result["memtotal"]
|
||
memfree = result["memfree"]
|
||
if memtotal > 0:
|
||
result["mem_percent"] = round((1 - memfree / memtotal) * 100, 1)
|
||
|
||
uptime_sec = int(sys_data.get("uptime", 0) or 0)
|
||
result["uptime"] = uptime_sec
|
||
if uptime_sec:
|
||
days = uptime_sec // 86400
|
||
hours = (uptime_sec % 86400) // 3600
|
||
mins = (uptime_sec % 3600) // 60
|
||
result["uptime_str"] = f"{days}d {hours}h {mins}m"
|
||
|
||
if need_info:
|
||
ver_data = show.get("version", {})
|
||
if ver_data:
|
||
result["firmware"] = ver_data.get("title", ver_data.get("release", ""))
|
||
|
||
defaults = show.get("defaults", {})
|
||
if defaults:
|
||
product = defaults.get("product", "")
|
||
hw_id = defaults.get("ndmhwid", "")
|
||
result["model"] = f"{product} ({hw_id})" if product and hw_id else product or hw_id
|
||
|
||
inet_block = show.get("internet", {})
|
||
inet = inet_block.get("status", inet_block) if isinstance(inet_block, dict) else {}
|
||
if inet:
|
||
result["internet"] = bool(inet.get("internet"))
|
||
result["gateway_accessible"] = bool(inet.get("gateway-accessible"))
|
||
result["dns_accessible"] = bool(inet.get("dns-accessible"))
|
||
|
||
VPN_TYPES = {"Wireguard", "WireGuard", "OpenVPN", "PPTP", "L2TP", "SSTP", "EoIP", "IPsec"}
|
||
|
||
ifaces = show.get("interface", {})
|
||
if ifaces and isinstance(ifaces, dict):
|
||
for iface_name, iface_data in ifaces.items():
|
||
if not isinstance(iface_data, dict):
|
||
continue
|
||
itype = iface_data.get("type", "")
|
||
if itype in VPN_TYPES:
|
||
result["vpn"].append({
|
||
"name": iface_name,
|
||
"type": itype,
|
||
"state": iface_data.get("state", ""),
|
||
"description": iface_data.get("description", ""),
|
||
"address": iface_data.get("address", ""),
|
||
})
|
||
|
||
return result
|
||
|
||
async def collect_detail(self) -> Optional[dict]:
|
||
"""Heavy detail fetch: interfaces + connected clients."""
|
||
if not await self.authenticate():
|
||
return None
|
||
|
||
batch = await self.rci_post({
|
||
"show": {
|
||
"interface": {},
|
||
"ip": {"hotspot": {}},
|
||
}
|
||
})
|
||
|
||
if not batch or not isinstance(batch, dict):
|
||
return None
|
||
|
||
show = batch.get("show", batch)
|
||
detail = {"interfaces": [], "clients": []}
|
||
|
||
VPN_TYPES = {"Wireguard", "WireGuard", "OpenVPN", "PPTP", "L2TP", "SSTP", "EoIP", "IPsec"}
|
||
SHOW_TYPES = {"GigabitEthernet", "XGigabitEthernet", "WifiMaster", "AccessPoint",
|
||
"Bridge", "PPPoE"} | VPN_TYPES
|
||
|
||
ifaces = show.get("interface", {})
|
||
if ifaces and isinstance(ifaces, dict):
|
||
for iface_name, iface_data in ifaces.items():
|
||
if not isinstance(iface_data, dict):
|
||
continue
|
||
itype = iface_data.get("type", "")
|
||
if itype in SHOW_TYPES:
|
||
detail["interfaces"].append({
|
||
"id": iface_data.get("id", iface_name),
|
||
"type": itype,
|
||
"description": iface_data.get("description", ""),
|
||
"state": iface_data.get("state", ""),
|
||
"address": iface_data.get("address", ""),
|
||
"uptime": iface_data.get("uptime", 0),
|
||
})
|
||
|
||
ip_block = show.get("ip", {})
|
||
hotspot = ip_block.get("hotspot", ip_block) if isinstance(ip_block, dict) else {}
|
||
if hotspot:
|
||
hosts = hotspot.get("host", [])
|
||
if isinstance(hosts, list):
|
||
for h in hosts:
|
||
detail["clients"].append({
|
||
"name": h.get("name", h.get("hostname", "")),
|
||
"hostname": h.get("hostname", ""),
|
||
"ip": h.get("ip", ""),
|
||
"mac": h.get("mac", ""),
|
||
"active": h.get("active", False),
|
||
"speed": h.get("speed", 0),
|
||
"ssid": h.get("ssid", ""),
|
||
})
|
||
|
||
return detail
|