#!/bin/bash

#######################################################################
#            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE              #
#                    Version 2, December 2004                         #
#                                                                     #
# Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>                    #
#                                                                     #
# Everyone is permitted to copy and distribute verbatim or modified   #
# copies of this license document, and changing it is allowed as long #
# as the name is changed.                                             #
#                                                                     #
#            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE              #
#   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION   #
#                                                                     #
#  0. You just DO WHAT THE FUCK YOU WANT TO.                          #
#######################################################################


# This is Wonko's backup script.
# Enter snackup -h for help, and snackup -T to see a sample
# config file.


#######################################################################
#
# define functions
#
#######################################################################

# shell function to tell what this script does
help()
{
	cat <<- FNORD
	$ScriptName [-Cdfhlv] [-cL file] [-m msg] [-nN level] [-o options] [-s size] [-S char] target...
	Wonkos backup script, optionally using LVM snapshots.
	
	Targets (and additional features) are defined in the config file, which
	must be one of these (or set with option -c):
	  ~/.$ScriptName
	  /usr/local/etc/$ScriptName.cfg
	  /etc/$ScriptName.cfg
	
	See the sample config file to see how to define targets.
	
	Send your questions and suggestions to wonko@wonkology.org.
	
	Options:
	  -c file location of config file
	  -C      clear destination first
	  -d      dummy mode, just show what would be done
	  -f      force backup (the initial rdiff-backup may need this)
	  -h      show this help
	  -l      output to log file, too
	  -L      log file (default:$oLogFile); may be a directory (add a
	          trailing slash) to create target-specific logs
	  -m msg  additional log message (activates -l)
	  -n l    use nice level l (default:$oNice, 0 to turn off)
	  -N l    use ionice level l (default:$oIonice, 0 to turn off)
	  -o      extra options that will added to the actual backup command
	  -s size size of LVM snapshot (default:$oSize)
	  -S char replace tab as delimiter for targets in config file
	  -T      output config file template
	  -v      verbose output; may be given multiple times
	          1: some extra output; 2: add -v to commands; 3: set -xv
	  -V      output version information and exit
	  -x file exclude file from backup; may be given multiple times
	          use "" to clear list of exclude files
	
	Return codes:
	  0       success
	  1       wrong argument
	  2       file not found
	  3       no targets defined
	  4       target not found
	  5       mount error
	  other   result of sub-command
	FNORD
}

