#!/usr/bin/env bash
#
# rsync-backup - a backup script using rsync.
#
# Copyright 2015 - 2018 Einhard Leichtfuß
#
# This file is part of rsync-backup.
#
# rsync-backup is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# rsync-backup is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with rsync-backup. If not, see .
#
typeset -r exec_name="${0##*/}"
# CONSTANTS
typeset -r CONFIG=/etc/rsync-backup/config
typeset -r RET_TYPES='
RET_SUCCESS RET_FAILURE RET_BADSYNTAX RET_FILE_ERR RET_ERROR'
typeset -r RET_SUCCESS=0
typeset -r RET_FAILURE=1
typeset -r RET_BADSYNTAX=2
typeset -r RET_FILE_ERR=4
typeset -r RET_ERROR=8
typeset -r DATEFMT='%Y-%m-%d_%H%M%S'
# Shell options:
# nullglob: In case of a non-matching glob, return nothing.
# dotglob: Include dotfiles (except . and ..) in glob matching.
typeset -r SHOPTS='nullglob dotglob'
# Global program variables.
typeset src src_path src_host
typeset dest dest_path dest_host
typeset -a filter_args
typeset -i n
typeset bakdate
# Variables that may be configured.
typeset RSYNC=rsync
typeset -a rsync_args
typeset -a rsync_inc_args
typeset -A bakpath
typeset filter_file_all # Applied after specific filter.
typeset -A filter_file
# Default arguments for rsync (-rptogAXlHS, --timeout=, --info=).
rsync_args=(
--recursive
--perms --times --owner --group
--acls --xattrs
--links
--hard-links
--sparse
--timeout=60
#-vv
--info=progress2
)
# Supplemental arguments for rsync; only used when an actual incremental
# update is performed, i.e. when --link-dest is used.
rsync_inc_args=(
--fuzzy --fuzzy
)
filter_file_all=/etc/rsync-backup/filter
source "$CONFIG"
# RSYNC args.
# basic args: --recursive --perms --times --owner --group --links
# extra args: --sparse --acls --xattrs --hard-links --timeout=60
# --fuzzy --fuzzy
# --filter="merge "
# likely: --compress
# --partial{,dir=DIR}
# --progress
# possibly: --devices --specials
# --max-size=SIZE-OF-FILE
# --one-file-system
# --log-file=FILE --log-file-format=FORMAT
# --human-readable
# unlikely: --omit-{dir,link}-times --update (!--inplace) --delete
# --exclude
# great: --link-dest=DIR (timestamps?)
# testing: --verbose --dry-run (-vn)
# interesting: --sockopts --itemize-changes --out-format=FORMAT
# --stats
function main
{
shopt -s $SHOPTS
# Set $src, $dest_path and $filter_args.
get_args "$@" || return $?
# Verify $src_path exists.
run_function "$src_host" src_check_path '' 'src_path src' || return $?
# Make sure $dest_path exists.
run_function "$dest_host" dest_check_path '' 'dest_path dest' || return $?
# Populate the global $dirs array.
dirs=( $(
run_function "$dest_host" dest_find_existing_backup_dirs '' 'dest_path' \
|| return $?
) )
n=${#dirs[@]}
run_function "$dest_host" dest_prepare 'dest_clear_file' \
'dest dirs[@] n dest_host dest_path' "$(date +$DATEFMT)" \
|| return $?
perform_backup || return $?
run_function "$dest_host" dest_finalize \
'dest_refresh_symlinks dest_clear_file' \
'dest_host dest_path bakdate n' \
|| return $?
dest_refresh_symlinks
return $RET_SUCCESS
}
# Set the globabl variables $src, $dest_path and $filter_args according to
# command line parameters and the configuration.
#
# $@: command line parameters
#
function get_args
{
if [ $# -eq 0 ]
then
echo "Usage: $exec_name []"
return $RET_BADSYNTAX
fi
local l_src="$1"
local l_dest
# Set $src_path, $src_host and $src.
resolve_host_path src "$l_src" || return $?
if [ $# -gt 1 ]
then
l_dest="$2"
elif test -v bakpath[$src]
then
l_dest="${bakpath[$src]}"
else
echo "Error: Backup path for $src neither configured"
echo " nor specified on command line."
return $RET_BADSYNTAX
fi
# Set $dest_path, $dest_host and $dest.
resolve_host_path dest "$l_dest" || return $?
filter_args=()
if test -v filter_file[$src] && test -f "${filter_file[$src]}"
then
filter_args+=("--filter=merge ${filter_file[$src]}")
fi
if test -v filter_file_all -a -f "$filter_file_all"
then
filter_args+=("--filter=merge ${filter_file_all}")
fi
return $RET_SUCCESS
}
# Split host:target like rsync and convert local (!) relative paths to
# absolute ones. This is needed at least for $src_path which is used as
# key for associative arrays.
#
# The results are written to the global variables $, $_host
# and $_path, where is either src or dest. $ gets
# set to the original to be resolved string, unless we are local, i.e.
# there is no host.
#
# Note: This is not a place for bash to shine. Perl for instance would do
# this much more easily.
#
# Examples:
# - a:b -> (a, b)
# - a/b:c -> ('', a/b:c)
#
# $1: which global variables to set: (src|dest)
# $2: path string potentially containing host prefix
#
# sets: ${$1}_host: host name (may be empty)
# ${$1}_path: path (absolute, if local, i.e. ${$1}_host='')
# ${$1}: ${$1}_path, if ${$1}_host='',
# ${$1}_host:${$1}_path (= $2), elsewise.
#
function resolve_host_path
{
local vtype="$1"
local combined="$2"
local host path
# Split host and path.
host=${combined%%:*}
if [[ "$host" == "$combined" || "$host" =~ '/' ]]
then
host=''
path="$combined"
# Resolve to absolute directory.
# pwd -P avoids symlinks.
# Do not resolve symlimks in ${path} or test for its existance.
# `- realpath could do that.
if ! [[ "$path" =~ / ]]
then
path="$(pwd -P)/${path}"
fi
combined="$path"
else
path="${combined#*:}"
fi
# Set global variables.
if [[ "$vtype" == 'src' ]]
then
src_host="$host"
src_path="$path"
src="$combined"
else # "$vtype" == 'dest'
dest_host="$host"
dest_path="$path"
dest="$combined"
fi
return $RET_SUCCESS
}
# Run a function on a remote host.
# If the host parameter is empty, run locally.
#
# $1: host: name
# $2: function to run: name
# $3: other functions to export: [name (. ' ' . name)*]
# $4: variables to export: [name (. ' ' . name)*]
# ${@:5}: function arguments: string+
#
function run_function
{
local host="$1"
local fun=$2
local funs="$fun $3"
local vars="$4"
local vars_set
shift 4
if [[ -z "$host" ]]
then
$fun "$@"
else
# Create string to export variables.
vars_set=
for var in $RET_TYPES $vars
do
vars_set+="$(eval echo \${${var}@A}); "
done
# Use ssh to run command remotely.
# Make sure to properly escape the strings.
ssh "$host" "shopt -s ${SHOPTS}; ${vars_set} $(typeset -f $funs);" \
"$fun $(printf "'%s' " "$@")"
fi
return $?
}
# Run command remotely.
#
# Not used.
#
# $1: host: name
# ${@:2}: command with arguments: string+
#
function do_thing
{
local host="$1"
shift
if [[ -z "$host" ]]
then
"$@"
else
# Use ssh to run command remotely.
# Make sure to properly escape the strings.
ssh "$host" "shopt -s ${SHOPTS}; $(printf "'%s' " "$@")"
fi
return $?
}
# Check if $src exists.
# Must be run on source host.
#
# requires: $src_path, $src
#
function src_check_path
{
if ! test -d "$src_path"
then
echo "Source (\"$src\") does not exist."
return $RET_FILE_ERR
fi
return $RET_SUCCESS
}
# Check if $dest_path exists and try to create it if necessary.
# Must be run on destination host.
#
# requires: $dest_path, $dest
# interactive.
#
function dest_check_path
{
local -l reply
if ! test -d "$dest_path"
then
echo "Target (\"$dest\") does not exist."
echo -n "Do you want do create it [y/N]? "
read reply
if ! [[ "$reply" =~ ^y(es)?$ ]]
then
return $RET_FAILURE
fi
echo
if ! mkdir -p "$dest_path"
then
echo "Backup creation has failed."
echo "\"$dest\" could not be created."
return $RET_FILE_ERR
fi
fi
return $RET_SUCCESS
}
# Print existing backup directories.
# Must be run on destination host.
#
# requires: $dest_path
#
function dest_find_existing_backup_dirs
{
find "$dest_path" -mindepth 1 -maxdepth 1 -type d \
-regextype posix-extended -regex \
'.*/[0-9]{4}-[0-1][0-9]-[0-3][0-9]_[0-2][0-9]([0-5][0-9]){2}.backup' \
-printf '%f\n' \
| LC_ALL=C sort
if [[ ${PIPESTATUS[0]} -eq 0 && ${PIPESTATUS[1]} -eq 0 ]]
then
return $RET_SUCCESS
else
return $RET_ERROR
fi
}
# Prepare destination for the backup process.
# Must be run on destination host.
#
# requires: $dirs[@], $n
# $dest_host (self, <- dest_clear_file)
# $dest_path
#
# $1: current date on local host in format $DATEFMT
#
# interactive (<- dest_clear_file)
#
function dest_prepare
{
local current_date="$1"
# Check for dates in the future, which might mess with the yet to be
# determined new backup's date.
if [[ $n -gt 0 ]]
then
local last_date=${dirs[n-1]%.backup}
if [[ "$current_date" == "$last_date" ]]
then
echo "Last backup was run in the same second."
echo "Exiting."
return $RET_FAILURE
elif
[[ "$current_date" < "$last_date" ]]
then
echo "Last backup lies in the future. Fix your clocks."
echo "Exiting."
return $RET_FAILURE
fi
fi
dest_clear_file "${dest_path}/new.backup" "Backup creation has failed." \
|| return $?
return $RET_SUCCESS
}
# Perform the actual backup.
# To be run locally.
#
# requires: $DATEFMT,
# $dest_path, $dest, $src_path, $src,
# $n, $dirs[@],
# $rsync_args[@], $rsync_inc_args[@], $filter_args[@]
#
# sets: $bakdate
#
function perform_backup
{
bakdate=`date +"$DATEFMT"`
printf 'Starting backup nr. %u.\n' "$n"
if [ $n -eq 0 ]
then
# Perform first backup, not incremental.
$RSYNC "${rsync_args[@]}" \
"${filter_args[@]}" \
"$src"/ "${dest}/new.backup" \
|| return $RET_ERROR
else
# Perform incremental backup on the basis of the last.
$RSYNC "${rsync_args[@]}" \
"${rsync_inc_args[@]}" --link-dest="../${dirs[n-1]}" \
"${filter_args[@]}" \
"$src"/ "${dest}/new.backup" \
|| return $RET_ERROR
fi
return $RET_SUCCESS
}
# Finalize the backup creation on the destination host.
#
# requires: $dest_host (<- dest_refresh_symlinks),
# $dest_path (self, <- dest_refresh_symlinks),
# $bakdate (self, <- dest_refresh_symlinks),
# $n (self, <- dest_refresh_symlinks),
#
# interactive (<- dest_refresh_symlinks)
#
function dest_finalize
{
mv "${dest_path}/new.backup" "${dest_path}/${bakdate}.backup" \
|| return $RET_FILE_ERR
if
dest_refresh_symlinks
then
printf '\nCreating of backup nr. %u successfully executed.\n' "$n"
return $RET_SUCCESS
else
local ret=$?
printf '\nCreating of backup nonetheless successfully executed.\n'
return $ret
fi
}
# Create symbolic links to backup directories.
# Must be run on the destination host.
#
# requires: $dest_host, $dest_path, $bakdate, $n
# interactive (<- dest_clear_file)
#
function dest_refresh_symlinks
{
local -i i
local host_prefix="${dest_host}${dest_host:+:}"
local linkdir ret
linkdir="${dest_path}/latest"
if test -L "$linkdir"
then
rm "$linkdir"
ln -s "${bakdate}.backup" "$linkdir" || return $RET_ERROR
elif
dest_clear_file "$linkdir" "Creation of softlink \"latest\" failed." \
|| return $?
then
ln -s "${bakdate}.backup" "$linkdir" || return $RET_ERROR
fi
# by_number
numdir="${dest_path}/by_number"
if ! test -d "$numdir"
then
dest_clear_file "$dest_host" "$numdir" \
"Setting up numbered softlinks failed." || return $?
mkdir "$numdir" || return $RET_ERROR
fi
# Remove old symlinks.
find "$numdir" -mindepth 1 -maxdepth 1 -type l -regextype posix-extended \
-regex '.*/[0-9]*' -delete || return $RET_ERROR
# TODO: Simplify.
for file in "$numdir"/*
do
echo $file
dest_clear_file "$file" "Setting up numbered softlinks failed." \
|| return $?
done
# Set last dir on the new one.
dirs[$n]="${bakdate}.backup"
for (( i = 0; i <= n; i++ ))
do
ln -s "../${dirs[i]}" "$(printf "%s/%.${#n}u" "$numdir" $i)"
done
return $RET_SUCCESS
}
# Make sure a file does not exist on the destination host.
# Must be run on destination host.
#
# $1: file: path
# $2: error message: string
#
# requires: $dest_host
# interactive.
#
function dest_clear_file
{
local file_path="$1"
local err_msg="$2"
local file="${dest_host}${dest_host:+:}${file_path}"
local -l reply
if test -e "$file_path" -o -L "$file_path"
then
printf "%s %s\n %s\n%s\n" \
"It seems like the last backup process failed or you have created" \
"the file" "$file" "manually."
echo -n "Do you want to delete it [y/N]? "
read reply
if [[ "$reply" =~ ^y(es)?$ ]]
then
printf "Deleting.\n"
if rm -rf "$file_path"
then
return $RET_SUCCESS
else
printf '%s\n' "$err_msg"
return $RET_FILE_ERR
fi
else
printf '%s\n' "$err_msg"
return $RET_FAILURE
fi
fi
return $RET_SUCCESS
}
# Ask a yes/no question.
# Note, that ^D is considered as a default answer.
#
# Not used.
#
# $1 default answer: (y|n|x)
# $2 question: string
#
# returns yes / no: (0|1)
#
# interactive.
#
function ask
{
# -l auto-converts everything to lower case.
local -l default="$1"
local question="$2"
while true
do
if [[ "$default" == "y" ]]
then
printf "%s [Y/n]? " "$question"
elif [[ "$default" == "n" ]]
then
printf "%s [y/N]? " "$question"
else
printf "%s [y/n]? " "$question"
default="x"
fi
local -l reply # -l converts to lowercase
read reply
if [[ "$reply" =~ ^y(es)?$ ]]
then
return 0
elif [[ "$reply" =~ ^no?$ ]]
then
return 1
elif [[ "$default" == "x" || "$reply" != "" ]]
then
continue
elif [[ "$default" == "y" ]]
then
return 0
else # "$default" == "n"
return 1
fi
done
}
main "$@"