diff --git a/servers/fr1.md b/servers/fr1.md index 7a016e7..ce4f6f4 100644 --- a/servers/fr1.md +++ b/servers/fr1.md @@ -115,6 +115,7 @@ backend=systemd banaction=ufw - Дашборд **Network / Traffic** (`network.json`): RX/TX по интерфейсам, пакеты, ошибки/дропы, статус — с фильтром по instance/device (lo/veth/docker скрыты). - DNS: `mon.ruzzy.dev` → 161.97.93.252. - **Pushgateway** (с 2026-10-11) — для хостов за NAT, которых не скрейпнуть (домашняя `rpi`, см. `servers/rpi.md`). Слушает только `127.0.0.1:9091`; job `pushgateway` с `honor_labels: true` (иначе job/instance заменились бы на `pushgateway:9091`). Персистентность в томе `monitoring_pushgateway_data` — рестарт не обнуляет метрики. **Как приходят данные:** хост делает `ssh rus@fr1 < metrics.prom`, forced command `~/bin/pushgw-ingest ` делает `PUT /metrics/job//instance/`. instance зашит в authorized_keys, а не берётся от клиента — хост не может писать от чужого имени; имя job фильтруется по `[a-z0-9_]`. Наружу порт не открыт, nginx/ufw не трогали. + - В `~/bin/pushgw-ingest` есть режим `health` (с 2026-10-11): `ssh … health` отдаёт key=value о здоровье Prometheus/Alertmanager/pushgateway, свежести пушей хоста и счётчиках доставки в Telegram — только чтение. Им пользуется внешний сторож `monwatch` на rpi. - ⚠️ Pushgateway хранит последнее значение вечно: если хост умер, метрики не исчезнут, а «застынут». Поэтому свежесть ловит отдельный алерт `PushMetricsStale` по `push_time_seconds`. - ⚠️ Живой `~/monitoring/docker-compose.yml` расходится с репо (пароль Grafana вписан литералом), поэтому при деплое 2026-10-11 compose **не копировался**, а блок pushgateway вставлен на месте. Бэкапы: `docker-compose.yml.bak-20261011`, `prometheus/prometheus.yml.bak-20261011`. - Подключён `ru1` (node + cadvisor + openvpn-exporter, напрямую по публичному IP). `de1` выведен — его таргеты удалены. Планируется: экспортёры wireguard/awg и telemt. diff --git a/servers/rpi.md b/servers/rpi.md index 1b41b17..b8a3ab6 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -62,6 +62,8 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - `smart-metrics` заодно отдаёт `backup_disk_size_bytes`/`backup_disk_avail_bytes` для `/mnt/backup` (statfs, диск не будит). - Дашборд: https://mon.ruzzy.dev/d/rpi-disk (SMART, износ за сутки, свежесть бэкапов, заполнение). - ⚠️ `BackupStale` видит только **существующие** серии — задача, ни разу не отчитавшаяся, им не ловится. Для этого `BackupNeverReported` на `absent()` (с 2026-10-11): OneDrive 12 ч, серверы 26 ч, роутер 30 ч, проверка OneDrive 8 дней; и `PushMetricsStale` на отсутствие SMART-метрик 1 ч. Доставка в Telegram проверена тестовым алертом 2026-10-11. +- **monwatch — сторож самого мониторинга** (с 2026-10-11, `stacks/rpi-backup/bin/monwatch`, таймер раз в час): весь мониторинг живёт на fr1, и если упадёт fr1 или цепочка доставки — алертов не будет вообще. Малина спрашивает fr1 `health` (тот же ключ метрик, режим только чтение): живы ли Prometheus/Alertmanager/pushgateway, видит ли Prometheus пуши малины моложе часа, не растут ли `alertmanager_notifications_failed_total{integration="telegram"}`. При проблеме (после повторной проверки через 2 мин) пишет в Telegram **напрямую, минуя fr1** — ботом @rasp_contol_bot (токен из `/etc/astra-bot.env`) в чат 607015; повтор раз в 6 ч, ✅ при восстановлении. Состояние — `/var/lib/monwatch/`. Путь тревоги проверен имитацией 2026-10-11 (`MONWATCH_MAX_PUSH_AGE=1`). +- ⚠️ Сообщения сторожа приходят в чат с ботом Astra — это бот сам пишет, Codex их не видит и не обрабатывает. - Алерты — группа `rpi` в `stacks/monitoring/prometheus/rules/alerts.yml`. ## Домашняя печать (с 2026-10-11) diff --git a/stacks/monitoring/pushgw-ingest b/stacks/monitoring/pushgw-ingest index ef90f88..d8d125c 100755 --- a/stacks/monitoring/pushgw-ingest +++ b/stacks/monitoring/pushgw-ingest @@ -2,9 +2,37 @@ # Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest "). # Клиент передаёт только имя job (ssh ... ), метрики — в stdin в text-формате Prometheus. # instance задаётся ключом, а не клиентом: хост не может писать метрики от чужого имени. +# +# Особый «job» `health` — только чтение: внешний сторож (rpi, monwatch) раз в час проверяет, +# что сам мониторинг fr1 жив. Отдаёт строки key=value, ничего не меняет. instance="$1" job="$SSH_ORIGINAL_COMMAND" case "$instance" in ""|*[!a-z0-9_-]*) echo "bad instance" >&2; exit 2;; esac + +if [ "$job" = health ]; then + code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1"; } + echo "prometheus_healthy=$(code http://127.0.0.1:9090/-/healthy)" + echo "alertmanager_healthy=$(code http://127.0.0.1:9093/-/healthy)" + echo "pushgateway_healthy=$(code http://127.0.0.1:9091/-/healthy)" + # видит ли Prometheus свежие пуши этого хоста — сквозная проверка pushgateway → scrape → TSDB + age=$(curl -s --max-time 10 http://127.0.0.1:9090/api/v1/query \ + --data-urlencode "query=time() - max(push_time_seconds{instance=\"$instance\"})" \ + | python3 -c 'import json,sys +try: print(int(float(json.load(sys.stdin)["data"]["result"][0]["value"][1]))) +except Exception: print(-1)') + echo "push_age_seconds=$age" + # счётчики доставки в Telegram (монотонные; сторож сравнивает с прошлым замером) + curl -s --max-time 10 http://127.0.0.1:9093/metrics | awk ' + /^alertmanager_notifications_failed_total\{integration="telegram"/ {f+=$2} + /^alertmanager_notifications_total\{integration="telegram"/ {t+=$2} + END {printf "tg_notifications_total=%d\ntg_notifications_failed_total=%d\n", t, f}' + firing=$(curl -s --max-time 10 http://127.0.0.1:9093/api/v2/alerts?active=true | python3 -c 'import json,sys +try: print(len(json.load(sys.stdin))) +except Exception: print(-1)') + echo "alerts_active=$firing" + exit 0 +fi + case "$job" in ""|*[!a-z0-9_]*) echo "bad job" >&2; exit 2;; esac exec curl -sS --fail-with-body --max-time 30 -X PUT --data-binary @- \ "http://127.0.0.1:9091/metrics/job/$job/instance/$instance" diff --git a/stacks/rpi-backup/bin/monwatch b/stacks/rpi-backup/bin/monwatch new file mode 100644 index 0000000..3c888d0 --- /dev/null +++ b/stacks/rpi-backup/bin/monwatch @@ -0,0 +1,67 @@ +#!/bin/bash +# monwatch — внешний сторож мониторинга fr1 («кто сторожит сторожа»). +# Весь мониторинг (Prometheus, Alertmanager → Telegram) живёт на fr1: если упадёт fr1 или сломается +# цепочка доставки, никаких алертов не будет вообще. Раз в час малина спрашивает fr1 `health` +# (тот же ограниченный ключ метрик, режим только чтение) и при проблеме пишет в Telegram +# НАПРЯМУЮ — через своего бота (@rasp_contol_bot, токен из /etc/astra-bot.env), минуя fr1. +# +# Тревога — при поломке, повтор раз в 6 ч, ✅ при восстановлении. Разовый сбой сети +# не тревожит: перед тревогой одна повторная проверка через 2 минуты. +set -uo pipefail +STATE=/var/lib/monwatch; mkdir -p "$STATE" +CHAT=${MONWATCH_CHAT:-607015} +REPEAT=$((6 * 3600)) +MAX_PUSH_AGE=${MONWATCH_MAX_PUSH_AGE:-3600} # smart-metrics пушит каждые 15 мин + +health() { + timeout 60 ssh -i /etc/metrics-push/id_ed25519 -o UserKnownHostsFile=/etc/metrics-push/known_hosts \ + -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 rus@161.97.93.252 health 2>&1 +} + +check() { # печатает проблемы по строке; пусто = всё хорошо + local out rc + out=$(health); rc=$? + if [ $rc -ne 0 ] || ! grep -q '^prometheus_healthy=' <<< "$out"; then + echo "fr1 не отвечает на проверку (ssh rc=$rc): $(tail -1 <<< "$out")"; return + fi + declare -A v; while IFS='=' read -r k val; do v[$k]=$val; done <<< "$out" + [ "${v[prometheus_healthy]}" = 200 ] || echo "Prometheus не отвечает (HTTP ${v[prometheus_healthy]})" + [ "${v[alertmanager_healthy]}" = 200 ] || echo "Alertmanager не отвечает (HTTP ${v[alertmanager_healthy]}) — алерты не доставляются" + [ "${v[pushgateway_healthy]}" = 200 ] || echo "Pushgateway не отвечает (HTTP ${v[pushgateway_healthy]})" + local age=${v[push_age_seconds]:--1} + if [ "$age" -lt 0 ] || [ "$age" -gt $MAX_PUSH_AGE ]; then + echo "Prometheus не видит свежих метрик малины ($([ "$age" -lt 0 ] && echo 'нет данных' || echo "$((age / 60)) мин"))" + fi + local failed=${v[tg_notifications_failed_total]:-0} prev + prev=$(cat "$STATE/tg_failed" 2>/dev/null || echo "$failed") + [ "$failed" -gt "$prev" ] && echo "Alertmanager не смог отправить в Telegram $((failed - prev)) уведомл. за последний час" + echo "$failed" > "$STATE/tg_failed" +} + +tg() { + local token + token=$(sed -n 's/^TG_TOKEN=//p' /etc/astra-bot.env) + curl -s -o /dev/null -w '%{http_code}' --max-time 30 "https://api.telegram.org/bot$token/sendMessage" \ + --data-urlencode "chat_id=$CHAT" --data-urlencode "text=$1" | grep -q 200 || echo "не удалось отправить в Telegram" >&2 +} + +problems=$(check) +if [ -n "$problems" ]; then + sleep 120; problems=$(check) # повторная проверка: не тревожим из-за разового сбоя сети +fi +now=$(date +%s); state=$(cat "$STATE/state" 2>/dev/null || echo up); last=$(cat "$STATE/last_alert" 2>/dev/null || echo 0) + +if [ -n "$problems" ]; then + echo "DOWN: $problems" + if [ "$state" != down ] || [ $((now - last)) -ge $REPEAT ]; then + tg "🔴 Мониторинг fr1 сломан — алерты от него могут не приходить. +$problems + +(сторож на rpi, проверка раз в час; повтор через 6 ч)" && echo "$now" > "$STATE/last_alert" + fi + echo down > "$STATE/state" + exit 1 +fi +echo "OK: мониторинг fr1 в порядке" +[ "$state" = down ] && tg "✅ Мониторинг fr1 снова в порядке (сторож на rpi)." +echo up > "$STATE/state" diff --git a/stacks/rpi-backup/systemd/monwatch.service b/stacks/rpi-backup/systemd/monwatch.service new file mode 100644 index 0000000..978295d --- /dev/null +++ b/stacks/rpi-backup/systemd/monwatch.service @@ -0,0 +1,9 @@ +[Unit] +Description=Watchdog for fr1 monitoring (alerts to Telegram directly, bypassing fr1) +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/monwatch +TimeoutStartSec=10min diff --git a/stacks/rpi-backup/systemd/monwatch.timer b/stacks/rpi-backup/systemd/monwatch.timer new file mode 100644 index 0000000..1a009b5 --- /dev/null +++ b/stacks/rpi-backup/systemd/monwatch.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Hourly check that fr1 monitoring is alive + +[Timer] +OnBootSec=5min +OnCalendar=hourly +RandomizedDelaySec=2min +Persistent=true + +[Install] +WantedBy=timers.target