#!/bin/bash # Restic backup restore script # This script should only be called via ./manage restore/restore-prod set -euo pipefail # Strict mode: exit on error, undefined vars, pipe failures # Check if called via manage script if [ "${CALLED_FROM_MANAGE:-}" != "true" ]; then echo "ERROR: This script should not be called directly." echo "Use: ./manage restore or ./manage restore-prod " exit 1 fi # Configuration should be sourced before running: source backup.env # Configuration readonly BACKUP_DIR="$BACKUP_BASE_DIR" readonly SERVICES_DIR="$SERVICES_BASE_DIR" readonly CONFIG_FILE="$RESTIC_CONFIG_FILE" readonly SCRIPT_NAME=$(basename "$0") readonly VERSION="2.1.0" # Global variables USER_CHOICE="" # Colors readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[1;33m' readonly BLUE='\033[0;34m' readonly NC='\033[0m' # Logging functions log() { echo -e "${BLUE}[$(date '+%H:%M:%S')]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1" >&2; exit 1; } success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } # Check dependencies check_dependencies() { local deps=("restic" "jq" "awk" "grep") local missing=() for dep in "${deps[@]}"; do if ! command -v "$dep" &>/dev/null; then missing+=("$dep") fi done if [ ${#missing[@]} -ne 0 ]; then error "Missing dependencies: ${missing[*]}" fi } # Show help show_help() { cat << EOF Usage: $SCRIPT_NAME [OPTIONS] Options: --test Restore to test instance (default) --production Restore to production instance -h, --help Show this help message -v, --version Show version Examples: $SCRIPT_NAME paperless # Restore in test mode (default) $SCRIPT_NAME paperless --test # Restore to test instance $SCRIPT_NAME paperless --production # Restore to production (requires confirmation) Available services: EOF if [ -f "$CONFIG_FILE" ]; then # Secure validation of config file if grep -qE '^[^#]*export\s+RESTIC_' "$CONFIG_FILE"; then # shellcheck source=/dev/null source "$CONFIG_FILE" # List services from snapshots if restic snapshots &>/dev/null; then restic snapshots --json 2>/dev/null | \ jq -r '.[].tags[]' 2>/dev/null | \ grep -v -E '^(daily|weekly|monthly|yearly)$' | \ sort -u | sed 's/^/ /' || echo " (cannot list services)" else echo " (cannot access repository)" fi else error "Invalid configuration file format" fi else echo " (no config found)" fi } # Check prerequisites check_prerequisites() { # Check configuration file if [ ! -f "$CONFIG_FILE" ]; then error "Config file not found: $CONFIG_FILE" fi # Check config file permissions if [ ! -r "$CONFIG_FILE" ]; then error "Cannot read config file: $CONFIG_FILE" fi # Secure validation before source if ! grep -qE '^[^#]*export\s+RESTIC_' "$CONFIG_FILE"; then error "Invalid configuration file format" fi # Source config # shellcheck source=/dev/null source "$CONFIG_FILE" # Check required variables if [ -z "${RESTIC_REPOSITORY:-}" ]; then error "RESTIC_REPOSITORY not defined in config" fi # Test restic access if ! restic snapshots &>/dev/null; then error "Cannot access Restic repository" fi log "Prerequisites OK" } # Show available snapshots show_snapshots() { local service="$1" # Validate service name if [[ ! "$service" =~ ^[a-zA-Z0-9_-]+$ ]]; then error "Invalid service name format" fi echo "" log "Available snapshots for $service:" echo "" # Get snapshots with error handling local snapshots_json if ! snapshots_json=$(restic snapshots --tag "$service" --json 2>/dev/null); then error "Failed to retrieve snapshots" fi if [ -z "$snapshots_json" ] || [ "$snapshots_json" = "[]" ]; then warning "No snapshots found for service: $service" return 1 fi # Format and display snapshots echo "$snapshots_json" | jq -r '.[] | "\(.short_id) \(.time[:19]) \(.summary.total_files_processed // "?") files"' | \ awk 'BEGIN {printf "%-12s %-20s %s\n", "ID", "DATE", "FILES"; print "----------------------------------------"} {printf "%-12s %-20s %s\n", $1, $2, $3" "$4}' echo "" return 0 } # Get user choice get_user_choice() { local max_attempts=3 local attempt=0 while [ $attempt -lt $max_attempts ]; do echo "" echo "Enter snapshot ID (or 'latest' for most recent, 'q' to quit):" echo -n "> " read -r USER_CHOICE echo "" # Basic validation if [ "$USER_CHOICE" = "q" ] || [ "$USER_CHOICE" = "quit" ]; then log "Operation cancelled by user" exit 0 fi if [ -n "$USER_CHOICE" ]; then # Format validation (alphanumeric or "latest") if [[ "$USER_CHOICE" =~ ^[a-zA-Z0-9]+$ ]] || [ "$USER_CHOICE" = "latest" ]; then return 0 else warning "Invalid snapshot ID format. Please use alphanumeric characters only." fi else warning "No selection made" fi ((attempt++)) done error "Maximum attempts exceeded" } # Resolve snapshot ID resolve_snapshot_id() { local service="$1" local choice="$2" if [ "$choice" = "latest" ]; then local latest_id latest_id=$(restic snapshots --tag "$service" --json 2>/dev/null | jq -r '.[-1].short_id // empty') if [ -z "$latest_id" ]; then error "No snapshots found for service: $service" fi echo "$latest_id" else echo "$choice" fi } # Verify snapshot verify_snapshot() { local service="$1" local snapshot_id="$2" log "Verifying snapshot $snapshot_id for service $service" # Get snapshot info local snapshot_info snapshot_info=$(restic snapshots --tag "$service" --json 2>/dev/null | \ jq -r ".[] | select(.short_id == \"$snapshot_id\")") if [ -z "$snapshot_info" ]; then error "Snapshot $snapshot_id not found for service $service" fi # Extract date local date date=$(echo "$snapshot_info" | jq -r '.time[:19]') # Check integrity (optional but recommended) if restic check --read-data-subset=1/10 "$snapshot_id" &>/dev/null; then success "Found snapshot $snapshot_id from $date (integrity check passed)" else warning "Found snapshot $snapshot_id from $date (integrity check skipped)" fi } # Check service capabilities check_service_capabilities() { local service="$1" local service_dir="$SERVICES_DIR/$service" echo "" log "Checking service capabilities for $service" # Check service directory exists if [ ! -d "$service_dir" ]; then error "Service directory not found: $service_dir" fi # Check for custom restore script local custom_restore="$service_dir/restore" local has_custom_restore=false if [ -f "$custom_restore" ] && [ -x "$custom_restore" ]; then has_custom_restore=true success "Found custom restore script: $custom_restore" else log "No custom restore script found - will extract to temporary directory" fi echo "$has_custom_restore" } # Create secure temporary directory create_secure_temp_dir() { local prefix="${1:-restore}" local temp_dir # Use mktemp to create a secure temporary directory temp_dir=$(mktemp -d "$TEMP_DIR/${prefix}-$(date +%Y%m%d-%H%M%S)-XXXXXX") if [ ! -d "$temp_dir" ]; then error "Failed to create temporary directory" fi # Set restrictive permissions chmod 700 "$temp_dir" echo "$temp_dir" } # Extract snapshot to temporary directory extract_snapshot_to_temp() { local snapshot_id="$1" local temp_dir temp_dir=$(create_secure_temp_dir "restore") log "Extracting snapshot $snapshot_id to temporary directory" log "Target directory: $temp_dir" # Extract with error handling if ! restic restore "$snapshot_id" --target "$temp_dir" 2>&1 | tee -a "$temp_dir/restore.log"; then error "Failed to extract snapshot" fi success "Snapshot extracted to: $temp_dir" echo "" echo "📁 Contents of the restore:" ls -la "$temp_dir" | head -20 echo "" echo "💡 Manual restore instructions:" echo " 1. Review the extracted data in: $temp_dir" echo " 2. Copy the required files to their destination" echo " 3. Verify permissions and ownership" echo " 4. Remove the temporary directory: rm -rf $temp_dir" echo "" echo "⚠️ The directory will remain until you delete it manually" } # Run custom restore run_custom_restore() { local service="$1" local snapshot_id="$2" local mode="$3" local service_dir="$SERVICES_DIR/$service" local custom_restore="$service_dir/restore" log "Running custom restore script for $service" log "Script: $custom_restore" log "Snapshot: $snapshot_id" log "Mode: $mode" # Create restore log local log_file="$TEMP_DIR/restore-${service}-$(date +%Y%m%d-%H%M%S).log" # Change directory with verification if ! cd "$service_dir"; then error "Cannot change to service directory: $service_dir" fi # Execute restore script with log capture if "$custom_restore" "$snapshot_id" "--$mode" 2>&1 | tee "$log_file"; then success "Custom restore script completed" log "Restore log saved to: $log_file" else error "Custom restore script failed. Check log: $log_file" fi } # Main function main() { # Check dependencies check_dependencies # Parse arguments if [ $# -eq 0 ]; then show_help exit 0 fi local service="" local mode="test" # Default mode: test (safer) # Parse arguments while [ $# -gt 0 ]; do case "$1" in --test) mode="test" shift ;; --production) mode="production" shift ;; -h|--help) show_help exit 0 ;; -v|--version) echo "$SCRIPT_NAME version $VERSION" exit 0 ;; -*) error "Unknown option: $1" ;; *) if [ -z "$service" ]; then service="$1" else error "Too many arguments" fi shift ;; esac done # Check service is specified if [ -z "$service" ]; then error "Service name required" fi # Validate service name format if [[ ! "$service" =~ ^[a-zA-Z0-9_-]+$ ]]; then error "Invalid service name format. Use only alphanumeric characters, hyphens, and underscores." fi # Check service directory exists if [ ! -d "$SERVICES_DIR/$service" ]; then error "Service directory not found: $SERVICES_DIR/$service" fi echo "=== Restore $service ===" log "Mode: $mode" # Step 1: Check prerequisites check_prerequisites # Step 2: Check service capabilities local has_custom_restore has_custom_restore=$(check_service_capabilities "$service") # Step 3: Show snapshots if ! show_snapshots "$service"; then exit 1 fi # Step 4: Get user choice get_user_choice local choice="$USER_CHOICE" # Step 5: Resolve and verify snapshot local snapshot_id snapshot_id=$(resolve_snapshot_id "$service" "$choice") verify_snapshot "$service" "$snapshot_id" # Step 6: Display restore plan echo "" log "Restore plan:" echo " Service: $service" echo " Snapshot: $snapshot_id" echo " Mode: $mode" if [ "$has_custom_restore" = "true" ]; then echo " Method: Custom restore script" else echo " Method: Extract to temporary directory" fi # Step 7: Final confirmation echo "" if [ "$mode" = "test" ]; then read -p "Proceed with TEST restore? [y/N]: " -r confirm if [[ ! $confirm =~ ^[Yy]$ ]]; then echo "Cancelled." exit 0 fi else echo -e "${RED}⚠️ WARNING: This is a PRODUCTION restore!${NC}" echo -e "${RED}This operation may overwrite existing data.${NC}" echo "" read -p "Type 'yes-production' to proceed: " -r confirm if [ "$confirm" != "yes-production" ]; then echo "Cancelled." exit 0 fi fi # Step 8: Execute restore echo "" if [ "$has_custom_restore" = "true" ]; then run_custom_restore "$service" "$snapshot_id" "$mode" else # No custom script, extract to temp for both test and production extract_snapshot_to_temp "$snapshot_id" fi echo "" success "Restore operation completed!" } # Trap to clean up on interruption trap 'echo -e "\n${RED}Interrupted${NC}"; exit 130' INT TERM # Entry point main "$@"