#!/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 "$@"