diff options
Diffstat (limited to 'rsync-backup')
-rwxr-xr-x | rsync-backup | 657 |
1 files changed, 657 insertions, 0 deletions
diff --git a/rsync-backup b/rsync-backup new file mode 100755 index 0000000..e0ed37a --- /dev/null +++ b/rsync-backup @@ -0,0 +1,657 @@ +#!/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 <https://www.gnu.org/licenses/>. +# + + +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 <file>" +# 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 <src> [<dest>]" + 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 $<vtype>, $<vtype>_host +# and $<vtype>_path, where <vtype> is either src or dest. $<vtype> 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 "$@" |