|
1 | 1 | #!/bin/bash
|
2 |
| -# ===================== v0.32 - 2025.08.13 ======================== |
| 2 | +# ===================== v0.33 - 2025.08.14 ======================== |
3 | 3 | #
|
4 | 4 | # Example backup.conf:
|
5 | 5 | # BACKUP_DIRS="/home/user/test/./ /var/www/./"
|
@@ -359,137 +359,179 @@ run_restore_mode() {
|
359 | 359 | if [[ "$dir_choice" == "$RECYCLE_OPTION" ]]; then
|
360 | 360 | printf "${C_BOLD}${C_CYAN}--- Browse Recycle Bin ---${C_RESET}\n"
|
361 | 361 | local remote_recycle_path="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}"
|
362 |
| - local date_folders |
363 |
| - date_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "ls -1 \"$remote_recycle_path\"" 2>/dev/null) || true |
364 |
| - if [[ -z "$date_folders" ]]; then |
365 |
| - echo "❌ No dated folders found in the recycle bin. Nothing to restore." >&2 |
366 |
| - return 1 |
| 362 | + local date_folders; date_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "ls -1 \"$remote_recycle_path\"" 2>/dev/null) || true |
| 363 | + local valid_folders=() |
| 364 | + for f in $date_folders; do |
| 365 | + if [[ "$f" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$ ]]; then |
| 366 | + valid_folders+=( "$f" ) |
| 367 | + fi |
| 368 | + done |
| 369 | + date_folders=("${valid_folders[@]}") |
| 370 | + if [[ ${#date_folders[@]} -eq 0 ]]; then |
| 371 | + echo "❌ No validly-named backup folders found in the recycle bin." >&2; return 1 |
367 | 372 | fi
|
368 | 373 | printf "${C_YELLOW}Select a backup run (date_time) to browse:${C_RESET}\n"
|
369 |
| - select date_choice in $date_folders "Cancel"; do |
| 374 | + select date_choice in "${date_folders[@]}" "Cancel"; do |
370 | 375 | if [[ "$date_choice" == "Cancel" ]]; then echo "Restore cancelled."; return 0;
|
371 | 376 | elif [[ -n "$date_choice" ]]; then break;
|
372 | 377 | else echo "Invalid selection. Please try again."; fi
|
373 | 378 | done
|
374 | 379 | local remote_date_path="${remote_recycle_path}/${date_choice}"
|
375 | 380 | printf "${C_BOLD}--- Files available from ${date_choice} (showing first 20) ---${C_RESET}\n"
|
376 | 381 | local remote_listing_source="${BOX_ADDR}:${remote_date_path}/"
|
377 |
| - rsync -r -n --out-format='%n' -e "$SSH_CMD" "$remote_listing_source" . 2>/dev/null | head -n 20 || echo "No files found for this date." |
| 382 | + rsync -r -n --out-format='%n' -e "$SSH_CMD" "$remote_listing_source" /dev/null | head -n 20 || echo "No files found for this date." |
378 | 383 | printf "${C_BOLD}--------------------------------------------------------${C_RESET}\n"
|
379 |
| - printf "${C_YELLOW}Enter the full original path of the item to restore (e.g., home/user/file.txt): ${C_RESET}" |
380 |
| - read -r specific_path |
| 384 | + printf "${C_YELLOW}Enter the full original path of the item to restore (e.g., home/user/file.txt): ${C_RESET}"; read -r specific_path |
| 385 | + if [[ "$specific_path" == /* || "$specific_path" =~ (^|/)\.\.(/|$) ]]; then |
| 386 | + echo "❌ Invalid restore path: must be relative and contain no '..'" >&2; return 1 |
| 387 | + fi |
381 | 388 | specific_path=$(echo "$specific_path" | sed 's#^/##')
|
382 | 389 | if [[ -z "$specific_path" ]]; then echo "❌ Path cannot be empty. Aborting."; return 1; fi
|
383 | 390 | full_remote_source="${BOX_ADDR}:${remote_date_path}/${specific_path}"
|
384 |
| - if ! rsync -r -n -e "$SSH_CMD" "$full_remote_source" . >/dev/null 2>&1; then |
385 |
| - echo "❌ ERROR: The path '${specific_path}' was not found in the recycle bin for ${date_choice}. Aborting." >&2 |
386 |
| - return 1 |
| 391 | + if ! rsync -r -n -e "$SSH_CMD" "$full_remote_source" /dev/null >/dev/null 2>&1; then |
| 392 | + echo "❌ ERROR: The path '${specific_path}' was not found in the recycle bin for ${date_choice}. Aborting." >&2; return 1 |
387 | 393 | fi
|
388 |
| - default_local_dest="/${specific_path}" |
389 |
| - item_for_display="(from Recycle Bin) '${specific_path}'" |
| 394 | + default_local_dest="/${specific_path}"; item_for_display="(from Recycle Bin) '${specific_path}'" |
390 | 395 | elif [[ "$dir_choice" == "Cancel" ]]; then
|
391 |
| - echo "Restore cancelled." |
392 |
| - return 0 |
| 396 | + echo "Restore cancelled."; return 0 |
393 | 397 | else
|
394 | 398 | item_for_display="the entire directory '${dir_choice}'"
|
395 | 399 | while true; do
|
396 |
| - printf "\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}" |
397 |
| - read -r choice |
| 400 | + printf "\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}"; read -r choice |
398 | 401 | case "$choice" in
|
399 |
| - entire) |
400 |
| - is_full_directory_restore=true |
401 |
| - break |
402 |
| - ;; |
| 402 | + entire) is_full_directory_restore=true; break ;; |
403 | 403 | specific)
|
404 |
| - local specific_path_prompt |
405 |
| - printf -v specific_path_prompt "Enter the path relative to '%s' to restore: " "$dir_choice" |
406 |
| - printf "${C_YELLOW}%s${C_RESET}" "$specific_path_prompt" |
407 |
| - read -er specific_path |
| 404 | + printf -v specific_path_prompt "Enter the path relative to '%s' to restore: " "$dir_choice"; printf "${C_YELLOW}%s${C_RESET}" "$specific_path_prompt"; read -er specific_path |
| 405 | + if [[ "$specific_path" == /* || "$specific_path" =~ (^|/)\.\.(/|$) ]]; then |
| 406 | + echo "❌ Invalid restore path: must be relative and contain no '..'" >&2; return 1 |
| 407 | + fi |
408 | 408 | specific_path=$(echo "$specific_path" | sed 's#^/##')
|
409 | 409 | if [[ -n "$specific_path" ]]; then
|
410 |
| - restore_path="$specific_path" |
411 |
| - item_for_display="'$restore_path' from '${dir_choice}'" |
412 |
| - break |
| 410 | + restore_path="$specific_path"; item_for_display="'$restore_path' from '${dir_choice}'"; break |
413 | 411 | else
|
414 | 412 | echo "Path cannot be empty. Please try again or choose 'entire'."
|
415 |
| - fi |
416 |
| - ;; |
| 413 | + fi ;; |
417 | 414 | *) echo "Invalid choice. Please answer 'entire' or 'specific'." ;;
|
418 | 415 | esac
|
419 | 416 | done
|
420 | 417 | local relative_path="${dir_choice#*./}"
|
421 |
| - full_remote_source="${REMOTE_TARGET}${relative_path}${restore_path}" |
| 418 | + local remote_base="${REMOTE_TARGET%/}" |
| 419 | + full_remote_source="${remote_base}/${relative_path#/}" |
| 420 | + if [[ -n "$restore_path" ]]; then |
| 421 | + full_remote_source="${full_remote_source%/}/${restore_path#/}" |
| 422 | + fi |
422 | 423 | if [[ -n "$restore_path" ]]; then
|
423 |
| - default_local_dest=$(echo "${dir_choice}${restore_path}" | sed 's#/\./#/#') |
| 424 | + default_local_dest=$(echo "${dir_choice}${restore_path}" | sed 's#/\./#/#g') |
424 | 425 | else
|
425 |
| - default_local_dest=$(echo "$dir_choice" | sed 's#/\./#/#') |
| 426 | + default_local_dest=$(echo "$dir_choice" | sed 's#/\./#/#g') |
426 | 427 | fi
|
427 | 428 | fi
|
428 |
| - local final_dest |
429 |
| - printf "\n${C_YELLOW}Enter the destination path.\n${C_DIM}Press [Enter] to use the original location (%s):${C_RESET} " "$default_local_dest" |
430 |
| - read -r final_dest |
| 429 | + local final_dest |
| 430 | + printf "\n%s\n" "${C_BOLD}--------------------------------------------------------" |
| 431 | + printf "%s\n" " Restore Destination" |
| 432 | + printf "%s\n" "--------------------------------------------------------${C_RESET}" |
| 433 | + printf "%s\n\n" "Enter the absolute destination path for the restore." |
| 434 | + printf "%s\n" "${C_YELLOW}Default (original location):${C_RESET}" |
| 435 | + printf "${C_CYAN}%s${C_RESET}\n\n" "$default_local_dest" |
| 436 | + printf "%s\n" "Press [Enter] to use the default path, or enter a new one." |
| 437 | + read -rp "> " final_dest |
431 | 438 | : "${final_dest:=$default_local_dest}"
|
| 439 | + local path_validation_attempts=0 |
| 440 | + local max_attempts=5 |
| 441 | + while true; do |
| 442 | + ((path_validation_attempts++)) |
| 443 | + if (( path_validation_attempts > max_attempts )); then |
| 444 | + printf "\n${C_RED}❌ Too many invalid attempts. Exiting restore mode.${C_RESET}\n"; return 1 |
| 445 | + fi |
| 446 | + if [[ "$final_dest" != "/" ]]; then final_dest="${final_dest%/}"; fi |
| 447 | + local parent_dir; parent_dir=$(dirname -- "$final_dest") |
| 448 | + if [[ "$final_dest" != /* ]]; then |
| 449 | + printf "\n${C_RED}❌ Error: Please provide an absolute path (starting with '/').${C_RESET}\n" |
| 450 | + elif [[ -e "$final_dest" && ! -d "$final_dest" ]]; then |
| 451 | + printf "\n${C_RED}❌ Error: The destination '%s' exists but is a file. Please choose a different path.${C_RESET}\n" "$final_dest" |
| 452 | + elif [[ -e "$parent_dir" && ! -w "$parent_dir" ]]; then |
| 453 | + printf "\n${C_RED}❌ Error: The parent directory '%s' exists but is not writable.${C_RESET}\n" "$parent_dir" |
| 454 | + elif [[ -d "$final_dest" ]]; then |
| 455 | + printf "${C_GREEN}✅ Destination '%s' exists and is accessible.${C_RESET}\n" "$final_dest" |
| 456 | + if [[ "$final_dest" != "$default_local_dest" && -z "$restore_path" ]]; then |
| 457 | + local warning_msg="⚠️ WARNING: Custom destination directory already exists. Files may be overwritten." |
| 458 | + printf "${C_YELLOW}%s${C_RESET}\n" "$warning_msg"; log_message "$warning_msg" |
| 459 | + fi |
| 460 | + break |
| 461 | + else |
| 462 | + printf "\n${C_YELLOW}⚠️ The destination '%s' does not exist.${C_RESET}\n" "$final_dest" |
| 463 | + printf "${C_YELLOW}Choose an action:${C_RESET}\n" |
| 464 | + PS3="Your choice: " |
| 465 | + select action in "Create the destination path" "Enter a different path" "Cancel"; do |
| 466 | + case "$action" in |
| 467 | + "Create the destination path") |
| 468 | + if mkdir -p "$final_dest"; then |
| 469 | + printf "${C_GREEN}✅ Successfully created directory '%s'.${C_RESET}\n" "$final_dest" |
| 470 | + if [[ "${is_full_directory_restore:-false}" == "true" ]]; then |
| 471 | + chmod 700 "$final_dest"; log_message "Set permissions to 700 on newly created restore directory: $final_dest" |
| 472 | + else |
| 473 | + chmod 755 "$final_dest" |
| 474 | + fi |
| 475 | + break 2 |
| 476 | + else |
| 477 | + printf "\n${C_RED}❌ Failed to create directory '%s'. Check permissions.${C_RESET}\n" "$final_dest"; break |
| 478 | + fi ;; |
| 479 | + "Enter a different path") break ;; |
| 480 | + "Cancel") echo "Restore cancelled by user."; return 0 ;; |
| 481 | + *) echo "Invalid option. Please try again." ;; |
| 482 | + esac |
| 483 | + done |
| 484 | + PS3="#? " |
| 485 | + fi |
| 486 | + if (( path_validation_attempts < max_attempts )); then |
| 487 | + printf "\n${C_YELLOW}Please enter a new destination path: ${C_RESET}"; read -r final_dest |
| 488 | + if [[ -z "$final_dest" ]]; then |
| 489 | + final_dest="$default_local_dest"; printf "${C_DIM}Empty input, using default location: %s${C_RESET}\n" "$final_dest" |
| 490 | + fi |
| 491 | + fi |
| 492 | + done |
432 | 493 | local extra_rsync_opts=()
|
433 | 494 | local dest_user=""
|
434 | 495 | if [[ "$final_dest" == /home/* ]]; then
|
435 | 496 | dest_user=$(echo "$final_dest" | cut -d/ -f3)
|
436 | 497 | if [[ -n "$dest_user" ]] && id -u "$dest_user" &>/dev/null; then
|
437 | 498 | printf "${C_CYAN}ℹ️ Home directory detected. Restored files will be owned by '${dest_user}'.${C_RESET}\n"
|
438 | 499 | extra_rsync_opts+=("--chown=${dest_user}:${dest_user}")
|
| 500 | + chown "${dest_user}:${dest_user}" "$final_dest" 2>/dev/null || true |
439 | 501 | else
|
440 | 502 | dest_user=""
|
441 | 503 | fi
|
442 | 504 | fi
|
443 |
| - local dest_created=false |
444 |
| - if [[ ! -e "$final_dest" ]]; then |
445 |
| - dest_created=true |
446 |
| - fi |
447 |
| - local dest_parent |
448 |
| - dest_parent=$(dirname "$final_dest") |
449 |
| - if ! mkdir -p "$dest_parent"; then |
450 |
| - echo "❌ FATAL: Could not create parent destination directory '$dest_parent'. Aborting." >&2 |
451 |
| - return 1 |
452 |
| - fi |
453 |
| - if [[ -n "$dest_user" ]]; then |
454 |
| - chown "${dest_user}:${dest_user}" "$dest_parent" |
455 |
| - fi |
456 |
| - if [[ "$final_dest" != "$default_local_dest" && -d "$final_dest" && -z "$restore_path" ]]; then |
457 |
| - local warning_msg="⚠️ WARNING: The custom destination directory '$final_dest' already exists. Files may be overwritten." |
458 |
| - echo "$warning_msg"; log_message "$warning_msg" |
459 |
| - fi |
460 |
| - if [[ "$dest_created" == "true" && "${is_full_directory_restore:-false}" == "true" ]]; then |
461 |
| - chmod 700 "$final_dest"; log_message "Set permissions to 700 on newly created restore directory: $final_dest" |
462 |
| - fi |
463 |
| - printf "Restore destination is set to: ${C_BOLD}%s${C_RESET}\n" "$final_dest" |
464 |
| - printf "\n${C_BOLD}${C_YELLOW}--- PERFORMING DRY RUN. NO FILES WILL BE CHANGED. ---${C_RESET}\n" |
| 505 | + printf "\n${C_BOLD}Restore Summary:${C_RESET}\n" |
| 506 | + printf " Source: %s\n" "$item_for_display" |
| 507 | + printf " Destination: ${C_BOLD}%s${C_RESET}\n" "$final_dest" |
| 508 | + printf "\n${C_BOLD}${C_YELLOW}--- PERFORMING DRY RUN (NO CHANGES MADE) ---${C_RESET}\n" |
465 | 509 | log_message "Starting restore dry-run of ${item_for_display} from ${full_remote_source} to ${final_dest}"
|
466 |
| - local rsync_restore_opts=(-avhi --progress --exclude-from="$EXCLUDE_FILE_TMP" -e "$SSH_CMD") |
| 510 | + local rsync_restore_opts=(-avhi --safe-links --progress --exclude-from="$EXCLUDE_FILE_TMP" -e "$SSH_CMD") |
467 | 511 | if ! rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" --dry-run "$full_remote_source" "$final_dest"; then
|
468 |
| - echo "❌ DRY RUN FAILED. Rsync reported an error. Aborting." >&2; return 1 |
| 512 | + printf "${C_RED}❌ DRY RUN FAILED. Rsync reported an error. Check connectivity and permissions.${C_RESET}\n" >&2 |
| 513 | + log_message "Restore dry-run failed for ${item_for_display}"; return 1 |
469 | 514 | fi
|
470 | 515 | printf "${C_BOLD}${C_GREEN}--- DRY RUN COMPLETE ---${C_RESET}\n"
|
471 |
| - local confirmation |
472 | 516 | while true; do
|
473 |
| - printf "\n${C_YELLOW}Are you sure you want to proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}" "$item_for_display" "$final_dest" |
474 |
| - read -r confirmation |
475 |
| - |
476 |
| - case "$confirmation" in |
477 |
| - yes) break ;; |
478 |
| - no) echo "Restore aborted by user." ; return 0 ;; |
479 |
| - *) echo "Please answer yes or no." ;; |
| 517 | + printf "\n${C_YELLOW}Proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}" "$item_for_display" "$final_dest"; read -r confirmation |
| 518 | + case "${confirmation,,}" in |
| 519 | + yes|y) break ;; |
| 520 | + no|n) echo "Restore cancelled by user."; return 0 ;; |
| 521 | + *) echo "Please answer 'yes' or 'no'." ;; |
480 | 522 | esac
|
481 | 523 | done
|
482 |
| - printf "\n${C_BOLD}--- PROCEEDING WITH RESTORE... ---${C_RESET}\n" |
483 |
| - log_message "Starting REAL restore of ${item_for_display} from ${full_remote_source} to ${final_dest}" |
| 524 | + printf "\n${C_BOLD}--- EXECUTING RESTORE ---${C_RESET}\n" |
| 525 | + log_message "Starting actual restore of ${item_for_display} from ${full_remote_source} to ${final_dest}" |
484 | 526 | if rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" "$full_remote_source" "$final_dest"; then
|
485 | 527 | log_message "Restore completed successfully."
|
486 | 528 | printf "${C_GREEN}✅ Restore of %s to '%s' completed successfully.${C_RESET}\n" "$item_for_display" "$final_dest"
|
487 | 529 | send_notification "Restore SUCCESS: ${HOSTNAME}" "white_check_mark" "${NTFY_PRIORITY_SUCCESS}" "success" "Successfully restored ${item_for_display} to ${final_dest}"
|
488 | 530 | else
|
489 |
| - log_message "Restore FAILED with rsync exit code $?." |
| 531 | + local rsync_exit_code=$? |
| 532 | + log_message "Restore FAILED with rsync exit code ${rsync_exit_code}." |
490 | 533 | printf "${C_RED}❌ Restore FAILED. Check the rsync output and log for details.${C_RESET}\n"
|
491 |
| - send_notification "Restore FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Restore of ${item_for_display} to ${final_dest} failed." |
492 |
| - return 1 |
| 534 | + send_notification "Restore FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Restore of ${item_for_display} to ${final_dest} failed (exit code: ${rsync_exit_code})"; return 1 |
493 | 535 | fi
|
494 | 536 | }
|
495 | 537 | run_recycle_bin_cleanup() {
|
|
0 commit comments