#!/bin/bash # ============================================================================================== # ================================= Backup Verify ============================================== # ============================================================================================== # Verifies the rsync mirror is healthy by comparing random file samples between # local and remote servers using MD5 checksums. # # ── WHAT IT DOES ────────────────────────────────────────────────────────────────────────────── # Randomly samples BACKUP_VERIFY_SAMPLE files per share above BACKUP_VERIFY_MIN_SIZE, # computes MD5 checksums locally, then computes the same checksums on the remote via SSH # and compares results. Catches silent corruption or incomplete syncs that rsync itself # would not detect. # # ── RESULTS PER FILE ────────────────────────────────────────────────────────────────────────── # MATCH — checksums identical, file is correctly mirrored ✅ # MISMATCH — file exists on both but checksums differ — sync may have partially failed # MISSING — file exists locally but not on remote — not yet synced or deleted on remote # # ── SHARE SELECTION ─────────────────────────────────────────────────────────────────────────── # Uses HOST*_BACKUP_VERIFY_SHARES if defined, falls back to HOST*_DAILY_SYNC_SHARES. # Both aliased by detect_hosts() — no manual HOST1/HOST2 selection needed. # # ── SAFEGUARDS ──────────────────────────────────────────────────────────────────────────────── # acquire_lock — prevents concurrent runs producing duplicate/conflicting results # check_connectivity() — verifies remote reachable before attempting SSH calls # check_remote_array() — verifies remote array mounted before checksums # remote array down = all files "missing" = false alarm ✅ # version parity — verifies both servers on compatible unRAID before trusting results # SSH_TIMEOUT — all SSH calls protected against hangs # validate_unraid_cmd — notify script validated before use # Silent by default — only issues produce output, all-match runs are silent # # ── CONFIGURATION (master_host*.conf) ───────────────────────────────────────────────────────── # HOST*_BACKUP_VERIFY_SHARES — override share list (empty = use DAILY_SYNC_SHARES) # HOST*_DAILY_SYNC_SHARES — fallback share list # All aliased by detect_hosts() # # ── CONFIGURATION (master.conf) ─────────────────────────────────────────────────────────────── # BACKUP_VERIFY_SAMPLE — random files to check per share (default 10) # BACKUP_VERIFY_MIN_SIZE — minimum file size to include in sample (default 1M) # # ── USAGE ───────────────────────────────────────────────────────────────────────────────────── # backup_verify.sh — normal run # backup_verify.sh --dry-run — show sample selection only, no checksums # backup_verify.sh --log — verbose output # backup_verify.sh --status — show config and exit # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" SSH_TIMEOUT=15 # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_GEAR Setup ━━━" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi validate_unraid_cmd \ "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ "" "" \ "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" acquire_lock # detect_hosts() sets MY_ID and aliases BACKUP_VERIFY_SHARES + DAILY_SYNC_SHARES detect_hosts # Share selection — configured list or fallback to daily sync shares if [[ ${#BACKUP_VERIFY_SHARES[@]} -gt 0 ]]; then VERIFY_SHARES=("${BACKUP_VERIFY_SHARES[@]}") log "Using BACKUP_VERIFY_SHARES (${#VERIFY_SHARES[@]} shares)" else VERIFY_SHARES=("${DAILY_SYNC_SHARES[@]}") log "BACKUP_VERIFY_SHARES not set — using DAILY_SYNC_SHARES (${#VERIFY_SHARES[@]} shares)" fi if [[ ${#VERIFY_SHARES[@]} -eq 0 ]]; then warn "No shares configured for $MY_ID — nothing to verify" warn "Check HOST*_BACKUP_VERIFY_SHARES or HOST*_DAILY_SYNC_SHARES in master_host*.conf" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — showing sample selection only, no checksums computed" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME — $REMOTE_SERVER)" echo "$ICON_VERIFY Shares: ${#VERIFY_SHARES[@]}" echo "$ICON_VERIFY Sample: $BACKUP_VERIFY_SAMPLE files per share" echo "$ICON_VERIFY Min size: $BACKUP_VERIFY_MIN_SIZE" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "" echo " Shares to verify:" for share in "${VERIFY_SHARES[@]}"; do echo " $share" done echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Pre-flight ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_SHIELD Pre-flight ━━━" resolve_remote_ip # Connectivity — no point making 100+ SSH calls if remote is unreachable check_connectivity # Version parity — mismatched unRAID could cause md5sum path differences check_unraid_version_parity || { warn "Version parity check failed — proceeding with caution" warn "Checksum results may be unreliable if md5sum path changed between versions" } # Remote array — if array is down all files appear "missing" = false alarm if ! check_remote_array; then error "Remote array not mounted on $REMOTE_SERVER_NAME" error "All files would appear as MISSING — aborting to prevent false alarm" notify "Backup verify aborted on $(hostname) — remote array not mounted on $REMOTE_SERVER_NAME" \ "Backup Verify" "warning" exit 1 fi log "Pre-flight passed ✅" # ============================================================================================== # ━━━ Backup Verification ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_VERIFY Backup Verification — $(date '+%Y-%m-%d %H:%M:%S') ━━━" echo "$ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → $REMOTE_ID ($REMOTE_SERVER_NAME)" echo "$ICON_VERIFY Sample: $BACKUP_VERIFY_SAMPLE files per share (min: $BACKUP_VERIFY_MIN_SIZE)" echo "" START=$(date +%s) TOTAL_CHECKED=0 TOTAL_MATCH=0 TOTAL_MISMATCH=0 TOTAL_MISSING=0 SHARES_WITH_ISSUES=() for share in "${VERIFY_SHARES[@]}"; do SHARE_NAME=$(basename "$share") echo "━━━ $ICON_VERIFY $SHARE_NAME ━━━" if [[ ! -d "$share" ]]; then warn "$SHARE_NAME not found locally — skipping" echo "" continue fi # Sample random files above minimum size mapfile -t SAMPLE_FILES < <( find "$share" -type f -size +"$BACKUP_VERIFY_MIN_SIZE" 2>/dev/null | \ shuf | head -n "$BACKUP_VERIFY_SAMPLE" ) if [[ ${#SAMPLE_FILES[@]} -eq 0 ]]; then log "$SHARE_NAME — no files found above $BACKUP_VERIFY_MIN_SIZE" echo "" continue fi log "$SHARE_NAME — sampled ${#SAMPLE_FILES[@]} files" if [[ "$DRY_RUN" == true ]]; then for f in "${SAMPLE_FILES[@]}"; do warn "DRY RUN — would check: $(basename "$f")" done echo "" continue fi SHARE_MATCH=0 SHARE_MISMATCH=0 SHARE_MISSING=0 for local_file in "${SAMPLE_FILES[@]}"; do [[ -z "$local_file" ]] && continue # Local checksum local_md5=$(md5sum "$local_file" 2>/dev/null | awk '{print $1}') if [[ -z "$local_md5" ]]; then warn "Could not checksum locally: $(basename "$local_file") — skipping" continue fi # Remote checksum via SSH — timeout protected remote_md5=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ -o ConnectTimeout="$SSH_TIMEOUT" \ -o StrictHostKeyChecking=no \ root@"$REMOTE_SERVER" \ "md5sum '$local_file' 2>/dev/null | awk '{print \$1}'" 2>/dev/null) (( TOTAL_CHECKED++ )) if [[ -z "$remote_md5" ]]; then warn "$ICON_ERROR MISSING: $(basename "$local_file")" (( SHARE_MISSING++ )) (( TOTAL_MISSING++ )) elif [[ "$local_md5" == "$remote_md5" ]]; then log "MATCH: $(basename "$local_file")" (( SHARE_MATCH++ )) (( TOTAL_MATCH++ )) else error "MISMATCH: $(basename "$local_file")" error " local: $local_md5" error " remote: $remote_md5" (( SHARE_MISMATCH++ )) (( TOTAL_MISMATCH++ )) fi done # Per-share result — only visible if issues found if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 ]]; then warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH" SHARES_WITH_ISSUES+=("$SHARE_NAME") else log "$SHARE_NAME — all $SHARE_MATCH files match ✅" fi echo "" done END=$(date +%s) # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "━━━━━ $ICON_SUMMARY BACKUP VERIFY SUMMARY ━━━━━" echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)" echo "$ICON_VERIFY Checked: $TOTAL_CHECKED files" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" echo "" if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then echo "$ICON_SUCCESS Match: $TOTAL_MATCH" warn "Missing: $TOTAL_MISSING" [[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH" fi if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no checksums computed" elif [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then echo "$ICON_ERROR Status: ISSUES FOUND — ${#SHARES_WITH_ISSUES[@]} share(s) need attention: ${SHARES_WITH_ISSUES[*]}" notify "Backup verify FAILED on $(hostname) → $REMOTE_SERVER_NAME — mismatches: $TOTAL_MISMATCH missing: $TOTAL_MISSING — shares: ${SHARES_WITH_ISSUES[*]}" \ "Backup Verify" "warning" else log "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ "$TOTAL_MISMATCH" -gt 0 ]] && exit 1 exit 0