A path with an apostrophe broke the remote shell quoting and a big file outran the connect timeout, so intact backups were reported corrupt and absent

This commit is contained in:
Gmer4Lfe
2026-08-25 21:30:57 -04:00
parent d42b1e2dda
commit d444fd8036
2 changed files with 50 additions and 10 deletions
+4
View File
@@ -1656,6 +1656,10 @@
# HOST1_BACKUP_VERIFY_SHARES / HOST2_BACKUP_VERIFY_SHARES # HOST1_BACKUP_VERIFY_SHARES / HOST2_BACKUP_VERIFY_SHARES
BACKUP_VERIFY_SAMPLE=10 # random files to check per share BACKUP_VERIFY_SAMPLE=10 # random files to check per share
BACKUP_VERIFY_MIN_SIZE=1M # minimum file size to include in sample BACKUP_VERIFY_MIN_SIZE=1M # minimum file size to include in sample
BACKUP_VERIFY_MD5_TIMEOUT_MAX=600 # ceiling for one remote checksum. The per-file budget
# scales with size (~50MB/s); this caps it. A fixed
# connect-sized timeout killed multi-GB checksums and
# the empty result was then reported as MISSING.
# ━━━ SMART Health ━━━ # ━━━ SMART Health ━━━
# Monitors drive SMART attributes — discovers all drives via /dev/sd* and /dev/nvme*. # Monitors drive SMART attributes — discovers all drives via /dev/sd* and /dev/nvme*.
Executable → Regular
+43 -7
View File
@@ -122,6 +122,7 @@ source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@" parse_args "$@"
SSH_TIMEOUT=15 SSH_TIMEOUT=15
BACKUP_VERIFY_MD5_TIMEOUT_MAX="${BACKUP_VERIFY_MD5_TIMEOUT_MAX:-600}"
# ============================================================================================== # ==============================================================================================
# ━━━ Setup ━━━ # ━━━ Setup ━━━
@@ -228,6 +229,7 @@ TOTAL_CHECKED=0
TOTAL_MATCH=0 TOTAL_MATCH=0
TOTAL_MISMATCH=0 TOTAL_MISMATCH=0
TOTAL_MISSING=0 TOTAL_MISSING=0
TOTAL_UNVERIFIED=0
SHARES_WITH_ISSUES=() SHARES_WITH_ISSUES=()
for share in "${VERIFY_SHARES[@]}"; do for share in "${VERIFY_SHARES[@]}"; do
@@ -265,6 +267,7 @@ for share in "${VERIFY_SHARES[@]}"; do
SHARE_MATCH=0 SHARE_MATCH=0
SHARE_MISMATCH=0 SHARE_MISMATCH=0
SHARE_MISSING=0 SHARE_MISSING=0
SHARE_UNVERIFIED=0
for local_file in "${SAMPLE_FILES[@]}"; do for local_file in "${SAMPLE_FILES[@]}"; do
[[ -z "$local_file" ]] && continue [[ -z "$local_file" ]] && continue
@@ -276,19 +279,49 @@ for share in "${VERIFY_SHARES[@]}"; do
continue continue
fi fi
# Remote checksum via SSH — timeout protected # The path is interpolated into a remote shell command, so it must be escaped for
remote_md5=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \ # reuse as one word. A bare '$local_file' inside single quotes breaks on the first
# apostrophe — "Frieren - Beyond Journey's End" ended the quote early, md5sum fell
# back to reading stdin, and the empty-input hash d41d8cd9... was reported as a
# MISMATCH against a file that is byte-identical on the remote.
printf -v remote_q '%q' "$local_file"
# Existence and content are separate questions. Asking them together means a slow
# checksum is indistinguishable from an absent file.
remote_exists=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \ -o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \ -o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \ root@"$REMOTE_SERVER" \
"md5sum '$local_file' 2>/dev/null | awk '{print \$1}'" 2>/dev/null) "test -f $remote_q && echo yes" 2>/dev/null </dev/null)
(( TOTAL_CHECKED++ )) (( TOTAL_CHECKED++ ))
if [[ -z "$remote_md5" ]]; then if [[ "$remote_exists" != "yes" ]]; then
warn "$ICON_ERROR MISSING: $(basename "$local_file")" warn "$ICON_ERROR MISSING: $(basename "$local_file")"
(( SHARE_MISSING++ )) (( SHARE_MISSING++ ))
(( TOTAL_MISSING++ )) (( TOTAL_MISSING++ ))
continue
fi
# md5sum of a multi-GB file cannot finish inside a connect-sized timeout. Budget by
# size — a 5.9GB file needs ~30s and was being killed at 15s, then counted MISSING
# even though it was present and correct.
local_size=$(stat -c%s "$local_file" 2>/dev/null || echo 0)
md5_timeout=$(( local_size / 52428800 + SSH_TIMEOUT ))
(( md5_timeout > BACKUP_VERIFY_MD5_TIMEOUT_MAX )) && md5_timeout=$BACKUP_VERIFY_MD5_TIMEOUT_MAX
remote_md5=$(timeout "$md5_timeout" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" \
-o StrictHostKeyChecking=no \
root@"$REMOTE_SERVER" \
"md5sum $remote_q 2>/dev/null | awk '{print \$1}'" 2>/dev/null </dev/null)
if [[ -z "$remote_md5" ]]; then
# Present but unreadable within budget. Reporting this as a mismatch or a miss
# would be a claim the run did not earn.
warn "$ICON_WARN UNVERIFIED (checksum timed out after ${md5_timeout}s): $(basename "$local_file")"
(( SHARE_UNVERIFIED++ ))
(( TOTAL_UNVERIFIED++ ))
elif [[ "$local_md5" == "$remote_md5" ]]; then elif [[ "$local_md5" == "$remote_md5" ]]; then
log "MATCH: $(basename "$local_file")" log "MATCH: $(basename "$local_file")"
(( SHARE_MATCH++ )) (( SHARE_MATCH++ ))
@@ -303,8 +336,8 @@ for share in "${VERIFY_SHARES[@]}"; do
done done
# Per-share result — only visible if issues found # Per-share result — only visible if issues found
if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 ]]; then if [[ "$SHARE_MISMATCH" -gt 0 || "$SHARE_MISSING" -gt 0 || "$SHARE_UNVERIFIED" -gt 0 ]]; then
warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH" warn "$SHARE_NAME — match: $SHARE_MATCH missing: $SHARE_MISSING mismatch: $SHARE_MISMATCH unverified: $SHARE_UNVERIFIED"
SHARES_WITH_ISSUES+=("$SHARE_NAME") SHARES_WITH_ISSUES+=("$SHARE_NAME")
else else
echo "$SHARE_NAME — all $SHARE_MATCH files match ✅" echo "$SHARE_NAME — all $SHARE_MATCH files match ✅"
@@ -325,10 +358,11 @@ echo "$ICON_VERIFY Checked: $TOTAL_CHECKED files"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "" echo ""
if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 ]]; then if [[ "$TOTAL_MISMATCH" -gt 0 || "$TOTAL_MISSING" -gt 0 || "$TOTAL_UNVERIFIED" -gt 0 ]]; then
echo "$ICON_SUCCESS Match: $TOTAL_MATCH" echo "$ICON_SUCCESS Match: $TOTAL_MATCH"
warn "Missing: $TOTAL_MISSING" warn "Missing: $TOTAL_MISSING"
[[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH" [[ "$TOTAL_MISMATCH" -gt 0 ]] && echo "$ICON_ERROR Mismatch: $TOTAL_MISMATCH"
[[ "$TOTAL_UNVERIFIED" -gt 0 ]] && warn "Unverified: $TOTAL_UNVERIFIED (present, checksum timed out)"
fi fi
if [[ "$DRY_RUN" == true ]]; then if [[ "$DRY_RUN" == true ]]; then
@@ -337,6 +371,8 @@ 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[*]}" 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[*]}" \ notify "Backup verify FAILED on $(hostname)$REMOTE_SERVER_NAME — mismatches: $TOTAL_MISMATCH missing: $TOTAL_MISSING — shares: ${SHARES_WITH_ISSUES[*]}" \
"Backup Verify" "warning" "Backup Verify" "warning"
elif [[ "$TOTAL_UNVERIFIED" -gt 0 ]]; then
warn "Status: $TOTAL_MATCH verified, $TOTAL_UNVERIFIED could not be checksummed in time — NOT a clean run"
else else
echo "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅" echo "$ICON_DONE Status: all $TOTAL_CHECKED files match across ${#VERIFY_SHARES[@]} shares ✅"
fi fi