feat: keenetic integration, VPN alerts, docs

Wire keenetic monitor loop and router into main app. Add deduplicated
Telegram alerts for router offline, internet, CPU/RAM, and VPN down.
Update README with KeenDNS edit, import format, troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-22 01:55:03 +03:00
parent 8f8e9a3234
commit 1c4f97124a
13 changed files with 1085 additions and 191 deletions

View File

@@ -88,6 +88,38 @@ async def test_notification(channel: str, request: Request, user: str = Depends(
return {"status": "ok" if ok else "error", "channel": channel}
@router.get("/muted-devices")
async def get_muted_devices(request: Request, user: str = Depends(require_auth)):
"""Get list of muted device keys."""
settings = load_settings()
return {"muted": settings.get("muted_devices", [])}
@router.post("/mute-device")
async def toggle_mute_device(request: Request, user: str = Depends(require_auth)):
"""Toggle mute for a device. Body: {category, name}. Key format: 'servers:SGA_3'."""
body = await request.json()
category = body.get("category", "")
name = body.get("name", "")
if not category or not name:
return {"status": "error", "detail": "category and name required"}
key = f"{category}:{name}"
settings = load_settings()
muted = settings.get("muted_devices", [])
if key in muted:
muted.remove(key)
is_muted = False
else:
muted.append(key)
is_muted = True
settings["muted_devices"] = muted
save_settings(settings)
return {"status": "ok", "muted": is_muted, "key": key}
@router.post("/generate-pc-agent")
async def generate_pc_agent(request: Request, user: str = Depends(require_auth)):
"""Generate personalized PC agent install command."""
@@ -96,16 +128,20 @@ async def generate_pc_agent(request: Request, user: str = Depends(require_auth))
server_url = body.get("server_url", "").strip()
if not server_url:
# Auto-detect
host = request.headers.get("host", "localhost:7272")
proto = "http"
server_url = f"{proto}://{host}"
# Auto-detect: use IP on port 80 (HTTP, works on all Windows)
host = request.headers.get("host", "localhost")
# Strip port if present, use plain HTTP port 80
host_ip = host.split(":")[0]
server_url = f"http://{host_ip}"
cmd = (
f'powershell -ExecutionPolicy Bypass -Command '
f'"Invoke-WebRequest -Uri \'{server_url}/static/downloads/install_agent.ps1\' '
f'-OutFile install_agent.ps1; .\\install_agent.ps1 '
f'-ServerUrl \'{server_url}\' -AgentName \'{agent_name}\'"'
# Command for use directly inside PowerShell (Admin)
# Uses HTTP port 80 (nginx proxy) — no SSL issues on old PowerShell
ps_cmd = (
f"Set-ExecutionPolicy Bypass -Scope Process -Force; "
f"$ProgressPreference = 'SilentlyContinue'; "
f"(New-Object Net.WebClient).DownloadFile('{server_url}/static/downloads/install_agent.ps1', "
f"\"$PWD\\install_agent.ps1\"); "
f"& \"$PWD\\install_agent.ps1\" -ServerUrl '{server_url}' -AgentName '{agent_name}'"
)
return {"status": "ok", "command": cmd, "agent_name": agent_name}
return {"status": "ok", "command": ps_cmd, "agent_name": agent_name}

View File

@@ -1,16 +1,23 @@
"""Synology NAS monitoring API endpoints."""
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Dict, List
from fastapi import APIRouter, Request, Depends
from fastapi.responses import PlainTextResponse
from server.auth import require_auth
from server.config import DATA_DIR
from server.config import DATA_DIR, BASE_DIR
router = APIRouter(prefix="/api/synology", tags=["synology"])
# Tunnel config
TUNNEL_KEY_DIR = Path(BASE_DIR) / "tunnel_keys"
TUNNEL_VPS_PORT = 15000 # VPS listens on this port, tunneled to Synology
# In-memory cache
synology_metrics: Dict[str, dict] = {}
@@ -99,6 +106,8 @@ async def synology_refresh(name: str, request: Request, user: str = Depends(requ
metrics["last_updated"] = datetime.now().isoformat()
synology_metrics[name] = metrics
await client.logout()
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)}
@@ -130,3 +139,88 @@ async def synology_refresh_all(request: Request, user: str = Depends(require_aut
results.append({"name": dev["name"], "online": False, "error": str(e)})
return {"status": "ok", "results": results}
@router.post("/tunnel/enable/{name}")
async def synology_enable_tunnel(name: str, request: Request, user: str = Depends(require_auth)):
"""Enable tunnel mode for a Synology device — VPS connects via localhost tunnel."""
body = await request.json()
local_ip = body.get("local_ip", "192.168.88.6")
local_port = body.get("local_port", 5000)
devices = _load_synology()
dev = next((d for d in devices if d["name"] == name), None)
if not dev:
return {"status": "error", "detail": "device not found"}
# Save original host and switch to tunnel
dev["original_host"] = dev.get("original_host", dev["host"])
dev["host"] = "127.0.0.1"
dev["port"] = TUNNEL_VPS_PORT
dev["https"] = False
dev["tunnel"] = {
"enabled": True,
"local_ip": local_ip,
"local_port": local_port,
"vps_port": TUNNEL_VPS_PORT,
}
_save_synology(devices)
return {"status": "ok", "vps_port": TUNNEL_VPS_PORT}
@router.post("/tunnel/disable/{name}")
async def synology_disable_tunnel(name: str, request: Request, user: str = Depends(require_auth)):
"""Disable tunnel mode, revert to direct host."""
devices = _load_synology()
dev = next((d for d in devices if d["name"] == name), None)
if not dev:
return {"status": "error", "detail": "device not found"}
if dev.get("original_host"):
dev["host"] = dev["original_host"]
dev["port"] = dev.get("tunnel", {}).get("local_port", 5000)
dev.pop("tunnel", None)
dev.pop("original_host", None)
_save_synology(devices)
return {"status": "ok"}
@router.get("/tunnel/key")
async def get_tunnel_key(request: Request, user: str = Depends(require_auth)):
"""Download SSH private key for tunnel setup."""
key_file = TUNNEL_KEY_DIR / "synology_tunnel"
if not key_file.exists():
return {"status": "error", "detail": "tunnel key not generated"}
key_content = key_file.read_text()
return PlainTextResponse(content=key_content, media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=synology_tunnel"})
@router.get("/tunnel/setup-command")
async def get_tunnel_setup_command(name: str, request: Request, user: str = Depends(require_auth)):
"""Generate PowerShell command to set up Synology tunnel on Windows PC."""
devices = _load_synology()
dev = next((d for d in devices if d["name"] == name), None)
tunnel = dev.get("tunnel", {}) if dev else {}
local_ip = tunnel.get("local_ip", "192.168.88.6")
local_port = tunnel.get("local_port", 5000)
vps_port = tunnel.get("vps_port", TUNNEL_VPS_PORT)
host = request.headers.get("host", "77.239.126.123").split(":")[0]
server_url = f"http://{host}"
ps_cmd = (
f"Set-ExecutionPolicy Bypass -Scope Process -Force; "
f"$ProgressPreference = 'SilentlyContinue'; "
f"(New-Object Net.WebClient).DownloadFile('{server_url}/static/downloads/synology_tunnel.ps1', "
f"\"$PWD\\synology_tunnel.ps1\"); "
f"& \"$PWD\\synology_tunnel.ps1\" -ServerUrl '{server_url}' "
f"-LocalTarget '{local_ip}:{local_port}' -VpsPort {vps_port}"
)
return {"status": "ok", "command": ps_cmd, "local_ip": local_ip,
"local_port": local_port, "vps_port": vps_port}