@@ -184,7 +184,7 @@ To run the backup automatically, edit the root crontab.
184184
185185```ini
186186# =================================================================
187- # Configuration for rsync Backup Script v0.32
187+ # Configuration for rsync Backup Script v0.33
188188# =================================================================
189189# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)
190190
@@ -310,7 +310,7 @@ END_EXCLUDES
310310
311311` ` ` bash
312312#! /bin/bash
313- # ===================== v0.32 - 2025.08.13 ========================
313+ # ===================== v0.33 - 2025.08.15 ========================
314314#
315315# =================================================================
316316# SCRIPT INITIALIZATION & SETUP
@@ -616,8 +616,11 @@ run_preflight_checks() {
616616 if [[ " $test_mode " == " true" ]]; then printf " ${C_GREEN} ✅ Local disk space OK.${C_RESET} \n" ; fi
617617 fi
618618}
619+ print_header () {
620+ printf " \n%b--- %s ---%b\n" " ${C_BOLD} " " $1 " " ${C_RESET} "
621+ }
619622run_restore_mode () {
620- printf " ${C_BOLD}${C_CYAN} --- RESTORE MODE ACTIVATED --- ${C_RESET} \n "
623+ print_header " RESTORE MODE ACTIVATED"
621624 run_preflight_checks " restore"
622625 local DIRS_ARRAY; read -ra DIRS_ARRAY <<< " $BACKUP_DIRS"
623626 local RECYCLE_OPTION=" [ Restore from Recycle Bin ]"
@@ -627,149 +630,193 @@ run_restore_mode() {
627630 fi
628631 all_options+=(" Cancel" )
629632 printf " ${C_YELLOW} Available backup sets to restore from:${C_RESET} \n"
633+ PS3=" Your choice: "
630634 select dir_choice in " ${all_options[@]} " ; do
631635 if [[ -n " $dir_choice " ]]; then break ;
632636 else echo " Invalid selection. Please try again." ; fi
633637 done
638+ PS3=" #? "
634639 local full_remote_source=" "
635640 local default_local_dest=" "
636641 local item_for_display=" "
637642 local restore_path=" "
638643 local is_full_directory_restore=false
639644 if [[ " $dir_choice " == " $RECYCLE_OPTION " ]]; then
640- printf " ${C_BOLD}${C_CYAN} --- Browse Recycle Bin ---${C_RESET} \n"
645+ print_header " Browse Recycle Bin"
646+ local date_folders=()
641647 local remote_recycle_path=" ${BOX_DIR%/ } /${RECYCLE_BIN_DIR%/ } "
642- local date_folders
643- date_folders=$( ssh " ${SSH_OPTS_ARRAY[@]} " " ${SSH_DIRECT_OPTS[@]} " " $BOX_ADDR " " ls -1 \" $remote_recycle_path \" " 2> /dev/null) || true
644- if [[ -z " $date_folders " ]]; then
645- echo " ❌ No dated folders found in the recycle bin. Nothing to restore." >&2
646- return 1
647- fi
648+ mapfile -t date_folders < <( ssh " ${SSH_OPTS_ARRAY[@]} " " ${SSH_DIRECT_OPTS[@]} " " $BOX_ADDR " " ls -1 \" $remote_recycle_path \" " 2> /dev/null | grep -E ' ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$' )
649+ if [[ ${# date_folders[@]} -eq 0 ]]; then
650+ printf " ${C_YELLOW} ❌ The remote recycle bin is empty or contains no valid backup folders.${C_RESET} \n"
651+ return 1
652+ fi
648653 printf " ${C_YELLOW} Select a backup run (date_time) to browse:${C_RESET} \n"
649- select date_choice in $date_folders " Cancel" ; do
654+ PS3=" Your choice: "
655+ select date_choice in " ${date_folders[@]} " " Cancel" ; do
650656 if [[ " $date_choice " == " Cancel" ]]; then echo " Restore cancelled." ; return 0;
651657 elif [[ -n " $date_choice " ]]; then break ;
652658 else echo " Invalid selection. Please try again." ; fi
653659 done
660+ PS3=" #? "
654661 local remote_date_path=" ${remote_recycle_path} /${date_choice} "
655- printf " ${C_BOLD} --- Files available from ${date_choice} (showing first 20) --- ${C_RESET} \n "
662+ print_header " Files available from ${date_choice} (showing first 20)"
656663 local remote_listing_source=" ${BOX_ADDR} :${remote_date_path} /"
657664 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."
658- printf " ${C_BOLD} --------------------------------------------------------${C_RESET} \n"
659- printf " ${C_YELLOW} Enter the full original path of the item to restore (e.g., home/user/file.txt): ${C_RESET} "
660- read -r specific_path
665+ printf " %b--------------------------------------------------------%b\n" " ${C_BOLD} " " ${C_RESET} "
666+ 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
667+ if [[ " $specific_path " == /* || " $specific_path " =~ (^| /)\.\. (/| $) ]]; then
668+ echo " ❌ Invalid restore path: must be relative and contain no '..'" >&2 ; return 1
669+ fi
661670 specific_path=$( echo " $specific_path " | sed ' s#^/##' )
662671 if [[ -z " $specific_path " ]]; then echo " ❌ Path cannot be empty. Aborting." ; return 1; fi
663672 full_remote_source=" ${BOX_ADDR} :${remote_date_path} /${specific_path} "
664673 if ! rsync -r -n -e " $SSH_CMD " " $full_remote_source " . > /dev/null 2>&1 ; then
665- echo " ❌ ERROR: The path '${specific_path} ' was not found in the recycle bin for ${date_choice} . Aborting." >&2
666- return 1
674+ echo " ❌ ERROR: The path '${specific_path} ' was not found in the recycle bin for ${date_choice} . Aborting." >&2 ; return 1
667675 fi
668- default_local_dest=" /${specific_path} "
669- item_for_display=" (from Recycle Bin) '${specific_path} '"
676+ default_local_dest=" /${specific_path} " ; item_for_display=" (from Recycle Bin) '${specific_path} '"
670677 elif [[ " $dir_choice " == " Cancel" ]]; then
671- echo " Restore cancelled."
672- return 0
678+ echo " Restore cancelled." ; return 0
673679 else
674680 item_for_display=" the entire directory '${dir_choice} '"
675681 while true ; do
676- printf " \n${C_YELLOW} Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET} "
677- read -r choice
682+ printf " \n${C_YELLOW} Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET} " ; read -r choice
678683 case " $choice " in
679- entire)
680- is_full_directory_restore=true
681- break
682- ;;
684+ entire) is_full_directory_restore=true; break ;;
683685 specific)
684- local specific_path_prompt
685- printf -v specific_path_prompt " Enter the path relative to '%s' to restore: " " $dir_choice "
686- printf " ${C_YELLOW} %s${C_RESET} " " $specific_path_prompt "
687- read -er specific_path
686+ local relative_path_browse=" ${dir_choice#* ./ } "
687+ local remote_browse_source=" ${REMOTE_TARGET}${relative_path_browse} "
688+ print_header " Files available in ${dir_choice} (showing first 20)"
689+ rsync -r -n --out-format=' %n' -e " $SSH_CMD " " $remote_browse_source " . 2> /dev/null | head -n 20 || echo " No files found for this backup set."
690+ printf " %b--------------------------------------------------------%b\n" " ${C_BOLD} " " ${C_RESET} "
691+ printf -v specific_path_prompt " Enter the path relative to '%s' to restore (e.g., subfolder/file.txt): " " $dir_choice " ; printf " ${C_YELLOW} %s${C_RESET} " " $specific_path_prompt " ; read -er specific_path
692+ if [[ " $specific_path " == /* || " $specific_path " =~ (^| /)\.\. (/| $) ]]; then
693+ echo " ❌ Invalid restore path: must be relative and contain no '..'" >&2 ; return 1
694+ fi
688695 specific_path=$( echo " $specific_path " | sed ' s#^/##' )
689696 if [[ -n " $specific_path " ]]; then
690- restore_path=" $specific_path "
691- item_for_display=" '$restore_path ' from '${dir_choice} '"
692- break
697+ restore_path=" $specific_path " ; item_for_display=" '$restore_path ' from '${dir_choice} '" ; break
693698 else
694699 echo " Path cannot be empty. Please try again or choose 'entire'."
695- fi
696- ;;
700+ fi ;;
697701 * ) echo " Invalid choice. Please answer 'entire' or 'specific'." ;;
698702 esac
699703 done
700704 local relative_path=" ${dir_choice#* ./ } "
701- full_remote_source=" ${REMOTE_TARGET}${relative_path}${restore_path} "
705+ local remote_base=" ${REMOTE_TARGET%/ } "
706+ full_remote_source=" ${remote_base} /${relative_path#/ } "
707+ if [[ -n " $restore_path " ]]; then
708+ full_remote_source=" ${full_remote_source%/ } /${restore_path#/ } "
709+ fi
702710 if [[ -n " $restore_path " ]]; then
703- default_local_dest=$( echo " ${dir_choice}${restore_path} " | sed ' s#/\./#/#' )
711+ default_local_dest=$( echo " ${dir_choice}${restore_path} " | sed ' s#/\./#/#g ' )
704712 else
705- default_local_dest=$( echo " $dir_choice " | sed ' s#/\./#/#' )
713+ default_local_dest=$( echo " $dir_choice " | sed ' s#/\./#/#g ' )
706714 fi
707715 fi
708- local final_dest
709- printf " \n${C_YELLOW} Enter the destination path.\n${C_DIM} Press [Enter] to use the original location (%s):${C_RESET} " " $default_local_dest "
710- read -r final_dest
716+ local final_dest
717+ print_header " Restore Destination"
718+ printf " Enter the absolute destination path for the restore.\n\n"
719+ printf " %bDefault (original location):%b\n" " ${C_YELLOW} " " ${C_RESET} "
720+ printf " %b%s%b\n\n" " ${C_CYAN} " " $default_local_dest " " ${C_RESET} "
721+ printf " Press [Enter] to use the default path, or enter a new one.\n"
722+ read -rp " > " final_dest
711723 : " ${final_dest:= $default_local_dest } "
724+ local path_validation_attempts=0
725+ local max_attempts=5
726+ while true ; do
727+ (( path_validation_attempts++ ))
728+ if (( path_validation_attempts > max_attempts )) ; then
729+ printf " \n${C_RED} ❌ Too many invalid attempts. Exiting restore mode.${C_RESET} \n" ; return 1
730+ fi
731+ if [[ " $final_dest " != " /" ]]; then final_dest=" ${final_dest%/ } " ; fi
732+ local parent_dir; parent_dir=$( dirname -- " $final_dest " )
733+ if [[ " $final_dest " != /* ]]; then
734+ printf " \n${C_RED} ❌ Error: Please provide an absolute path (starting with '/').${C_RESET} \n"
735+ elif [[ -e " $final_dest " && ! -d " $final_dest " ]]; then
736+ printf " \n${C_RED} ❌ Error: The destination '%s' exists but is a file. Please choose a different path.${C_RESET} \n" " $final_dest "
737+ elif [[ -e " $parent_dir " && ! -w " $parent_dir " ]]; then
738+ printf " \n${C_RED} ❌ Error: The parent directory '%s' exists but is not writable.${C_RESET} \n" " $parent_dir "
739+ elif [[ -d " $final_dest " ]]; then
740+ printf " ${C_GREEN} ✅ Destination '%s' exists and is accessible.${C_RESET} \n" " $final_dest "
741+ if [[ " $final_dest " != " $default_local_dest " && -z " $restore_path " ]]; then
742+ local warning_msg=" ⚠️ WARNING: Custom destination directory already exists. Files may be overwritten."
743+ printf " ${C_YELLOW} %s${C_RESET} \n" " $warning_msg " ; log_message " $warning_msg "
744+ fi
745+ break
746+ else
747+ printf " \n${C_YELLOW} ⚠️ The destination '%s' does not exist.${C_RESET} \n" " $final_dest "
748+ printf " ${C_YELLOW} Choose an action:${C_RESET} \n"
749+ PS3=" Your choice: "
750+ select action in " Create the destination path" " Enter a different path" " Cancel" ; do
751+ case " $action " in
752+ " Create the destination path" )
753+ if mkdir -p " $final_dest " ; then
754+ printf " ${C_GREEN} ✅ Successfully created directory '%s'.${C_RESET} \n" " $final_dest "
755+ if [[ " ${is_full_directory_restore:- false} " == " true" ]]; then
756+ chmod 700 " $final_dest " ; log_message " Set permissions to 700 on newly created restore directory: $final_dest "
757+ else
758+ chmod 755 " $final_dest "
759+ fi
760+ break 2
761+ else
762+ printf " \n${C_RED} ❌ Failed to create directory '%s'. Check permissions.${C_RESET} \n" " $final_dest " ; break
763+ fi ;;
764+ " Enter a different path" ) break ;;
765+ " Cancel" ) echo " Restore cancelled by user." ; return 0 ;;
766+ * ) echo " Invalid option. Please try again." ;;
767+ esac
768+ done
769+ PS3=" #? "
770+ fi
771+ if (( path_validation_attempts < max_attempts )) ; then
772+ printf " \n${C_YELLOW} Please enter a new destination path: ${C_RESET} " ; read -r final_dest
773+ if [[ -z " $final_dest " ]]; then
774+ final_dest=" $default_local_dest " ; printf " ${C_DIM} Empty input, using default location: %s${C_RESET} \n" " $final_dest "
775+ fi
776+ fi
777+ done
712778 local extra_rsync_opts=()
713779 local dest_user=" "
714780 if [[ " $final_dest " == /home/* ]]; then
715781 dest_user=$( echo " $final_dest " | cut -d/ -f3)
716782 if [[ -n " $dest_user " ]] && id -u " $dest_user " & > /dev/null; then
717783 printf " ${C_CYAN} ℹ️ Home directory detected. Restored files will be owned by '${dest_user} '.${C_RESET} \n"
718784 extra_rsync_opts+=(" --chown=${dest_user} :${dest_user} " )
785+ chown " ${dest_user} :${dest_user} " " $final_dest " 2> /dev/null || true
719786 else
720787 dest_user=" "
721788 fi
722789 fi
723- local dest_created=false
724- if [[ ! -e " $final_dest " ]]; then
725- dest_created=true
726- fi
727- local dest_parent
728- dest_parent=$( dirname " $final_dest " )
729- if ! mkdir -p " $dest_parent " ; then
730- echo " ❌ FATAL: Could not create parent destination directory '$dest_parent '. Aborting." >&2
731- return 1
732- fi
733- if [[ -n " $dest_user " ]]; then
734- chown " ${dest_user} :${dest_user} " " $dest_parent "
735- fi
736- if [[ " $final_dest " != " $default_local_dest " && -d " $final_dest " && -z " $restore_path " ]]; then
737- local warning_msg=" ⚠️ WARNING: The custom destination directory '$final_dest ' already exists. Files may be overwritten."
738- echo " $warning_msg " ; log_message " $warning_msg "
739- fi
740- if [[ " $dest_created " == " true" && " ${is_full_directory_restore:- false} " == " true" ]]; then
741- chmod 700 " $final_dest " ; log_message " Set permissions to 700 on newly created restore directory: $final_dest "
742- fi
743- printf " Restore destination is set to: ${C_BOLD} %s${C_RESET} \n" " $final_dest "
744- printf " \n${C_BOLD}${C_YELLOW} --- PERFORMING DRY RUN. NO FILES WILL BE CHANGED. ---${C_RESET} \n"
790+ print_header " Restore Summary"
791+ printf " Source: %s\n" " $item_for_display "
792+ printf " Destination: %b%s%b\n" " ${C_BOLD} " " $final_dest " " ${C_RESET} "
793+ print_header " PERFORMING DRY RUN (NO CHANGES MADE)"
745794 log_message " Starting restore dry-run of ${item_for_display} from ${full_remote_source} to ${final_dest} "
746- local rsync_restore_opts=(-avhi --progress --exclude-from=" $EXCLUDE_FILE_TMP " -e " $SSH_CMD " )
795+ local rsync_restore_opts=(-avhi --safe-links -- progress --exclude-from=" $EXCLUDE_FILE_TMP " -e " $SSH_CMD " )
747796 if ! rsync " ${rsync_restore_opts[@]} " " ${extra_rsync_opts[@]} " --dry-run " $full_remote_source " " $final_dest " ; then
748- echo " ❌ DRY RUN FAILED. Rsync reported an error. Aborting." >&2 ; return 1
797+ printf " ${C_RED} ❌ DRY RUN FAILED. Rsync reported an error. Check connectivity and permissions.${C_RESET} \n" >&2
798+ log_message " Restore dry-run failed for ${item_for_display} " ; return 1
749799 fi
750- printf " ${C_BOLD}${C_GREEN} --- DRY RUN COMPLETE ---${C_RESET} \n"
751- local confirmation
800+ print_header " DRY RUN COMPLETE"
752801 while true ; do
753- 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 "
754- read -r confirmation
755-
756- case " $confirmation " in
757- yes) break ;;
758- no) echo " Restore aborted by user." ; return 0 ;;
759- * ) echo " Please answer yes or no." ;;
802+ printf " \n${C_YELLOW} Proceed with restoring %s to '%s'? [yes/no]: ${C_RESET} " " $item_for_display " " $final_dest " ; read -r confirmation
803+ case " ${confirmation,,} " in
804+ yes|y) break ;;
805+ no|n) echo " Restore cancelled by user." ; return 0 ;;
806+ * ) echo " Please answer 'yes' or 'no'." ;;
760807 esac
761808 done
762- printf " \n ${C_BOLD} --- PROCEEDING WITH RESTORE... --- ${C_RESET} \n "
763- log_message " Starting REAL restore of ${item_for_display} from ${full_remote_source} to ${final_dest} "
809+ print_header " EXECUTING RESTORE"
810+ log_message " Starting actual restore of ${item_for_display} from ${full_remote_source} to ${final_dest} "
764811 if rsync " ${rsync_restore_opts[@]} " " ${extra_rsync_opts[@]} " " $full_remote_source " " $final_dest " ; then
765812 log_message " Restore completed successfully."
766813 printf " ${C_GREEN} ✅ Restore of %s to '%s' completed successfully.${C_RESET} \n" " $item_for_display " " $final_dest "
767814 send_notification " Restore SUCCESS: ${HOSTNAME} " " white_check_mark" " ${NTFY_PRIORITY_SUCCESS} " " success" " Successfully restored ${item_for_display} to ${final_dest} "
768815 else
769- log_message " Restore FAILED with rsync exit code $? ."
816+ local rsync_exit_code=$?
817+ log_message " Restore FAILED with rsync exit code ${rsync_exit_code} ."
770818 printf " ${C_RED} ❌ Restore FAILED. Check the rsync output and log for details.${C_RESET} \n"
771- send_notification " Restore FAILED: ${HOSTNAME} " " x" " ${NTFY_PRIORITY_FAILURE} " " failure" " Restore of ${item_for_display} to ${final_dest} failed."
772- return 1
819+ 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
773820 fi
774821}
775822run_recycle_bin_cleanup () {
@@ -778,10 +825,10 @@ run_recycle_bin_cleanup() {
778825 local remote_cleanup_path=" ${BOX_DIR%/ } /${RECYCLE_BIN_DIR%/ } "
779826 local list_command=" ls -1 \" $remote_cleanup_path \" "
780827 local all_folders
781- all_folders=$( ssh " ${SSH_OPTS_ARRAY[@]} " " ${SSH_DIRECT_OPTS[@]} " " $BOX_ADDR " " $list_command " 2>> " ${LOG_FILE:-/ dev/ null} " ) || {
828+ if ! all_folders=$( ssh " ${SSH_OPTS_ARRAY[@]} " " ${SSH_DIRECT_OPTS[@]} " " $BOX_ADDR " " $list_command " 2>> " ${LOG_FILE:-/ dev/ null} " ) ; then
782829 log_message " Recycle bin not found or unable to list contents. Nothing to clean."
783830 return 0
784- }
831+ fi
785832 if [[ -z " $all_folders " ]]; then
786833 log_message " No daily folders in recycle bin to check."
787834 return 0
@@ -929,7 +976,8 @@ for dir in "${DIRS_ARRAY[@]}"; do
929976 RSYNC_EXIT_CODE=${PIPESTATUS[0]}
930977 else
931978 RSYNC_OPTS+=(--info=stats2)
932- nice -n 19 ionice -c 3 rsync " ${RSYNC_OPTS[@]} " " $dir " " $REMOTE_TARGET " > " $RSYNC_LOG_TMP " 2>&1 || RSYNC_EXIT_CODE=$?
979+ nice -n 19 ionice -c 3 rsync " ${RSYNC_OPTS[@]} " " $dir " " $REMOTE_TARGET " > " $RSYNC_LOG_TMP " 2>&1
980+ RSYNC_EXIT_CODE=$?
933981 fi
934982 cat " $RSYNC_LOG_TMP " >> " $LOG_FILE " ; full_rsync_output+=$' \n ' " $( < " $RSYNC_LOG_TMP " ) "
935983 rm -f " $RSYNC_LOG_TMP "
0 commit comments