# shell function toi output a sample config file
template()
{
	cat <<- FNORD
	# SNACKUP config file
	#
	# This file contains backup targets as tab-separated table, started by a colon.
	# Multiple tabs are removed. Empty cells should contain a '-' only.
	#
	# All other stuff in this file is sourced. So add anything you want to have
	# executed, and functions you want being called for specific targets. 
	#
	# Variables you can use:
	#   \$Date:    start date of backup as given by internal date() function
	#   \$Lf:     empty, or line feed when verbosity is set to -vv
	#   \$Seconds: start value of $SECONDS bash variable
	#   \$V:       empty, or -v when verbosity is set to -vv
	#
	# Pre-Defined Functions:
	#
	# date():  outputs a date timestamp in YYYY-MM-DD format. Used
	#          in the log file, and replaces the %d place holder.
	#
	# debug(): output some internal variables
	#
	#
	# Target Table Format:
	#
	# <name>
	#	Target's name, argument to the script
	# 
	# <type>
	#	One of these:
	#	- dd     dd source to destination
	#	- cp     cp source to destination
	#	- rdiff  rdiff-backup of source to destination
	#	- rsync  rsync soure to destination
	#	- tar    tar source file/dir to destination; when source has the
	#	         format 'X//Y', the local directory is set to X.
	#
	# <source>
	#	Source file or directory in '[vg/lvm::[key::]]path' format.
	#	May contain wildcards to process multiple directories at once.
	#	If vg/lvm is specified, an LVM snapshot is to be taken. Add optinal
	#	LUKS key when the volume is encrypted.
	#
	# <destination>
	#
	#	Backup destination. May contain these place holders:
	#	- %d: timestamp (output of the date() function you may override)
	#	- %f: base name of source arguments;
	#             useful when source contains wild cards
	#	- %s: automatic file suffix (tar, rgz, tbz2)
	#
	# <options>
	#	Options will be passed directly to backup command.
	#
	# <pre-commands>
	#	Arbitrary commands to be executed before the backup starts.
	#
	# <post-commands>
	#	Arbitrary commands to be executed after the backup has finished.
	#
	# List of Targets
	# ---------------
	#	<name>	<type>	<source>			<destnation>			<options>	<pre-commands>	<post-commands>
	
	# Backup boot partition:
	:	boot	dd	/dev/sda5			/dev/sdb5			bs=32M		-		-
	
	# Backup root partition, making an LVM snapshot of volume group system and logical volume root:
	:	root	rdiff	system/root::/etc/key::/	/backup/system			-		-		-
	
	# Backup of var partition. Pre-command compresses stuff in /var/log/portage first.
	:	var	rdiff	system/var::/etc/key::/		/backup/system/var		-		zipLog		-
	
	# Create tar file for every kernel in /usr/src/, compressed with bzip2:
	:	kernels	tar	/usr/src//linux-*		/backup/system/usr/src/%f.%s	-j		-		-
	
	# Sync portage packages
	:	portage	rsync	/usr/portage/packages		/backup/system/usr/portage	-		-		-
	
	# Create gzipped tar file of /etc directory, and view the result afterwards:
	:	etc	tar	/etc				/backup/conf/etc/etc_%d.%s	-z		-		lsf
	
	# Put all KDE directories into one tar file (the * needs be escaped here):
	:	kde	tar	~//.kde*			/backup/$USER/home/kde-%d.%s	-z		-		lsf
	
	# Put all dot-files into one tar file:
	:	dot	tar	~//.??*				/backup/$USER/home/dot-%d.%s	-z		-		lsf
	
	# Set defaults for command line options
	# -------------------------------------
	
	# list of files to exclude
	oXclude=( '**/.ccache' \\
	          '**/tmp/binpkgs' \\
	          '**/tmp/ccache*' \\
	          '**/tmp/cross' \\
	          '**/tmp/genkernel' \\
	          '**/tmp/kdecache-*' \\
	          '**/tmp/portage' \\
	          '**/.kde4/share/apps/nepomuk' )
	
	# Functions to be used as pre/post commands
	# -----------------------------------------
	
	lsf() # list destination file
	{
	 	\$NiceCmd ls -ld "\$myDst"
	}
	
	zipLog() # compress some stuff in /var/log
	{
	 	log "  zipping stuff in /var/log/portage... \$Lf"
	 	\$NiceCmd bzip2 /var/log/portage/*.log
	 	log "OK.\n  backing up /var... "
	}
FNORD
}

# routine to output the current date
date()
{
	/bin/date "+%Y-%m-%d"
}

# dump content of some variables for debugging
debug()
{
	echo " src='$src'"
	echo " dst='$dst'"
	echo "  vg='$vg'"
	echo " lvm='$lvm'"
	echo " mnt='$mnt'"
	echo " dir='$dir'"
	echo "file='$file'"
}

# logger routine
log()
{
	# output to stdout
	printf "$@"
	
	# output to file when set and not in dummy mode
	if [[ $oLog ]] && ! [[ $oDummy ]]
	then
		[[ -d $oLogFile ]] &&
			printf "$@" >> "$oLogFile/$aArg.log" ||
			printf "$@" >> "$oLogFile"
	fi
}

# start message
startlog()
{
	log "Backup of %s started at %s.\n" "$1" "$( date )"
	[[ $oMessage ]] &&
		log "Message: %s\n" "$oMessage"
}

# end message
endlog()
{
	log "Backup finished after %d seconds at %s.\n" \
	    $(( SECONDS - Seconds )) "$( date )"
}


#######################################################################
#
# some initialization
#
#######################################################################

# name and version of this script
ScriptName=${0##*/}
   Version=0.23

# make using undefined variables issue an error
set -u

# store current value of $IFS
OldIFS=$IFS

# additional options for the individual backup commands
   CpOptions=
   DdOptions=
RdiffOptions=( --exclude-other-filesystems --backup-mode )
RsyncOptions=( --one-file-system )
  TarOptions=( --one-file-system )

#######################################################################
#
# parse command line options and config file
#
#######################################################################

# define and set defaults for command line options
   oConfig=
    oClear=
    oDummy=
    oForce=
  oMessage=
      oLog=
[[ $LOGNAME == root ]] &&
  oLogFile=/var/log/$ScriptName.log ||
[[ -d $HOME/log ]] &&
  oLogFile=$HOME/log/$ScriptName.log ||
  oLogFile=$HOME/$ScriptName.log 
     oNice=10
   oIonice=3
  oOptions=()
     oSize=2G
oSeparator=
  oVerbose=0
      oZip= oZipOpt=
   oXclude=()

# parse comand line options for option -c first
while getopts ":c:CdfhjlL:m:n:N:o:s:S:TvVx:z" opt
do
	case $opt in
	c )
		oConfig=$OPTARG
		;;
	h )
		help
		exit 
		;;
	T )
		template
		exit
		;;
	S )
		oSeparator=$OPTARG
		;;
	v )
		(( oVerbose++ ))
		;;
	V )
		echo "$ScriptName version $Version"
		exit 0
	esac
