From 75a1e66ed21a2fe086e9ac823ead754625d80229 Mon Sep 17 00:00:00 2001 From: Nicolas Duhamel Date: Mon, 23 Jun 2025 17:05:19 +0200 Subject: [PATCH] Initial commit: Add quantumrick backup system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backup/gen-conf.sh | 54 ++++ backup/init-restic.sh | 51 ++++ backup/install-service | 138 ++++++++++ backup/list-snapshots | 156 +++++++++++ backup/manage | 242 +++++++++++++++++ backup/restic.conf | 11 + backup/restore | 478 +++++++++++++++++++++++++++++++++ backup/service-backup@.service | 18 ++ backup/service-backup@.timer | 14 + 9 files changed, 1162 insertions(+) create mode 100644 backup/gen-conf.sh create mode 100644 backup/init-restic.sh create mode 100755 backup/install-service create mode 100755 backup/list-snapshots create mode 100755 backup/manage create mode 100644 backup/restic.conf create mode 100755 backup/restore create mode 100644 backup/service-backup@.service create mode 100644 backup/service-backup@.timer diff --git a/backup/gen-conf.sh b/backup/gen-conf.sh new file mode 100644 index 0000000..7b5cab8 --- /dev/null +++ b/backup/gen-conf.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Script to generate Restic configuration with secure password +# Location: /home/citadel/services/paperless/generate-restic-config.sh + +set -e + +CONFIG_FILE="/home/citadel/backup/restic.conf" +REPO_PATH="/mnt/data/backup/quantumrick" + +echo "=== Generating Restic Configuration ===" + +# Check if config already exists +if [ -f "$CONFIG_FILE" ]; then + echo "Configuration file already exists at $CONFIG_FILE" + read -p "Do you want to overwrite it? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +fi + +# Generate secure password (32 characters) +echo "Generating secure password..." +RESTIC_PASSWORD=$(openssl rand -base64 32) + +# Create configuration file +echo "Creating configuration file..." +cat > "$CONFIG_FILE" << EOF +# Restic configuration for Paperless NGX backups +# Generated on $(date) + +# Repository path +export RESTIC_REPOSITORY="$REPO_PATH" + +# Repository password +export RESTIC_PASSWORD="$RESTIC_PASSWORD" + +# Cache directory (optional) +export RESTIC_CACHE_DIR="/tmp/restic-cache" +EOF + +# Set secure permissions +chmod 600 "$CONFIG_FILE" + +echo "✅ Configuration file created at $CONFIG_FILE" +echo "🔒 Password generated and saved securely" +echo "" +echo "⚠️ IMPORTANT: Save this password somewhere safe!" +echo " If you lose it, you won't be able to restore your backups!" +echo "" +echo "Password: $RESTIC_PASSWORD" +echo "" diff --git a/backup/init-restic.sh b/backup/init-restic.sh new file mode 100644 index 0000000..dfe55b8 --- /dev/null +++ b/backup/init-restic.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Script to initialize Restic repository for Paperless NGX backups +# Location: /home/citadel/services/paperless/init-restic-repo.sh + +set -e + +REPO_PATH="/mnt/data/backup/quantumrick" +CONFIG_FILE="/home/citadel/backup/restic.conf" + +echo "=== Initializing Restic Repository ===" + +# Check if repository directory exists +if [ ! -d "$(dirname "$REPO_PATH")" ]; then + echo "Creating backup directory..." + sudo mkdir -p "$(dirname "$REPO_PATH")" + sudo chown $(whoami):$(whoami) "$(dirname "$REPO_PATH")" +fi + +# Check if config file exists +if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: Configuration file $CONFIG_FILE not found!" + echo "Please run generate-restic-config.sh first to create the configuration." + exit 1 +fi + +# Source the configuration +source "$CONFIG_FILE" + +# Check if repository is already initialized +if restic -r "$REPO_PATH" cat config &>/dev/null; then + echo "Repository already initialized at $REPO_PATH" + exit 0 +fi + +echo "Initializing new Restic repository at $REPO_PATH" + +# Initialize the repository +restic init -r "$REPO_PATH" + +if [ $? -eq 0 ]; then + echo "✅ Repository successfully initialized!" + echo "Repository path: $REPO_PATH" + echo "Configuration: $CONFIG_FILE" +else + echo "❌ Failed to initialize repository" + exit 1 +fi + +echo "=== Repository Information ===" +restic -r "$REPO_PATH" stats diff --git a/backup/install-service b/backup/install-service new file mode 100755 index 0000000..50f9497 --- /dev/null +++ b/backup/install-service @@ -0,0 +1,138 @@ +#!/bin/bash + +# Generic script to install systemd timer for any service backup +# Location: /home/citadel/backup/install-service-timer.sh +# Usage: sudo ./install-service.sh [schedule] + +set -e + +# Check arguments +if [ $# -lt 1 ]; then + echo "Usage: $0 [schedule]" + echo "" + echo "Examples:" + echo " $0 paperless # Daily at 3:00 AM (default)" + echo " $0 nextcloud \"*-*-* 02:30:00\" # Daily at 2:30 AM" + echo " $0 gitlab \"Mon *-*-* 04:00:00\" # Weekly on Monday at 4:00 AM" + echo "" + echo "Available services with backup.sh:" + find /home/citadel/services -name "backup.sh" -printf " %h\n" | sed 's|/home/citadel/services/||' 2>/dev/null || echo " (none found)" + exit 1 +fi + +SERVICE_NAME="$1" +SCHEDULE="${2:-*-*-* 03:00:00}" # Default: daily at 3:00 AM + +SERVICE_DIR="/home/citadel/services/$SERVICE_NAME" +BACKUP_SCRIPT="$SERVICE_DIR/backup.sh" +BACKUP_DIR="/home/citadel/backup" + +# Systemd template files +SERVICE_TEMPLATE="service-backup@.service" +TIMER_TEMPLATE="service-backup@.timer" +SYSTEMD_DIR="/etc/systemd/system" + +# Instance names +SERVICE_INSTANCE="service-backup@$SERVICE_NAME.service" +TIMER_INSTANCE="service-backup@$SERVICE_NAME.timer" + +echo "=== Installing Systemd Timer for $SERVICE_NAME Backup ===" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "This script needs to be run with sudo for systemd operations" + echo "Usage: sudo $0 $SERVICE_NAME" + exit 1 +fi + +# Check if service directory exists +if [ ! -d "$SERVICE_DIR" ]; then + echo "ERROR: Service directory not found: $SERVICE_DIR" + exit 1 +fi + +# Check if backup script exists +if [ ! -f "$BACKUP_SCRIPT" ]; then + echo "ERROR: Backup script not found: $BACKUP_SCRIPT" + echo "Please create a backup.sh script in the service directory" + exit 1 +fi + +# Make backup script executable +chmod +x "$BACKUP_SCRIPT" + +# Check if template files exist +if [ ! -f "$BACKUP_DIR/$SERVICE_TEMPLATE" ]; then + echo "ERROR: Service template not found: $BACKUP_DIR/$SERVICE_TEMPLATE" + exit 1 +fi + +if [ ! -f "$BACKUP_DIR/$TIMER_TEMPLATE" ]; then + echo "ERROR: Timer template not found: $BACKUP_DIR/$TIMER_TEMPLATE" + exit 1 +fi + +# Stop and disable existing timer if it exists +if systemctl is-active --quiet "$TIMER_INSTANCE" 2>/dev/null; then + echo "Stopping existing timer..." + systemctl stop "$TIMER_INSTANCE" +fi + +if systemctl is-enabled --quiet "$TIMER_INSTANCE" 2>/dev/null; then + echo "Disabling existing timer..." + systemctl disable "$TIMER_INSTANCE" +fi + +# Copy template files if they don't exist in systemd directory +if [ ! -f "$SYSTEMD_DIR/$SERVICE_TEMPLATE" ]; then + echo "Installing service template..." + cp "$BACKUP_DIR/$SERVICE_TEMPLATE" "$SYSTEMD_DIR/" + chmod 644 "$SYSTEMD_DIR/$SERVICE_TEMPLATE" +fi + +if [ ! -f "$SYSTEMD_DIR/$TIMER_TEMPLATE" ]; then + echo "Installing timer template..." + cp "$BACKUP_DIR/$TIMER_TEMPLATE" "$SYSTEMD_DIR/" + chmod 644 "$SYSTEMD_DIR/$TIMER_TEMPLATE" +fi + +# Create custom timer with specific schedule if different from default +if [ "$SCHEDULE" != "*-*-* 03:00:00" ]; then + echo "Creating custom timer with schedule: $SCHEDULE" + sed "s|OnCalendar=\*-\*-\* 03:00:00|OnCalendar=$SCHEDULE|" \ + "$BACKUP_DIR/$TIMER_TEMPLATE" > "$SYSTEMD_DIR/$TIMER_INSTANCE" + chmod 644 "$SYSTEMD_DIR/$TIMER_INSTANCE" +fi + +# Reload systemd +echo "Reloading systemd daemon..." +systemctl daemon-reload + +# Enable and start timer +echo "Enabling and starting timer for $SERVICE_NAME..." +systemctl enable "service-backup@$SERVICE_NAME.timer" +systemctl start "service-backup@$SERVICE_NAME.timer" + +# Create log file with proper permissions +LOG_FILE="/var/log/$SERVICE_NAME-backup.log" +touch "$LOG_FILE" +chown citadel:citadel "$LOG_FILE" + +echo "" +echo "✅ Systemd timer installed successfully for $SERVICE_NAME!" +echo "" +echo "📋 Useful commands:" +echo " Check timer status: systemctl status service-backup@$SERVICE_NAME.timer" +echo " View timer schedule: systemctl list-timers service-backup@$SERVICE_NAME.timer" +echo " Run backup manually: systemctl start service-backup@$SERVICE_NAME.service" +echo " View backup logs: journalctl -u service-backup@$SERVICE_NAME.service" +echo " View recent logs: journalctl -u service-backup@$SERVICE_NAME.service -f" +echo " Stop timer: systemctl stop service-backup@$SERVICE_NAME.timer" +echo " Disable timer: systemctl disable service-backup@$SERVICE_NAME.timer" +echo "" +echo "📅 Schedule: $SCHEDULE (with up to 5min random delay)" +echo "📝 Logs: Available via journalctl and $LOG_FILE" + +echo "" +echo "🔍 Current timer status:" +systemctl status "service-backup@$SERVICE_NAME.timer" --no-pager -l || true diff --git a/backup/list-snapshots b/backup/list-snapshots new file mode 100755 index 0000000..6d87801 --- /dev/null +++ b/backup/list-snapshots @@ -0,0 +1,156 @@ +#!/bin/bash + +# Script to list available snapshots from Restic repository +# Location: /home/citadel/backup/list-snapshots +# Usage: ./list-snapshots [service_name] + +set -e + +# Configuration +BACKUP_DIR="/home/citadel/backup" +CONFIG_FILE="$BACKUP_DIR/restic.conf" + +# Colors for output +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +show_help() { + echo "Usage: $0 [service_name]" + echo "" + echo "List available snapshots from Restic repository" + echo "" + echo "Examples:" + echo " $0 # List all snapshots grouped by service" + echo " $0 paperless # List snapshots for paperless service only" + echo "" +} + +log() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +list_all_snapshots() { + log "All available snapshots:" + echo "" + + local snapshots=$(restic snapshots --json 2>/dev/null) + + if [ -z "$snapshots" ] || [ "$snapshots" = "null" ] || [ "$snapshots" = "[]" ]; then + echo "No snapshots found in repository." + return + fi + + # Filter out timestamp tags (like paperless-20250609-115655) and retention tags + local services=$(echo "$snapshots" | jq -r '.[].tags[]' | grep -v -E '^(daily|weekly|monthly|yearly)$' | grep -v -E '^.*-[0-9]{8}-[0-9]{6}$' | sort -u) + + for service in $services; do + echo -e "${GREEN}=== $service ===${NC}" + + # Display snapshots for this service + echo "$snapshots" | jq -r ".[] | select(.tags[] == \"$service\") | \"\(.short_id) \(.time[:19]) \(.summary.total_files_processed) \((.summary.total_bytes_processed / 1024 / 1024 / 1024 * 100 | floor) / 100)\"" | \ + awk 'BEGIN {printf "%-10s %-20s %-10s %s\n", "ID", "DATE", "FILES", "SIZE(GB)"; print "-------------------------------------------------------"} {printf "%-10s %-20s %-10s %.2f\n", $1, $2, $3, $4}' + + echo "" + done +} + +list_service_snapshots() { + local service="$1" + + log "Snapshots for service: $service" + echo "" + + local snapshots=$(restic snapshots --tag "$service" --json 2>/dev/null) + + if [ -z "$snapshots" ] || [ "$snapshots" = "null" ] || [ "$snapshots" = "[]" ]; then + echo "No snapshots found for service: $service" + return + fi + + # Display snapshots in a readable format + echo "$snapshots" | jq -r '.[] | "\(.short_id) \(.time[:19]) \(.tags | join(",")) \(.summary.total_files_processed) \((.summary.total_bytes_processed / 1024 / 1024 / 1024 * 100 | floor) / 100)"' | \ + awk 'BEGIN {printf "%-10s %-20s %-35s %-10s %s\n", "ID", "DATE", "TAGS", "FILES", "SIZE(GB)"; print "-------------------------------------------------------------------------------------"} {printf "%-10s %-20s %-35s %-10s %.2f\n", $1, $2, $3, $4, $5}' + + echo "" + + # Show summary + local count=$(echo "$snapshots" | jq length) + local total_size=$(echo "$snapshots" | jq -r '.[].summary.total_bytes_processed' | awk '{sum+=$1} END {printf "%.2f", sum/1024/1024/1024}') + local oldest=$(echo "$snapshots" | jq -r '.[0].time[:19]' 2>/dev/null) + local newest=$(echo "$snapshots" | jq -r '.[-1].time[:19]' 2>/dev/null) + + echo -e "${YELLOW}Summary:${NC}" + echo " Total snapshots: $count" + echo " Total size: ${total_size}GB" + if [ "$oldest" != "$newest" ]; then + echo " Date range: $oldest to $newest" + else + echo " Date: $oldest" + fi +} + +show_repository_info() { + log "Repository information:" + echo "" + + # Repository stats + restic stats --mode raw-data 2>/dev/null | head -10 + + echo "" + log "Repository status:" + restic check --read-data-subset=1% 2>/dev/null && echo "✅ Repository is healthy" || echo "⚠️ Repository check failed or not available" +} + +# Main script logic +main() { + local service="${1:-}" + + if [ "$service" = "--help" ] || [ "$service" = "-h" ] || [ "$service" = "help" ]; then + show_help + exit 0 + fi + + # Check if we're in the backup directory or config exists + if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: Configuration file not found: $CONFIG_FILE" + echo "Make sure you're running this from the backup directory or have valid restic.conf" + exit 1 + fi + + # Source Restic configuration + source "$CONFIG_FILE" + + # Test repository access + if ! restic snapshots >/dev/null 2>&1; then + echo "Error: Cannot access Restic repository" + echo "Check your configuration and repository password" + exit 1 + fi + + # List snapshots + if [ -n "$service" ]; then + list_service_snapshots "$service" + else + list_all_snapshots + fi + + # Show repository info + echo "" + show_repository_info +} + +# Check for required tools +if ! command -v restic &> /dev/null; then + echo "Error: restic is not installed or not in PATH" + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "Error: jq is not installed (required for JSON parsing)" + exit 1 +fi + +# Run main function +main "$@" diff --git a/backup/manage b/backup/manage new file mode 100755 index 0000000..6313a28 --- /dev/null +++ b/backup/manage @@ -0,0 +1,242 @@ +#!/bin/bash + +# Script to manage backup timers for all services +# Location: /home/citadel/backup/manage + +set -e + +BACKUP_DIR="/home/citadel/backup" +SERVICES_DIR="/home/citadel/services" + +show_help() { + echo "Usage: $0 [service_name]" + echo "" + echo "Backup Commands:" + echo " list List all backup timers and their status" + echo " status [service] Show detailed status for service (or all)" + echo " start Start backup timer for service" + echo " stop Stop backup timer for service" + echo " restart Restart backup timer for service" + echo " enable Enable backup timer for service" + echo " disable Disable backup timer for service" + echo " run Run backup manually for service" + echo " logs Show logs for service backup" + echo " install Install timer for service" + echo " remove Remove timer for service" + echo " available List services with backup.sh available" + echo "" + echo "Restore Commands:" + echo " snapshots [service] List available snapshots" + echo " restore Restore service (test mode)" + echo " restore-prod Restore service (production mode)" + echo " cleanup-test [service] Clean up test restore instances" + echo "" + echo "Examples:" + echo " $0 list" + echo " $0 status paperless" + echo " $0 run paperless" + echo " $0 logs paperless" + echo " $0 snapshots paperless" + echo " $0 restore paperless" + echo " $0 cleanup-test" +} + +list_timers() { + echo "=== Backup Timers Status ===" + echo "" + + # Get all service-backup timers + local timer_output=$(systemctl list-timers "service-backup@*.timer" --all --no-legend 2>/dev/null || true) + + if [ -z "$timer_output" ]; then + echo "No backup timers found." + echo "" + echo "Available services with backup.sh:" + find "$SERVICES_DIR" -name "backup.sh" -printf " %h\n" | sed "s|$SERVICES_DIR/||" 2>/dev/null || echo " (none found)" + return + fi + + printf "%-20s %-10s %-15s %s\n" "SERVICE" "STATUS" "ENABLED" "NEXT RUN" + printf "%-20s %-10s %-15s %s\n" "-------" "------" "-------" "--------" + + # Parse each line from systemctl list-timers + # Format: NEXT(1-4) LEFT(5) LAST(6-9) PASSED(10) UNIT(11) ACTIVATES(12) + echo "$timer_output" | while read -r line; do + # Extract timer name (11th field) + local timer=$(echo "$line" | awk '{print $11}') + + # Extract service name from timer name + local service_name=$(echo "$timer" | sed 's/service-backup@\(.*\)\.timer/\1/') + + # Skip if service name is empty + if [ -z "$service_name" ]; then + continue + fi + + # Get next run (first 4 fields for date/time) + local next_run=$(echo "$line" | awk '{print $1, $2, $3, $4}') + + # Get status and enabled state + local status=$(systemctl is-active "$timer" 2>/dev/null || echo "inactive") + local enabled=$(systemctl is-enabled "$timer" 2>/dev/null || echo "disabled") + + printf "%-20s %-10s %-15s %s\n" "$service_name" "$status" "$enabled" "$next_run" + done +} + +show_status() { + local service="$1" + + if [ -n "$service" ]; then + echo "=== Status for $service ===" + systemctl status "service-backup@$service.timer" --no-pager -l + echo "" + echo "Next scheduled runs:" + systemctl list-timers "service-backup@$service.timer" --no-legend + else + echo "=== All Backup Timers Status ===" + systemctl list-timers "service-backup@*.timer" --no-legend + fi +} + +manage_timer() { + local action="$1" + local service="$2" + + if [ -z "$service" ]; then + echo "ERROR: Service name required for $action" + exit 1 + fi + + local timer="service-backup@$service.timer" + local service_unit="service-backup@$service.service" + + case $action in + start) + systemctl start "$timer" + echo "✅ Started timer for $service" + ;; + stop) + systemctl stop "$timer" + echo "✅ Stopped timer for $service" + ;; + restart) + systemctl restart "$timer" + echo "✅ Restarted timer for $service" + ;; + enable) + systemctl enable "$timer" + echo "✅ Enabled timer for $service" + ;; + disable) + systemctl disable "$timer" + echo "✅ Disabled timer for $service" + ;; + run) + echo "Running backup for $service..." + systemctl start "$service_unit" + echo "✅ Backup started for $service" + echo "Monitor with: journalctl -u $service_unit -f" + ;; + logs) + journalctl -u "$service_unit" --no-pager -l + ;; + remove) + if systemctl is-active --quiet "$timer" 2>/dev/null; then + systemctl stop "$timer" + fi + if systemctl is-enabled --quiet "$timer" 2>/dev/null; then + systemctl disable "$timer" + fi + echo "✅ Removed timer for $service" + ;; + esac +} + +install_timer() { + local service="$1" + + if [ -z "$service" ]; then + echo "ERROR: Service name required for install" + exit 1 + fi + + if [ "$EUID" -ne 0 ]; then + echo "ERROR: Install requires sudo privileges" + echo "Usage: sudo $0 install $service" + exit 1 + fi + + "$BACKUP_DIR/install" "$service" +} + +list_available() { + echo "=== Services with backup.sh available ===" + echo "" + + local found=false + for service_dir in "$SERVICES_DIR"/*; do + if [ -d "$service_dir" ] && [ -f "$service_dir/backup.sh" ]; then + local service_name=$(basename "$service_dir") + local timer_status="not installed" + + if systemctl is-enabled --quiet "service-backup@$service_name.timer" 2>/dev/null; then + timer_status="installed" + fi + + printf " %-20s (timer: %s)\n" "$service_name" "$timer_status" + found=true + fi + done + + if [ "$found" = false ]; then + echo "No services with backup.sh found in $SERVICES_DIR" + fi +} + +# Main script logic +case "${1:-}" in + list) + list_timers + ;; + status) + show_status "$2" + ;; + start|stop|restart|enable|disable|run|logs|remove) + if [ "$1" = "remove" ] || [ "$1" = "enable" ] || [ "$1" = "disable" ] || [ "$1" = "start" ] || [ "$1" = "stop" ] || [ "$1" = "restart" ]; then + if [ "$EUID" -ne 0 ]; then + echo "ERROR: $1 requires sudo privileges" + echo "Usage: sudo $0 $1 $2" + exit 1 + fi + fi + manage_timer "$1" "$2" + ;; + install) + install_timer "$2" + ;; + available) + list_available + ;; + snapshots) + "$BACKUP_DIR/list-snapshots" "$2" + ;; + restore) + "$BACKUP_DIR/restore" "$2" --test + ;; + restore-prod) + "$BACKUP_DIR/restore" "$2" --production + ;; + cleanup-test) + "$BACKUP_DIR/cleanup-test" "$2" + ;; + help|--help|-h) + show_help + ;; + *) + echo "ERROR: Unknown command '${1:-}'" + echo "" + show_help + exit 1 + ;; +esac diff --git a/backup/restic.conf b/backup/restic.conf new file mode 100644 index 0000000..1e55159 --- /dev/null +++ b/backup/restic.conf @@ -0,0 +1,11 @@ +# Restic configuration for Paperless NGX backups +# Generated on Mon Jun 9 11:45:41 CEST 2025 + +# Repository path +export RESTIC_REPOSITORY="/mnt/data/backup/quantumrick" + +# Repository password +export RESTIC_PASSWORD="CHANGE-ME" + +# Cache directory (optional) +export RESTIC_CACHE_DIR="/tmp/restic-cache" diff --git a/backup/restore b/backup/restore new file mode 100755 index 0000000..4f239e9 --- /dev/null +++ b/backup/restore @@ -0,0 +1,478 @@ +#!/bin/bash + +# Restic backup restore script +# Location: /home/citadel/backup/restore +# Usage: ./restore [--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 [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 "$@" diff --git a/backup/service-backup@.service b/backup/service-backup@.service new file mode 100644 index 0000000..dfa64ae --- /dev/null +++ b/backup/service-backup@.service @@ -0,0 +1,18 @@ +[Unit] +Description=Backup Service for %i +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +User=citadel +Group=citadel +WorkingDirectory=/home/citadel/services/%i +ExecStart=/home/citadel/services/%i/backup.sh +StandardOutput=journal +StandardError=journal +Environment=PATH=/usr/local/bin:/usr/bin:/bin + + +[Install] +WantedBy=multi-user.target diff --git a/backup/service-backup@.timer b/backup/service-backup@.timer new file mode 100644 index 0000000..cb97489 --- /dev/null +++ b/backup/service-backup@.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Daily Backup Timer for %i +Requires=service-backup@%i.service + +[Timer] +# Run daily at 3:00 AM +OnCalendar=*-*-* 03:00:00 +# If system was down during scheduled time, run on next boot +Persistent=true +# Add randomization to avoid conflicts with other services +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target