rs-backup-suite/client/usr/bin/rs-backup-run

741 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
##
# Copyright (C) 2013-2016 Janek Bevendorff
# Website: http://www.refining-linux.org/
#
# Script to push backups to a remote rsync backup server.
# Read the source code or use with --help parameter for
# further usage information.
#
# The MIT License (MIT)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
##
###############################################################################
# Source configs
###############################################################################
. /etc/rs-backup/client-config
###############################################################################
# Additional internal config
###############################################################################
_VERSION=$(rs-version version)
_GLOBAL_INCLUSION_PATTERN_FILE="/etc/rs-backup/include-files"
_FORCED_INCLUSION_PATTERN_FILE=""
_SKIP_HOME_DIRS=false
_FALLBACK_LOG_FILE="/var/log/rs-backup.log"
_FALLBACK_USER_LOG_FILE="rs-backup.user.log"
_FORCED_LOG_FILE=""
_QUIET_MODE=false
_VERBOSE_MODE=false
_SHOW_PROGRESS=false
_DRY_RUN=false
_FORCE_RUN=false
_PRE_HOOK=""
_PRE_HOOK_RUN=false
_POST_HOOK=""
_POST_HOOK_RUN=false
_FORCED_POST_HOOK=""
_FORCED_POST_HOOK_RUN=false
_ERROR_COUNT=0
if [ $(id -u) -eq 0 ]; then
_RUNFILE="/var/run/rs-backup/rs-backup-run.pid"
else
_RUNFILE="${HOME}/.rs-backup/rs-backup-run.pid"
fi
###############################################################################
# Function declarations
###############################################################################
# Print usage instructions to the screen
#
# Usage: print_help
print_help() {
rs-version headline rs-backup-run
rs-version copyright
cat << HELP
Push backup to rsync backup server over SSH.
If run as root and with no parameters, a full backup of all files and folders
specified in '${_GLOBAL_INCLUSION_PATTERN_FILE}' and all home directories
containing a proper backup config file will be pushed.
If invoked without root privileges, only the home directory of the current
user will be backed up.
Usage: $(basename $0) [OPTION]...
Options:
-r, --remote-host=HOST The remote host to connect to
--remote-user=NAME The username to use for logging into the remote server
(%h will be replaced with the host name of this
machine and %u with your username)
--push-module=NAME The remote rsync server module
--ssh-options=OPTS Additional SSH options (will be merged with the default
options set in the rs-backup client-config file)
-o, --rsync-options=OPTS Additional options for rsync
-n, --dry-run Perform a test run (same as the --dry-run option for
rsync). Enable --verbose mode for useful control output
-s, --no-home-dirs Don't back up home dirs, only perform global system
backup (root only)
-i, --include-from=FILE Specify an alternate inclusion pattern file
This will override the default setting. If the script
is run as root, only the system backup will be
performed, no additional home directories will be
backed up
-l, --log-level=NUM Set log level to NUM (between 0 and 4)
--log-file=FILE Log to this file instead of syslog
-f, --force-run Force rs-backup to run, even if a lock file exists
-q, --quiet Don't print any error messages or warnings to the
screen (only write to log file)
-v, --verbose Print all messages of the current debug level
-p, --progress Print file transfer information to the terminal
--pre-hook=CMD Command to be run before the backup. Will only be run
once for multi-user / global system backup. The hook
command will be executed as the user who started the
backup command
--post-hook=CMD Similar to --pre-hook, but run after the backup
has successfully finished. If an error occurred
during the backup, the post hook will not be run.
--forced-post-hook=CMD Same as --post-hook, but will always be run, regardless
of wether an error occurred or not. Will also be run
if the backup was interrupted by SIGINT or SIGTERM.
If both --post-hook and --forced-post-hook are specified,
--post-hook is run first
-h, --help Print this help and exit
HELP
}
# Write log messages to screen and/or log file
#
# Usage: write_log <log level> <log message>
#
write_log() {
local log_msg="${2}"
local log_date="[$(date)]"
local log_dest
local use_syslog=false
local logger="logger -t $(basename $0)"
command -v logger > /dev/null 2>&1
if [ $? -eq 0 ] && [ "${_FORCED_LOG_FILE}" == "" ]; then
use_syslog=true
fi
if [ $1 -gt 0 ] && [ $1 -le $LOG_LEVEL ]; then
if $use_syslog; then
case $1 in
1) $logger -p err "${log_msg}" ;;
2) $logger -p warning "${log_msg}" ;;
3) $logger -p info "${log_msg}" ;;
*) $logger -p debug "${log_msg}" ;;
esac
fi
# prepend priority prefixes to message for further logging output
case $1 in
1) log_msg="ERROR: ${log_msg}" ;;
2) log_msg="WARNING: ${log_msg}" ;;
3) log_msg="INFO: ${log_msg}" ;;
*) log_msg="DEBUG: ${log_msg}" ;;
esac
# if no syslog facility exists, go the cumbersome way...
if ! $use_syslog || [ "${_FORCED_LOG_FILE}" != "" ]; then
if [ "${_FORCED_LOG_FILE}" != "" ]; then
log_dest=${_FORCED_LOG_FILE}
elif [ $(id -u) -eq 0 ]; then
log_dest=${_FALLBACK_LOG_FILE}
elif [ "${HOME}" != "" ] && [ "${_FALLBACK_USER_LOG_FILE}" != "" ]; then
log_dest=${HOME}/${_FALLBACK_USER_LOG_FILE}
else
echo -e "\e[1mWARNING: Couldn't determine valid log file location, using '/var/tmp'...\e[0m" >&2
log_dest="/var/tmp/$(basename ${LOG_FILE})"
fi
touch "${log_dest}" 2> /dev/null
if ! test_file_perms "w" "${log_dest}"; then
echo -e "\e[1m\e[91mERROR: Couldn't open log file for writing, redirecting to STDOUT!\e[0m" >&2
echo "${log_date} ${log_msg}" >&1
else
echo "${log_date} ${log_msg}" >> "${log_dest}"
fi
fi
# after logging stuff, print it to the screen if we're not in quiet mode
if ! $_QUIET_MODE && [ $1 -eq 1 ]; then
$_VERBOSE_MODE || $PRINT_ERRORS && echo -e "\e[1m\e[91m${log_msg}\e[0m" >&2
elif ! $_QUIET_MODE && [ $1 -le 2 ]; then
$_VERBOSE_MODE || $PRINT_WARNINGS && echo -e "\e[1m${log_msg}\e[0m" >&2
elif ! $_QUIET_MODE && [ $1 -gt 2 ]; then
$_VERBOSE_MODE && echo "${log_msg}" >&1
fi
fi
# Increase error count
if [ $1 -eq 1 ]; then
_ERROR_COUNT=$(($_ERROR_COUNT + 1))
fi
}
# Create runfile containing current PID
#
# Usage: create_runfile
#
create_runfile() {
write_log 4 "Creating runfile (PID=${$}) at '${_RUNFILE}'..."
if [ ! -d "$(dirname $_RUNFILE)" ]; then
mkdir -p "$(dirname $_RUNFILE)"
fi
echo $$ > "$_RUNFILE"
}
# Remove created runfile
#
# Usage: remove_runfile
#
remove_runfile() {
write_log 4 "Removing runfile at '${_RUNFILE}'..."
rm -f "$_RUNFILE"
# also remove parent directory, but only if empty
# redirect error output since --ignore-fail-on-non-empty is not POSIX
rmdir "$(dirname $_RUNFILE)" > /dev/null 2>&1
}
# Run the user-specified pre hook
#
# Usage: run_pre_hook
#
run_pre_hook() {
if ! $_PRE_HOOK_RUN && [ "" != "$_PRE_HOOK" ]; then
$SHELL -c "$_PRE_HOOK"
_PRE_HOOK_RUN=true
fi
}
# Run the user-specified post hook
#
# Usage: run_post_hook
#
run_post_hook() {
if ! $_POST_HOOK_RUN && [ "" != "$_POST_HOOK" ]; then
$SHELL -c "$_POST_HOOK"
_POST_HOOK_RUN=true
fi
}
# Run the user-specified forced post hook
#
# Usage: run_forced_post_hook
#
run_forced_post_hook() {
if ! $_FORCED_POST_HOOK_RUN && [ "" != "$_FORCED_POST_HOOK" ]; then
$SHELL -c "$_FORCED_POST_HOOK"
_FORCED_POST_HOOK_RUN=true
fi
}
# Exit cleanly with given exit code.
# Removes any run files and runs the forced post hook.
#
# Usage clean_exit <exit_code>
#
clean_exit() {
remove_runfile
run_forced_post_hook
exit $@
}
# Handle script termination by external signals.
#
# Usage: handle_signals
#
handle_exit_signal() {
write_log 1 "Program terminated upon user request."
clean_exit 1
}
# Show a desktop notification using notify-send
#
# Usage: desktop_notify <type: INFO|WARNING|ERROR> <title> <message>
#
desktop_notify() {
local icon
local urgency
local user
if [[ "$(uname -o)" == "Cygwin" ]]; then
# not implemented
return
fi
command -v notify-send > /dev/null 2>&1
if [ $? -ne 0 ]; then
# notify-send not available
return
fi
if $_QUIET_MODE || $_DRY_RUN; then
return
fi
user=$(who | awk '/:0/ { print $1; exit; }')
if [ $(id -u) -ne 0 ] && [[ "$user" != "" ]] && [ $(id -u "$user") -ne $(id -u) ]; then
# we're neither root nor the current user on display :0.0,
# so don't show any notification
return
fi
case $1 in
"ERROR")
icon="dialog-error.png"
urgency="critical"
;;
"WARNING")
icon="dialog-warning.png"
urgency="normal"
;;
"SUCCESS")
icon="dialog-ok.png"
urgency="low"
;;
*)
icon="dialog-information.png"
urgency="low"
;;
esac
if [[ "$user" != "" ]]; then
dbus_pid=$(pgrep -u "$user" dbus-daemon | head -n1)
environment=$(xargs --null < /proc/$dbus_pid/environ)
sudo -u "$user" $environment notify-send -i "$icon" -u "$urgency" "rs-backup: $2" "$3"
fi
}
# Test if a file is readable and/or writeable
#
# Usage: test_file_perms <mode: r|w|rw> <filename>
#
test_file_perms() {
local perms=$1
local filename=$2
local result
if [ "${perms}" == "r" ]; then
[ -f "${filename}" ] && [ -r "${filename}" ]
elif [ "${perms}" == "w" ]; then
[ -f "${filename}" -a -w "${filename}" ] || [ ! -e "${2}" -a -w "$(dirname ${2})" ]
elif [ "${perms}" == "rw" ]; then
[ -f "${filename}" ] && [ -w "${filename}" ] && [ -r "${filename}" ]
fi
return $?
}
# Generate and return remote user name for SSH login
#
# Usage: get_remote_username [<local username>] [<local hostname>]
#
get_remote_username() {
local username=$1
local hostname=$2
if [ "${username}" == "" ]; then
username=$(id -un)
fi
if [ "${hostname}" == "" ]; then
hostname=$(hostname)
fi
echo $(echo ${REMOTE_USER} | sed "s/%h/${hostname}/" | sed "s/%u/${username}/")
}
# Get a stripped down version of the /etc/passwd file with all non-system users,
# their UIDs, GIDs, realpath'd home directories and shells
# Users whose home directories don't actually exist are skipped
#
# Usage: get_processed_passwd_file
#
get_processed_passwd_file() {
local line
local username
local uid
local gid
local home_dir
local shell
cat /etc/passwd | while read line; do
uid=$(echo -n "${line}" | cut -d ":" -f 3)
if [ $uid -lt 1000 ]; then
continue
fi
home_dir=$(echo -n "${line}" | cut -d ":" -f 6)
home_dir=$(realpath "${home_dir}")
if [ ! -d "${home_dir}" ]; then
continue
fi
username=$(echo -n "${line}" | cut -d ":" -f 1)
gid=$(echo -n "${line}" | cut -d ":" -f 4)
shell=$(echo -n "${line}" | cut -d ":" -f 7)
echo "${username}:x:${uid}:${gid}::${home_dir}:${shell}"
done
}
# Back up a directory.
# The third parameter is optional and specifys the user under whose
# privileges the backup will be performed
#
# Usage: perform_backup <inclusion pattern file> <destination> [<username>]
#
perform_backup() {
local inclusion_pattern_file=$1
local destination=$2
local username=$3
local msg
local backup_cmd
local ssh_cmd
local rsync_opts="${RSYNC_OPTIONS}"
local exit_code
local tee_device="/dev/tty"
if $_DRY_RUN; then
rsync_opts+=" --dry-run"
fi
if $_SHOW_PROGRESS; then
rsync_opts+=" --progress" # append to rsync_opts: preserves other options (e.g. --dry-run)
elif $_VERBOSE_MODE; then # --progress implies --verbose
rsync_opts+=" --verbose"
else
tee_device="/dev/null"
rsync_opts+=" --quiet"
fi
if [ "${username}" == "" ]; then
username=$(id -un)
fi
if [ "${_FORCED_INCLUSION_PATTERN_FILE}" != "" ]; then
inclusion_pattern_file=${_FORCED_INCLUSION_PATTERN_FILE}
fi
if ! test_file_perms "r" "${inclusion_pattern_file}"; then
write_log 1 "Pattern file '${inclusion_pattern_file}' does not exist or is not readable!"
return 1
fi
if [ "${SSH_OPTIONS}" != "" ]; then
ssh_cmd="ssh "$(echo -n "${SSH_OPTIONS}" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') # added missing space
else
ssh_cmd="ssh"
fi
backup_cmd="rsync \
--rsh=\"${ssh_cmd}\" \
--archive \
--delete \
--delete-excluded \
--exclude=\"${_RUNFILE}\" \
--exclude=\"${LOG_FILE}\" \
--exclude=\"${USER_LOG_FILE}\" \
--include-from=\"${inclusion_pattern_file}\" \
--exclude=\"*\" \
${rsync_opts} \
/ \
\"${destination}\""
write_log 4 "This is the command about to be run: $(echo ${backup_cmd})"
set -o pipefail
if [ $(id -u) -eq 0 ] && [ "${username}" != "$(id -un)" ]; then
write_log 4 "Running backup with privileges of user '${username}' (UID: $(id -u ${username}))..."
msg=$(su - "${username}" -c "${backup_cmd}" 3>&1 1>&2 2>&3- | tee "${tee_device}")
elif [ $(id -u) -ne 0 ] && [ "${username}" != "$(id -un)" ]; then
write_log 1 "Cannot run run backup as user '${username}' (UID: $(id -u ${username}), missing root privileges!"
return 1
else
msg=$(sh -c "${backup_cmd}" 3>&1 1>&2 2>&3- | tee "${tee_device}")
fi
exit_code=$?
if [ ${exit_code} -ne 0 ]; then
write_log 1 "Backup failed! Error message: ${msg}"
desktop_notify "ERROR" "Backup failed!" "Backup for user '${username}' failed!<br>Please refer to your log files for further information."
return ${exit_code}
else
write_log 3 "Backup finished."
fi
}
# Back up selected system files
#
# Usage: back_up_system
#
back_up_system() {
if [ $(id -u) -ne 0 ]; then
write_log 1 "Cannot perform system backup unless run as root!"
return 1
fi
write_log 4 "Entering directory '/root'..."
cd /root
if $_DRY_RUN; then
write_log 3 "Starting global system backup (DRY RUN)..."
else
write_log 3 "Starting global system backup..."
fi
perform_backup "${_GLOBAL_INCLUSION_PATTERN_FILE}" "$(get_remote_username root)@${REMOTE_HOST}::${PUSH_MODULE}"
return $?
}
# Back up single home directory
#
# Usage: back_up_single_home_dir <home dir> <username>
#
back_up_single_home_dir() {
local home_dir=$(realpath "${1}")
local username=$2
# Don't back up home directory if no files are marked for backup
if [ ! -f "${home_dir}/${INCLUSION_PATTERN_FILE}" ]; then
write_log 4 "Skipping '${home_dir}', because '${INCLUSION_PATTERN_FILE}' does not exist."
return
fi
# Also don't create a backup if no SSH key exists and no custom SSH options were specified
if [ ! -f "${home_dir}/.ssh/id_rsa" ] && [ ! -f "${home_dir}/.ssh/config" ] && [ "${SSH_OPTIONS}" == "" ]; then
write_log 4 "Skipping '${home_dir}', because no proper SSH key could be found."
return
fi
write_log 4 "Entering directory '${home_dir}'..."
cd "${home_dir}"
if $_DRY_RUN; then
write_log 3 "Starting backup of '${home_dir}' (DRY RUN)..."
else
write_log 3 "Starting backup of '${home_dir}'..."
fi
perform_backup "${home_dir}/${INCLUSION_PATTERN_FILE}" "$(get_remote_username ${username})@${REMOTE_HOST}::${PUSH_MODULE}" "${username}"
return $?
}
# Back up all home dirs
#
# Usage: back_up_home_dirs
#
back_up_home_dirs() {
local exit_code=0
write_log 3 "Starting backup of all home directories..."
get_processed_passwd_file | while read line; do
back_up_single_home_dir "$(echo -n ${line} | cut -d ':' -f 6)" "$(echo -n ${line} | cut -d ':' -f 1)"
exit_code=$(($exit_code | $?))
done
return $exit_code
}
# Parse command line args
#
# Usage: parse_cmd_args <cmd arg line>
#
parse_cmd_args() {
local args
local name=$(basename $0)
if [ "$1" == "" ]; then
return
fi
getopt -T > /dev/null
if [ $? -ne 4 ]; then
write_log 1 "Need GNU getopt for command line parameter parsing!"
exit 1
fi
short_opts="r:o:nsi:l:fqvph"
long_opts="remote-host:,remote-user:,push-module:,ssh-options:,rsync-options:,"
long_opts+="dry-run,no-home-dirs,include-from:,log-level:,log-file:,force-run"
long_opts+="quiet,verbose,progress,pre-hook:,post-hook:,forced-post-hook:,help"
args=$(getopt -s sh -o "$short_opts" -l "$long_opts" -n "${name}" -- "${@}")
if [ $? -ne 0 ]; then
exit 1
fi
eval set -- "${args}"
while true; do
case "$1" in
"-r"|"--remote-host")
REMOTE_HOST=$2
shift 2 ;;
"--remote-user")
REMOTE_USER=$2
shift 2 ;;
"--push-module")
PUSH_MODULE=$2
shift 2 ;;
"--ssh-options")
SSH_OPTIONS="${SSH_OPTIONS} ${2}"
shift 2 ;;
"-o"|"--rsync-options")
RSYNC_OPTIONS="${RSYNC_OPTIONS} $2"
shift 2 ;;
"-n"|"--dry-run")
_DRY_RUN=true
shift ;;
"-s"|"--no-home-dirs")
_SKIP_HOME_DIRS=true
shift ;;
"-i"|"--include-from")
# File must exist and be readable
! test_file_perms "r" "${2}" && echo "$name: '${2}' does not exist or is not readable!" >&2 && exit 1
_FORCED_INCLUSION_PATTERN_FILE=$2
_SKIP_HOME_DIRS=true
shift 2 ;;
"-l"|"--log-level")
LOG_LEVEL="$2"
shift 2 ;;
"--log-file")
# Test if file is writeable
! test_file_perms "w" "${2}" && echo "$name: '${2}' is not writeable!" >&2 && exit 1
_FORCED_LOG_FILE="$2"
shift 2 ;;
"-f"|"--force-run")
_FORCE_RUN=true
shift ;;
"-q"|"--quiet")
_QUIET_MODE=true
shift ;;
"-v"|"--verbose")
! $_QUIET_MODE && _VERBOSE_MODE=true
shift ;;
"-p"|"--progress")
! $_QUIET_MODE && _SHOW_PROGRESS=true
shift ;;
"--pre-hook")
_PRE_HOOK="$2"
shift 2 ;;
"--post-hook")
_POST_HOOK="$2"
shift 2 ;;
"--forced-post-hook")
_FORCED_POST_HOOK="$2"
shift 2 ;;
"-h"|"--help")
print_help
exit ;;
*)
shift
break ;;
esac
done
}
###############################################################################
# Initialize the actual backup
###############################################################################
# Register exit trap to catch signals and cleanly shut down the script
trap handle_exit_signal SIGHUP SIGINT SIGTERM
parse_cmd_args "$@"
# Check if a backup is already running
if [ -f "$_RUNFILE" ] && ! $_FORCE_RUN; then
write_log 1 "rs-backup is already running as PID $(<$_RUNFILE)."
write_log 1 "Please finish any running backups before starting a new one."
write_log 1 "If you're sure you want to run another backup, either remove the runfile at " \
"'$_RUNFILE' or use the '--force-run' parameter."
exit 1
fi
if [ ! -f "$_RUNFILE" ]; then
write_log 4 "No other backup running, ready to start."
elif $_FORCE_RUN; then
write_log 4 "Backup already running as PID $(<$_RUNFILE), forcing parallel backup..."
fi
run_pre_hook
create_runfile
# Backup exit code (0 if all backups have finished successfully)
_exit_code=$?
# Check if script has been invoked as root
if [ $(id -u) -eq 0 ]; then
write_log 4 "Running as root, performing global system backup..."
desktop_notify "INFO" "Full system backup" "rs-backup is starting a full system backup..."
back_up_system
_exit_code=$(($_exit_code | $?))
if ! $_SKIP_HOME_DIRS; then
back_up_home_dirs
_exit_code=$(($_exit_code | $?))
else
write_log 3 "Skipping home directory backup as requested."
fi
else
write_log 3 "Running without root privileges, only backing up user home directory..."
if [ "${HOME}" != "" ]; then
desktop_notify "INFO" "Home directory backup" "rs-backup is starting a home directory backup (unprivileged)..."
back_up_single_home_dir "$(realpath ${HOME})" "$(id -nu)"
_exit_code=$(($_exit_code | $?))
else
write_log 2 "Current user has no home directory, skipping."
fi
fi
if [ $_exit_code -eq 0 ]; then
desktop_notify "SUCCESS" "Backup finished" "Your backup has successfully finished"
fi
remove_runfile
write_log 4 "Done."
if [ $_ERROR_COUNT -gt 0 ]; then
clean_exit 1
fi
run_post_hook
run_forced_post_hook