diff --git a/.env.example b/.env.example index 78da8c3..a3d6987 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,17 @@ PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db DEV_UPLOAD_DIR=./data/dev/uploads PROD_UPLOAD_DIR=/data/uploads +# ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ─────── +# Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан +# BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально. +# Ключ бэкапа — deploy/backup/id_backup (в git НЕ идёт); его pubkey → authorized_keys у backup@VPS. +BACKUP_VPS_HOST= +BACKUP_VPS_USER=backup +BACKUP_VPS_DIR=/srv/fs-backups +BACKUP_VPS_KEY=deploy/backup/id_backup +BACKUP_KEEP_LOCAL=14 +BACKUP_KEEP_REMOTE=30 + # ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── # Часовой пояс приложения (фикс. смещение в часах; МСК = 3) APP_TZ_OFFSET_HOURS=3 diff --git a/.gitignore b/.gitignore index a8293a5..8a2ce9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ backend/dev.db* *.pem *.key id_tunnel* +id_backup* deploy/**/certs/ # Node / фронт diff --git a/deploy/pi/README.md b/deploy/pi/README.md index ef16b60..970181a 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -38,8 +38,26 @@ git pull docker compose up -d --build ``` -## Бэкап БД -`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`. +## Бэкап (полный + оффсайт на VPS) +`scripts/backup.sh` делает консистентный онлайн-снимок и собирает **полный** архив +`fs_.tar.gz` (БД + `uploads/` + `achievements/`) в `backups/` с ротацией. Если в `.env` +задан `BACKUP_VPS_HOST` — копия дополнительно уходит на VPS (оффсайт; Pi → VPS push, т.к. CGNAT). + +Настройка оффсайта (один раз): +```bash +# 1) отдельный ключ бэкапа + его pubkey на VPS (юзер backup — см. deploy/vps/README.md): +ssh-keygen -t ed25519 -f deploy/backup/id_backup -N "" +ssh-copy-id -i deploy/backup/id_backup.pub backup@186.246.51.17 +# 2) в .env заполнить BACKUP_VPS_HOST=186.246.51.17 (по желанию — BACKUP_VPS_USER/DIR/KEEP_*) +# 3) разовый прогон и проверка, что архив доехал на VPS: +scripts/backup.sh +# 4) cron (ежедневно в 04:00): +crontab -e +# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 +``` + +Восстановление: `scripts/restore.sh backups/fs_.tar.gz` (ДЕСТРУКТИВНО — заменяет текущие +данные; сначала проверь на тест-контуре). Тома — `db-data` / `uploads-data` / `achievements-data`. Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт 502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в diff --git a/deploy/vps/README.md b/deploy/vps/README.md index 06307bb..830334a 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -108,3 +108,18 @@ systemctl reload caddy (HTTP 503), это ожидаемо. Логи Caddy: `journalctl -u caddy -f`. + +## 8. Приём бэкапов с Pi (оффсайт-копии) +Pi ежедневно шлёт полный архив (`scripts/backup.sh`) на VPS отдельным пользователем `backup` +(не путать с `tunnel`). Каталог — **вне** веб-корней Caddy, права 700. +```bash +useradd -m -s /bin/bash backup +install -d -m 700 -o backup -g backup /home/backup/.ssh +install -m 600 -o backup -g backup /dev/null /home/backup/.ssh/authorized_keys +install -d -m 700 -o backup -g backup /srv/fs-backups +# Публичный ключ бэкапа с Pi (deploy/backup/id_backup.pub) добавь в authorized_keys: +# echo '<содержимое id_backup.pub>' >> /home/backup/.ssh/authorized_keys +``` +> Ротацию на VPS делает сам скрипт с Pi (`BACKUP_KEEP_REMOTE`, по умолч. 30). Каталог +> `/srv/fs-backups` не отдаётся Caddy (нет `root`/`file_server` на него) → бэкапы не публичны. +> Проверка приёма: после `scripts/backup.sh` на Pi → `ls -1 /srv/fs-backups/` на VPS. diff --git a/scripts/backup.sh b/scripts/backup.sh old mode 100644 new mode 100755 index 69e1fc9..ce4db80 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,28 +1,74 @@ #!/usr/bin/env bash -# Консистентный онлайн-бэкап SQLite из работающего контейнера. -# Запуск по cron на хосте Pi, например ежедневно в 04:00: +# Полный консистентный онлайн-бэкап прода (БД + uploads + achievements) с работающего +# контейнера + оффсайт-копия на VPS. Запуск по cron на хосте Pi, например ежедневно в 04:00: # 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 +# +# Настройки берутся из корневого .env (BACKUP_*), их можно переопределить переменными +# окружения. Если BACKUP_VPS_HOST пуст — делается только локальная копия в backups/. set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}" -KEEP="${KEEP:-14}" +COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" SERVICE="app" TS="$(date +%Y%m%d_%H%M%S)" +# Безопасно прочитать ОДИН ключ из .env (НЕ source: в .env есть спецсимволы # & и т.п.). +read_env() { # $1=ключ $2=дефолт + local val + val="$(grep -E "^$1=" "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r')" + val="${val%\"}"; val="${val#\"}" # снять обрамляющие кавычки, если есть + printf '%s' "${val:-$2}" +} + +BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}" +KEEP_LOCAL="${BACKUP_KEEP_LOCAL:-$(read_env BACKUP_KEEP_LOCAL 14)}" +KEEP_REMOTE="${BACKUP_KEEP_REMOTE:-$(read_env BACKUP_KEEP_REMOTE 30)}" +VPS_HOST="${BACKUP_VPS_HOST:-$(read_env BACKUP_VPS_HOST "")}" +VPS_USER="${BACKUP_VPS_USER:-$(read_env BACKUP_VPS_USER backup)}" +VPS_DIR="${BACKUP_VPS_DIR:-$(read_env BACKUP_VPS_DIR /srv/fs-backups)}" +VPS_KEY="${BACKUP_VPS_KEY:-$(read_env BACKUP_VPS_KEY "$PROJECT_DIR/deploy/backup/id_backup")}" + mkdir -p "$BACKUP_DIR" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT -# .backup внутри контейнера (sqlite3 модуль Python) → консистентная копия в /data. -docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" \ - python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()" +CID="$($COMPOSE ps -q "$SERVICE")" +if [ -z "$CID" ]; then + echo "[backup] ОШИБКА: контейнер '$SERVICE' не запущен." >&2 + exit 1 +fi -# Копируем во вне контейнера и сжимаем. -CID="$(docker compose -f "$PROJECT_DIR/docker-compose.yml" ps -q "$SERVICE")" -docker cp "$CID:/data/backup_tmp.db" "$BACKUP_DIR/fs_$TS.db" -docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" rm -f /data/backup_tmp.db -gzip -f "$BACKUP_DIR/fs_$TS.db" +echo "[backup] Консистентный снимок БД…" +$COMPOSE exec -T "$SERVICE" python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()" +docker cp "$CID:/data/backup_tmp.db" "$STAGE/forbidden_stars.db" +$COMPOSE exec -T "$SERVICE" rm -f /data/backup_tmp.db -# Ротация: оставить последние $KEEP. -ls -1t "$BACKUP_DIR"/fs_*.db.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f +echo "[backup] Копирование медиа и титулов…" +docker cp "$CID:/data/uploads" "$STAGE/uploads" +docker cp "$CID:/data/achievements" "$STAGE/achievements" -echo "[backup] Готово: $BACKUP_DIR/fs_$TS.db.gz" +ARCHIVE="$BACKUP_DIR/fs_$TS.tar.gz" +tar -czf "$ARCHIVE" -C "$STAGE" forbidden_stars.db uploads achievements +echo "[backup] Локальный архив: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" + +# Локальная ротация: оставить последние $KEEP_LOCAL (архив только что создан → ≥1 файл). +ls -1t "$BACKUP_DIR"/fs_*.tar.gz 2>/dev/null | tail -n +"$((KEEP_LOCAL + 1))" | xargs -r rm -f + +# Оффсайт-копия на VPS (push: Pi за CGNAT, входящих нет). +if [ -n "$VPS_HOST" ]; then + if [ ! -f "$VPS_KEY" ]; then + echo "[backup] ОШИБКА: ключ $VPS_KEY не найден — оффсайт-копия пропущена." >&2 + exit 1 + fi + echo "[backup] Отправка на VPS $VPS_USER@$VPS_HOST:$VPS_DIR …" + SSH_OPTS=(-i "$VPS_KEY" -o StrictHostKeyChecking=accept-new -o BatchMode=yes) + scp "${SSH_OPTS[@]}" "$ARCHIVE" "$VPS_USER@$VPS_HOST:$VPS_DIR/" + # Удалённая ротация (sh на VPS без pipefail → пустой ls не уронит команду). + ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" \ + "ls -1t '$VPS_DIR'/fs_*.tar.gz 2>/dev/null | tail -n +$((KEEP_REMOTE + 1)) | xargs -r rm -f" + echo "[backup] Оффсайт-копия отправлена." +else + echo "[backup] BACKUP_VPS_HOST не задан — только локальная копия." +fi + +echo "[backup] Готово." diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..3057571 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Восстановление прода из архива бэкапа (fs_.tar.gz): БД + uploads + achievements. +# ДЕСТРУКТИВНО — заменяет текущие данные тома. Сначала проверьте на тест-контуре! +# Использование: +# scripts/restore.sh backups/fs_YYYYmmdd_HHMMSS.tar.gz [--yes] +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" +SERVICE="app" + +ARCHIVE="${1:-}" +CONFIRM="${2:-}" +if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then + echo "Использование: $0 <путь к fs_*.tar.gz> [--yes]" >&2 + exit 1 +fi +if [ "$CONFIRM" != "--yes" ]; then + printf "ВНИМАНИЕ: текущие БД/uploads/achievements будут заменены из %s\nПродолжить? [y/N] " "$ARCHIVE" + read -r ans + case "$ans" in y|Y|yes|да|Да) ;; *) echo "Отменено."; exit 1 ;; esac +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +tar -xzf "$ARCHIVE" -C "$STAGE" +if [ ! -f "$STAGE/forbidden_stars.db" ]; then + echo "ОШИБКА: в архиве нет forbidden_stars.db." >&2 + exit 1 +fi + +echo "[restore] Остановка приложения…" +$COMPOSE stop "$SERVICE" + +echo "[restore] Очистка тома (старая БД, WAL/SHM, медиа, титулы)…" +$COMPOSE run --rm --no-deps -T --entrypoint sh "$SERVICE" -c \ + "rm -f /data/forbidden_stars.db /data/forbidden_stars.db-wal /data/forbidden_stars.db-shm; rm -rf /data/uploads/* /data/achievements/*" + +CID="$($COMPOSE ps -aq "$SERVICE")" +echo "[restore] Запись данных в контейнер…" +docker cp "$STAGE/forbidden_stars.db" "$CID:/data/forbidden_stars.db" +[ -d "$STAGE/uploads" ] && docker cp "$STAGE/uploads/." "$CID:/data/uploads" +[ -d "$STAGE/achievements" ] && docker cp "$STAGE/achievements/." "$CID:/data/achievements" + +echo "[restore] Запуск приложения…" +$COMPOSE start "$SERVICE" +echo "[restore] Готово. Проверьте https://forbiddenstars.ru и логи: $COMPOSE logs -f app"