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 - **VPS** — CPU, RAM, Disk, Network, Uptime, Load Average, SSH-терминал
- **SSH терминал** — подключение к серверам прямо из браузера - **Keenetic** — мониторинг роутеров через KeenDNS/RCI API, VPN-туннели, AnyDesk-ссылки
- **Telegram бот** — статус, алерты, управление (перезагрузка) - **Synology / HA / PC** — дополнительные вкладки мониторинга
- **Telegram Mini App** — мобильная панель прямо в Telegram - **Telegram** — бот, Mini App, алерты (offline, CPU/RAM, VPN down 5+ мин)
- **Алерты** — уведомления при превышении порогов (CPU/RAM/Disk) - **KeenDNS edit** — изменение web URL прямо из карточки роутера
- **Управление** — добавление/удаление серверов, перезагрузка
- **Авторизация** — защита паролем с возможностью смены
## Установка (одна команда) ## Установка
```bash ```bash
curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | 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+ - Ubuntu 20.04+ / Debian 11+
- Python 3.10+ - Python 3.10+
- 512 MB RAM минимум - 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` - **Панель:** `http://YOUR_IP:7272`
- **Логин:** `admin` / `admin` - **Логин:** `admin` / `admin` (смените в настройках)
- **Telegram:** бот запускается автоматически - **Health:** `GET /api/health`
## Управление
```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)
```
## Переменные окружения ## Переменные окружения
| Переменная | Описание | | Переменная | Описание |
|-----------|----------| |-----------|----------|
| `TELEGRAM_BOT_TOKEN` | Токен Telegram бота | | `TELEGRAM_BOT_TOKEN` | Токен Telegram-бота |
| `TELEGRAM_CHAT_ID` | ID чата для алертов | | `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** **@Iot_andrey** — [GitHub](https://github.com/andrey271192) · [Telegram](https://t.me/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)
## Лицензия
MIT 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} 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") @router.post("/generate-pc-agent")
async def generate_pc_agent(request: Request, user: str = Depends(require_auth)): async def generate_pc_agent(request: Request, user: str = Depends(require_auth)):
"""Generate personalized PC agent install command.""" """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() server_url = body.get("server_url", "").strip()
if not server_url: if not server_url:
# Auto-detect # Auto-detect: use IP on port 80 (HTTP, works on all Windows)
host = request.headers.get("host", "localhost:7272") host = request.headers.get("host", "localhost")
proto = "http" # Strip port if present, use plain HTTP port 80
server_url = f"{proto}://{host}" host_ip = host.split(":")[0]
server_url = f"http://{host_ip}"
cmd = ( # Command for use directly inside PowerShell (Admin)
f'powershell -ExecutionPolicy Bypass -Command ' # Uses HTTP port 80 (nginx proxy) — no SSL issues on old PowerShell
f'"Invoke-WebRequest -Uri \'{server_url}/static/downloads/install_agent.ps1\' ' ps_cmd = (
f'-OutFile install_agent.ps1; .\\install_agent.ps1 ' f"Set-ExecutionPolicy Bypass -Scope Process -Force; "
f'-ServerUrl \'{server_url}\' -AgentName \'{agent_name}\'"' 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.""" """Synology NAS monitoring API endpoints."""
import json import json
import os
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import PlainTextResponse
from server.auth import require_auth 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"]) 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 # In-memory cache
synology_metrics: Dict[str, dict] = {} 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() metrics["last_updated"] = datetime.now().isoformat()
synology_metrics[name] = metrics synology_metrics[name] = metrics
await client.logout() await client.logout()
if not metrics["online"] and metrics.get("error"):
return {"status": "error", "detail": metrics["error"], "metrics": metrics}
return {"status": "ok", "metrics": metrics} return {"status": "ok", "metrics": metrics}
except Exception as e: except Exception as e:
return {"status": "error", "detail": str(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)}) results.append({"name": dev["name"], "online": False, "error": str(e)})
return {"status": "ok", "results": results} 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.pc import router as pc_router
from server.api.synology import router as synology_router from server.api.synology import router as synology_router
from server.api.ha import router as ha_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.api.notifications import router as notifications_router
from server.services.monitor import monitor_loop from server.services.monitor import monitor_loop
from server.services.telegram_bot import start_bot, stop_bot from server.services.telegram_bot import start_bot, stop_bot
@@ -30,6 +31,7 @@ logger = logging.getLogger(__name__)
monitor_task = None monitor_task = None
alert_task = None alert_task = None
keenetic_task = None
async def alert_loop(): async def alert_loop():
@@ -44,18 +46,19 @@ async def alert_loop():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global monitor_task, alert_task global monitor_task, alert_task, keenetic_task
# Start background tasks
monitor_task = asyncio.create_task(monitor_loop()) monitor_task = asyncio.create_task(monitor_loop())
alert_task = asyncio.create_task(alert_loop()) alert_task = asyncio.create_task(alert_loop())
keenetic_task = asyncio.create_task(keenetic_monitor_loop())
await start_bot() await start_bot()
logger.info("VPS Monitoring started") logger.info("VPS Monitoring started")
yield yield
# Cleanup
if monitor_task: if monitor_task:
monitor_task.cancel() monitor_task.cancel()
if alert_task: if alert_task:
alert_task.cancel() alert_task.cancel()
if keenetic_task:
keenetic_task.cancel()
await stop_bot() await stop_bot()
@@ -79,6 +82,7 @@ app.include_router(ssh_router)
app.include_router(pc_router) app.include_router(pc_router)
app.include_router(synology_router) app.include_router(synology_router)
app.include_router(ha_router) app.include_router(ha_router)
app.include_router(keenetic_router)
app.include_router(notifications_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 import logging
from datetime import datetime 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 from server.services.monitor import get_all_metrics
logger = logging.getLogger(__name__) 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(): async def check_alerts():
"""Check metrics against thresholds and send alerts.""" """Check all 5 tabs for issues."""
from server.services.notifier import send_notification
from server.api.auth_routes import mute_until from server.api.auth_routes import mute_until
# Check mute # Check global mute
if mute_until and datetime.now() < mute_until: if mute_until and datetime.now() < mute_until:
return return
settings = load_settings() 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() servers = load_servers()
metrics = get_all_metrics() metrics = get_all_metrics()
@@ -29,48 +129,381 @@ async def check_alerts():
for srv in servers: for srv in servers:
host = srv["host"] host = srv["host"]
m = metrics.get(host, {})
name = srv.get("name", host) name = srv.get("name", host)
m = metrics.get(host, {})
online = m.get("online", False)
current_online = m.get("online", False) # Online/Offline
prev_online = previous_states.get(host) await _check_issue(
"servers", name, "offline",
# Online/Offline state change is_problem=not online,
if prev_online is not None and prev_online != current_online: alert_msg=f"🔴 *{name}* ({host}) - OFFLINE!",
if current_online: resolve_msg=f"🟢 *{name}* ({host}) - back online",
await send_notification( subject=f"{name} offline",
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 online:
if not current_online:
continue continue
# Threshold alerts # CPU
cpu = m.get("cpu_percent", 0) cpu = m.get("cpu_percent", 0)
if cpu >= cpu_threshold: await _check_issue(
await send_notification( "servers", name, "cpu_high",
f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)", is_problem=cpu >= cpu_threshold,
subject=f"{name} CPU {cpu}%", category="servers" 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) ram = m.get("ram_percent", 0)
if ram >= ram_threshold: await _check_issue(
await send_notification( "servers", name, "ram_high",
f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)", is_problem=ram >= ram_threshold,
subject=f"{name} RAM {ram}%", category="servers" 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) disk = m.get("disk_percent", 0)
if disk >= disk_threshold: await _check_issue(
await send_notification( "servers", name, "disk_high",
f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)", is_problem=disk >= disk_threshold,
subject=f"{name} Disk {disk}%", category="servers" 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 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, "text": message,
"parse_mode": parse_mode, "parse_mode": parse_mode,
}) as resp: }) 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: except Exception as e:
logger.error(f"Telegram send error: {e}") logger.error(f"Telegram send error: {e}")
return False return False

View File

@@ -1,6 +1,7 @@
"""Synology DSM API client for NAS monitoring.""" """Synology DSM API client for NAS monitoring."""
import logging import logging
import traceback
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
import aiohttp import aiohttp
@@ -13,16 +14,28 @@ class SynologyClient:
def __init__(self, host: str, port: int = 5000, https: bool = False, def __init__(self, host: str, port: int = 5000, https: bool = False,
username: str = "", password: str = ""): 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" proto = "https" if https else "http"
self.base_url = f"{proto}://{host}:{port}" self.base_url = f"{proto}://{host}:{port}"
self.username = username self.username = username
self.password = password self.password = password
self.sid: Optional[str] = None self.sid: Optional[str] = None
logger.info(f"SynologyClient init: base_url={self.base_url}")
async def login(self) -> bool: async def login(self) -> bool:
"""Authenticate and get session ID.""" """Authenticate and get session ID."""
try: try:
url = f"{self.base_url}/webapi/auth.cgi" url = f"{self.base_url}/webapi/entry.cgi"
params = { params = {
"api": "SYNO.API.Auth", "api": "SYNO.API.Auth",
"version": "6", "version": "6",
@@ -32,17 +45,20 @@ class SynologyClient:
"session": "VPSMonitor", "session": "VPSMonitor",
"format": "sid", "format": "sid",
} }
logger.info(f"Synology login attempt: {url}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10), async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10),
ssl=False) as resp: 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"): if data.get("success"):
self.sid = data["data"]["sid"] self.sid = data["data"]["sid"]
logger.info("Synology login OK")
return True return True
logger.error(f"Synology login failed: {data}") logger.error(f"Synology login failed: {data}")
return False return False
except Exception as e: 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 return False
async def _api_call(self, cgi: str, api: str, version: str = "1", 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 aiohttp.ClientSession() as session:
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15), async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15),
ssl=False) as resp: ssl=False) as resp:
data = await resp.json() data = await resp.json(content_type=None)
if data.get("success"): if data.get("success"):
return data.get("data", {}) return data.get("data", {})
# Session expired? Re-login once # Session expired? Re-login once
@@ -75,12 +91,13 @@ class SynologyClient:
if await self.login(): if await self.login():
params["_sid"] = self.sid params["_sid"] = self.sid
async with session.get(url, params=params, ssl=False) as resp2: 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"): if data2.get("success"):
return data2.get("data", {}) return data2.get("data", {})
logger.warning(f"Synology API {api} returned: {data}")
return None return None
except Exception as e: 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 return None
async def get_system_info(self) -> Optional[dict]: async def get_system_info(self) -> Optional[dict]:
@@ -124,6 +141,7 @@ class SynologyClient:
"""Collect all metrics in one go.""" """Collect all metrics in one go."""
result = { result = {
"online": False, "online": False,
"error": "",
"system": {}, "system": {},
"cpu_percent": 0, "cpu_percent": 0,
"ram_percent": 0, "ram_percent": 0,
@@ -139,16 +157,29 @@ class SynologyClient:
"dsm_version": "", "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 # System info
sys_info = await self.get_system_info() sys_info = await self.get_system_info()
if sys_info is None: if sys_info is None:
result["error"] = "Failed to get system info"
return result return result
result["online"] = True result["online"] = True
result["model"] = sys_info.get("model", "") 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["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 # CPU / Memory
util = await self.get_cpu_memory() util = await self.get_cpu_memory()
@@ -173,8 +204,8 @@ class SynologyClient:
storage = await self.get_storage() storage = await self.get_storage()
if storage: if storage:
for vol in storage.get("volumes", []): for vol in storage.get("volumes", []):
total_bytes = vol.get("size", {}).get("total", 0) total_bytes = int(vol.get("size", {}).get("total", 0) or 0)
used_bytes = vol.get("size", {}).get("used", 0) used_bytes = int(vol.get("size", {}).get("used", 0) or 0)
total_gb = total_bytes / (1024 ** 3) if total_bytes else 0 total_gb = total_bytes / (1024 ** 3) if total_bytes else 0
used_gb = used_bytes / (1024 ** 3) if used_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 percent = round(used_gb / total_gb * 100, 1) if total_gb > 0 else 0
@@ -225,7 +256,7 @@ class SynologyClient:
"""Close session.""" """Close session."""
if self.sid: if self.sid:
try: 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: except Exception:
pass pass
self.sid = None self.sid = None

View File

@@ -20,7 +20,6 @@ bot_app: Optional[Application] = None
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
settings = load_settings()
servers = load_servers() servers = load_servers()
metrics = get_all_metrics() metrics = get_all_metrics()
@@ -28,17 +27,17 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
offline = len(servers) - online offline = len(servers) - online
text = ( text = (
f"🖥 **VPS Monitoring**\n\n" f"🖥 *VPS Monitoring*\n\n"
f"Серверов: {len(servers)}\n" f"Servers: {len(servers)}\n"
f"🟢 Online: {online}\n" f"🟢 Online: {online}\n"
f"🔴 Offline: {offline}\n\n" f"🔴 Offline: {offline}\n\n"
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}" f"Updated: {datetime.now().strftime('%H:%M:%S')}"
) )
keyboard = [ keyboard = [
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")], [InlineKeyboardButton("📊 Full Status", callback_data="fullstatus")],
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")], [InlineKeyboardButton("🔄 Refresh", callback_data="refresh")],
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")], [InlineKeyboardButton("⚙️ Manage", callback_data="manage")],
] ]
# WebApp button (HTTPS via nip.io for valid SSL) # 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") 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): async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query query = update.callback_query
await query.answer() await query.answer()
@@ -65,10 +90,10 @@ async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
metrics = get_all_metrics() metrics = get_all_metrics()
if not servers: if not servers:
await query.edit_message_text("Нет добавленных серверов") await query.edit_message_text("No servers added")
return return
text = "📊 **Статус серверов:**\n\n" text = "📊 *VPS Servers:*\n\n"
for srv in servers: for srv in servers:
m = metrics.get(srv["host"], {}) m = metrics.get(srv["host"], {})
status = "🟢" if m.get("online") else "🔴" 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) ram = m.get("ram_percent", 0)
disk = m.get("disk_percent", 0) disk = m.get("disk_percent", 0)
text += ( text += (
f"{status} **{srv['name']}** ({srv['host']})\n" f"{status} *{srv['name']}* ({srv['host']})\n"
f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n" f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n"
) )
keyboard = [ keyboard = [
[InlineKeyboardButton("🔄 Обновить", callback_data="status")], [InlineKeyboardButton("🔄 Refresh", callback_data="status")],
[InlineKeyboardButton("◀️ Назад", callback_data="back")], [InlineKeyboardButton("◀️ Back", callback_data="back")],
] ]
await query.edit_message_text( await query.edit_message_text(
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown" 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 = Application.builder().token(token).build()
bot_app.add_handler(CommandHandler("start", start_command)) 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(status_callback, pattern="^status$"))
bot_app.add_handler(CallbackQueryHandler(manage_callback, pattern="^manage$")) 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$)")) bot_app.add_handler(CallbackQueryHandler(server_action_callback, pattern="^(server_|reboot_|confirm_reboot_|refresh_|refresh$|back$)"))
try:
await bot_app.initialize() await bot_app.initialize()
await bot_app.start() await bot_app.start()
await bot_app.updater.start_polling(drop_pending_updates=True) await bot_app.updater.start_polling(drop_pending_updates=True)
logger.info("Telegram bot started") 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(): async def stop_bot():

View File

@@ -1,5 +1,5 @@
# VPS Monitor Windows Agent Installer # VPS Monitor - Windows Agent Installer
# Usage: powershell -ExecutionPolicy Bypass -File install_agent.ps1 -ServerUrl "http://IP:7272" -AgentName "MyPC" # Usage (PowerShell Admin): .\install_agent.ps1 -ServerUrl "http://IP" -AgentName "MyPC"
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
@@ -14,7 +14,7 @@ $InstallDir = "C:\VPS-Monitor"
$TaskName = "VPS-Monitor-Agent" $TaskName = "VPS-Monitor-Agent"
Write-Host "================================================" -ForegroundColor Cyan 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 "================================================" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Host " Server: $ServerUrl" -ForegroundColor White Write-Host " Server: $ServerUrl" -ForegroundColor White
@@ -32,15 +32,17 @@ if (-not (Test-Path $InstallDir)) {
# Download agent script # Download agent script
Write-Host "[*] Downloading agent..." -ForegroundColor Yellow Write-Host "[*] Downloading agent..." -ForegroundColor Yellow
$AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1" $AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1"
$AgentDest = "$InstallDir\vps_monitor_agent.ps1"
try { 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 Write-Host "[+] Agent downloaded" -ForegroundColor Green
} catch { } catch {
Write-Host "[!] Download failed, using local copy..." -ForegroundColor Yellow Write-Host "[!] Download failed: $($_.Exception.Message)" -ForegroundColor Yellow
# Fallback: copy from same directory
$localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1" $localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1"
if (Test-Path $localAgent) { if (Test-Path $localAgent) {
Copy-Item $localAgent "$InstallDir\vps_monitor_agent.ps1" Copy-Item $localAgent $AgentDest
Write-Host "[+] Local copy used" -ForegroundColor Green Write-Host "[+] Local copy used" -ForegroundColor Green
} else { } else {
Write-Host "[-] No agent script found!" -ForegroundColor Red Write-Host "[-] No agent script found!" -ForegroundColor Red
@@ -55,10 +57,10 @@ $config = @{
interval = $Interval interval = $Interval
} | ConvertTo-Json } | 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 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 $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($existingTask) { if ($existingTask) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
@@ -71,7 +73,6 @@ $action = New-ScheduledTaskAction `
-WorkingDirectory $InstallDir -WorkingDirectory $InstallDir
$triggerStartup = New-ScheduledTaskTrigger -AtStartup $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 $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
@@ -88,7 +89,7 @@ Register-ScheduledTask `
-Trigger $triggerStartup ` -Trigger $triggerStartup `
-Principal $principal ` -Principal $principal `
-Settings $settings ` -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 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 " Status: Running" -ForegroundColor White
Write-Host "" Write-Host ""
Write-Host " Commands:" -ForegroundColor Gray Write-Host " Commands:" -ForegroundColor Gray
Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray Write-Host " Check: Get-ScheduledTask $TaskName" -ForegroundColor Gray
Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray Write-Host " Stop: Stop-ScheduledTask $TaskName" -ForegroundColor Gray
Write-Host " Start: Start-ScheduledTask '$TaskName'" -ForegroundColor Gray Write-Host " Start: Start-ScheduledTask $TaskName" -ForegroundColor Gray
Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray Write-Host " Remove: Unregister-ScheduledTask $TaskName" -ForegroundColor Gray
Write-Host " Logs: type $InstallDir\agent_config.json" -ForegroundColor Gray Write-Host " Config: type $InstallDir\agent_config.json" -ForegroundColor Gray
Write-Host "" 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 # Sends system metrics to VPS Monitoring server
# #
# Install: Run install_agent.ps1 # Install: Run install_agent.ps1
@@ -13,7 +13,7 @@ param(
# Load config # Load config
$ConfigPath = Join-Path $PSScriptRoot "agent_config.json" $ConfigPath = Join-Path $PSScriptRoot "agent_config.json"
if (Test-Path $ConfigPath) { 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 $ServerUrl) { $ServerUrl = $config.server_url }
if (-not $AgentName) { $AgentName = $config.agent_name } if (-not $AgentName) { $AgentName = $config.agent_name }
if ($config.interval) { $Interval = $config.interval } if ($config.interval) { $Interval = $config.interval }
@@ -26,7 +26,7 @@ if (-not $ServerUrl -or -not $AgentName) {
} }
Write-Host "================================================" -ForegroundColor Cyan 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 " Server: $ServerUrl" -ForegroundColor Gray
Write-Host " Name: $AgentName" -ForegroundColor Gray Write-Host " Name: $AgentName" -ForegroundColor Gray
Write-Host " Interval: ${Interval}s" -ForegroundColor Gray Write-Host " Interval: ${Interval}s" -ForegroundColor Gray
@@ -44,9 +44,19 @@ function Get-SystemMetrics {
$metrics["os"] = "$($os.Caption) $($os.Version)" $metrics["os"] = "$($os.Caption) $($os.Version)"
# CPU # CPU
$cpu = Get-CimInstance Win32_Processor try {
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$metrics["cpu_name"] = $cpu.Name $metrics["cpu_name"] = $cpu.Name
$metrics["cpu_percent"] = [math]::Round((Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue, 1) $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 # RAM
$totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0) $totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0)
@@ -82,18 +92,24 @@ function Get-SystemMetrics {
} }
# Network # Network
try {
$adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" } $adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" }
$netInfo = @() $netInfo = @()
foreach ($a in $adapters) { foreach ($a in $adapters) {
$stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue $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 += @{ $netInfo += @{
name = $a.Name name = $a.Name
speed_mbps = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0) speed_mbps = $speed
sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 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 } received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 }
} }
} }
$metrics["network"] = $netInfo $metrics["network"] = $netInfo
} catch {
$metrics["network"] = @()
}
# Uptime # Uptime
$uptime = (Get-Date) - $os.LastBootUpTime $uptime = (Get-Date) - $os.LastBootUpTime
@@ -110,9 +126,13 @@ function Get-SystemMetrics {
} catch {} } catch {}
# Top processes by CPU # Top processes by CPU
try {
$topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 | $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) } } ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } }
$metrics["top_processes"] = $topProcs $metrics["top_processes"] = $topProcs
} catch {
$metrics["top_processes"] = @()
}
return $metrics return $metrics
} }
@@ -125,12 +145,10 @@ function Send-Metrics($metrics) {
} | ConvertTo-Json -Depth 5 } | ConvertTo-Json -Depth 5
try { try {
$response = Invoke-RestMethod -Uri "$ServerUrl/api/pc/heartbeat" ` $wc = New-Object System.Net.WebClient
-Method POST ` $wc.Headers.Add("Content-Type", "application/json")
-Body $body ` $wc.Encoding = [System.Text.Encoding]::UTF8
-ContentType "application/json" ` $null = $wc.UploadString("$ServerUrl/api/pc/heartbeat", "POST", $body)
-TimeoutSec 15
return $true return $true
} catch { } catch {
Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow
@@ -150,7 +168,7 @@ while ($true) {
$ok = Send-Metrics $metrics $ok = Send-Metrics $metrics
if ($ok) { 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 { } catch {
Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red