done

# add a -v to commands when we want to be very verbose
(( oVerbose >= 2 )) &&
	V=-v ||
	V=

# debug mode to be most verbose
(( oVerbose >= 3 )) &&
	set -xv

# without option -c, look for config file in default locations
if ! [[ $oConfig ]]
then
	# check for config file
	for oConfig in ~/.$ScriptName \
	               /usr/local/etc/$ScriptName.cfg \
	               /etc/$ScriptName.cfg \
	               ""
	do
		# exit loop when we found one
		[[ -r $oConfig ]] && break
	done

	if ! [[ $oConfig ]]
	then
		printf "Error: No config file found!\n"
		exit 2
	fi
elif ! [[ -f $oConfig ]] # does the file exist?
then
	printf "Error: Config file '%s' not found!\n" "$oConfig"
	exit 2
elif ! [[ -r $oConfig ]] # can we read it`?
then
	printf "Error: Config file '%s' not readable!\n" "$oConfig"
	exit 2
fi

# source config file
(( oVerbose )) &&
	printf "Reading confile file '%s'\n\n" "$oConfig"
. "$oConfig"

# extract targets from config file, multiple tabs replaced by a single one
[[ $oSeparator ]] &&
	s=$oSeparator ||
	s=\\t
str=$( grep ^: "$oConfig" |
       sed "s/$s$s$s$s$s/$s/g;s/$s$s$s/$s/g;s/$s$s/$s/g" )
if ! [[ $str ]]
then
	printf "Error: No targets defined in config file '%s'!\n" "$oConfig"
	exit 3
fi

# parse rows of target table
set -f     # disable file name globbing
IFS=$'\n'  # set field separator to line feed only
Tgt=( $( echo "$str" | cut -f 2 ${oSeparator:+-d "$oSeparator"} ) )
Typ=( $( echo "$str" | cut -f 3 ${oSeparator:+-d "$oSeparator"} ) )
Src=( $( echo "$str" | cut -f 4 ${oSeparator:+-d "$oSeparator"} ) )
Dst=( $( echo "$str" | cut -f 5 ${oSeparator:+-d "$oSeparator"} ) )
Opt=( $( echo "$str" | cut -f 6 ${oSeparator:+-d "$oSeparator"} ) )
Pre=( $( echo "$str" | cut -f 7 ${oSeparator:+-d "$oSeparator"} ) )
Pos=( $( echo "$str" | cut -f 8 ${oSeparator:+-d "$oSeparator"} ) )
IFS=$OldIFS
set +f

# parse comand line options
OPTIND=1
while getopts ":c:CdfhjlL:m:n:N:o:s:S:T:vx:z" opt
do
	case $opt in
	c | h | T | S | v )
		: already parsed
		;;
	C )
		oClear=true
		;;
	d )
		oDummy=true
		;;
	f )
		oForce=true
		;;
	j )
		   oZip=bz2
		oZipOpt=-j
		;;
	l )
		oLog=true
		;;
	L )
		oLogFile=$OPTARG
		;;
	m )
		oMessage=$OPTARG
		    oLog=true
		;;
	n )
		oNice=$OPTARG
		;;
	N )
		oIonice=$OPTARG
		;;
	o )
		if [[ $OPTARG ]]
		then
			set +u
			oOptions=( "${oOptions[@]}" "$OPTARG" )
			set -u
		else
			oOptions=()
		fi
		;;
	s )
		oSize=$OPTARG
		;;
	x )
		if [[ $OPTARG ]]
		then
			set +u
			oXclude=( "${oXclude[@]}" "$OPTARG" )
			set -u
		else
			oXclude=()
		fi
		;;
	z )
		   oZip=gz
		oZipOpt=-z
		;;
	* )
		printf "Error: No such option '%s'!\n" $OPTARG
		exit 1
	esac
done
shift $(( OPTIND-1 ))

# we want to output an extra line feed in dummy mode
if [[ $oDummy ]] || [[ $V ]]
then
	Lf="\n"
else
	Lf=
fi

# create nice command we start I/O and CPU intensive commands with
NiceCmd=
(( oNice   )) && NiceCmd="nice   -n $oNice   $NiceCmd"
(( oIonice )) && NiceCmd="ionice -c $oIonice $NiceCmd"
[[ $oDummy ]] && NiceCmd="echo               $NiceCmd"

