#!/bin/sh
VERS="4.00"
# set -x  # uncomment this line for massive debugging :-)
# ------------------------------------------------------------------------
#                            DESCRIPTION:
#
# Backup creates split cpio archives of a unix system which can then be
# burned onto a set of CD-Rs.  A sed RE is used to exclude a few unwanted
# directories.  Assumes the availability of GNU 'find' and GNU 'cpio' or
# equivalent.
#
# The resulting archive is split via "fsplit" into 630Mb chunks called
# <archive-name>.000, <archive-name>.001, etc for convenient copying onto
# CD-Rs.
#
# Use "backup -r" to recover into the current directory.  
#
# For help re all options, type "backup -h".
#
# NB: The HISTORY has now been moved to the end of the script.
# ------------------------------------------------------------------------

# ------------------------------------------------------------------------
#
#                  User adjustable constants
#
# ------------------------------------------------------------------------

# Re-set cdr_size and free_space below if you wish.
# Do NOT USE 700Mb unless you are CERTAIN that your burners and
# readers can handle it!  Stick with 650Mb if you aren't sure:

cdr_size=650	# Nominal Mb capacity of CD-R
#cdr_size=18	# For testing
#cdr_size=35	# For testing
free_space=15	# Mb of space to leave for other files, such as 'list'

# ----------------------
# Directories and files:
# ----------------------

# Change BKUP_DIR if you like.  Needs a mount point with lots of space.
# Your archives and log files are normally written here (unless the defs
# further below are changed):
BKUP_DIR=/usr/local/backups

# TMP is just where the recovery log files go.  I was using /tmp for a while,
# but then I figured "what the heck, may as well put 'em into BKUP_DIR ..."
TMP=$BKUP_DIR

MNT=/cdrom	# Where you normally mount your cd drive
DEV=/dev/cdrom # And it's device name
BX=bex # Backup exclusions file
BL=list
BE=errors
FE=find_errors
PL=Log.backup
RL=rlist
RE=rerrors
FI=cpio_backup

# ------------------------------------------------------------------------
#
#                End of user adjustable constants
#
# ------------------------------------------------------------------------

#  Calc the chunk size:
kb=1024
free=`expr $free_space \* $kb \* $kb`
CHUNK_SIZE=`expr $cdr_size \* $kb \* $kb \- $free`

DATE_STAMP="`date '+%Y%m%d%H%M'`"
ARC_NAME="$BKUP_DIR/${DATE_STAMP}.cpio"
BKUP_LIST="$BKUP_DIR/$BL"
ERRS="$BKUP_DIR/$BE"
FIND_ERRS="$BKUP_DIR/$FE"
PROG_LOG="$BKUP_DIR/$PL"
BEX=${BKUP_DIR}/$BX
FOFT=" -depth "
CPIO_ARGS=" -o -H crc "
NM=`basename $0`
FNM=$0
PARGS="$@"
CDIR=`pwd`
CDATE="`date`"

# ------------------------------------------------------------------------
#
#                   Recovery-related constants
#
# ------------------------------------------------------------------------
RLOG=$TMP/$RL
ELOG=$TMP/$RE
FIFO=$TMP/$FI

# ------------------------------------------------------------------------
#
#                         Misc definitions
#
# ------------------------------------------------------------------------

#  Full usage message:
FUSAGE="$NM version $VERS usage:
$NM with no parameters creates a complete cpio-format backup archive for
your Unix or Linux system in the form of a set of file 'chunks', each of
which may then be burned onto standard 650Mb CD-Rs.

A list of directory and file exclusions can be supplied via the file 'bex',
a sed' regular expression (RE) list assumed to be in $BKUP_DIR.
Add, delete or alter this list to suit your own system(s).

