krona-staff/scripts/pull-pilot-backups.sh

119 lines
5.5 KiB
Bash

#!/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"