diff --git a/AGENTS.md b/AGENTS.md index d98458e..d1d8eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан. - [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`. - [`servers/ru1.md`](servers/ru1.md) — `85.198.109.86` (VDSina, РФ). VPN entry-нода (OpenVPN ×2, AmneziaWG **до fr1** после переключения, WireGuard клиентов), умный роутинг RU/зарубеж; WEB-прокси Telegram (tproxy-server + MTProxy) на `ru.ruzzy.dev:443`, telemt удалён 2026-10-09. ⚠️ Файл описания сверялся до переключения. +- [`servers/rpi.md`](servers/rpi.md) — `rpi.local` (192.168.10.25, дом, за NAT). Raspberry Pi 4: бэкап личного OneDrive на HDD 4 ТБ (rclone, вечный архив версий, еженедельная сверка хэшей); SMART и статус бэкапов пушит в pushgateway fr1 по SSH. Код — `stacks/rpi-backup/`. ## Поддержание описаний в актуальном состоянии Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и diff --git a/servers/fr1.md b/servers/fr1.md index 6c287eb..ba17d10 100644 --- a/servers/fr1.md +++ b/servers/fr1.md @@ -15,6 +15,7 @@ - ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте. - Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519). - root-доступ по SSH закрыт; для root изнутри — `sudo -i`. +- **Пуш-ключ `metrics-push@rpi`** (с 2026-10-11) в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/pushgw-ingest rpi"`: этим ключом домашняя малина может только залить метрики в pushgateway — шелла, форвардинга и pty нет. Бэкап прежнего файла — `~/.ssh/authorized_keys.bak-20261011`. ## SSH (`/etc/ssh/sshd_config.d/00-hardening.conf`) ⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. ⏳ Рядом лежит `50-cloud-init.conf`, который из-под `rus` не читается: под root стоит проверить `sshd -T | grep -i permitrootlogin`. @@ -104,8 +105,8 @@ backend=systemd banaction=ufw - Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam. ## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`) -- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager** (docker compose). -- **Алерты:** Alertmanager → Telegram (бот @ultimate_log_bot, chat 607015). Правила `prometheus/rules/alerts.yml`: TargetDown, HostHighCPU, HostLowMemory, HostDiskLow/Critical. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git). +- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager + Pushgateway** (docker compose). +- **Алерты:** Alertmanager → Telegram (бот @ultimate_log_bot, chat 607015). Правила `prometheus/rules/alerts.yml`: TargetDown, HostHighCPU, HostLowMemory, HostDiskLow/Critical; группа `rpi` (с 2026-10-11) — SMART и бэкапы малины: PushMetricsStale, SmartHealthFailed, SmartBadSectors(+Growing), SmartUdmaCrcErrors, SmartSelftestFailed, SmartTemperatureHigh, BackupFailed, BackupStale. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git). - Prometheus: retention 90d, слушает `127.0.0.1:9090`. Job'ы: `node` и `cadvisor` (file_sd, instance'ы **fr1 и ru1**), `openvpn` (file_sd, только ru1 — `85.198.109.86:9176`), `gitea` (static, `gitea:3000` через внешнюю docker-сеть `monitoring_monitoring`), `prometheus` (self). На 2026-09-08 все таргеты up, активных алертов нет. - Grafana: `127.0.0.1:3000`, доступ снаружи через **https://mon.ruzzy.dev** (nginx + Let's Encrypt). Datasource Prometheus (uid `prometheus`) и дашборды (Node Exporter Full, cAdvisor, Network/Traffic, Gitea, OpenVPN — папка Infra) провижатся автоматически. Пароль admin — в `~/monitoring/docker-compose.yml` на сервере (в репо вынесен в `${GF_ADMIN_PASSWORD}`). - **Тома:** `monitoring_prometheus_data` — **6.5 ГБ** (при retention 90d; самый крупный том на сервере, учитывать при планировании диска), `monitoring_grafana_data` — 50 МБ, `monitoring_alertmanager_data` — пустой. @@ -113,6 +114,9 @@ backend=systemd banaction=ufw - **node-exporter — `network_mode: host`** (видит реальные интерфейсы хоста: eth0, wg0-туннель и т.д.; иначе показывал бы только сеть docker-бриджа). Слушает host:9100, доступ ограничен ufw (`from 172.16.0.0/12 to port 9100`), Prometheus скрейпит через `host.docker.internal:9100`. nodename берётся с хоста (fr1). - Дашборд **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 не трогали. + - ⚠️ 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. ## Gitea (`~/gitea`, stack в репо `stacks/gitea/`) diff --git a/servers/rpi.md b/servers/rpi.md new file mode 100644 index 0000000..fbdc708 --- /dev/null +++ b/servers/rpi.md @@ -0,0 +1,50 @@ +# rpi — домашняя Raspberry Pi 4 (`rpi.local`, 192.168.10.25) + +Поднята 2026-10-11. Домашний сервер за NAT: первая задача — бэкап личного +OneDrive на внешний HDD. Мониторится с fr1 через pushgateway (скрейпить +её снаружи нельзя — она за домашним роутером). + +## Железо и ОС +- Raspberry Pi 4, SD 64 ГБ (`mmcblk0`), питание штатное — `vcgencmd get_throttled` = `0x0`. +- Raspberry Pi OS Lite 64-bit **Trixie** (Debian 13), образ `2026-10-06-raspios-trixie-arm64-lite`. +- Headless-настройка — **cloud-init**, а не старые `ssh`/`userconf.txt`: в Trixie Pi Imager пишет `user-data`/`network-config` на `bootfs`. Наш `user-data` создал пользователя, ключ, hostname, таймзону MSK. +- Сеть: Ethernet, DHCP. MAC `dc:a6:32:db:b0:f9`, роутер `192.168.10.254`. ⏳ Резерв адреса на роутере — на пользователе (см. хвосты). + +## Доступ +- С Mac: `ssh rpi` (алиас → `rpi.local`, user `rus`, ключ `~/.ssh/id_ed25519`). Резолвится через mDNS (avahi в образе по умолчанию). +- Только по ключу: `passwordauthentication no`, у `rus` пароль заблокирован, **sudo NOPASSWD** (иначе sudo был бы недоступен вовсе). + +## Диски +| Устройство | Модель | Роль | +|---|---|---| +| `sda` | WD My Passport 4 ТБ (WD40NDZW, s/n WD-WX82D6228YCD) | ext4 `backup` → `/mnt/backup` | +| — | WD Elements 1 ТБ (WD10SDRW) | **не подключён**, ждёт хаба (APFS, не отформатирован) | + +- fstab по UUID с `nofail,x-systemd.device-timeout=30s` (`stacks/rpi-backup/fstab.line`): без диска малина грузится, бэкап просто пропускается (`mountpoint -q`). +- ⚠️ **Два 2.5" HDD от USB Pi 4 не тянут.** Проверено 2026-10-11: при раскрутке обоих — `over-current` на всех USB3-портах; под нагрузкой 4-ТБ диск отваливался (`USB disconnect`) и залипал в `Unit Not Ready`. Один диск стабилен (20 ГБ чтения 103 МБ/с, mkfs ~60 ГБ записи без сбросов). Второй диск — только через USB3-хаб с собственным питанием. +- Залипший после отвала диск лечится power-cycle всех USB (Pi 4 переключает питание портов только разом): `sudo uhubctl -l 2 -a off; sudo uhubctl -l 1-1 -a off; sleep 8; …-a on`. +- SMART на момент ввода: 9 897 ч, 0 переназначенных/ожидающих, Start_Stop 21 946, Load_Cycle 42 530. + +## Бэкап OneDrive (код — `stacks/rpi-backup/`) +- rclone (официальный .deb, v1.75.2 — в Debian 1.60, старый), remote `onedrive:` = личный OneDrive (drive_type personal). Токен — в `~rus/.config/rclone/rclone.conf` (600), только на малине. Получен через `rclone authorize onedrive` на Mac. +- `onedrive-backup` (таймер каждые 4 ч, `00/4:30`): `rclone sync` в `/mnt/backup/onedrive/current`, всё удалённое/перезаписанное в облаке — в `archive//` **навсегда** (пользователь хочет иметь всё, что когда-либо было в OneDrive). Хэши архива — `manifests/archive-.qxh`, лог каждого прогона — `logs/sync-.log` (INFO: поштучно что скачано/убрано в архив). +- Целостность: rclone сверяет QuickXorHash каждого скачанного файла с OneDrive; `onedrive-verify` (вс 05:00) перечитывает весь HDD: `rclone check` current↔облако + `rclone checksum` archive↔манифесты. Отчёт — `logs/verify-*`. +- Оба юнита под общим `flock /run/lock/onedrive-backup.lock` — не идут параллельно и не убивают друг друга. +- ⚠️ Файл, загруженный и удалённый в облаке между прогонами (<4 ч), в бэкап не попадёт. +- ⚠️ `Personal Vault` исключён — rclone через API туда не ходит. +- ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter. +- Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал). + +## Мониторинг → fr1 +- `smartd` (`stacks/rpi-backup/smartd.conf`): `DEVICESCAN -d removable`, `-n standby,q` (не будить диск), короткий самотест сб 03:00, длинный 1-го числа 05:00. ⚠️ `-d sat` с DEVICESCAN не работает («Not a SAT device») — тип моста пусть определяет сам. +- `smart-metrics` (таймер 15 мин): smartctl `-n standby` → метрики `smart_*` → job `smart`. Спящий диск не будится: отдаются значения из кэша `/var/lib/smart-metrics/` + `smart_disk_standby=1`. +- `backup-metrics ` — `ExecStopPost=+` у юнитов бэкапа: `backup_last_exit_code`, `backup_last_run_timestamp`, `backup_last_success_timestamp` (время успеха хранится локально в `/var/lib/backup-metrics/`, т.к. PUT заменяет группу целиком). +- Доставка: `metrics-push ` = ssh с ключом `/etc/metrics-push/id_ed25519` (root, 600) на `rus@161.97.93.252`; host key fr1 закреплён в `/etc/metrics-push/known_hosts`. На fr1 ключ ограничен forced command (`servers/fr1.md` → Мониторинг). +- Алерты — группа `rpi` в `stacks/monitoring/prometheus/rules/alerts.yml`. + +## Открытые хвосты (на 2026-10-11) +- ⏳ Закрепить `192.168.10.25` за MAC `dc:a6:32:db:b0:f9` на роутере. +- ⏳ USB3-хаб с питанием → подключить и отформатировать WD Elements 1 ТБ (вторая копия архива — сейчас копия одна). +- Нет node-exporter (CPU/RAM/диск самой малины в Prometheus не видны) — пуш через pushgateway для node-метрик неудобен; решить, нужен ли. +- Первый прогон бэкапа стартовал до появления `ExecStopPost` — его итог может не дойти в метрики; `backup_last_success_timestamp` появится с первого прогона по таймеру. До этого `BackupStale` молчит (серии нет). +- Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный. diff --git a/stacks/monitoring/docker-compose.yml b/stacks/monitoring/docker-compose.yml index 00d25f3..227bd41 100644 --- a/stacks/monitoring/docker-compose.yml +++ b/stacks/monitoring/docker-compose.yml @@ -33,6 +33,22 @@ services: - '127.0.0.1:9093:9093' networks: [monitoring] + # pushgateway: метрики от хостов за NAT (rpi), которые не скрейпнуть. + # Порт только на 127.0.0.1 — пуш идёт по SSH через forced command ~/bin/pushgw-ingest + # (см. servers/fr1.md), наружу не торчит. Персистентность — чтобы рестарт не обнулял алерты. + pushgateway: + image: prom/pushgateway:latest + container_name: pushgateway + restart: unless-stopped + command: + - '--persistence.file=/data/metrics' + - '--persistence.interval=1m' + volumes: + - pushgateway_data:/data + ports: + - '127.0.0.1:9091:9091' + networks: [monitoring] + grafana: image: grafana/grafana:latest container_name: grafana @@ -93,6 +109,7 @@ services: networks: [monitoring] volumes: + pushgateway_data: prometheus_data: grafana_data: alertmanager_data: diff --git a/stacks/monitoring/prometheus/prometheus.yml b/stacks/monitoring/prometheus/prometheus.yml index 79c8d5a..5fa6f62 100644 --- a/stacks/monitoring/prometheus/prometheus.yml +++ b/stacks/monitoring/prometheus/prometheus.yml @@ -35,3 +35,9 @@ scrape_configs: - job_name: openvpn file_sd_configs: - files: ["/etc/prometheus/targets/openvpn/*.yml"] + + # honor_labels: сохраняем job/instance, с которыми хост запушил, а не pushgateway:9091 + - job_name: pushgateway + honor_labels: true + static_configs: + - targets: ["pushgateway:9091"] diff --git a/stacks/monitoring/prometheus/rules/alerts.yml b/stacks/monitoring/prometheus/rules/alerts.yml index 2cd419e..d026c9b 100644 --- a/stacks/monitoring/prometheus/rules/alerts.yml +++ b/stacks/monitoring/prometheus/rules/alerts.yml @@ -35,3 +35,67 @@ groups: labels: {severity: critical} annotations: summary: "КРИТИЧНО: диск почти полон на {{ $labels.instance }} ({{ $labels.mountpoint }}): свободно {{ printf \"%.0f\" $value }}%" + + # Метрики от rpi приходят через pushgateway (SSH-пуш, см. servers/rpi.md). + # push_time_seconds — когда группа последний раз обновлялась. + - name: rpi + rules: + - alert: PushMetricsStale + expr: time() - push_time_seconds{job=~"smart|backup_.*"} > 3 * 3600 + for: 10m + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: метрики {{ $labels.job }} не приходят {{ $value | humanizeDuration }} — хост/пуш лежит" + + - alert: SmartHealthFailed + expr: smart_health_passed == 0 + labels: {severity: critical} + annotations: + summary: "{{ $labels.instance }}: SMART FAILED на диске {{ $labels.model }} ({{ $labels.serial }}) — менять диск" + + - alert: SmartBadSectors + expr: smart_attribute_raw{id=~"5|197|198"} > 0 + labels: {severity: critical} + annotations: + summary: "{{ $labels.instance }}: {{ $labels.name }} = {{ $value }} на {{ $labels.model }} ({{ $labels.serial }}) — диск начал сыпаться" + + - alert: SmartBadSectorsGrowing + expr: increase(smart_attribute_raw{id=~"5|197|198"}[1d]) > 0 + labels: {severity: critical} + annotations: + summary: "{{ $labels.instance }}: {{ $labels.name }} растёт на {{ $labels.model }} — срочно вторую копию" + + - alert: SmartUdmaCrcErrors + expr: increase(smart_attribute_raw{id="199"}[1d]) > 0 + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: CRC-ошибки на {{ $labels.model }} — кабель/USB/питание" + + - alert: SmartSelftestFailed + expr: smart_selftest_last_passed == 0 + labels: {severity: critical} + annotations: + summary: "{{ $labels.instance }}: самотест SMART провален на {{ $labels.model }} ({{ $labels.serial }})" + + - alert: SmartTemperatureHigh + expr: smart_temperature_celsius > 55 + for: 15m + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: диск {{ $labels.model }} греется: {{ $value }}°C" + + - alert: BackupFailed + expr: backup_last_exit_code != 0 + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте" + + # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней + - alert: BackupStale + expr: | + (time() - backup_last_success_timestamp{job="backup_onedrive"} > 12 * 3600) + or (time() - backup_last_success_timestamp{job="backup_onedrive_verify"} > 8 * 86400) + for: 10m + labels: {severity: critical} + annotations: + summary: "{{ $labels.instance }}: {{ $labels.job }} не было успешного прогона {{ $value | humanizeDuration }}" diff --git a/stacks/monitoring/pushgw-ingest b/stacks/monitoring/pushgw-ingest new file mode 100755 index 0000000..ef90f88 --- /dev/null +++ b/stacks/monitoring/pushgw-ingest @@ -0,0 +1,10 @@ +#!/bin/sh +# Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest "). +# Клиент передаёт только имя job (ssh ... ), метрики — в stdin в text-формате Prometheus. +# instance задаётся ключом, а не клиентом: хост не может писать метрики от чужого имени. +instance="$1" +job="$SSH_ORIGINAL_COMMAND" +case "$instance" in ""|*[!a-z0-9_-]*) echo "bad instance" >&2; exit 2;; esac +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/backup-metrics b/stacks/rpi-backup/bin/backup-metrics new file mode 100755 index 0000000..37746f7 --- /dev/null +++ b/stacks/rpi-backup/bin/backup-metrics @@ -0,0 +1,16 @@ +#!/bin/bash +# backup-metrics — из ExecStopPost юнита бэкапа: пушит итог прогона (job=backup_). +# last_success хранится локально: PUT в pushgateway заменяет группу целиком, +# и после неудачи время последнего успеха иначе бы потерялось. +set -uo pipefail +job="$1"; state=/var/lib/backup-metrics; mkdir -p "$state" +now=$(date +%s) +code=${EXIT_STATUS:-1}; [ "${SERVICE_RESULT:-}" = success ] && code=0 +[ "$code" = 0 ] && echo "$now" > "$state/$job.success" +last_ok=$(cat "$state/$job.success" 2>/dev/null || echo 0) +metrics-push "$job" < < metrics.prom — отправка в pushgateway на fr1. +# На fr1 ключ привязан к forced command ~/bin/pushgw-ingest rpi: можно только запушить, больше ничего. +exec 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 "$1" diff --git a/stacks/rpi-backup/bin/onedrive-backup b/stacks/rpi-backup/bin/onedrive-backup new file mode 100755 index 0000000..f838c65 --- /dev/null +++ b/stacks/rpi-backup/bin/onedrive-backup @@ -0,0 +1,30 @@ +#!/bin/bash +# OneDrive -> /mnt/backup. Ничего не удаляется навсегда: +# current/ — точное зеркало OneDrive +# archive/<время>/ — всё, что в OneDrive удалили или перезаписали (хранится вечно) +# manifests/ — quickxor-хэши архивных файлов (для onedrive-verify) +# logs/ — журнал каждого прогона: какие файлы скачаны/изменены/убраны в архив +set -euo pipefail +ROOT=/mnt/backup/onedrive +TS=$(date +%F_%H%M) + +mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; } +mkdir -p "$ROOT"/{current,archive,manifests,logs} +LOG="$ROOT/logs/sync-$TS.log" + +# rclone сам сверяет хэш каждого скачанного файла с хэшем OneDrive +rclone sync onedrive: "$ROOT/current" \ + --backup-dir "$ROOT/archive/$TS" \ + --exclude "/Personal Vault/**" \ + --transfers 4 --checkers 8 \ + --retries 5 --low-level-retries 20 \ + --log-file "$LOG" --log-level INFO \ + --stats 10m --stats-one-line + +if [ -d "$ROOT/archive/$TS" ]; then + rclone hashsum quickxor "$ROOT/archive/$TS" > "$ROOT/manifests/archive-$TS.qxh" + echo "в архив ушло: $(wc -l < "$ROOT/manifests/archive-$TS.qxh") файлов" +fi + +echo "OK: current=$(du -sh "$ROOT/current" | cut -f1), диск занят на $(df --output=pcent /mnt/backup | tail -1 | tr -d " ")" +[ "$(df --output=pcent /mnt/backup | tail -1 | tr -dc 0-9)" -lt 90 ] || { echo "ВНИМАНИЕ: диск заполнен >90%"; exit 2; } diff --git a/stacks/rpi-backup/bin/onedrive-verify b/stacks/rpi-backup/bin/onedrive-verify new file mode 100755 index 0000000..02f436c --- /dev/null +++ b/stacks/rpi-backup/bin/onedrive-verify @@ -0,0 +1,24 @@ +#!/bin/bash +# Еженедельная проверка целостности: перечитывает весь HDD и сверяет хэши +# current/ — с хэшами OneDrive (ловит битые/недокачанные/лишние файлы) +# archive/ — с сохранёнными манифестами (ловит порчу данных на диске) +set -uo pipefail +ROOT=/mnt/backup/onedrive +TS=$(date +%F_%H%M) +LOG="$ROOT/logs/verify-$TS.log" +mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; } +rc=0 + +rclone check onedrive: "$ROOT/current" --exclude "/Personal Vault/**" \ + --checkers 8 --combined "$ROOT/logs/verify-$TS.current.txt" \ + --log-file "$LOG" --log-level NOTICE || rc=1 + +for m in "$ROOT"/manifests/archive-*.qxh; do + [ -e "$m" ] || continue + d=$(basename "$m" .qxh); d=${d#archive-} + rclone checksum quickxor "$m" "$ROOT/archive/$d" --log-file "$LOG" --log-level NOTICE || { echo "архив $d: несовпадение"; rc=1; } +done + +grep -vc "^=" "$ROOT/logs/verify-$TS.current.txt" | xargs echo "расхождений в current:" +[ $rc -eq 0 ] && echo "OK: целостность подтверждена" || echo "ЕСТЬ РАСХОЖДЕНИЯ — см. $LOG и verify-$TS.current.txt" +exit $rc diff --git a/stacks/rpi-backup/bin/smart-metrics b/stacks/rpi-backup/bin/smart-metrics new file mode 100755 index 0000000..738e460 --- /dev/null +++ b/stacks/rpi-backup/bin/smart-metrics @@ -0,0 +1,36 @@ +#!/bin/bash +# SMART всех USB/SATA-дисков → pushgateway (job=smart). +# -n standby: спящий диск НЕ будим (иначе опрос раз в 15 мин не дал бы ему спать) — +# для него отдаём последние снятые значения из кэша и smart_disk_standby=1. +set -uo pipefail +CACHE=/var/lib/smart-metrics; mkdir -p "$CACHE" +out=$(mktemp); trap 'rm -f "$out"' EXIT +echo "# TYPE smart_disk_standby gauge" >> "$out" +for dev in /dev/sd?; do + [ -b "$dev" ] || continue + json=$(smartctl -d sat -n standby,7 -j -H -i -A -l selftest "$dev"); rc=$? + serial=$(udevadm info -q property -n "$dev" | sed -n 's/^ID_SERIAL_SHORT=//p') + [ -n "$serial" ] || continue + f="$CACHE/$serial.prom" + if [ $rc -eq 7 ]; then + standby=1 + else + standby=0 + echo "$json" | jq -r --arg dev "${dev#/dev/}" ' + (.model_name // "unknown") as $m | (.serial_number // "unknown") as $s | + "{model=\"\($m)\",serial=\"\($s)\",device=\"\($dev)\"" as $l | + "smart_health_passed\($l)} \(if .smart_status.passed then 1 else 0 end)", + "smart_temperature_celsius\($l)} \(.temperature.current // "NaN")", + "smart_power_on_hours\($l)} \(.power_on_time.hours // "NaN")", + (.ata_smart_self_test_log.standard.table[0]? // empty | + "smart_selftest_last_passed\($l)} \(if .status.passed then 1 else 0 end)"), + (.ata_smart_attributes.table[]? | + "smart_attribute_raw\($l),id=\"\(.id)\",name=\"\(.name)\"} \(.raw.value)", + "smart_attribute_value\($l),id=\"\(.id)\",name=\"\(.name)\"} \(.value)"), + "smart_last_read_timestamp\($l)} \(now|floor)" + ' > "$f.tmp" && [ -s "$f.tmp" ] && mv "$f.tmp" "$f" + fi + [ -f "$f" ] && cat "$f" >> "$out" + echo "smart_disk_standby{serial=\"$serial\",device=\"${dev#/dev/}\"} $standby" >> "$out" +done +metrics-push smart < "$out" diff --git a/stacks/rpi-backup/fstab.line b/stacks/rpi-backup/fstab.line new file mode 100644 index 0000000..c91626f --- /dev/null +++ b/stacks/rpi-backup/fstab.line @@ -0,0 +1 @@ +UUID=6eea3421-da3f-4a0f-b66d-26000b9a1b01 /mnt/backup ext4 defaults,noatime,nofail,x-systemd.device-timeout=30s 0 2 diff --git a/stacks/rpi-backup/smartd.conf b/stacks/rpi-backup/smartd.conf new file mode 100644 index 0000000..faaecc4 --- /dev/null +++ b/stacks/rpi-backup/smartd.conf @@ -0,0 +1,7 @@ +# smartd: следит за всеми дисками (DEVICESCAN подхватит и второй диск после хаба). +# -d removable — тип моста определяется сам; диска может не быть (не падать) +# -n standby,q — не будить спящий диск ради опроса +# -s — короткий самотест по субботам 03:00, длинный (~10ч на 4ТБ) 1-го числа в 05:00 +# -W 4,50,55 — фиксировать скачки температуры ≥4°, порог предупреждения 50, критичный 55 +# Почту не шлём (-m нет): тревоги идут через Prometheus на fr1 (smart-metrics → pushgateway). +DEVICESCAN -d removable -a -o on -S on -n standby,q -s (S/../../6/03|L/../01/./05) -W 4,50,55 diff --git a/stacks/rpi-backup/systemd/onedrive-backup.service b/stacks/rpi-backup/systemd/onedrive-backup.service new file mode 100644 index 0000000..159d4a2 --- /dev/null +++ b/stacks/rpi-backup/systemd/onedrive-backup.service @@ -0,0 +1,14 @@ +[Unit] +Description=OneDrive -> HDD backup (rclone) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/mnt/backup + +[Service] +Type=oneshot +User=rus +ExecStart=/usr/bin/flock /run/lock/onedrive-backup.lock /usr/local/bin/onedrive-backup +ExecStopPost=+/usr/local/bin/backup-metrics backup_onedrive +Nice=10 +IOSchedulingClass=idle +TimeoutStartSec=infinity diff --git a/stacks/rpi-backup/systemd/onedrive-backup.timer b/stacks/rpi-backup/systemd/onedrive-backup.timer new file mode 100644 index 0000000..8c1f621 --- /dev/null +++ b/stacks/rpi-backup/systemd/onedrive-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=OneDrive backup every 4h + +[Timer] +OnCalendar=*-*-* 00/4:30 +RandomizedDelaySec=5m +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/stacks/rpi-backup/systemd/onedrive-verify.service b/stacks/rpi-backup/systemd/onedrive-verify.service new file mode 100644 index 0000000..b2e4772 --- /dev/null +++ b/stacks/rpi-backup/systemd/onedrive-verify.service @@ -0,0 +1,14 @@ +[Unit] +Description=OneDrive backup integrity check +Wants=network-online.target +After=network-online.target onedrive-backup.service +RequiresMountsFor=/mnt/backup + +[Service] +Type=oneshot +User=rus +ExecStart=/usr/bin/flock /run/lock/onedrive-backup.lock /usr/local/bin/onedrive-verify +ExecStopPost=+/usr/local/bin/backup-metrics backup_onedrive_verify +Nice=15 +IOSchedulingClass=idle +TimeoutStartSec=infinity diff --git a/stacks/rpi-backup/systemd/onedrive-verify.timer b/stacks/rpi-backup/systemd/onedrive-verify.timer new file mode 100644 index 0000000..e46fb4f --- /dev/null +++ b/stacks/rpi-backup/systemd/onedrive-verify.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Weekly OneDrive backup integrity check + +[Timer] +OnCalendar=Sun *-*-* 05:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/stacks/rpi-backup/systemd/smart-metrics.service b/stacks/rpi-backup/systemd/smart-metrics.service new file mode 100644 index 0000000..b1b998d --- /dev/null +++ b/stacks/rpi-backup/systemd/smart-metrics.service @@ -0,0 +1,8 @@ +[Unit] +Description=Push SMART metrics to pushgateway on fr1 +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/smart-metrics diff --git a/stacks/rpi-backup/systemd/smart-metrics.timer b/stacks/rpi-backup/systemd/smart-metrics.timer new file mode 100644 index 0000000..3a9459f --- /dev/null +++ b/stacks/rpi-backup/systemd/smart-metrics.timer @@ -0,0 +1,9 @@ +[Unit] +Description=SMART metrics push every 15 min + +[Timer] +OnBootSec=2min +OnUnitActiveSec=15min + +[Install] +WantedBy=timers.target