Options:-
-h:     Print this (help)
Backing up:
-d dir: Do backup of directory 'dir' (default is to backup from root).
-n N: Only include files modified within the last N days (make a delta).
-g: (#) Compress archive with gzip (Lempel-Ziv coding).  Seems faster than -b.
-b: (#) Compress archive with bzip2 (Burrows-Wheeler-Huffman).
Recovery:
-r [file (*)]: Do full recovery from a CD set [file] into current directory.
-t [file (*)]: List archive contents into '$RL' (use -T for 'ls -l' style).
-f: Force the recovery - overwrite existing files even if they're newer.
-p <pattern> (**): Only recover files or directories matching 'pattern'.
 *  Specifying a file with -r/-t/-T tells backup to use that file(set) on the
local disc/network INSTEAD of the local CD drive (wildcards okay for > 1 file).

Examples:
backup:       Do full backup (from / down) into $BKUP_DIR.
backup -g:    As above, but compress with gzip.
backup -n 31: Backup those files which are up to 31 days old (delta).
backup -d /home/fiona:  Backup the user dir /home/fiona.
backup -r -p 'pattern': Recover files and/or dirs matching 'pattern'. (**)

 **  Different cpio program versions operate slightly differently re pattern
matching, but the following should work with all of them to (eg) extract
the usr/local/src tree:     backup -r -p 'usr*local*src*'
(#)  gzip (-g) seems to work much faster than bzip2 (-b) on larger archives.

Copyright (c) 2001-2003 Tony Sanderson, Bluehaze Solutions
No charge for non-profit or non-commercial use.
"

# ---------------------------------------------------------------------------
#
#          Define a few handy functions:
#
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
#  function error_exit; call as: error_exit <string>.  Outputs the word
#  "Error: " plus user's string to stderr, exits with status of +1.  The
#  supplied string must be quoted.
# ---------------------------------------------------------------------------

error_exit () {
echo "Error: $1" >&2
exit 1
}

# ---------------------------------------------------------------------------
#  function nextcd looks after the mounting and name checking of each CD
#  during a recovery (-r).  It assumes 2 params - $1 = CDNUM, $2=arcname.
#  Sets $chunk = the chunk filename if sucessful.  Also sets DONE = y if user
#  responds with a 'q' (= quit), and sets WRONG = y if the wrong archive piece
#  has been loaded:
# ---------------------------------------------------------------------------

nextcd() {
	DONE="n"
	WRONG="n"
	echo "Insert CD $1 and type ENTER to continue (or 'q' to quit):" >&2
	read -n 1 KB
	if [ "$KB" = "q" ]
	then
		DONE="y"
		umount $MNT >&2 2>/dev/null
		eject $DEV >&2
	else
		dir="`ls $MNT`"
		if [ -z "$dir" ]
		then
			MCMD="mount $DEV $MNT 2>/dev/null"
			eval $MCMD
			if [ $? -ne 0 ]
			then
				 echo "Ooops!  The CD 'mount' operation ($MCMD) failed." >&2
				 echo "You'll need to find out why it's failing before I can get on with the restore." >&2
				 exit 1
			fi
		fi
		chunk=`ls $2 2>/dev/null`
		if [ -z $chunk ]
		then
			echo "Arrr c'mon ... this CD doesn't contain $2" >&2
			umount $MNT >&2 2>/dev/null
			eject $DEV >&2
			WRONG="y"
		fi
	fi
}

# ---------------------------------------------------------------------------
#  function setcat just sets the variable CAT.  Checks param 1 (assumed to be
#  a chunk filename) for the substrings ".gz" or ".bz2".  If it contains
#  ".gz", set CAT=zcat.  If it contains ".bz2", set CAT="bzcat".  Otherwise,
#  it just sets CAT = cat.  $CAT is used in the recovery phase 'pipe' for
#  decompression where appropriate.
# ---------------------------------------------------------------------------

setcat() {
	CAT=" cat " # = the default pipe filter for uncompressed chunks.
	echo $1 | grep '.gz' > /dev/null 2>&1 && CAT=" zcat "
	echo $1 | grep '.bz2' > /dev/null 2>&1 && CAT=" bzcat "
}

# ---------------------------------------------------------------------------
#  function clean:
# ---------------------------------------------------------------------------

clean () {
	rm -f $FIFO
}

# ---------------------------------------------------------------------------
#
#          End of function definition block.
#
# ---------------------------------------------------------------------------

#  Definition of interrupt (killed) response:
trap 'clean; exit 1' 1 2 15

# ------------------------------------------------------------------------
#
#                 Use 'getopt' to check/sort user's args
#
# ------------------------------------------------------------------------
OPTIONS="+hftTgbn:d:rp:"
RES="`getopt $OPTIONS $* 2>&1`"

#  If getopt didn't like user's args, try to give a useful error message:
if [ $? -ne 0 ]
then
	echo "$NM option error: $RES"
	echo ""
	exit 1
fi
set -- $RES

# ------------------------------------------------------------------------
#
#  Set defaults for variables before we parse user args:
#
# ------------------------------------------------------------------------

MODE=backup
FORCE=
FOPT=
DELTA=n
PAT=
VERBOSE=
CONTENTS=
WDIR=/
ABS_REL=
SCE=fifo
FILES=
COMP=n
PROC=
DONE=n

# ------------------------------------------------------------------------
#
#  Now parse user args (will now be in the shell's $1, $2, etc vars):
#
# ------------------------------------------------------------------------

while :
do
	arg="$1"
	if [ -z "$arg" ]; then break; fi	# When all args processed, exit loop.
	# echo "top of while-loop, args now $* and arg=$arg"
	case $arg in
	-n)	FOPT="$FOPT -mtime -$2 "; shift; shift;;
	-t)	MODE="recover"; CONTENTS=t; shift;;
	-T)	MODE="recover"; CONTENTS=t; VERBOSE=v; shift;;
	-r)	MODE="recover"; VERBOSE=v; shift;;
	-p)	PAT=$2; shift; shift;;
	-d)	WDIR=$2; shift; shift;;
	-f)	FORCE=u; shift;;
	-g)	COMP=y; PROC=" gzip | "; ARC_NAME=${ARC_NAME}.gz; shift;;
	-b)	COMP=y; PROC=" bzip2 | "; ARC_NAME=${ARC_NAME}.bz2; shift;;
	-+|-h)	echo "$FUSAGE" >&2; exit;;
	--)	shift;;
	-*)	echo "$E dunno about $arg (Type \"$NM\" for options)" >&2
			exit 1;;
	# This switch added at vers 3.30 to allow recovery from file(s):
	*)	if [ $MODE = "recover" ]
		then
			if [ -f $arg -a -s $arg ]
			then
				SCE=file
				FILES="$FILES $arg"
				shift;
			else
				echo "Unknown arg '$arg' used with recover/list option" >&2
				exit 1
			fi
		else
			echo "Unknown arg(s) '$arg'" >&2
			exit 1
		fi
		;;
	esac
	# echo "bottom of while-loop, args now $* and arg=$arg"
