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

7
.env.example Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
venv/
.env
data/*.json.bak
*.log

195
README.md
View File

@@ -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

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}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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 ""

166
windows/synology_tunnel.ps1 Normal file
View File

@@ -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
}

View File

@@ -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