{% raw %}#!/usr/bin/env bash set -euo pipefail # ===== CONFIG (filled by Ansible) ===== KEEP_LOCAL=30 KEEP_REMOTE=10 KEYFILE="/root/.ssh/backup" HOST_DIR="backup" DATASET="{% endraw %}{{ backup_zfs_dataset }}{% raw %}" USER="{% endraw %}{{ backup_ssh_user }}{% raw %}" HOST="{% endraw %}{{ backup_ssh_host }}{% raw %}" EMAIL_TO=root # ===================================== # ----- Helpers ----- die() { echo "Error: $*" >&2; exit 1; } require_cmds() { local cmds=("$@") for c in "${cmds[@]}"; do command -v "$c" >/dev/null 2>&1 || die "Missing command: $c"; done } timestamp() { # Replace '+' with '-' so timezone is filename-safe and lexicographically sortable within TZ. date +%Y-%m-%d-%H-%M-%S%z | tr '+' '-' } latest_snapshot_for_dataset() { # Latest snapshot on the TOP dataset only (newest first). Returns e.g. zroot@2025-09-30-... zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \ | awk 'NR==2{print; exit}' } list_top_snapshots_newest_first() { # Only list snapshots on the top dataset (not children), newest first. zfs list -t snapshot -o name -S creation "$DATASET" 2>/dev/null \ | awk 'NR>1{print $1}' } list_remote_backups_sorted() { # Note: sorting strictly orders only within the same timezone in the name, which is fine for our daily cadence. # We store files under ${HOST_DIR}/@.enc echo "ls ${HOST_DIR}" \ | sftp -q -i "${KEYFILE}" "${USER}@${HOST}" 2>/dev/null \ | tail -n +2 | sort } # Upload a local file to the storage box path "${HOST_DIR}/" sftp_put() { local local_file="$1" local remote_name="$2" # just the filename, no directory sftp -i "${KEYFILE}" "${USER}@${HOST}" </dev/null 2>&1; then echo "Remote directory already exists: ${HOST_DIR}" return 0 fi echo "Creating remote directory: ${HOST_DIR}" set +e sftp -i "${KEYFILE}" "${USER}@${HOST}" </dev/null 2>&1; then echo "Remote directory created: ${HOST_DIR}" else die "Remote directory ${HOST_DIR} not found after creation" fi } cmd_snapshot() { echo "" echo "[snapshot]" require_cmds zfs date tr local ts snap ts="$(timestamp)" snap="${DATASET}@${ts}" echo "Taking recursive snapshot \"${snap}\"" (set -x; zfs snapshot -r "${snap}") echo "Recursive snapshot \"${snap}\" created" } cmd_send_to_remote() { echo "" echo "[send-to-remote]" require_cmds zfs age sftp mktemp stat local snap base target tmp size_bytes snap="$(latest_snapshot_for_dataset)" [[ -n "${snap}" ]] || die "No snapshot found to send. Run 'snapshot' first or ensure the dataset has snapshots." base="$(basename "${snap}")" # e.g., zroot@2025-09-30-12-00-00-0300 target="${base}.enc" tmp="$(mktemp -t backup_send.XXXXXX)" trap 'rm -f "${tmp}"' EXIT echo "Creating encrypted replication stream to temp file: ${tmp}" # -R: recursive hierarchy, -v: progress to stderr, -c: send compressed (keeps on-disk compression) (set -x; zfs send -Rvc "${snap}" | age -e -i "${KEYFILE}" > "${tmp}") size_bytes="$(stat -f %z "${tmp}" 2>/dev/null || stat -c %s "${tmp}" 2>/dev/null || echo "unknown")" echo "Local stream size: ${size_bytes} bytes" echo "Uploading via SFTP to ${HOST}:${HOST_DIR}/${target}" if sftp_put "${tmp}" "${target}"; then echo "Upload complete" else die "SFTP upload failed (does the remote directory '${HOST_DIR}' exist? Run 'init-remote')" fi rm -f "${tmp}" trap - EXIT } cmd_prune_remote() { echo "" echo "[prune-remote]" require_cmds sftp awk sort wc echo "Fetching remote backup listing from sftp://${HOST}/${HOST_DIR}" BACKUPS="$(list_remote_backups_sorted || true)" mapfile -t BACKUP_ARR < <(printf "%s\n" "${BACKUPS}") local count="${#BACKUP_ARR[@]}" if [[ "${count}" -le "${KEEP_REMOTE}" ]]; then echo "Remote backups (${count}) <= KEEP_REMOTE (${KEEP_REMOTE}); nothing to prune." return 0 fi echo "Pruning remote backups, keeping latest ${KEEP_REMOTE} (will delete $(("${count}" - "${KEEP_REMOTE}")))" local to_delete_count=$((count - KEEP_REMOTE)) local deleted=0 for ((i=0; i