Compare commits

27 Commits
Author SHA1 Message Date
gilfanovrandClaude Opus 5.5 880ad61253 rpi: удалена неиспользуемая DHCP-привязка 192.168.10.101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:05:05 +03:00
astra 7843c6e958 rpi: описать разрешённые занятия и возрастные правила Поли 2026-10-11 16:03:55 +03:00
gilfanovrandClaude Opus 5.5 60558dc4b3 rpi: роутер HG8245X6 — скилл router, сторож устройств и трафика, алерты home-network, дашборд home-net
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:59:58 +03:00
astra f9d027ba86 rpi: открыть доступ Поли по Telegram ID 2026-10-11 15:59:17 +03:00
astra 69c02162db rpi: описать постоянную сессию и журнал Поли 2026-10-11 15:54:44 +03:00
astra 6d7dc4c8f1 rpi: описать профиль Поли и ожидание её Telegram ID 2026-10-11 15:51:55 +03:00
astra 0ffcb63b3c rpi: описать исправление кнопок и ответов детской печати 2026-10-11 15:44:13 +03:00
astra 89ca3cea03 rpi: описать детскую печать единым PDF без подтверждений 2026-10-11 15:38:01 +03:00
gilfanovrandClaude Opus 5.5 facc6c1e65 monwatch: сторож мониторинга fr1 на rpi — раз в час health-проверка, тревога в Telegram напрямую
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:36:56 +03:00
astra b881d861f0 rpi: добавить создание PDF с кириллицей для домашней печати 2026-10-11 15:33:38 +03:00
astra bc0537a2d9 rpi: описать навык печати с обязательным предпросмотром 2026-10-11 15:28:38 +03:00
astra 931219ad47 rpi: подтвердить успешное завершение тестовой печати 2026-10-11 15:22:35 +03:00
gilfanovrandClaude Opus 5.5 d780c91bd5 rpi: ежемесячная выборочная перечитка файлов OneDrive с диска роутера (sha256) + алерты
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:21:32 +03:00
astra 629b11f03f rpi: настроить домашнюю печать на Brother DCP-L2620DW 2026-10-11 15:18:56 +03:00
gilfanovrandClaude Opus 5.5 4965f70299 alerts: BackupNeverReported (absent) — ловит задачи бэкапа, ни разу не отчитавшиеся
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:16:17 +03:00
gilfanovrandClaude Opus 5.5 464e12abdb rpi: вторая копия /mnt/backup на 1 ТБ диск в роутере (rclone SFTP) + мониторинг монтирования и заполнения
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:06:53 +03:00
astra 3811483577 rpi: описать удаление повторных уведомлений очереди Astra 2026-10-11 15:04:09 +03:00
gilfanovrandClaude Opus 5.5 550c359e4a Gitea: пользователь astra вместо двух deploy-ключей — один ключ, запись в infra/astra-bot, свои репо
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:57:13 +03:00
astra c1f16ebc38 rpi: описать обработку альбомов одной пачкой в Astra 2026-10-11 14:56:44 +03:00
gilfanovrandClaude Opus 5.5 0a6d728851 rpi: код бота — только в ruzzy/astra-bot; доступ astra к нему, временная копия из infra убрана
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:48:36 +03:00
astra bba3858894 rpi: исправить приём картинок и пересланных сообщений в Astra 2026-10-11 14:46:48 +03:00
gilfanovrandClaude Opus 5.5 7d0a891793 Бэкап серверов fr1/ru1 на rpi: сервер шифрует age открытым ключом, малина только хранит
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:44:05 +03:00
gilfanovrandClaude Opus 5.5 e645377916 rpi: агенту Astra разрешён прямой push в main infra
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:12:49 +03:00
gilfanovrandClaude Opus 5.5 ef2891323c rpi: агент Astra — Codex + Telegram-бот (ruzzy/astra-bot), грабли с Codex Remote на Linux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:07:30 +03:00
gilfanovrandClaude Opus 5.5 65a01740e8 grafana: дашборд RPi — диск и бэкап OneDrive; заполнение /mnt/backup в метриках
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:37:14 +03:00
gilfanovrandClaude Opus 5.5 2bb74f7430 rpi: пользователь astra (sudo) и deploy-ключ с записью в ruzzy/infra для агента
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:33:28 +03:00
gilfanovrandClaude Opus 5.5 d1269162e7 rpi: бэкап OneDrive на HDD + SMART/бэкап-метрики через pushgateway на fr1
- servers/rpi.md: новая домашняя Raspberry Pi 4 (Trixie, cloud-init), HDD 4 ТБ,
  грабли с питанием двух HDD от USB
- stacks/rpi-backup: rclone sync с вечным архивом версий, еженедельная сверка
  хэшей, smartd, пуш SMART и статуса бэкапов
- fr1: pushgateway (127.0.0.1:9091), приём по SSH через forced command
  ~/bin/pushgw-ingest, алерты группы rpi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:28:20 +03:00
