{"id":30528831,"url":"https://github.com/buildplan/rsync-backup-script","last_synced_at":"2026-05-16T11:32:02.734Z","repository":{"id":308938471,"uuid":"1033393110","full_name":"buildplan/rsync-backup-script","owner":"buildplan","description":"Backup script for VPS/VMSs to rsync enabled remote location like Hetzner Storage Box","archived":false,"fork":false,"pushed_at":"2025-08-31T18:31:31.000Z","size":517,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-09-25T02:53:56.225Z","etag":null,"topics":["backup","backup-script","bash-script","hetzner-storage-box","rsync-backup"],"latest_commit_sha":null,"homepage":"https://buildplan.org/rsync-backup-script/getting-started.html","language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-3-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/buildplan.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-08-06T18:39:26.000Z","updated_at":"2025-08-31T18:31:34.000Z","dependencies_parsed_at":"2025-08-08T20:39:36.800Z","dependency_job_id":"f3027fb2-07a8-4289-95b1-0ef53527fffc","html_url":"https://github.com/buildplan/rsync-backup-script","commit_stats":null,"previous_names":["buildplan/rsync-backup-script"],"tags_count":35,"template":false,"template_full_name":null,"purl":"pkg:github/buildplan/rsync-backup-script","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/buildplan%2Frsync-backup-script","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/buildplan%2Frsync-backup-script/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/buildplan%2Frsync-backup-script/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/buildplan%2Frsync-backup-script/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/buildplan","download_url":"https://codeload.github.com/buildplan/rsync-backup-script/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/buildplan%2Frsync-backup-script/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":278891768,"owners_count":26063864,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-08T02:00:06.501Z","response_time":56,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["backup","backup-script","bash-script","hetzner-storage-box","rsync-backup"],"created_at":"2025-08-27T05:46:49.228Z","updated_at":"2025-10-08T05:28:29.917Z","avatar_url":"https://github.com/buildplan.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Automated rsync Backup Script\n\nThis script automates backups of local directories to a remote server (such as a Hetzner Storage Box) using `rsync` over SSH.\n\n-----\n\n## Features\n\n- **Unified Configuration**: All settings are in a single `backup.conf` file with secure parsing\n- **Portable Setup**: The backup system can be migrated by copying the script and configuration\n- **Recycle Bin**: Automatically moves deleted files into a versioned recycle bin on the remote server, with a configurable retention period.\n- **Notification Support**: Sends notifications to ntfy and/or Discord, configurable via toggles\n- **Error Handling**: Uses strict shell options and traps to detect and report errors\n- **Detailed Reports**: Notifications include transfer size and file operation summaries\n- **System Friendly**: Uses `nice` and `ionice` to reduce resource impact during backups\n- **Multiple Operation Modes**: Supports dry run, checksum verification, summary reports, verbose output, and file restoration\n- **Concurrency Control**: Prevents simultaneous runs and handles log rotation automatically\n- **Pre-run Validation**: Checks for necessary commands and SSH connectivity before execution\n\n-----\n\n## Usage\n\n#### Download the script and configuration:\n  - These are same files as at the bottom of this README.\n\n    ```sh\n    # 1. Get the script and make it executable\n    wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup_script.sh \u0026\u0026 chmod +x backup_script.sh\n\n    # 2. Get the config file and set secure permissions\n    wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup.conf \u0026\u0026 chmod 600 backup.conf\n    ```\n\n  - A cleaner version of the config file without comments.\n    ```sh\n\twget -O backup.conf https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/clean_backup.conf \u0026\u0026 chmod 600 backup.conf\n    ```\n\n#### Verify script integrity (optional):\n\n  - To ensure the script is authentic, verify its SHA256 checksum.\n\n    ```sh\n    # Download the official checksum file\n    wget https://github.com/buildplan/rsync-backup-script/raw/refs/heads/main/backup_script.sh.sha256\n\n    # Run the check (it should output: backup_script.sh: OK)\n    sha256sum -c backup_script.sh.sha256\n    ```\n\n#### Run modes:\n\n  - `sudo ./backup_script.sh` - Run silently (suitable for cron)\n  - `sudo ./backup_script.sh --verbose` - Run with live progress\n  - `sudo ./backup_script.sh --dry-run` - Preview changes without applying them\n  - `sudo ./backup_script.sh --checksum` - Verify backup integrity\n  - `sudo ./backup_script.sh --summary` - Report file differences\n  - `sudo ./backup_script.sh --test` - Checks to validate `backup.conf`, permissions and SSH connectivity\n  - `sudo ./backup_script.sh --restore` - Interactive restore with dry-run preview and confirmation\n\n\n\u003e *Default log location: `/var/log/backup_rsync.log`*\n\n\n#### Diagnostics \u0026 Error Codes\n\nThe script uses specific exit codes for different pre-flight failures, which can help with debugging automated runs.\n\n  - **Exit Code `1`: Fatal Configuration Error.** A critical issue was found during startup. This can be caused by:\n      - The script not being run as `root`.\n      - The `backup.conf` file being missing or a required variable not being set.\n      - An invalid `RECYCLE_BIN_DIR` setting (e.g., an absolute path).\n      - The script being unable to access or create the recycle bin on the remote server.\n  - **Exit Code `2`: `BACKUP_DIRS` Error.** An issue with a directory listed in `BACKUP_DIRS` (e.g., it doesn't exist, isn't readable, or is missing the `/./` syntax).\n  - **Exit Code `5`: Lock Contention.** Another instance of the script is already running.\n  - **Exit Code `6`: SSH Failure.** The pre-flight check failed to establish an SSH connection to the `BOX_ADDR`.\n  - **Exit Code `7`: Disk Space Error.** Insufficient local disk space for logging.\n  - **Exit Code `10`: Prerequisite Missing.** A required command (like `rsync` or `curl`) is not installed.\n\n-----\n\n## File Structure\n\nAll 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.\n\n```\n/home/user/scripts/backup/\n├── backup_script.sh      (main script)\n└── backup.conf           (settings, credentials, and excludes)\n```\n\n-----\n\n## Setup Instructions\n\nFollow these steps to get the backup system running.\n\n### 1\\. Prerequisites\n\nEnsure the required tools are installed, run:\n\n```sh\n# On Debian/Ubuntu\nsudo apt-get update \u0026\u0026 sudo apt-get install rsync curl coreutils util-linux\n\n# On CentOS/RHEL/Fedora\nsudo dnf install rsync curl coreutils util-linux\n```\n\n*(coreutils provides `numfmt`, `stat`, etc. and util-linux provides `flock` and `mktemp`)*\n\n### 2\\. Passwordless SSH Login\n\nThe script needs to log into the Hetzner Storage Box without a password.\n\n  - **Generate a root user SSH key** on your server if you don't have one (using root will avoid permissions issues):\n\n    ```sh\n    sudo ssh-keygen -t ed25519\n    ```\n\n    (Just press Enter through all the prompts).\n\n  - **Copy your public key** to the Hetzner Storage Box. First, view your public key:\n\n    ```sh\n    sudo cat /root/.ssh/id_ed25519.pub\n    ```\n\n  - Or use the `ssh-copy-id` command (replace `u123456` and `u123456-sub4`):\n\n    ```sh\n    # Hetzner Storage Box requires the `-s` flag. Replace `u123456` and `u123456-sub4`\n    \n    sudo ssh-copy-id -p 23 -s u123456-sub4@u123456.your-storagebox.de\n    ```\n\n  - After public key has been added to your Hetzner Storage Box via the control panel or `ssh-copy-id`. Test the connection (replace `u123456` and `u123456-sub4`):\n\n    ```sh\n    # This should work without asking for a password, and show /home in terminal output.\n    \n    sudo ssh -p 23 u123456-sub4@u123456.your-storagebox.de pwd\n    ```\n\n\n### 3\\. Place and Configure Files (If not downloading with `wget` above)\n\n1.  Create your script directory: `mkdir -p /home/user/scripts/backup \u0026\u0026 cd /home/user/scripts/backup`\n2.  Create the two files (`backup_script.sh` and `backup.conf`) in this directory using the content provided below.\n3.  **Make the script executable**:\n    ```sh\n    chmod +x backup_script.sh\n    ```\n4.  **Set secure permissions** for your configuration file:\n    ```sh\n    chmod 600 backup.conf\n    ```\n5.  Edit `backup.conf` to specify your local paths, remote server details, notification settings, and file exclusions.\n\n### 4\\. Set up a Cron Job\n\nTo run the backup automatically, edit the root crontab.\n\n  - Open the crontab editor:\n\n    ```sh\n    sudo crontab -e\n    ```\n\n  - Add a line to schedule the script. This example runs the backup every day at 3:00 AM.\n\n    ```crontab\n    # Run the rsync backup every day at 3:00 AM\n    0 3 * * * /home/user/scripts/backup/backup_script.sh \u003e /dev/null 2\u003e\u00261\n    ```\n\n    *(Redirecting output to `/dev/null` is fine since the script handles its own logging and notifications).*\n\n    *(Note: `sudo` is not needed here because this command is placed in the root user's crontab via `sudo crontab -e`, so it already runs with root privileges.)*\n\n-----\n\n-----\n\n## The Files\n\n### **`backup.conf`**\n\n```ini\n# =================================================================\n#         Configuration for rsync Backup Script v0.36\n# =================================================================\n# !! IMPORTANT !! Set file permissions to 600 (chmod 600 backup.conf)\n\n# --- Source and Destination ---\n# List all source directories to back up, separated by spaces.\n#\n# IMPORTANT: Follow these two rules for each path:\n# 1. End the path with a trailing slash (e.g., \"user/\").\n# 2. Use \"/./\" to mark the part of the path you want to create on the destination.\n#    Example: \"/home/./user/\" will create a \"user\" directory in your BOX_DIR.\nBACKUP_DIRS=\"/./home/user/ /./var/log/ /./etc/nginx/\"\n\n# The root folder on the remote server where backups will be placed.\n# IMPORTANT: This path MUST end with a trailing slash (e.g., /backups/).\nBOX_DIR=\"/home/myvps/\"\n\n# --- Connection Details ---\n# The SSH address of your remote backup server (e.g., user@host).\nBOX_ADDR=\"u123456-sub4@u123456.your-storagebox.de\"\n\n# Add each SSH option on a new line.\n# For options taking a value, see the rules below.\nBEGIN_SSH_OPTS\n# Options with simple values (like a port) can be combined.\n-p23\n\n# Options that take a file path (like an identity key) MUST be on separate lines.\n-i\n/root/.ssh/id_ed25519\nEND_SSH_OPTS\n\n# --- Performance ---\n# Use --noatime for a potential performance boost.\n# This requires rsync v3.3.0+ on BOTH the local and remote server.\n# If your remote server (e.g., a storage box) has an older rsync, keep this to false.\nRSYNC_NOATIME_ENABLED=false\n\n# The timeout in seconds for rsync operations.\nRSYNC_TIMEOUT=300\n\n# Optional: Limit rsync's bandwidth usage in KiB/s. Leave empty or set to 0 to disable.\n# Example: BANDWIDTH_LIMIT_KBPS=5000  (for 5 MB/s)\nBANDWIDTH_LIMIT_KBPS=\"\"\n\n# --- Integrity Check ---\n# Set to true to enable slow but thorough checksum-based integrity checks.\n# Default is false for fast checks (based on file size and modification time).\nCHECKSUM_ENABLED=false\n\n# --- Logging ---\nLOG_FILE=\"/var/log/backup_rsync.log\"\n# Max log size in Megabytes (MB) before the script rotates it.\nMAX_LOG_SIZE_MB=10\n# Delete rotated logs older than this many days.\nLOG_RETENTION_DAYS=90\n\n# --- Recycle Bin ---\n# If enabled, files deleted from the source will be moved to a remote\n# recycle bin instead of being permanently removed. This provides a safety net.\nRECYCLE_BIN_ENABLED=true\n# The name of the directory on the remote server to use as the recycle bin.\nRECYCLE_BIN_DIR=\"recycle_bin\"\n# The number of days to keep daily backup folders in the recycle bin before deleting them.\nRECYCLE_BIN_RETENTION_DAYS=30\n\n# --- Notification Toggles ---\n# Set to 'true' to enable, 'false' to disable.\nNTFY_ENABLED=true\nDISCORD_ENABLED=false\n\n# --- ntfy Credentials ---\nNTFY_TOKEN=\"tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\nNTFY_URL=\"https://ntfy.sh/your-private-topic-name\"\n\n# --- ntfy Notification Priorities ---\n# 5=max/urgent, 4=high, 3=default, 2=low, 1=min\nNTFY_PRIORITY_SUCCESS=3\nNTFY_PRIORITY_WARNING=4\nNTFY_PRIORITY_FAILURE=4\n\n# --- Discord Credentials ---\nDISCORD_WEBHOOK_URL=\"https://discord.com/api/webhooks/your/webhook_url_here\"\n\n# --- rsync Exclusions ---\n# List all file/directory patterns to exclude below.\n# The script will read everything between the BEGIN and END markers.\nBEGIN_EXCLUDES\n# Common cache,logs and temporary files\n.cache/\n/tmp/\n*.tmp\n*.bak\n*.swp\n*.log\n\n# Specific application caches/dependencies\n/node_modules/\n/vendor/\n__pycache__/\n\n# System files that shouldn't be backed up\n/lost+found/\n.DS_Store\nThumbs.db\n\n# Some common files which can be skipped\n.bash_history\n.bash_logout\n.bashrc\n.config/\n.docker/\n.local/\n.profile\n.ssh/\n.sudo_as_admin_successful\n.wget-hsts\nEND_EXCLUDES\n```\n\n-----\n\n### **`backup_script.sh`**\n\n```bash\n#!/bin/bash\n# ===================== v0.36 - 2025.08.29 ========================\n#\n# =================================================================\n#                 SCRIPT INITIALIZATION \u0026 SETUP\n# =================================================================\nset -Euo pipefail\numask 077\n\nHOSTNAME=$(hostname -s)\n\n# --- Color Palette ---\nif [ -t 1 ]; then\n    C_RESET='\\e[0m'\n    C_BOLD='\\e[1m'\n    C_DIM='\\e[2m'\n    C_RED='\\e[0;31m'\n    C_GREEN='\\e[0;32m'\n    C_YELLOW='\\e[0;33m'\n    C_CYAN='\\e[0;36m'\nelse\n    C_RESET=''\n    C_BOLD=''\n    C_DIM=''\n    C_RED=''\n    C_GREEN=''\n    C_YELLOW=''\n    C_CYAN=''\nfi\n\n# Re-run the script with sudo if not already root\nif [[ $EUID -ne 0 ]]; then\n   echo -e \"${C_BOLD}${C_YELLOW}This script requires root privileges to function correctly.${C_RESET}\"\n   echo -e \"${C_YELLOW}Attempting to re-run with sudo. You may be prompted for your password.${C_RESET}\"\n   echo \"----------------------------------------------------------------\"\n   exec sudo \"$0\" \"$@\"\nfi\n\n# --- Determine script's location to load the config file ---\nSCRIPT_DIR=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" \u0026\u003e/dev/null \u0026\u0026 pwd)\nCONFIG_FILE=\"${SCRIPT_DIR}/backup.conf\"\n\n# --- Create a temporary file for rsync exclusions ---\nEXCLUDE_FILE_TMP=$(mktemp)\nSSH_OPTS_ARRAY=()\n\n# --- Securely parse the unified configuration file ---\nif [ -f \"$CONFIG_FILE\" ]; then\n    in_exclude_block=false\n    in_ssh_opts_block=false\n    while IFS= read -r line; do\n        # --- Handle block markers ---\n        if [[ \"$line\" == \"BEGIN_EXCLUDES\" ]]; then in_exclude_block=true; continue; fi\n        if [[ \"$line\" == \"END_EXCLUDES\" ]]; then in_exclude_block=false; continue; fi\n        if [[ \"$line\" == \"BEGIN_SSH_OPTS\" ]]; then in_ssh_opts_block=true; continue; fi\n        if [[ \"$line\" == \"END_SSH_OPTS\" ]]; then in_ssh_opts_block=false; continue; fi\n\n        # --- Process lines within blocks ---\n        if [[ \"$in_exclude_block\" == \"true\" ]]; then\n            [[ ! \"$line\" =~ ^([[:space:]]*#|[[:space:]]*$) ]] \u0026\u0026 echo \"$line\" \u003e\u003e \"$EXCLUDE_FILE_TMP\"\n            continue\n        fi\n        if [[ \"$in_ssh_opts_block\" == \"true\" ]]; then\n            [[ ! \"$line\" =~ ^([[:space:]]*#|[[:space:]]*$) ]] \u0026\u0026 SSH_OPTS_ARRAY+=(\"$line\")\n            continue\n        fi\n\n        # --- Process key-value pairs ---\n        if [[ \"$line\" =~ ^[[:space:]]*([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*) ]]; then\n            key=\"${BASH_REMATCH[1]}\"; value=\"${BASH_REMATCH[2]}\"\n            value=\"${value%\\\"}\"; value=\"${value#\\\"}\"\n\n            case \"$key\" in\n                BACKUP_DIRS|BOX_DIR|BOX_ADDR|LOG_FILE|LOG_RETENTION_DAYS|CHECKSUM_ENABLED|\\\n                MAX_LOG_SIZE_MB|BANDWIDTH_LIMIT_KBPS|RSYNC_TIMEOUT|RSYNC_NOATIME_ENABLED|\\\n                NTFY_ENABLED|DISCORD_ENABLED|NTFY_TOKEN|NTFY_URL|DISCORD_WEBHOOK_URL|\\\n                NTFY_PRIORITY_SUCCESS|NTFY_PRIORITY_WARNING|NTFY_PRIORITY_FAILURE|\\\n                RECYCLE_BIN_ENABLED|RECYCLE_BIN_DIR|RECYCLE_BIN_RETENTION_DAYS)\n                    declare \"$key\"=\"$value\"\n                    ;;\n                *)\n                    echo \"WARNING: Unknown config variable '$key' ignored in $CONFIG_FILE\" \u003e\u00262\n                    ;;\n            esac\n        fi\n    done \u003c \"$CONFIG_FILE\"\nelse\n    echo \"FATAL: Unified configuration file backup.conf not found.\" \u003e\u00262; exit 1\nfi\n\n# --- Validate that all required configuration variables are set ---\nfor var in BACKUP_DIRS BOX_DIR BOX_ADDR LOG_FILE \\\n           NTFY_PRIORITY_SUCCESS NTFY_PRIORITY_WARNING NTFY_PRIORITY_FAILURE \\\n           LOG_RETENTION_DAYS; do\n    if [ -z \"${!var:-}\" ]; then\n        echo \"FATAL: Required config variable '$var' is missing or empty in $CONFIG_FILE.\" \u003e\u00262\n        exit 1\n    fi\ndone\nif [[ \"$BOX_DIR\" != */ ]]; then\n    echo \"❌ FATAL: BOX_DIR must end with a trailing slash (/). Please check backup.conf.\" \u003e\u00262\n    exit 2\nfi\nif [[ \"${RECYCLE_BIN_ENABLED:-false}\" == \"true\" ]]; then\n    for var in RECYCLE_BIN_DIR RECYCLE_BIN_RETENTION_DAYS; do\n        if [ -z \"${!var:-}\" ]; then\n            echo \"FATAL: When RECYCLE_BIN_ENABLED is true, '$var' must be set in $CONFIG_FILE.\" \u003e\u00262\n            exit 1\n        fi\n    done\n    if [[ \"${RECYCLE_BIN_DIR}\" == /* ]]; then\n        echo \"❌ FATAL: RECYCLE_BIN_DIR must be a relative path, not absolute: '${RECYCLE_BIN_DIR}'\" \u003e\u00262\n        exit 1\n    fi\n    if [[ \"$RECYCLE_BIN_DIR\" == *\"../\"* ]]; then\n        echo \"❌ FATAL: RECYCLE_BIN_DIR cannot contain '../'\" \u003e\u00262\n        exit 1\n    fi\nfi\n\n# =================================================================\n#               SCRIPT CONFIGURATION (STATIC)\n# =================================================================\n\nREMOTE_TARGET=\"${BOX_ADDR}:${BOX_DIR}\"\nLOCK_FILE=\"/tmp/backup_rsync.lock\"\n\nSSH_CMD=\"ssh\"\nif (( ${#SSH_OPTS_ARRAY[@]} \u003e 0 )); then\n    SSH_CMD+=$(printf \" %q\" \"${SSH_OPTS_ARRAY[@]}\")\nfi\n\nRSYNC_BASE_OPTS=(\n    -aR -z --delete --partial --timeout=\"${RSYNC_TIMEOUT:-300}\" --mkpath\n    --exclude-from=\"$EXCLUDE_FILE_TMP\"\n    -e \"$SSH_CMD\"\n)\n\nif [[ \"${RSYNC_NOATIME_ENABLED:-false}\" == \"true\" ]]; then\n    RSYNC_BASE_OPTS+=(--noatime)\nfi\n\n# Optional: Add bandwidth limit if configured\nif [[ -n \"${BANDWIDTH_LIMIT_KBPS:-}\" \u0026\u0026 \"${BANDWIDTH_LIMIT_KBPS}\" -gt 0 ]]; then\n    RSYNC_BASE_OPTS+=(--bwlimit=\"$BANDWIDTH_LIMIT_KBPS\")\nfi\n\n# Shared options for direct, non-interactive SSH commands\nSSH_DIRECT_OPTS=(\n    -o StrictHostKeyChecking=no\n    -o BatchMode=yes\n    -o ConnectTimeout=30\n    -n\n)\n\n# =================================================================\n#                       HELPER FUNCTIONS\n# =================================================================\n\nlog_message() {\n    local message=\"$1\"\n    echo \"[$HOSTNAME] [$(date '+%Y-%m-%d %H:%M:%S')] $message\" \u003e\u003e \"${LOG_FILE:-/dev/null}\"\n    if [[ \"${VERBOSE_MODE:-false}\" == \"true\" ]]; then\n        echo \"$message\"\n    fi\n}\nsend_ntfy() {\n    local title=\"$1\" tags=\"$2\" priority=\"$3\" message=\"$4\"\n    if [[ \"${NTFY_ENABLED:-false}\" != \"true\" ]] || [ -z \"${NTFY_TOKEN:-}\" ] || [ -z \"${NTFY_URL:-}\" ]; then return; fi\n    curl -s --max-time 15 -u \":$NTFY_TOKEN\" -H \"Title: $title\" -H \"Tags: $tags\" -H \"Priority: $priority\" -d \"$message\" \"$NTFY_URL\" \u003e /dev/null 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"\n}\nsend_discord() {\n    local title=\"$1\" status=\"$2\" message=\"$3\"\n    if [[ \"${DISCORD_ENABLED:-false}\" != \"true\" ]] || [ -z \"${DISCORD_WEBHOOK_URL:-}\" ]; then return; fi\n    local color; case \"$status\" in\n        success) color=3066993 ;; warning) color=16776960 ;; failure) color=15158332 ;; *) color=9807270 ;;\n    esac\n    local escaped_title; escaped_title=$(echo \"$title\" | sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g')\n    local escaped_message; escaped_message=$(echo \"$message\" | sed 's/\\\\/\\\\\\\\/g' | sed 's/\"/\\\\\"/g' | sed ':a;N;$!ba;s/\\n/\\\\n/g')\n    local json_payload; printf -v json_payload '{\"embeds\": [{\"title\": \"%s\", \"description\": \"%s\", \"color\": %d, \"timestamp\": \"%s\"}]}' \\\n        \"$escaped_title\" \"$escaped_message\" \"$color\" \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"\n    curl -s --max-time 15 -H \"Content-Type: application/json\" -d \"$json_payload\" \"$DISCORD_WEBHOOK_URL\" \u003e /dev/null 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"\n}\nsend_notification() {\n    local title=\"$1\" tags=\"$2\" ntfy_priority=\"$3\" discord_status=\"$4\" message=\"$5\"\n    send_ntfy \"$title\" \"$tags\" \"$ntfy_priority\" \"$message\"\n    send_discord \"$title\" \"$discord_status\" \"$message\"\n}\nrun_integrity_check() {\n    local rsync_check_opts=(-aincR --delete --mkpath --exclude-from=\"$EXCLUDE_FILE_TMP\" --out-format=\"%n\" -e \"$SSH_CMD\")\n    if [[ \"${CHECKSUM_ENABLED:-false}\" == \"true\" ]]; then\n        rsync_check_opts+=(-c)\n    fi\n    local DIRS_ARRAY; read -ra DIRS_ARRAY \u003c\u003c\u003c \"$BACKUP_DIRS\"\n    for dir in \"${DIRS_ARRAY[@]}\"; do\n        echo \"--- Integrity Check: $dir ---\" \u003e\u00262\n\tlocal relative_path=\"${dir#*./}\"\n\tLC_ALL=C rsync \"${rsync_check_opts[@]}\" \"$dir\" \"${REMOTE_TARGET}${relative_path}\" 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"\n    done\n}\nparse_stat() {\n    local output=\"$1\" pattern=\"$2\" awk_command=\"$3\"\n    ( set +o pipefail; echo \"$output\" | grep \"$pattern\" | awk \"$awk_command\" )\n}\nformat_backup_stats() {\n    local rsync_output=\"$1\"\n    local files_transferred=$(parse_stat \"$rsync_output\" 'Number of regular files transferred:' '{s+=$2} END {print s}')\n    local bytes_transferred=$(parse_stat \"$rsync_output\" 'Total_transferred_size:' '{s+=$2} END {print s}')\n    local files_created=$(parse_stat \"$rsync_output\" 'Number_of_created_files:' '{s+=$2} END {print s}')\n    local files_deleted=$(parse_stat \"$rsync_output\" 'Number_of_deleted_files:' '{s+=$2} END {print s}')\n    if [[ -z \"$bytes_transferred\" \u0026\u0026 -z \"$files_created\" \u0026\u0026 -z \"$files_deleted\" ]]; then\n        files_transferred=$(parse_stat \"$rsync_output\" 'Number of files transferred:' '{gsub(/,/, \"\"); s+=$4} END {print s}')\n        bytes_transferred=$(parse_stat \"$rsync_output\" 'Total transferred file size:' '{gsub(/,/, \"\"); s+=$5} END {print s}')\n        files_created=$(parse_stat \"$rsync_output\" 'Number of created files:' '{s+=$5} END {print s}')\n        files_deleted=$(parse_stat \"$rsync_output\" 'Number of deleted files:' '{s+=$5} END {print s}')\n    fi\n    if [[ -z \"$bytes_transferred\" \u0026\u0026 -z \"$files_transferred\" ]]; then\n        log_message \"WARNING: Unable to parse rsync stats. Output format may be incompatible.\"\n        printf \"Data Transferred: Unknown\\nFiles Updated: Unknown\\nFiles Created: Unknown\\nFiles Deleted: Unknown\\n\"\n        return 0\n    fi\n    local files_updated=$(( ${files_transferred:-0} - ${files_created:-0} ))\n    if (( files_updated \u003c 0 )); then files_updated=0; fi\n    local stats_summary=\"\"\n    if [[ \"${bytes_transferred:-0}\" -gt 0 ]]; then\n        stats_summary=$(printf \"Data Transferred: %s\" \"$(numfmt --to=iec-i --suffix=B --format=\"%.2f\" \"$bytes_transferred\")\")\n    else\n        stats_summary=\"Data Transferred: 0 B (No changes)\"\n    fi\n    stats_summary+=$(printf \"\\nFiles Updated: %s\\nFiles Created: %s\\nFiles Deleted: %s\" \"${files_updated:-0}\" \"${files_created:-0}\" \"${files_deleted:-0}\")\n    printf \"%s\\n\" \"$stats_summary\"\n}\ncleanup() {\n    rm -f \"${EXCLUDE_FILE_TMP:-}\" \"${RSYNC_LOG_TMP:-}\"\n}\nrun_preflight_checks() {\n    local mode=${1:-backup}; local test_mode=false\n    if [[ \"$mode\" == \"test\" ]]; then test_mode=true; fi\n    local check_failed=false\n    if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_BOLD}--- Checking required commands...${C_RESET}\\n\"; fi\n    for cmd in \"${REQUIRED_CMDS[@]}\"; do\n        if ! command -v \"$cmd\" \u0026\u003e/dev/null; then echo \"❌ FATAL: Required command '$cmd' not found.\" \u003e\u00262; check_failed=true; fi\n    done\n    if [[ \"$check_failed\" == \"true\" ]]; then exit 10; fi\n    if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_GREEN}✅ All required commands are present.${C_RESET}\\n\"; fi\n    # Check rsync version for --noatime compatibility if the feature is enabled\n    if [[ \"${RSYNC_NOATIME_ENABLED:-false}\" == \"true\" ]]; then\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_BOLD}--- Checking rsync version for --noatime...${C_RESET}\\n\"; fi\n        local rsync_version\n        rsync_version=$(rsync --version | head -n1 | awk '{print $3}')\n        local major minor\n        IFS='.' read -r major minor _ \u003c\u003c\u003c \"$rsync_version\"\n        if ! (( major \u003e 3 || (major == 3 \u0026\u0026 minor \u003e= 3) )); then\n            printf \"${C_RED}❌ FATAL: RSYNC_NOATIME_ENABLED is true but rsync version %s is too old.${C_RESET}\\n\" \"$rsync_version\" \u003e\u00262\n            printf \"${C_DIM}   The --noatime option requires rsync version 3.3.0 or newer.${C_RESET}\\n\" \u003e\u00262\n            exit 10\n        fi\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_GREEN}✅ rsync version %s supports --noatime.${C_RESET}\\n\" \"$rsync_version\"; fi\n    fi\n    if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_BOLD}--- Checking SSH connectivity...${C_RESET}\\n\"; fi\n    # Quick preflight connectivity \"ping\": short 10s timeout for fail-fast behaviour\n    if ! ssh \"${SSH_OPTS_ARRAY[@]}\" -o BatchMode=yes -o ConnectTimeout=10 \"$BOX_ADDR\" 'exit' 2\u003e/dev/null; then\n        local err_msg=\"Unable to SSH into $BOX_ADDR. Check keys and connectivity.\"\n        if [[ \"$test_mode\" == \"true\" ]]; then echo \"❌ $err_msg\"; else send_notification \"SSH FAILED: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"$err_msg\"; fi; exit 6\n    fi\n    if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_GREEN}✅ SSH connectivity OK.${C_RESET}\\n\"; fi\n    if [[ \"${RECYCLE_BIN_ENABLED:-false}\" == \"true\" ]]; then\n        local remote_recycle_path=\"${BOX_DIR}${RECYCLE_BIN_DIR}\"\n        if ! ssh \"${SSH_OPTS_ARRAY[@]}\" -o BatchMode=yes -o ConnectTimeout=10 \"$BOX_ADDR\" \"ls -d \\\"$remote_recycle_path\\\"\" \u003e/dev/null 2\u003e\u00261; then\n            if ! ssh \"${SSH_OPTS_ARRAY[@]}\" -o BatchMode=yes -o ConnectTimeout=10 \"$BOX_ADDR\" \"mkdir -p \\\"$remote_recycle_path\\\"\" \u003e/dev/null 2\u003e\u00261; then\n                echo \"❌ FATAL: Cannot access or create recycle bin directory '$remote_recycle_path' on remote.\" \u003e\u00262\n                exit 1\n            fi\n        fi\n    fi\n    if [[ \"$mode\" != \"restore\" ]]; then\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_BOLD}--- Checking backup directories...${C_RESET}\\n\"; fi\n        local DIRS_ARRAY; read -ra DIRS_ARRAY \u003c\u003c\u003c \"$BACKUP_DIRS\"\n        for dir in \"${DIRS_ARRAY[@]}\"; do\n            if [[ ! -d \"$dir\" ]] || [[ \"$dir\" != */ ]]; then\n                local err_msg=\"A directory in BACKUP_DIRS ('$dir') must exist and end with a trailing slash ('/').\"\n                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\n            fi\n            if [[ \"$dir\" != *\"/./\"* ]]; then\n                local err_msg=\"Directory '$dir' in BACKUP_DIRS is missing the required '/./' syntax.\"\n                if [[ \"$test_mode\" == \"true\" ]]; then\n                    echo \"❌ FATAL: $err_msg\"\n                else\n                    send_notification \"❌ Backup FAILED: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"FATAL: $err_msg\"\n                fi\n                exit 2\n            fi\n            if [[ ! -r \"$dir\" ]]; then\n                local err_msg=\"A directory in BACKUP_DIRS ('$dir') is not readable.\"\n                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\n            fi\n        done\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_GREEN}✅ All backup directories are valid.${C_RESET}\\n\"; fi\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_BOLD}--- Checking local disk space...${C_RESET}\\n\"; fi\n        local required_space_kb=102400\n        local available_space_kb\n        available_space_kb=$(df --output=avail \"$(dirname \"${LOG_FILE}\")\" | tail -n1)\n        if [[ \"$available_space_kb\" -lt \"$required_space_kb\" ]]; then\n            local err_msg=\"Insufficient disk space in $(dirname \"${LOG_FILE}\") to guarantee logging. ($((available_space_kb / 1024))MB available)\"\n            if [[ \"$test_mode\" == \"true\" ]]; then echo \"❌ FATAL: $err_msg\"; else send_notification \"❌ Backup FAILED: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"FATAL: $err_msg\"; fi\n            exit 7\n        fi\n        if [[ \"$test_mode\" == \"true\" ]]; then printf \"${C_GREEN}✅ Local disk space OK.${C_RESET}\\n\"; fi\n    fi\n}\nprint_header() {\n    printf \"\\n%b--- %s ---%b\\n\" \"${C_BOLD}\" \"$1\" \"${C_RESET}\"\n}\nrun_restore_mode() {\n    print_header \"RESTORE MODE ACTIVATED\"\n    run_preflight_checks \"restore\"\n    local DIRS_ARRAY; read -ra DIRS_ARRAY \u003c\u003c\u003c \"$BACKUP_DIRS\"\n    local RECYCLE_OPTION=\"[ Restore from Recycle Bin ]\"\n    local all_options=(\"${DIRS_ARRAY[@]}\")\n    if [[ \"${RECYCLE_BIN_ENABLED:-false}\" == \"true\" ]]; then\n        all_options+=(\"$RECYCLE_OPTION\")\n    fi\n    all_options+=(\"Cancel\")\n    printf \"${C_YELLOW}Available backup sets to restore from:${C_RESET}\\n\"\n    PS3=\"Your choice: \"\n    select dir_choice in \"${all_options[@]}\"; do\n        if [[ -n \"$dir_choice\" ]]; then break;\n        else echo \"Invalid selection. Please try again.\"; fi\n    done\n    PS3=\"#? \"\n    local paths_to_process=()\n    local source_base_remote=\"\"\n    local source_base_local_prefix=\"\"\n    local source_display_name=\"\"\n    local is_full_directory_restore=false\n    if [[ \"$dir_choice\" == \"$RECYCLE_OPTION\" ]]; then\n        print_header \"Browse Recycle Bin\"\n        local date_folders=()\n        local remote_recycle_path=\"${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}\"\n        mapfile -t date_folders \u003c \u003c(ssh \"${SSH_OPTS_ARRAY[@]}\" \"${SSH_DIRECT_OPTS[@]}\" \"$BOX_ADDR\" \"ls -1 \\\"$remote_recycle_path\\\"\" 2\u003e/dev/null | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}$')\n        if [[ ${#date_folders[@]} -eq 0 ]]; then\n            printf \"${C_YELLOW}❌ The remote recycle bin is empty or contains no valid backup folders.${C_RESET}\\n\"\n            return 1\n        fi\n        printf \"${C_YELLOW}Select a backup run (date_time) to browse:${C_RESET}\\n\"\n        PS3=\"Your choice: \"\n        select date_choice in \"${date_folders[@]}\" \"Cancel\"; do\n            if [[ \"$date_choice\" == \"Cancel\" ]]; then echo \"Restore cancelled.\"; return 0;\n            elif [[ -n \"$date_choice\" ]]; then break;\n            else echo \"Invalid selection. Please try again.\"; fi\n        done\n        PS3=\"#? \"\n        local remote_date_path=\"${remote_recycle_path}/${date_choice}\"\n        print_header \"Files available from ${date_choice} (showing first 20)\"\n        rsync -r -n --out-format='%n' -e \"$SSH_CMD\" \"${BOX_ADDR}:${remote_date_path}/\" . 2\u003e/dev/null | head -n 20 || echo \"No files found for this date.\"\n        printf \"%b--------------------------------------------------------%b\\n\" \"${C_BOLD}\" \"${C_RESET}\"\n        printf -v path_prompt \"Enter original path(s) to restore (e.g., home/user/file.txt), space-separated: \"\n        read -erp \"$(printf '%b%s%b' \"${C_YELLOW}\" \"$path_prompt\" \"${C_RESET}\")\" -a paths_to_process\n        if [[ ${#paths_to_process[@]} -eq 0 ]]; then echo \"❌ Path cannot be empty. Aborting.\"; return 1; fi\n        source_base_remote=\"${BOX_ADDR}:${remote_date_path}\"\n        source_base_local_prefix=\"/\"\n        source_display_name=\"(from Recycle Bin, ${date_choice})\"\n    elif [[ \"$dir_choice\" == \"Cancel\" ]]; then\n        echo \"Restore cancelled.\"; return 0\n    else \n        while true; do\n            printf \"\\n${C_YELLOW}Restore the entire directory or a specific file/subfolder? [entire/specific]: ${C_RESET}\"; read -r choice\n            case \"$choice\" in\n                entire) is_full_directory_restore=true; paths_to_process+=(\"\"); break ;;\n                specific)\n                    local relative_path_browse=\"${dir_choice#*./}\"\n                    local remote_browse_source=\"${REMOTE_TARGET}${relative_path_browse}\"\n                    print_header \"Files available in ${dir_choice} (showing first 20)\"\n                    rsync -r -n --out-format='%n' -e \"$SSH_CMD\" \"$remote_browse_source\" . 2\u003e/dev/null | head -n 20 || echo \"No files found for this backup set.\"\n                    printf \"%b--------------------------------------------------------%b\\n\" \"${C_BOLD}\" \"${C_RESET}\"\n                    printf -v path_prompt \"Enter path(s) relative to '%s' to restore (space-separated, quote if spaces): \" \"$dir_choice\"\n                    read -erp \"$(printf '%b%s%b' \"${C_YELLOW}\" \"$path_prompt\" \"${C_RESET}\")\" -a paths_to_process\n                    if [[ ${#paths_to_process[@]} -eq 0 ]]; then\n                        echo \"Path cannot be empty. Please try again or choose 'entire'.\"\n                        continue\n                    fi\n                    break ;;\n                *) echo \"Invalid choice. Please answer 'entire' or 'specific'.\" ;;\n            esac\n        done\n        local relative_path=\"${dir_choice#*./}\"\n        source_base_remote=\"${REMOTE_TARGET}${relative_path}\"\n        source_base_local_prefix=$(echo \"$dir_choice\" | sed 's#/\\./#/#g')\n        source_display_name=\"'${dir_choice}'\"\n    fi\n    local successful_count=0\n    local failed_count=0\n    local total_items=${#paths_to_process[@]}\n\n    for restore_path in \"${paths_to_process[@]}\"; do\n        if [[ \"$restore_path\" == /* || \"$restore_path\" =~ (^|/)\\.\\.(/|$) ]]; then\n            echo \"❌ Invalid restore path: '${restore_path}' must be relative and contain no '..'. Skipping.\" \u003e\u00262;\n            ((failed_count++))\n            continue\n        fi\n        restore_path=$(echo \"$restore_path\" | sed 's#^/##')\n        local item_for_display full_remote_source default_local_dest\n        if [[ -n \"$restore_path\" ]]; then\n            item_for_display=\"'${restore_path%/}' from ${source_display_name}\"\n            full_remote_source=\"${source_base_remote%/}/${restore_path%/}\"\n            default_local_dest=\"${source_base_local_prefix%/}/${restore_path%/}\"\n        else \n            item_for_display=\"the entire directory ${source_display_name}\"\n            full_remote_source=\"$source_base_remote\"\n            default_local_dest=\"$source_base_local_prefix\"\n        fi\n        local final_dest\n        print_header \"Restore Destination for ${item_for_display}\"\n        printf \"Enter the absolute destination path for the restore.\\n\\n\"\n        printf \"%bDefault (original location):%b\\n\" \"${C_YELLOW}\" \"${C_RESET}\"\n        printf \"%b%s%b\\n\\n\" \"${C_CYAN}\" \"$default_local_dest\" \"${C_RESET}\"\n        printf \"Press [Enter] to use the default path, or enter a new one.\\n\"\n        read -rp \"\u003e \" final_dest\n        : \"${final_dest:=$default_local_dest}\"\n        local path_validation_attempts=0\n        local max_attempts=5\n        while true; do\n            ((path_validation_attempts++))\n            if (( path_validation_attempts \u003e max_attempts )); then\n                printf \"\\n${C_RED}❌ Too many invalid attempts. Skipping restore for this item.${C_RESET}\\n\"\n                ((failed_count++))\n                continue 2\n            fi\n            if [[ \"$final_dest\" != \"/\" ]]; then final_dest=\"${final_dest%/}\"; fi\n            local parent_dir; parent_dir=$(dirname -- \"$final_dest\")\n            if [[ \"$final_dest\" != /* ]]; then\n                printf \"\\n${C_RED}❌ Error: Please provide an absolute path (starting with '/').${C_RESET}\\n\"\n            elif [[ -e \"$final_dest\" \u0026\u0026 ! -d \"$final_dest\" ]]; then\n                printf \"\\n${C_RED}❌ Error: The destination '%s' exists but is a file. Please choose a different path.${C_RESET}\\n\" \"$final_dest\"\n            elif [[ -e \"$parent_dir\" \u0026\u0026 ! -w \"$parent_dir\" ]]; then\n                printf \"\\n${C_RED}❌ Error: The parent directory '%s' exists but is not writable.${C_RESET}\\n\" \"$parent_dir\"\n            elif [[ -d \"$final_dest\" ]]; then\n                printf \"${C_GREEN}✅ Destination '%s' exists and is accessible.${C_RESET}\\n\" \"$final_dest\"\n                if [[ \"$final_dest\" != \"$default_local_dest\" \u0026\u0026 -z \"$restore_path\" ]]; then\n                     local warning_msg=\"⚠️  WARNING: Custom destination directory already exists. Files may be overwritten.\"\n                     printf \"${C_YELLOW}%s${C_RESET}\\n\" \"$warning_msg\"; log_message \"$warning_msg\"\n                fi\n                break\n            else\n                printf \"\\n${C_YELLOW}⚠️  The destination '%s' does not exist.${C_RESET}\\n\" \"$final_dest\"\n                printf \"${C_YELLOW}Choose an action:${C_RESET}\\n\"\n                PS3=\"Your choice: \"\n                select action in \"Create the destination path\" \"Enter a different path\" \"Cancel\"; do\n                    case \"$action\" in\n                        \"Create the destination path\")\n                            if mkdir -p \"$final_dest\"; then\n                                 printf \"${C_GREEN}✅ Successfully created directory '%s'.${C_RESET}\\n\" \"$final_dest\"\n                                 if [[ \"${is_full_directory_restore:-false}\" == \"true\" ]]; then\n                                    chmod 700 \"$final_dest\"; log_message \"Set permissions to 700 on newly created restore directory: $final_dest\"\n                                 else\n                                    chmod 755 \"$final_dest\"\n                                 fi\n                                 break 2\n                            else\n                                 printf \"\\n${C_RED}❌ Failed to create directory '%s'. Check permissions.${C_RESET}\\n\"; break\n                            fi ;;\n                        \"Enter a different path\") break ;;\n                        \"Cancel\") \n                            echo \"Restore cancelled for this item.\"\n                            ((failed_count++))\n                            continue 2 ;;\n                        *) echo \"Invalid option. Please try again.\" ;;\n                    esac\n                done\n                PS3=\"#? \"\n            fi\n            if (( path_validation_attempts \u003c max_attempts )); then\n                printf \"\\n${C_YELLOW}Please enter a new destination path: ${C_RESET}\"; read -r final_dest\n                if [[ -z \"$final_dest\" ]]; then\n                    final_dest=\"$default_local_dest\"; printf \"${C_DIM}Empty input, using default location: %s${C_RESET}\\n\" \"$final_dest\"\n                fi\n            fi\n        done\n        local extra_rsync_opts=()\n        local dest_user=\"\"\n        if [[ \"$final_dest\" == /home/* ]]; then\n            dest_user=$(echo \"$final_dest\" | cut -d/ -f3)\n            if [[ -n \"$dest_user\" ]] \u0026\u0026 id -u \"$dest_user\" \u0026\u003e/dev/null; then\n                printf \"${C_CYAN}ℹ️  Home directory detected. Restored files will be owned by '${dest_user}'.${C_RESET}\\n\"\n                extra_rsync_opts+=(\"--chown=${dest_user}:${dest_user}\")\n                chown \"${dest_user}:${dest_user}\" \"$final_dest\" 2\u003e/dev/null || true\n            else\n                dest_user=\"\"\n            fi\n        fi\n        print_header \"Restore Summary\"\n        printf \"  Source:      %s\\n\" \"$item_for_display\"\n        printf \"  Destination: %b%s%b\\n\" \"${C_BOLD}\" \"$final_dest\" \"${C_RESET}\"\n        print_header \"PERFORMING DRY RUN (NO CHANGES MADE)\"\n        log_message \"Starting restore dry-run of ${item_for_display} from ${full_remote_source} to ${final_dest}\"\n        local rsync_restore_opts=(-avhi --safe-links --progress --exclude-from=\"$EXCLUDE_FILE_TMP\" -e \"$SSH_CMD\")\n        if ! rsync \"${rsync_restore_opts[@]}\" \"${extra_rsync_opts[@]}\" --dry-run \"$full_remote_source\" \"$final_dest\"; then\n            printf \"${C_RED}❌ DRY RUN FAILED. Rsync reported an error. Skipping item.${C_RESET}\\n\" \u003e\u00262\n            log_message \"Restore dry-run failed for ${item_for_display}\"\n            ((failed_count++))\n            continue\n        fi\n        print_header \"DRY RUN COMPLETE\"\n        while true; do\n            printf \"\\n${C_YELLOW}Proceed with restoring %s to '%s'? [yes/no]: ${C_RESET}\" \"$item_for_display\" \"$final_dest\"; read -r confirmation\n            case \"${confirmation,,}\" in\n                yes|y) break ;;\n                no|n) \n                    echo \"Restore cancelled by user for this item.\"\n                    ((failed_count++))\n                    continue 2 ;;\n                *) echo \"Please answer 'yes' or 'no'.\" ;;\n            esac\n        done\n        print_header \"EXECUTING RESTORE\"\n        log_message \"Starting actual restore of ${item_for_display} from ${full_remote_source} to ${final_dest}\"\n        if rsync \"${rsync_restore_opts[@]}\" \"${extra_rsync_opts[@]}\" \"$full_remote_source\" \"$final_dest\"; then\n            log_message \"Restore completed successfully.\"\n            printf \"${C_GREEN}✅ Restore of %s to '%s' completed successfully.${C_RESET}\\n\\n\" \"$item_for_display\" \"$final_dest\"\n            send_notification \"Restore SUCCESS: ${HOSTNAME}\" \"white_check_mark\" \"${NTFY_PRIORITY_SUCCESS}\" \"success\" \"Successfully restored ${item_for_display} to ${final_dest}\"\n            ((successful_count++))\n        else\n            local rsync_exit_code=$?\n            log_message \"Restore FAILED with rsync exit code ${rsync_exit_code}.\"\n            printf \"${C_RED}❌ Restore FAILED. Check the rsync output and log for details.${C_RESET}\\n\\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})\";\n            ((failed_count++))\n            continue\n        fi\n    done\n    print_header \"Overall Restore Summary\"\n    printf \"Total items selected: %d\\n\" \"$total_items\"\n    printf \"${C_GREEN}Succeeded: %d${C_RESET}\\n\" \"$successful_count\"\n    if (( failed_count \u003e 0 )); then\n        printf \"${C_RED}Failed/Skipped: %d${C_RESET}\\n\" \"$failed_count\"\n    else\n        printf \"${C_GREEN}Failed/Skipped: 0${C_RESET}\\n\"\n    fi\n}\nrun_recycle_bin_cleanup() {\n    if [[ \"${RECYCLE_BIN_ENABLED:-false}\" != \"true\" ]]; then return 0; fi\n    log_message \"Checking remote recycle bin...\"\n    local remote_cleanup_path=\"${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}\"\n    local list_command=\"ls -1 \\\"$remote_cleanup_path\\\"\"\n    local all_folders\n    if ! all_folders=$(ssh \"${SSH_OPTS_ARRAY[@]}\" \"${SSH_DIRECT_OPTS[@]}\" \"$BOX_ADDR\" \"$list_command\" 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"); then\n        log_message \"Recycle bin not found or unable to list contents. Nothing to clean.\"\n        return 0\n    fi\n    if [[ -z \"$all_folders\" ]]; then\n        log_message \"No daily folders in recycle bin to check.\"\n        return 0\n    fi\n    log_message \"Checking for folders older than ${RECYCLE_BIN_RETENTION_DAYS} days...\"\n    local folders_to_delete=\"\"\n    local retention_days=${RECYCLE_BIN_RETENTION_DAYS}\n    local threshold_timestamp\n    threshold_timestamp=$(date -d \"$retention_days days ago\" +%s)\n    while IFS= read -r folder; do\n        local folder_date=${folder%%_*}\n        if folder_timestamp=$(date -d \"$folder_date\" +%s 2\u003e/dev/null) \u0026\u0026 [[ -n \"$folder_timestamp\" ]]; then\n            if (( folder_timestamp \u003c threshold_timestamp )); then\n                folders_to_delete+=\"${folder}\"$'\\n'\n            fi\n        fi\n    done \u003c\u003c\u003c \"$all_folders\"\n    if [[ -n \"$folders_to_delete\" ]]; then\n        log_message \"Removing old recycle bin folders:\"\n        local empty_dir\n        empty_dir=$(mktemp -d)\n        while IFS= read -r folder; do\n            if [[ -n \"$folder\" ]]; then\n                log_message \"  Deleting: $folder\"\n                local remote_dir_to_delete=\"${remote_cleanup_path}/${folder}/\"\n                rsync -a --delete -e \"$SSH_CMD\" \"$empty_dir/\" \"${BOX_ADDR}:${remote_dir_to_delete}\" \u003e/dev/null 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"\n                ssh \"${SSH_OPTS_ARRAY[@]}\" \"${SSH_DIRECT_OPTS[@]}\" \"$BOX_ADDR\" \"rmdir \\\"$remote_dir_to_delete\\\"\" 2\u003e\u003e \"${LOG_FILE:-/dev/null}\"\n            fi\n        done \u003c\u003c\u003c \"$folders_to_delete\"\n\n        rm -rf \"$empty_dir\"\n    else\n        log_message \"No old recycle bin folders to remove.\"\n    fi\n}\ntrap cleanup EXIT\ntrap 'send_notification \"Backup Crashed: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"Backup script terminated unexpectedly. Check log: ${LOG_FILE:-/dev/null}\"' ERR\n\nREQUIRED_CMDS=(rsync ssh curl flock hostname date stat mv touch awk numfmt grep printf nice ionice sed mktemp basename read)\n\n# =================================================================\n#                       SCRIPT EXECUTION\n# =================================================================\n\nVERBOSE_MODE=false\nif [[ \"${1:-}\" == \"--verbose\" ]]; then\n    VERBOSE_MODE=true; shift\nfi\n\nif [[ \"${1:-}\" ]]; then\n    case \"${1}\" in\n        --dry-run)\n            trap - ERR\n            echo \"--- DRY RUN MODE ACTIVATED ---\"; DRY_RUN_FAILED=false; full_dry_run_output=\"\"\n            read -ra DIRS_ARRAY \u003c\u003c\u003c \"$BACKUP_DIRS\"\n            for dir in \"${DIRS_ARRAY[@]}\"; do\n                echo -e \"\\n--- Checking dry run for: $dir ---\"\n                rsync_dry_opts=( \"${RSYNC_BASE_OPTS[@]}\" --dry-run --itemize-changes --out-format=\"%i %n%L\" --info=stats2,name,flist2 )\n                if [[ \"${RECYCLE_BIN_ENABLED:-false}\" == \"true\" ]]; then\n                    backup_dir=\"${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}/$(date +%F_%H%M%S)/\"\n                    rsync_dry_opts+=(--backup --backup-dir=\"$backup_dir\")\n                fi\n                DRY_RUN_LOG_TMP=$(mktemp)\n                if ! rsync \"${rsync_dry_opts[@]}\" \"$dir\" \"$REMOTE_TARGET\" \u003e \"$DRY_RUN_LOG_TMP\" 2\u003e\u00261; then DRY_RUN_FAILED=true; fi\n                echo \"---- Preview of changes (first 20) ----\"\n                grep -E '^\\*deleting|^[\u003c\u003ech\\.]f|^cd|^\\.d' \"$DRY_RUN_LOG_TMP\" | head -n 20 || true\n                echo \"-------------------------------------\"\n                full_dry_run_output+=$'\\n'\"$(\u003c\"$DRY_RUN_LOG_TMP\")\"; rm -f \"$DRY_RUN_LOG_TMP\"\n            done\n            echo -e \"\\n--- Overall Dry Run Summary ---\"\n            BACKUP_STATS=$(format_backup_stats \"$full_dry_run_output\")\n            echo -e \"$BACKUP_STATS\"; echo \"-------------------------------\"\n            if [[ \"$DRY_RUN_FAILED\" == \"true\" ]]; then\n                echo -e \"\\n❌ Dry run FAILED for one or more directories. See rsync errors above.\"; exit 1\n            fi\n            echo \"--- DRY RUN COMPLETED ---\"; exit 0 ;;\n        --checksum | --summary)\n            trap - ERR\n            echo \"--- INTEGRITY CHECK MODE ACTIVATED ---\"; echo \"Calculating differences...\"\n            START_TIME_INTEGRITY=$(date +%s); FILE_DISCREPANCIES=$(run_integrity_check); END_TIME_INTEGRITY=$(date +%s)\n            DURATION_INTEGRITY=$((END_TIME_INTEGRITY - START_TIME_INTEGRITY))\n            CLEAN_DISCREPANCIES=$(echo \"$FILE_DISCREPANCIES\" | grep -v '^\\*')\n            if [[ \"$1\" == \"--summary\" ]]; then\n                MISMATCH_COUNT=$(echo \"$CLEAN_DISCREPANCIES\" | wc -l)\n                printf \"🚨 Total files with checksum mismatches: %d\\n\" \"$MISMATCH_COUNT\"\n                log_message \"Summary mode check found $MISMATCH_COUNT mismatched files.\"\n                send_notification \"📊 Backup Summary: ${HOSTNAME}\" \"bar_chart\" \"${NTFY_PRIORITY_SUCCESS}\" \"success\" \"Mismatched files found: $MISMATCH_COUNT\"\n            else # --checksum\n                if [ -z \"$CLEAN_DISCREPANCIES\" ]; then\n                    echo \"✅ Checksum validation passed. No discrepancies found.\"\n                    log_message \"Checksum validation passed. No discrepancies found.\"\n                    send_notification \"Backup Integrity OK: ${HOSTNAME}\" \"white_check_mark\" \"${NTFY_PRIORITY_SUCCESS}\" \"success\" \"Checksum validation passed.\"\n                else\n                    log_message \"Backup integrity check FAILED. Found discrepancies.\"\n                    ISSUE_LIST=$(echo \"$CLEAN_DISCREPANCIES\" | head -n 10)\n                    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))\n                    printf \"❌ %s\\n\" \"$FAILURE_MSG\"\n                    send_notification \"Backup Integrity FAILED: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"${FAILURE_MSG}\"\n                fi\n            fi\n            exit 0 ;;\n        --test)\n            trap - ERR\n            echo \"--- TEST MODE ACTIVATED ---\"; run_preflight_checks \"test\"\n            echo \"---------------------------\"; echo \"✅ All configuration checks passed.\"; exit 0 ;;\n        --restore)\n            trap - ERR; run_restore_mode; exit 0 ;;\n    esac\nfi\n\nrun_preflight_checks\n\nexec 200\u003e\"$LOCK_FILE\"\nflock -n 200 || { echo \"Another instance is running, exiting.\"; exit 5; }\n\n# --- Log Rotation ---\n# Use default of 10MB if not set in config\nmax_log_size_bytes=$(( ${MAX_LOG_SIZE_MB:-10} * 1024 * 1024 ))\nif [ -f \"$LOG_FILE\" ] \u0026\u0026 [ \"$(stat -c%s \"$LOG_FILE\")\" -gt \"$max_log_size_bytes\" ]; then\n    mv \"$LOG_FILE\" \"${LOG_FILE}.$(date +%Y%m%d_%H%M%S)\"\n    touch \"$LOG_FILE\"\n    find \"$(dirname \"$LOG_FILE\")\" -name \"$(basename \"$LOG_FILE\").*\" -type f -mtime +\"$LOG_RETENTION_DAYS\" -delete\nfi\n\nlog_message \"Flushing filesystem buffers to disk...\"\nsync\n\necho \"============================================================\" \u003e\u003e \"$LOG_FILE\"\nlog_message \"Starting rsync backup...\"\n\nSTART_TIME=$(date +%s)\nsuccess_dirs=(); failed_dirs=(); overall_exit_code=0; full_rsync_output=\"\"\nread -ra DIRS_ARRAY \u003c\u003c\u003c \"$BACKUP_DIRS\"\nfor dir in \"${DIRS_ARRAY[@]}\"; do\n    log_message \"Backing up directory: $dir\"\n    RSYNC_LOG_TMP=$(mktemp)\n    RSYNC_EXIT_CODE=0; RSYNC_OPTS=(\"${RSYNC_BASE_OPTS[@]}\")\n    if [[ \"${RECYCLE_BIN_ENABLED:-false}\" == \"true\" ]]; then\n        backup_dir=\"${BOX_DIR%/}/${RECYCLE_BIN_DIR%/}/$(date +%F_%H%M%S)/\"\n        RSYNC_OPTS+=(--backup --backup-dir=\"$backup_dir\")\n    fi\n    if [[ \"$VERBOSE_MODE\" == \"true\" ]]; then\n        RSYNC_OPTS+=(--info=stats2,progress2)\n        nice -n 19 ionice -c 3 rsync \"${RSYNC_OPTS[@]}\" \"$dir\" \"$REMOTE_TARGET\" 2\u003e\u00261 | tee \"$RSYNC_LOG_TMP\"\n        RSYNC_EXIT_CODE=${PIPESTATUS[0]}\n    else\n        RSYNC_OPTS+=(--info=stats2)\n\tnice -n 19 ionice -c 3 rsync \"${RSYNC_OPTS[@]}\" \"$dir\" \"$REMOTE_TARGET\" \u003e \"$RSYNC_LOG_TMP\" 2\u003e\u00261 || RSYNC_EXIT_CODE=$?\n    fi\n    cat \"$RSYNC_LOG_TMP\" \u003e\u003e \"$LOG_FILE\"; full_rsync_output+=$'\\n'\"$(\u003c\"$RSYNC_LOG_TMP\")\"\n    rm -f \"$RSYNC_LOG_TMP\"\n    if [[ $RSYNC_EXIT_CODE -eq 0 || $RSYNC_EXIT_CODE -eq 24 || $RSYNC_EXIT_CODE -eq 23 ]]; then\n        success_dirs+=(\"$(basename \"$dir\")\")\n        if [[ $RSYNC_EXIT_CODE -eq 24 || $RSYNC_EXIT_CODE -eq 23 ]]; then\n            log_message \"WARNING for $dir: rsync completed with code $RSYNC_EXIT_CODE.\"; overall_exit_code=24\n        fi\n    else\n        failed_dirs+=(\"$(basename \"$dir\")\")\n        log_message \"FAILED for $dir: rsync exited with code: $RSYNC_EXIT_CODE.\"; overall_exit_code=1\n    fi\ndone\n\nrun_recycle_bin_cleanup\n\nEND_TIME=$(date +%s); DURATION=$((END_TIME - START_TIME)); trap - ERR\n\nBACKUP_STATS=$(format_backup_stats \"$full_rsync_output\")\nFINAL_MESSAGE=$(printf \"%s\\n\\nSuccessful: %s\\nFailed: %s\\n\\nDuration: %dm %ds\" \\\n    \"$BACKUP_STATS\" \\\n    \"${success_dirs[*]:-None}\" \\\n    \"${failed_dirs[*]:-None}\" \\\n    $((DURATION / 60)) $((DURATION % 60)))\n\nif [[ ${#FINAL_MESSAGE} -gt 1800 ]]; then\n    FINAL_MESSAGE=$(printf \"%.1800s\\n\\n[Message truncated, see %s for full details]\" \"$FINAL_MESSAGE\" \"$LOG_FILE\")\nfi\n\nif [[ ${#failed_dirs[@]} -eq 0 ]]; then\n    log_message \"SUCCESS: All backups completed.\"\n    if [[ $overall_exit_code -eq 24 ]]; then\n        send_notification \"Backup Warning: ${HOSTNAME}\" \"warning\" \"${NTFY_PRIORITY_WARNING}\" \"warning\" \"One or more directories completed with warnings.\\n\\n$FINAL_MESSAGE\"\n    else\n        send_notification \"Backup SUCCESS: ${HOSTNAME}\" \"white_check_mark\" \"${NTFY_PRIORITY_SUCCESS}\" \"success\" \"$FINAL_MESSAGE\"\n    fi\nelse\n    log_message \"FAILURE: One or more backups failed.\"; send_notification \"Backup FAILED: ${HOSTNAME}\" \"x\" \"${NTFY_PRIORITY_FAILURE}\" \"failure\" \"$FINAL_MESSAGE\"\nfi\n\necho \"======================= Run Finished =======================\" \u003e\u003e \"$LOG_FILE\"\necho \"\" \u003e\u003e \"$LOG_FILE\"\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbuildplan%2Frsync-backup-script","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbuildplan%2Frsync-backup-script","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbuildplan%2Frsync-backup-script/lists"}