dest/current/ stays a plain mirror; deleted/overwritten files are moved to dest/versions/YYYY-MM-DD/ so accidental deletions are recoverable while intentional --delete still propagates. Unchanged files in version folders are hardlinked via --link-dest to avoid extra disk cost. Supports local and remote (SSH) source/destination paths, saved jobs, and a cron hint. https://claude.ai/code/session_015SmW4EAD6mMLZGVCygy3GR
379 lines
14 KiB
Bash
Executable File
379 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# tools/rsync-backup.sh — Interactive rsync mirror backup with versioned deletes.
|
|
#
|
|
# Usage:
|
|
# bash rsync-backup.sh
|
|
#
|
|
# How it works:
|
|
# - dest/current/ is always a plain mirror of the source (any file browser works)
|
|
# - dest/versions/YYYY-MM-DD/ holds files that were deleted or overwritten that day
|
|
# - Unchanged files in versions/ are hardlinked (no extra disk cost)
|
|
# - --delete is active, so intentional source deletions propagate; accidental
|
|
# deletes are safe because the file lands in today's versions/ folder first
|
|
#
|
|
# Requirements:
|
|
# - rsync installed on both source and destination hosts
|
|
# - Passwordless SSH access already configured (ssh-copy-id or equivalent)
|
|
# Run: ssh-copy-id user@remotehost before using this script for remote jobs.
|
|
|
|
set -euo pipefail
|
|
|
|
# ── Colours ───────────────────────────────────────────────────────────────────
|
|
info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
|
|
ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
|
|
err() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; }
|
|
|
|
# ── Config file ───────────────────────────────────────────────────────────────
|
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/rsync-backup"
|
|
CONFIG_FILE="$CONFIG_DIR/jobs.conf"
|
|
|
|
save_job() {
|
|
# save_job NAME SOURCE DEST EXCLUDES
|
|
mkdir -p "$CONFIG_DIR"
|
|
# Remove any existing entry for this name
|
|
if [[ -f "$CONFIG_FILE" ]]; then
|
|
grep -v "^JOB_${1}=" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" || true
|
|
fi
|
|
printf 'JOB_%s=%s\n' "$1" "$(printf '%q %q %q' "$2" "$3" "$4")" >> "$CONFIG_FILE"
|
|
chmod 600 "$CONFIG_FILE"
|
|
}
|
|
|
|
load_jobs() {
|
|
[[ -f "$CONFIG_FILE" ]] || return 0
|
|
# shellcheck source=/dev/null
|
|
source "$CONFIG_FILE"
|
|
}
|
|
|
|
list_jobs() {
|
|
[[ -f "$CONFIG_FILE" ]] || return 0
|
|
grep '^JOB_' "$CONFIG_FILE" | sed 's/^JOB_/ /' | sed 's/=.*//'
|
|
}
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
ask() {
|
|
# ask PROMPT DEFAULT VARNAME
|
|
local _prompt="$1" _default="$2" _var="$3" _input=""
|
|
read -r -p " $_prompt${_default:+ [$_default]}: " _input
|
|
printf -v "$_var" '%s' "${_input:-$_default}"
|
|
}
|
|
|
|
ask_yn() {
|
|
# ask_yn PROMPT DEFAULT(y/n) VARNAME
|
|
local _prompt="$1" _default="$2" _var="$3" _input=""
|
|
read -r -p " $_prompt [${_default^^}/${_default,,}]: " _input
|
|
_input="${_input:-$_default}"
|
|
printf -v "$_var" '%s' "${_input,,}"
|
|
}
|
|
|
|
require_rsync() {
|
|
command -v rsync &>/dev/null && return 0
|
|
err "rsync is not installed. Install with: sudo apt install rsync"
|
|
exit 1
|
|
}
|
|
|
|
test_ssh() {
|
|
local host="$1"
|
|
info "Testing SSH connection to $host ..."
|
|
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then
|
|
ok "SSH connection OK"
|
|
return 0
|
|
else
|
|
err "Cannot connect to $host via SSH without a password."
|
|
echo ""
|
|
echo " Passwordless SSH is required. Set it up with:"
|
|
echo " ssh-copy-id $host"
|
|
echo ""
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
is_remote() {
|
|
# returns true if path contains user@host: or host:
|
|
[[ "$1" == *:* ]]
|
|
}
|
|
|
|
remote_host() {
|
|
echo "${1%%:*}"
|
|
}
|
|
|
|
# ── Core backup logic ─────────────────────────────────────────────────────────
|
|
run_backup() {
|
|
local name="$1" source="$2" dest="$3" excludes="$4"
|
|
|
|
local today; today="$(date +%Y-%m-%d)"
|
|
local current="${dest%/}/current"
|
|
local versions_today="${dest%/}/versions/${today}"
|
|
local versions_prev="${dest%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')"
|
|
|
|
echo ""
|
|
info "Job: $name"
|
|
info "Source : $source"
|
|
info "Dest : $current"
|
|
info "Versions → $versions_today"
|
|
echo ""
|
|
|
|
# Build exclude args
|
|
local -a excl_args=()
|
|
if [[ -n "$excludes" ]]; then
|
|
IFS=',' read -ra _excl_list <<< "$excludes"
|
|
for _e in "${_excl_list[@]}"; do
|
|
excl_args+=(--exclude="${_e// /}")
|
|
done
|
|
fi
|
|
|
|
# Build rsync command
|
|
local -a cmd=(
|
|
rsync
|
|
-avh
|
|
--delete
|
|
--backup
|
|
--backup-dir="$versions_today"
|
|
--progress
|
|
--stats
|
|
"${excl_args[@]}"
|
|
)
|
|
|
|
# Add --link-dest if yesterday's versions folder exists (saves space via hardlinks)
|
|
if is_remote "$dest"; then
|
|
local rhost; rhost="$(remote_host "$dest")"
|
|
local remote_prev="${dest#*:}"
|
|
remote_prev="${remote_prev%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')"
|
|
if ssh "$rhost" "[ -d '$remote_prev' ]" 2>/dev/null; then
|
|
cmd+=(--link-dest="$remote_prev")
|
|
fi
|
|
else
|
|
if [[ -d "$versions_prev" ]]; then
|
|
cmd+=(--link-dest="$versions_prev")
|
|
fi
|
|
fi
|
|
|
|
cmd+=("${source%/}/" "$current/")
|
|
|
|
info "Running: ${cmd[*]}"
|
|
echo ""
|
|
|
|
if "${cmd[@]}"; then
|
|
echo ""
|
|
ok "Backup complete: $name"
|
|
ok "Mirror : $current"
|
|
ok "Changed/deleted files saved to: $versions_today"
|
|
else
|
|
local rc=$?
|
|
# rsync exit 24 = vanished files (harmless)
|
|
if [[ $rc -eq 24 ]]; then
|
|
warn "Some files vanished during sync (exit 24) — this is usually harmless."
|
|
ok "Backup finished: $name"
|
|
else
|
|
err "rsync exited with code $rc — check output above."
|
|
return $rc
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── Prune old versions ────────────────────────────────────────────────────────
|
|
prune_versions() {
|
|
local dest="$1" keep_days="$2"
|
|
|
|
if is_remote "$dest"; then
|
|
local rhost; rhost="$(remote_host "$dest")"
|
|
local rpath="${dest#*:}"
|
|
local versions_dir="${rpath%/}/versions"
|
|
info "Pruning remote versions older than $keep_days days from $rhost:$versions_dir ..."
|
|
ssh "$rhost" "find '$versions_dir' -maxdepth 1 -mindepth 1 -type d -mtime +${keep_days} -exec rm -rf {} + 2>/dev/null; echo done" \
|
|
&& ok "Prune complete" || warn "Prune failed (non-fatal)"
|
|
else
|
|
local versions_dir="${dest%/}/versions"
|
|
if [[ -d "$versions_dir" ]]; then
|
|
info "Pruning local versions older than $keep_days days from $versions_dir ..."
|
|
find "$versions_dir" -maxdepth 1 -mindepth 1 -type d -mtime "+${keep_days}" \
|
|
-exec rm -rf {} + 2>/dev/null || true
|
|
ok "Prune complete"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── Wizard: create / edit a job ───────────────────────────────────────────────
|
|
wizard_job() {
|
|
echo ""
|
|
echo " ── Job Configuration ─────────────────────────────────────"
|
|
echo ""
|
|
echo " Paths can be local (/home/user/photos) or remote (user@host:/path)."
|
|
echo " Remote paths require passwordless SSH (ssh-copy-id user@host)."
|
|
echo ""
|
|
|
|
local name="" source="" dest="" excludes="" save=""
|
|
|
|
ask "Job name (letters/numbers/hyphens)" "my-backup" name
|
|
name="${name//[^a-zA-Z0-9_-]/-}"
|
|
|
|
ask "Source path" "" source
|
|
[[ -z "$source" ]] && { warn "Source cannot be empty."; return 1; }
|
|
|
|
ask "Destination base path (current/ and versions/ created here)" "" dest
|
|
[[ -z "$dest" ]] && { warn "Destination cannot be empty."; return 1; }
|
|
|
|
ask "Exclude patterns, comma-separated (e.g. *.tmp,Thumbs.db) or leave blank" "" excludes
|
|
|
|
echo ""
|
|
# Test SSH if remote is involved
|
|
if is_remote "$source"; then
|
|
test_ssh "$(remote_host "$source")" || return 1
|
|
fi
|
|
if is_remote "$dest"; then
|
|
test_ssh "$(remote_host "$dest")" || return 1
|
|
fi
|
|
|
|
echo ""
|
|
ask_yn "Save this job for future runs?" "y" save
|
|
if [[ "$save" == "y" ]]; then
|
|
save_job "$name" "$source" "$dest" "$excludes"
|
|
ok "Job '$name' saved to $CONFIG_FILE"
|
|
fi
|
|
|
|
echo ""
|
|
local run_now=""
|
|
ask_yn "Run the backup now?" "y" run_now
|
|
if [[ "$run_now" == "y" ]]; then
|
|
run_backup "$name" "$source" "$dest" "$excludes"
|
|
|
|
echo ""
|
|
local do_prune=""
|
|
ask_yn "Prune old version folders now?" "y" do_prune
|
|
if [[ "$do_prune" == "y" ]]; then
|
|
local keep=""
|
|
ask "Keep versions for how many days?" "30" keep
|
|
prune_versions "$dest" "$keep"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── Run a saved job ───────────────────────────────────────────────────────────
|
|
run_saved_job() {
|
|
load_jobs
|
|
|
|
local names=()
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] && names+=("$line")
|
|
done < <(list_jobs)
|
|
|
|
if [[ ${#names[@]} -eq 0 ]]; then
|
|
warn "No saved jobs found."
|
|
return 0
|
|
fi
|
|
|
|
echo ""
|
|
echo " Saved jobs:"
|
|
echo ""
|
|
local i=1
|
|
for n in "${names[@]}"; do
|
|
printf " %2d) %s\n" "$i" "$n"
|
|
i=$(( i + 1 ))
|
|
done
|
|
echo ""
|
|
|
|
local choice=""
|
|
read -r -p " Run job number [1]: " choice
|
|
choice="${choice:-1}"
|
|
[[ ! "$choice" =~ ^[0-9]+$ ]] && { warn "Invalid."; return 1; }
|
|
|
|
local idx=$(( choice - 1 ))
|
|
[[ "$idx" -lt 0 || "$idx" -ge ${#names[@]} ]] && { warn "Invalid selection."; return 1; }
|
|
|
|
local job_name="${names[$idx]// /}"
|
|
local var="JOB_${job_name}"
|
|
local job_val="${!var:-}"
|
|
[[ -z "$job_val" ]] && { err "Could not load job '$job_name'."; return 1; }
|
|
|
|
# Parse the three quoted fields back out
|
|
eval "local _parts=($job_val)"
|
|
local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}"
|
|
|
|
run_backup "$job_name" "$src" "$dst" "$excl"
|
|
|
|
echo ""
|
|
local do_prune=""
|
|
ask_yn "Prune old version folders now?" "n" do_prune
|
|
if [[ "$do_prune" == "y" ]]; then
|
|
local keep=""
|
|
ask "Keep versions for how many days?" "30" keep
|
|
prune_versions "$dst" "$keep"
|
|
fi
|
|
}
|
|
|
|
# ── Cron helper ───────────────────────────────────────────────────────────────
|
|
show_cron_hint() {
|
|
echo ""
|
|
echo " ── Automate with cron ────────────────────────────────────"
|
|
echo ""
|
|
echo " To run a saved job automatically, add a line like this to crontab"
|
|
echo " (edit with: crontab -e):"
|
|
echo ""
|
|
echo " # Daily at 2 AM — run job 'my-backup'"
|
|
echo " 0 2 * * * bash $PWD/$(basename "$0") --job my-backup >> /var/log/rsync-backup.log 2>&1"
|
|
echo ""
|
|
echo " Or use a systemd timer — ask the setup wizard for details."
|
|
echo ""
|
|
}
|
|
|
|
# ── Non-interactive job run (--job NAME) ──────────────────────────────────────
|
|
run_job_by_name() {
|
|
local name="$1"
|
|
load_jobs
|
|
local var="JOB_${name}"
|
|
local job_val="${!var:-}"
|
|
[[ -z "$job_val" ]] && { err "No saved job named '$name'. Run without --job to create one."; exit 1; }
|
|
eval "local _parts=($job_val)"
|
|
local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}"
|
|
run_backup "$name" "$src" "$dst" "$excl"
|
|
}
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
main() {
|
|
# Non-interactive mode
|
|
if [[ "${1:-}" == "--job" ]]; then
|
|
require_rsync
|
|
run_job_by_name "${2:?--job requires a job name}"
|
|
exit $?
|
|
fi
|
|
|
|
require_rsync
|
|
|
|
echo ""
|
|
echo "┌──────────────────────────────────────────────────────────┐"
|
|
echo "│ rsync Mirror Backup with Versioned Deletes │"
|
|
echo "│ │"
|
|
echo "│ dest/current/ — plain mirror (always current) │"
|
|
echo "│ dest/versions/DATE/ — deleted/changed files by day │"
|
|
echo "└──────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
echo " NOTE: Passwordless SSH is required for remote paths."
|
|
echo " Set up with: ssh-copy-id user@remotehost"
|
|
echo ""
|
|
|
|
while true; do
|
|
echo " What would you like to do?"
|
|
echo " 1) Create a new backup job (and optionally run it)"
|
|
echo " 2) Run a saved job"
|
|
echo " 3) Show cron/automation hint"
|
|
echo " 0) Quit"
|
|
echo ""
|
|
read -r -p " Choice [1]: " action
|
|
action="${action:-1}"
|
|
echo ""
|
|
|
|
case "$action" in
|
|
1) wizard_job ;;
|
|
2) run_saved_job ;;
|
|
3) show_cron_hint ;;
|
|
0|q|Q) break ;;
|
|
*) warn "Invalid choice." ;;
|
|
esac
|
|
echo ""
|
|
done
|
|
|
|
ok "Done."
|
|
}
|
|
|
|
main "$@"
|