40 changed files with 2693 additions and 3 deletions
+1
View File
@@ -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/`. Агент Astra (Codex) с управлением из Telegram — репо `ruzzy/astra-bot`.
## Поддержание описаний в актуальном состоянии
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
+18 -3
View File
@@ -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,15 +105,19 @@ 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}`).
- 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, **RPi — диск и бэкап OneDrive** (`rpi-disk.json`, с 2026-10-11) — папка Infra) провижатся автоматически. Пароль admin — в `~/monitoring/docker-compose.yml` на сервере (в репо вынесен в `${GF_ADMIN_PASSWORD}`).
- **Тома:** `monitoring_prometheus_data` — **6.5 ГБ** (при retention 90d; самый крупный том на сервере, учитывать при планировании диска), `monitoring_grafana_data` — 50 МБ, `monitoring_alertmanager_data` — пустой.
- **Multi-server:** таргеты через file_sd (`prometheus/targets/{node,cadvisor}/*.yml`) — новый сервер = добавить файл + `curl -X POST .../-/reload`. См. `stacks/monitoring/README.md`.
- **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 <job> < metrics.prom`, forced command `~/bin/pushgw-ingest <instance>` делает `PUT /metrics/job/<job>/instance/<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.
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
@@ -127,6 +132,8 @@ backend=systemd banaction=ufw
- `ROOT_URL`/`SSH_DOMAIN` = `git.ruzzy.dev` (не менялись). Host-ключи SSH и user/deploy-ключи перенесены с данными — `ssh://git@git.ruzzy.dev:2222` работает как раньше.
- Docker-томов нет — все данные на bind-mount `/srv/gitea` (1.9 ГБ), это и есть точка бэкапа.
- Это origin репо `infra` и vault openclaw.
- **Пользователь `astra`** (id 5, с 2026-10-11) — агент на домашней малине (`servers/rpi.md`): коллаборатор с записью в `ruzzy/infra` и `ruzzy/astra-bot`, свои репо в `astra/…`, постоянный токен `astra-rpi` (хранится только на малине). Пароль случайный и нигде не сохранён — вход только ключом/токеном; сбросить — `gitea admin user change-password`. Deploy-ключи astra (id 10, 11) удалены.
- Как сделать разовую операцию через API без следов: `gitea admin user generate-access-token --scopes write:repository --raw` (конфиг по умолчанию верный — работающая Gitea тоже читает `/data/gitea/conf/app.ini`, БД `/data/gitea.db`) → `POST /api/v1/repos/<owner>/<repo>/keys` → удалить токен прямо из БД: `docker exec -u 1000 gitea sqlite3 /data/gitea.db "delete from access_token where name='<имя>'"` (API требует basic-auth, как выяснилось с imgbot-токенами). Проверено 2026-10-11: после удаления токен получает 401.
## AmneziaWG exit (для ru1) — перенос VPN, фаза 1
- Зеркало exit-узла `de1`: AmneziaWG server `wg0` = **10.8.1.0/24**, ListenPort **36360/udp**, те же server-ключи/PSK и obfuscation-параметры (Jc/Jmin/Jmax/S1/S2/H1-4), что и на `de1` (server-pubkey `Lumb1o…`) → на `ru1` свитч = только смена Endpoint.
@@ -230,6 +237,14 @@ backend=systemd banaction=ufw
- Таймеры `fwupd-refresh.timer` (раз в сутки ходил в сеть за метаданными прошивок LVFS) и `motd-news.timer` (`/etc/default/motd-news`: `ENABLED=1`, `URLS=https://motd.ubuntu.com` — раз в сутки запрашивал у Canonical новостной текст для приветствия) — `disabled`.
- Демоны `ModemManager`, `fwupd`, `multipathd`, `udisks2` — `masked`. Все четыре висели с 0.0% CPU и ~100 МБ RSS суммарно, и обслуживать им было нечего: multipath-устройств нет (`/dev/mapper` — только `control`, диск один), модемов нет (в `/sys/class/net` только `eth0`, `wg0`, `lo` и docker-интерфейсы), прошивок в KVM-гостe нет, съёмных носителей тоже.
## Бэкап (с 2026-10-11)
До этого регулярных бэкапов fr1 **не было** (только разовый снимок Gitea перед апгрейдом).
- Домашняя `rpi` каждую ночь (02:15 MSK) забирает зашифрованную выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/backup-export"` (бэкап прежнего файла — `authorized_keys.bak-20261011b`).
- `~/bin/backup-export` читает root-файлы через `docker run --rm --network none -v /:/host:ro debian:stable-slim` — sudo тут по паролю, а `rus` в группе docker. Шифрует `~/bin/age` (статический v1.3.2) открытым ключом; закрытый только у владельца.
- daily: `/etc`, `/srv/gitea` + согласованный `.backup` `gitea.db` (живой файл исключён), `/usr/local`, `~rus` (без backups/openclaw/кэшей), `/root`, crontab. weekly (вс): тома `claude-channels_claude-home`, grafana, alertmanager, pushgateway + `~tgclaude` (без backups/openclaw-deploy/.local).
- ⚠️ Prometheus (том 7.9 ГБ) **не бэкапится** сознательно. ⚠️ Новые важные данные вне перечисленных путей в бэкап не попадут — добавлять в `fr1-backup-export`.
- В образах добавился `debian:stable-slim` (нужен для выгрузки).
## Открытые хвосты (на 2026-09-08)
Мелочи, оставленные сознательно, — чтобы не искать их заново.
- ⚠️ **В Gitea живут два временных токена** — `imgbot-setup` и `imgbot-setup2`, scope `write:repository,write:user`. Созданы через `docker exec -u 1000 gitea gitea admin user generate-access-token` для создания репо `ruzzy/imgbot` и deploy-ключа. Удалить через API не удалось: Gitea отвечает 401 на управление токенами по токен-авторизации, нужна basic-auth. **Отозвать вручную:** Настройки → Приложения.
+122
View File
@@ -0,0 +1,122 @@
# 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/<ts>/` **навсегда** (пользователь хочет иметь всё, что когда-либо было в OneDrive). Хэши архива — `manifests/archive-<ts>.qxh`, лог каждого прогона — `logs/sync-<ts>.log` (INFO: поштучно что скачано/убрано в архив).
- Целостность: rclone сверяет QuickXorHash каждого скачанного файла с OneDrive; `onedrive-verify` (вс 05:00) перечитывает весь HDD: `rclone check` current↔облако + `rclone checksum` archive↔манифесты. Отчёт — `logs/verify-<ts>*`.
- Оба юнита под общим `flock /run/lock/onedrive-backup.lock` — не идут параллельно и не убивают друг друга.
- ⚠️ Файл, загруженный и удалённый в облаке между прогонами (<4 ч), в бэкап не попадёт.
- ⚠️ `Personal Vault` исключён — rclone через API туда не ходит.
- ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter.
- Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал).
## Вторая копия — 1 ТБ диск в роутере (с 2026-10-11)
- Диск воткнут в роутер Huawei (`192.168.10.254`), а не в малину — **не ест питание USB малины** (два HDD от неё не тянут, см. «Диски»). Доступ — SFTP роутера на порту **8022**, пользователь `root`, remote `router:` в `~rus/.config/rclone/rclone.conf` (пароль только там), host key — `~rus/.ssh/known_hosts_router`.
- ⚠️ Роутер не считает хэши (`shell_type = none`, `md5sum_command = none`) — rclone сравнивает по размеру+mtime, целостность проверяется перечиткой.
- `mnt-router.service` — `rclone mount router: /mnt/router` (user rus, `--vfs-cache-mode writes`, кэш в `/mnt/backup/.rclone-cache`). Для просмотра/ручной работы; бэкап пишет **напрямую в remote**, не через FUSE (коды ошибок, без кэша).
- Скорость (замер 2026-10-11, параллельно с выгрузкой OneDrive): запись ~8 МБ/с, чтение ~11 МБ/с — первичная копия ~370 ГБ ≈ 13 ч. Диск 932 ГБ.
- `router-backup` (`stacks/rpi-backup/`, таймер 06:00, `flock`): в `router:rpi-backup/` — `onedrive/{archive,manifests,logs}` только `copy` (там ничего не удаляется), затем зеркало `onedrive/current` с `--max-delete 1000`, затем зеркало зашифрованных `servers/` с `--max-delete 50`. Порядок важен: удалённое из current к этому моменту уже скопировано в archive. Логи — `/mnt/backup/router-logs/`.
- ⚠️ `--max-delete` — предохранитель: если локально «пропадёт» много файлов (сбой диска/монтирования), копия на роутере не зачистится, а прогон упадёт с ошибкой → алерт `BackupFailed`.
- По воскресеньям `verify`: сверка размеров деревьев + перечитать с роутера последний daily каждого сервера и сравнить sha256.
- **Ежемесячно (1-го, 08:00) `router-sample`**: случайные файлы OneDrive из `current/` и `archive/` старше суток, ~2 ГБ / до 300 шт., перечитываются с роутера и сверяются sha256 с HDD — ловит тихую порчу, которую сверка размеров не видит. Отсутствующий на роутере файл = ошибка. Итог — `backup_*{job="backup_router_sample"}` → `BackupFailed`, `BackupStale` (>35 дней). Проверено 2026-10-11: 20 из 20 скопированных файлов совпали.
- Мониторинг: `router_mount_up` и заполнение `/mnt/router` (в `smart-metrics`, `df` под `timeout 20`), итог прогона — `backup_*{job="backup_router"}`; алерты `RouterDiskMountDown` (30 мин), `BackupDiskFull` (>90 %, любой диск бэкапа), `BackupStale` (>30 ч); ряд «Вторая копия» на дашборде rpi-disk.
- ⚠️ 1 ТБ меньше основного диска: сейчас влезает (~370 ГБ OneDrive + ~100 ГБ серверов), но архив версий растёт вечно — следить за `BackupDiskFull`.
## Бэкап серверов fr1/ru1 (с 2026-10-11, код — `stacks/server-backup/`)
- Малина — внешняя площадка для VPS: `servers-backup.timer` (02:15) забирает по SSH выгрузки, которые **сервер сам шифрует открытым ключом age** (`tar | zstd | age`). Закрытого ключа на малине нет — ни она, ни Astra, ни взломщик архивы не прочитают. Ключ у владельца: Mac `~/.config/age/servers-backup.key`.
- Хранилище `/mnt/backup/servers/<host>/{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot.
- Ключ `/etc/server-backup/id_ed25519` (root 600): на серверах ограничен forced command — только запустить выгрузку. Host keys закреплены в `/etc/server-backup/known_hosts`.
- Целостность: sha256 шифротекста считает сервер, малина сверяет при приёме и по воскресеньям (`servers-backup verify`). Итог — метрики `backup_*{job="backup_servers"}`, алерт `BackupStale` >26 ч, панель на дашборде rpi-disk.
- Пробное восстановление 2026-10-11 на Mac: оба архива расшифрованы, на месте CA OpenVPN (`easy-rsa/pki/private/ca.key`), ключи AWG, конфиги прокси, репо gitea, снимок `gitea.db`.
## Мониторинг → 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 <job>` — `ExecStopPost=+` у юнитов бэкапа: `backup_last_exit_code`, `backup_last_run_timestamp`, `backup_last_success_timestamp` (время успеха хранится локально в `/var/lib/backup-metrics/`, т.к. PUT заменяет группу целиком).
- Доставка: `metrics-push <job>` = 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` → Мониторинг).
- `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)
- Brother DCP-L2620DW обнаружен через mDNS: `192.168.10.24`, `BRW2C9C58C1EEA7.local`, IPP порт 631 `/ipp/print`; чёрно-белый, A4, двусторонняя печать. Ранее службы печати на rpi не было.
- Установлены `cups`, `cups-client`, `cups-filters`, `cups-ipp-utils` (CUPS 2.4.10); включён `cups.service`. Очередь по умолчанию `home-brother`, драйвер `everywhere`, URI `ipp://BRW2C9C58C1EEA7.local:631/ipp/print`. По умолчанию A4, одна сторона; `printer-is-shared=false`, CUPS слушает только `127.0.0.1:631` и `[::1]:631`. Доступ из Telegram — команда агента, без отдельного открытого веб-интерфейса.
- Код настройки и команда — `stacks/rpi-print/`; `home-print` установлен в `/usr/local/bin/` (root, 755). `home-print status` — состояние/очередь; `home-print [--duplex] <PDF/PNG/JPEG>` — одна копия, A4 с подгонкой. Печать только по прямой просьбе пользователя; приём задания в очередь не означает, что бумага уже вышла.
- **Навык `home-print` упрощён для ребёнка 2026-10-11**; источник — [astra-bot `11d0a85`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/11d0a85), `codex/skills/home-print/`, установлен в `~astra/.codex/skills/home-print/`. Вместо PNG каждой страницы и повторного подтверждения отправляет **один PDF**, затем по прямой просьбе «распечатай» печатает одну копию A4 с одной стороны без дополнительных вопросов. Текст извлекается из скриншотов, картинки сохраняются в оттенках серого, все страницы проверяются локально. Если PDF не доставлен, печать не запускается; повторная отправка задания при ошибке не автоматическая. Для этого сценария установлены узкие постоянные разрешения `~astra/.codex/rules/home-print.rules` (источник `codex/rules/` в astra-bot): только `/usr/local/bin/home-print` и PDF-режим штатного отправщика. Общая политика подтверждений сохранена. Проверка: 31 тест, валидация скилла и правил; произвольный Python правилами не разрешается. Инструкции установлены в `~/.codex/AGENTS.md`; новые разговоры получают их автоматически.
- **Кнопки и промежуточные ответы печати убраны в самом боте 2026-10-11**, [astra-bot `6b1317d`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6b1317d). Ранее постоянных правил скилла было недостаточно: запросы approval и все agentMessage уходили в Telegram. Бот теперь автоматически отвечает accept только на штатную PDF-отправку разрешённому чату и home-print для print.pdf в каталогах /tmp/astra-print-*; shell-операторы и произвольные команды исключены. В задачах печати сохраняет последний ответ и отправляет его после завершения, PDF доставляется отдельным вложением. Проверено 34 тестами, установлен bot.py; прежний код — /opt/astra-bot/bot.py.bak-20261011-child-print. Перезапуск через restart-check.py после завершения текущего ответа.
- ⚠️ DOCX напрямую не печатается — сначала преобразовать в PDF. Hostname выбран вместо DHCP-IP, чтобы пережить смену адреса; требуется mDNS и включённый принтер. `printer-error-policy=abort-job` исключает неожиданную автоматическую печать старых заданий после ошибки; ошибочный прогон нужно отправить заново.
- Проверено: запрос атрибутов IPP (`ipptool`) PASS, принтер idle/enabled, A4/Gray/duplex доступны. Портрет успешно преобразован фильтрами CUPS PNG → PDF → PWG Raster (A4, 300 dpi), без ошибок. Тестовая страница A4 отправлена 2026-10-11 в 15:20 MSK: `home-brother-1`, CUPS сообщил `job-completed-successfully`, принтер вернулся в idle, очередь пуста; качество на бумаге проверяет пользователь.
- Для перевёрстки извлечённого текста в PDF установлен `python3-reportlab` 4.3.1 (2026-10-11; ранее средств создания PDF с кириллицей не было), с зависимостями Pillow/Cairo. Шрифт DejaVu Sans внедряется в PDF; PNG-предпросмотр строится из итогового серого PDF. Проверено на комплекте Лермонтова: 3 страницы A4, текст со скриншотов и исходный портрет.
## Агент Astra (с 2026-10-11)
Агент на базе **Codex** (OpenAI), работает на самой малине, управление — из Telegram-бота **@rasp_contol_bot**. Код бота — отдельный репо **`ruzzy/astra-bot`** (там же схема работы).
- Пользователь `astra` (группы `sudo`, `adm`, `systemd-journal`, `users`), пароль заблокирован, **sudo NOPASSWD** (`/etc/sudoers.d/90-astra`) — по решению пользователя. SSH-вход под ним — ключом `~/.ssh/id_ed25519` с Mac (алиас `ssh rpi-astra`).
- ⚠️ sudo = агент видит всё: токен OneDrive (`~rus/.config/rclone/rclone.conf`), пуш-ключ метрик (`/etc/metrics-push/`), deploy-ключ с записью в `infra`, токен бота. Утечка контекста агента = утечка этих секретов.
- **Codex CLI 0.162.1** — полный пакет в `/opt/codex`, симлинк `/usr/local/bin/codex`. ⚠️ Голый бинарник из релиза (`codex-aarch64-…tar.gz`) не годится: `remote-control`/app-server требуют пакет (`codex-package-aarch64-unknown-linux-musl.tar.gz`), иначе «this CLI has no complete local package». Логин — подпиской ChatGPT (`codex login --device-auth`, аккаунт ruzzyrullezz@gmail.com, Plus), токен `~astra/.codex/auth.json` (600). Ключ API не используется.
- **Агенту разрешено пушить в `main` `ruzzy/infra` напрямую** (решение пользователя 2026-10-11, перекрывает правило из `CLAUDE.md` только для него): любые изменения сервера он обязан внести в `servers/rpi.md`/`stacks/` и запушить в том же прогоне, конфликты — `pull --rebase` без `--force`, неразрешимые — отдать пользователю. Источник инструкций — `codex/AGENTS.md` в `ruzzy/astra-bot`.
- Конфиг `~astra/.codex/config.toml`: `approval_policy=on-request`, `sandbox_mode=workspace-write`; инструкции агенту — `~astra/.codex/AGENTS.md` (паспорт, правила infra, что не трогать).
- **astra-bot** (`astra-bot.service`, user `astra`): `/opt/astra-bot/bot.py` ⇄ `codex app-server` (JSON-RPC stdio) ⇄ Telegram long polling. Подтверждения команд/правок — кнопками в чате. Конфиг `/etc/astra-bot.env` (root 600): `TG_TOKEN`, `TG_ALLOWED_IDS=607015,6310034441`. Тред Codex на чат — `~astra/.local/state/astra-bot.json`. Бот лежит в `/opt` под root, а не в `~/infra`, чтобы агент не правил свой код без `sudo`.
- **Приём вложений исправлен 2026-10-11** (раньше бот читал только `text`, фото и подписи молча пропадали): код — `ruzzy/astra-bot`. Фото и JPEG/PNG/WebP-документы до 20 МиБ скачиваются через Telegram `getFile` в `~astra/.local/share/astra-bot/incoming/` (файлы 600, UUID), передаются Codex как `localImage`; подпись и источник пересылки — текстом. Пересланные `/stop` и другие команды читаются как цитата. Во время работы сообщения ставятся в очередь процесса, реальная доставка картинки с подписью и источником подтверждена пользователем 2026-10-11.
- **Пачки изображений добавлены 2026-10-11** (раньше каждый элемент альбома запускал отдельную задачу и отдельное уведомление очереди): установлен [коммит `6276bf8`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/6276bf8). Бот собирает альбом по `media_group_id`/чату/отправителю, быстро пересланные картинки без ID — по чату/отправителю; после 1,5 секунды тишины передаёт все картинки, подписи и источники одним запросом Codex в порядке `message_id`. Служебные уведомления ожидания убраны 2026-10-11 ([коммит `c4e3cd1`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/c4e3cd1)): прежняя проверка пустой очереди повторяла их между пачками; очередь сохранена и работает молча. Проверка включает повторное заполнение опустевшей очереди. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-silent-queue`. 23 теста прошли; изменение загружается при перезапуске через `restart-check.py`. ⚠️ Пауза больше 1,5 секунды разделяет серию на пачки; буфер и очередь не переживают перезапуск. Предыдущий код — `/opt/astra-bot/bot.py.bak-20261011-batches`.
- Код бота с доработкой Astra перенесён в `ruzzy/astra-bot` (`f9f6c39`; текущая установленная версия — `c4e3cd1`); временная копия `stacks/astra-bot/` удалена 2026-10-11. Клон — `~astra/astra-bot`. Резервная копия прежнего кода — `/opt/astra-bot/bot.py.bak-20261011-incoming`.
- ⚠️ **Почему не штатный Codex Remote из ChatGPT:** телефон к безголовому Linux не сопрягается — официально хостом может быть только macOS/Windows; `codex remote-control pair` выдаёт код, но и QR, и ручной ввод дают «Pair Failed», до демона попытка даже не доходит (проверено с debug-логом 2026-10-11). Рабочая альтернатива — ChatGPT Desktop на Mac → SSH-хост `rpi-astra`, но тогда Mac должен быть включён.
- `/boot/firmware/cmdline.txt`: добавлен `cfg80211.ieee80211_regdom=RU` (бэкап `.bak-20261011`) — только чтобы `/etc/profile.d/wifi-check.sh` не печатал в stdout login-shell предупреждение про rfkill: оно ломало бы stdio-протоколы (Codex app-server по SSH). Wi-Fi не используется.
- **Доступ к Gitea — пользователь `astra`** (с 2026-10-11, вместо двух deploy-ключей): один SSH-ключ `~astra/.ssh/gitea`, коллаборатор с записью в `ruzzy/infra` (`~astra/infra`) и `ruzzy/astra-bot` (`~astra/astra-bot`), может создавать свои репо в `astra/…` (API-токен `~astra/.config/gitea/token`, 600, scopes `write:repository,write:user`, в Gitea — `astra-rpi`). Почему пользователь, а не deploy-ключи: deploy-ключом нельзя создавать репо, а права коллаборатора видны и меняются в настройках репо. Создавать репо от имени `ruzzy` нельзя — он пользователь, не организация. git identity `Astra (rpi) <astra@rpi.ruzzy.dev>`, host key Gitea (RSA) закреплён. Автопулла нет.
- Профиль Поли (@Polli4ka303), 10 лет, сохранён в инструкциях 2026-10-11: простые короткие ответы без технических терминов; источник [astra-bot `88b88a5`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/88b88a5). Детская печать уточнена: единый PDF → «Печатай» / «Печатай на двух сторонах» → печать без системных кнопок. Доступ добавлен 2026-10-11: Telegram ID `6310034441` внесён в TG_ALLOWED_IDS (ранее только владелец `607015`), профиль закреплён за ID вместо username; [astra-bot `2d761d3`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/2d761d3). Настройка применяется после перезапуска бота.
- Постоянная сессия и журнал Поли добавлены 2026-10-11, [astra-bot `8b3400a`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/8b3400a). После авторизации её чат сохраняется в state.child_chats, тред возобновляется после перезапуска; служебные slash-команды не сбрасывают разговор и не показывают справку администратора. Бот передаёт возраст и текущий chat_id в контекст, отправляет только итоговый ответ. Входящие сообщения, метаданные/пути вложений и текстовые ответы пишутся в ~/.local/share/astra-bot/conversations/<chat_id>.jsonl (файл 600, каталог 700); PDF, отправленный отдельным скриптом, в этот журнал не записывается. Имя пользователя выбирает профиль только после проверки TG_ALLOWED_IDS и не предоставляет доступ. Проверка: 37 тестов; резервный код /opt/astra-bot/bot.py.bak-20261011-child-session. ⚠️ При невозможности resume бот создаёт новый тред; локальный журнал остаётся, автоматического импорта журнала в контекст нет.
## Роутер Huawei HG8245X6 — скилл `router` (с 2026-10-11)
- Роутер — **OptiXstar HG8245X6-10**, оптический терминал Cyta (GPON), ПО V5R022C10S110, `192.168.10.254`. Интернет — PPPoE `1_INTERNET_R_VID_42` (публичный IP динамический, на 2026-10-11 — 46.199.208.45); ещё WAN TR069, VOIP, IPTV. За LAN3 — mesh-точки M15/E15 (Wi-Fi-клиенты за ними видны как `lan3`), на LAN4 — малина.
- Управление — Codex-скилл Astra `router`, код в `ruzzy/astra-bot` (`codex/skills/router/`), команда `/usr/local/bin/router`. Креды веб-интерфейса — `/etc/router.env` (root:astra 640), в git нет. Умеет: статус/WAN, клиенты (IPv4 из аренд DHCP, сигнал Wi-Fi), трафик по интерфейсам, DHCP static add/del, проброс портов add/del (через WAN INTERNET), Wi-Fi — только чтение. Изменения — только `--yes` и кнопкой (`codex/rules/router.rules` разрешает лишь чтение).
- ⚠️ **3 неудачных входа подряд блокируют вход в роутер** (`errloginlockNum = '3'`). `login.cgi` разбирает поля позиционно — порядок как у браузера (UserName, PassWord, Language, x.X_HW_Token), иначе вход «неудачный». login не ретраится. Веб-сервер однопоточный и рвёт соединения при частых запросах — чтение ретраится с паузой; выход (`logout.cgi`) всегда отвечает обрывом, это норма. После каждой операции — выход, чтобы не забивать лимит сессий и не выбивать человека из веб-интерфейса.
- ⚠️ Трафик **по устройствам прошивка не считает** (поля `TrafficRecvRate/SendRate` всегда 0) — только по интерфейсам: `wan` (сумма GEM Ethernet), `iptv`, `lan1..4`, `wifi-2.4`, `wifi-5`.
- ⚠️ Запись Wi-Fi не сделана сознательно: SSID 2.4/5 ГГц связаны (WifiCover/band steering), запрос — десятки полей; ошибка отключит весь дом. Делать отдельно, в согласованное окно.
- `router-watch.timer` (5 мин, root): снимок `/var/lib/router-watch/state.json` (читает astra без сети), `seen.json` (все MAC с первым появлением; база — 22 устройства на 2026-10-11 15:57), `known.json` (имена от пользователя), `events.log`. Новый MAC → Telegram ботом @rasp_contol_bot с флагами (случайный MAC, ночь, без имени, ≥3 новых за час). Метрики `router_*` → pushgateway (job `router`) → алерты группы `home-network`: `RouterWatchStale` (>20 мин), `RouterDownloadSpike`/`RouterUploadSpike` (WAN: 15-мин скорость >4× средней за неделю и >5 МБ/с ↓ / >1 МБ/с ↑, 30 мин). Дашборд https://mon.ruzzy.dev/d/home-net.
- Проверено на живом роутере 2026-10-11: чтение всех разделов; `dhcp add/del` (тестовая привязка 02:00:00:00:00:01 → .250 создана и удалена), `portmap add/del` (TCP 59999 → rpi создан и удалён).
- Статическая привязка DHCP `192.168.10.25` ↔ `dc:a6:32:db:b0:f9` (малина) на роутере уже есть — единственная. Привязка `192.168.10.101` ↔ `8e:a9:3a:4e:69:ba` удалена 2026-10-11: устройства с таким MAC нет ни в сети, ни в истории роутера (вероятно, старый телефон со сменившимся случайным MAC).
- Свободная беседа, интернет-поиск, объяснение школьного материала, поиск и генерация картинок для Поли разрешены 2026-10-11; правила установлены в ~/.codex/AGENTS.md, источник [astra-bot `95a2867`](https://git.ruzzy.dev/ruzzy/astra-bot/commit/95a2867). Содержание подбирается для 10 лет, материалы 16+/18+ исключаются; выбранные картинки проверяются перед отправкой. Это инструкции агента, не отдельный сетевой фильтр. Разрешение не распространяется на управление сервером и доступами.
## Открытые хвосты (на 2026-10-11)
- ⏳ USB3-хаб с питанием → подключить и отформатировать WD Elements 1 ТБ (вторая копия архива — сейчас копия одна).
- Нет node-exporter (CPU/RAM/диск самой малины в Prometheus не видны) — пуш через pushgateway для node-метрик неудобен; решить, нужен ли.
- Первый прогон бэкапа стартовал до появления `ExecStopPost` — его итог может не дойти в метрики; `backup_last_success_timestamp` появится с первого прогона по таймеру. До этого `BackupStale` молчит (серии нет).
- ⏳ `codex-remote.service` (демон `codex remote-control` с `RUST_LOG=debug`) всё ещё включён и работает, хотя сопряжение не взлетело — убрать или оставить ждать поддержки Linux от OpenAI (решение за пользователем).
- Токен бота передавался через чат с ассистентом — при желании перевыпустить в @BotFather (`/revoke`) и вписать в `/etc/astra-bot.env`.
- ⚠️ **Закрытый ключ age бэкапа серверов — одна копия, на Mac.** Положить вторую вне малины и вне Mac (менеджер паролей/распечатка), иначе потеря Mac = бэкапы fr1/ru1 бесполезны.
- Разово скопировать на малину `~rus/backups` (5.4 ГБ) и `~tgclaude/backups` (4.2 ГБ) с fr1 — тогда их можно убрать с fr1.
- Пробное восстановление бэкапа серверов — раз в квартал (следующее ~январь 2027), см. `stacks/server-backup/README.md`.
- Через неделю снять SMART и посчитать реальный прирост Start_Stop/Load_Cycle — прогноз ресурса пока оценочный.
- ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске.
+5
View File
@@ -86,6 +86,11 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D
- `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`).
- Таблица маршрутизации — `vpn` (100), не `awg`.
## Бэкап (с 2026-10-11)
- Домашняя `rpi` каждую ночь забирает зашифрованную (age, открытый ключ) выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~mik/.ssh/authorized_keys` с `restrict,command="sudo -n /usr/local/sbin/backup-export"` (бэкап — `authorized_keys.bak-20261011`).
- `/usr/local/sbin/backup-export` (root 750) пакует `/etc`, `/opt`, `/srv`, `/usr/local`, `~mik` (без сборок badvpn/telemt-build, логов, `.claude`), `/root`, crontab; `/usr/local/bin/age` — статический v1.3.2. Архив ~17 МБ.
- Главное в нём — **`~mik/easy-rsa` (CA OpenVPN)**: без него пришлось бы перевыпускать все 43 клиентских конфига.
## Открытые хвосты
- **Умный роутинг для `wg1` не настроен** (2026-10-09): mangle-правила есть только для `-i tun0`, для `wg1` / `10.8.2.0/24` — ни одного, хотя раздел «Умный роутинг» заявляет tun0 и wg1. В `wg1` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили.
- Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило.
+17
View File
@@ -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:
@@ -0,0 +1,323 @@
{
"uid": "home-net",
"title": "Домашняя сеть (роутер)",
"tags": [
"rpi",
"router"
],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "5m",
"time": {
"from": "now-24h",
"to": "now"
},
"editable": true,
"templating": {
"list": [
{
"name": "iface",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"query": {
"query": "label_values(router_iface_bytes_total, iface)",
"refId": "i"
},
"refresh": 2,
"includeAll": true,
"multi": true,
"current": {
"text": "wan",
"value": "wan"
}
}
]
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Клиентов онлайн",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_clients_online",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 2,
"type": "stat",
"title": "Онлайн без имени (не в known.json)",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"description": "Устройства, которым ещё не дали имя через `router known add`.",
"targets": [
{
"refId": "A",
"expr": "router_clients_unknown_online",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 3,
"type": "stat",
"title": "Wi-Fi 2.4 ГГц",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_wifi_stations{band=\"wifi-2.4\"}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 4,
"type": "stat",
"title": "Wi-Fi 5 ГГц",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"description": "",
"targets": [
{
"refId": "A",
"expr": "router_wifi_stations{band=\"wifi-5\"}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "none",
"graphMode": "area",
"textMode": "value"
}
},
{
"id": 5,
"type": "timeseries",
"title": "Загрузка (↓ rx) по интерфейсам",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"rx\"}[5m])",
"legendFormat": "{{iface}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"id": 6,
"type": "timeseries",
"title": "Отдача (↑ tx) по интерфейсам",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"expr": "rate(router_iface_bytes_total{iface=~\"$iface\",dir=\"tx\"}[5m])",
"legendFormat": "{{iface}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Клиенты онлайн",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 7
},
"targets": [
{
"refId": "A",
"expr": "router_clients_online",
"legendFormat": "всего"
},
{
"refId": "B",
"expr": "router_clients_unknown_online",
"legendFormat": "без имени"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"lineWidth": 2,
"fillOpacity": 10,
"spanNulls": true
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
}
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -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"]
@@ -35,3 +35,157 @@ 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 дней; бэкап серверов ежедневно → 26ч; копия на роутер ежедневно → 30ч (первичная ~13ч)
- 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)
or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600)
or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600)
or (time() - backup_last_success_timestamp{job="backup_router_sample"} > 35 * 86400)
for: 10m
labels: {severity: critical}
annotations:
summary: "{{ $labels.instance }}: {{ $labels.job }} не было успешного прогона {{ $value | humanizeDuration }}"
# /mnt/backup (HDD 4 ТБ) и /mnt/router (1 ТБ диск в роутере) — заполнение
- alert: BackupDiskFull
expr: 1 - backup_disk_avail_bytes / backup_disk_size_bytes > 0.9
for: 15m
labels: {severity: warning}
annotations:
summary: "{{ $labels.instance }}: диск бэкапа {{ $labels.mountpoint }} заполнен на {{ $value | humanizePercentage }}"
- alert: RouterDiskMountDown
expr: router_mount_up == 0
for: 30m
labels: {severity: warning}
annotations:
summary: "{{ $labels.instance }}: диск в роутере (/mnt/router, SFTP) недоступен 30+ мин — вторая копия бэкапа не пишется"
# BackupStale видит только существующие серии: если задача ни разу не отчиталась
# (таймер сломан, первый прогон упал до хука) — серии нет, и он молчит. Ловим отсутствие.
- alert: BackupNeverReported
expr: |
absent(backup_last_success_timestamp{instance="rpi",job="backup_onedrive"})
for: 12h
labels: {severity: critical, instance: rpi, job: backup_onedrive}
annotations:
summary: "rpi: синхронизация OneDrive ни разу не отчиталась об успехе за 12+ ч — проверь onedrive-backup"
- alert: BackupNeverReported
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_servers"})
for: 26h
labels: {severity: critical, instance: rpi, job: backup_servers}
annotations:
summary: "rpi: бэкап серверов fr1/ru1 не отчитывался 26+ ч — проверь servers-backup"
- alert: BackupNeverReported
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_router"})
for: 30h
labels: {severity: critical, instance: rpi, job: backup_router}
annotations:
summary: "rpi: копия на диск роутера не отчитывалась 30+ ч — проверь router-backup"
- alert: BackupNeverReported
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_onedrive_verify"})
for: 8d
labels: {severity: warning, instance: rpi, job: backup_onedrive_verify}
annotations:
summary: "rpi: проверка целостности OneDrive не отчитывалась 8+ дней — проверь onedrive-verify"
- alert: PushMetricsStale
expr: absent(push_time_seconds{instance="rpi",job="smart"})
for: 1h
labels: {severity: warning, instance: rpi, job: smart}
annotations:
summary: "rpi: метрики SMART в pushgateway отсутствуют 1+ ч — малина/пуш лежат или pushgateway потерял данные"
- alert: BackupNeverReported
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_router_sample"})
for: 35d
labels: {severity: warning, instance: rpi, job: backup_router_sample}
annotations:
summary: "rpi: ежемесячная перечитка файлов с диска роутера не отчитывалась 35+ дней — проверь router-sample"
# Домашняя сеть: роутер Huawei HG8245X6 (ONT Cyta), сторож router-watch на rpi раз в 5 мин.
# По отдельным устройствам прошивка трафик не считает — только по интерфейсам (wan, lan*, wifi-*).
- name: home-network
rules:
- alert: RouterWatchStale
expr: time() - push_time_seconds{job="router"} > 20 * 60
for: 10m
labels: {severity: warning}
annotations:
summary: "rpi: сторож роутера не присылал данных {{ $value | humanizeDuration }} — router-watch или вход на роутер сломан"
# всплеск = скорость за 15 мин в 4+ раза выше средней за неделю И выше абсолютного порога
- alert: RouterDownloadSpike
expr: |
rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m]) > 5e6
and rate(router_iface_bytes_total{iface="wan",dir="rx"}[15m])
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="rx"}[1h])[7d:1h])
for: 30m
labels: {severity: warning}
annotations:
summary: "Дом: необычно большая загрузка из интернета — {{ $value | humanize1024 }}Б/с 30+ мин. Кто качает: `router traffic --live` (по портам/Wi-Fi). Сама малина качает OneDrive/бэкапы — сверь с их расписанием."
- alert: RouterUploadSpike
expr: |
rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m]) > 1e6
and rate(router_iface_bytes_total{iface="wan",dir="tx"}[15m])
> 4 * avg_over_time(rate(router_iface_bytes_total{iface="wan",dir="tx"}[1h])[7d:1h])
for: 30m
labels: {severity: warning}
annotations:
summary: "Дом: необычно большая ОТДАЧА в интернет — {{ $value | humanize1024 }}Б/с 30+ мин (облачная синхронизация, торрент или что-то подозрительное). Кто: `router traffic --live`."
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# Forced command для пуш-ключей хостов за NAT (authorized_keys: command="~/bin/pushgw-ingest <instance>").
# Клиент передаёт только имя job (ssh ... <job>), метрики — в 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"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# backup-metrics <job> — из ExecStopPost юнита бэкапа: пушит итог прогона (job=backup_<name>).
# 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" <<M
# TYPE backup_last_exit_code gauge
backup_last_exit_code $code
backup_last_run_timestamp $now
backup_last_success_timestamp $last_ok
M
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# 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"
+67
View File
@@ -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"
+30
View File
@@ -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; }
+24
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# Вторая копия /mnt/backup на 1 ТБ диск в роутере Huawei (SFTP :8022, remote `router:` в
# ~rus/.config/rclone/rclone.conf). Диск питается от роутера — не нагружает USB малины.
#
# router-backup run — ежедневно (таймер 06:00)
# router-backup verify — по воскресеньям после run: размеры + перечитать свежие архивы серверов по sha256
# router-backup sample — ежемесячно: случайные файлы OneDrive (~2 ГБ) перечитать с роутера и сверить sha256
#
# Пишем прямо в remote, а не через FUSE /mnt/router: так надёжнее и есть коды ошибок.
# Роутер не умеет хэши (shell_type=none) → сравнение по размеру+mtime, целостность — перечиткой.
set -uo pipefail
C=/home/rus/.config/rclone/rclone.conf
R=router:rpi-backup
SRC=/mnt/backup
TS=$(date +%F_%H%M)
LOGDIR=$SRC/router-logs; mkdir -p "$LOGDIR"
LOG="$LOGDIR/router-$TS.log"
rc() { rclone --config "$C" --log-file "$LOG" --log-level INFO --stats 10m --stats-log-level NOTICE \
--transfers 2 --checkers 4 --sftp-concurrency 8 --retries 5 --low-level-retries 20 "$@"; }
mountpoint -q "$SRC" || { echo "$SRC не смонтирован — пропуск"; exit 1; }
rclone --config "$C" lsd router: >/dev/null 2>&1 || { echo "роутер недоступен по SFTP"; exit 1; }
run() {
local r=0
# 1) то, что никогда не удаляется: архив версий, манифесты, логи — только copy
for d in archive manifests logs; do
[ -d "$SRC/onedrive/$d" ] && { rc copy "$SRC/onedrive/$d" "$R/onedrive/$d" || r=1; }
done
# 2) зеркало current. Удалённое из current локально уже лежит в archive (скопированном выше).
# --max-delete: если локально «пропало» много файлов (сбой диска/монтирования) — стоп, а не зачистка копии
rc sync "$SRC/onedrive/current" "$R/onedrive/current" --exclude '*.partial' --max-delete 1000 || r=1
# 3) зашифрованные бэкапы серверов — зеркало (ротация 14/8/12 → удаляется пара файлов в день)
rc sync "$SRC/servers" "$R/servers" --max-delete 50 || r=1
echo "run: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ ОШИБКИ') — лог $LOG"
return $r
}
verify() {
local r=0 s rel want got
# размеры всего дерева (только листинг, без чтения данных)
rc check "$SRC/onedrive/current" "$R/onedrive/current" --size-only --one-way --exclude '*.partial' || r=1
rc check "$SRC/servers" "$R/servers" --size-only --one-way || r=1
# перечитать с роутера последний daily каждого сервера и сверить с sha256
for s in $(ls -1t "$SRC"/servers/*/daily/*.age.sha256 2>/dev/null | awk -F/ '!seen[$(NF-2)]++'); do
rel=${s#"$SRC/servers/"}; rel=${rel%.sha256}
want=$(cut -d' ' -f1 "$s")
got=$(rclone --config "$C" cat "$R/servers/$rel" 2>>"$LOG" | sha256sum | cut -d' ' -f1)
if [ "$want" = "$got" ]; then echo "read-back OK $rel"; else echo "read-back BAD $rel"; r=1; fi
done
echo "verify: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ РАСХОЖДЕНИЯ')"
return $r
}
sample() {
# Роутер хэшей не считает, verify сверяет OneDrive только по размеру — тихую порчу внутри
# файла того же размера ловит только перечитка. Берём файлы старше суток (точно должны
# быть скопированы), до SAMPLE_BYTES суммарно и не больше SAMPLE_MAX штук.
local budget=${SAMPLE_BYTES:-2147483648} max=${SAMPLE_MAX:-300} total=0 n=0 bad=0 miss=0 size rel want got
while IFS=' ' read -r size rel; do
[ $n -ge $max ] && break
[ $total -gt 0 ] && [ $((total + size)) -gt $budget ] && continue
want=$(sha256sum "$SRC/onedrive/$rel" | cut -d' ' -f1)
if ! got=$(rclone --config "$C" cat "$R/onedrive/$rel" 2>>"$LOG" | sha256sum | cut -d' ' -f1) \
|| [ "$got" = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -a "$size" != 0 ]; then
echo "MISSING $rel"; miss=$((miss + 1))
elif [ "$want" != "$got" ]; then
echo "BAD $rel"; bad=$((bad + 1))
fi
n=$((n + 1)); total=$((total + size))
done < <(cd "$SRC/onedrive" && find current archive -type f ! -name '*.partial' -mmin +1440 -printf '%s %p\n' | shuf)
echo "sample: проверено $n файлов, $((total / 1048576)) МиБ; повреждено $bad, нет на роутере $miss"
[ $bad = 0 ] && { [ $miss = 0 ] || [ -n "${SAMPLE_ALLOW_MISSING:-}" ]; }
}
case "${1:-run}" in
run) run; r=$?; [ "$(date +%u)" = 7 ] && { verify || r=1; }; exit $r ;;
verify) verify ;;
sample) sample ;;
*) echo "usage: router-backup run|verify|sample" >&2; exit 2 ;;
esac
+53
View File
@@ -0,0 +1,53 @@
#!/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
# заполнение диска бэкапа (statfs — из памяти, спящий диск не будит)
if mountpoint -q /mnt/backup; then
read -r size avail < <(df -B1 --output=size,avail /mnt/backup | tail -1)
echo "backup_disk_size_bytes{mountpoint=\"/mnt/backup\"} $size" >> "$out"
echo "backup_disk_avail_bytes{mountpoint=\"/mnt/backup\"} $avail" >> "$out"
fi
# диск в роутере (rclone SFTP-монтирование /mnt/router): жив ли и сколько занято.
# timeout — зависший FUSE не должен вешать сбор метрик
echo "# TYPE router_mount_up gauge" >> "$out"
if mountpoint -q /mnt/router && r=$(timeout 20 df -B1 --output=size,avail /mnt/router 2>/dev/null | tail -1) && [ -n "$r" ]; then
read -r size avail <<< "$r"
echo "router_mount_up 1" >> "$out"
echo "backup_disk_size_bytes{mountpoint=\"/mnt/router\"} $size" >> "$out"
echo "backup_disk_avail_bytes{mountpoint=\"/mnt/router\"} $avail" >> "$out"
else
echo "router_mount_up 0" >> "$out"
fi
metrics-push smart < "$out"
+1
View File
@@ -0,0 +1 @@
UUID=6eea3421-da3f-4a0f-b66d-26000b9a1b01 /mnt/backup ext4 defaults,noatime,nofail,x-systemd.device-timeout=30s 0 2
+7
View File
@@ -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
@@ -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
+11
View File
@@ -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
@@ -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
@@ -0,0 +1,10 @@
[Unit]
Description=OneDrive backup every 4h
[Timer]
OnCalendar=*-*-* 00/4:30
RandomizedDelaySec=5m
Persistent=true
[Install]
WantedBy=timers.target
@@ -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
@@ -0,0 +1,9 @@
[Unit]
Description=Weekly OneDrive backup integrity check
[Timer]
OnCalendar=Sun *-*-* 05:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=Second copy of /mnt/backup to router USB disk (rclone SFTP)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/mnt/backup
[Service]
Type=oneshot
ExecStart=/usr/bin/flock -n /run/lock/router-backup.lock /usr/local/bin/router-backup run
ExecStopPost=/usr/local/bin/backup-metrics backup_router
Nice=10
IOSchedulingClass=idle
# первичная копия ~370 ГБ при ~8 МБ/с — около 13 ч
TimeoutStartSec=infinity
@@ -0,0 +1,10 @@
[Unit]
Description=Daily copy of /mnt/backup to router disk
[Timer]
OnCalendar=*-*-* 06:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=Monthly random read-back of OneDrive files from router disk (sha256)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/mnt/backup
[Service]
Type=oneshot
# ждёт, если идёт router-backup (общий lock), а не падает
ExecStart=/usr/bin/flock /run/lock/router-backup.lock /usr/local/bin/router-backup sample
ExecStopPost=/usr/local/bin/backup-metrics backup_router_sample
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=6h
@@ -0,0 +1,9 @@
[Unit]
Description=Monthly router disk read-back sample
[Timer]
OnCalendar=*-*-01 08:00
Persistent=true
[Install]
WantedBy=timers.target
@@ -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
@@ -0,0 +1,9 @@
[Unit]
Description=SMART metrics push every 15 min
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target
+23
View File
@@ -0,0 +1,23 @@
# Домашняя печать с rpi
Принтер: Brother DCP-L2620DW, обнаружен 2026-10-11 через mDNS,
`192.168.10.24`, hostname `BRW2C9C58C1EEA7.local`.
Чёрно-белый, A4, двусторонняя печать; IPP `/ipp/print`, порт 631.
Код настройки — `setup.sh`; очередь `home-brother`, драйвер IPP Everywhere.
Установка: `sudo apt-get install --no-install-recommends cups cups-client cups-filters cups-ipp-utils`,
затем `sudo sh stacks/rpi-print/setup.sh` и
`sudo install -o root -g root -m 755 stacks/rpi-print/home-print /usr/local/bin/home-print`.
Печать PDF/PNG/JPEG: `lp -d home-brother -o media=A4 -o fit-to-page /path/to/file`.
Двусторонняя: добавить `-o sides=two-sided-long-edge`.
Очередь: `lpstat -W not-completed -o home-brother`; отмена: `cancel <job-id>`.
Word DOCX перед печатью нужно преобразовать в PDF: напрямую CUPS его не понимает.
⚠️ hostname через mDNS позволяет пережить смену DHCP-адреса; если принтер
не объявляет mDNS или выключен, отправка упадёт. Политика `abort-job` исключает
неожиданную автоматическую печать давно забытых заданий после включения.
Очередь не публикуется в сеть (`printer-is-shared=false`); CUPS остаётся локальным.
Отправлять на бумагу только по прямой просьбе пользователя. Сообщение о принятии
задания означает приём в очередь; итог следует проверить через `lpstat`.
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Usage: home-print status | home-print [--duplex] FILE.pdf|FILE.png|FILE.jpg
set -eu
printer=home-brother
if [ "${1:-}" = status ]; then
lpstat -p "$printer"
lpstat -W not-completed -o "$printer"
exit
fi
sides=one-sided
if [ "${1:-}" = --duplex ]; then
sides=two-sided-long-edge
shift
fi
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo 'Укажите один существующий PDF/PNG/JPEG-файл' >&2
exit 2
fi
case "$1" in
*.pdf|*.PDF|*.png|*.PNG|*.jpg|*.JPG|*.jpeg|*.JPEG) ;;
*) echo 'Поддерживаются PDF/PNG/JPEG; Word сначала преобразуйте в PDF' >&2; exit 2 ;;
esac
# Absolute path prevents a filename from becoming an lp option.
file=$(realpath -- "$1")
exec lp -d "$printer" -n 1 -o media=A4 -o fit-to-page -o sides="$sides" "$file"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Run as root after installing cups cups-client cups-filters cups-ipp-utils.
set -eu
systemctl enable --now cups.service
lpadmin -p home-brother -E -v ipp://BRW2C9C58C1EEA7.local:631/ipp/print -m everywhere \
-D 'Brother DCP-L2620DW' -L 'Дом' \
-o printer-is-shared=false -o PageSize=A4 -o sides-default=one-sided \
-o printer-error-policy=abort-job
lpadmin -d home-brother
lpstat -p home-brother -v
+68
View File
@@ -0,0 +1,68 @@
# Бэкап серверов на rpi (fr1, ru1)
Внешняя площадка для VPS — домашняя малина. Схема «малина забирает, сервер шифрует»:
```mermaid
sequenceDiagram
participant R as rpi (таймер 02:15)
participant S as сервер (fr1 / ru1)
R->>S: ssh -i /etc/server-backup/id_ed25519 <профиль>
Note over S: forced command backup-export<br/>tar | zstd | age -r <ОТКРЫТЫЙ ключ>
S-->>R: stdout: шифротекст
S-->>R: stderr: "SHA256 <hex>" шифротекста
Note over R: сверяет sha256, кладёт<br/>/mnt/backup/servers/<host>/<профиль>/
```
- **Малина не может расшифровать то, что хранит.** Шифрует сервер открытым ключом age,
закрытый ключ только у владельца: Mac `~/.config/age/servers-backup.key` (+ резервная копия
вне малины). Взлом малины или промпт-инъекция агента Astra = утечка только шифротекста.
- **Сервер не получает доступа к малине** (в отличие от схемы «сервер пушит через обратный
туннель»): ключ малины на сервере умеет только запустить выгрузку (`restrict,command=…`).
- Целостность: sha256 шифротекста считает сервер, малина сверяет при приёме и раз в неделю
(`servers-backup verify`). Подмену/порчу внутри поймает и age при расшифровке (AEAD).
## Что входит
| Хост/профиль | Когда | Содержимое | Размер |
|---|---|---|---|
| ru1 daily | ежедневно | `/etc` целиком (OpenVPN, AWG, mtproxy, tproxy, iptables, юниты), `/opt` (MTProxy, xray), `/srv`, `/usr/local`, `~mik` (**easy-rsa — CA OpenVPN**, клиентские .ovpn, wireguard, gost+LE, smart-routing), `/root`, crontab | ~17 МБ |
| fr1 daily | ежедневно | `/etc` целиком (AWG-ключи, nginx, letsencrypt, tg-notify…), `/srv/gitea` + согласованный снимок `gitea.db` (`snap/gitea.db`, через sqlite `.backup`), `/usr/local`, `~rus` (monitoring с секретами, bin, MyVault, .ssh), `/root`, crontab | ~2.1 ГБ |
| fr1 weekly | вс | docker-тома `claude-channels_claude-home` (⚠️ ключи Astra к хостам), grafana, alertmanager, pushgateway; `~tgclaude` (imgbot-data, imgbot, боты) | ~10 ГБ |
Не входит сознательно: том Prometheus (метрики, 7.9 ГБ, потеря не критична), `~rus/backups`
и `~tgclaude/backups` (разовые архивы — скопировать один раз отдельно), спящий openclaw,
кэши `.claude`/`.cache`, сборки (`badvpn`, `telemt-build`).
Хранение: `daily` — 14, `weekly` — 8, `monthly` — 12 (хардлинк первого daily месяца).
## Файлы
- `fr1-backup-export` → fr1 `~rus/bin/backup-export` (читает root-файлы через `docker run -v /:/host:ro`:
sudo на fr1 по паролю, а rus в группе docker). `age` — статический бинарник `~rus/bin/age`.
- `ru1-backup-export` → ru1 `/usr/local/sbin/backup-export` (root, 750), forced command
`sudo -n …` у `mik` (у него sudo NOPASSWD). `age` — `/usr/local/bin/age`.
- `rpi/servers-backup`, `rpi/servers-backup.{service,timer}` → rpi `/usr/local/bin`, `/etc/systemd/system`.
Ключ `/etc/server-backup/id_ed25519` (root 600), host keys серверов закреплены в
`/etc/server-backup/known_hosts`. Итог прогона → метрики `backup_*{job="backup_servers"}`,
алерт `BackupStale` (>26 ч) и `BackupFailed`.
## Восстановление
Нужен закрытый ключ (на Mac). Архив можно расшифровывать потоком, не храня открытым на диске:
```bash
# список файлов
ssh rpi 'sudo sh -c "cat /mnt/backup/servers/ru1/daily/<файл>.age"' \
| age -d -i ~/.config/age/servers-backup.key | zstd -dq | tar -tvf -
# достать один файл/каталог (например CA OpenVPN)
ssh rpi 'sudo sh -c "cat /mnt/backup/servers/ru1/daily/<файл>.age"' \
| age -d -i ~/.config/age/servers-backup.key | zstd -dq | tar -xvf - home/mik/easy-rsa
# fr1: пути в архиве с префиксом host/ (tar шёл из контейнера с / на /host):
# репозитории — host/srv/gitea/data/git/repositories/…, БД gitea — snap/gitea.db
# (живой host/srv/gitea/data/gitea.db исключён — он был бы рваной копией)
```
⚠️ **Потеря закрытого ключа = все бэкапы бесполезны.** Минимум две копии в разных местах,
и пробное восстановление раз в квартал.
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Выгрузка бэкапа fr1 для rpi. Forced command в ~rus/.ssh/authorized_keys:
# restrict,command="/home/rus/bin/backup-export" <ключ server-backup@rpi>
# Клиент передаёт профиль: daily | weekly. В stdout — tar | zstd | age (шифрует ОТКРЫТЫМ
# ключом, закрытый только у владельца вне малины — rpi хранит то, что сама прочитать не может).
# В stderr последней строкой — "SHA256 <hex>" шифротекста: rpi сверяет, что дошло целиком.
#
# Читает root-файлы через docker (rus в группе docker = root-эквивалент), а не через sudo:
# sudo на fr1 по паролю. Монтирование / только read-only.
set -euo pipefail
PUB=age1qfsgzyglv0vmq4kskg888hzgq032mry5kxz9kptkvzsrz0fz0v7q6kxwk2
AGE=/home/rus/bin/age
IMG=debian:stable-slim # GNU tar: --exclude/--warning, в alpine — busybox
profile="${SSH_ORIGINAL_COMMAND:-daily}"
tmp=$(mktemp -d /tmp/backup-export.XXXXXX)
cleanup() { rm -rf "$tmp"; docker exec gitea rm -f /tmp/gitea-snap.db >/dev/null 2>&1 || true; }
trap cleanup EXIT
case "$profile" in
daily)
# Gitea на SQLite: копировать живой gitea.db нельзя (рваная копия) — .backup даёт согласованный снимок
docker exec -u 1000 gitea sqlite3 /data/gitea.db ".backup '/tmp/gitea-snap.db'"
docker cp gitea:/tmp/gitea-snap.db "$tmp/gitea.db" >/dev/null
paths=(host/etc host/srv/gitea host/usr/local host/home/rus host/var/spool/cron host/root snap)
excl=(--exclude=host/srv/gitea/data/gitea.db --exclude='host/srv/gitea/data/gitea.db-*'
--exclude=host/home/rus/backups --exclude=host/home/rus/.openclaw
--exclude=host/home/rus/openclaw --exclude=host/home/rus/openclaw-work
--exclude=host/home/rus/.cache --exclude=host/home/rus/.claude --exclude='*.bak-*' --exclude='*.tar.gz')
;;
weekly)
paths=(host/var/lib/docker/volumes/claude-channels_claude-home
host/var/lib/docker/volumes/monitoring_grafana_data
host/var/lib/docker/volumes/monitoring_alertmanager_data
host/var/lib/docker/volumes/monitoring_pushgateway_data
host/home/tgclaude)
excl=(--exclude=host/home/tgclaude/backups --exclude=host/home/tgclaude/openclaw-deploy
--exclude=host/home/tgclaude/.local --exclude=host/home/tgclaude/.cache --exclude=host/home/tgclaude/chrome-libs)
;;
*) echo "профиль: daily|weekly" >&2; exit 2 ;;
esac
# tar exit 1 = «файл изменился при чтении» (живые логи/репо) — не ошибка для бэкапа
docker run --rm --network none -v /:/host:ro -v "$tmp":/snap:ro "$IMG" \
sh -c 'tar --numeric-owner --ignore-failed-read --warning=no-file-changed -C / -cf - "$@"; rc=$?; [ $rc -le 1 ] || exit $rc' tar "${excl[@]}" "${paths[@]}" \
| zstd -3 -T0 -q | "$AGE" -r "$PUB" | tee >(sha256sum > "$tmp/sum")
# процесс-подстановка живёт отдельно от конвейера — ждём, пока sha256sum допишет итог
for _ in $(seq 300); do [ -s "$tmp/sum" ] && break; sleep 0.1; done
echo "SHA256 $(cut -d' ' -f1 "$tmp/sum")" >&2
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# Бэкап серверов на rpi (вариант «малина забирает»): ssh к серверу → forced command отдаёт
# tar|zstd|age, зашифрованный ОТКРЫТЫМ ключом. Закрытого ключа на малине нет — расшифровать
# здесь ничего нельзя (ключ у владельца: Mac ~/.config/age/servers-backup.key + резервная копия).
#
# servers-backup all — ежедневный прогон (таймер): fr1 daily, ru1 daily, по воскресеньям fr1 weekly
# servers-backup verify — сверка sha256 всех хранимых архивов (порча на диске)
#
# Хранение: <host>/daily — 14 последних, <host>/weekly — 8, <host>/monthly — 12 (хардлинк
# первого daily за месяц, места не занимает).
set -uo pipefail
ROOT=/mnt/backup/servers
KEY=/etc/server-backup/id_ed25519
KH=/etc/server-backup/known_hosts
declare -A TARGET=([fr1]=rus@161.97.93.252 [ru1]=mik@85.198.109.86)
declare -A KEEP=([daily]=14 [weekly]=8 [monthly]=12)
log() { echo "$*"; }
pull() { # pull <host> <profile>
local host=$1 profile=$2 dir="$ROOT/$1/$2" ts f rc want got
mkdir -p "$dir"
ts=$(date +%F_%H%M); f="$dir/$host-$profile-$ts.tar.zst.age"
ssh -i "$KEY" -o UserKnownHostsFile="$KH" -o StrictHostKeyChecking=yes -o BatchMode=yes \
-o ConnectTimeout=30 -o ServerAliveInterval=30 -o ServerAliveCountMax=6 \
"${TARGET[$host]}" "$profile" > "$f.part" 2> "$f.stderr"
rc=$?
want=$(grep '^SHA256 ' "$f.stderr" | tail -1 | cut -d' ' -f2)
got=$(sha256sum "$f.part" | cut -d' ' -f1)
if [ $rc -ne 0 ] || [ -z "$want" ] || [ "$want" != "$got" ] || [ ! -s "$f.part" ]; then
log "FAIL $host/$profile: ssh rc=$rc, sha сервера=${want:-нет}, sha принятого=$got"
sed 's/^/ stderr: /' "$f.stderr" | tail -20
rm -f "$f.part" "$f.stderr"
return 1
fi
mv "$f.part" "$f"
echo "$got $(basename "$f")" > "$f.sha256"
grep -v '^SHA256 ' "$f.stderr" | tail -50 > "$f.log"; [ -s "$f.log" ] || rm -f "$f.log"
rm -f "$f.stderr"
log "OK $host/$profile: $(du -h "$f" | cut -f1) $(basename "$f")"
# первый daily за месяц → monthly (хардлинк)
if [ "$profile" = daily ] && ! ls "$ROOT/$host/monthly/$host-daily-$(date +%Y-%m)"-* >/dev/null 2>&1; then
mkdir -p "$ROOT/$host/monthly"; ln "$f" "$f.sha256" "$ROOT/$host/monthly/"
fi
}
prune() {
local d keep
for d in "$ROOT"/*/{daily,weekly,monthly}; do
[ -d "$d" ] || continue
keep=${KEEP[$(basename "$d")]}
ls -1t "$d"/*.age 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
rm -f "$old" "$old.sha256" "$old.log"; log "prune $(basename "$old")"
done
done
}
verify() {
local bad=0 n=0 s
while read -r s; do
n=$((n + 1))
(cd "$(dirname "$s")" && sha256sum --quiet -c "$(basename "$s")") || { log "BAD $s"; bad=1; }
done < <(find "$ROOT" -name '*.sha256')
log "verify: проверено $n архивов, $([ $bad = 0 ] && echo 'всё цело' || echo 'ЕСТЬ ПОВРЕЖДЁННЫЕ')"
return $bad
}
mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован — пропуск"; exit 1; }
install -d -m 700 "$ROOT"
case "${1:-all}" in
all)
rc=0
pull fr1 daily || rc=1
pull ru1 daily || rc=1
[ "$(date +%u)" = 7 ] && { pull fr1 weekly || rc=1; }
prune
[ "$(date +%u)" = 7 ] && { verify || rc=1; }
exit $rc ;;
verify) verify ;;
pull) pull "$2" "$3" ;;
*) echo "usage: servers-backup all|verify|pull <host> <profile>" >&2; exit 2 ;;
esac
@@ -0,0 +1,13 @@
[Unit]
Description=Pull encrypted backups of fr1/ru1 (age, key not on this host)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/mnt/backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/servers-backup all
ExecStopPost=/usr/local/bin/backup-metrics backup_servers
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=6h
@@ -0,0 +1,10 @@
[Unit]
Description=Daily servers backup (fr1, ru1)
[Timer]
OnCalendar=*-*-* 02:15
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Выгрузка бэкапа ru1 для rpi. Forced command в ~mik/.ssh/authorized_keys:
# restrict,command="sudo -n /usr/local/sbin/backup-export" <ключ server-backup@rpi>
# В stdout — tar | zstd | age (открытый ключ; закрытый только у владельца вне малины).
# В stderr последней строкой — "SHA256 <hex>" шифротекста.
# Главное здесь — ~mik/easy-rsa: CA OpenVPN; без него — перевыпуск всех клиентских конфигов.
set -euo pipefail
PUB=age1qfsgzyglv0vmq4kskg888hzgq032mry5kxz9kptkvzsrz0fz0v7q6kxwk2
tmp=$(mktemp -d /tmp/backup-export.XXXXXX); trap 'rm -rf "$tmp"' EXIT
cd /
# tar exit 1 = «файл изменился при чтении» — не ошибка
{ rc=0; tar --numeric-owner --ignore-failed-read --warning=no-file-changed -cf - \
--exclude=home/mik/badvpn --exclude=home/mik/telemt-build --exclude=home/mik/cloudflared.deb \
--exclude=home/mik/.claude --exclude=home/mik/.cache --exclude='home/mik/*.log' --exclude=home/mik/nohup.out \
etc opt srv usr/local home/mik root var/spool/cron 2>/dev/null || rc=$?; [ $rc -le 1 ] || exit $rc; } \
| zstd -3 -T0 -q | /usr/local/bin/age -r "$PUB" | tee >(sha256sum > "$tmp/sum")
# процесс-подстановка живёт отдельно от конвейера — ждём, пока sha256sum допишет итог
for _ in $(seq 300); do [ -s "$tmp/sum" ] && break; sleep 0.1; done
echo "SHA256 $(cut -d' ' -f1 "$tmp/sum")" >&2