done

# ===========================================================================
#
#                             Do a backup
#
# ===========================================================================

LMSG="
Date run=$CDATE
Command=$FNM $PARGS
Wkg dir=$CDIR
Back up=$WDIR
Archive=$ARC_NAME.*"

if [ $MODE = backup ]
then

	echo "$LMSG" >> $PROG_LOG
	rm -f $ERRS
	# Source backup exclusions file.  Prior to version 4.0, these definitions
	# were listed above but that meant that everyone had to re-edit this
	# script for every new version.
	# Required format is described in the file:
	if [ -f $BEX -a -s $BEX ]
	then
		# BEX is there and non-empty - read into SED_ARGS minus any comments:
		SED_ARGS="`sed -e '/^#/d' -e '/^$/d' $BEX`"
		echo "$BEX backup-exclusions found and read" >> $PROG_LOG
	else
		SED_ARGS=
		echo "$BEX backup-exclusions file not found - ignored" >> $PROG_LOG
	fi

	# ABS_REL is an extra sed RE to convert abs paths to dot-relative.  Also
	# remove the superfluous "." or "./" line that find produces at line 1:
	if [ $WDIR = / ]
	then
		ABS_REL=' -e s#^/$#.# -e s#^/#./# -e \#^\.$#d '
	else
		ABS_REL=" -e s#^$WDIR#./# -e s#//#/# -e \#^\./\$#d "
	fi

	# Using "find", we now create the list of wanted files.  This is everything
	# below $WDIR except for the exclusions defined via SED_ARGS:

	find $WDIR $FOPT 2>$FIND_ERRS | sed $SED_ARGS $ABS_REL 2>$ERRS >$BKUP_LIST
	if [ -s $ERRS ]
	then
		SEMSG="sed parsing problem with $BEX backup-exclusions file"
		echo "$SEMSG" >> $PROG_LOG
		error_exit "$SEMSG"
	fi

	# Now pipe list into cpio and through fsplit to build the archive chunks:
	cd $WDIR
	CMD="cat $BKUP_LIST |cpio $CPIO_ARGS 2>>$ERRS | $PROC fsplit -s $CHUNK_SIZE -o ${ARC_NAME}"
	eval $CMD
	# Rename the last chunk so it can be identified during recovery:
	for f in `ls ${ARC_NAME}*`
	do
		LAST=$f
	done
	NLAST=`echo $LAST | sed s'/cpio/lastcpio/'`
	mv $LAST $NLAST || error_exit "Rename of last chunk failed"
	exit
