#!/bin/sh # backup.sh - OpenBSD Server Backup Script # Author: Biswa Kalyan Bhuyan (biswa@surgot.in) # # DEPLOYMENT INSTRUCTIONS: # 1. Copy this script to your OpenBSD server: /usr/local/bin/backup.sh # 2. Make it executable: chmod +x /usr/local/bin/backup.sh # 3. Install dependencies: pkg_add rclone mailx # 4. Configure rclone: rclone config (setup 'cf' remote) # 5. Test: backup.sh --dir /etc --dry-run # 6. Run: backup.sh --dir /etc,/var/www,/var/log # 7. Add to crontab for automation # # Usage: ./backup.sh # ./backup.sh --dir /etc,/var/www,/home # ./backup.sh --dir /etc,/var/log --dry-run set -e # Color codes for terminal output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Configuration variables RCLONE_REMOTE="cf:backups/" COMPRESSION_LEVEL=9 EMAIL_RECIPIENT="root@mail.surgot.in" LOG_FILE="/var/log/backup-log-$(date +%Y%m%d).log" HOSTNAME=$(hostname -s) BACKUP_BASE_DIR="/tmp/backups-$(date +%Y%m%d)" DRY_RUN=false # Server settings BACKUP_USER="root" MIN_FREE_SPACE_MB=1024 MAX_LOG_FILES=10 # Write log message with timestamp log_with_timestamp() { local message="$1" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') echo "[$timestamp] $message" >> "$LOG_FILE" } # Print status message with blue color and log it print_status() { local message="${BLUE}[INFO]${NC} $1" log_with_timestamp "[INFO] $1" printf "%s\n" "$message" >&2 } # Print success message with green color and log it print_success() { local message="${GREEN}[SUCCESS]${NC} $1" log_with_timestamp "[SUCCESS] $1" printf "%s\n" "$message" >&2 } # Print warning message with yellow color and log it print_warning() { local message="${YELLOW}[WARNING]${NC} $1" log_with_timestamp "[WARNING] $1" printf "%s\n" "$message" >&2 } # Print error message with red color and log it print_error() { local message="${RED}[ERROR]${NC} $1" log_with_timestamp "[ERROR] $1" printf "%s\n" "$message" >&2 } # Send email with backup log details send_log_email() { local exit_status="$1" local subject_prefix="SUCCESS" if [ "$exit_status" -ne 0 ]; then subject_prefix="FAILED" fi local subject="[$subject_prefix] Backup Script Log - $HOSTNAME - $(date '+%Y-%m-%d %H:%M:%S')" if command -v mail >/dev/null 2>&1; then { echo "Backup script execution log from $HOSTNAME" echo "==========================================" echo "Execution time: $(date)" echo "Exit status: $exit_status" echo "Script arguments: $ORIGINAL_ARGS" echo "" echo "Full log output:" echo "----------------" cat "$LOG_FILE" 2>/dev/null || echo "Log file not found" } | mail -s "$subject" "$EMAIL_RECIPIENT" echo "Log email sent to $EMAIL_RECIPIENT" >&2 else echo "Warning: mail command not available. Log saved to: $LOG_FILE" >&2 fi } # Clean up temporary files and send email on script exit cleanup_and_email() { local exit_code=$? send_log_email "$exit_code" if [ -d "$BACKUP_BASE_DIR" ]; then rm -rf "$BACKUP_BASE_DIR" print_status "Cleaned up temporary directory: $BACKUP_BASE_DIR" fi if [ "$exit_code" -eq 0 ] && [ -f "$LOG_FILE" ]; then rm -f "$LOG_FILE" fi exit $exit_code } trap cleanup_and_email EXIT # Check if script is running with appropriate permissions check_user_permissions() { if [ "$(id -u)" -ne 0 ] && [ "$DRY_RUN" = "false" ]; then print_warning "Not running as root. Some system directories may not be accessible." print_warning "For complete server backups, consider running as root: sudo $0 $ORIGINAL_ARGS" fi local log_dir=$(dirname "$LOG_FILE") if [ ! -w "$log_dir" ]; then print_warning "Cannot write to log directory: $log_dir" LOG_FILE="/tmp/backup-log-$(date +%Y%m%d).log" print_status "Using alternative log location: $LOG_FILE" fi } # Remove old log files keeping only the latest MAX_LOG_FILES cleanup_old_logs() { local log_dir=$(dirname "$LOG_FILE") local log_pattern="backup-log-*.log" if [ -d "$log_dir" ] && [ -w "$log_dir" ]; then find "$log_dir" -name "$log_pattern" -type f 2>/dev/null | \ sort -r | \ tail -n +$((MAX_LOG_FILES + 1)) | \ while read -r old_log; do rm -f "$old_log" 2>/dev/null print_status "Cleaned up old log file: $(basename "$old_log")" done fi } # Initialize log file with header information initialize_log() { local log_dir=$(dirname "$LOG_FILE") if [ ! -d "$log_dir" ]; then mkdir -p "$log_dir" 2>/dev/null || { LOG_FILE="/tmp/backup-log-$(date +%Y%m%d).log" print_warning "Created log in /tmp instead: $LOG_FILE" } fi echo "==================================================" > "$LOG_FILE" echo "Server Backup Script Execution Log" >> "$LOG_FILE" echo "==================================================" >> "$LOG_FILE" echo "Start time: $(date)" >> "$LOG_FILE" echo "Hostname: $HOSTNAME" >> "$LOG_FILE" echo "Script: $0" >> "$LOG_FILE" echo "Arguments: $ORIGINAL_ARGS" >> "$LOG_FILE" echo "User: $(whoami) (UID: $(id -u))" >> "$LOG_FILE" echo "Log file: $LOG_FILE" >> "$LOG_FILE" echo "Server info: $(uname -a)" >> "$LOG_FILE" echo "==================================================" >> "$LOG_FILE" echo "" >> "$LOG_FILE" cleanup_old_logs } # Verify required tools are installed and available check_dependencies() { print_status "Checking dependencies..." if ! command -v tar >/dev/null 2>&1; then print_error "tar is not installed or not in PATH" exit 1 fi if ! command -v gzip >/dev/null 2>&1; then print_error "gzip is not installed or not in PATH" exit 1 fi if [ "$DRY_RUN" = "false" ]; then if ! command -v rclone >/dev/null 2>&1; then print_error "rclone is not installed or not in PATH" print_error "Please install rclone: pkg_add rclone" print_error "Or use --dry-run flag to test without uploading" exit 1 fi else print_warning "DRY RUN mode enabled - skipping rclone check" fi if ! command -v mail >/dev/null 2>&1; then print_warning "mail command not available - logs will be saved locally only" log_with_timestamp "[WARNING] Consider installing mailx: pkg_add mailx" fi print_success "All essential dependencies found" } # Create tar archive from directory with error handling create_tar_archive() { local source_dir="$1" local archive_name="$2" local tar_file="$BACKUP_BASE_DIR/$archive_name.tar" print_status "Creating tar archive for: $source_dir" if [ ! -d "$source_dir" ]; then print_error "Directory does not exist: $source_dir" return 1 fi if [ ! -r "$source_dir" ]; then print_error "Directory is not readable: $source_dir" print_error "Try running as root or check permissions" return 1 fi local dir_size=$(du -sh "$source_dir" 2>/dev/null | cut -f1) print_status "Directory size: $dir_size" local backup_dir_parent=$(dirname "$BACKUP_BASE_DIR") local available_space=$(df "$backup_dir_parent" | awk 'NR==2 {print $4}') local available_space_mb=$(( available_space / 1024 )) print_status "Available space in backup location: ${available_space_mb} MB" if [ "$available_space_mb" -lt "$MIN_FREE_SPACE_MB" ]; then print_error "Insufficient disk space. Available: ${available_space_mb}MB, Required: ${MIN_FREE_SPACE_MB}MB" return 1 fi local start_time=$(date +%s) local parent_dir=$(dirname "$source_dir") local dir_name=$(basename "$source_dir") print_status "Creating archive: $tar_file" if (cd "$parent_dir" && tar -cf "$tar_file" "$dir_name") 2>&1 | tee -a "$LOG_FILE" >&2; then local end_time=$(date +%s) local duration=$(( end_time - start_time )) if [ ! -f "$tar_file" ]; then print_error "Tar file was not created: $tar_file" return 1 fi local tar_size=$(stat -f%z "$tar_file" 2>/dev/null || stat -c%s "$tar_file" 2>/dev/null) if [ "$tar_size" -eq 0 ]; then print_warning "Tar file is empty, this might indicate an issue" fi print_success "Tar archive created in ${duration} seconds" print_status "Archive size: $(( tar_size / 1024 / 1024 )) MB" echo "$tar_file" return 0 else local tar_exit_code=$? print_error "Failed to create tar archive for: $source_dir (exit code: $tar_exit_code)" print_error "Check the log file for detailed error messages: $LOG_FILE" if [ ! -w "$backup_dir_parent" ]; then print_error "Backup directory is not writable: $backup_dir_parent" fi return 1 fi } # Remove leading slash and replace other slashes with hyphens for filenames sanitize_filename() { local path="$1" echo "$path" | sed 's/^\/*//' | sed 's/\//-/g' } # Compress tar file using gzip with maximum compression compress_tarball() { local input_file="$1" local output_file="${input_file}.gz" print_status "Compressing '$(basename "$input_file")' with maximum compression (level $COMPRESSION_LEVEL)..." local original_size=$(stat -f%z "$input_file" 2>/dev/null || stat -c%s "$input_file" 2>/dev/null) log_with_timestamp "Starting compression - Original size: $original_size bytes" local start_time=$(date +%s) if gzip -${COMPRESSION_LEVEL} -c "$input_file" > "$output_file"; then local end_time=$(date +%s) local duration=$(( end_time - start_time )) local compressed_size=$(stat -f%z "$output_file" 2>/dev/null || stat -c%s "$output_file" 2>/dev/null) local compression_ratio=$(( (original_size - compressed_size) * 100 / original_size )) print_success "Compression completed in ${duration} seconds" print_status "Original size: $(( original_size / 1024 / 1024 )) MB" print_status "Compressed size: $(( compressed_size / 1024 / 1024 )) MB" print_status "Compression ratio: ${compression_ratio}%" print_status "Space saved: $(( (original_size - compressed_size) / 1024 / 1024 )) MB" echo "$output_file" return 0 else print_error "Failed to compress '$(basename "$input_file")'" return 1 fi } # Upload file to cloud storage using rclone upload_to_cloud() { local file_to_upload="$1" print_status "Uploading '$(basename "$file_to_upload")' to $RCLONE_REMOTE..." if [ "$DRY_RUN" = "true" ]; then print_warning "DRY RUN: Skipping actual upload" local file_size=$(stat -f%z "$file_to_upload" 2>/dev/null || stat -c%s "$file_to_upload" 2>/dev/null) print_status "Would upload file size: $(( file_size / 1024 / 1024 )) MB" sleep 1 print_success "DRY RUN: Upload simulation completed" return 0 fi if ! command -v rclone >/dev/null 2>&1; then print_error "rclone is not installed or not in PATH" print_error "Please install rclone: pkg_add rclone" return 1 fi if ! rclone listremotes | grep -q "cf:"; then print_error "rclone remote 'cf' is not configured" print_error "Please configure rclone first: rclone config" return 1 fi local file_size=$(stat -f%z "$file_to_upload" 2>/dev/null || stat -c%s "$file_to_upload" 2>/dev/null) print_status "Upload file size: $(( file_size / 1024 / 1024 )) MB" local start_time=$(date +%s) print_status "Starting upload with progress monitoring..." if rclone copy "$file_to_upload" "$RCLONE_REMOTE" --progress --stats=1s >> "$LOG_FILE" 2>&1; then local end_time=$(date +%s) local duration=$(( end_time - start_time )) print_success "Upload completed successfully in ${duration} seconds" return 0 else print_error "Upload failed" return 1 fi } # Remove local tar and compressed files after successful upload cleanup_files() { local original_tar="$1" local compressed_file="$2" print_status "Cleaning up local files..." if [ -f "$original_tar" ]; then local tar_size=$(stat -f%z "$original_tar" 2>/dev/null || stat -c%s "$original_tar" 2>/dev/null) if rm "$original_tar"; then print_success "Removed original tar file: $(basename "$original_tar") ($(( tar_size / 1024 / 1024 )) MB freed)" else print_warning "Failed to remove original tar file: $(basename "$original_tar")" fi fi if [ -f "$compressed_file" ]; then local gz_size=$(stat -f%z "$compressed_file" 2>/dev/null || stat -c%s "$compressed_file" 2>/dev/null) if rm "$compressed_file"; then print_success "Removed compressed file: $(basename "$compressed_file") ($(( gz_size / 1024 / 1024 )) MB freed)" else print_warning "Failed to remove compressed file: $(basename "$compressed_file")" fi fi } # Process single tar file: compress, upload, and optionally cleanup process_tar_file() { local tarball="$1" local auto_cleanup="${2:-false}" print_status "Processing tar file: $(basename "$tarball")" local compressed_file if compressed_file=$(compress_tarball "$tarball"); then if upload_to_cloud "$compressed_file"; then print_success "Successfully processed: $(basename "$tarball")" if [ "$auto_cleanup" = "true" ]; then cleanup_files "$tarball" "$compressed_file" fi return 0 else print_error "Failed to upload: $(basename "$tarball")" return 1 fi else print_error "Failed to compress: $(basename "$tarball")" return 1 fi } # Validate all directories exist and are readable before starting backup validate_backup_directories() { local dir_list="$1" local temp_validation_file="/tmp/dir-validation-$$" local valid_count=0 local invalid_count=0 print_status "Validating directories before backup..." echo "$dir_list" | tr ',' '\n' > "$temp_validation_file" while IFS= read -r dir; do dir=$(echo "$dir" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -z "$dir" ]; then continue fi if [ ! -d "$dir" ]; then print_error "Directory does not exist: $dir" invalid_count=$((invalid_count + 1)) elif [ ! -r "$dir" ]; then print_error "Directory is not readable: $dir" invalid_count=$((invalid_count + 1)) else print_status "✓ Valid directory: $dir" valid_count=$((valid_count + 1)) fi done < "$temp_validation_file" rm -f "$temp_validation_file" if [ $invalid_count -gt 0 ]; then print_error "Found $invalid_count invalid directories. Aborting backup." return 1 fi print_success "All $valid_count directories validated successfully" return 0 } # Create tar archives for multiple directories and upload them backup_directories() { local dir_list="$1" local failed_count=0 local success_count=0 local temp_list_file="/tmp/dir-list-$$" print_status "Starting directory backup mode" print_status "Target directories: $dir_list" if ! validate_backup_directories "$dir_list"; then exit 1 fi if ! mkdir -p "$BACKUP_BASE_DIR"; then print_error "Failed to create backup directory: $BACKUP_BASE_DIR" exit 1 fi print_status "Created temporary backup directory: $BACKUP_BASE_DIR" echo "$dir_list" | tr ',' '\n' > "$temp_list_file" while IFS= read -r dir; do dir=$(echo "$dir" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -z "$dir" ]; then continue fi echo >&2 print_status "=== Processing directory: $dir ===" local sanitized_name=$(sanitize_filename "$dir") local timestamp=$(date +%Y%m%d) local archive_name="${sanitized_name}-${timestamp}" if tar_file=$(create_tar_archive "$dir" "$archive_name"); then if process_tar_file "$tar_file" "true"; then success_count=$((success_count + 1)) print_success "Completed backup of: $dir" else failed_count=$((failed_count + 1)) print_error "Failed backup of: $dir" fi else failed_count=$((failed_count + 1)) print_error "Failed to create archive for: $dir" fi done < "$temp_list_file" rm -f "$temp_list_file" echo >&2 print_status "=== Backup Summary ===" print_status "Successful backups: $success_count" if [ "$failed_count" -gt 0 ]; then print_error "Failed backups: $failed_count" return 1 else print_success "All directory backups completed successfully!" return 0 fi } # Validate input tar file exists and is readable validate_input() { local tarball="$1" if [ -z "$tarball" ]; then print_error "No tarball file specified" exit 1 fi if [ ! -f "$tarball" ]; then print_error "File '$tarball' does not exist" exit 1 fi local file_size=$(stat -f%z "$tarball" 2>/dev/null || stat -c%s "$tarball" 2>/dev/null) print_status "Input file: $tarball" print_status "File size: $(( file_size / 1024 / 1024 )) MB" if ! file "$tarball" | grep -q "tar archive\|POSIX tar archive"; then print_warning "File '$tarball' might not be a valid tar archive" echo -n "Continue anyway? (y/N): " >&2 read -r response case "$response" in [yY]|[yY][eE][sS]) log_with_timestamp "User chose to continue with non-tar file" ;; *) print_error "Operation cancelled by user" exit 1 ;; esac else print_success "File validated as tar archive" fi } # Parse command line arguments and set global variables parse_arguments() { while [ $# -gt 0 ]; do case "$1" in --dir) if [ -z "$2" ]; then print_error "--dir flag requires directory list" exit 1 fi DIRECTORY_MODE=true DIRECTORY_LIST="$2" shift 2 ;; --dry-run) DRY_RUN=true shift ;; --help|-h) show_usage exit 0 ;; -*) print_error "Unknown option: $1" show_usage exit 1 ;; *) if [ -z "$TARBALL_FILE" ]; then TARBALL_FILE="$1" else print_error "Multiple file arguments not supported" exit 1 fi shift ;; esac done } # Display usage information and examples show_usage() { cat >&2 << EOF OpenBSD Server Backup Script ============================ Usage: $0 # Process existing tarball $0 --dir /etc,/var/www,/home # Backup server directories $0 --dir /etc,/var/log,/usr/local # System directories backup Options: --dir DIRS Comma-separated list of directories to backup --dry-run Test mode - skip actual upload to cloud storage --help, -h Show this help message Common Server Backup Examples: sudo $0 --dir /etc,/var/www,/var/log # Web server sudo $0 --dir /etc,/var/mail,/home # Mail server sudo $0 --dir /etc,/var/lib/mysql,/var/www # Database + Web sudo $0 --dir /etc,/usr/local/etc,/var/log # System config This script will: 1. Validate directories and permissions 2. Create individual tar archives for each directory 3. Compress with maximum gzip compression (level 9) 4. Upload to cloud storage using rclone 5. Clean up local files after successful upload 6. Send detailed logs via email to $EMAIL_RECIPIENT 7. Maintain log rotation (keeps last $MAX_LOG_FILES logs) Server Requirements: - Run as root for complete system backups - tar (pre-installed on OpenBSD) - gzip (pre-installed on OpenBSD) - rclone (install with: pkg_add rclone) - mailx (install with: pkg_add mailx) for email notifications - Configured rclone remote named 'cf' - Minimum $MIN_FREE_SPACE_MB MB free space in /tmp Log Location: $LOG_FILE EOF } # Main script execution function main() { print_status "Starting server backup process on $HOSTNAME..." if [ "$DRY_RUN" = "true" ]; then print_warning "DRY RUN MODE ENABLED - No files will be uploaded" fi print_status "Email notifications will be sent to: $EMAIL_RECIPIENT" print_status "Logs will be saved to: $LOG_FILE" echo >&2 check_user_permissions echo >&2 check_dependencies echo >&2 if [ "$DIRECTORY_MODE" = "true" ]; then if backup_directories "$DIRECTORY_LIST"; then print_success "All directory backups completed successfully!" else print_error "Some directory backups failed" exit 1 fi else validate_input "$TARBALL_FILE" echo >&2 if process_tar_file "$TARBALL_FILE"; then print_success "Tarball backup completed successfully!" else print_error "Tarball backup failed" exit 1 fi fi echo >&2 print_success "All operations completed!" } # Global variables for argument parsing ORIGINAL_ARGS="$*" DIRECTORY_MODE=false DIRECTORY_LIST="" TARBALL_FILE="" # Parse command line arguments parse_arguments "$@" # Initialize logging initialize_log # Validate arguments if [ "$DIRECTORY_MODE" = "false" ] && [ -z "$TARBALL_FILE" ]; then show_usage exit 1 fi # Run main function main