-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackup_script.sh
More file actions
770 lines (733 loc) · 38.5 KB
/
Copy pathbackup_script.sh
File metadata and controls
770 lines (733 loc) · 38.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
#!/bin/bash
# ===================== v0.36 - 2025.08.29 ========================
#
# Example backup.conf:
# BACKUP_DIRS="/home/user/test/./ /var/www/./"
# BOX_DIR="/backup/"
# BOX_ADDR="user@storagebox.example.com"
# LOG_FILE="/var/log/backup.log"
# LOG_RETENTION_DAYS=7
# MAX_LOG_SIZE_MB=10
# BANDWIDTH_LIMIT_KBPS=1000
# RSYNC_NOATIME_ENABLED=false
# Set RSYNC_NOATIME_ENABLED to true for rsync >= 3.3.0. Set to false for older versions (e.g., 3.2.7).
# RSYNC_TIMEOUT=300
# RECYCLE_BIN_ENABLED=true
# RECYCLE_BIN_DIR="recycle_bin"
# RECYCLE_BIN_RETENTION_DAYS=30
# CHECKSUM_ENABLED=false
# NTFY_ENABLED=true
# NTFY_TOKEN="your_token"
# NTFY_URL="https://ntfy.sh/your_topic"
# NTFY_PRIORITY_SUCCESS=3
# NTFY_PRIORITY_WARNING=4
# NTFY_PRIORITY_FAILURE=5
# BEGIN_SSH_OPTS
# -i
# /root/.ssh/id_rsa
# -p22
# END_SSH_OPTS
# BEGIN_EXCLUDES
# *.tmp
# /tmp/
# END_EXCLUDES
#
# =================================================================
# SCRIPT INITIALIZATION & SETUP
# =================================================================
set -Euo pipefail
umask 077
HOSTNAME=$(hostname -s)
# --- Color Palette ---
if [ -t 1 ]; then
C_RESET='\e[0m'
C_BOLD='\e[1m'
C_DIM='\e[2m'
C_RED='\e[0;31m'
C_GREEN='\e[0;32m'
C_YELLOW='\e[0;33m'
C_CYAN='\e[0;36m'
else
C_RESET=''
C_BOLD=''
C_DIM=''
C_RED=''
C_GREEN=''
C_YELLOW=''
C_CYAN=''
fi
# Re-run the script with sudo if not already root
if [[ $EUID -ne 0 ]]; then
echo -e "${C_BOLD}${C_YELLOW}This script requires root privileges to function correctly.${C_RESET}"
echo -e "${C_YELLOW}Attempting to re-run with sudo. You may be prompted for your password.${C_RESET}"
echo "----------------------------------------------------------------"
exec sudo "$0" "$@"
fi
# --- Determine script's location to load the config file ---
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
CONFIG_FILE="${SCRIPT_DIR}/backup.conf"
# --- Create a temporary file for rsync exclusions ---
EXCLUDE_FILE_TMP=$(mktemp)
SSH_OPTS_ARRAY=()
# --- Securely parse the unified configuration file ---
if [ -f "$CONFIG_FILE" ]; then
in_exclude_block=false
in_ssh_opts_block=false
while IFS= read -r line; do
# --- Handle block markers ---
if [[ "$line" == "BEGIN_EXCLUDES" ]]; then in_exclude_block=true; continue; fi
if [[ "$line" == "END_EXCLUDES" ]]; then in_exclude_block=false; continue; fi
if [[ "$line" == "BEGIN_SSH_OPTS" ]]; then in_ssh_opts_block=true; continue; fi
if [[ "$line" == "END_SSH_OPTS" ]]; then in_ssh_opts_block=false; continue; fi
# --- Process lines within blocks ---
if [[ "$in_exclude_block" == "true" ]]; then
[[ ! "$line" =~ ^([[:space:]]*#|[[:space:]]*$) ]] && echo "$line" >> "$EXCLUDE_FILE_TMP"
continue
fi
if [[ "$in_ssh_opts_block" == "true" ]]; then
[[ ! "$line" =~ ^([[:space:]]*#|[[:space:]]*$) ]] && SSH_OPTS_ARRAY+=("$line")
continue
fi
# --- Process key-value pairs ---
if [[ "$line" =~ ^[[:space:]]*([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*) ]]; then
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
value="${value%\"}"; value="${value#\"}"
case "$key" in
BACKUP_DIRS|BOX_DIR|BOX_ADDR|LOG_FILE|LOG_RETENTION_DAYS|CHECKSUM_ENABLED|\
MAX_LOG_SIZE_MB|BANDWIDTH_LIMIT_KBPS|RSYNC_TIMEOUT|RSYNC_NOATIME_ENABLED|\
NTFY_ENABLED|DISCORD_ENABLED|NTFY_TOKEN|NTFY_URL|DISCORD_WEBHOOK_URL|\
NTFY_PRIORITY_SUCCESS|NTFY_PRIORITY_WARNING|NTFY_PRIORITY_FAILURE|\
RECYCLE_BIN_ENABLED|RECYCLE_BIN_DIR|RECYCLE_BIN_RETENTION_DAYS)
declare "$key"="$value"
;;
*)
echo "WARNING: Unknown config variable '$key' ignored in $CONFIG_FILE" >&2
;;
esac
fi
done < "$CONFIG_FILE"
else
echo "FATAL: Unified configuration file backup.conf not found." >&2; exit 1
fi
# --- Validate that all required configuration variables are set ---
for var in BACKUP_DIRS BOX_DIR BOX_ADDR LOG_FILE \
NTFY_PRIORITY_SUCCESS NTFY_PRIORITY_WARNING NTFY_PRIORITY_FAILURE \
LOG_RETENTION_DAYS; do
if [ -z "${!var:-}" ]; then
echo "FATAL: Required config variable '$var' is missing or empty in $CONFIG_FILE." >&2
exit 1
fi
done
if [[ "$BOX_DIR" != */ ]]; then
echo "❌ FATAL: BOX_DIR must end with a trailing slash (/). Please check backup.conf." >&2
exit 2
fi
if [[ "${RECYCLE_BIN_ENABLED:-false}" == "true" ]]; then
for var in RECYCLE_BIN_DIR RECYCLE_BIN_RETENTION_DAYS; do
if [ -z "${!var:-}" ]; then
echo "FATAL: When RECYCLE_BIN_ENABLED is true, '$var' must be set in $CONFIG_FILE." >&2
exit 1
fi
done
if [[ "${RECYCLE_BIN_DIR}" == /* ]]; then
echo "❌ FATAL: RECYCLE_BIN_DIR must be a relative path, not absolute: '${RECYCLE_BIN_DIR}'" >&2
exit 1
fi
if [[ "$RECYCLE_BIN_DIR" == *"../"* ]]; then
echo "❌ FATAL: RECYCLE_BIN_DIR cannot contain '../'" >&2
exit 1
fi
fi
# =================================================================
# SCRIPT CONFIGURATION (STATIC)
# =================================================================
REMOTE_TARGET="${BOX_ADDR}:${BOX_DIR}"
LOCK_FILE="/tmp/backup_rsync.lock"
SSH_CMD="ssh"
if (( ${#SSH_OPTS_ARRAY[@]} > 0 )); then
SSH_CMD+=$(printf " %q" "${SSH_OPTS_ARRAY[@]}")
fi
RSYNC_BASE_OPTS=(
-aR -z --delete --partial --timeout="${RSYNC_TIMEOUT:-300}" --mkpath
--exclude-from="$EXCLUDE_FILE_TMP"
-e "$SSH_CMD"
)
if [[ "${RSYNC_NOATIME_ENABLED:-false}" == "true" ]]; then
RSYNC_BASE_OPTS+=(--noatime)
fi
# Optional: Add bandwidth limit if configured
if [[ -n "${BANDWIDTH_LIMIT_KBPS:-}" && "${BANDWIDTH_LIMIT_KBPS}" -gt 0 ]]; then
RSYNC_BASE_OPTS+=(--bwlimit="$BANDWIDTH_LIMIT_KBPS")
fi
# Shared options for direct, non-interactive SSH commands
SSH_DIRECT_OPTS=(
-o StrictHostKeyChecking=no
-o BatchMode=yes
-o ConnectTimeout=30
-n
)
# =================================================================
# HELPER FUNCTIONS
# =================================================================
log_message() {
local message="$1"
echo "[$HOSTNAME] [$(date '+%Y-%m-%d %H:%M:%S')] $message" >> "${LOG_FILE:-/dev/null}"
if [[ "${VERBOSE_MODE:-false}" == "true" ]]; then
echo "$message"
fi
}
send_ntfy() {
local title="$1" tags="$2" priority="$3" message="$4"
if [[ "${NTFY_ENABLED:-false}" != "true" ]] || [ -z "${NTFY_TOKEN:-}" ] || [ -z "${NTFY_URL:-}" ]; then return; fi
curl -s --max-time 15 -u ":$NTFY_TOKEN" -H "Title: $title" -H "Tags: $tags" -H "Priority: $priority" -d "$message" "$NTFY_URL" > /dev/null 2>> "${LOG_FILE:-/dev/null}"
}
send_discord() {
local title="$1" status="$2" message="$3"
if [[ "${DISCORD_ENABLED:-false}" != "true" ]] || [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then return; fi
local color; case "$status" in
success) color=3066993 ;; warning) color=16776960 ;; failure) color=15158332 ;; *) color=9807270 ;;
esac
local escaped_title; escaped_title=$(echo "$title" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g')
local escaped_message; escaped_message=$(echo "$message" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
local json_payload; printf -v json_payload '{"embeds": [{"title": "%s", "description": "%s", "color": %d, "timestamp": "%s"}]}' \
"$escaped_title" "$escaped_message" "$color" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
curl -s --max-time 15 -H "Content-Type: application/json" -d "$json_payload" "$DISCORD_WEBHOOK_URL" > /dev/null 2>> "${LOG_FILE:-/dev/null}"
}
send_notification() {
local title="$1" tags="$2" ntfy_priority="$3" discord_status="$4" message="$5"
send_ntfy "$title" "$tags" "$ntfy_priority" "$message"
send_discord "$title" "$discord_status" "$message"
}
run_integrity_check() {
local rsync_check_opts=(-aincR --delete --mkpath --exclude-from="$EXCLUDE_FILE_TMP" --out-format="%n" -e "$SSH_CMD")
if [[ "${CHECKSUM_ENABLED:-false}" == "true" ]]; then
rsync_check_opts+=(-c)
fi
local DIRS_ARRAY; read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
for dir in "${DIRS_ARRAY[@]}"; do
echo "--- Integrity Check: $dir ---" >&2
local relative_path="${dir#*./}"
LC_ALL=C rsync "${rsync_check_opts[@]}" "$dir" "${REMOTE_TARGET}${relative_path}" 2>> "${LOG_FILE:-/dev/null}"
done
}
parse_stat() {
local output="$1" pattern="$2" awk_command="$3"
( set +o pipefail; echo "$output" | grep "$pattern" | awk "$awk_command" )
}
format_backup_stats() {
local rsync_output="$1"
local files_transferred=$(parse_stat "$rsync_output" 'Number of regular files transferred:' '{s+=$2} END {print s}')
local bytes_transferred=$(parse_stat "$rsync_output" 'Total_transferred_size:' '{s+=$2} END {print s}')
local files_created=$(parse_stat "$rsync_output" 'Number_of_created_files:' '{s+=$2} END {print s}')
local files_deleted=$(parse_stat "$rsync_output" 'Number_of_deleted_files:' '{s+=$2} END {print s}')
if [[ -z "$bytes_transferred" && -z "$files_created" && -z "$files_deleted" ]]; then
files_transferred=$(parse_stat "$rsync_output" 'Number of files transferred:' '{gsub(/,/, ""); s+=$4} END {print s}')
bytes_transferred=$(parse_stat "$rsync_output" 'Total transferred file size:' '{gsub(/,/, ""); s+=$5} END {print s}')
files_created=$(parse_stat "$rsync_output" 'Number of created files:' '{s+=$5} END {print s}')
files_deleted=$(parse_stat "$rsync_output" 'Number of deleted files:' '{s+=$5} END {print s}')
fi
if [[ -z "$bytes_transferred" && -z "$files_transferred" ]]; then
log_message "WARNING: Unable to parse rsync stats. Output format may be incompatible."
printf "Data Transferred: Unknown\nFiles Updated: Unknown\nFiles Created: Unknown\nFiles Deleted: Unknown\n"
return 0
fi
local files_updated=$(( ${files_transferred:-0} - ${files_created:-0} ))
if (( files_updated < 0 )); then files_updated=0; fi
local stats_summary=""
if [[ "${bytes_transferred:-0}" -gt 0 ]]; then
stats_summary=$(printf "Data Transferred: %s" "$(numfmt --to=iec-i --suffix=B --format="%.2f" "$bytes_transferred")")
else
stats_summary="Data Transferred: 0 B (No changes)"
fi
stats_summary+=$(printf "\nFiles Updated: %s\nFiles Created: %s\nFiles Deleted: %s" "${files_updated:-0}" "${files_created:-0}" "${files_deleted:-0}")
printf "%s\n" "$stats_summary"
}
cleanup() {
rm -f "${EXCLUDE_FILE_TMP:-}" "${RSYNC_LOG_TMP:-}"
}
run_preflight_checks() {
local mode=${1:-backup}; local test_mode=false
if [[ "$mode" == "test" ]]; then test_mode=true; fi
local check_failed=false
if [[ "$test_mode" == "true" ]]; then printf "${C_BOLD}--- Checking required commands...${C_RESET}\n"; fi
for cmd in "${REQUIRED_CMDS[@]}"; do
if ! command -v "$cmd" &>/dev/null; then echo "❌ FATAL: Required command '$cmd' not found." >&2; check_failed=true; fi
done
if [[ "$check_failed" == "true" ]]; then exit 10; fi
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ All required commands are present.${C_RESET}\n"; fi
# Check rsync version for --noatime compatibility if the feature is enabled
if [[ "${RSYNC_NOATIME_ENABLED:-false}" == "true" ]]; then
if [[ "$test_mode" == "true" ]]; then printf "${C_BOLD}--- Checking rsync version for --noatime...${C_RESET}\n"; fi
local rsync_version
rsync_version=$(rsync --version | head -n1 | awk '{print $3}')
local major minor
IFS='.' read -r major minor _ <<< "$rsync_version"
if ! (( major > 3 || (major == 3 && minor >= 3) )); then
printf "${C_RED}❌ FATAL: RSYNC_NOATIME_ENABLED is true but rsync version %s is too old.${C_RESET}\n" "$rsync_version" >&2
printf "${C_DIM} The --noatime option requires rsync version 3.3.0 or newer.${C_RESET}\n" >&2
exit 10
fi
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ rsync version %s supports --noatime.${C_RESET}\n" "$rsync_version"; fi
fi
if [[ "$test_mode" == "true" ]]; then printf "${C_BOLD}--- Checking SSH connectivity...${C_RESET}\n"; fi
# Quick preflight connectivity "ping": short 10s timeout for fail-fast behaviour
if ! ssh "${SSH_OPTS_ARRAY[@]}" -o BatchMode=yes -o ConnectTimeout=10 "$BOX_ADDR" 'exit' 2>/dev/null; then
local err_msg="Unable to SSH into $BOX_ADDR. Check keys and connectivity."
if [[ "$test_mode" == "true" ]]; then echo "❌ $err_msg"; else send_notification "SSH FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "$err_msg"; fi; exit 6
fi
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ SSH connectivity OK.${C_RESET}\n"; fi
if [[ "${RECYCLE_BIN_ENABLED:-false}" == "true" ]]; then
local remote_recycle_path="${BOX_DIR}${RECYCLE_BIN_DIR}"
if ! ssh "${SSH_OPTS_ARRAY[@]}" -o BatchMode=yes -o ConnectTimeout=10 "$BOX_ADDR" "ls -d \"$remote_recycle_path\"" >/dev/null 2>&1; then
if ! ssh "${SSH_OPTS_ARRAY[@]}" -o BatchMode=yes -o ConnectTimeout=10 "$BOX_ADDR" "mkdir -p \"$remote_recycle_path\"" >/dev/null 2>&1; then
echo "❌ FATAL: Cannot access or create recycle bin directory '$remote_recycle_path' on remote." >&2
exit 1
fi
fi
fi
if [[ "$mode" != "restore" ]]; then
if [[ "$test_mode" == "true" ]]; then printf "${C_BOLD}--- Checking backup directories...${C_RESET}\n"; fi
local DIRS_ARRAY; read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
for dir in "${DIRS_ARRAY[@]}"; do
if [[ ! -d "$dir" ]] || [[ "$dir" != */ ]]; then
local err_msg="A directory in BACKUP_DIRS ('$dir') must exist and end with a trailing slash ('/')."
if [[ "$test_mode" == "true" ]]; then echo "❌ FATAL: $err_msg"; else send_notification "❌ Backup FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "FATAL: $err_msg"; fi; exit 2
fi
if [[ "$dir" != *"/./"* ]]; then
local err_msg="Directory '$dir' in BACKUP_DIRS is missing the required '/./' syntax."
if [[ "$test_mode" == "true" ]]; then
echo "❌ FATAL: $err_msg"
else
send_notification "❌ Backup FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "FATAL: $err_msg"
fi
exit 2
fi
if [[ ! -r "$dir" ]]; then
local err_msg="A directory in BACKUP_DIRS ('$dir') is not readable."
if [[ "$test_mode" == "true" ]]; then echo "❌ FATAL: $err_msg"; else send_notification "❌ Backup FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "FATAL: $err_msg"; fi; exit 2
fi
done
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ All backup directories are valid.${C_RESET}\n"; fi
if [[ "$test_mode" == "true" ]]; then printf "${C_BOLD}--- Checking local disk space...${C_RESET}\n"; fi
local required_space_kb=102400
local available_space_kb
available_space_kb=$(df --output=avail "$(dirname "${LOG_FILE}")" | tail -n1)
if [[ "$available_space_kb" -lt "$required_space_kb" ]]; then
local err_msg="Insufficient disk space in $(dirname "${LOG_FILE}") to guarantee logging. ($((available_space_kb / 1024))MB available)"
if [[ "$test_mode" == "true" ]]; then echo "❌ FATAL: $err_msg"; else send_notification "❌ Backup FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "FATAL: $err_msg"; fi
exit 7
fi
if [[ "$test_mode" == "true" ]]; then printf "${C_GREEN}✅ Local disk space OK.${C_RESET}\n"; fi
fi
}
print_header() {
printf "\n%b--- %s ---%b\n" "${C_BOLD}" "$1" "${C_RESET}"
}
run_restore_mode() {
print_header "RESTORE MODE ACTIVATED"
run_preflight_checks "restore"
local DIRS_ARRAY; read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
local RECYCLE_OPTION="[ Restore from Recycle Bin ]"
local all_options=("${DIRS_ARRAY[@]}")
if [[ "${RECYCLE_BIN_ENABLED:-false}" == "true" ]]; then
all_options+=("$RECYCLE_OPTION")
fi
all_options+=("Cancel")
printf "${C_YELLOW}Available backup sets to restore from:${C_RESET}\n"
PS3="Your choice: "
select dir_choice in "${all_options[@]}"; do
if [[ -n "$dir_choice" ]]; then break;
else echo "Invalid selection. Please try again."; fi
done
PS3="#? "
local paths_to_process=()
local source_base_remote=""
local source_base_local_prefix=""
local source_display_name=""
local is_full_directory_restore=false
if [[ "$dir_choice" == "$RECYCLE_OPTION" ]]; then
print_header "Browse Recycle Bin"
local date_folders=()
local remote_recycle_path="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}"
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}$')
if [[ ${#date_folders[@]} -eq 0 ]]; then
printf "${C_YELLOW}❌ The remote recycle bin is empty or contains no valid backup folders.${C_RESET}\n"
return 1
fi
printf "${C_YELLOW}Select a backup run (date_time) to browse:${C_RESET}\n"
PS3="Your choice: "
select date_choice in "${date_folders[@]}" "Cancel"; do
if [[ "$date_choice" == "Cancel" ]]; then echo "Restore cancelled."; return 0;
elif [[ -n "$date_choice" ]]; then break;
else echo "Invalid selection. Please try again."; fi
done
PS3="#? "
local remote_date_path="${remote_recycle_path}/${date_choice}"
print_header "Files available from ${date_choice} (showing first 20)"
rsync -r -n --out-format='%n' -e "$SSH_CMD" "${BOX_ADDR}:${remote_date_path}/" . 2>/dev/null | head -n 20 || echo "No files found for this date."
printf "%b--------------------------------------------------------%b\n" "${C_BOLD}" "${C_RESET}"
printf -v path_prompt "Enter original path(s) to restore (e.g., home/user/file.txt), space-separated: "
read -erp "$(printf '%b%s%b' "${C_YELLOW}" "$path_prompt" "${C_RESET}")" -a paths_to_process
if [[ ${#paths_to_process[@]} -eq 0 ]]; then echo "❌ Path cannot be empty. Aborting."; return 1; fi
source_base_remote="${BOX_ADDR}:${remote_date_path}"
source_base_local_prefix="/"
source_display_name="(from Recycle Bin, ${date_choice})"
elif [[ "$dir_choice" == "Cancel" ]]; then
echo "Restore cancelled."; return 0
else
while true; do
printf "\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}"; read -r choice
case "$choice" in
entire) is_full_directory_restore=true; paths_to_process+=(""); break ;;
specific)
local relative_path_browse="${dir_choice#*./}"
local remote_browse_source="${REMOTE_TARGET}${relative_path_browse}"
print_header "Files available in ${dir_choice} (showing first 20)"
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."
printf "%b--------------------------------------------------------%b\n" "${C_BOLD}" "${C_RESET}"
printf -v path_prompt "Enter path(s) relative to '%s' to restore (space-separated, quote if spaces): " "$dir_choice"
read -erp "$(printf '%b%s%b' "${C_YELLOW}" "$path_prompt" "${C_RESET}")" -a paths_to_process
if [[ ${#paths_to_process[@]} -eq 0 ]]; then
echo "Path cannot be empty. Please try again or choose 'entire'."
continue
fi
break ;;
*) echo "Invalid choice. Please answer 'entire' or 'specific'." ;;
esac
done
local relative_path="${dir_choice#*./}"
source_base_remote="${REMOTE_TARGET}${relative_path}"
source_base_local_prefix=$(echo "$dir_choice" | sed 's#/\./#/#g')
source_display_name="'${dir_choice}'"
fi
local successful_count=0
local failed_count=0
local total_items=${#paths_to_process[@]}
for restore_path in "${paths_to_process[@]}"; do
if [[ "$restore_path" == /* || "$restore_path" =~ (^|/)\.\.(/|$) ]]; then
echo "❌ Invalid restore path: '${restore_path}' must be relative and contain no '..'. Skipping." >&2;
((failed_count++))
continue
fi
restore_path=$(echo "$restore_path" | sed 's#^/##')
local item_for_display full_remote_source default_local_dest
if [[ -n "$restore_path" ]]; then
item_for_display="'${restore_path%/}' from ${source_display_name}"
full_remote_source="${source_base_remote%/}/${restore_path%/}"
default_local_dest="${source_base_local_prefix%/}/${restore_path%/}"
else
item_for_display="the entire directory ${source_display_name}"
full_remote_source="$source_base_remote"
default_local_dest="$source_base_local_prefix"
fi
local final_dest
print_header "Restore Destination for ${item_for_display}"
printf "Enter the absolute destination path for the restore.\n\n"
printf "%bDefault (original location):%b\n" "${C_YELLOW}" "${C_RESET}"
printf "%b%s%b\n\n" "${C_CYAN}" "$default_local_dest" "${C_RESET}"
printf "Press [Enter] to use the default path, or enter a new one.\n"
read -rp "> " final_dest
: "${final_dest:=$default_local_dest}"
local path_validation_attempts=0
local max_attempts=5
while true; do
((path_validation_attempts++))
if (( path_validation_attempts > max_attempts )); then
printf "\n${C_RED}❌ Too many invalid attempts. Skipping restore for this item.${C_RESET}\n"
((failed_count++))
continue 2
fi
if [[ "$final_dest" != "/" ]]; then final_dest="${final_dest%/}"; fi
local parent_dir; parent_dir=$(dirname -- "$final_dest")
if [[ "$final_dest" != /* ]]; then
printf "\n${C_RED}❌ Error: Please provide an absolute path (starting with '/').${C_RESET}\n"
elif [[ -e "$final_dest" && ! -d "$final_dest" ]]; then
printf "\n${C_RED}❌ Error: The destination '%s' exists but is a file. Please choose a different path.${C_RESET}\n" "$final_dest"
elif [[ -e "$parent_dir" && ! -w "$parent_dir" ]]; then
printf "\n${C_RED}❌ Error: The parent directory '%s' exists but is not writable.${C_RESET}\n" "$parent_dir"
elif [[ -d "$final_dest" ]]; then
printf "${C_GREEN}✅ Destination '%s' exists and is accessible.${C_RESET}\n" "$final_dest"
if [[ "$final_dest" != "$default_local_dest" && -z "$restore_path" ]]; then
local warning_msg="⚠️ WARNING: Custom destination directory already exists. Files may be overwritten."
printf "${C_YELLOW}%s${C_RESET}\n" "$warning_msg"; log_message "$warning_msg"
fi
break
else
printf "\n${C_YELLOW}⚠️ The destination '%s' does not exist.${C_RESET}\n" "$final_dest"
printf "${C_YELLOW}Choose an action:${C_RESET}\n"
PS3="Your choice: "
select action in "Create the destination path" "Enter a different path" "Cancel"; do
case "$action" in
"Create the destination path")
if mkdir -p "$final_dest"; then
printf "${C_GREEN}✅ Successfully created directory '%s'.${C_RESET}\n" "$final_dest"
if [[ "${is_full_directory_restore:-false}" == "true" ]]; then
chmod 700 "$final_dest"; log_message "Set permissions to 700 on newly created restore directory: $final_dest"
else
chmod 755 "$final_dest"
fi
break 2
else
printf "\n${C_RED}❌ Failed to create directory '%s'. Check permissions.${C_RESET}\n"; break
fi ;;
"Enter a different path") break ;;
"Cancel")
echo "Restore cancelled for this item."
((failed_count++))
continue 2 ;;
*) echo "Invalid option. Please try again." ;;
esac
done
PS3="#? "
fi
if (( path_validation_attempts < max_attempts )); then
printf "\n${C_YELLOW}Please enter a new destination path: ${C_RESET}"; read -r final_dest
if [[ -z "$final_dest" ]]; then
final_dest="$default_local_dest"; printf "${C_DIM}Empty input, using default location: %s${C_RESET}\n" "$final_dest"
fi
fi
done
local extra_rsync_opts=()
local dest_user=""
if [[ "$final_dest" == /home/* ]]; then
dest_user=$(echo "$final_dest" | cut -d/ -f3)
if [[ -n "$dest_user" ]] && id -u "$dest_user" &>/dev/null; then
printf "${C_CYAN}ℹ️ Home directory detected. Restored files will be owned by '${dest_user}'.${C_RESET}\n"
extra_rsync_opts+=("--chown=${dest_user}:${dest_user}")
chown "${dest_user}:${dest_user}" "$final_dest" 2>/dev/null || true
else
dest_user=""
fi
fi
print_header "Restore Summary"
printf " Source: %s\n" "$item_for_display"
printf " Destination: %b%s%b\n" "${C_BOLD}" "$final_dest" "${C_RESET}"
print_header "PERFORMING DRY RUN (NO CHANGES MADE)"
log_message "Starting restore dry-run of ${item_for_display} from ${full_remote_source} to ${final_dest}"
local rsync_restore_opts=(-avhi --safe-links --progress --exclude-from="$EXCLUDE_FILE_TMP" -e "$SSH_CMD")
if ! rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" --dry-run "$full_remote_source" "$final_dest"; then
printf "${C_RED}❌ DRY RUN FAILED. Rsync reported an error. Skipping item.${C_RESET}\n" >&2
log_message "Restore dry-run failed for ${item_for_display}"
((failed_count++))
continue
fi
print_header "DRY RUN COMPLETE"
while true; do
printf "\n${C_YELLOW}Proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}" "$item_for_display" "$final_dest"; read -r confirmation
case "${confirmation,,}" in
yes|y) break ;;
no|n)
echo "Restore cancelled by user for this item."
((failed_count++))
continue 2 ;;
*) echo "Please answer 'yes' or 'no'." ;;
esac
done
print_header "EXECUTING RESTORE"
log_message "Starting actual restore of ${item_for_display} from ${full_remote_source} to ${final_dest}"
if rsync "${rsync_restore_opts[@]}" "${extra_rsync_opts[@]}" "$full_remote_source" "$final_dest"; then
log_message "Restore completed successfully."
printf "${C_GREEN}✅ Restore of %s to '%s' completed successfully.${C_RESET}\n\n" "$item_for_display" "$final_dest"
send_notification "Restore SUCCESS: ${HOSTNAME}" "white_check_mark" "${NTFY_PRIORITY_SUCCESS}" "success" "Successfully restored ${item_for_display} to ${final_dest}"
((successful_count++))
else
local rsync_exit_code=$?
log_message "Restore FAILED with rsync exit code ${rsync_exit_code}."
printf "${C_RED}❌ Restore FAILED. Check the rsync output and log for details.${C_RESET}\n\n"
send_notification "Restore FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Restore of ${item_for_display} to ${final_dest} failed (exit code: ${rsync_exit_code})";
((failed_count++))
continue
fi
done
print_header "Overall Restore Summary"
printf "Total items selected: %d\n" "$total_items"
printf "${C_GREEN}Succeeded: %d${C_RESET}\n" "$successful_count"
if (( failed_count > 0 )); then
printf "${C_RED}Failed/Skipped: %d${C_RESET}\n" "$failed_count"
else
printf "${C_GREEN}Failed/Skipped: 0${C_RESET}\n"
fi
}
run_recycle_bin_cleanup() {
if [[ "${RECYCLE_BIN_ENABLED:-false}" != "true" ]]; then return 0; fi
log_message "Checking remote recycle bin..."
local remote_cleanup_path="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}"
local list_command="ls -1 \"$remote_cleanup_path\""
local all_folders
if ! all_folders=$(ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "$list_command" 2>> "${LOG_FILE:-/dev/null}"); then
log_message "Recycle bin not found or unable to list contents. Nothing to clean."
return 0
fi
if [[ -z "$all_folders" ]]; then
log_message "No daily folders in recycle bin to check."
return 0
fi
log_message "Checking for folders older than ${RECYCLE_BIN_RETENTION_DAYS} days..."
local folders_to_delete=""
local retention_days=${RECYCLE_BIN_RETENTION_DAYS}
local threshold_timestamp
threshold_timestamp=$(date -d "$retention_days days ago" +%s)
while IFS= read -r folder; do
local folder_date=${folder%%_*}
if folder_timestamp=$(date -d "$folder_date" +%s 2>/dev/null) && [[ -n "$folder_timestamp" ]]; then
if (( folder_timestamp < threshold_timestamp )); then
folders_to_delete+="${folder}"$'\n'
fi
fi
done <<< "$all_folders"
if [[ -n "$folders_to_delete" ]]; then
log_message "Removing old recycle bin folders:"
local empty_dir
empty_dir=$(mktemp -d)
while IFS= read -r folder; do
if [[ -n "$folder" ]]; then
log_message " Deleting: $folder"
local remote_dir_to_delete="${remote_cleanup_path}/${folder}/"
rsync -a --delete -e "$SSH_CMD" "$empty_dir/" "${BOX_ADDR}:${remote_dir_to_delete}" >/dev/null 2>> "${LOG_FILE:-/dev/null}"
ssh "${SSH_OPTS_ARRAY[@]}" "${SSH_DIRECT_OPTS[@]}" "$BOX_ADDR" "rmdir \"$remote_dir_to_delete\"" 2>> "${LOG_FILE:-/dev/null}"
fi
done <<< "$folders_to_delete"
rm -rf "$empty_dir"
else
log_message "No old recycle bin folders to remove."
fi
}
trap cleanup EXIT
trap 'send_notification "Backup Crashed: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "Backup script terminated unexpectedly. Check log: ${LOG_FILE:-/dev/null}"' ERR
REQUIRED_CMDS=(rsync ssh curl flock hostname date stat mv touch awk numfmt grep printf nice ionice sed mktemp basename read)
# =================================================================
# SCRIPT EXECUTION
# =================================================================
VERBOSE_MODE=false
if [[ "${1:-}" == "--verbose" ]]; then
VERBOSE_MODE=true; shift
fi
if [[ "${1:-}" ]]; then
case "${1}" in
--dry-run)
trap - ERR
echo "--- DRY RUN MODE ACTIVATED ---"; DRY_RUN_FAILED=false; full_dry_run_output=""
read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
for dir in "${DIRS_ARRAY[@]}"; do
echo -e "\n--- Checking dry run for: $dir ---"
rsync_dry_opts=( "${RSYNC_BASE_OPTS[@]}" --dry-run --itemize-changes --out-format="%i %n%L" --info=stats2,name,flist2 )
if [[ "${RECYCLE_BIN_ENABLED:-false}" == "true" ]]; then
backup_dir="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}/$(date +%F_%H%M%S)/"
rsync_dry_opts+=(--backup --backup-dir="$backup_dir")
fi
DRY_RUN_LOG_TMP=$(mktemp)
if ! rsync "${rsync_dry_opts[@]}" "$dir" "$REMOTE_TARGET" > "$DRY_RUN_LOG_TMP" 2>&1; then DRY_RUN_FAILED=true; fi
echo "---- Preview of changes (first 20) ----"
grep -E '^\*deleting|^[<>ch\.]f|^cd|^\.d' "$DRY_RUN_LOG_TMP" | head -n 20 || true
echo "-------------------------------------"
full_dry_run_output+=$'\n'"$(<"$DRY_RUN_LOG_TMP")"; rm -f "$DRY_RUN_LOG_TMP"
done
echo -e "\n--- Overall Dry Run Summary ---"
BACKUP_STATS=$(format_backup_stats "$full_dry_run_output")
echo -e "$BACKUP_STATS"; echo "-------------------------------"
if [[ "$DRY_RUN_FAILED" == "true" ]]; then
echo -e "\n❌ Dry run FAILED for one or more directories. See rsync errors above."; exit 1
fi
echo "--- DRY RUN COMPLETED ---"; exit 0 ;;
--checksum | --summary)
trap - ERR
echo "--- INTEGRITY CHECK MODE ACTIVATED ---"; echo "Calculating differences..."
START_TIME_INTEGRITY=$(date +%s); FILE_DISCREPANCIES=$(run_integrity_check); END_TIME_INTEGRITY=$(date +%s)
DURATION_INTEGRITY=$((END_TIME_INTEGRITY - START_TIME_INTEGRITY))
CLEAN_DISCREPANCIES=$(echo "$FILE_DISCREPANCIES" | grep -v '^\*')
if [[ "$1" == "--summary" ]]; then
MISMATCH_COUNT=$(echo "$CLEAN_DISCREPANCIES" | wc -l)
printf "🚨 Total files with checksum mismatches: %d\n" "$MISMATCH_COUNT"
log_message "Summary mode check found $MISMATCH_COUNT mismatched files."
send_notification "📊 Backup Summary: ${HOSTNAME}" "bar_chart" "${NTFY_PRIORITY_SUCCESS}" "success" "Mismatched files found: $MISMATCH_COUNT"
else # --checksum
if [ -z "$CLEAN_DISCREPANCIES" ]; then
echo "✅ Checksum validation passed. No discrepancies found."
log_message "Checksum validation passed. No discrepancies found."
send_notification "Backup Integrity OK: ${HOSTNAME}" "white_check_mark" "${NTFY_PRIORITY_SUCCESS}" "success" "Checksum validation passed."
else
log_message "Backup integrity check FAILED. Found discrepancies."
ISSUE_LIST=$(echo "$CLEAN_DISCREPANCIES" | head -n 10)
printf -v FAILURE_MSG "Backup integrity check FAILED.\n\nFirst 10 differing files:\n%s\n\nCheck duration: %dm %ds" "${ISSUE_LIST}" $((DURATION_INTEGRITY / 60)) $((DURATION_INTEGRITY % 60))
printf "❌ %s\n" "$FAILURE_MSG"
send_notification "Backup Integrity FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "${FAILURE_MSG}"
fi
fi
exit 0 ;;
--test)
trap - ERR
echo "--- TEST MODE ACTIVATED ---"; run_preflight_checks "test"
echo "---------------------------"; echo "✅ All configuration checks passed."; exit 0 ;;
--restore)
trap - ERR; run_restore_mode; exit 0 ;;
esac
fi
run_preflight_checks
exec 200>"$LOCK_FILE"
flock -n 200 || { echo "Another instance is running, exiting."; exit 5; }
# --- Log Rotation ---
# Use default of 10MB if not set in config
max_log_size_bytes=$(( ${MAX_LOG_SIZE_MB:-10} * 1024 * 1024 ))
if [ -f "$LOG_FILE" ] && [ "$(stat -c%s "$LOG_FILE")" -gt "$max_log_size_bytes" ]; then
mv "$LOG_FILE" "${LOG_FILE}.$(date +%Y%m%d_%H%M%S)"
touch "$LOG_FILE"
find "$(dirname "$LOG_FILE")" -name "$(basename "$LOG_FILE").*" -type f -mtime +"$LOG_RETENTION_DAYS" -delete
fi
log_message "Flushing filesystem buffers to disk..."
sync
echo "============================================================" >> "$LOG_FILE"
log_message "Starting rsync backup..."
START_TIME=$(date +%s)
success_dirs=(); failed_dirs=(); overall_exit_code=0; full_rsync_output=""
read -ra DIRS_ARRAY <<< "$BACKUP_DIRS"
for dir in "${DIRS_ARRAY[@]}"; do
log_message "Backing up directory: $dir"
RSYNC_LOG_TMP=$(mktemp)
RSYNC_EXIT_CODE=0; RSYNC_OPTS=("${RSYNC_BASE_OPTS[@]}")
if [[ "${RECYCLE_BIN_ENABLED:-false}" == "true" ]]; then
backup_dir="${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}/$(date +%F_%H%M%S)/"
RSYNC_OPTS+=(--backup --backup-dir="$backup_dir")
fi
if [[ "$VERBOSE_MODE" == "true" ]]; then
RSYNC_OPTS+=(--info=stats2,progress2)
nice -n 19 ionice -c 3 rsync "${RSYNC_OPTS[@]}" "$dir" "$REMOTE_TARGET" 2>&1 | tee "$RSYNC_LOG_TMP"
RSYNC_EXIT_CODE=${PIPESTATUS[0]}
else
RSYNC_OPTS+=(--info=stats2)
nice -n 19 ionice -c 3 rsync "${RSYNC_OPTS[@]}" "$dir" "$REMOTE_TARGET" > "$RSYNC_LOG_TMP" 2>&1 || RSYNC_EXIT_CODE=$?
fi
cat "$RSYNC_LOG_TMP" >> "$LOG_FILE"; full_rsync_output+=$'\n'"$(<"$RSYNC_LOG_TMP")"
rm -f "$RSYNC_LOG_TMP"
if [[ $RSYNC_EXIT_CODE -eq 0 || $RSYNC_EXIT_CODE -eq 24 || $RSYNC_EXIT_CODE -eq 23 ]]; then
success_dirs+=("$(basename "$dir")")
if [[ $RSYNC_EXIT_CODE -eq 24 || $RSYNC_EXIT_CODE -eq 23 ]]; then
log_message "WARNING for $dir: rsync completed with code $RSYNC_EXIT_CODE."; overall_exit_code=24
fi
else
failed_dirs+=("$(basename "$dir")")
log_message "FAILED for $dir: rsync exited with code: $RSYNC_EXIT_CODE."; overall_exit_code=1
fi
done
run_recycle_bin_cleanup
END_TIME=$(date +%s); DURATION=$((END_TIME - START_TIME)); trap - ERR
BACKUP_STATS=$(format_backup_stats "$full_rsync_output")
FINAL_MESSAGE=$(printf "%s\n\nSuccessful: %s\nFailed: %s\n\nDuration: %dm %ds" \
"$BACKUP_STATS" \
"${success_dirs[*]:-None}" \
"${failed_dirs[*]:-None}" \
$((DURATION / 60)) $((DURATION % 60)))
if [[ ${#FINAL_MESSAGE} -gt 1800 ]]; then
FINAL_MESSAGE=$(printf "%.1800s\n\n[Message truncated, see %s for full details]" "$FINAL_MESSAGE" "$LOG_FILE")
fi
if [[ ${#failed_dirs[@]} -eq 0 ]]; then
log_message "SUCCESS: All backups completed."
if [[ $overall_exit_code -eq 24 ]]; then
send_notification "Backup Warning: ${HOSTNAME}" "warning" "${NTFY_PRIORITY_WARNING}" "warning" "One or more directories completed with warnings.\n\n$FINAL_MESSAGE"
else
send_notification "Backup SUCCESS: ${HOSTNAME}" "white_check_mark" "${NTFY_PRIORITY_SUCCESS}" "success" "$FINAL_MESSAGE"
fi
else
log_message "FAILURE: One or more backups failed."; send_notification "Backup FAILED: ${HOSTNAME}" "x" "${NTFY_PRIORITY_FAILURE}" "failure" "$FINAL_MESSAGE"
fi
echo "======================= Run Finished =======================" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"