{% 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 %}" BACKUP_EXCLUDE_PROP="{% endraw %}{{ backup_zfs_exclude_property | default('com.pursotin:backup') }}{% raw %}" USER="{% endraw %}{{ backup_ssh_user }}{% raw %}" HOST="{% endraw %}{{ backup_ssh_host }}{% raw %}" EMAIL_TO=root # ===================================== # ----- Globals for notification ----- STARTED_AT="$(date '+%Y-%m-%dT%H:%M:%S%z')" FINISHED_AT="" MESSAGE_LOG="" BACKUP_NAME="" # e.g. zroot@2025-09-30-12-00-00-0300.enc BACKUP_SIZE_BYTES="" # numeric bytes # ----- 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 } log_note() { # Echo to console and append to message buffer local msg="$1" echo "$msg" MESSAGE_LOG+="$msg"$'\n' } timestamp() { # Replace '+' with '-' so timezone is filename-safe and lexicographically sortable within TZ. date +%Y-%m-%d-%H-%M-%S%z | tr '+' '-' } humanize_bytes() { local bytes="$1" if [[ "${bytes}" =~ ^[0-9]+$ ]]; then awk -v b="${bytes}" ' BEGIN { unit_count = split("B KiB MiB GiB TiB PiB EiB", units, " ") i = 1 while (b >= 1024 && i < unit_count) { b = b / 1024 i++ } if (i == 1) { printf "%.0f %s\n", b, units[i] } else { printf "%.2f %s\n", b, units[i] } } ' else echo "unknown" fi } 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_excluded_datasets() { # List datasets (including inherited values) where backup property is explicitly false. zfs get -r -H -o name,value "${BACKUP_EXCLUDE_PROP}" "${DATASET}" 2>/dev/null \ | awk 'tolower($2)=="false"{print $1}' } list_remote_backups_sorted() { # 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}" log_note "init-remote: verified remote directory '${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}" log_note "init-remote: created remote directory '${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" log_note "snapshot: created recursive snapshot '${snap}'" } cmd_send_to_remote() { echo "" echo "[send-to-remote]" require_cmds zfs age sftp mktemp stat awk sort local snap base target tmp size_bytes ds parent skip local -a send_args excluded_raw excluded 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 send_args=(-Rvc) mapfile -t excluded_raw < <(list_excluded_datasets | sort) excluded=() for ds in "${excluded_raw[@]}"; do skip=0 [[ -n "${ds}" ]] || continue for parent in "${excluded[@]}"; do if [[ "${ds}" == "${parent}" || "${ds}" == "${parent}/"* ]]; then skip=1 break fi done [[ "${skip}" -eq 1 ]] || excluded+=("${ds}") done if [[ "${#excluded[@]}" -gt 0 ]]; then echo "Excluding datasets where ${BACKUP_EXCLUDE_PROP}=false:" for ds in "${excluded[@]}"; do if [[ "${ds}" == "${DATASET}" ]]; then die "Top dataset ${DATASET} is excluded via ${BACKUP_EXCLUDE_PROP}=false; refusing to create an empty backup stream." fi echo " - ${ds}" send_args+=(-X "${ds}") done log_note "send-to-remote: excluded ${#excluded[@]} dataset(s) using ${BACKUP_EXCLUDE_PROP}=false" fi 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 "${send_args[@]}" "${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 # Set globals for notify() BACKUP_NAME="${target}" BACKUP_SIZE_BYTES="${size_bytes}" log_note "send-to-remote: uploaded '${snap}' as '${target}' (${size_bytes} bytes) to ${HOST}:${HOST_DIR}" 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." log_note "prune-remote: kept ${count} (<= ${KEEP_REMOTE}); no deletions" 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