Files
backup-to-b2/backup-to-b2.sh
2025-11-04 13:49:18 -06:00

358 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# backup-to-b2.sh
# Config-driven backups of files + docker volumes to Restic (Backblaze B2), plus DR helpers.
set -Eeuo pipefail
#####################################
# CONFIG PATHS #
#####################################
RESTIC_DIR="/etc/restic"
ENV_FILE="${RESTIC_DIR}/env"
FILES_LIST="${RESTIC_DIR}/files.list" # one absolute path per line
VOLUMES_LIST="${RESTIC_DIR}/volumes.list" # one docker volume name per line
EXCLUDES_FILE="${RESTIC_DIR}/excludes.txt" # optional
# Staging & logs
STAGING_DIR="/var/backups/staging"
VOL_ARCHIVE_DIR="${STAGING_DIR}/volumes"
CONFIG_ARCHIVE_DIR="/var/backups/restic-config"
LOG_DIR="/var/log/backup"
LOCK_FILE="/var/lock/backup-to-b2.lock"
# Snapshot tag:
BACKUP_NAME="$(hostname)-$(date +%Y%m%d_%H%M%S)"
# Retention
KEEP_DAILY=7
KEEP_WEEKLY=4
KEEP_MONTHLY=12
KEEP_YEARLY=2
# Extra tags
EXTRA_TAGS="prod,server"
#####################################
# AUTO-LOAD RESTIC ENV #
#####################################
if [ -f "${ENV_FILE}" ]; then
# shellcheck source=/dev/null
source "${ENV_FILE}"
fi
#####################################
# HELPER FUNCS #
#####################################
log() { mkdir -p "$LOG_DIR"; echo "[$(date -Is)] $*" | tee -a "${LOG_DIR}/backup.log"; }
require_bin() { command -v "$1" >/dev/null 2>&1 || { echo "Missing required binary: $1"; exit 1; }; }
check_env() {
: "${RESTIC_REPOSITORY:?Set in ${ENV_FILE} (e.g., b2:bucket:prefix)}"
: "${RESTIC_PASSWORD:?Set in ${ENV_FILE}}"
: "${B2_ACCOUNT_ID:?Set in ${ENV_FILE}}"
: "${B2_ACCOUNT_KEY:?Set in ${ENV_FILE}}"
}
ensure_repo() { restic snapshots >/dev/null 2>&1 || { log "Initializing restic repo..."; restic init; }; }
make_dirs() { mkdir -p "$VOL_ARCHIVE_DIR" "$CONFIG_ARCHIVE_DIR"; }
# Read non-empty, non-comment lines
read_list() {
local file="$1"
[ -f "$file" ] || { echo ""; return 0; }
grep -v '^\s*#' "$file" | sed '/^\s*$/d'
}
# ---------- INLINE DOCKER VOLUME BACKUP/RESTORE (FIXED) ----------
# Writes a tar.gz of <volume> to <dest_tar_gz>, handling absolute paths safely.
backup_docker_volume() {
local volume="$1" dest_tar="$2"
local dest_dir dest_base
dest_dir="$(dirname "$dest_tar")"; dest_base="$(basename "$dest_tar")"
[ -n "$dest_base" ] && [ "$dest_base" != "." ] && [ "$dest_base" != "/" ] || { echo "Invalid dest: $dest_tar"; return 1; }
mkdir -p "$dest_dir"
docker run --rm \
--env DEST="$dest_base" \
--mount "type=volume,source=${volume},target=/volume,readonly" \
--mount "type=bind,src=${dest_dir},dst=/backup" \
busybox sh -c 'set -e; tar czf "/backup/${DEST}" -C /volume .'
}
# Restores a tar.gz file into <volume>
restore_docker_volume() {
local volume="$1" src_tar="$2"
local src_dir src_base
src_dir="$(dirname "$src_tar")"; src_base="$(basename "$src_tar")"
[ -n "$src_base" ] && [ "$src_base" != "." ] && [ "$src_base" != "/" ] || { echo "Invalid src: $src_tar"; return 1; }
docker volume inspect "$volume" >/dev/null 2>&1 || docker volume create "$volume" >/dev/null
docker run --rm \
--env SRC="$src_base" \
--mount "type=volume,source=${volume},target=/volume" \
--mount "type=bind,src=${src_dir},dst=/backup,readonly" \
busybox sh -c 'set -e; cd /volume && tar xzf "/backup/${SRC}"'
}
# ---------------------------------------------------------------
# Restore a Docker volume by fetching its tar.gz from restic (snapshot or latest)
restore_volume_from_repo() {
local volume="$1" snap="${2:-latest}"
local rel_path="${VOL_ARCHIVE_DIR#/}/" # ensure leading slash
rel_path="/$(echo "$rel_path" | sed 's#^/*##')" # normalize
rel_path="${rel_path%/}/" # trailing slash
rel_path="${rel_path}${volume}.tar.gz"
require_bin restic; require_bin docker
local tmpdir outfile; tmpdir="$(mktemp -d)"; outfile="${tmpdir}/${volume}.tar.gz"
log "Fetching '${rel_path}' from snapshot '${snap}'..."
if ! restic dump "${snap}" "${rel_path}" > "${outfile}"; then
log "ERROR: ${rel_path} not found in snapshot ${snap}. Try: restic ls ${snap} | grep volumes/"
rm -rf "${tmpdir}"; exit 1
fi
log "Restoring into Docker volume '${volume}'..."
restore_docker_volume "${volume}" "${outfile}"
log "Restore complete for volume '${volume}'."
rm -rf "${tmpdir}"
}
# Build restic sources from files.list and staged volume tarballs
build_restic_sources() {
RESTIC_SRC_ARGS=()
local p
while IFS= read -r p; do
[ -e "$p" ] || { log "WARN: path not found (skipped): $p"; continue; }
RESTIC_SRC_ARGS+=("$p")
done < <(read_list "$FILES_LIST")
if [ -d "$VOL_ARCHIVE_DIR" ] && compgen -G "${VOL_ARCHIVE_DIR}/*.tar.gz" >/dev/null; then
RESTIC_SRC_ARGS+=("$VOL_ARCHIVE_DIR")
fi
[ "${#RESTIC_SRC_ARGS[@]}" -gt 0 ] || { echo "No sources to back up. Fill ${FILES_LIST} and/or ${VOLUMES_LIST}."; exit 1; }
}
dump_volumes() {
local any=0
require_bin docker
while IFS= read -r vol; do
[ -n "$vol" ] || continue
any=1
local out="${VOL_ARCHIVE_DIR}/${vol}.tar.gz"
log "Dumping volume '${vol}' => ${out}"
backup_docker_volume "$vol" "$out"
log "Volume '${vol}' backed up."
done < <(read_list "$VOLUMES_LIST" || true)
[ "$any" -eq 1 ] || log "No Docker volumes listed in ${VOLUMES_LIST} — skipping."
}
do_backup() {
check_env; require_bin restic; make_dirs
dump_volumes
ensure_repo
build_restic_sources
local tags="name=${BACKUP_NAME}"; [ -n "$EXTRA_TAGS" ] && tags="${tags},${EXTRA_TAGS}"
log "Starting restic backup..."
set +e
if [ -f "$EXCLUDES_FILE" ]; then
restic backup --tag "$tags" --exclude-file "$EXCLUDES_FILE" --one-file-system --verbose "${RESTIC_SRC_ARGS[@]}"
else
restic backup --tag "$tags" --one-file-system --verbose "${RESTIC_SRC_ARGS[@]}"
fi
local rc=$?; set -e
[ $rc -eq 0 ] || { log "Restic backup FAILED ($rc)"; exit $rc; }
log "Restic backup completed."
log "Running restic check..."
restic check --with-cache
log "Applying prune policy (D=$KEEP_DAILY W=$KEEP_WEEKLY M=$KEEP_MONTHLY Y=$KEEP_YEARLY)..."
restic forget --prune \
--keep-daily "${KEEP_DAILY}" \
--keep-weekly "${KEEP_WEEKLY}" \
--keep-monthly "${KEEP_MONTHLY}" \
--keep-yearly "${KEEP_YEARLY}"
log "Backup run finished successfully."
}
clean_staging() { log "Cleaning ${STAGING_DIR}..."; rm -rf "${STAGING_DIR:?}/"* || true; }
# Create encrypted bundle of critical config + script; optionally upload
make_config_backup() {
require_bin openssl
make_dirs
local script_path; script_path="$(readlink -f "$0")"
local ts; ts="$(date +%Y%m%d_%H%M%S)"
local tarball="${CONFIG_ARCHIVE_DIR}/restic-config-${ts}.tar.gz"
local enc="${tarball}.enc"
# Build the tarball (include env, lists, excludes, script, cron drop-in if exists)
local cron_file="/etc/cron.d/backup-to-b2"
log "Bundling config into ${tarball} ..."
tar czf "${tarball}" \
-C / \
"$(realpath --relative-to=/ "$ENV_FILE")" \
"$(realpath --relative-to=/ "$FILES_LIST" 2>/dev/null || true)" \
"$(realpath --relative-to=/ "$VOLUMES_LIST" 2>/dev/null || true)" \
"$(realpath --relative-to=/ "$EXCLUDES_FILE" 2>/dev/null || true)" \
"$(realpath --relative-to=/ "$script_path")" \
"$(realpath --relative-to=/ "$cron_file" 2>/dev/null || true)"
# Encrypt it — use env CONFIG_ARCHIVE_PASSPHRASE if set, else prompt
if [ -n "${CONFIG_ARCHIVE_PASSPHRASE:-}" ]; then
log "Encrypting archive (env passphrase) -> ${enc}"
openssl enc -aes-256-cbc -salt -pbkdf2 -pass env:CONFIG_ARCHIVE_PASSPHRASE -in "${tarball}" -out "${enc}"
else
log "Encrypting archive (will prompt for passphrase) -> ${enc}"
openssl enc -aes-256-cbc -salt -pbkdf2 -in "${tarball}" -out "${enc}"
fi
shred -u "${tarball}" || rm -f "${tarball}"
log "Encrypted config bundle written to ${enc}"
# Optional upload priority 1: B2 CLI, if CONFIG_BACKUP_B2_URL=b2://bucket/prefix
if [ -n "${CONFIG_BACKUP_B2_URL:-}" ] && command -v b2 >/dev/null 2>&1; then
require_bin b2
log "Uploading encrypted bundle to ${CONFIG_BACKUP_B2_URL}..."
# Parse b2://bucket/prefix
local url="${CONFIG_BACKUP_B2_URL#b2://}"
local bucket="${url%%/*}" prefix="${url#*/}"
b2 authorize-account
b2 upload-file "$bucket" "${enc}" "${prefix%/}/$(basename "${enc}")"
log "Upload via B2 CLI complete."
return 0
fi
# Optional upload fallback: store inside your Restic repo
if [ -n "${RESTIC_REPOSITORY:-}" ] && command -v restic >/dev/null 2>&1; then
log "Backing up config bundle into restic repo..."
restic backup --tag "config-bundle" "${enc}"
log "Config bundle stored in restic."
else
log "No upload target configured; keep ${enc} safe (copy off-box)."
fi
}
# ---------------------------------------------------------------
# Decrypt an encrypted config bundle (.tar.gz.enc)
# Usage: decrypt-config-backup <file.enc> [--restore]
# ---------------------------------------------------------------
decrypt_config_backup() {
require_bin openssl
local enc_file="$1"
local mode="${2:-}"
[ -f "$enc_file" ] || { echo "File not found: $enc_file"; exit 1; }
local ts tmpdir outdir tarfile
ts="$(date +%Y%m%d_%H%M%S)"
tmpdir="/tmp/restic-config-restore-${ts}"
outdir="${tmpdir}/decrypted"
tarfile="${tmpdir}/bundle.tar.gz"
mkdir -p "$outdir"
log "Decrypting ${enc_file}..."
if [ -n "${CONFIG_ARCHIVE_PASSPHRASE:-}" ]; then
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:CONFIG_ARCHIVE_PASSPHRASE -in "$enc_file" -out "$tarfile"
else
openssl enc -d -aes-256-cbc -pbkdf2 -in "$enc_file" -out "$tarfile"
fi
log "Extracting contents to ${outdir}..."
tar xzf "$tarfile" -C "$outdir"
rm -f "$tarfile"
echo
log "✅ Decrypted configuration extracted to: ${outdir}"
echo
tree "$outdir" 2>/dev/null || ls -R "$outdir"
if [ "$mode" = "--restore" ]; then
echo
read -r -p "⚠️ This will overwrite files under /etc/restic and your backup script. Continue? [y/N] " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
log "Restoring configuration to system paths..."
sudo cp -v -r "$outdir/etc/restic/"* /etc/restic/ 2>/dev/null || true
[ -f "$outdir/usr/local/bin/backup-to-b2.sh" ] && sudo cp -v "$outdir/usr/local/bin/backup-to-b2.sh" /usr/local/bin/
[ -f "$outdir/etc/cron.d/backup-to-b2" ] && sudo cp -v "$outdir/etc/cron.d/backup-to-b2" /etc/cron.d/
sudo chmod 600 /etc/restic/env
sudo chmod +x /usr/local/bin/backup-to-b2.sh
log "Configuration restored."
else
log "Skipped restoring; files remain in ${outdir}."
fi
fi
}
usage() {
cat <<EOF
Usage: $0 [run|dry-run|clean|restore-volume <volume> <path/to/backup.tar.gz>|restore-volume-from-repo <volume> [snapshot|latest]|make-config-backup]
Commands:
run Dump volumes listed in ${VOLUMES_LIST}, restic-backup files from ${FILES_LIST} + prune.
dry-run Show intended sources (no repo writes).
clean Remove staged volume tarballs.
restore-volume Restore a tar.gz archive into a Docker volume (no restic).
restore-volume-from-repo Restore a volume by pulling its tar.gz from the restic repo (snapshot id or 'latest').
make-config-backup Create encrypted tar of env/lists/excludes/script (+ optional upload).
decrypt-config-backup <file.enc> [--restore] Decrypt and optionally restore a config bundle
Config files:
${ENV_FILE} # exports for RESTIC_REPOSITORY, RESTIC_PASSWORD, B2_ACCOUNT_ID, B2_ACCOUNT_KEY
${FILES_LIST} # absolute paths (one per line), '#' for comments
${VOLUMES_LIST} # docker volume names (one per line), '#' for comments
${EXCLUDES_FILE} # optional restic exclude patterns
Environment (optional):
CONFIG_ARCHIVE_PASSPHRASE # passphrase for make-config-backup (otherwise prompted)
CONFIG_BACKUP_B2_URL # e.g. b2://my-bucket/config-bundles
EOF
}
dry_run() {
make_dirs
echo "Would dump volumes from ${VOLUMES_LIST}:"
while IFS= read -r vol; do [ -n "$vol" ] && echo " - ${vol} => ${VOL_ARCHIVE_DIR}/${vol}.tar.gz"; done < <(read_list "$VOLUMES_LIST" || true)
echo
echo "Would back up these file paths from ${FILES_LIST}:"
while IFS= read -r p; do [ -n "$p" ] && echo " - $p"; done < <(read_list "$FILES_LIST" || true)
echo
echo "Exclude file: ${EXCLUDES_FILE} $( [ -f "$EXCLUDES_FILE" ] && echo '(FOUND)' || echo '(not present)' )"
echo "Prune policy: daily=${KEEP_DAILY}, weekly=${KEEP_WEEKLY}, monthly=${KEEP_MONTHLY}, yearly=${KEEP_YEARLY}"
}
main() {
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE" || { echo "Cannot open lock file: $LOCK_FILE"; exit 1; }
flock -n 9 || { echo "Another backup is running. Exiting."; exit 1; }
trap 'echo "Error on line $LINENO"; exit 1' ERR
case "${1:-}" in
run) do_backup ;;
dry-run|--dry-run) dry_run ;;
clean) clean_staging ;;
restore-volume)
require_bin docker
[[ $# -eq 3 ]] || { echo "Usage: $0 restore-volume <volume> <path/to/backup.tar.gz>"; exit 1; }
restore_docker_volume "$2" "$3"
log "Restore complete into volume: $2"
;;
restore-volume-from-repo)
[[ $# -ge 2 && $# -le 3 ]] || { echo "Usage: $0 restore-volume-from-repo <volume> [snapshot|latest]"; exit 1; }
check_env; restore_volume_from_repo "$2" "${3:-latest}"
;;
make-config-backup)
make_config_backup
;;
decrypt-config-backup)
[[ $# -ge 2 && $# -le 3 ]] || { echo "Usage: $0 decrypt-config-backup <file.enc> [--restore]"; exit 1; }
decrypt_config_backup "$2" "${3:-}"
;;
*) usage ;;
esac
}
main "$@"