diff --git a/.github/about.md b/.github/about.md new file mode 100644 index 0000000..f9900e9 --- /dev/null +++ b/.github/about.md @@ -0,0 +1,45 @@ +# About restic-backup-script + +## Project Overview +This repository provides a Bash script (`restic-backup.sh`) and configuration (`restic-backup.conf`) for automated, encrypted backups using [restic](https://restic.net/). It targets VPS environments, backing up local directories to remote SFTP storage (e.g., Hetzner Storage Box) with client-side encryption, deduplication, and snapshot management. + +## Key Files & Structure +- `restic-backup.sh`: Main script. Handles backup, restore, integrity checks, scheduling, notifications, and self-updating. +- `restic-backup.conf`: Central config. All operational parameters (sources, retention, logging, notifications, etc.) are set here. +- `restic-excludes.txt`: Patterns for files/directories to exclude from backups. +- `how-to/`: Step-by-step guides for restoring, migrating, troubleshooting, and best practices. + +## Essential Workflows +- **Run Modes**: Script supports multiple flags (`--restore`, `--check`, `--install-scheduler`, etc.). See `README.md` for full list and usage examples. +- **Configuration**: All settings (sources, repository, retention, notifications) are managed in `restic-backup.conf`. Use Bash array syntax for `BACKUP_SOURCES`. +- **Exclusions**: Update `restic-excludes.txt` or `EXCLUDE_PATTERNS` in config for custom exclusions. +- **Scheduling**: Use `--install-scheduler` to set up systemd/cron jobs interactively. +- **Self-Update**: Script can auto-update itself and restic binary if run interactively. +- **Notifications**: Integrates with ntfy and Discord for backup status alerts. +- **Healthchecks**: Optional integration for cron job monitoring via Healthchecks.io. + +## Patterns & Conventions +- **Root Privileges**: Script enforces root execution (auto re-invokes with sudo). +- **Locking**: Prevents concurrent runs via `/tmp/restic-backup.lock`. +- **Logging**: Rotates logs at `/var/log/restic-backup.log` based on config. +- **Error Handling**: Uses `set -euo pipefail` for strict error management. +- **Color Output**: Uses ANSI colors for interactive output, disables for non-TTY. +- **Repository URL**: Uses SSH config alias for SFTP (e.g., `sftp:storagebox:/home/vps`). +- **Restore Safety**: Guides recommend restoring to temp directories before overwriting live data. + +## Integration Points +- **restic**: Main dependency. Script checks, installs, and verifies restic binary. +- **SFTP/SSH**: Repository access via SSH config alias. Ensure `/root/.ssh/config` is set up. +- **ntfy/Discord**: Notification endpoints configured in `restic-backup.conf`. +- **Healthchecks.io**: Optional dead man's switch for scheduled jobs. + +## Examples +- To run a backup: `sudo ./restic-backup.sh` +- To restore: `sudo ./restic-backup.sh --restore` (interactive wizard) +- To check integrity: `sudo ./restic-backup.sh --check` +- To install scheduler: `sudo ./restic-backup.sh --install-scheduler` + +## References +- See `README.md` for feature overview and usage. +- See `how-to/` for guides on restore, migration, troubleshooting, and best practices. +- See `restic-backup.conf` for all config options and conventions. diff --git a/README.md b/README.md index ece3261..9b2528e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ This script automates encrypted, deduplicated backups of local directories to a - `sudo ./restic-backup.sh --check` - Verify repository integrity by checking a subset of data. - `sudo ./restic-backup.sh --check-full` - Run a full check verifying all repository data. - `sudo ./restic-backup.sh --test` - Validate configuration, permissions, and SSH connectivity. + - `sudo ./restic-backup.sh --install-scheduler` - Run the interactive wizard to set up an automated backup schedule (systemd/cron). + - `sudo ./restic-backup.sh --uninstall-scheduler` - Remove a schedule created by the wizard. - `sudo ./restic-backup.sh --restore` - Start the interactive restore wizard. - `sudo ./restic-backup.sh --forget` - Manually apply the retention policy and prune old data. - `sudo ./restic-backup.sh --diff` - Show a summary of changes between the last two snapshots. @@ -238,7 +240,25 @@ Before the first backup, you need to create the repository password file and ini sudo ./restic-backup.sh --init ``` -### 5. Set up a Cron Job +### 5. Set up an Automated Schedule (Recommended) + +The easiest and most reliable way to schedule your backups is to use the script's built-in interactive wizard. It will guide you through creating and enabling either a modern `systemd timer` (recommended) or a traditional `cron job`. + +1. Navigate to your script directory: + ```sh + cd /root/scripts/backup + ``` + +2. Run the scheduler installation wizard: + ```sh + sudo ./restic-backup.sh --install-scheduler + ``` + +Follow the on-screen prompts to choose your preferred scheduling system and frequency. The script will handle creating all the necessary files and enabling the service for you. + +#### Manual Cron Job Setup + +If you prefer to manage the schedule manually instead of using the wizard, you can edit the root crontab directly. To run the backup automatically, edit the root crontab. diff --git a/restic-backup.sh b/restic-backup.sh index 0ad85bb..94131e7 100644 --- a/restic-backup.sh +++ b/restic-backup.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # ================================================================= -# Restic Backup Script v0.30 - 2025.09.26 +# Restic Backup Script v0.31 - 2025.09.27 # ================================================================= set -euo pipefail umask 077 # --- Script Constants --- -SCRIPT_VERSION="0.30" +SCRIPT_VERSION="0.31" SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) CONFIG_FILE="${SCRIPT_DIR}/restic-backup.conf" LOCK_FILE="/tmp/restic-backup.lock" @@ -255,6 +255,8 @@ display_help() { printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--restore" "Start the interactive restore wizard." printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--dry-run" "Preview backup changes without creating a new snapshot." printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--test" "Validate configuration, permissions, and SSH connectivity." + printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--install-scheduler" "Install an automated backup schedule (systemd/cron)." + printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--uninstall-scheduler" "Remove an existing automated backup schedule." printf " ${C_GREEN}%-20s${C_RESET} %s\n" "--help, -h" "Display this help message." echo echo -e "Use ${C_GREEN}--verbose${C_RESET} before any command for detailed live output (e.g., 'sudo $0 --verbose --diff')." @@ -580,7 +582,7 @@ rotate_log() { local max_size_bytes=$(( ${MAX_LOG_SIZE_MB:-10} * 1024 * 1024 )) local log_size if command -v stat >/dev/null 2>&1; then - log_size=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE" 2>/dev/null || echo 0) + log_size=$(stat -f%z "$LOG_FILE" 2>/dev/null || stat -c%s "$LOG_FILE" 2>/dev/null || wc -c < "$LOG_FILE" 2>/dev/null || echo 0) else log_size=0 fi @@ -606,6 +608,240 @@ run_with_priority() { fi } +run_install_scheduler() { + echo -e "${C_BOLD}--- Backup Schedule Installation Wizard ---${C_RESET}" + if [[ $EUID -ne 0 ]]; then + echo -e "${C_RED}ERROR: This operation requires root privileges.${C_RESET}" >&2 + exit 1 + fi + local script_path + script_path=$(realpath "$0") + echo -e "\n${C_YELLOW}Which scheduling system would you like to use?${C_RESET}" + echo -e " 1) ${C_GREEN}systemd timer${C_RESET} (Modern, recommended, more flexible logging)" + echo -e " 2) ${C_CYAN}crontab${C_RESET} (Classic, simple, universally available)" + local scheduler_choice + read -p "Enter your choice [1]: " scheduler_choice + scheduler_choice=${scheduler_choice:-1} + echo -e "\n${C_YELLOW}How often would you like the backup to run?${C_RESET}" + echo -e " 1) ${C_GREEN}Once daily${C_RESET}" + echo -e " 2) ${C_GREEN}Twice daily${C_RESET} (e.g., every 12 hours)" + echo -e " 3) ${C_CYAN}Custom schedule${C_RESET} (provide your own expression)" + local schedule_choice + read -p "Enter your choice [1]: " schedule_choice + schedule_choice=${schedule_choice:-1} + + local systemd_schedule cron_schedule + case "$schedule_choice" in + 1) + local daily_time + while true; do + read -p "Enter the time to run the backup (24-hour HH:MM format) [03:00]: " daily_time + daily_time=${daily_time:-03:00} + if [[ "$daily_time" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then break; else echo -e "${C_RED}Invalid format. Please use HH:MM.${C_RESET}"; fi + done + local hour=${daily_time%%:*} minute=${daily_time##*:} + systemd_schedule="*-*-* ${hour}:${minute}:00" + cron_schedule="${minute} ${hour} * * *" + ;; + 2) + local time1 time2 + while true; do + read -p "Enter the first time (24-hour HH:MM format) [03:00]: " time1 + time1=${time1:-03:00} + if [[ "$time1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then break; else echo -e "${C_RED}Invalid format. Please use HH:MM.${C_RESET}"; fi + done + while true; do + read -p "Enter the second time (24-hour HH:MM format) [15:30]: " time2 + time2=${time2:-15:30} + if [[ "$time2" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then break; else echo -e "${C_RED}Invalid format. Please use HH:MM.${C_RESET}"; fi + done + local hour1=${time1%%:*} min1=${time1##*:} + local hour2=${time2%%:*} min2=${time2##*:} + printf -v systemd_schedule "*-*-* %s:%s:00\n*-*-* %s:%s:00" "$hour1" "$min1" "$hour2" "$min2" + systemd_schedule="*-*-* ${hour1}:${min1}:00\n*-*-* ${hour2}:${min2}:00" + printf -v cron_schedule "%s %s * * *\n%s %s * * *" "$min1" "$hour1" "$min2" "$hour2" + ;; + 3) + if [[ "$scheduler_choice" == "1" ]]; then + read -p "Enter a custom systemd 'OnCalendar' expression: " systemd_schedule + if command -v systemd-analyze >/dev/null && ! systemd-analyze calendar "$systemd_schedule" --iterations=1 >/dev/null 2>&1; then + echo -e "${C_RED}Warning: '$systemd_schedule' may be an invalid expression.${C_RESET}" + fi + else + while true; do + read -p "Enter a custom cron expression (e.g., '0 4 * * *'): " cron_schedule + if echo "$cron_schedule" | grep -qE '^([0-9*,/-]+\s){4}[0-9*,/-]+$'; then + break + else + echo -e "${C_RED}Invalid format. A cron expression must have 5 fields separated by spaces, using only valid characters (0-9,*,/,-).${C_RESET}" + fi + done + fi + ;; + *) + echo -e "${C_RED}Invalid choice. Aborting.${C_RESET}" >&2; return 1 ;; + esac + echo -e "\n${C_BOLD}--- Summary ---${C_RESET}" + echo -e " ${C_DIM}Script Path:${C_RESET} $script_path" + echo -e " ${C_DIM}Config File:${C_RESET} $CONFIG_FILE" + if [[ "$scheduler_choice" == "1" ]]; then + echo -e " ${C_DIM}Scheduler:${C_RESET} systemd timer" + printf " ${C_DIM}Schedule:%b\n%s${C_RESET}\n" "${C_RESET}" "$systemd_schedule" + echo + read -p "Proceed with installation? (y/n): " confirm + if [[ "${confirm,,}" != "y" ]]; then echo "Aborted."; return 1; fi + install_systemd_timer "$script_path" "$systemd_schedule" "$CONFIG_FILE" + else + echo -e " ${C_DIM}Scheduler:${C_RESET} crontab" + printf " ${C_DIM}Schedule:%b\n%s${C_RESET}\n" "${C_RESET}" "$cron_schedule" + echo + read -p "Proceed with installation? (y/n): " confirm + if [[ "${confirm,,}" != "y" ]]; then echo "Aborted."; return 1; fi + install_crontab "$script_path" "$cron_schedule" "$LOG_FILE" + fi +} + +install_systemd_timer() { + local script_path="$1" + local schedule="$2" + local config_file="$3" + local service_file="/etc/systemd/system/restic-backup.service" + local timer_file="/etc/systemd/system/restic-backup.timer" + + if [ -f "$service_file" ] || [ -f "$timer_file" ]; then + read -p "Existing systemd files found. Overwrite? (y/n): " confirm + if [[ "${confirm,,}" != "y" ]]; then echo "Aborted."; return 1; fi + fi + echo "Creating systemd service file: $service_file" + cat > "$service_file" << EOF +[Unit] +Description=Restic Backup Service +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +EnvironmentFile=$config_file +ExecStart=$script_path +User=root +Group=root +EOF + + echo "Creating systemd timer file: $timer_file" + cat > "$timer_file" << EOF +[Unit] +Description=Run Restic Backup on a schedule + +[Timer] +Persistent=true +EOF + while IFS= read -r schedule_line; do + if [ -n "$schedule_line" ]; then + echo "OnCalendar=$schedule_line" >> "$timer_file" + fi + done <<< "$schedule" + cat >> "$timer_file" << EOF + +[Install] +WantedBy=timers.target +EOF + + echo "Reloading systemd daemon, enabling and starting timer..." + if systemctl daemon-reload && systemctl enable --now restic-backup.timer; then + echo -e "${C_GREEN}✅ systemd timer installed and activated successfully.${C_RESET}" + echo -e "\n${C_BOLD}--- Verifying Status ---${C_RESET}" + systemctl list-timers restic-backup.timer + else + echo -e "${C_RED}❌ Failed to install or start systemd timer.${C_RESET}" >&2 + return 1 + fi +} + +install_crontab() { + local script_path="$1" + local schedule="$2" + local log_file="$3" + local cron_file="/etc/cron.d/restic-backup" + if [ -f "$cron_file" ]; then + echo -e "${C_YELLOW}Existing cron file found at $cron_file:${C_RESET}" + cat "$cron_file" + echo + read -p "Add new schedule(s) to this file? (y/n): " confirm + if [[ "${confirm,,}" != "y" ]]; then + echo "Aborted." + return 1 + fi + echo "Appending new schedule(s)..." + local new_jobs_added=0 + while IFS= read -r schedule_line; do + if [ -n "$schedule_line" ]; then + local full_command_line="$schedule_line root $script_path" + if grep -qF "$full_command_line" "$cron_file"; then + echo -e "${C_DIM}Skipping duplicate schedule: $schedule_line${C_RESET}" + else + echo "$full_command_line >> \"$log_file\" 2>&1" >> "$cron_file" + ((new_jobs_added++)) + fi + fi + done <<< "$schedule" + if [ "$new_jobs_added" -eq 0 ]; then + echo -e "${C_YELLOW}No new unique schedules were added.${C_RESET}" + fi + else + echo "Creating new cron job file: $cron_file" + cat > "$cron_file" << EOF +# Restic Backup Job installed by restic-backup.sh +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin + +EOF + + while IFS= read -r schedule_line; do + if [ -n "$schedule_line" ]; then + echo "$schedule_line root $script_path >> \"$log_file\" 2>&1" >> "$cron_file" + fi + done <<< "$schedule" + fi + + chmod 644 "$cron_file" + echo -e "${C_GREEN}✅ Cron job file updated successfully.${C_RESET}" + echo -e "\n${C_BOLD}--- Current Cron File Content ---${C_RESET}" + cat "$cron_file" +} + +run_uninstall_scheduler() { + echo -e "${C_BOLD}--- Backup Schedule Uninstallation ---${C_RESET}" + if [[ $EUID -ne 0 ]]; then + echo -e "${C_RED}ERROR: This operation requires root privileges.${C_RESET}" >&2 + exit 1 + fi + local service_file="/etc/systemd/system/restic-backup.service" + local timer_file="/etc/systemd/system/restic-backup.timer" + local cron_file="/etc/cron.d/restic-backup" + local found=false + + if [ -f "$timer_file" ]; then + found=true + echo "Found systemd timer. Stopping and disabling..." + systemctl stop restic-backup.timer + systemctl disable restic-backup.timer + rm -f "$timer_file" "$service_file" + systemctl daemon-reload + echo -e "${C_GREEN}✅ systemd timer and service files removed.${C_RESET}" + fi + + if [ -f "$cron_file" ]; then + found=true + echo "Found cron file. Removing..." + rm -f "$cron_file" + echo -e "${C_GREEN}✅ Cron file removed.${C_RESET}" + fi + + if [ "$found" = false ]; then + echo -e "${C_YELLOW}No scheduled backup tasks found to uninstall.${C_RESET}" + fi +} + # ================================================================= # MAIN OPERATIONS # ================================================================= @@ -826,7 +1062,7 @@ run_restore() { # Set file ownership logic if [[ "$restore_dest" == /home/* ]]; then local dest_user - dest_user=$(echo "$restore_dest" | cut -d/ -f3) + dest_user=$(stat -c %U "$(dirname "$restore_dest")" 2>/dev/null || echo "${restore_dest#/home/}" | cut -d/ -f1) if [[ -n "$dest_user" ]] && id -u "$dest_user" &>/dev/null; then echo -e "${C_CYAN}ℹ️ Home directory detected. Setting ownership of restored files to '$dest_user'...${C_RESET}" if chown -R "${dest_user}:${dest_user}" "$restore_dest"; then @@ -922,6 +1158,12 @@ rotate_log # Handle different modes case "${1:-}" in + --install-scheduler) + run_install_scheduler + ;; + --uninstall-scheduler) + run_uninstall_scheduler + ;; --init) run_preflight_checks "init" "quiet" init_repository @@ -986,20 +1228,43 @@ case "${1:-}" in fi run_preflight_checks "backup" "quiet" log_message "=== Starting backup run ===" - if run_backup; then + + backup_exit_code=0 + if ! run_backup; then + backup_exit_code=1 + fi + + if [ "$backup_exit_code" -eq 0 ]; then run_forget if [ "${CHECK_AFTER_BACKUP:-false}" = "true" ]; then run_check fi fi + log_message "=== Backup run completed ===" - # --- Ping Healthchecks.io on success --- - if [[ -n "${HEALTHCHECKS_URL:-}" ]]; then + # --- Ping Healthchecks.io (Success or Failure) --- + if [ "$backup_exit_code" -eq 0 ] && [[ -n "${HEALTHCHECKS_URL:-}" ]]; then log_message "Pinging Healthchecks.io to signal successful run." - curl -fsS -m 15 --retry 3 "${HEALTHCHECKS_URL}" >/dev/null 2>>"$LOG_FILE" + if ! curl -fsS -m 15 --retry 3 "${HEALTHCHECKS_URL}" >/dev/null 2>>"$LOG_FILE"; then + log_message "WARNING: Healthchecks.io success ping failed." + send_notification "Healthchecks Ping Failed: $HOSTNAME" "warning" \ + "${NTFY_PRIORITY_WARNING}" "warning" "Failed to ping Healthchecks.io after successful backup." + fi + elif [ "$backup_exit_code" -ne 0 ] && [[ -n "${HEALTHCHECKS_URL:-}" ]]; then + log_message "Pinging Healthchecks.io with failure signal." + if ! curl -fsS -m 15 --retry 3 "${HEALTHCHECKS_URL}/fail" >/dev/null 2>>"$LOG_FILE"; then + log_message "WARNING: Healthchecks.io failure ping failed." + send_notification "Healthchecks Ping Failed: $HOSTNAME" "warning" \ + "${NTFY_PRIORITY_WARNING}" "warning" "Failed to ping Healthchecks.io /fail endpoint after backup failure." + fi + fi + + # Exit with the correct code to signal success or failure to the scheduler + if [ "$backup_exit_code" -ne 0 ]; then + exit "$backup_exit_code" fi ;; esac -echo -e "${C_BOLD}--- Backup Script Completed ---${C_RESET}" +echo -e "${C_BOLD}--- Backup Script Completed ---${C_RESET}" \ No newline at end of file diff --git a/restic-backup.sh.sha256 b/restic-backup.sh.sha256 index f73db47..1a68428 100644 --- a/restic-backup.sh.sha256 +++ b/restic-backup.sh.sha256 @@ -1 +1 @@ -f46058f5e1dfb2cc158e30183d01d62a225b71459b4b702a9ec57113cb76f743 restic-backup.sh +9a870262d8c680ea1afaf2bef175088f75c599de7fa06f39974f22d6e89e4c8d restic-backup.sh