diff --git a/monitor/.env.example b/monitor/.env.example new file mode 100644 index 0000000..386aece --- /dev/null +++ b/monitor/.env.example @@ -0,0 +1,6 @@ +# hard-monitor — конфиг агента (скопируй в .env и заполни) +# Ключ берётся в панели: Servers → добавить сервер → показать ключ + +PANEL_URL=https://panel.example.com +SERVER_KEY=ghm_xxxxxxxxxxxxxxxxxxxxx +INTERVAL=30 \ No newline at end of file diff --git a/monitor/README.md b/monitor/README.md index 030d09c..72fd23e 100644 --- a/monitor/README.md +++ b/monitor/README.md @@ -1,7 +1,48 @@ # hard-monitor -Агент GHard Monitor для серверов. Stateless: только собирает факты -(CPU, RAM, диски, сеть, топ процессов, docker) и POST-ит их на панель -по `PANEL_URL` с ключом `SERVER_KEY` из `.env`. +Агент GHard Monitor для серверов. **Stateless**: только собирает факты +(CPU, RAM, диски, сеть, топ процессов, docker, температуры) и POST-ит их +на панель по `PANEL_URL` с ключом `SERVER_KEY`. Вся логика — пороги, +ивенты, расчёт МБ/с — на панели. -Реализация — этап 2 (см. корневой README). \ No newline at end of file +## Установка (Linux + systemd) + +1. В панели: `Servers → добавить сервер` → скопируй ключ (`ghm_...`, показывается один раз) +2. На сервере: + +```bash +sudo ./install.sh https://panel.example.com ghm_xxxxxxxxxxxxxxxx 30 +``` + +или одной командой с raw-скриптом: + +```bash +curl -fsSL https://git.gnexus.space/root/hard-panel/raw/branch/master/monitor/install.sh | \ + sudo bash -s -- https://panel.example.com ghm_xxxxxxxxxxxxxxxx 30 +``` + +Устанавливает всё в `/opt/hard-monitor` (venv + psutil + requests), пишет +`.env` (права 600), ставит systemd-юнит `hard-monitor` и запускает. +Повторный запуск — обновление агента с рестартом сервиса. + +## Конфиг + +`.env` (перекрывается переменными окружения): + +```ini +PANEL_URL=https://panel.example.com +SERVER_KEY=ghm_... +INTERVAL=30 +``` + +## Ручной запуск / отладка + +```bash +./hard_monitor.py --dry-run # собрать и напечатать пакет, не отправляя +./hard_monitor.py --once # один пакет на панель +./hard_monitor.py # штатный цикл (обычно через systemd) +journalctl -u hard-monitor -f # логи сервиса +``` + +Зависимости: python3.8+, psutil, requests. Docker-метрики собираются +автоматически, если есть доступ к docker-сокету (иначе тихо пропускаются). \ No newline at end of file diff --git a/monitor/hard-monitor.service b/monitor/hard-monitor.service new file mode 100644 index 0000000..aa55811 --- /dev/null +++ b/monitor/hard-monitor.service @@ -0,0 +1,15 @@ +[Unit] +Description=GHard Monitor agent +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/hard-monitor +EnvironmentFile=/opt/hard-monitor/.env +ExecStart=/opt/hard-monitor/venv/bin/python hard_monitor.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/monitor/hard_monitor.py b/monitor/hard_monitor.py new file mode 100755 index 0000000..d4cad6a --- /dev/null +++ b/monitor/hard_monitor.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""hard-monitor — агент GHard Monitor (Gnexus Hardware Monitor). + +Stateless-сборщик: собирает факты о сервере и POST-ит их на hard-panel +(POST /api/v1/ingest, заголовок X-Server-Key). Вся логика — пороги, ивенты, +расчёт скорости сети — на панели. + +Конфиг: переменные окружения или .env рядом со скриптом + PANEL_URL — адрес панели, напр. https://panel.example.com + SERVER_KEY — ключ сервера из панели + INTERVAL — период отправки, сек (по умолчанию 30) + +Использование: + hard_monitor.py — бесконечный цикл (штатный режим, systemd) + hard_monitor.py --once — один пакет и выход (для теста) + hard_monitor.py --dry-run — собрать и напечатать пакет, не отправляя +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import platform +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import psutil +import requests + +log = logging.getLogger("hard-monitor") + +# Файловые системы/маунты, которые не показываем как «диски» панели +SKIP_FSTYPES = {"squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660", "proc", "sysfs"} + + +# --- Конфиг ----------------------------------------------------------------- + +def load_env_file(path: Path) -> None: + """Мини-парсер .env: KEY=VALUE в переменные окружения (env важнее файла).""" + if not path.exists(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key, value = key.strip(), value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +# --- Сбор фактов ------------------------------------------------------------- + +def collect_identity() -> dict: + os_name = platform.platform() + pretty = Path("/etc/os-release") + if pretty.exists(): + for line in pretty.read_text(encoding="utf-8").splitlines(): + if line.startswith("PRETTY_NAME="): + os_name = line.partition("=")[2].strip('"') + break + return { + "hostname": platform.node() or "unknown", + "os": os_name, + "kernel": platform.release(), + "ips": collect_ips(), + } + + +def collect_ips() -> list[str]: + """IPv4-адреса активных интерфейсов. + + Виртуальные (docker/veth/мосты) не показываем — в карточке сервера + нужен реальный адрес, а не 172.17.0.1 каждого docker-моста. + """ + virtual = ("lo", "docker", "veth", "br-", "virbr") + stats = psutil.net_if_stats() + result = [] + for iface, addrs in psutil.net_if_addrs().items(): + if iface.startswith(virtual): + continue + if not stats.get(iface) or not stats[iface].isup: + continue + for addr in addrs: + if addr.family.name == "AF_INET": + result.append(addr.address) + return sorted(set(result)) + + +def collect_cpu() -> dict: + # psutil считает % от предыдущего вызова — при старте сеем первое чтение + load1, load5, load15 = os.getloadavg() + return { + "percent": psutil.cpu_percent(interval=None), + "count": psutil.cpu_count() or 0, + "load": [round(load1, 2), round(load5, 2), round(load15, 2)], + } + + +def collect_memory() -> dict: + ram = psutil.virtual_memory() + swap = psutil.swap_memory() + return { + "ram": {"total": ram.total, "used": ram.used, "available": ram.available}, + "swap": {"total": swap.total, "used": swap.used}, + } + + +def collect_disks() -> list[dict]: + disks = [] + seen = set() + for part in psutil.disk_partitions(all=False): + if part.fstype in SKIP_FSTYPES or "/snap/" in part.mountpoint or part.mountpoint in seen: + continue + seen.add(part.mountpoint) + try: + usage = psutil.disk_usage(part.mountpoint) + except (PermissionError, OSError): + continue + disks.append( + { + "mount": part.mountpoint, + "total": usage.total, + "used": usage.used, + "percent": round(usage.percent, 1), + } + ) + return disks + + +def collect_net() -> list[dict]: + counters = psutil.net_io_counters(pernic=True) + return [ + { + "iface": iface, + "bytes_sent": counters[iface].bytes_sent, + "bytes_recv": counters[iface].bytes_recv, + } + for iface in sorted(counters) + ] # loopback панель отфильтрует сама + + +def collect_processes(limit: int = 5) -> list[dict]: + """Топ процессов по CPU (с прошлого цикла).""" + procs = [] + for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]): + try: + info = proc.info + procs.append( + { + "name": info["name"] or "?", + "pid": info["pid"] or 0, + "cpu": info["cpu_percent"] or 0.0, + "mem": info["memory_percent"] or 0.0, + } + ) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + procs.sort(key=lambda p: p["cpu"], reverse=True) + return procs[:limit] + + +def collect_docker() -> list[dict]: + """Все контейнеры (включая упавшие — по ним панель сделает ивенты).""" + if not shutil.which("docker"): + return [] + try: + out = subprocess.run( + ["docker", "ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.Status}}"], + capture_output=True, text=True, timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if out.returncode != 0: # нет доступа к docker-сокету и т.п. + return [] + containers = [] + for line in out.stdout.strip().splitlines(): + name, _, rest = line.partition("|") + image, _, status = rest.partition("|") + containers.append({"name": name, "image": image, "status": status}) + return containers + + +def collect_extra() -> dict: + """Необязательные факты: температуры. Панель хранит их в extra.""" + extra: dict = {} + try: + temps = psutil.sensors_temperatures() + if temps: + extra["temps"] = { + label: round(entry[0].current or 0, 1) + for label, entries in temps.items() + if entries + } + except (OSError, AttributeError, Exception): + pass + return extra + + +def collect() -> dict: + return { + **collect_identity(), + "interval": int(env("INTERVAL", "30")), + "ts": datetime.now(timezone.utc).isoformat(), + "cpu": collect_cpu(), + "memory": collect_memory(), + "disks": collect_disks(), + "uptime": int(time.time() - psutil.boot_time()), + "net": collect_net(), + "processes": collect_processes(), + "docker": collect_docker(), + "extra": collect_extra(), + } + + +# --- Отправка ---------------------------------------------------------------- + +def send(panel_url: str, server_key: str, payload: dict) -> None: + response = requests.post( + panel_url.rstrip("/") + "/api/v1/ingest", + json=payload, + headers={"X-Server-Key": server_key}, + timeout=15, + ) + response.raise_for_status() + + +def main() -> int: + parser = argparse.ArgumentParser(description="hard-monitor agent") + parser.add_argument("--once", action="store_true", help="отправить один пакет и выйти") + parser.add_argument("--dry-run", action="store_true", help="напечатать пакет, не отправляя") + parser.add_argument("--env", default=".env", help="путь к .env (по умолчанию рядом со скриптом)") + args = parser.parse_args() + + load_env_file(Path(__file__).resolve().parent / args.env) + + panel_url = env("PANEL_URL") + server_key = env("SERVER_KEY") + interval = int(env("INTERVAL", "30")) + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stdout, + ) + + if not args.dry_run: + if not panel_url or not server_key: + log.error("PANEL_URL и SERVER_KEY обязательны (env или .env)") + return 1 + + # сеем базовые точки, чтобы cpu_percent и process cpu были не нулевые + psutil.cpu_percent(interval=None) + list(psutil.process_iter(["cpu_percent"])) + + while True: + started = time.monotonic() + try: + payload = collect() + if args.dry_run: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + send(panel_url, server_key, payload) + log.info("sent: cpu=%.1f%% net_ifaces=%d", payload["cpu"]["percent"], len(payload["net"])) + except requests.RequestException as exc: + log.warning("send failed: %s", exc) + except Exception: + log.exception("collect failed") + + if args.once: + return 0 + time.sleep(max(interval - (time.monotonic() - started), 0)) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/monitor/install.sh b/monitor/install.sh new file mode 100755 index 0000000..4c05327 --- /dev/null +++ b/monitor/install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Установка hard-monitor (агент GHard Monitor) в одну команду: +# +# curl -fsSL https://git.gnexus.space/root/hard-panel/raw/branch/master/monitor/install.sh | bash -s -- PANEL_URL SERVER_KEY +# +# или склонируй репо и запусти из monitor/: +# ./install.sh https://panel.example.com ghm_xxx [INTERVAL] +# +# Кладёт агент в /opt/hard-monitor, создаёт venv, пишет .env (600), +# ставит systemd-юнит и запускает. Повторный запуск — обновление. + +set -euo pipefail + +PANEL_URL="${1:-}" +SERVER_KEY="${2:-}" +INTERVAL="${3:-30}" +INSTALL_DIR=/opt/hard-monitor +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +say() { printf '\e[1;32m==>\e[0m %s\n' "$*"; } +die() { printf '\e[1;31mошибка:\e[0m %s\n' "$*" >&2; exit 1; } + +if [[ $EUID -ne 0 ]]; then + die "запусти от root: sudo ./install.sh ..." +fi + +if [[ -z "$PANEL_URL" || -z "$SERVER_KEY" ]]; then + [[ -t 0 ]] || die "нужны аргументы: install.sh PANEL_URL SERVER_KEY [INTERVAL]" + read -rp "PANEL_URL: " PANEL_URL + read -rp "SERVER_KEY: " SERVER_KEY + read -rp "INTERVAL [30]: " INTERVAL + INTERVAL="${INTERVAL:-30}" +fi +[[ "$PANEL_URL" =~ ^https?:// ]] || die "PANEL_URL должен начинаться с http(s)://" +[[ "$SERVER_KEY" =~ ^ghm_ ]] || die "SERVER_KEY должен начинаться с ghm_ (сгенерирован панелью)" + +command -v python3 >/dev/null || die "python3 не найден" + +say "устанавливаю в $INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +install -m 755 "$SCRIPT_DIR/hard_monitor.py" "$INSTALL_DIR/hard_monitor.py" + +say "создаю venv и ставлю зависимости (psutil, requests)" +if [[ ! -d "$INSTALL_DIR/venv" ]]; then + python3 -m venv "$INSTALL_DIR/venv" || die "не создался venv (нужен пакет python3-venv)" +fi +"$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade -r <(printf 'psutil>=5.9\nrequests>=2.31\n') \ + || die "не установились зависимости" + +say "пишу конфиг" +umask 177 +cat > "$INSTALL_DIR/.env" </dev/null; then + say "ставлю systemd-юнит" + install -m 644 "$SCRIPT_DIR/hard-monitor.service" /etc/systemd/system/hard-monitor.service + systemctl daemon-reload + systemctl enable --now hard-monitor + sleep 2 + systemctl --no-pager --lines=5 status hard-monitor || true + say "готово: journalctl -u hard-monitor -f" +else + say "systemd не найден — юнит не ставил. Запускай вручную:" + echo " cd $INSTALL_DIR && ./venv/bin/python hard_monitor.py" +fi \ No newline at end of file diff --git a/monitor/requirements.txt b/monitor/requirements.txt new file mode 100644 index 0000000..b5600ff --- /dev/null +++ b/monitor/requirements.txt @@ -0,0 +1,2 @@ +psutil>=5.9 +requests>=2.31 \ No newline at end of file