#!/usr/bin/env bash # Run on the backup PC (Git Bash), after the server has made encrypted backups. set -euo pipefail umask 077 : "${PILOT_SSH_TARGET:?Set the configured SSH alias for the pilot server}" : "${PILOT_REMOTE_BACKUP_DIRECTORY:?Set its absolute encrypted backup directory}" : "${BACKUP_LOCAL_DIRECTORY:?Set an absolute backup directory on this PC}" BACKUP_LOCAL_RETENTION_DAYS=${BACKUP_LOCAL_RETENTION_DAYS:-90} [[ "$BACKUP_LOCAL_RETENTION_DAYS" =~ ^[1-9][0-9]{0,4}$ ]] && (( BACKUP_LOCAL_RETENTION_DAYS <= 36500 )) || { echo 'BACKUP_LOCAL_RETENTION_DAYS must be an integer from 1 to 36500' >&2 exit 1 } [[ "$PILOT_SSH_TARGET" =~ ^[a-zA-Z0-9_.@-]+$ ]] || { echo 'Invalid SSH target' >&2; exit 1; } [[ "$PILOT_REMOTE_BACKUP_DIRECTORY" =~ ^/[a-zA-Z0-9_./-]+$ ]] || { echo 'Invalid server backup directory' >&2; exit 1; } [[ "$PILOT_REMOTE_BACKUP_DIRECTORY" != */../* && "$PILOT_REMOTE_BACKUP_DIRECTORY" != */.. ]] || { echo 'Parent traversal is not allowed' >&2; exit 1; } [[ "$PILOT_REMOTE_BACKUP_DIRECTORY" != / ]] || { echo 'Use a dedicated server backup directory' >&2; exit 1; } [[ "$BACKUP_LOCAL_DIRECTORY" = /* && "$BACKUP_LOCAL_DIRECTORY" != / ]] || { echo 'Use a dedicated absolute local directory' >&2; exit 1; } [[ "$BACKUP_LOCAL_DIRECTORY" != */../* && "$BACKUP_LOCAL_DIRECTORY" != */.. ]] || { echo 'Parent traversal is not allowed' >&2; exit 1; } [[ ! -L "$BACKUP_LOCAL_DIRECTORY" ]] || { echo 'Local directory must not be a symlink' >&2; exit 1; } mkdir -p -- "$BACKUP_LOCAL_DIRECTORY" local_dir=$(cd -- "$BACKUP_LOCAL_DIRECTORY" && pwd -P) [[ "$local_dir" != / && "$local_dir" != /tmp && "$local_dir" != /var && "$local_dir" != /home ]] || { echo 'Local backup path resolves to a broad directory' >&2 exit 1 } cd -- "$local_dir" remote_dir=${PILOT_REMOTE_BACKUP_DIRECTORY%/} listing=$(ssh -o BatchMode=yes -- "$PILOT_SSH_TARGET" \ "find '$remote_dir' -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' -printf '%f\n'") [[ -n "$listing" ]] || { echo 'No encrypted backups found on server' >&2; exit 1; } copied=0 latest_backup_epoch=0 while IFS= read -r name; do [[ "$name" =~ ^krona-staff-[0-9]{8}T[0-9]{6}Z-[0-9]+\.dump\.cms$ ]] || { echo 'Unexpected backup filename from server' >&2 exit 1 } timestamp=${name#krona-staff-} timestamp=${timestamp%%-*} backup_epoch=$(date -u -d "${timestamp:0:4}-${timestamp:4:2}-${timestamp:6:2} ${timestamp:9:2}:${timestamp:11:2}:${timestamp:13:2} UTC" +%s) if (( backup_epoch > latest_backup_epoch )); then latest_backup_epoch=$backup_epoch fi if [[ -e "$name" || -e "$name.sha256" ]]; then [[ -f "$name" && -f "$name.sha256" ]] || { echo "Incomplete local copy: $name" >&2; exit 1; } sha256sum -c -- "$name.sha256" >/dev/null || { echo "Local checksum mismatch: $name" >&2; exit 1; } continue fi encrypted_partial="$name.partial" checksum_partial="$name.sha256.partial" [[ ! -e "$encrypted_partial" && ! -e "$checksum_partial" ]] || { echo "Interrupted prior copy needs inspection: $name" >&2 exit 1 } scp -B -- "$PILOT_SSH_TARGET:$remote_dir/$name" "$encrypted_partial" scp -B -- "$PILOT_SSH_TARGET:$remote_dir/$name.sha256" "$checksum_partial" expected=$(awk 'NR == 1 { print $1 }' "$checksum_partial") [[ "$expected" =~ ^[[:xdigit:]]{64}$ ]] || { echo "Malformed checksum: $name" >&2; exit 1; } actual=$(sha256sum -- "$encrypted_partial") [[ "${actual%% *}" = "$expected" ]] || { echo "Transfer checksum mismatch: $name" >&2; exit 1; } mv -- "$encrypted_partial" "$name" mv -- "$checksum_partial" "$name.sha256" copied=$((copied + 1)) done <<< "$listing" max_age_hours=${BACKUP_MAX_AGE_HOURS:-36} [[ "$max_age_hours" =~ ^[1-9][0-9]*$ ]] || { echo 'BACKUP_MAX_AGE_HOURS must be a positive integer' >&2; exit 1; } now_epoch=$(date -u +%s) (( now_epoch >= latest_backup_epoch && now_epoch - latest_backup_epoch <= max_age_hours * 3600 )) || { echo 'Newest encrypted backup is stale or has a future timestamp' >&2 exit 1 } retention_cutoff=$((now_epoch - BACKUP_LOCAL_RETENTION_DAYS * 86400)) pruned=0 retention_skipped=0 while IFS= read -r -d '' backup_path; do name=${backup_path##*/} [[ "$name" =~ ^krona-staff-([0-9]{8}T[0-9]{6}Z)-[0-9]+\.dump\.cms$ ]] || continue timestamp=${BASH_REMATCH[1]} backup_epoch=$(date -u -d "${timestamp:0:4}-${timestamp:4:2}-${timestamp:6:2} ${timestamp:9:2}:${timestamp:11:2}:${timestamp:13:2} UTC" +%s 2>/dev/null) || continue [[ "$(date -u -d "@$backup_epoch" '+%Y%m%dT%H%M%SZ')" == "$timestamp" ]] || continue (( backup_epoch < retention_cutoff )) || continue checksum_path="$local_dir/$name.sha256" if [[ ! -f "$backup_path" || -L "$backup_path" || ! -f "$checksum_path" || -L "$checksum_path" ]] || [[ "$(wc -l < "$checksum_path")" -ne 1 ]]; then retention_skipped=$((retention_skipped + 1)) continue fi read -r expected stored_name extra < "$checksum_path" || { retention_skipped=$((retention_skipped + 1)) continue } if [[ ! "$expected" =~ ^[[:xdigit:]]{64}$ || "$stored_name" != "$name" || -n "${extra:-}" ]]; then retention_skipped=$((retention_skipped + 1)) continue fi actual=$(sha256sum -- "$backup_path") if [[ "${actual%% *}" != "$expected" ]]; then retention_skipped=$((retention_skipped + 1)) continue fi rm -- "$backup_path" "$checksum_path" pruned=$((pruned + 1)) done < <(find "$local_dir" -mindepth 1 -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' -print0) printf 'off_host_backup_count=%s\nnewly_copied=%s\nretention_pruned=%s\nretention_skipped=%s\n' \ "$(find "$local_dir" -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' | wc -l)" \ "$copied" "$pruned" "$retention_skipped"