fi

# ===========================================================================
#
#          Do recovery (or a title listing) from file(s) or CD set
#
# ===========================================================================

if [ $MODE = recover ]
then
	DIR=`pwd`

	if [ "$CONTENTS" = t ]
	then
		echo "Listing contents only, into $RLOG." >&2
	else
		if [ -z "$PAT" ]
		then
			echo "Recovering all files from backup set into $DIR" >&2
		else
			echo "Recovering files matching '$PAT' into $DIR" >&2
		fi
	fi

	#  Check SCE to see whether we're recovering from file(s) or a CD set.
	#  This 'if' alternative added at vers 3.30.
	if [ $SCE = file ]
	then

		# ------------------------------------
		# Aha - easy; recovering from file(s):
		# ------------------------------------

		setcat "$FILES"
		CMD="cat $FILES | $CAT | cpio -idm${VERBOSE}${FORCE}${CONTENTS} $PAT > $RLOG 2>$ELOG"
		eval $CMD # Done.

	else

		# ---------------------------------------------------------------------
		#  More complex - using a CD (set).  This uses some clever code written
		#  by Jean-Francois Landry (.ca):
		# ---------------------------------------------------------------------

		rm -f $FIFO 2>/dev/null
		mkfifo $FIFO 2>/dev/null

		# Create a background process to read from the fifo into cpio:
		cpio -idm${VERBOSE}${FORCE}${CONTENTS} $PAT < $FIFO > $RLOG 2>$ELOG &
	
		# Get the first CD mounted and ready for reading.  This loop will
		# only exit when things look correct, or if the user quits:

		arcname=$MNT/20*cpio.*000
		while :
		do
			nextcd "1", $arcname
			if [ $DONE = "y" ]
			then
				echo "Okay - done" >&2
				exit 1
			fi
			if	[ $WRONG = "y" ]
			then
				continue
			fi
			break
		done

		# Set up the appropriate decompressor if one is needed:
		setcat "$chunk"

		# ------------------------------------------------------------
		# Next, we loop around and recover the archive off the CD set:
		# ------------------------------------------------------------

		SUF=0
		while :
		do
			echo "... pulling data ..." >&2
			cat $chunk
			umount $MNT >&2 2>/dev/null
			eject >&2

			# For compressed archives, we need to be able to identify the final
			# chunk so we now include the string 'last' in that last piece.
			# This facilitates our loop break after the last CD is processed:

			echo $chunk | grep 'last' > /dev/null 2>&1 && break

			# For older existing (pre backup v.4.0) style archives which don't
			# use 'last' in the final chunk name, following line facilitates the
			# loop break after the last CD:

			tail -1 $ELOG 2>/dev/null | grep -i '[0-9] blocks' >/dev/null 2>&1 && break

			# Otherwise we continue - this bit sets up loading of the next CD in
			# the sequence, then we fly back up to the top of the while loop to
			# read it in:

			SUF=`expr $SUF \+ 0`
			if [ $SUF -lt 100 ]; then SUF="0$SUF"; fi
			if [ $SUF -lt 10 ];  then SUF="0$SUF"; fi
			SUF=`expr $SUF \+ 1`
			CDNUM=`expr $SUF \+ 1`
			arcname=$MNT/20*cpio.*${SUF}  # Warning - not 2100 compliant!
			nextcd $CDNUM, $arcname
			if [ $DONE = "y" ]
			then
				error_exit "### Early exit - recovery probably incomplete ###"
			fi
			if [ $WRONG = "y" ]
			then
				continue
			fi
		done | $CAT > $FIFO
	fi

	echo -e "Recovery finished\nRecovery list = $RLOG\nRecovery errors = $ELOG" >&2
	clean
fi

