#! /bin/bash set -Eeo pipefail umask 077 VERSION="1.1.0" CONFIG="backup.cfg" # default config path; can be overridden with -c/--config DRY_RUN=false tmpdir="" cleanup() { if [ -n "${tmpdir-}" ] && [ -d "$tmpdir" ]; then rm -rf "$tmpdir" fi } trap cleanup EXIT function usage { cat >&2 <&2 usage exit 1 fi ;; -n|--dry-run) DRY_RUN=true shift ;; -h|--help) usage exit 0 ;; -v|--version) show_version exit 0 ;; --) shift break ;; -*) echo "Unknown option: $1" >&2 usage exit 1 ;; *) shift ;; esac done } function log_enabled { local needle="$1" local item for item in "${log_to_backup[@]:-}"; do if [ "$item" = "$needle" ]; then return 0 fi done return 1 } function require_cmd { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "Error: required command '$cmd' not found in PATH." >&2 exit 1 fi } function preflight { require_cmd tar if [ "${rsync_push:-false}" = true ]; then require_cmd rsync require_cmd ssh fi if [ "${docker_enabled:-false}" = true ]; then require_cmd docker fi if [ "${db_backup:-false}" = true ]; then case "${database_type:-}" in mysql) require_cmd mysqldump ;; postgresql) require_cmd pg_dump require_cmd pg_dumpall ;; *) echo "Error: database_type must be 'mysql' or 'postgresql' when db_backup is true." >&2 exit 1 ;; esac fi } function init { echo "Backify is starting, looking for configuration file..." >&2 config="$CONFIG" secured_config='sbackup.cfg' if [ ! -f "$config" ]; then echo "Error: Config file not found: $config" >&2 echo "Please create a config file or specify the location of an existing file (use -c/--config)." >&2 exit 1 fi if grep -E -q -v '^#|^[^ ]*=[^;]*' "$config"; then echo "Config file is unclean, cleaning it..." >&2 grep -E '^#|^[^ ]*=[^;&]*' "$config" >"$secured_config" config="$secured_config" fi source "$config" echo "Configuration file loaded" >&2 if [ "$EUID" -ne 0 ]; then echo "Please run as root" >&2 exit 1 fi mkdir -p "$backup_path" if [ ! -w "$backup_path" ]; then echo "Error: backup_path '$backup_path' is not writable." >&2 exit 1 fi : "${retention_days:=0}" : "${retention_keep_min:=0}" : "${pre_backup_hook:=}" : "${post_backup_hook:=}" if ! declare -p log_to_backup >/dev/null 2>&1; then log_to_backup=() fi if ! declare -p custom_dirs >/dev/null 2>&1; then custom_dirs=() fi if ! declare -p targets >/dev/null 2>&1; then targets=() fi } function detect_system { echo "Detecting OS type..." >&2 if [ -r /etc/os-release ]; then . /etc/os-release case "$ID" in rhel|centos|rocky|almalinux) echo "Discovered Red Hat-based OS..." >&2 SYSTEM='rhel' ;; debian|ubuntu) echo "Discovered Debian-based OS..." >&2 SYSTEM='debian' ;; *) echo "Error: Unsupported OS: $ID" >&2 exit 1 ;; esac elif [ -f /etc/redhat-release ]; then echo "Discovered Red Hat-based OS via legacy detection..." >&2 SYSTEM='rhel' elif [ -f /etc/lsb-release ]; then echo "Discovered Debian-based OS via legacy detection..." >&2 SYSTEM='debian' else echo "Error: Unable to detect OS type." >&2 exit 1 fi } function makedir { timestamp=$(date +%Y%m%d_%H%M) tmpdir="$backup_path/backify-$timestamp" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create temporary directory $tmpdir" >&2 else mkdir -p "$tmpdir" fi } function wwwbackup { if [ "$www_backup" = true ]; then echo "Backing up wwwroot..." >&2 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy $www_dir to $tmpdir/wwwdata" >&2 return fi mkdir -p "$tmpdir/wwwdata" cp -r "$www_dir/" "$tmpdir/wwwdata/" echo "Finished" >&2 fi } function vhostbackup { if [ "$vhost_backup" = true ]; then echo "Backing up vhosts..." >&2 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy $vhost_dir to $tmpdir/vhosts" >&2 return fi mkdir -p "$tmpdir/vhosts" cp -avr "$vhost_dir/" "$tmpdir/vhosts/" echo "Finished" >&2 fi } function logbackup { if [ "$log_backup" = true ]; then echo "Backing up system logs..." >&2 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would collect selected logs into $tmpdir/syslogs" >&2 else mkdir -p "$tmpdir/syslogs" fi case "$SYSTEM" in "rhel") if log_enabled "fail2ban"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/fail2ban.log" >&2 else cp /var/log/fail2ban.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "apache"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/httpd to $tmpdir/apachelogs" >&2 else mkdir -p "$tmpdir/apachelogs" cp -r /var/log/httpd "$tmpdir/apachelogs" 2>/dev/null || true fi fi if log_enabled "nginx"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/nginx to $tmpdir/nginxlogs" >&2 else mkdir -p "$tmpdir/nginxlogs" cp -r /var/log/nginx "$tmpdir/nginxlogs" 2>/dev/null || true fi fi if log_enabled "pckg_mngr"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy yum/dnf logs to $tmpdir/syslogs/yum" >&2 else mkdir -p "$tmpdir/syslogs/yum" cp -r /var/log/yum/* "$tmpdir/syslogs/yum/" 2>/dev/null || true cp -r /var/log/dnf* "$tmpdir/syslogs/yum/" 2>/dev/null || true fi fi if log_enabled "letsencrypt"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/letsencrypt to $tmpdir/syslogs/letsencrypt" >&2 else mkdir -p "$tmpdir/syslogs/letsencrypt" cp -r /var/log/letsencrypt/* "$tmpdir/syslogs/letsencrypt/" 2>/dev/null || true fi fi if log_enabled "php"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/php*.log to $tmpdir/syslogs" >&2 else cp -r /var/log/php*.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "syslog"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/secure to $tmpdir/syslogs" >&2 else cp -r /var/log/secure "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "purge"; then echo "Purging logs..." >&2 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would truncate and clear configured logs on RHEL system" >&2 else truncate -s 0 /var/log/messages 2>/dev/null || true truncate -s 0 /var/log/syslog 2>/dev/null || true if log_enabled "apache"; then truncate -s 0 /var/log/httpd/* 2>/dev/null || true rm /var/log/httpd/*.gz 2>/dev/null || true fi if log_enabled "nginx"; then truncate -s 0 /var/log/nginx/* 2>/dev/null || true rm /var/log/nginx/*.gz 2>/dev/null || true fi if log_enabled "fail2ban"; then truncate -s 0 /var/log/fail2ban.log 2>/dev/null || true fi if log_enabled "pckg_mngr"; then truncate -s 0 /var/log/yum/* 2>/dev/null || true truncate -s 0 /var/log/dnf* 2>/dev/null || true fi if log_enabled "letsencrypt"; then truncate -s 0 /var/log/letsencrypt/* 2>/dev/null || true fi if log_enabled "php"; then truncate -s 0 /var/log/php*.log 2>/dev/null || true fi if log_enabled "syslog"; then truncate -s 0 /var/log/secure 2>/dev/null || true fi fi fi ;; "debian") if log_enabled "fail2ban"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/fail2ban.log" >&2 else cp /var/log/fail2ban.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "apache"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/apache2 to $tmpdir/apachelogs" >&2 else mkdir -p "$tmpdir/apachelogs" cp -r /var/log/apache2 "$tmpdir/apachelogs" 2>/dev/null || true fi fi if log_enabled "nginx"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/nginx to $tmpdir/nginxlogs" >&2 else mkdir -p "$tmpdir/nginxlogs" cp -r /var/log/nginx "$tmpdir/nginxlogs" 2>/dev/null || true fi fi if log_enabled "pckg_mngr"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy apt logs to $tmpdir/syslogs/apt" >&2 else mkdir -p "$tmpdir/syslogs/apt" cp -r /var/log/apt/* "$tmpdir/syslogs/apt/" 2>/dev/null || true fi fi if log_enabled "auth"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/auth.log" >&2 else cp -r /var/log/auth.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "dmesg"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/dmesg" >&2 else cp -r /var/log/dmesg "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "dpkg"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/dpkg.log" >&2 else cp -r /var/log/dpkg.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "letsencrypt"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/letsencrypt to $tmpdir/syslogs/letsencrypt" >&2 else mkdir -p "$tmpdir/syslogs/letsencrypt" cp -r /var/log/letsencrypt/* "$tmpdir/syslogs/letsencrypt/" 2>/dev/null || true fi fi if log_enabled "php"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/php*.log" >&2 else cp -r /var/log/php*.log "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "syslog"; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy /var/log/syslog" >&2 else cp -r /var/log/syslog "$tmpdir/syslogs/" 2>/dev/null || true fi fi if log_enabled "purge"; then echo "Purging logs..." >&2 if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would truncate and clear configured logs on Debian system" >&2 else truncate -s 0 /var/log/syslog 2>/dev/null || true truncate -s 0 /var/log/message 2>/dev/null || true if log_enabled "apache"; then truncate -s 0 /var/log/apache2/* 2>/dev/null || true rm /var/log/apache2/*.gz 2>/dev/null || true fi if log_enabled "nginx"; then truncate -s 0 /var/log/nginx/* 2>/dev/null || true rm /var/log/nginx/*.gz 2>/dev/null || true fi if log_enabled "fail2ban"; then truncate -s 0 /var/log/fail2ban.log 2>/dev/null || true fi if log_enabled "pckg_mngr"; then truncate -s 0 /var/log/apt/* 2>/dev/null || true fi if log_enabled "auth"; then truncate -s 0 /var/log/auth.log 2>/dev/null || true fi if log_enabled "dmesg"; then truncate -s 0 /var/log/dmesg 2>/dev/null || true fi if log_enabled "dpkg"; then truncate -s 0 /var/log/dpkg.log 2>/dev/null || true fi if log_enabled "letsencrypt"; then truncate -s 0 /var/log/letsencrypt/* 2>/dev/null || true fi if log_enabled "php"; then truncate -s 0 /var/log/php*.log 2>/dev/null || true fi if log_enabled "syslog"; then truncate -s 0 /var/log/syslog 2>/dev/null || true fi fi fi ;; esac fi } function push { if [ "$rsync_push" = true ]; then local archive="$backup_path/backify-$timestamp.tar.gz" if [ "$DRY_RUN" = true ]; then if [ "${#targets[@]}" -gt 0 ]; then echo "[DRY-RUN] Would rsync $archive to multiple remote targets:" >&2 local t for t in "${targets[@]}"; do echo " - $t" >&2 done else echo "[DRY-RUN] Would rsync $archive to $target_user@$target_host:$target_dir" >&2 fi return fi local rsync_ssh="ssh" if [ -n "${target_key:-}" ]; then rsync_ssh="ssh -i $target_key" fi if [ "${#targets[@]}" -gt 0 ]; then local remote for remote in "${targets[@]}"; do echo "Pushing the backup package to $remote..." >&2 rsync -avz -e "$rsync_ssh" "$archive" "$remote" done else echo "Pushing the backup package to $target_host..." >&2 rsync -avz -e "$rsync_ssh" "$archive" "$target_user@$target_host:$target_dir" fi if [ "$push_clean" = true ]; then echo "Removing archive..." >&2 rm -f "$archive" fi fi } function dockerbackup { if [ "$docker_enabled" = true ]; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would back up Docker images/volumes/data according to configuration." >&2 return fi if [ "$docker_images" = true ]; then echo "Backing up Docker images..." >&2 for container_name in $(docker inspect --format='{{.Name}}' $(docker ps -q) | cut -f2 -d/); do echo -n "$container_name - " >&2 container_image=$(docker inspect --format='{{.Config.Image}}' "$container_name") mkdir -p "$tmpdir/containers/$container_name" save_dir="$tmpdir/containers/$container_name/$container_name-image.tar" docker save -o "$save_dir" "$container_image" echo "Finished" >&2 done fi if [ "$docker_volumes" = true ]; then echo "Backing up Docker volumes..." >&2 #Thanks piscue :) for container_name in $(docker inspect --format='{{.Name}}' $(docker ps -q) | cut -f2 -d/); do mkdir -p "$tmpdir/containers/$container_name" echo -n "$container_name - " >&2 docker run --rm --userns=host \ --volumes-from "$container_name" \ -v "$tmpdir/containers/$container_name:/backup" \ -e TAR_OPTS="$tar_opts" \ piscue/docker-backup \ backup "$container_name-volume.tar.xz" echo "Finished" >&2 done fi if [ "$docker_data" = true ]; then echo "Backing up container information..." >&2 for container_name in $(docker inspect --format='{{.Name}}' $(docker ps -q) | cut -f2 -d/); do echo -n "$container_name - " >&2 container_data=$(docker inspect "$container_name") mkdir -p "$tmpdir/containers/$container_name" echo "$container_data" >"$tmpdir/containers/$container_name/$container_name-data.txt" echo "Finished" >&2 done fi fi } function backup_db { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would dump database(s) of type '$database_type' into $tmpdir/db" >&2 return fi mkdir -p "$tmpdir/db" if [ "$db_all" = true ]; then if [ "$database_type" = "mysql" ]; then mysqldump -u "$db_username" -p"$db_password" -h "$db_host" -P"$db_port" --all-databases >"$tmpdir/db/db_all.sql" elif [ "$database_type" = "postgresql" ]; then PGPASSWORD="$db_password" pg_dumpall -U "$db_username" -h "$db_host" -f "$tmpdir/db/db_all.sql" fi else if [ "$database_type" = "mysql" ]; then mysqldump -u "$db_username" -p"$db_password" -h "$db_host" -P"$db_port" "$db_name" >"$tmpdir/db/$db_name.sql" elif [ "$database_type" = "postgresql" ]; then PGPASSWORD="$db_password" pg_dump -U "$db_username" -h "$db_host" "$db_name" -f "$tmpdir/db/$db_name.sql" fi fi } function custombackup { if [ "$custom_backup" = true ]; then if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would copy custom directories into $tmpdir/custom:" >&2 local i for i in "${custom_dirs[@]}"; do echo " - $i" >&2 done return fi mkdir -p "$tmpdir/custom" local i for i in "${custom_dirs[@]}"; do cp -r "$i" "$tmpdir/custom/" 2>/dev/null || true done fi } function apply_retention { if [ "${retention_days:-0}" -le 0 ] && [ "${retention_keep_min:-0}" -le 0 ]; then return fi local dir="$backup_path" local pattern="$dir/backify-"*.tar.gz if ! compgen -G "$pattern" >/dev/null; then return fi echo "Applying retention policy in $dir..." >&2 local archives=() local file while IFS= read -r file; do archives+=("$file") done < <(ls -1 "$pattern" 2>/dev/null | sort) local total=${#archives[@]} if [ "$total" -eq 0 ]; then return fi local keep_min=${retention_keep_min:-0} if [ "$keep_min" -lt 0 ]; then keep_min=0; fi local cutoff_date="" if [ "${retention_days:-0}" -gt 0 ]; then cutoff_date=$(date -d "-${retention_days} days" +%Y%m%d 2>/dev/null || true) fi local i=0 for file in "${archives[@]}"; do i=$((i + 1)) if [ "$keep_min" -gt 0 ] && [ $((total - i)) -lt "$keep_min" ]; then continue fi if [ -z "$cutoff_date" ] && [ "$keep_min" -gt 0 ]; then echo "Removing old backup (by count): $file" >&2 rm -f "$file" continue elif [ -z "$cutoff_date" ]; then continue fi local base base=$(basename "$file") local date_part=${base#backify-} date_part=${date_part%%_*} if [ "$date_part" -lt "$cutoff_date" ]; then echo "Removing old backup (older than ${retention_days} days): $file" >&2 rm -f "$file" fi done } function runbackup { init detect_system preflight if [ "$enabled" = true ]; then if [ "$DRY_RUN" = true ]; then echo "Running Backify in DRY-RUN mode. No files will be copied, compressed, pushed or deleted." >&2 fi makedir if [ "$DRY_RUN" = false ] && [ -n "${pre_backup_hook:-}" ] && [ -x "$pre_backup_hook" ]; then echo "Running pre-backup hook: $pre_backup_hook" >&2 "$pre_backup_hook" "$tmpdir" fi wwwbackup vhostbackup logbackup dockerbackup if [ "$db_backup" = true ]; then backup_db fi custombackup if [ "$DRY_RUN" = false ]; then echo "Creating backup archive..." >&2 tar -czvf "$backup_path/backify-$timestamp.tar.gz" -C "$backup_path" "backify-$timestamp" >> /var/log/backify-compress.log 2>&1 push apply_retention if [ -n "${post_backup_hook:-}" ] && [ -x "$post_backup_hook" ]; then echo "Running post-backup hook: $post_backup_hook" >&2 local post_backup_archive post_backup_archive="$backup_path/backify-$timestamp.tar.gz" "$post_backup_hook" "$post_backup_archive" fi else echo "[DRY-RUN] Skipping archive creation, remote push, retention and post-backup hooks." >&2 fi echo "Voila, enjoy the rest of the day" >&2 else echo "Backup is disabled in the configuration" >&2 fi } parse_args "$@" runbackup