Files
vps_monitoring/server/api/keenetic.py
Андрей Бобырев 746ecfc343 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>
2026-05-22 00:52:11 +03:00

319 lines
11 KiB
Python

"""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(3)
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))