# ----------------------------------------
#
#  HISTORY (latest at top):
#
#  V.4.00, Jan 4, 2003: Add compression options via -g (gzip) or -b (bzip2).
#  NB: The additional buffering with a decompressor in the recovery pipe has
#  forced a new naming convention for the final chunk: it must now include
#  the string 'last' so the script knows when the last chunk has been read,
#  Otherwise recovery is always incomplete when reading back compressed data.
#
#  gzip (-g) seems to work much faster than bzip2 for large archives and is
#  probably to be preferred.  bzip2 was originally included because it
#  compressed into a somewhat smaller size, but on multi-Gb streams, bzip2
#  took 8 hours to compress 6Gb whereas gzip did it in 90 minutes.
#  
#  Note that these new -g/-b flags are only relevant to the backup phase.
#  The recovery process deduces the type of archive from the ".gz" or ".bz2"
#  (or the lack of) which is automatically included in the chunk file names.
#  
#  The sed RE list (of directories/files to be excluded) has now been moved out
#  of here into a side-file called 'bex', defined via BEX above.  This should
#  make maintenance much easier.
#  
#  This version also removes the extraneous "/" or "./" from the top of the
#  "list" file - this was causing the documented "diff" quick-check method
#  shown on the web page to unnecessarily grumble about line 1 with a "1d0"
#  result instead of the expected null result.
#  
#  And a cupla code blocks have been made into functions to reduce duplicity.
#
#  V.3.41, Sep 29, 2002: Change "eject" to "eject $DEV".  Stephen Goodenough
#  (UK) found this necessary with his system which has 2 CD drives (a burner
#  plus a reader) to prevent the wrong drive from ejecting.
#
#  V.3.40, Aug 11, 2002: Add an accumulating running log which records when
#  backup was used, args, working dir, dir being backed up, etc.
#
#  V.3.32, Aug 5, 2002: Just correct and/or add a few comments.
#
#  V.3.30, June 22, 2002: Allow file(s) to be optionally specified with -r|t|-T
#  instead of always requiring a local CD (set) for the archive source.  Ensure
#  that such extra string(s) are an error in any mode except -r|t|T.  Had to
#  modify the arg processing case-statement stuff *and* replace its outer
#  for-loop with a while-loop before everything was happy again though.
#
#  V.3.20, April 13, 2002: Any absolute exclusion expressions in SED_ARGS
#  beginning with "^/" were ineffectual when backing up sub-trees using
#  "-d".   Have added ABS_REL as a separate sed RE and changed the code
#  slightly so these work as expected with -d.
#
#  V.3.15, April 5, 2002: Remove date suffixes from log files.  Remove a
#  superfluous 'if' block.  Tidy up comments.  
#
#  V.3.11, April 3, 2002: Add -t/-T flags to pull out titles (contents) only.
#  This also provides a quick verify method.
#
#  V.3.0, April 2, 2002: Add automated recovery code via -r.  This also uses
#  some clever code suggested (and tested) by Jean-Francois Landry (.ca) that
#  eliminates the need to pull the archive pieces onto disc prior to doing
#  a recovery.  (See help via -h).  Also added -f (force), and -p (pattern).
#
#  V.2.7, April 1, 2002: Now saves previous "list" files as date-suffixed
#  rather than just over-writing (never know when they may be handy).
#  Add 3 new excludes to get rid of some apache/webalizer log-file
#  backups (500Mb + worth in my case).
#
#  V 2.6, Dec 5,2001: Another typo found by Justin Noack.  In Vers 2.5,
#  var FOFT had been mis-typed as FIND_ARG in one line.  This caused
#  the delta backup arg (# days) to be ignored.  Corrected (thanks Justin!)
#
#  V.2.5, Nov 21,2001: Add "-depth" arg and remove "-type f" from find cmd
#  to ensure that dir perms and ownerships are saved in the archive.
#  This was causing restored directories to take incorrect owner/perms.
#  (Bug found by Justin Noack, JN Computer Care and Coloma Community Schools)
#
#  V.2.4, Jun 10,2001: Add "-H crc" arg to cpio (enables > 65k inodes).
#
#  V.2.3, Jun 9,2001:
#  a) Add exclusion dirs /usr/share, /usr/src, /usr/doc, */old_stuff/*.
#  b) Add "full_list" (so we can see what *wasn't* backed up as well).
#  c) Add FIND_ERRS to keep a list of mis-traversed dirs.
#
#  V.2.1, Mar 11,2001: Pipe output of cpio into fsplit instead of generating
#  intermediate files via cpio and then having fsplit read these later.  May
#  run a bit faster, and will use less disc.
#
# ---------------------------- End of script ----------------------------
