Files
Backify/backify.sh
2025-11-19 17:47:29 +01:00

727 lines
20 KiB
Bash

#! /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 <<EOF
Usage: $0 [options]
Options:
-c, --config PATH Path to configuration file (default: backup.cfg)
-n, --dry-run Show what would be done, but do not copy/compress/push/delete
-h, --help Show this help and exit
-v, --version Show Backify version and exit
EOF
}
function show_version {
echo "Backify version $VERSION"
}
function parse_args {
while [ $# -gt 0 ]; do
case "$1" in
-c|--config)
if [ -n "${2-}" ]; then
CONFIG="$2"
shift 2
else
echo "Error: -c|--config requires a path argument." >&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