feat: secure offsite backups

This commit is contained in:
2026-07-19 13:01:20 +08:00
parent 1e01f3295e
commit 21395c19e9
6 changed files with 53 additions and 4 deletions
+17 -1
View File
@@ -3,6 +3,7 @@ set -euo pipefail
# Creates a consistent SQLite backup through the running web container, then archives Hub uploads.
# Optional: BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/backup/mebbling ./scripts/backup.sh
# For encrypted remote copies: BACKUP_AGE_RECIPIENT=age1... BACKUP_RCLONE_REMOTE='remote:bucket/mebbling' ./scripts/backup.sh
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir"
stamp="$(date +%Y%m%d-%H%M%S)"
@@ -23,12 +24,27 @@ docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" web node
'
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
(cd "$backup_dir" && sha256sum hub.db uploads.tar.gz > SHA256SUMS)
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
archive="$backup_dir/backup.tar.gz"
tar -czf "$archive" -C "$backup_dir" hub.db uploads.tar.gz SHA256SUMS
offsite_payload="$backup_dir"
if [[ -n "${BACKUP_AGE_RECIPIENT:-}" ]]; then
command -v age >/dev/null || { echo "age is required when BACKUP_AGE_RECIPIENT is set" >&2; exit 2; }
age -r "$BACKUP_AGE_RECIPIENT" -o "$archive.age" "$archive"
offsite_payload="$archive.age"
fi
if [[ -n "${BACKUP_OFFSITE_DIR:-}" ]]; then
destination="$BACKUP_OFFSITE_DIR/$stamp"; mkdir -p "$destination"
cp -a "$backup_dir/." "$destination/"
if [[ "$offsite_payload" == "$backup_dir" ]]; then cp -a "$backup_dir/." "$destination/"; else cp -a "$offsite_payload" "$destination/"; fi
printf 'Copied verified backup to: %s\n' "$destination"
fi
if [[ -n "${BACKUP_RCLONE_REMOTE:-}" ]]; then
command -v rclone >/dev/null || { echo "rclone is required when BACKUP_RCLONE_REMOTE is set" >&2; exit 2; }
if [[ "$offsite_payload" == "$backup_dir" ]]; then rclone copy "$backup_dir" "$BACKUP_RCLONE_REMOTE/$stamp"; else rclone copy "$offsite_payload" "$BACKUP_RCLONE_REMOTE/$stamp"; fi
printf 'Copied verified backup using rclone to: %s/%s\n' "$BACKUP_RCLONE_REMOTE" "$stamp"
fi
if [[ -n "${BACKUP_RETENTION_DAYS:-}" ]]; then
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "BACKUP_RETENTION_DAYS must be a non-negative integer" >&2; exit 2; }
find data/backups -mindepth 1 -maxdepth 1 -type d -mtime "+$BACKUP_RETENTION_DAYS" -exec rm -rf {} +
+1 -1
View File
@@ -4,8 +4,8 @@ set -euo pipefail
if [[ $# -ne 1 ]]; then echo "Usage: CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
if [[ "${CONFIRM_RESTORE:-}" != "YES" ]]; then echo "Refusing restore. Set CONFIRM_RESTORE=YES after verifying the backup path." >&2; exit 2; fi
backup_dir="$1"; [[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
(cd "$backup_dir" && sha256sum -c SHA256SUMS)
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$root_dir"
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
docker compose down
mkdir -p data public
mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then echo "Usage: ./scripts/verify-backup.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
backup_dir="$(cd "$1" && pwd)"
[[ "$backup_dir" == "$root_dir"/* ]] || { echo "Backup must be inside the project directory" >&2; exit 2; }
[[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
(cd "$backup_dir" && sha256sum -c SHA256SUMS)
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
container_path="/app/${backup_dir#"$root_dir"/}/hub.db"
docker compose -f "$root_dir/docker-compose.yml" exec -T -e BACKUP_PATH="$container_path" web node -e '
const Database = require("better-sqlite3"); const db = new Database(process.env.BACKUP_PATH, { readonly: true });
const row = db.prepare("PRAGMA integrity_check").get(); db.close(); if (row.integrity_check !== "ok") { console.error("SQLite integrity check failed"); process.exit(1); }
'
printf 'Verified backup: %s\n' "$backup_dir"