# create log directory, if necessary
if [[ $oLog ]]
then
	logDir=${oLogFile%/*}
	if ! [[ -d $logDir ]]
		then
		(( oVerbose )) &&
			printf "Creating log directory '%s'... " "$logDir"
		mkdir -p "$logDir" || exit $?
		(( oVerbose )) &&
			printf "OK.\n"
	fi
fi


#######################################################################
#
# now loop through arguments
#
#######################################################################

# check if there is at least one argument
if ! (( $# ))
then
	printf "Error: No argument given!\n%s -h for help.\n\n" \
	       "$ScriptName"
	exit 1
fi

# loop through all arguments
while (( $# ))
do
	# put argument into variable
	aTarget=$1
	shift
	
	Seconds=$SECONDS    # current time, for calculating duration in endlog
	   Date=$( date )   # may be used in config file
	
	startlog "$aTarget"
			
	# t will contain the index for the Tgt, Typ... arrays
	for (( t=0; t < ${#Tgt[@]}; t++ ))
	do
		[[ $aTarget == ${Tgt[t]} ]] && break
	done
	if  (( t == ${#Tgt[@]} ))
	then
		printf "Error: No such target '%s'!\n" "$aTarget"
		exit 4
	fi
	
	# copy our entry into new variables to get rid of index
	typ=${Typ[t]}
	src=${Src[t]}
	dst=${Dst[t]}
	opt=${Opt[t]}; [[ $opt == - ]] && opt=
	pre=${Pre[t]}; [[ $pre == - ]] && pre=
	pos=${Pos[t]}; [[ $pos == - ]] && pos=
	
	# do we backup a logical volume?
	if [[ $src == *::* ]]
	then
		# parse volume group and name
		lvm=${src%%::*}
		 vg=${lvm%/*}
		lvm=${lvm#*/}
		if [[ $src == *::*::* ]]
		then
			key=${src#*::}
			key=${key%::*}
		else
			key=
		fi
		src=${src##*::}
		
		# remove trailing slash
		while [[ $src == /* ]]
		do
			src=${src#/}
		done
		
		# if src is empty, replace it by a dot
		[[ $src ]] ||
			src=.
		
		# device and mount point
		dev=/dev/$vg/$lvm
		mnt=/mnt/$lvm-snapshot
	else
		# just define LVM variables
		 vg= # volume group
		lvm= # logical volume
		key= # optional LUKS key
		mnt= # mount point
		
		# add path if $src is local
		case "$src" in
		/* | ~* )
			:
			;;
		* )
			src=$PWD/$src
		esac
	fi
	
	# set tar file suffix according to target and command options
	if [[ $oZip ]]
	then
		zip=$oZip
	else
		case $opt in
		*-j* )	zip=bz2 ;;
		*-z* )	zip=gz  ;;
		esac
	fi
	
	# execute target commands
	if [[ $pre ]]
	then
		for cmd in $pre
		do
			(( oVerbose )) &&
				printf "Executing target command: %s\n" "$cmd"
			$cmd
			(( oVerbose )) &&
				printf "Done with target command.\n" 
		done
	fi
	
	# create LVM snapshot
	if [[ $lvm ]]
	then
		(( oVerbose )) &&
			log "Creating LVM snapshot for %s/%s... $Lf" \
			    "$vg" "$lvm"
		
		$NiceCmd \
		lvcreate -L "$oSize" -n "$lvm-snapshot" -s "$dev" || exit $?
		
		if [[ $key ]]
		then
			$NiceCmd \
			cryptsetup luksOpen -d "$key" \
			           "$dev-snapshot" "$lvm-snapshot" || exit $?
			dev=/dev/mapper/$lvm-snapshot
		fi
		
		[[ -d $mnt ]] ||
			$NiceCmd \
			mkdir "$mnt" || exit $?
		
		$NiceCmd \
		mount -o ro "$dev" "$mnt" || exit $?
		
		(( oVerbose )) &&
			log "OK.\n"
	fi
	
	# clear destination?
	if [[ $oClear ]] && [[ -e $dst ]]
	then
		(( oVerbose )) &&
			log "Cleaning destination '%s'...$Lf" "$dst"
		$NiceCmd \
		\rm -r $V "$dst"
		(( oVerbose )) &&
			log "OK.\n"
	fi
	
	# get local source directory
	if [[ $src == *//* ]]
	then
		 dir=${src%//*}
		base=${src##*//}
	else
		 dir=
		base=$src
	fi
	
	[[ $lvm ]] &&
		dir=$mnt/$dir
	
	# expand tilde and variables
	dir=$( eval echo "$dir" )
	
	# change into local directory
	[[ $oDummy ]] || pushd "$dir" > /dev/null || exit $?
		
	# in multi mode, process sources individually
	if [[ $typ == *\* ]]
	then
		IFS=$'\n'
		files=( $base )
		IFS=$OldIFS
	else
		files=( "$base" )
	fi
	
	# loop through files
	for file in "${files[@]}"
	do
		# expand wildcards
		file=( $file )
		# replace place holders in destination path
		  myDst=${dst//%d/$Date} 
		myDst=${myDst//%f/${file##*/}}
		myDst=${myDst//%s/t${zip:-ar}}
		
		# expand variables
		myDst=$( eval echo "$myDst" )
		
		# does the destination exist already?
		if [[ $myDst -nt $file ]] && ! [[ $oForce ]]
		then
			log " Already existing and newer. -f to overwrite.\n"
			continue
		fi
		
		# create destination directory
		[[ -d ${myDst%/*} ]] ||
			$NiceCmd \
			mkdir -p "${myDst%/*}" || exit $?
		
		# we will output similar messages, so we use a function
		msg()
		{
			(( oVerbose )) &&
				log "  %s '%s' to '%s'... $Lf" \
			            "$1" "${file[*]}" "$myDst"
		}
		
		# further action depends on the target type
		case "$typ" in
		
		cp* )
			msg "Copying"
			
			# does the file exist already?
			if [[ $myDst -nt $file ]] && ! [[ $oForce ]]
			then
				log "Already existing and newer. -f to overwrite.\n"
				break
			fi
			
			# set additional options to default, unless set with -o
			(( ${#oOptions[@]} )) &&
				options=( "${oOptions[@]}" ) ||
				options=$CpOptions
			
			$NiceCmd \
			cp -a $opt "${options[@]}" $V \
			   "$file" "$myDst"
			ret=$?
			;;
		
		dd* )
			msg "DDing"
			
			# set additional options to default, unless set with -o
			(( ${#oOptions[@]} )) &&
				options=( "${oOptions[@]}" ) ||
				options=$DdOptions
			
			$NiceCmd \
			dd if="$file" of="$myDst" \
			   $opt "${options[@]}"
			ret=$?
			;;
		
		rdiff* )
			msg "Rdiffbackupping"
			
			# set additional options to default, unless set with -o
			(( ${#oOptions[@]} )) &&
				options=( "${oOptions[@]}" ) ||
				options=$RdiffOptions
			
			printf "%s\n" "${oXclude[@]}" |			
			$NiceCmd \
			rdiff-backup --exclude-globbing-filelist-stdin \
			               ${oForce:+--force} \
			             $opt ${options[@]} $V \
			             "$file" "$myDst"
			ret=$?
			;;
		
		rsync* )
			msg "Rsyncing"

			# set additional options to default, unless set with -o
			(( ${#oOptions[@]} )) &&
				options=( "${oOptions[@]}" ) ||
				options=$RsyncOptions
			
			printf "%s\n" "${oXclude[@]}" |			
			$NiceCmd \
			rsync -a --exclude-from=- \
			      $opt "${options[@]}" $V \
			      "${file[@]}" "$myDst"
			ret=$?
			;;
		
		tar* )
			msg "Tarring up"
			
			# set additional options to default, unless set with -o
			(( ${#oOptions[@]} )) &&
				options=( "${oOptions[@]}" ) ||
				options=$TarOptions
			
			printf "%s\n" "${oXclude[@]#\*\*/}" |
			$NiceCmd \
			tar -c $oZipOpt ${dir:+-C "$dir"} -f "$myDst" -X - \
			    $opt "${options[@]}" $V \
			    "${file[@]}"
			ret=$?
			;;
		
		* )
			log "%s: No or wrong argument given!\n" "$0"
		esac
		
		! (( ret )) && (( oVerbose )) &&
			log "OK.\n"
	done
	
	# get back to the directory we were in
	[[ $oDummy ]] || popd > /dev/null
	
 	# clean up LVM snapshot
	if [[ $lvm ]]
	then
		$NiceCmd umount "$mnt"
		$NiceCmd rmdir "$mnt"
		$NiceCmd cryptsetup remove "$lvm-snapshot"
		$NiceCmd lvremove -f "/dev/$vg/$lvm-snapshot"
	fi
	
	if (( ${ret:=99} ))
	then
		printf "Error %d occured! Exiting.\n" $ret
		exit $ret
	fi
	
	# execute target functions
	if [[ $pos ]]
	then
		for cmd in $pos
		do
			(( oVerbose )) &&
				printf "Executing target command: %s\n" "$cmd"
			$cmd
			(( oVerbose )) &&
				printf "Done with target command.\n"
		done
	fi
	
	endlog
done

exit 0
