- Automated backup system using Restic - Systemd timer integration for scheduled backups - Service management tools (start/stop/restore) - Multi-service support with template-based configuration - Backup and restore functionality with test/production modes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
479 lines
13 KiB
Bash
Executable File
479 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# Restic backup restore script
|
|
# Location: /home/citadel/backup/restore
|
|
# Usage: ./restore <service_name> [--test|--production]
|
|
|
|
set -euo pipefail # Strict mode: exit on error, undefined vars, pipe failures
|
|
|
|
# Configuration
|
|
readonly BACKUP_DIR="/home/citadel/backup"
|
|
readonly SERVICES_DIR="/home/citadel/services"
|
|
readonly CONFIG_FILE="$BACKUP_DIR/restic.conf"
|
|
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 <service_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 "/tmp/${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="/tmp/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 "$@"
|