Бэкап серверов fr1/ru1 на rpi: сервер шифрует age открытым ключом, малина только хранит
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e645377916
commit
7d0a891793
@@ -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