This script automates backups of local directories to a remote server (such as a Hetzner Storage Box) using rsync
over SSH.
- Unified Configuration: All settings are in a single
backup.conf
file with secure parsing - Portable Setup: The backup system can be migrated by copying the script and configuration
- Recycle Bin: Automatically moves deleted files into a versioned recycle bin on the remote server, with a configurable retention period.
- Notification Support: Sends notifications to ntfy and/or Discord, configurable via toggles
- Error Handling: Uses strict shell options and traps to detect and report errors
- Detailed Reports: Notifications include transfer size and file operation summaries
- System Friendly: Uses
nice
andionice
to reduce resource impact during backups - Multiple Operation Modes: Supports dry run, checksum verification, summary reports, verbose output, and file restoration
- Concurrency Control: Prevents simultaneous runs and handles log rotation automatically
- Pre-run Validation: Checks for necessary commands and SSH connectivity before execution
-
These are same files as at the bottom of this README.
# 1. Get the script and make it executable wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup_script.sh && chmod +x backup_script.sh # 2. Get the config file and set secure permissions wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup.conf && chmod 600 backup.conf
-
A cleaner version of the config file without comments.
wget -O backup.conf https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/clean_backup.conf && chmod 600 backup.conf
-
To ensure the script is authentic, verify its SHA256 checksum.
# Download the official checksum file wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup_script.sh.sha256 # Run the check (it should output: backup_script.sh: OK) sha256sum -c backup_script.sh.sha256
sudo ./backup_script.sh
- Run silently (suitable for cron)sudo ./backup_script.sh --verbose
- Run with live progresssudo ./backup_script.sh --dry-run
- Preview changes without applying themsudo ./backup_script.sh --checksum
- Verify backup integritysudo ./backup_script.sh --summary
- Report file differencessudo ./backup_script.sh --test
- Checks to validatebackup.conf
, permissions and SSH connectivitysudo ./backup_script.sh --restore
- Interactive restore with dry-run preview and confirmation
Default log location:
/var/log/backup_rsync.log
The script uses specific exit codes for different pre-flight failures, which can help with debugging automated runs.
- Exit Code
1
: Fatal Configuration Error. A critical issue was found during startup. This can be caused by:- The script not being run as
root
. - The
backup.conf
file being missing or a required variable not being set. - An invalid
RECYCLE_BIN_DIR
setting (e.g., an absolute path). - The script being unable to access or create the recycle bin on the remote server.
- The script not being run as
- Exit Code
2
:BACKUP_DIRS
Error. An issue with a directory listed inBACKUP_DIRS
(e.g., it doesn't exist, isn't readable, or is missing the/./
syntax). - Exit Code
5
: Lock Contention. Another instance of the script is already running. - Exit Code
6
: SSH Failure. The pre-flight check failed to establish an SSH connection to theBOX_ADDR
. - Exit Code
7
: Disk Space Error. Insufficient local disk space for logging. - Exit Code
10
: Prerequisite Missing. A required command (likersync
orcurl
) is not installed.
All files should be placed in a single directory (e.g., /home/user/scripts/backup
). The new structure is simpler with only two files to manage.
/home/user/scripts/backup/
βββ backup_script.sh (main script)
βββ backup.conf (settings, credentials, and excludes)
Follow these steps to get the backup system running.
Ensure the required tools are installed, run:
# On Debian/Ubuntu
sudo apt-get update && sudo apt-get install rsync curl coreutils util-linux
# On CentOS/RHEL/Fedora
sudo dnf install rsync curl coreutils util-linux
(coreutils provides numfmt
, stat
, etc. and util-linux provides flock
and mktemp
)
The script needs to log into the Hetzner Storage Box without a password.
-
Generate a root user SSH key on your server if you don't have one (using root will avoid permissions issues):
sudo ssh-keygen -t ed25519
(Just press Enter through all the prompts).
-
Copy your public key to the Hetzner Storage Box. First, view your public key:
sudo cat /root/.ssh/id_ed25519.pub
-
Or use the
ssh-copy-id
command (replaceu123456
andu123456-sub4
):# Hetzner Storage Box requires the `-s` flag. Replace `u123456` and `u123456-sub4` sudo ssh-copy-id -p 23 -s [email protected]
-
After public key has been added to your Hetzner Storage Box via the control panel or
ssh-copy-id
. Test the connection (replaceu123456
andu123456-sub4
):# This should work without asking for a password, and show /home in terminal output. sudo ssh -p 23 [email protected] pwd
- Create your script directory:
mkdir -p /home/user/scripts/backup && cd /home/user/scripts/backup
- Create the two files (
backup_script.sh
andbackup.conf
) in this directory using the content provided below. - Make the script executable:
chmod +x backup_script.sh
- Set secure permissions for your configuration file:
chmod 600 backup.conf
- Edit
backup.conf
to specify your local paths, remote server details, notification settings, and file exclusions.
To run the backup automatically, edit the root crontab.
-
Open the crontab editor:
sudo crontab -e
-
Add a line to schedule the script. This example runs the backup every day at 3:00 AM.
# Run the rsync backup every day at 3:00 AM 0 3 * * * /home/user/scripts/backup/backup_script.sh > /dev/null 2>&1
(Redirecting output to
/dev/null
is fine since the script handles its own logging and notifications).(Note:
sudo
is not needed here because this command is placed in the root user's crontab viasudo crontab -e
, so it already runs with root privileges.)
# =================================================================
# Configuration for rsync Backup Script v0.35
# =================================================================
# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)
# --- Source and Destination ---
# List all source directories to back up, separated by spaces.
#
# IMPORTANT: Follow these two rules for each path:
# 1. End the path with a trailing slash (e.g., "user/").
# 2. Use "/./" to mark the part of the path you want to create on the destination.
# Example: "/home/./user/" will create a "user" directory in your BOX_DIR.
BACKUP_DIRS="/./home/user/ /./var/log/ /./etc/nginx/"
# The root folder on the remote server where backups will be placed.
# IMPORTANT: This path MUST end with a trailing slash (e.g., /backups/).
BOX_DIR="/home/myvps/"
# --- Connection Details ---
# The SSH address of your remote backup server (e.g., user@host).
BOX_ADDR="[email protected]"
# Add each SSH option on a new line.
# For options taking a value, see the rules below.
BEGIN_SSH_OPTS
# Options with simple values (like a port) can be combined.
-p23
# Options that take a file path (like an identity key) MUST be on separate lines.
-i
/root/.ssh/id_ed25519
END_SSH_OPTS
# --- Performance ---
# Use --noatime for a potential performance boost.
# This requires rsync v3.3.0+ on BOTH the local and remote server.
# If your remote server (e.g., a storage box) has an older rsync, keep this to false.
RSYNC_NOATIME_ENABLED=false
# The timeout in seconds for rsync operations.
RSYNC_TIMEOUT=300
# Optional: Limit rsync's bandwidth usage in KiB/s. Leave empty or set to 0 to disable.
# Example: BANDWIDTH_LIMIT_KBPS=5000 (for 5 MB/s)
BANDWIDTH_LIMIT_KBPS=""
# --- Integrity Check ---
# Set to true to enable slow but thorough checksum-based integrity checks.
# Default is false for fast checks (based on file size and modification time).
CHECKSUM_ENABLED=false
# --- Logging ---
LOG_FILE="/var/log/backup_rsync.log"
# Max log size in Megabytes (MB) before the script rotates it.
MAX_LOG_SIZE_MB=10
# Delete rotated logs older than this many days.
LOG_RETENTION_DAYS=90
# --- Recycle Bin ---
# If enabled, files deleted from the source will be moved to a remote
# recycle bin instead of being permanently removed. This provides a safety net.
RECYCLE_BIN_ENABLED=true
# The name of the directory on the remote server to use as the recycle bin.
RECYCLE_BIN_DIR="recycle_bin"
# The number of days to keep daily backup folders in the recycle bin before deleting them.
RECYCLE_BIN_RETENTION_DAYS=30
# --- Notification Toggles ---
# Set to 'true' to enable, 'false' to disable.
NTFY_ENABLED=true
DISCORD_ENABLED=false
# --- ntfy Credentials ---
NTFY_TOKEN="tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
NTFY_URL="https://ntfy.sh/your-private-topic-name"
# --- ntfy Notification Priorities ---
# 5=max/urgent, 4=high, 3=default, 2=low, 1=min
NTFY_PRIORITY_SUCCESS=3
NTFY_PRIORITY_WARNING=4
NTFY_PRIORITY_FAILURE=4
# --- Discord Credentials ---
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your/webhook_url_here"
# --- rsync Exclusions ---
# List all file/directory patterns to exclude below.
# The script will read everything between the BEGIN and END markers.
BEGIN_EXCLUDES
# Common cache,logs and temporary files
.cache/
/tmp/
*.tmp
*.bak
*.swp
*.log
# Specific application caches/dependencies
/node_modules/
/vendor/
__pycache__/
# System files that shouldn't be backed up
/lost+found/
.DS_Store
Thumbs.db
# Some common files which can be skipped
.bash_history
.bash_logout
.bashrc
.config/
.docker/
.local/
.profile
.ssh/
.sudo_as_admin_successful
.wget-hsts
END_EXCLUDES
#!/bin/bash
# ===================== v0.35 - 2025.08.21 ========================
#
# =================================================================
# 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
# Check if the script is being run as root
if (( EUID != 0 )); then
echo "β This script must be run as root or with sudo." >&2
exit 1
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"