diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48b779a --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Telegram alerts (also set in systemd unit or data/settings.json) +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# Optional overrides (defaults in data/settings.json) +# MONITOR_INTERVAL=60 +# KEENETIC_INTERVAL=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..804ace2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +venv/ +.env +data/*.json.bak +*.log diff --git a/README.md b/README.md index e7abdbb..def5719 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,16 @@ -# 🖥 VPS Monitoring +# VPS Monitoring -Платформа мониторинга VPS серверов с веб-интерфейсом, SSH терминалом и Telegram ботом. +Платформа мониторинга VPS, Keenetic-роутеров, Synology, Home Assistant и Windows PC с веб-панелью, SSH-терминалом и Telegram-ботом. ## Возможности -- **Мониторинг** — CPU, RAM, Disk, Network, Uptime, Load Average -- **SSH терминал** — подключение к серверам прямо из браузера -- **Telegram бот** — статус, алерты, управление (перезагрузка) -- **Telegram Mini App** — мобильная панель прямо в Telegram -- **Алерты** — уведомления при превышении порогов (CPU/RAM/Disk) -- **Управление** — добавление/удаление серверов, перезагрузка -- **Авторизация** — защита паролем с возможностью смены +- **VPS** — CPU, RAM, Disk, Network, Uptime, Load Average, SSH-терминал +- **Keenetic** — мониторинг роутеров через KeenDNS/RCI API, VPN-туннели, AnyDesk-ссылки +- **Synology / HA / PC** — дополнительные вкладки мониторинга +- **Telegram** — бот, Mini App, алерты (offline, CPU/RAM, VPN down 5+ мин) +- **KeenDNS edit** — изменение web URL прямо из карточки роутера -## Установка (одна команда) +## Установка ```bash curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | bash @@ -24,78 +22,141 @@ curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/ins ``` ### Требования + - Ubuntu 20.04+ / Debian 11+ - Python 3.10+ - 512 MB RAM минимум +### Ручная установка + +```bash +git clone https://github.com/andrey271192/vps_monitoring.git /opt/vps-monitoring +cd /opt/vps-monitoring +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # заполните TELEGRAM_* +mkdir -p data +``` + +Systemd unit (`/etc/systemd/system/vps-monitoring.service`): + +- `WorkingDirectory=/opt/vps-monitoring` +- `ExecStart=/opt/vps-monitoring/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 7272` + +```bash +systemctl daemon-reload && systemctl enable --now vps-monitoring +``` + ## После установки - **Панель:** `http://YOUR_IP:7272` -- **Логин:** `admin` / `admin` -- **Telegram:** бот запускается автоматически - -## Управление - -```bash -# Статус -systemctl status vps-monitoring - -# Перезапуск -systemctl restart vps-monitoring - -# Логи -journalctl -u vps-monitoring -f - -# Обновление -cd /opt/vps-monitoring && git pull && systemctl restart vps-monitoring -``` - -## Telegram бот - -Команды: -- `/start` — главное меню -- Кнопки: статус серверов, управление, перезагрузка -- Mini App: полная панель в Telegram - -## API - -| Endpoint | Method | Описание | -|----------|--------|----------| -| `/api/servers` | GET | Список серверов | -| `/api/servers` | POST | Добавить сервер | -| `/api/servers/{id}` | DELETE | Удалить сервер | -| `/api/servers/{id}/reboot` | POST | Перезагрузить | -| `/api/servers/{id}/exec` | POST | Выполнить команду | -| `/api/settings` | GET/PUT | Настройки | -| `/api/health` | GET | Health check | -| `/ws/ssh/{id}` | WS | SSH терминал | - -## Архитектура - -``` -FastAPI (Python) → SSH (asyncssh) → Target Servers - ↕ ↕ - Web UI (HTML/JS) Telegram Bot - ↕ - xterm.js (SSH in browser) -``` +- **Логин:** `admin` / `admin` (смените в настройках) +- **Health:** `GET /api/health` ## Переменные окружения | Переменная | Описание | |-----------|----------| -| `TELEGRAM_BOT_TOKEN` | Токен Telegram бота | +| `TELEGRAM_BOT_TOKEN` | Токен Telegram-бота | | `TELEGRAM_CHAT_ID` | ID чата для алертов | +Задаются в systemd unit (`Environment=...`) или в `data/settings.json`. См. `.env.example`. + +Дополнительные настройки в `data/settings.json`: пороги CPU/RAM/Disk, `keenetic_interval`, `muted_devices`. + +## Keenetic — импорт роутеров + +### Формат import (TSV/CSV) + +Колонки: `Address`, `Keenetic`, `AnyDesk` (заголовок опционален). + +``` +Address Keenetic AnyDesk +Москва Сити https://moscowcity.netcraze.pro:5443 +Лофт https://loftliliana.netcraze.pro 1020687391 +Подмосковный http://95.165.93.46:777 1527495291 +``` + +`POST /api/keenetic/import` — body: `{"login":"admin","password":"...","tsv":"..."}`. + +### Скрипт синхронизации (24 роутера) + +```bash +./venv/bin/python3 scripts/sync_keenetic_import.py /opt/vps-monitoring/data/keenetic.json +``` + +### HTTP/HTTPS и порты + +- URL сохраняется как есть: `https://host:5443`, `http://91.77.164.164` +- Схема по умолчанию — `https` для KeenDNS, `http` для IP +- Порт входит в `host` и `web_url` (например `malahovka1.netcraze.pro:5083`) +- Кнопка **Веб** на карточке использует точный `web_url` + +### KeenDNS edit (dashboard) + +1. Карточка роутера → **✏️ KeenDNS** +2. Введите URL (`https://example.netcraze.pro:8443`) +3. **Сохранить** → `PATCH /api/keenetic/{name}` обновляет `keenetic.json` и делает refresh + +### Проверка доступности с VPS + +```bash +cd /opt/vps-monitoring +./venv/bin/python3 scripts/quick_auth_check.py # быстрая auth-проверка +./venv/bin/python3 scripts/check_keenetic.py # полная (медленнее) +``` + +## Telegram алерты + +Настройка: `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` в systemd или settings. + +Keenetic алерты: +- Router offline / back online +- No internet +- CPU/RAM > 90% +- **VPN down 5+ минут** (sustained, deduplicated) + +Per-device mute: 🔔/🔕 на карточке → `POST /api/notifications/mute-device`. + +Тест: `POST /api/notifications/test/telegram`. + +## API (основное) + +| Endpoint | Method | Описание | +|----------|--------|----------| +| `/api/login` | POST | Авторизация | +| `/api/keenetic/list` | GET | Список роутеров | +| `/api/keenetic/import` | POST | Импорт TSV/CSV | +| `/api/keenetic/{name}` | PATCH | Обновить web_url | +| `/api/keenetic/refresh/{name}` | POST | Обновить один | +| `/api/keenetic/refresh-all` | POST | Обновить все | +| `/api/servers` | GET | VPS серверы | +| `/api/health` | GET | Health check | + +## Troubleshooting + +| Симптом | Причина | Действие | +|---------|---------|----------| +| `ConnectionTimeoutError` | VPS не достучался до роутера (сеть/VPN/firewall) | Проверить KeenDNS, порт, доступность с VPS: `curl -m 10 URL` | +| `Authentication failed` / 401 | Неверный login/password | Проверить credentials в keenetic.json | +| `Name or service not known` | DNS не резолвится | Проверить KeenDNS имя, TTL | +| Панель не отвечает | Сервис упал / перегрузка polling | `systemctl restart vps-monitoring`, `journalctl -u vps-monitoring -n 50` | +| Медленный refresh-all | 24 роутера × 3 retry × timeout | Использовать `quick_auth_check.py`, увеличить `keenetic_interval` | + +```bash +systemctl status vps-monitoring +journalctl -u vps-monitoring --since "1 hour ago" -p err +ss -tlnp | grep 7272 +``` + +## Обновление + +```bash +cd /opt/vps-monitoring && git pull && systemctl restart vps-monitoring +``` + ## Автор -**@Iot_andrey** - -- [GitHub](https://github.com/andrey271192) -- [Boosty](https://boosty.to/iot_andrey) -- [Поддержка](https://t.me/Iot_andrey) -- Telegram: [@Iot_andrey](https://t.me/Iot_andrey) - -## Лицензия +**@Iot_andrey** — [GitHub](https://github.com/andrey271192) · [Telegram](https://t.me/Iot_andrey) MIT diff --git a/server/api/notifications.py b/server/api/notifications.py index 1f06e1c..110c8d5 100644 --- a/server/api/notifications.py +++ b/server/api/notifications.py @@ -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} diff --git a/server/api/synology.py b/server/api/synology.py index d713f8c..bc7fa3c 100644 --- a/server/api/synology.py +++ b/server/api/synology.py @@ -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} diff --git a/server/main.py b/server/main.py index d933313..c885fa9 100644 --- a/server/main.py +++ b/server/main.py @@ -19,6 +19,7 @@ from server.api.ssh_ws import router as ssh_router from server.api.pc import router as pc_router from server.api.synology import router as synology_router from server.api.ha import router as ha_router +from server.api.keenetic import router as keenetic_router, keenetic_monitor_loop from server.api.notifications import router as notifications_router from server.services.monitor import monitor_loop from server.services.telegram_bot import start_bot, stop_bot @@ -30,6 +31,7 @@ logger = logging.getLogger(__name__) monitor_task = None alert_task = None +keenetic_task = None async def alert_loop(): @@ -44,18 +46,19 @@ async def alert_loop(): @asynccontextmanager async def lifespan(app: FastAPI): - global monitor_task, alert_task - # Start background tasks + global monitor_task, alert_task, keenetic_task monitor_task = asyncio.create_task(monitor_loop()) alert_task = asyncio.create_task(alert_loop()) + keenetic_task = asyncio.create_task(keenetic_monitor_loop()) await start_bot() logger.info("VPS Monitoring started") yield - # Cleanup if monitor_task: monitor_task.cancel() if alert_task: alert_task.cancel() + if keenetic_task: + keenetic_task.cancel() await stop_bot() @@ -79,6 +82,7 @@ app.include_router(ssh_router) app.include_router(pc_router) app.include_router(synology_router) app.include_router(ha_router) +app.include_router(keenetic_router) app.include_router(notifications_router) diff --git a/server/services/alerter.py b/server/services/alerter.py index af5a322..eb45302 100644 --- a/server/services/alerter.py +++ b/server/services/alerter.py @@ -1,25 +1,125 @@ +"""Smart alert system with deduplication for all monitoring tabs. + +Logic per issue: + 1. Problem detected -> send 1 alert + 2. Problem continues -> stay silent (no spam) + 3. Problem fixed -> send "resolved" message + 4. New different problem -> send new alert + +State tracked per (source, issue_key) pair. +""" + +import json import logging from datetime import datetime -from typing import Dict +from typing import Dict, Tuple -from server.config import load_settings, load_servers +from server.config import load_settings, load_servers, DATA_DIR from server.services.monitor import get_all_metrics logger = logging.getLogger(__name__) -previous_states: Dict[str, bool] = {} +# Active issues: {(category, source_name, issue_key): datetime_first_seen} +active_issues: Dict[Tuple[str, str, str], datetime] = {} +# Sustained problems waiting for threshold: {issue_key: datetime_first_seen} +_pending_sustain: Dict[Tuple[str, str, str], datetime] = {} + + +def _issue_key(category: str, source: str, key: str) -> tuple: + return (category, source, key) + + +async def _fire_alert(message: str, subject: str, category: str): + """Send notification via configured channels.""" + from server.services.notifier import send_notification + await send_notification(message, subject=subject, category=category) + + +def _is_device_muted(category: str, source: str) -> bool: + """Check if device is muted in settings.""" + try: + settings = load_settings() + muted = settings.get("muted_devices", []) + return f"{category}:{source}" in muted + except Exception: + return False + + +async def _check_issue(category: str, source: str, key: str, is_problem: bool, + alert_msg: str, resolve_msg: str, subject: str): + """Core dedup logic for one issue.""" + ik = _issue_key(category, source, key) + was_active = ik in active_issues + + if is_problem and not was_active: + active_issues[ik] = datetime.now() + # Skip notification if device muted (still track state) + if not _is_device_muted(category, source): + await _fire_alert(alert_msg, subject, category) + logger.info(f"Alert fired: {category}/{source}/{key}") + else: + logger.info(f"Alert suppressed (muted): {category}/{source}/{key}") + + elif not is_problem and was_active: + del active_issues[ik] + if not _is_device_muted(category, source): + await _fire_alert(resolve_msg, f"{subject} resolved", category) + logger.info(f"Resolved: {category}/{source}/{key}") + else: + logger.info(f"Resolved (muted): {category}/{source}/{key}") + + # Problem continues or no problem - stay silent + + +async def _check_sustained_issue(category: str, source: str, key: str, is_problem: bool, + alert_msg: str, resolve_msg: str, subject: str, + sustain_seconds: int = 300): + """Alert only after problem persists for sustain_seconds (default 5 min).""" + ik = _issue_key(category, source, key) + was_active = ik in active_issues + now = datetime.now() + + if is_problem: + if ik not in _pending_sustain: + _pending_sustain[ik] = now + elapsed = (now - _pending_sustain[ik]).total_seconds() + if elapsed >= sustain_seconds and not was_active: + active_issues[ik] = _pending_sustain[ik] + if not _is_device_muted(category, source): + await _fire_alert(alert_msg, subject, category) + logger.info(f"Sustained alert: {category}/{source}/{key}") + else: + logger.info(f"Sustained alert suppressed (muted): {category}/{source}/{key}") + else: + _pending_sustain.pop(ik, None) + if was_active: + del active_issues[ik] + if not _is_device_muted(category, source): + await _fire_alert(resolve_msg, f"{subject} resolved", category) + logger.info(f"Sustained resolved: {category}/{source}/{key}") async def check_alerts(): - """Check metrics against thresholds and send alerts.""" - from server.services.notifier import send_notification + """Check all 5 tabs for issues.""" from server.api.auth_routes import mute_until - # Check mute + # Check global mute if mute_until and datetime.now() < mute_until: return settings = load_settings() + + await _check_servers(settings) + await _check_pc(settings) + await _check_synology(settings) + await _check_ha(settings) + await _check_keenetic(settings) + + +# ==================== SERVERS ==================== + +async def _check_servers(settings: dict): + """Check VPS servers for offline/threshold issues.""" servers = load_servers() metrics = get_all_metrics() @@ -29,48 +129,381 @@ async def check_alerts(): for srv in servers: host = srv["host"] - m = metrics.get(host, {}) name = srv.get("name", host) + m = metrics.get(host, {}) + online = m.get("online", False) - current_online = m.get("online", False) - prev_online = previous_states.get(host) + # Online/Offline + await _check_issue( + "servers", name, "offline", + is_problem=not online, + alert_msg=f"🔴 *{name}* ({host}) - OFFLINE!", + resolve_msg=f"🟢 *{name}* ({host}) - back online", + subject=f"{name} offline", + ) - # Online/Offline state change - if prev_online is not None and prev_online != current_online: - if current_online: - await send_notification( - f"🟢 **{name}** ({host}) — снова онлайн", - subject=f"{name} Online", category="servers" - ) - else: - await send_notification( - f"🔴 **{name}** ({host}) — OFFLINE!", - subject=f"{name} OFFLINE", category="servers" - ) - - previous_states[host] = current_online - - if not current_online: + if not online: continue - # Threshold alerts + # CPU cpu = m.get("cpu_percent", 0) - if cpu >= cpu_threshold: - await send_notification( - f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)", - subject=f"{name} CPU {cpu}%", category="servers" - ) + await _check_issue( + "servers", name, "cpu_high", + is_problem=cpu >= cpu_threshold, + alert_msg=f"⚠️ *{name}* CPU: {cpu}% (threshold: {cpu_threshold}%)", + resolve_msg=f"✅ *{name}* CPU normalized: {cpu}%", + subject=f"{name} CPU", + ) + # RAM ram = m.get("ram_percent", 0) - if ram >= ram_threshold: - await send_notification( - f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)", - subject=f"{name} RAM {ram}%", category="servers" + await _check_issue( + "servers", name, "ram_high", + is_problem=ram >= ram_threshold, + alert_msg=f"⚠️ *{name}* RAM: {ram}% (threshold: {ram_threshold}%)", + resolve_msg=f"✅ *{name}* RAM normalized: {ram}%", + subject=f"{name} RAM", + ) + + # Disk + disk = m.get("disk_percent", 0) + await _check_issue( + "servers", name, "disk_high", + is_problem=disk >= disk_threshold, + alert_msg=f"⚠️ *{name}* Disk: {disk}% (threshold: {disk_threshold}%)", + resolve_msg=f"✅ *{name}* Disk normalized: {disk}%", + subject=f"{name} Disk", + ) + + +# ==================== PC AGENTS ==================== + +async def _check_pc(settings: dict): + """Check PC agents for offline status.""" + pc_file = DATA_DIR / "pc_agents.json" + if not pc_file.exists(): + return + + with open(pc_file) as f: + pc_data = json.load(f) + + now = datetime.now() + + for name, data in pc_data.items(): + last_seen = data.get("last_seen", "") + try: + last_dt = datetime.fromisoformat(last_seen) + stale = (now - last_dt).total_seconds() > 180 # 3 min + except Exception: + stale = True + + await _check_issue( + "pc", name, "offline", + is_problem=stale, + alert_msg=f"🔴 *PC {name}* - no heartbeat for 3+ min", + resolve_msg=f"🟢 *PC {name}* - back online", + subject=f"PC {name}", + ) + + +# ==================== SYNOLOGY ==================== + +async def _check_synology(settings: dict): + """Check Synology NAS devices.""" + from server.api.synology import synology_metrics, _load_synology + + devices = _load_synology() + + for dev in devices: + name = dev["name"] + m = synology_metrics.get(name, {}) + + if not m: + continue + + online = m.get("online", False) + + await _check_issue( + "synology", name, "offline", + is_problem=not online, + alert_msg=f"🔴 *NAS {name}* - OFFLINE!", + resolve_msg=f"🟢 *NAS {name}* - back online", + subject=f"NAS {name}", + ) + + if not online: + continue + + # Temperature + temp = m.get("temperature", 0) + await _check_issue( + "synology", name, "temp_high", + is_problem=temp >= 65, + alert_msg=f"🌡 *NAS {name}* temperature: {temp}C (critical!)", + resolve_msg=f"✅ *NAS {name}* temperature normalized: {temp}C", + subject=f"NAS {name} temp", + ) + + # Volume usage + for vol in m.get("volumes", []): + vol_name = vol.get("name", "?") + pct = vol.get("percent", 0) + await _check_issue( + "synology", name, f"vol_{vol_name}_full", + is_problem=pct >= 90, + alert_msg=f"💾 *NAS {name}* volume {vol_name}: {pct}% full!", + resolve_msg=f"✅ *NAS {name}* volume {vol_name} freed: {pct}%", + subject=f"NAS {name} disk", ) - disk = m.get("disk_percent", 0) - if disk >= disk_threshold: - await send_notification( - f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)", - subject=f"{name} Disk {disk}%", category="servers" + # Disk SMART + for disk in m.get("disks", []): + disk_name = disk.get("name", "?") + smart = disk.get("smart_status", "normal") + await _check_issue( + "synology", name, f"smart_{disk_name}", + is_problem=smart not in ("normal", ""), + alert_msg=f"🔩 *NAS {name}* disk {disk_name} SMART: {smart}!", + resolve_msg=f"✅ *NAS {name}* disk {disk_name} SMART normal", + subject=f"NAS {name} SMART", ) + + # RAM + ram_pct = m.get("ram_percent", 0) + await _check_issue( + "synology", name, "ram_high", + is_problem=ram_pct >= 90, + alert_msg=f"⚠️ *NAS {name}* RAM: {ram_pct}%", + resolve_msg=f"✅ *NAS {name}* RAM normalized: {ram_pct}%", + subject=f"NAS {name} RAM", + ) + + +# ==================== HOME ASSISTANT ==================== + +async def _check_ha(settings: dict): + """Check Home Assistant instances.""" + from server.api.ha import ha_metrics, _load_ha + + instances = _load_ha() + + for inst in instances: + name = inst["name"] + m = ha_metrics.get(name, {}) + + if not m: + continue + + online = m.get("online", False) + + await _check_issue( + "ha", name, "offline", + is_problem=not online, + alert_msg=f"🔴 *HA {name}* - OFFLINE!", + resolve_msg=f"🟢 *HA {name}* - back online", + subject=f"HA {name}", + ) + + if not online: + continue + + # Problem entities + problems = m.get("problem_entities", []) + await _check_issue( + "ha", name, "problems", + is_problem=len(problems) > 0, + alert_msg=f"⚠️ *HA {name}* {len(problems)} problem entities", + resolve_msg=f"✅ *HA {name}* all entities OK", + subject=f"HA {name} problems", + ) + + # Low battery devices + battery_sensors = m.get("sensors", {}).get("battery", []) + low_battery = [b for b in battery_sensors if b.get("value") is not None and b["value"] < 10] + await _check_issue( + "ha", name, "low_battery", + is_problem=len(low_battery) > 0, + alert_msg=f"🔋 *HA {name}* {len(low_battery)} devices critical battery (<10%)", + resolve_msg=f"✅ *HA {name}* all batteries OK", + subject=f"HA {name} battery", + ) + + +# ==================== KEENETIC ==================== + +async def _check_keenetic(settings: dict): + """Check Keenetic routers.""" + from server.api.keenetic import keenetic_metrics, _load_keenetic + + devices = _load_keenetic() + + for dev in devices: + name = dev["name"] + m = keenetic_metrics.get(name, {}) + + if not m: + continue + + online = m.get("online", False) + + await _check_issue( + "keenetic", name, "offline", + is_problem=not online, + alert_msg=f"🔴 *Router {name}* - OFFLINE!", + resolve_msg=f"🟢 *Router {name}* - back online", + subject=f"Router {name}", + ) + + if not online: + continue + + # Internet connectivity + internet = m.get("internet", True) + await _check_issue( + "keenetic", name, "no_internet", + is_problem=not internet, + alert_msg=f"🌐 *Router {name}* - NO INTERNET!", + resolve_msg=f"✅ *Router {name}* - internet restored", + subject=f"Router {name} internet", + ) + + # High CPU + cpuload = m.get("cpuload", 0) + await _check_issue( + "keenetic", name, "cpu_high", + is_problem=cpuload >= 90, + alert_msg=f"⚠️ *Router {name}* CPU: {cpuload}%", + resolve_msg=f"✅ *Router {name}* CPU normalized: {cpuload}%", + subject=f"Router {name} CPU", + ) + + # High memory + mem_pct = m.get("mem_percent", 0) + await _check_issue( + "keenetic", name, "mem_high", + is_problem=mem_pct >= 90, + alert_msg=f"⚠️ *Router {name}* RAM: {mem_pct}%", + resolve_msg=f"✅ *Router {name}* RAM normalized: {mem_pct}%", + subject=f"Router {name} RAM", + ) + + # VPN down for 5+ minutes (per tunnel) + host = dev.get("host", "") + for vpn in m.get("vpn", []): + vpn_name = vpn.get("name", "vpn") + vpn_label = vpn.get("description") or vpn_name + vpn_type = vpn.get("type", "") + state = (vpn.get("state") or "").lower() + is_down = state != "up" + ts = datetime.now().strftime("%H:%M %d.%m.%Y") + await _check_sustained_issue( + "keenetic", name, f"vpn_{vpn_name}", + is_problem=is_down, + alert_msg=( + f"🔻 *VPN недоступен 5+ мин*\n" + f"Роутер: *{name}*\n" + f"VPN: `{vpn_label}` ({vpn_type})\n" + f"Состояние: `{state or 'down'}`\n" + f"Хост: `{host}`\n" + f"Время: {ts}" + ), + resolve_msg=( + f"✅ *VPN восстановлен*\n" + f"Роутер: *{name}*\n" + f"VPN: `{vpn_label}` ({vpn_type})" + ), + subject=f"VPN {name} {vpn_label}", + sustain_seconds=300, + ) + + +# ==================== STATUS REQUEST ==================== + +async def get_full_status() -> str: + """Generate full status summary for Telegram /status command.""" + lines = ["📊 *VPS Monitoring Status*\n"] + + # Servers + servers = load_servers() + metrics = get_all_metrics() + if servers: + lines.append("*VPS Servers:*") + for srv in servers: + m = metrics.get(srv["host"], {}) + name = srv.get("name", srv["host"]) + if m.get("online"): + cpu = m.get("cpu_percent", 0) + ram = m.get("ram_percent", 0) + disk = m.get("disk_percent", 0) + lines.append(f" 🟢 {name}: CPU {cpu}% RAM {ram}% Disk {disk}%") + else: + lines.append(f" 🔴 {name}: OFFLINE") + + # PC + pc_file = DATA_DIR / "pc_agents.json" + if pc_file.exists(): + with open(pc_file) as f: + pc_data = json.load(f) + if pc_data: + lines.append("\n*PC Agents:*") + now = datetime.now() + for name, data in pc_data.items(): + last_seen = data.get("last_seen", "") + try: + last_dt = datetime.fromisoformat(last_seen) + stale = (now - last_dt).total_seconds() > 180 + except Exception: + stale = True + m = data.get("metrics", {}) + if not stale: + lines.append(f" 🟢 {name}: CPU {m.get('cpu_percent', 0)}% RAM {m.get('ram_percent', 0)}%") + else: + lines.append(f" 🔴 {name}: offline") + + # Synology + from server.api.synology import synology_metrics, _load_synology + syn_devs = _load_synology() + if syn_devs: + lines.append("\n*Synology NAS:*") + for dev in syn_devs: + m = synology_metrics.get(dev["name"], {}) + if m.get("online"): + lines.append(f" 🟢 {dev['name']}: {m.get('model', '')} CPU {m.get('cpu_percent', 0)}% RAM {m.get('ram_percent', 0)}% {m.get('temperature', 0)}C") + else: + lines.append(f" 🔴 {dev['name']}: offline") + + # HA + from server.api.ha import ha_metrics, _load_ha + ha_insts = _load_ha() + if ha_insts: + lines.append("\n*Home Assistant:*") + for inst in ha_insts: + m = ha_metrics.get(inst["name"], {}) + if m.get("online"): + ent = m.get("entities_count", 0) + lines.append(f" 🟢 {inst['name']}: v{m.get('version', '?')} ({ent} entities)") + else: + lines.append(f" 🔴 {inst['name']}: offline") + + # Keenetic + from server.api.keenetic import keenetic_metrics, _load_keenetic + keen_devs = _load_keenetic() + if keen_devs: + lines.append("\n*Keenetic Routers:*") + for dev in keen_devs: + m = keenetic_metrics.get(dev["name"], {}) + if m.get("online"): + inet = "🌐" if m.get("internet") else "⚠️NO-INET" + lines.append(f" 🟢 {dev['name']}: {m.get('model', '')} CPU {m.get('cpuload', 0)}% RAM {m.get('mem_percent', 0)}% {inet} {m.get('clients_count', 0)} clients") + else: + lines.append(f" 🔴 {dev['name']}: offline") + + # Active issues + if active_issues: + lines.append(f"\n⚠️ *Active issues: {len(active_issues)}*") + for (cat, src, key), since in active_issues.items(): + lines.append(f" - {cat}/{src}: {key}") + else: + lines.append("\n✅ *No active issues*") + + return "\n".join(lines) diff --git a/server/services/notifier.py b/server/services/notifier.py index 725a2ed..af86830 100644 --- a/server/services/notifier.py +++ b/server/services/notifier.py @@ -30,7 +30,13 @@ async def send_telegram(message: str, parse_mode: str = "Markdown"): "text": message, "parse_mode": parse_mode, }) as resp: - return resp.status == 200 + ok = resp.status == 200 + if ok: + logger.info(f"Telegram sent: {message[:80]}") + else: + body = await resp.text() + logger.error(f"Telegram send HTTP {resp.status}: {body[:200]}") + return ok except Exception as e: logger.error(f"Telegram send error: {e}") return False diff --git a/server/services/synology_client.py b/server/services/synology_client.py index b348bbd..d3cec19 100644 --- a/server/services/synology_client.py +++ b/server/services/synology_client.py @@ -1,6 +1,7 @@ """Synology DSM API client for NAS monitoring.""" import logging +import traceback from typing import Optional, Dict, Any import aiohttp @@ -13,16 +14,28 @@ class SynologyClient: def __init__(self, host: str, port: int = 5000, https: bool = False, username: str = "", password: str = ""): + # Handle case where host already contains protocol + if host.startswith("http://") or host.startswith("https://"): + clean = host.replace("https://", "").replace("http://", "").rstrip("/") + if ":" in clean: + host = clean.split(":")[0] + port = int(clean.split(":")[1]) + else: + host = clean + if "https" in host: + https = True + proto = "https" if https else "http" self.base_url = f"{proto}://{host}:{port}" self.username = username self.password = password self.sid: Optional[str] = None + logger.info(f"SynologyClient init: base_url={self.base_url}") async def login(self) -> bool: """Authenticate and get session ID.""" try: - url = f"{self.base_url}/webapi/auth.cgi" + url = f"{self.base_url}/webapi/entry.cgi" params = { "api": "SYNO.API.Auth", "version": "6", @@ -32,17 +45,20 @@ class SynologyClient: "session": "VPSMonitor", "format": "sid", } + logger.info(f"Synology login attempt: {url}") async with aiohttp.ClientSession() as session: async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10), ssl=False) as resp: - data = await resp.json() + # content_type=None — Synology may return JSON as text/plain + data = await resp.json(content_type=None) if data.get("success"): self.sid = data["data"]["sid"] + logger.info("Synology login OK") return True logger.error(f"Synology login failed: {data}") return False except Exception as e: - logger.error(f"Synology login error: {e}") + logger.error(f"Synology login error: {type(e).__name__}: {e}\n{traceback.format_exc()}") return False async def _api_call(self, cgi: str, api: str, version: str = "1", @@ -66,7 +82,7 @@ class SynologyClient: async with aiohttp.ClientSession() as session: async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15), ssl=False) as resp: - data = await resp.json() + data = await resp.json(content_type=None) if data.get("success"): return data.get("data", {}) # Session expired? Re-login once @@ -75,12 +91,13 @@ class SynologyClient: if await self.login(): params["_sid"] = self.sid async with session.get(url, params=params, ssl=False) as resp2: - data2 = await resp2.json() + data2 = await resp2.json(content_type=None) if data2.get("success"): return data2.get("data", {}) + logger.warning(f"Synology API {api} returned: {data}") return None except Exception as e: - logger.error(f"Synology API error ({api}): {e}") + logger.error(f"Synology API error ({api}): {type(e).__name__}: {e}") return None async def get_system_info(self) -> Optional[dict]: @@ -124,6 +141,7 @@ class SynologyClient: """Collect all metrics in one go.""" result = { "online": False, + "error": "", "system": {}, "cpu_percent": 0, "ram_percent": 0, @@ -139,16 +157,29 @@ class SynologyClient: "dsm_version": "", } + # Login first to get clear error + if not self.sid: + try: + logged_in = await self.login() + except Exception as e: + result["error"] = f"Login error: {type(e).__name__}" + return result + if not logged_in: + result["error"] = "Login failed — check host/port/credentials" + return result + # System info sys_info = await self.get_system_info() if sys_info is None: + result["error"] = "Failed to get system info" return result result["online"] = True result["model"] = sys_info.get("model", "") - result["dsm_version"] = f"DSM {sys_info.get('version_string', '')}" + ver_str = sys_info.get("version_string", "") + result["dsm_version"] = ver_str if ver_str.startswith("DSM") else f"DSM {ver_str}" result["temperature"] = sys_info.get("temperature", 0) - result["uptime"] = _format_uptime(sys_info.get("up_time", 0)) + result["uptime"] = _format_uptime(sys_info.get("uptime", sys_info.get("up_time", 0))) # CPU / Memory util = await self.get_cpu_memory() @@ -173,8 +204,8 @@ class SynologyClient: storage = await self.get_storage() if storage: for vol in storage.get("volumes", []): - total_bytes = vol.get("size", {}).get("total", 0) - used_bytes = vol.get("size", {}).get("used", 0) + total_bytes = int(vol.get("size", {}).get("total", 0) or 0) + used_bytes = int(vol.get("size", {}).get("used", 0) or 0) total_gb = total_bytes / (1024 ** 3) if total_bytes else 0 used_gb = used_bytes / (1024 ** 3) if used_bytes else 0 percent = round(used_gb / total_gb * 100, 1) if total_gb > 0 else 0 @@ -225,7 +256,7 @@ class SynologyClient: """Close session.""" if self.sid: try: - await self._api_call("auth.cgi", "SYNO.API.Auth", version="6", method="logout") + await self._api_call("entry.cgi", "SYNO.API.Auth", version="6", method="logout") except Exception: pass self.sid = None diff --git a/server/services/telegram_bot.py b/server/services/telegram_bot.py index 808071c..162f6ef 100644 --- a/server/services/telegram_bot.py +++ b/server/services/telegram_bot.py @@ -20,7 +20,6 @@ bot_app: Optional[Application] = None async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - settings = load_settings() servers = load_servers() metrics = get_all_metrics() @@ -28,17 +27,17 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): offline = len(servers) - online text = ( - f"🖥 **VPS Monitoring**\n\n" - f"Серверов: {len(servers)}\n" + f"🖥 *VPS Monitoring*\n\n" + f"Servers: {len(servers)}\n" f"🟢 Online: {online}\n" f"🔴 Offline: {offline}\n\n" - f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}" + f"Updated: {datetime.now().strftime('%H:%M:%S')}" ) keyboard = [ - [InlineKeyboardButton("📊 Статус серверов", callback_data="status")], - [InlineKeyboardButton("🔄 Обновить", callback_data="refresh")], - [InlineKeyboardButton("⚙️ Управление", callback_data="manage")], + [InlineKeyboardButton("📊 Full Status", callback_data="fullstatus")], + [InlineKeyboardButton("🔄 Refresh", callback_data="refresh")], + [InlineKeyboardButton("⚙️ Manage", callback_data="manage")], ] # WebApp button (HTTPS via nip.io for valid SSL) @@ -57,6 +56,32 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(text, reply_markup=reply_markup, parse_mode="Markdown") +async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /status command - full status of all systems.""" + from server.services.alerter import get_full_status + text = await get_full_status() + keyboard = [[InlineKeyboardButton("🔄 Refresh", callback_data="fullstatus")]] + await update.message.reply_text( + text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown" + ) + + +async def fullstatus_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Show full status across all 4 tabs.""" + query = update.callback_query + await query.answer() + + from server.services.alerter import get_full_status + text = await get_full_status() + keyboard = [ + [InlineKeyboardButton("🔄 Refresh", callback_data="fullstatus")], + [InlineKeyboardButton("◀️ Back", callback_data="back")], + ] + await query.edit_message_text( + text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown" + ) + + async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query await query.answer() @@ -65,10 +90,10 @@ async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): metrics = get_all_metrics() if not servers: - await query.edit_message_text("Нет добавленных серверов") + await query.edit_message_text("No servers added") return - text = "📊 **Статус серверов:**\n\n" + text = "📊 *VPS Servers:*\n\n" for srv in servers: m = metrics.get(srv["host"], {}) status = "🟢" if m.get("online") else "🔴" @@ -76,13 +101,13 @@ async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): ram = m.get("ram_percent", 0) disk = m.get("disk_percent", 0) text += ( - f"{status} **{srv['name']}** ({srv['host']})\n" + f"{status} *{srv['name']}* ({srv['host']})\n" f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n" ) keyboard = [ - [InlineKeyboardButton("🔄 Обновить", callback_data="status")], - [InlineKeyboardButton("◀️ Назад", callback_data="back")], + [InlineKeyboardButton("🔄 Refresh", callback_data="status")], + [InlineKeyboardButton("◀️ Back", callback_data="back")], ] await query.edit_message_text( text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown" @@ -281,14 +306,20 @@ async def start_bot(): bot_app = Application.builder().token(token).build() bot_app.add_handler(CommandHandler("start", start_command)) + bot_app.add_handler(CommandHandler("status", status_command)) + bot_app.add_handler(CallbackQueryHandler(fullstatus_callback, pattern="^fullstatus$")) bot_app.add_handler(CallbackQueryHandler(status_callback, pattern="^status$")) bot_app.add_handler(CallbackQueryHandler(manage_callback, pattern="^manage$")) bot_app.add_handler(CallbackQueryHandler(server_action_callback, pattern="^(server_|reboot_|confirm_reboot_|refresh_|refresh$|back$)")) - await bot_app.initialize() - await bot_app.start() - await bot_app.updater.start_polling(drop_pending_updates=True) - logger.info("Telegram bot started") + try: + await bot_app.initialize() + await bot_app.start() + await bot_app.updater.start_polling(drop_pending_updates=True) + logger.info("Telegram bot started") + except Exception as e: + logger.error(f"Telegram bot failed to start: {e}") + bot_app = None async def stop_bot(): diff --git a/windows/install_agent.ps1 b/windows/install_agent.ps1 index 66c96c0..01030ff 100644 --- a/windows/install_agent.ps1 +++ b/windows/install_agent.ps1 @@ -1,5 +1,5 @@ -# VPS Monitor — Windows Agent Installer -# Usage: powershell -ExecutionPolicy Bypass -File install_agent.ps1 -ServerUrl "http://IP:7272" -AgentName "MyPC" +# VPS Monitor - Windows Agent Installer +# Usage (PowerShell Admin): .\install_agent.ps1 -ServerUrl "http://IP" -AgentName "MyPC" param( [Parameter(Mandatory=$true)] @@ -14,7 +14,7 @@ $InstallDir = "C:\VPS-Monitor" $TaskName = "VPS-Monitor-Agent" Write-Host "================================================" -ForegroundColor Cyan -Write-Host " VPS Monitor — Agent Installer" -ForegroundColor Cyan +Write-Host " VPS Monitor - Agent Installer" -ForegroundColor Cyan Write-Host "================================================" -ForegroundColor Cyan Write-Host "" Write-Host " Server: $ServerUrl" -ForegroundColor White @@ -32,15 +32,17 @@ if (-not (Test-Path $InstallDir)) { # Download agent script Write-Host "[*] Downloading agent..." -ForegroundColor Yellow $AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1" +$AgentDest = "$InstallDir\vps_monitor_agent.ps1" try { - Invoke-WebRequest -Uri $AgentUrl -OutFile "$InstallDir\vps_monitor_agent.ps1" -UseBasicParsing + $wc = New-Object System.Net.WebClient + $wc.Encoding = [System.Text.Encoding]::UTF8 + $wc.DownloadFile($AgentUrl, $AgentDest) Write-Host "[+] Agent downloaded" -ForegroundColor Green } catch { - Write-Host "[!] Download failed, using local copy..." -ForegroundColor Yellow - # Fallback: copy from same directory + Write-Host "[!] Download failed: $($_.Exception.Message)" -ForegroundColor Yellow $localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1" if (Test-Path $localAgent) { - Copy-Item $localAgent "$InstallDir\vps_monitor_agent.ps1" + Copy-Item $localAgent $AgentDest Write-Host "[+] Local copy used" -ForegroundColor Green } else { Write-Host "[-] No agent script found!" -ForegroundColor Red @@ -55,10 +57,10 @@ $config = @{ interval = $Interval } | ConvertTo-Json -Set-Content -Path "$InstallDir\agent_config.json" -Value $config +Set-Content -Path "$InstallDir\agent_config.json" -Value $config -Encoding UTF8 Write-Host "[+] Config saved" -ForegroundColor Green -# Create scheduled task (run at startup + every 5 min check) +# Create scheduled task $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($existingTask) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false @@ -71,7 +73,6 @@ $action = New-ScheduledTaskAction ` -WorkingDirectory $InstallDir $triggerStartup = New-ScheduledTaskTrigger -AtStartup -$triggerNow = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1) $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest @@ -88,7 +89,7 @@ Register-ScheduledTask ` -Trigger $triggerStartup ` -Principal $principal ` -Settings $settings ` - -Description "VPS Monitor PC Agent — sends metrics to $ServerUrl" | Out-Null + -Description "VPS Monitor PC Agent" | Out-Null Write-Host "[+] Scheduled task created" -ForegroundColor Green @@ -106,9 +107,9 @@ Write-Host " Task: $TaskName" -ForegroundColor White Write-Host " Status: Running" -ForegroundColor White Write-Host "" Write-Host " Commands:" -ForegroundColor Gray -Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray -Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray -Write-Host " Start: Start-ScheduledTask '$TaskName'" -ForegroundColor Gray -Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray -Write-Host " Logs: type $InstallDir\agent_config.json" -ForegroundColor Gray +Write-Host " Check: Get-ScheduledTask $TaskName" -ForegroundColor Gray +Write-Host " Stop: Stop-ScheduledTask $TaskName" -ForegroundColor Gray +Write-Host " Start: Start-ScheduledTask $TaskName" -ForegroundColor Gray +Write-Host " Remove: Unregister-ScheduledTask $TaskName" -ForegroundColor Gray +Write-Host " Config: type $InstallDir\agent_config.json" -ForegroundColor Gray Write-Host "" diff --git a/windows/synology_tunnel.ps1 b/windows/synology_tunnel.ps1 new file mode 100644 index 0000000..805f5ae --- /dev/null +++ b/windows/synology_tunnel.ps1 @@ -0,0 +1,166 @@ +# VPS Monitor — Synology SSH Tunnel +# Creates a reverse SSH tunnel so VPS can reach local Synology NAS +# +# Usage: .\synology_tunnel.ps1 -ServerUrl "http://IP" -LocalTarget "192.168.88.6:5000" -VpsPort 15000 + +param( + [string]$ServerUrl = "", + [string]$LocalTarget = "192.168.88.6:5000", + [int]$VpsPort = 15000, + [switch]$Install +) + +$InstallDir = "C:\VPS-Monitor" +$TaskName = "VPS-Monitor-SynologyTunnel" +$KeyFile = "$InstallDir\synology_tunnel_key" +$ConfigFile = "$InstallDir\tunnel_config.json" + +# Load config if exists +if (Test-Path $ConfigFile) { + $config = Get-Content $ConfigFile | ConvertFrom-Json + if (-not $ServerUrl) { $ServerUrl = $config.server_url } + if ($config.local_target) { $LocalTarget = $config.local_target } + if ($config.vps_port) { $VpsPort = $config.vps_port } +} + +if (-not $ServerUrl) { + Write-Host "ERROR: -ServerUrl required" -ForegroundColor Red + exit 1 +} + +# Extract VPS IP from ServerUrl +$VpsHost = ($ServerUrl -replace "https?://", "") -replace ":[0-9]+$", "" + +Write-Host "================================================" -ForegroundColor Cyan +Write-Host " VPS Monitor — Synology Tunnel" -ForegroundColor Cyan +Write-Host "================================================" -ForegroundColor Cyan +Write-Host " VPS: $VpsHost" -ForegroundColor White +Write-Host " Target: $LocalTarget" -ForegroundColor White +Write-Host " Port: $VpsPort (on VPS)" -ForegroundColor White +Write-Host "" + +# Ensure install directory +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +# Download SSH key from VPS +if (-not (Test-Path $KeyFile)) { + Write-Host "[*] Downloading tunnel SSH key..." -ForegroundColor Yellow + try { + $wc = New-Object System.Net.WebClient + $wc.DownloadFile("$ServerUrl/static/downloads/synology_tunnel_key", $KeyFile) + Write-Host "[+] Key downloaded" -ForegroundColor Green + } catch { + Write-Host "[!] Key download failed: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " Make sure you're logged into the dashboard first" -ForegroundColor Yellow + exit 1 + } + + # Fix key permissions (Windows — icacls) + icacls $KeyFile /inheritance:r /grant:r "${env:USERNAME}:(R)" 2>$null | Out-Null +} + +# Save config +$tunnelConfig = @{ + server_url = $ServerUrl + local_target = $LocalTarget + vps_port = $VpsPort + vps_host = $VpsHost +} | ConvertTo-Json +Set-Content -Path $ConfigFile -Value $tunnelConfig + +# Install mode — create scheduled task +if ($Install) { + Write-Host "[*] Creating scheduled task..." -ForegroundColor Yellow + + $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($existingTask) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + } + + $action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$InstallDir\synology_tunnel.ps1`"" ` + -WorkingDirectory $InstallDir + + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -RestartCount 999 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit (New-TimeSpan -Days 365) + + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description "VPS Monitor — SSH tunnel to Synology NAS via $VpsHost" | Out-Null + + Write-Host "[+] Scheduled task created: $TaskName" -ForegroundColor Green + Start-ScheduledTask -TaskName $TaskName + Write-Host "[+] Tunnel started!" -ForegroundColor Green + Write-Host "" + Write-Host " Commands:" -ForegroundColor Gray + Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray + Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray + Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray + exit 0 +} + +# Main loop — maintain SSH tunnel +Write-Host "[*] Starting tunnel loop..." -ForegroundColor Green +Write-Host " VPS:$VpsPort -> Local:$LocalTarget" -ForegroundColor Gray +Write-Host "" + +$LocalIP = $LocalTarget.Split(":")[0] +$LocalPort = $LocalTarget.Split(":")[1] + +while ($true) { + $ts = Get-Date -Format "HH:mm:ss" + + # Check if ssh.exe available (Windows 10+) + $sshExe = Get-Command ssh.exe -ErrorAction SilentlyContinue + + if ($sshExe) { + Write-Host "[$ts] Connecting via ssh.exe..." -ForegroundColor Cyan + # ssh -N = no command, -R = reverse tunnel, -o = options for stability + & ssh.exe -N ` + -o "StrictHostKeyChecking=no" ` + -o "ServerAliveInterval=30" ` + -o "ServerAliveCountMax=3" ` + -o "ExitOnForwardFailure=yes" ` + -o "BatchMode=yes" ` + -i $KeyFile ` + -R "127.0.0.1:${VpsPort}:${LocalIP}:${LocalPort}" ` + "root@${VpsHost}" 2>&1 + + $exitCode = $LASTEXITCODE + Write-Host "[$ts] SSH disconnected (exit: $exitCode)" -ForegroundColor Yellow + } else { + # Fallback to plink + $plinkPath = "$InstallDir\plink.exe" + if (-not (Test-Path $plinkPath)) { + Write-Host "[$ts] No ssh.exe or plink.exe found. Install OpenSSH or download plink." -ForegroundColor Red + Start-Sleep -Seconds 60 + continue + } + + Write-Host "[$ts] Connecting via plink..." -ForegroundColor Cyan + echo y | & $plinkPath -N -batch ` + -i $KeyFile ` + -R "127.0.0.1:${VpsPort}:${LocalIP}:${LocalPort}" ` + "root@${VpsHost}" 2>&1 + + Write-Host "[$ts] Plink disconnected" -ForegroundColor Yellow + } + + # Wait before reconnect + Write-Host "[$ts] Reconnecting in 10s..." -ForegroundColor Gray + Start-Sleep -Seconds 10 +} diff --git a/windows/vps_monitor_agent.ps1 b/windows/vps_monitor_agent.ps1 index 7aa712b..4336c5a 100644 --- a/windows/vps_monitor_agent.ps1 +++ b/windows/vps_monitor_agent.ps1 @@ -1,4 +1,4 @@ -# VPS Monitor — Windows PC Agent +# VPS Monitor - Windows PC Agent # Sends system metrics to VPS Monitoring server # # Install: Run install_agent.ps1 @@ -13,7 +13,7 @@ param( # Load config $ConfigPath = Join-Path $PSScriptRoot "agent_config.json" if (Test-Path $ConfigPath) { - $config = Get-Content $ConfigPath | ConvertFrom-Json + $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json if (-not $ServerUrl) { $ServerUrl = $config.server_url } if (-not $AgentName) { $AgentName = $config.agent_name } if ($config.interval) { $Interval = $config.interval } @@ -26,7 +26,7 @@ if (-not $ServerUrl -or -not $AgentName) { } Write-Host "================================================" -ForegroundColor Cyan -Write-Host " VPS Monitor — PC Agent" -ForegroundColor Cyan +Write-Host " VPS Monitor - PC Agent" -ForegroundColor Cyan Write-Host " Server: $ServerUrl" -ForegroundColor Gray Write-Host " Name: $AgentName" -ForegroundColor Gray Write-Host " Interval: ${Interval}s" -ForegroundColor Gray @@ -44,9 +44,19 @@ function Get-SystemMetrics { $metrics["os"] = "$($os.Caption) $($os.Version)" # CPU - $cpu = Get-CimInstance Win32_Processor - $metrics["cpu_name"] = $cpu.Name - $metrics["cpu_percent"] = [math]::Round((Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue, 1) + try { + $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 + $metrics["cpu_name"] = $cpu.Name + $counter = Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue + if ($counter) { + $metrics["cpu_percent"] = [math]::Round($counter.CounterSamples[0].CookedValue, 1) + } else { + $metrics["cpu_percent"] = 0 + } + } catch { + $metrics["cpu_percent"] = 0 + $metrics["cpu_name"] = "Unknown" + } # RAM $totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0) @@ -82,18 +92,24 @@ function Get-SystemMetrics { } # Network - $adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" } - $netInfo = @() - foreach ($a in $adapters) { - $stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue - $netInfo += @{ - name = $a.Name - speed_mbps = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0) - sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 0 } - received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 } + try { + $adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" } + $netInfo = @() + foreach ($a in $adapters) { + $stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue + $speed = 0 + try { $speed = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0) } catch {} + $netInfo += @{ + name = $a.Name + speed_mbps = $speed + sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 0 } + received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 } + } } + $metrics["network"] = $netInfo + } catch { + $metrics["network"] = @() } - $metrics["network"] = $netInfo # Uptime $uptime = (Get-Date) - $os.LastBootUpTime @@ -110,9 +126,13 @@ function Get-SystemMetrics { } catch {} # Top processes by CPU - $topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 | - ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } } - $metrics["top_processes"] = $topProcs + try { + $topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 | + ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } } + $metrics["top_processes"] = $topProcs + } catch { + $metrics["top_processes"] = @() + } return $metrics } @@ -125,12 +145,10 @@ function Send-Metrics($metrics) { } | ConvertTo-Json -Depth 5 try { - $response = Invoke-RestMethod -Uri "$ServerUrl/api/pc/heartbeat" ` - -Method POST ` - -Body $body ` - -ContentType "application/json" ` - -TimeoutSec 15 - + $wc = New-Object System.Net.WebClient + $wc.Headers.Add("Content-Type", "application/json") + $wc.Encoding = [System.Text.Encoding]::UTF8 + $null = $wc.UploadString("$ServerUrl/api/pc/heartbeat", "POST", $body) return $true } catch { Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow @@ -150,7 +168,7 @@ while ($true) { $ok = Send-Metrics $metrics if ($ok) { - Write-Host "[$ts] OK — CPU: $($metrics.cpu_percent)% RAM: $($metrics.ram_percent)% Disk: $($metrics.disk_percent)%" -ForegroundColor Green + Write-Host "[$ts] OK - CPU: $($metrics.cpu_percent)% RAM: $($metrics.ram_percent)% Disk: $($metrics.disk_percent)%" -ForegroundColor Green } } catch { Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red