@@ -184,7 +184,7 @@ To run the backup automatically, edit the root crontab.
184
184
185
185
```ini
186
186
# =================================================================
187
- # Configuration for rsync Backup Script v0.32
187
+ # Configuration for rsync Backup Script v0.33
188
188
# =================================================================
189
189
# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)
190
190
@@ -310,7 +310,7 @@ END_EXCLUDES
310
310
311
311
` ` ` bash
312
312
#! /bin/bash
313
- # ===================== v0.32 - 2025.08.13 ========================
313
+ # ===================== v0.33 - 2025.08.15 ========================
314
314
#
315
315
# =================================================================
316
316
# SCRIPT INITIALIZATION & SETUP
@@ -616,8 +616,11 @@ run_preflight_checks() {
616
616
if [[ " $test_mode " == " true" ]]; then printf " ${C_GREEN} ✅ Local disk space OK.${C_RESET} \n" ; fi
617
617
fi
618
618
}
619
+ print_header () {
620
+ printf " \n%b--- %s ---%b\n" " ${C_BOLD} " " $1 " " ${C_RESET} "
621
+ }
619
622
run_restore_mode () {
620
- printf " ${C_BOLD}${C_CYAN} --- RESTORE MODE ACTIVATED --- ${C_RESET} \n "
623
+ print_header " RESTORE MODE ACTIVATED"
621
624
run_preflight_checks " restore"
622
625
local DIRS_ARRAY; read -ra DIRS_ARRAY <<< " $BACKUP_DIRS"
623
626
local RECYCLE_OPTION=" [ Restore from Recycle Bin ]"
@@ -627,149 +630,193 @@ run_restore_mode() {
627
630
fi
628
631
all_options+=(" Cancel" )
629
632
printf " ${C_YELLOW} Available backup sets to restore from:${C_RESET} \n"
633
+ PS3=" Your choice: "
630
634
select dir_choice in " ${all_options[@]} " ; do
631
635
if [[ -n " $dir_choice " ]]; then break ;
632
636
else echo " Invalid selection. Please try again." ; fi
633
637
done
638
+ PS3=" #? "
634
639
local full_remote_source=" "
635
640
local default_local_dest=" "
636
641
local item_for_display=" "
637
642
local restore_path=" "
638
643
local is_full_directory_restore=false
639
644
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=()
641
647
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
648
653
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
650
656
if [[ " $date_choice " == " Cancel" ]]; then echo " Restore cancelled." ; return 0;
651
657
elif [[ -n " $date_choice " ]]; then break ;
652
658
else echo " Invalid selection. Please try again." ; fi
653
659
done
660
+ PS3=" #? "
654
661
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)"
656
663
local remote_listing_source=" ${BOX_ADDR} :${remote_date_path} /"
657
664
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
661
670
specific_path=$( echo " $specific_path " | sed ' s#^/##' )
662
671
if [[ -z " $specific_path " ]]; then echo " ❌ Path cannot be empty. Aborting." ; return 1; fi
663
672
full_remote_source=" ${BOX_ADDR} :${remote_date_path} /${specific_path} "
664
673
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
667
675
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} '"
670
677
elif [[ " $dir_choice " == " Cancel" ]]; then
671
- echo " Restore cancelled."
672
- return 0
678
+ echo " Restore cancelled." ; return 0
673
679
else
674
680
item_for_display=" the entire directory '${dir_choice} '"
675
681
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
678
683
case " $choice " in
679
- entire)
680
- is_full_directory_restore=true
681
- break
682
- ;;
684
+ entire) is_full_directory_restore=true; break ;;
683
685
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
688
695
specific_path=$( echo " $specific_path " | sed ' s#^/##' )
689
696
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
693
698
else
694
699
echo " Path cannot be empty. Please try again or choose 'entire'."
695
- fi
696
- ;;
700
+ fi ;;
697
701
* ) echo " Invalid choice. Please answer 'entire' or 'specific'." ;;
698
702
esac
699
703
done
700
704
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
702
710
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 ' )
704
712
else
705
- default_local_dest=$( echo " $dir_choice " | sed ' s#/\./#/#' )
713
+ default_local_dest=$( echo " $dir_choice " | sed ' s#/\./#/#g ' )
706
714
fi
707
715
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
711
723
: " ${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
712
778
local extra_rsync_opts=()
713
779
local dest_user=" "
714
780
if [[ " $final_dest " == /home/* ]]; then
715
781
dest_user=$( echo " $final_dest " | cut -d/ -f3)
716
782
if [[ -n " $dest_user " ]] && id -u " $dest_user " & > /dev/null; then
717
783
printf " ${C_CYAN} ℹ️ Home directory detected. Restored files will be owned by '${dest_user} '.${C_RESET} \n"
718
784
extra_rsync_opts+=(" --chown=${dest_user} :${dest_user} " )
785
+ chown " ${dest_user} :${dest_user} " " $final_dest " 2> /dev/null || true
719
786
else
720
787
dest_user=" "
721
788
fi
722
789
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)"
745
794
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 " )
747
796
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
749
799
fi
750
- printf " ${C_BOLD}${C_GREEN} --- DRY RUN COMPLETE ---${C_RESET} \n"
751
- local confirmation
800
+ print_header " DRY RUN COMPLETE"
752
801
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'." ;;
760
807
esac
761
808
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} "
764
811
if rsync " ${rsync_restore_opts[@]} " " ${extra_rsync_opts[@]} " " $full_remote_source " " $final_dest " ; then
765
812
log_message " Restore completed successfully."
766
813
printf " ${C_GREEN} ✅ Restore of %s to '%s' completed successfully.${C_RESET} \n" " $item_for_display " " $final_dest "
767
814
send_notification " Restore SUCCESS: ${HOSTNAME} " " white_check_mark" " ${NTFY_PRIORITY_SUCCESS} " " success" " Successfully restored ${item_for_display} to ${final_dest} "
768
815
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} ."
770
818
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
773
820
fi
774
821
}
775
822
run_recycle_bin_cleanup () {
@@ -778,10 +825,10 @@ run_recycle_bin_cleanup() {
778
825
local remote_cleanup_path=" ${BOX_DIR%/ } /${RECYCLE_BIN_DIR%/ } "
779
826
local list_command=" ls -1 \" $remote_cleanup_path \" "
780
827
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
782
829
log_message " Recycle bin not found or unable to list contents. Nothing to clean."
783
830
return 0
784
- }
831
+ fi
785
832
if [[ -z " $all_folders " ]]; then
786
833
log_message " No daily folders in recycle bin to check."
787
834
return 0
@@ -929,7 +976,8 @@ for dir in "${DIRS_ARRAY[@]}"; do
929
976
RSYNC_EXIT_CODE=${PIPESTATUS[0]}
930
977
else
931
978
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=$?
933
981
fi
934
982
cat " $RSYNC_LOG_TMP " >> " $LOG_FILE " ; full_rsync_output+=$' \n ' " $( < " $RSYNC_LOG_TMP " ) "
935
983
rm -f " $RSYNC_LOG_TMP "
0 commit comments