Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880ad61253 | ||
|
|
7843c6e958 | ||
|
|
60558dc4b3 | ||
|
|
f9d027ba86 | ||
|
|
69c02162db | ||
|
|
6d7dc4c8f1 | ||
|
|
0ffcb63b3c | ||
|
|
89ca3cea03 | ||
|
|
facc6c1e65 | ||
|
|
b881d861f0 | ||
|
|
bc0537a2d9 | ||
|
|
931219ad47 | ||
|
|
d780c91bd5 | ||
|
|
629b11f03f | ||
|
|
4965f70299 | ||
|
|
464e12abdb | ||
|
|
3811483577 | ||
|
|
550c359e4a | ||
|
|
c1f16ebc38 | ||
|
|
0a6d728851 | ||
|
|
bba3858894 | ||
|
|
7d0a891793 | ||
|
|
e645377916 | ||
|
|
ef2891323c | ||
|
|
65a01740e8 | ||
|
|
2bb74f7430 | ||
|
|
d1269162e7 |
@@ -6,6 +6,7 @@
|
|||||||
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
|
- [`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/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/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` в том же прогоне. Контракт и
|
Тронул сервер — обнови `servers/<хост>.md` в том же прогоне. Контракт и
|
||||||
|
|||||||
+18
-3
@@ -15,6 +15,7 @@
|
|||||||
- ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте.
|
- ⚠️ **Группа `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).
|
- Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519).
|
||||||
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
|
- 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`)
|
## 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`.
|
⚠️ В основном `/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.
|
- Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
||||||
|
|
||||||
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
||||||
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager** (docker compose).
|
- Стек: **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. Токен — в `~/monitoring/alertmanager/telegram_token` (chown 65534, не в git).
|
- **Алерты:** 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, активных алертов нет.
|
- 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` — пустой.
|
- **Тома:** `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`.
|
- **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).
|
- **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 скрыты).
|
- Дашборд **Network / Traffic** (`network.json`): RX/TX по интерфейсам, пакеты, ошибки/дропы, статус — с фильтром по instance/device (lo/veth/docker скрыты).
|
||||||
- DNS: `mon.ruzzy.dev` → 161.97.93.252.
|
- 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.
|
- Подключён `ru1` (node + cadvisor + openvpn-exporter, напрямую по публичному IP). `de1` выведен — его таргеты удалены. Планируется: экспортёры wireguard/awg и telemt.
|
||||||
|
|
||||||
## Gitea (`~/gitea`, stack в репо `stacks/gitea/`)
|
## 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` работает как раньше.
|
- `ROOT_URL`/`SSH_DOMAIN` = `git.ruzzy.dev` (не менялись). Host-ключи SSH и user/deploy-ключи перенесены с данными — `ssh://git@git.ruzzy.dev:2222` работает как раньше.
|
||||||
- Docker-томов нет — все данные на bind-mount `/srv/gitea` (1.9 ГБ), это и есть точка бэкапа.
|
- Docker-томов нет — все данные на bind-mount `/srv/gitea` (1.9 ГБ), это и есть точка бэкапа.
|
||||||
- Это origin репо `infra` и vault openclaw.
|
- Это 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
|
## 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.
|
- Зеркало 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`.
|
- Таймеры `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 нет, съёмных носителей тоже.
|
- Демоны `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)
|
## Открытые хвосты (на 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. **Отозвать вручную:** Настройки → Приложения.
|
- ⚠️ **В 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
@@ -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 — прогноз ресурса пока оценочный.
|
||||||
|
|
||||||
|
- ⏳ Определить хранение входящих фото: сейчас автоматически не удаляются ради ссылок в старых тредах; очередь сообщений только в памяти и теряется при перезапуске.
|
||||||
@@ -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`).
|
- `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`).
|
||||||
- Таблица маршрутизации — `vpn` (100), не `awg`.
|
- Таблица маршрутизации — `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` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили.
|
- **Умный роутинг для `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` должен показать правило.
|
- Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило.
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ services:
|
|||||||
- '127.0.0.1:9093:9093'
|
- '127.0.0.1:9093:9093'
|
||||||
networks: [monitoring]
|
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:
|
grafana:
|
||||||
image: grafana/grafana:latest
|
image: grafana/grafana:latest
|
||||||
container_name: grafana
|
container_name: grafana
|
||||||
@@ -93,6 +109,7 @@ services:
|
|||||||
networks: [monitoring]
|
networks: [monitoring]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
pushgateway_data:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
alertmanager_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
|
- job_name: openvpn
|
||||||
file_sd_configs:
|
file_sd_configs:
|
||||||
- files: ["/etc/prometheus/targets/openvpn/*.yml"]
|
- 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}
|
labels: {severity: critical}
|
||||||
annotations:
|
annotations:
|
||||||
summary: "КРИТИЧНО: диск почти полон на {{ $labels.instance }} ({{ $labels.mountpoint }}): свободно {{ printf \"%.0f\" $value }}%"
|
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`."
|
||||||
|
|||||||
Executable
+38
@@ -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"
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+7
@@ -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"
|
||||||
@@ -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"
|
||||||
Executable
+30
@@ -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; }
|
||||||
Executable
+24
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+53
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UUID=6eea3421-da3f-4a0f-b66d-26000b9a1b01 /mnt/backup ext4 defaults,noatime,nofail,x-systemd.device-timeout=30s 0 2
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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 исключён — он был бы рваной копией)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Потеря закрытого ключа = все бэкапы бесполезны.** Минимум две копии в разных местах,
|
||||||
|
и пробное восстановление раз в квартал.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user