diff --git a/scripts/check_keenetic.py b/scripts/check_keenetic.py new file mode 100644 index 0000000..0633f31 --- /dev/null +++ b/scripts/check_keenetic.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Check Keenetic API connectivity for all routers.""" +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 check(dev): + c = KeeneticClient( + host=dev.get("host", ""), + login=dev.get("login", "admin"), + password=dev.get("password", ""), + web_url=dev.get("web_url", ""), + ) + try: + m = await c.collect_metrics() + if m.get("online"): + return "online" + err = (m.get("error") or "unknown")[:80] + return f"offline: {err}" + except Exception as e: + return f"err: {str(e)[:80]}" + finally: + await c.close() + + +async def main(): + with open(DATA) as f: + devices = json.load(f) + for d in devices: + r = await check(d) + print(f"{d['name']:22} {r}") + await asyncio.sleep(0.3) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/sync_keenetic_import.py b/scripts/sync_keenetic_import.py new file mode 100644 index 0000000..8016b25 --- /dev/null +++ b/scripts/sync_keenetic_import.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Sync keenetic.json with canonical import list (names, web_url, anydesk).""" +import json +import sys +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +IMPORT = [ + ("Лилиана Лофт", "https://loftliliana.netcraze.pro", "1020687391"), + ("Артем vtb126", "https://vtb126.netcraze.club", ""), + ("Артем Квартира Подмосковный", "http://95.165.93.46:777", "1527495291"), + ("Артем Квартира 2", "https://pomidor.netcraze.pro:5443", ""), + ("Артем Малаховка", "https://malahovka1.netcraze.pro:5083", ""), + ("Арсен Квартира", "https://arsen53.netcraze.link", "1633258458"), + ("Вишневый Сад", "https://visheviisad.netcraze.link", "135128054"), + ("Антоновка", "https://antonovka.netcraze.pro:5083", "1356582394"), + ("Цехомский Николай Дом", "https://peredelkino15.netcraze.link", "1726497286"), + ("Цехомский Домик Истра", "https://utrodomik.netcraze.club:5443", "451325164"), + ("Маршала Жукова Квартира", "https://marshalaszukova.netcraze.pro", "1677737879"), + ("Ломакин Квартира", "https://lomakinkvartira.netcraze.pro", ""), + ("Ломакин Дача", "https://lomakindacha.netcraze.pro:8443/", "815142240"), + ("КАСТАНАЕВСКАЯ", "https://kastanaevskaya.netcraze.link", "1627649255"), + ("Чиверево Меламед", "https://chiverevo.netcraze.pro", "846367657"), + ("Рав Гедалья Меламед", "https://ravged.netcraze.pro", ""), + ("Кургин Дом", "https://kurgin.netcraze.link", "709243112"), + ("Таланова Дом", "https://talanovadom.netcraze.pro", "951049627"), + ("Загорье Дом", "https://zagorie.netcraze.link", "249788953"), +] + + +def normalize_web_url(url: str) -> str: + url = (url or "").strip().rstrip("/") + if not url: + return "" + if not url.startswith(("http://", "https://")): + url = "https://" + url + return url + + +def host_from_url(url: str) -> str: + parsed = urlparse(normalize_web_url(url)) + 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) + return { + "name": name, + "host": host, + "web_url": keenetic_url, + "anydesk": (anydesk or "").strip(), + "login": login, + "password": password, + "added": added or datetime.now().isoformat(), + } + + +def sync_file(path: Path, default_password: str): + with open(path) as f: + old = json.load(f) + + 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" + ) + + result = [] + for name, url, ad in IMPORT: + host = host_from_url(url) + prev = 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)") + + +if __name__ == "__main__": + p = Path(sys.argv[1] if len(sys.argv) > 1 else "/opt/vps-monitoring/data/keenetic.json") + pwd = sys.argv[2] if len(sys.argv) > 2 else "Ipadipad1" + sync_file(p, pwd) diff --git a/server/api/keenetic.py b/server/api/keenetic.py new file mode 100644 index 0000000..c2fafd8 --- /dev/null +++ b/server/api/keenetic.py @@ -0,0 +1,318 @@ +"""Keenetic router monitoring API endpoints.""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Dict, List +from urllib.parse import urlparse + +from fastapi import APIRouter, Request, Depends + +from server.auth import require_auth +from server.config import DATA_DIR, load_settings +from server.services.keenetic_client import ( + KeeneticClient, + normalize_web_url, + build_api_base_url, +) + +router = APIRouter(prefix="/api/keenetic", tags=["keenetic"]) +logger = logging.getLogger(__name__) + +keenetic_metrics: Dict[str, dict] = {} + +KEENETIC_FILE = DATA_DIR / "keenetic.json" + + +def _load_keenetic(): + if KEENETIC_FILE.exists(): + with open(KEENETIC_FILE) as f: + return json.load(f) + return [] + + +def _save_keenetic(data): + with open(KEENETIC_FILE, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def _host_from_url(url: str) -> str: + """host:port for API from Keenetic web URL.""" + parsed = urlparse(normalize_web_url(url)) + return parsed.netloc or url.replace("https://", "").replace("http://", "").rstrip("/") + + +def _make_device(name: str, keenetic_url: str, login: str, password: str, + anydesk: str = "") -> dict: + keenetic_url = (keenetic_url or "").strip() + host = _host_from_url(keenetic_url) if keenetic_url else "" + return { + "name": name, + "host": host, + "web_url": normalize_web_url(keenetic_url) if keenetic_url else "", + "anydesk": (anydesk or "").strip(), + "login": login, + "password": password, + "added": datetime.now().isoformat(), + } + + +def _client_for_device(dev: dict) -> KeeneticClient: + return KeeneticClient( + host=dev.get("host", ""), + login=dev.get("login", "admin"), + password=dev.get("password", ""), + web_url=dev.get("web_url", ""), + ) + + +async def _refresh_device(dev: dict) -> dict: + name = dev["name"] + client = _client_for_device(dev) + try: + cached = keenetic_metrics.get(name) + metrics = await client.collect_metrics(cached_info=cached) + metrics["last_updated"] = datetime.now().isoformat() + keenetic_metrics[name] = metrics + return metrics + finally: + await client.close() + + +@router.get("/list") +async def keenetic_list(request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + for dev in devices: + name = dev["name"] + if name in keenetic_metrics: + dev["metrics"] = keenetic_metrics[name] + return devices + + +@router.post("/add") +async def keenetic_add(request: Request, user: str = Depends(require_auth)): + body = await request.json() + name = body.get("name", "").strip() + host = body.get("host", "").strip() + web_url = body.get("web_url", "").strip() + if not name or (not host and not web_url): + return {"status": "error", "detail": "name and host (or web_url) required"} + + if not host and web_url: + host = _host_from_url(web_url) + + devices = _load_keenetic() + device = { + "name": name, + "host": host, + "web_url": normalize_web_url(web_url or host), + "anydesk": body.get("anydesk", "").strip(), + "login": body.get("login", "admin"), + "password": body.get("password", ""), + "added": datetime.now().isoformat(), + } + devices.append(device) + _save_keenetic(devices) + return {"status": "ok"} + + +@router.post("/import") +async def keenetic_import(request: Request, user: str = Depends(require_auth)): + """Import routers from spreadsheet columns: Address, Keenetic, AnyDesk.""" + body = await request.json() + login = body.get("login", "admin").strip() + password = body.get("password", "") + rows = body.get("rows") + tsv = body.get("tsv", "").strip() + + parsed: List[dict] = [] + if tsv: + lines = [l for l in tsv.splitlines() if l.strip()] + if not lines: + return {"status": "error", "detail": "Empty import"} + header = [c.strip().lower() for c in lines[0].split("\t")] + if len(header) < 2: + header = [c.strip().lower() for c in lines[0].split(",")] + start = 1 if any(h in ("address", "keenetic", "anydesk", "имя") for h in header) else 0 + if start == 0: + header = ["address", "keenetic", "anydesk"] + col = {h: i for i, h in enumerate(header)} + + def col_val(parts, *keys): + for k in keys: + if k in col and col[k] < len(parts): + return parts[col[k]].strip() + return "" + + for line in lines[start:]: + parts = line.split("\t") if "\t" in line else line.split(",") + addr = col_val(parts, "address", "имя", "name") + keen = col_val(parts, "keenetic", "url", "веб") + ad = col_val(parts, "anydesk", "any desk") + if keen or addr: + parsed.append({"address": addr, "keenetic": keen, "anydesk": ad}) + elif rows: + parsed = rows + else: + return {"status": "error", "detail": "Provide rows or tsv"} + + devices = _load_keenetic() + existing_hosts = {d.get("host") for d in devices} + existing_names = {d["name"] for d in devices} + added, skipped = [], [] + + for row in parsed: + keen_url = (row.get("keenetic") or row.get("url") or "").strip() + if not keen_url: + continue + host = _host_from_url(keen_url) + addr = (row.get("address") or row.get("name") or "").strip() + name = addr.capitalize() if addr else host.split(".")[0].capitalize() + base_name, counter = name, 2 + while name in existing_names: + name = f"{base_name}_{counter}" + counter += 1 + if host in existing_hosts: + skipped.append(host) + continue + device = _make_device(name, keen_url, login, password, row.get("anydesk", "")) + devices.append(device) + existing_hosts.add(host) + existing_names.add(name) + added.append(name) + + _save_keenetic(devices) + return {"status": "ok", "added": added, "skipped": skipped} + + +@router.post("/add-bulk") +async def keenetic_add_bulk(request: Request, user: str = Depends(require_auth)): + body = await request.json() + domains_raw = body.get("domains", "") + login = body.get("login", "admin").strip() + password = body.get("password", "") + + lines = [l.strip() for l in domains_raw.strip().splitlines() if l.strip()] + if not lines: + return {"status": "error", "detail": "No domains provided"} + + devices = _load_keenetic() + existing = {d["name"] for d in devices} + existing_hosts = {d.get("host") for d in devices} + added, skipped = [], [] + + for raw in lines: + keen_url = raw if "://" in raw else f"https://{raw}" + host = _host_from_url(keen_url) + name = host.split(".")[0].capitalize() if "." in host else host + base_name, counter = name, 2 + while name in existing: + name = f"{base_name}_{counter}" + counter += 1 + if host in existing_hosts: + skipped.append(host) + continue + device = _make_device(name, keen_url, login, password) + devices.append(device) + existing.add(name) + existing_hosts.add(host) + added.append(name) + + _save_keenetic(devices) + return {"status": "ok", "added": added, "skipped": skipped} + + +@router.delete("/{name}") +async def keenetic_delete(name: str, request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + devices = [d for d in devices if d["name"] != name] + _save_keenetic(devices) + keenetic_metrics.pop(name, None) + return {"status": "ok"} + + +@router.post("/refresh/{name}") +async def keenetic_refresh(name: str, request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + dev = next((d for d in devices if d["name"] == name), None) + if not dev: + return {"status": "error", "detail": "router not found"} + + try: + metrics = await _refresh_device(dev) + if not metrics["online"] and metrics.get("error"): + return {"status": "error", "detail": metrics["error"], "metrics": metrics} + return {"status": "ok", "metrics": metrics} + except Exception as e: + return {"status": "error", "detail": str(e)} + + +@router.post("/refresh-all") +async def keenetic_refresh_all(request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + results = [] + for dev in devices: + try: + metrics = await _refresh_device(dev) + results.append({"name": dev["name"], "online": metrics["online"], + "error": metrics.get("error", "")}) + except Exception as e: + results.append({"name": dev["name"], "online": False, "error": str(e)}) + return {"status": "ok", "results": results} + + +@router.get("/detail/{name}") +async def keenetic_detail(name: str, request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + dev = next((d for d in devices if d["name"] == name), None) + if not dev: + return {"status": "error", "detail": "router not found"} + + client = _client_for_device(dev) + try: + detail = await client.collect_detail() + if detail: + return {"status": "ok", **detail} + return {"status": "error", "detail": client.last_error or "No data"} + finally: + await client.close() + + +@router.post("/reboot/{name}") +async def keenetic_reboot(name: str, request: Request, user: str = Depends(require_auth)): + devices = _load_keenetic() + dev = next((d for d in devices if d["name"] == name), None) + if not dev: + return {"status": "error", "detail": "router not found"} + + client = _client_for_device(dev) + try: + if not await client.authenticate(): + return {"status": "error", "detail": client.last_error or "Authentication failed"} + await client.rci_post({"system": {"reboot": {}}}) + return {"status": "ok", "detail": f"Reboot command sent to {name}"} + finally: + await client.close() + + +async def keenetic_monitor_loop(): + """Background polling for all Keenetic routers.""" + await asyncio.sleep(15) + while True: + try: + devices = _load_keenetic() + if devices: + logger.info(f"Keenetic monitor: refreshing {len(devices)} routers") + for dev in devices: + try: + await _refresh_device(dev) + except Exception as e: + logger.error(f"Keenetic refresh {dev['name']}: {e}") + await asyncio.sleep(2) + except Exception as e: + logger.error(f"Keenetic monitor loop error: {e}") + + settings = load_settings() + interval = int(settings.get("keenetic_interval", 60)) + await asyncio.sleep(max(interval, 30)) diff --git a/server/services/keenetic_client.py b/server/services/keenetic_client.py new file mode 100644 index 0000000..7ab2710 --- /dev/null +++ b/server/services/keenetic_client.py @@ -0,0 +1,346 @@ +"""Keenetic router RCI API client for monitoring via KeenDNS.""" + +import hashlib +import logging +from typing import Optional +from urllib.parse import urlparse + +import aiohttp + +logger = logging.getLogger(__name__) + +KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic") + + +def normalize_web_url(url: str) -> str: + """Ensure web UI URL has a scheme (https by default).""" + url = (url or "").strip() + if not url: + return "" + if not url.startswith("http://") and not url.startswith("https://"): + url = "https://" + url + return url.rstrip("/") + + +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: + parsed = urlparse(normalize_web_url(web_url)) + raw = parsed.netloc or (parsed.path.split("/")[0] if parsed.path else "") + 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" + return f"{scheme}://{raw}".rstrip("/") + + +class KeeneticClient: + """Async client for Keenetic router RCI API. + + 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 = "", + 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 + self.login = login + self.password = password + self._session: Optional[aiohttp.ClientSession] = None + self._authenticated = False + self.last_error = "" + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=25, connect=10) + jar = aiohttp.CookieJar(unsafe=True) + self._session = aiohttp.ClientSession(timeout=timeout, cookie_jar=jar) + return self._session + + async def authenticate(self) -> bool: + """Perform challenge-response authentication.""" + 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 + + 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}") + 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}") + 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: + if not await self.authenticate(): + return None + + session = await self._get_session() + path = command.replace(" ", "/") + url = f"{self.base_url}/rci/show/{path}" + + try: + async with session.get(url, params=params, ssl=False) as resp: + if resp.status == 200: + return await resp.json(content_type=None) + elif resp.status == 401: + self._authenticated = False + if await self.authenticate(): + async with session.get(url, params=params, ssl=False) as resp2: + if resp2.status == 200: + return await resp2.json(content_type=None) + logger.error(f"Keenetic RCI {command}: HTTP {resp.status}") + 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 self._authenticated: + if not await self.authenticate(): + return None + + session = await self._get_session() + 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}") + return None + except Exception as e: + logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}") + 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 self._authenticated: + 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 + + async def close(self): + if self._session and not self._session.closed: + await self._session.close() diff --git a/server/static/js/app.js b/server/static/js/app.js index 0915323..428c938 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -1,6 +1,54 @@ let servers = []; let currentTerminal = null; let currentWs = null; +let mutedDevices = new Set(); + +async function loadMutedDevices() { + try { + const resp = await fetch('/api/notifications/muted-devices', {credentials: 'include'}); + const data = await resp.json(); + mutedDevices = new Set(data.muted || []); + } catch (e) { console.error('Muted devices load error:', e); } +} + +async function toggleMuteDevice(btn) { + const category = btn.dataset.cat; + const name = btn.dataset.name; + try { + const resp = await fetch('/api/notifications/mute-device', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({category, name}), + }); + const data = await resp.json(); + const key = `${category}:${name}`; + if (data.muted) { + mutedDevices.add(key); + } else { + mutedDevices.delete(key); + } + btn.textContent = data.muted ? '🔕' : '🔔'; + btn.className = 'mute-bell' + (data.muted ? ' muted' : ''); + btn.title = data.muted ? 'Уведомления выключены' : 'Уведомления включены'; + } catch (e) { console.error('Mute toggle error:', e); } +} + +// Delegate bell clicks from document level +document.addEventListener('click', function(e) { + const bell = e.target.closest('.mute-bell'); + if (bell) { + e.stopPropagation(); + e.preventDefault(); + toggleMuteDevice(bell); + } +}); + +function bellHtml(category, name) { + const key = `${category}:${name}`; + const isMuted = mutedDevices.has(key); + const esc = name.replace(/"/g, '"'); + return ``; +} // i18n render function renderPage() { @@ -57,6 +105,7 @@ function renderServers() { return `
+ ${bellHtml('servers', srv.name)}
${srv.name}
@@ -391,7 +440,7 @@ document.addEventListener('click', (e) => { // ===================== TABS ===================== let activeTab = 'servers'; -const allTabs = ['servers', 'pc', 'synology', 'ha']; +const allTabs = ['servers', 'pc', 'synology', 'ha', 'keenetic']; function switchTab(tab) { activeTab = tab; @@ -405,6 +454,7 @@ function switchTab(tab) { if (tab === 'pc') loadPCs(); else if (tab === 'synology') loadSynology(); else if (tab === 'ha') loadHA(); + else if (tab === 'keenetic') loadKeenetic(); } // ===================== PC MONITORING ===================== @@ -448,6 +498,7 @@ function renderPCs() { return `
+ ${bellHtml('pc', pc.agent_name)}
💻 ${pc.agent_name}
@@ -567,10 +618,11 @@ function renderSynology() { return `
+ ${bellHtml('synology', dev.name)}
📦 ${dev.name}
-
${dev.host}:${dev.port || 5000} ${m.model ? '• ' + m.model : ''}
+
${dev.tunnel?.enabled ? '🔗 tunnel' : dev.host + ':' + (dev.port || 5000)} ${m.model ? '• ' + m.model : ''}
@@ -598,9 +650,10 @@ function renderSynology() { ${vmsCount ? `
🖥 ${vmsCount} VM
` : ''} ${dockerCount ? `
🐳 ${dockerCount} контейнеров
` : ''} ${volsHtml} - ` : '
Нет данных — нажмите 🔄
'} + ` : `
${m.error ? '⚠️ ' + m.error : 'Нет данных — нажмите 🔄'}
`}
+ ${dev.tunnel?.enabled ? `` : ''}
@@ -701,15 +754,24 @@ document.getElementById('addSynologyForm')?.addEventListener('submit', async (e) async function refreshSynology(name) { try { - await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'}); - loadSynology(); + const resp = await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'}); + const data = await resp.json(); + if (data.status === 'error' && data.detail) { + console.warn('Synology refresh:', data.detail); + } + // Reload list with fresh data + const listResp = await fetch('/api/synology/list', {credentials: 'include'}); + synologyDevices = await listResp.json(); + renderSynology(); } catch (e) { alert('Error: ' + e.message); } } async function refreshAllSynology() { try { await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'}); - loadSynology(); + const resp = await fetch('/api/synology/list', {credentials: 'include'}); + synologyDevices = await resp.json(); + renderSynology(); } catch (e) { alert('Error: ' + e.message); } } @@ -719,6 +781,28 @@ async function deleteSynology(name) { loadSynology(); } +async function showSynTunnel() { + // Find first synology device with tunnel config + const dev = synologyDevices.find(d => d.tunnel?.enabled) || synologyDevices[0]; + if (!dev) { + alert('Сначала добавьте Synology NAS'); + return; + } + + const name = dev.name; + try { + const resp = await fetch(`/api/synology/tunnel/setup-command?name=${encodeURIComponent(name)}`, {credentials: 'include'}); + const data = await resp.json(); + if (data.command) { + document.getElementById('syn-tunnel-cmd').textContent = data.command; + } + } catch (e) { + document.getElementById('syn-tunnel-cmd').textContent = 'Ошибка загрузки команды'; + } + + document.getElementById('synTunnelModal').style.display = 'flex'; +} + // ===================== HOME ASSISTANT ===================== let haInstances = []; @@ -753,6 +837,7 @@ function renderHA() { return `
+ ${bellHtml('ha', inst.name)}
🏠 ${inst.name}
@@ -938,14 +1023,18 @@ document.getElementById('addHAForm')?.addEventListener('submit', async (e) => { async function refreshHA(name) { try { await fetch(`/api/ha/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'}); - loadHA(); + const resp = await fetch('/api/ha/list', {credentials: 'include'}); + haInstances = await resp.json(); + renderHA(); } catch (e) { alert('Error: ' + e.message); } } async function refreshAllHA() { try { await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'}); - loadHA(); + const resp = await fetch('/api/ha/list', {credentials: 'include'}); + haInstances = await resp.json(); + renderHA(); } catch (e) { alert('Error: ' + e.message); } } @@ -1050,11 +1139,421 @@ async function testNotification(channel) { } catch (e) { alert('Error: ' + e.message); } } +// ===================== KEENETIC MONITORING ===================== +let keeneticDevices = []; + +function escHtml(s) { + return String(s || '').replace(/&/g,'&').replace(/ { + if (document.hidden) return; + if (confirm('AnyDesk не открылся. Скачать с anydesk.com?')) { + window.open('https://anydesk.com/en/downloads', '_blank'); + } + }, 1500); +} +async function loadKeenetic() { + try { + const resp = await fetch('/api/keenetic/list', {credentials: 'include'}); + if (resp.status === 401) return; + keeneticDevices = await resp.json(); + renderKeenetic(); + } catch (e) { console.error('Keenetic load error:', e); } +} + +function renderKeenetic() { + const grid = document.getElementById('keenetic-grid'); + if (!keeneticDevices.length) { + grid.innerHTML = '
Нет роутеров. Нажмите "+ Добавить роутер"
'; + return; + } + + grid.innerHTML = keeneticDevices.map(dev => { + const m = dev.metrics || {}; + const isOnline = m.online; + const cpuload = m.cpuload || 0; + const memPct = m.mem_percent || 0; + const inet = m.internet; + const uptime = m.uptime_str || ''; + const model = m.model || ''; + const firmware = m.firmware || ''; + const error = m.error || ''; + const vpns = m.vpn || []; + + const cpuClass = cpuload > 90 ? 'crit' : cpuload > 70 ? 'warn' : ''; + const ramClass = memPct > 90 ? 'crit' : memPct > 70 ? 'warn' : ''; + + // VPN badges + let vpnHtml = ''; + if (vpns.length) { + vpnHtml = `
` + + vpns.map(v => { + const up = v.state === 'up'; + return `${v.type} ${v.description || v.name} ${up ? '🟢' : '🔴'}`; + }).join('') + `
`; + } + + const hostParts = [dev.host]; + if (model) hostParts.push(model); + if (firmware) hostParts.push('v' + firmware); + + const webUrl = keeneticWebUrl(dev); + const anydeskId = (dev.anydesk || '').replace(/\D/g, ''); + let linksHtml = ''; + if (webUrl || anydeskId) { + linksHtml = ''; + } + + return ` +
+ ${bellHtml('keenetic', dev.name)} +
+
+
📡 ${dev.name}
+
${hostParts.join(' • ')}
+
+ + + ${isOnline ? 'Online' : 'Offline'} + +
+ ${linksHtml} + ${vpnHtml} + ${isOnline ? ` +
+
+ CPU + ${cpuload}% +
+
+
+ RAM + ${memPct}% +
+
+
+
+ ${inet ? '🌐 Internet' : '⚠️ No Internet'} + ${uptime ? ' • ⏱ ' + uptime : ''} +
+ ` : `
${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}
`} +
+ + + +
+
`; + }).join(''); +} + +async function showKeeneticDetail(name) { + const dev = keeneticDevices.find(d => d.name === name); + if (!dev || !dev.metrics) return; + const m = dev.metrics; + + document.getElementById('keeneticDetailTitle').textContent = `📡 ${dev.name}`; + + let html = ` +
+
+

Система

+ + + + + + + + + +
Статус${m.online ? '🟢 Online' : '🔴 Offline'}
Хост${dev.host}
Hostname${m.hostname || '-'}
Модель${m.model || '-'}
Прошивка${m.firmware || '-'}
CPU${m.cpuload || 0}%
RAM${m.mem_percent || 0}% (${formatBytes(m.memtotal - m.memfree)}/${formatBytes(m.memtotal)})
Uptime${m.uptime_str || '-'}
+
+
+

Интернет

+ + + + +
Internet${m.internet ? '✅' : '❌'}
Gateway${m.gateway_accessible ? '✅' : '❌'}
DNS${m.dns_accessible ? '✅' : '❌'}
+
+
+
⏳ Загрузка клиентов и интерфейсов...
`; + + if (m.last_updated) { + html += `
Обновлено: ${new Date(m.last_updated).toLocaleTimeString()}
`; + } + + document.getElementById('keeneticDetailBody').innerHTML = html; + document.getElementById('keeneticDetailModal').style.display = 'flex'; + + // Fetch clients + interfaces lazily + try { + const resp = await fetch(`/api/keenetic/detail/${name}`, {credentials: 'include'}); + const data = await resp.json(); + const extra = document.getElementById('keeneticDetailExtra'); + if (!extra) return; + + let extraHtml = ''; + + // Clients + const activeClients = (data.clients || []).filter(c => c.active); + if (activeClients.length) { + extraHtml += `
+

Подключенные устройства (${activeClients.length})

+ + + ${activeClients.map(c => ` + + + + + + + `).join('')} +
ИмяIPMACТипSpeed
${c.name || c.hostname || '-'}${c.ip}${c.mac}${c.ssid ? '📶 ' + c.ssid : '🔌 LAN'}${c.speed ? c.speed + ' Mbps' : '-'}
+
`; + } else { + extraHtml += '
Нет активных клиентов
'; + } + + // Interfaces + if (data.interfaces && data.interfaces.length) { + extraHtml += `
+

Интерфейсы

+ + + ${data.interfaces.map(i => ` + + + + + + `).join('')} +
IDТипСостояниеIP
${i.id}${i.type}${i.state === 'up' ? '🟢' : '🔴'} ${i.state}${i.address || '-'}
+
`; + } + + extra.innerHTML = extraHtml; + extra.style.opacity = '1'; + } catch (e) { + const extra = document.getElementById('keeneticDetailExtra'); + if (extra) extra.innerHTML = '
Ошибка загрузки деталей
'; + } +} + +function formatBytes(bytes) { + if (!bytes || bytes <= 0) return '0'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB'; + return (bytes / 1048576).toFixed(0) + ' MB'; +} + +function showAddKeenetic() { + document.getElementById('addKeeneticModal').style.display = 'flex'; + document.getElementById('addKeeneticForm').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const body = { + name: fd.get('name'), + host: fd.get('host'), + web_url: fd.get('web_url') || fd.get('host'), + anydesk: fd.get('anydesk') || '', + login: fd.get('login') || 'admin', + password: fd.get('password'), + }; + try { + const resp = await fetch('/api/keenetic/add', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (data.status === 'ok') { + closeModal('addKeeneticModal'); + e.target.reset(); + await refreshAllKeenetic(); + } else { + alert(data.detail || 'Error'); + } + } catch (err) { alert('Error: ' + err.message); } + }; +} + +function showImportKeenetic() { + document.getElementById('importKeeneticResult').style.display = 'none'; + document.getElementById('importKeeneticModal').style.display = 'flex'; + document.getElementById('importKeeneticForm').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const btn = e.target.querySelector('button[type=submit]'); + btn.disabled = true; + try { + const resp = await fetch('/api/keenetic/import', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + tsv: fd.get('tsv'), + login: fd.get('login') || 'admin', + password: fd.get('password'), + }), + }); + const data = await resp.json(); + const resultDiv = document.getElementById('importKeeneticResult'); + if (data.status === 'ok') { + resultDiv.innerHTML = `✅ Добавлено: ${(data.added || []).join(', ') || '—'}` + + (data.skipped?.length ? `
⏭ Пропущено: ${data.skipped.join(', ')}` : ''); + resultDiv.style.display = 'block'; + await refreshAllKeenetic(); + } else { + resultDiv.innerHTML = `❌ ${data.detail || 'Error'}`; + resultDiv.style.display = 'block'; + } + } catch (err) { alert('Error: ' + err.message); } + btn.disabled = false; + }; +} + +function showBulkAddKeenetic() { + document.getElementById('bulkAddResult').style.display = 'none'; + document.getElementById('bulkAddKeeneticModal').style.display = 'flex'; + document.getElementById('bulkAddKeeneticForm').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const body = { + domains: fd.get('domains'), + login: fd.get('login') || 'admin', + password: fd.get('password'), + }; + const btn = e.target.querySelector('button[type=submit]'); + btn.disabled = true; + btn.textContent = '⏳ Добавление...'; + try { + const resp = await fetch('/api/keenetic/add-bulk', { + method: 'POST', credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + const data = await resp.json(); + const resultDiv = document.getElementById('bulkAddResult'); + if (data.status === 'ok') { + let msg = `✅ Добавлено: ${data.added.length} роутеров`; + if (data.added.length) msg += `
${data.added.join(', ')}`; + if (data.skipped.length) msg += `
⏭ Пропущено (уже есть): ${data.skipped.join(', ')}`; + resultDiv.innerHTML = msg; + resultDiv.style.display = 'block'; + e.target.querySelector('textarea').value = ''; + await loadKeenetic(); + } else { + resultDiv.innerHTML = `❌ ${data.detail || 'Error'}`; + resultDiv.style.display = 'block'; + } + } catch (err) { + alert('Error: ' + err.message); + } + btn.disabled = false; + btn.textContent = 'Добавить все'; + }; +} + +let keeneticRefreshCooldown = 0; + +async function refreshKeenetic(name) { + const now = Date.now(); + if (now < keeneticRefreshCooldown) { + console.log('Keenetic refresh cooldown, skip'); + return; + } + keeneticRefreshCooldown = now + 30000; // 30s cooldown + + const card = document.querySelector(`[data-keen="${name}"]`); + if (card) card.classList.add('loading'); + try { + const resp = await fetch(`/api/keenetic/refresh/${name}`, { + method: 'POST', credentials: 'include', + }); + const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'}); + keeneticDevices = await resp2.json(); + renderKeenetic(); + } catch (e) { console.error('Keenetic refresh error:', e); } +} + +async function refreshAllKeenetic() { + const now = Date.now(); + if (now < keeneticRefreshCooldown) { + console.log('Keenetic refresh cooldown, skip'); + return; + } + keeneticRefreshCooldown = now + 30000; + + document.querySelectorAll('#keenetic-grid .server-card').forEach(c => c.classList.add('loading')); + const grid = document.getElementById('keenetic-grid'); + if (!keeneticDevices.length) { + grid.innerHTML = '
⏳ Загрузка...
'; + } + try { + const resp = await fetch('/api/keenetic/refresh-all', { + method: 'POST', credentials: 'include', + }); + const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'}); + keeneticDevices = await resp2.json(); + renderKeenetic(); + } catch (e) { console.error('Keenetic refresh-all error:', e); } +} + +async function rebootKeenetic(name) { + if (!confirm(`Перезагрузить роутер "${name}"?`)) return; + try { + const resp = await fetch(`/api/keenetic/reboot/${name}`, { + method: 'POST', credentials: 'include', + }); + const data = await resp.json(); + if (data.status === 'ok') { + alert(`✅ Команда перезагрузки отправлена: ${name}`); + } else { + alert(`❌ Ошибка: ${data.detail || 'unknown'}`); + } + } catch (e) { alert('Error: ' + e.message); } +} + +async function deleteKeenetic(name) { + if (!confirm(`Удалить роутер "${name}"?`)) return; + try { + await fetch(`/api/keenetic/${name}`, { + method: 'DELETE', credentials: 'include', + }); + await loadKeenetic(); + } catch (e) { alert('Error: ' + e.message); } +} + // ===================== INIT ===================== document.addEventListener('DOMContentLoaded', () => { applyTheme(currentTheme); - renderPage(); - loadServers(); + loadMutedDevices().then(() => { + renderPage(); + loadServers(); + }); }); setInterval(() => { @@ -1062,4 +1561,5 @@ setInterval(() => { else if (activeTab === 'pc') loadPCs(); else if (activeTab === 'synology') loadSynology(); else if (activeTab === 'ha') loadHA(); + else if (activeTab === 'keenetic') loadKeenetic(); }, 30000); diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 187b033..887062d 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -43,10 +43,11 @@
- - + + +
@@ -72,6 +73,7 @@
+
@@ -84,6 +86,16 @@
+ + @@ -185,6 +197,105 @@
+ + + + + + + + + + + + + + +