From 7d0a891793df655e2952447559efe5279294e054 Mon Sep 17 00:00:00 2001 From: gilfanovr Date: Sun, 11 Oct 2026 14:44:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B0=D0=BF=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2=20fr1/ru1=20=D0=BD=D0=B0?= =?UTF-8?q?=20rpi:=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D1=88=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D1=83=D0=B5=D1=82=20age=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D0=BC=20=D0=BA=D0=BB=D1=8E=D1=87=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=BC=D0=B0=D0=BB=D0=B8=D0=BD=D0=B0=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- servers/fr1.md | 8 ++ servers/rpi.md | 10 +++ servers/ru1.md | 5 ++ .../provisioning/dashboards/rpi-disk.json | 76 +++++++++++++++-- stacks/monitoring/prometheus/rules/alerts.yml | 3 +- stacks/server-backup/README.md | 68 +++++++++++++++ stacks/server-backup/fr1-backup-export | 48 +++++++++++ stacks/server-backup/rpi/servers-backup | 82 +++++++++++++++++++ .../server-backup/rpi/servers-backup.service | 13 +++ stacks/server-backup/rpi/servers-backup.timer | 10 +++ stacks/server-backup/ru1-backup-export | 19 +++++ 11 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 stacks/server-backup/README.md create mode 100644 stacks/server-backup/fr1-backup-export create mode 100644 stacks/server-backup/rpi/servers-backup create mode 100644 stacks/server-backup/rpi/servers-backup.service create mode 100644 stacks/server-backup/rpi/servers-backup.timer create mode 100644 stacks/server-backup/ru1-backup-export diff --git a/servers/fr1.md b/servers/fr1.md index 8a35706..513711a 100644 --- a/servers/fr1.md +++ b/servers/fr1.md @@ -236,6 +236,14 @@ backend=systemd banaction=ufw - Таймеры `fwupd-refresh.timer` (раз в сутки ходил в сеть за метаданными прошивок LVFS) и `motd-news.timer` (`/etc/default/motd-news`: `ENABLED=1`, `URLS=https://motd.ubuntu.com` — раз в сутки запрашивал у Canonical новостной текст для приветствия) — `disabled`. - Демоны `ModemManager`, `fwupd`, `multipathd`, `udisks2` — `masked`. Все четыре висели с 0.0% CPU и ~100 МБ RSS суммарно, и обслуживать им было нечего: multipath-устройств нет (`/dev/mapper` — только `control`, диск один), модемов нет (в `/sys/class/net` только `eth0`, `wg0`, `lo` и docker-интерфейсы), прошивок в KVM-гостe нет, съёмных носителей тоже. +## Бэкап (с 2026-10-11) +До этого регулярных бэкапов fr1 **не было** (только разовый снимок Gitea перед апгрейдом). +- Домашняя `rpi` каждую ночь (02:15 MSK) забирает зашифрованную выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/backup-export"` (бэкап прежнего файла — `authorized_keys.bak-20261011b`). +- `~/bin/backup-export` читает root-файлы через `docker run --rm --network none -v /:/host:ro debian:stable-slim` — sudo тут по паролю, а `rus` в группе docker. Шифрует `~/bin/age` (статический v1.3.2) открытым ключом; закрытый только у владельца. +- daily: `/etc`, `/srv/gitea` + согласованный `.backup` `gitea.db` (живой файл исключён), `/usr/local`, `~rus` (без backups/openclaw/кэшей), `/root`, crontab. weekly (вс): тома `claude-channels_claude-home`, grafana, alertmanager, pushgateway + `~tgclaude` (без backups/openclaw-deploy/.local). +- ⚠️ Prometheus (том 7.9 ГБ) **не бэкапится** сознательно. ⚠️ Новые важные данные вне перечисленных путей в бэкап не попадут — добавлять в `fr1-backup-export`. +- В образах добавился `debian:stable-slim` (нужен для выгрузки). + ## Открытые хвосты (на 2026-09-08) Мелочи, оставленные сознательно, — чтобы не искать их заново. - ⚠️ **В Gitea живут два временных токена** — `imgbot-setup` и `imgbot-setup2`, scope `write:repository,write:user`. Созданы через `docker exec -u 1000 gitea gitea admin user generate-access-token` для создания репо `ruzzy/imgbot` и deploy-ключа. Удалить через API не удалось: Gitea отвечает 401 на управление токенами по токен-авторизации, нужна basic-auth. **Отозвать вручную:** Настройки → Приложения. diff --git a/servers/rpi.md b/servers/rpi.md index 1b46dff..5e2d9a2 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -35,6 +35,13 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter. - Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал). +## Бэкап серверов 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//{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`. @@ -63,4 +70,7 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - Первый прогон бэкапа стартовал до появления `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 — прогноз ресурса пока оценочный. diff --git a/servers/ru1.md b/servers/ru1.md index 20da8db..fb903fd 100644 --- a/servers/ru1.md +++ b/servers/ru1.md @@ -86,6 +86,11 @@ VPN entry-нода в РФ. Точка входа в топологии **RU↔D - `wg1.conf` обязан иметь `Table = off` — иначе wg-quick при рестарте сносит default route и сервер теряет SSH (восстановление: `ip route add default via 85.198.109.1 dev ens3`). - Таблица маршрутизации — `vpn` (100), не `awg`. +## Бэкап (с 2026-10-11) +- Домашняя `rpi` каждую ночь забирает зашифрованную (age, открытый ключ) выгрузку — `stacks/server-backup/`. Ключ `server-backup@rpi` в `~mik/.ssh/authorized_keys` с `restrict,command="sudo -n /usr/local/sbin/backup-export"` (бэкап — `authorized_keys.bak-20261011`). +- `/usr/local/sbin/backup-export` (root 750) пакует `/etc`, `/opt`, `/srv`, `/usr/local`, `~mik` (без сборок badvpn/telemt-build, логов, `.claude`), `/root`, crontab; `/usr/local/bin/age` — статический v1.3.2. Архив ~17 МБ. +- Главное в нём — **`~mik/easy-rsa` (CA OpenVPN)**: без него пришлось бы перевыпускать все 43 клиентских конфига. + ## Открытые хвосты - **Умный роутинг для `wg1` не настроен** (2026-10-09): mangle-правила есть только для `-i tun0`, для `wg1` / `10.8.2.0/24` — ни одного, хотя раздел «Умный роутинг» заявляет tun0 и wg1. В `wg1` один пир, рукопожатий не было ни разу — сервисом, похоже, никто не пользуется. Не правили. - Drop-in `10-keep-foreign-routing.conf` фактически не проверен рестартом networkd (рестарт ens3 по SSH на этой ноде — риск). Проверить при следующей плановой перезагрузке: после неё `ip rule | grep 0x64` должен показать правило. diff --git a/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json b/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json index d058ca3..1a54974 100644 --- a/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json +++ b/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json @@ -598,7 +598,7 @@ "gridPos": { "x": 0, "y": 10, - "w": 6, + "w": 5, "h": 4 }, "targets": [ @@ -657,9 +657,9 @@ "uid": "prometheus" }, "gridPos": { - "x": 6, + "x": 5, "y": 10, - "w": 6, + "w": 5, "h": 4 }, "targets": [ @@ -709,6 +709,68 @@ "justifyMode": "center" } }, + { + "id": 99, + "type": "stat", + "title": "С последнего бэкапа серверов (fr1, ru1)", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 10, + "y": 10, + "w": 5, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "time() - backup_last_success_timestamp{instance=\"$instance\",job=\"backup_servers\"}", + "legendFormat": "", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "noValue": "ещё не было", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 90000, + "color": "orange" + }, + { + "value": 93600, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center" + }, + "description": "Ежедневно 02:15: малина забирает зашифрованные (age) выгрузки fr1 и ru1. Расшифровать на малине нельзя — ключ у владельца." + }, { "id": 12, "type": "stat", @@ -718,9 +780,9 @@ "uid": "prometheus" }, "gridPos": { - "x": 12, + "x": 15, "y": 10, - "w": 6, + "w": 5, "h": 4 }, "targets": [ @@ -786,9 +848,9 @@ "uid": "prometheus" }, "gridPos": { - "x": 18, + "x": 20, "y": 10, - "w": 6, + "w": 4, "h": 4 }, "targets": [ diff --git a/stacks/monitoring/prometheus/rules/alerts.yml b/stacks/monitoring/prometheus/rules/alerts.yml index d026c9b..e2b9bc2 100644 --- a/stacks/monitoring/prometheus/rules/alerts.yml +++ b/stacks/monitoring/prometheus/rules/alerts.yml @@ -90,11 +90,12 @@ groups: annotations: summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте" - # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней + # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч - 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) for: 10m labels: {severity: critical} annotations: diff --git a/stacks/server-backup/README.md b/stacks/server-backup/README.md new file mode 100644 index 0000000..8aac10c --- /dev/null +++ b/stacks/server-backup/README.md @@ -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
tar | zstd | age -r <ОТКРЫТЫЙ ключ> + S-->>R: stdout: шифротекст + S-->>R: stderr: "SHA256 " шифротекста + Note over R: сверяет sha256, кладёт
/mnt/backup/servers//<профиль>/ +``` + +- **Малина не может расшифровать то, что хранит.** Шифрует сервер открытым ключом 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 исключён — он был бы рваной копией) +``` + +⚠️ **Потеря закрытого ключа = все бэкапы бесполезны.** Минимум две копии в разных местах, +и пробное восстановление раз в квартал. diff --git a/stacks/server-backup/fr1-backup-export b/stacks/server-backup/fr1-backup-export new file mode 100644 index 0000000..a1c6972 --- /dev/null +++ b/stacks/server-backup/fr1-backup-export @@ -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 " шифротекста: 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 diff --git a/stacks/server-backup/rpi/servers-backup b/stacks/server-backup/rpi/servers-backup new file mode 100644 index 0000000..b26e08e --- /dev/null +++ b/stacks/server-backup/rpi/servers-backup @@ -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 всех хранимых архивов (порча на диске) +# +# Хранение: /daily — 14 последних, /weekly — 8, /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 + 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 " >&2; exit 2 ;; +esac diff --git a/stacks/server-backup/rpi/servers-backup.service b/stacks/server-backup/rpi/servers-backup.service new file mode 100644 index 0000000..e0cde54 --- /dev/null +++ b/stacks/server-backup/rpi/servers-backup.service @@ -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 diff --git a/stacks/server-backup/rpi/servers-backup.timer b/stacks/server-backup/rpi/servers-backup.timer new file mode 100644 index 0000000..c9a296d --- /dev/null +++ b/stacks/server-backup/rpi/servers-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily servers backup (fr1, ru1) + +[Timer] +OnCalendar=*-*-* 02:15 +RandomizedDelaySec=10m +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/stacks/server-backup/ru1-backup-export b/stacks/server-backup/ru1-backup-export new file mode 100644 index 0000000..fe3f9b8 --- /dev/null +++ b/stacks/server-backup/ru1-backup-export @@ -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 " шифротекста. +# Главное здесь — ~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