#!/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}" [[ "$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 } printf 'off_host_backup_count=%s\nnewly_copied=%s\n' "$(find . -maxdepth 1 -type f -name 'krona-staff-*.dump.cms' | wc -l)" "$copied"