#!/bin/sh
# QDBackup v0.02 Copyright 2002 Eric Lee Green
# Released under terms of FSF's GNU General Public License v2

# This quick'n'dirty backup program makes a backup of my network. It
# assumes that we have ssh set up between our machines so that we can
# get stuff backed up that way.

# This script is set up to use a single tape changer and tape device.
# A more sophisticated script could do better. 

################# USER CONSTANTS: CHANGE THESE ##################

TAPE_CHANGER="/dev/pass2"
TAPE_DEVICE="/dev/nsa0"
NEED_EJECT="no"  # say 'yes' if need to eject prior to doing 'mtx unload'. 
BACKUP_DB_DIR="/usr/local/backup"  # directory we use for our databases.
CLEANING_TAPE=6
ADMIN=eric@badtux.org  # who to mail error reports to!

TRANSPORT="ssh" # ssh or rsh -- must be configured for passwordless root access

################ INSTALLATION CONSTANTS ####################
# you may need to fully path these, or change in the case of 'mt'. 

TAR="/usr/local/bin/gtar"                # GNU TAR. you may need to fully path it here. 

MTX="/usr/local/sbin/mtx"  # location of the mtx utility. 


# These are currently hardwired for FreeBSD. Change them for your OS.
OS=`uname`

# when I know about more OS's other than FreeBSD, this needs to become
# a switch :-). 
if [ "$OS" = "FreeBSD" ]
then
    MT_REWIND="mt -f $TAPE_DEVICE rewind"
    MT_EOD="mt -f $TAPE_DEVICE eod"
    MT_EJECT="mt -f $TAPE_DEVICE offline"
    MT_COMPRESS="mt -f $TAPE_DEVICE comp on"
    MT_BLOCKSIZE="mt -f $TAPE_DEVICE blocksize 0" # blocksize to variable
else
    echo "ERROR: I don't know how to control tapes on your system. Aborting."
    exit 1
fi

############### PERFORMANCE CONSTANT ########################
# *WARNING* -- change this, and qdback thinks your tapes are all
# blank! 
BLOCKSIZE=65536
BLOCKINGFACTOR=128   # WARNING: If you change blocksize, change this!

##################### FILENAME CONSTANTS #####################
FILES_PREFIX="${BACKUP_DB_DIR}/FILES"
BACKUP_DB="${BACKUP_DB_DIR}/backup.data"
BACKUP_LOG="${BACKUP_DB_DIR}/backup.log"
INC_DAILY="${BACKUP_DB_DIR}/INC_DAILY"
INC_WEEKLY="${BACKUP_DB_DIR}/INC_WEEKLY"
TAPE_DB="${BACKUP_DB_DIR}/tape.db"
TAPE_COUNT="${BACKUP_DB_DIR}/tape.count"
LAST_SLOT="${BACKUP_DB_DIR}/slot.last"
LAST_TAPE="${BACKUP_DB_DIR}/tape.last"

############# TAPE FORMAT CONSTANTS #############
FORMAT="QDBACK0"

############### SEVERITY CONSTANTS ############
DEBUG=1  # do debugging output
ERR_FATAL=0  # the most fatal kind.
ERR_WARNING=1 # not fatal. 
ERR_INFO=2    # just info, please!

################ SUBROUTINES ############################

# debugging messages:
debug() {
    if [ "$DEBUG" = "1" ]
    then
      echo "$1"
    fi
}


# log MESSAGE SEVERITY
log() {
   MESSAGE="$1"
   SEVERITY="$2"
   NUMBER="$3"
   DATE=`date`
   echo "[$DATE] $MESSAGE" >>$BACKUP_LOG
   if [ "$SEVERITY" = "$ERR_FATAL" ]
   then
      mail -s "Fatal Backup Error $NUMBER" $ADMIN <<EOF
A fatal error was received in the backup process at $DATE:

  $MESSAGE
EOF
  fi
}


# This rewinds a tape and looks at its header. Returns its value
# in global variable TAPE_HEADER. This value is either "" (no header/blank
# tape/unknown tar format), UNKNOWN (it's a tarball, but not one of ours, or
# an old format), or a valid tape header in QDBACK0 format. 
check_header() {
    TAPE_HEADER=""

    eval $MT_REWIND    #rewind the tape
    eval $MT_COMPRESS  # just in case, turn on its compress.
    eval $MT_BLOCKSIZE #  set the blocksize. 
    
    # use dd here basically so that if it's some other kind of tarball,
    # we don't spend lots of time on this!
    header=`dd if=$TAPE_DEVICE bs=$BLOCKSIZE count=1 </dev/null 2>/dev/null | $TAR -tf -`
    retval=$?

    if [ "$retval" != "0" ]
    then
        return # it's a blank tape, got nothing I guess.
    fi

    if [ "$header" = "" ]
    then
	return # got no tape header, it's a blank tape.
    else
	# it was at least a tarball with a blocking factor of $BLOCKINGFACTOR, see if it
	# was one of *OUR* tarballs:
	tape_type=`echo "$header" | cut -d ' ' -f 1`
	if [ "$tape_type" != "$FORMAT" ]
	then
	    TAPE_HEADER="UNKNOWN"  # sigh, let caller know we DID get a tape header.
	    return # got no tape header
	fi
    fi
    # okay, if we got down here, we got a tape header, and it's the appropriate format. 
    TAPE_HEADER="$header"
    # and return.
}
    
# Create a tape header. 
write_tape_header() {
    if [ ! -f $TAPE_COUNT ]
    then
	TAPE_ID=0
    else
	TAPE_ID=`cat $TAPE_COUNT`
    fi

    # increment
    NEXT_ID=`expr $TAPE_ID + 1`
    echo "$NEXT_ID" >$TAPE_COUNT

    set_tape_in_drive   
    if [ "$tape_in_drive" = "" ]
    then
	log "ERROR: Cannot find tape in $TAPE_CHANGER, aborting." $ERR_FATAL
	exit 1
    fi
    DATE=`date`
    TAPE_HEADER="$FORMAT $TAPE_ID Slot $tape_in_drive $DATE"

    eval $MT_REWIND  # rewind us again to overwrite the tape!

    # put this tempfile in our backup database dir rather than /tmp in order
    # to avoid tmp races, sigh.
    date >"${BACKUP_DB_DIR}/$TAPE_HEADER"
    cd $BACKUP_DB_DIR
    echo "$TAR -cvf $TAPE_DEVICE \"$TAPE_HEADER\" "
    # sigh. 
    $TAR -cvf $TAPE_DEVICE "$TAPE_HEADER" 
    /bin/rm -f "${BACKUP_DB_DIR}/$TAPE_HEADER"
}

# Go to end of tape. 
position_tape() {
    DO_ERASE=$1
    if [ "$DO_ERASE" = "y" ]
    then
        write_tape_header
    fi
    eval $MT_EOD  # end of device. 
}

check_tape() {
    # otherwise, we assume we want to start with the tape in the drive.
    # we rewind it and look for a tape header.
    needheader=""  # don't need one at moment.
    tape_ok="" # not ok yet. 
    check_header
    debug "TAPE_HEADER='$TAPE_HEADER'"
    if [ "$TAPE_HEADER" = "UNKNOWN" ]
    then
       debug "needheader=y tape_ok=y"
       needheader="y"  # need a header.
       tape_ok="y"
       return
    fi
    if [ "$TAPE_HEADER" = "" ]
    then
       debug "needheader=y tape_ok=y tape has no header"
       needheader="y" # need a header
       tape_ok="y"
       return
    fi    
    if fgrep "|${TAPE_HEADER}|" $TAPE_DB
    then
	debug "Tape is marked as full in our tape database"
        return #neither tape_ok nor needheader.
    fi
    tape_ok="y"  # append to this tape, we hope.
    return
}


#  Simply determines tape_in_drive w/simplest mechanism.
set_tape_in_drive() {
    tape_in_drive=`$MTX -f $TAPE_CHANGER status | grep 'Data Transfer Element 0:Full' | awk '{ print $7 }'`
}

# returns slot_status
get_slot_status() {
   gsef_slot_num="$1"
   slot_status=`$MTX -f $TAPE_CHANGER status | fgrep "Storage Element ${gsef_slot_num}:" | cut -d ':' -f 2 | cut -d ' ' -f 1`
}

# loads the next tape. 
next_tape() {
    # first, see what the current tape is:
    set_tape_in_drive
    if [ "$tape_in_drive" = "" ]
    then
       log "Internal error: next_tape only supposed to be called with tape in drive." $ERR_FATAL 104
       exit 1
    fi
    slot_num="$tape_in_drive" # the current slot #. 
    # okay, now let's see what we get for that tape:
    get_slot_status "${slot_num}" # get the slot_status variable.
    # the first slot SHOULD be empty... 
    while [ "$slot_status" = 'Empty' ]
    do
	slot_num=`expr $slot_num + 1`
        if [ "$slot_num" -eq "$CLEANING_TAPE" ]
        then
           continue  # skip to next! 
        fi
	get_slot_status "${slot_num}"
	if [ "$slot_status" = "" ]
        then
            echo "1" >$LAST_TAPE
	    # load_first_tape_empty_drive wants an empty drive, unlike us!
	    if [ "$NEED_EJECT" = "yes" ]
	    then
		eval $MT_EJECT
	    fi
	    $MTX -f $TAPE_CHANGER unload >/dev/null # unload any tape currently there :-(

            load_first_tape_empty_drive # do-over!
            if [ "$slot_num" = "$tape_in_drive" ]
            then
               log "All tapes are full. No more tapes." $ERR_FATAL 105 
               exit 1
            fi
        fi
    done
    if [ "$slot_status" = "Full" ]
    then 
        if [ "$slot_num" -eq "$CLEANING_TAPE" ]
        then
           log "Internal Error: Trying to load cleaning tape in next_tape" $ERR_FATAL 109
           exit 1
        fi
        echo "$slot_num" >$LAST_TAPE
	if [ "$NEED_EJECT" = "yes" ]
	then
	    eval $MT_EJECT
	fi
	$MTX -f $TAPE_CHANGER unload >/dev/null # unload any tape currently there :-(

        $MTX -f $TAPE_CHANGER load $slot_num
	# now to verify that it actually loaded :-(
	tape_in_drive=`$MTX -f $TAPE_CHANGER status | fgrep 'Data Transfer Element' | cut -d ':' -f 2 | cut -d ' ' -f 4`
    else
      log "Internal Error: Have tape in loader, but cannot find it?" $ERR_FATAL 103
      exit 1
    fi
}

# we have an empty drive. Load the first tape of the backup into it.
load_first_tape_empty_drive() {
    # first, see if we got something in there:
    got_slot=`$MTX -f $TAPE_CHANGER status | fgrep 'Storage Element' | fgrep 'Full'`
    if [ "$got_slot" = "" ]
    then
        log "Fatal Error: No Tapes In Loader" $ERR_FATAL 102
        exit 1
    fi
    # okay, so we have a tape. Let's start with $LAST_TAPE and go forward:
    if [ -f $LAST_TAPE ]
    then
         slot_num=`cat $LAST_TAPE`
    else
         slot_num="1"
         echo "1" >$LAST_TAPE
    fi
    if [ "$slot_num" -eq "$CLEANING_TAPE" ]
    then
	slot_num="1"
	echo "1" >$LAST_TAPE
    fi
    orig_slot_num="$slot_num"
    get_slot_status "${slot_num}"  # go ahead and get it. 
    while [ "$slot_status" = 'Empty' ]
    do
	slot_num=`expr $slot_num + 1`
        if [ "$slot_num" -eq "$orig_slot_num" ]
        then
           log "Fatal Error: Only tape in loader is cleaning tape" $ERR_FATAL 110 
           exit 1
        fi
           
        if [ "$slot_num" -eq "$CLEANING_TAPE" ]
        then
           continue
        fi
        get_slot_status "${slot_num}"
	if [ "$slot_status" = "" ]
        then
            echo "1" >$LAST_TAPE
            slot_num=0
            continue
        fi
    done
    if [ "$slot_status" = "Full" ]
    then 
        echo "$slot_num" >$LAST_TAPE
        $MTX -f $TAPE_CHANGER load $slot_num
	# now to verify that it actually loaded :-( 
        get_tape_in_drive
        if [ "$tape_in_drive" -ne "$slot_num" ]
        then
           log "Internal Error: Expected tape $slot_num, got tape $tape_in_drive, aborting." $ERR_FATAL 107
           exit 1
        fi
    else
      log "Internal Error: Have tape in loader, but cannot find it?" $ERR_FATAL 103
      exit 1
    fi
}                      

# sets the tape_in_drive variable to either a number or 'Empty'. 
get_tape_in_drive() {
    tape_in_drive=`$MTX -f $TAPE_CHANGER status | fgrep 'Data Transfer Element' | cut -d ':' -f 2 | cut -d ' ' -f 4`
}

# Locate/load tape into drive, if one is not already in there.
load_first_tape() {
    get_tape_in_drive
    if [ "$tape_in_drive" = 'Empty' ]
    then
        load_first_tape_empty_drive
    fi
    if [ "$tape_in_drive" = "" -o "$tape_in_drive" = "Empty" ]
    then
       log "Fatal Error: Could Not Find Tape In Drive" $ERR_FATAL 100
       exit 1
    fi
    first_tape="$tape_in_drive"  # which is set by load_first_tape_empty_drive
    check_tape
    while [ "$tape_ok" != "y" ]
    do
	# note: next_tape will abort if there is no other tape to load.
	# if next_tape returns rather than aborting, it means we got a
	# tape, which may or may not have something on it. Note that
	# next_tape skips tapes that are supposed to be cleaning tapes. 
        next_tape
        if [ "$tape_in_drive" = "$first_tape" ]
        then
             log "Fatal Error: No Blank Tapes In Loader" $ERR_FATAL 101
             exit 1
        fi
        check_tape
    done

    # okay, done! position the tape! (possibly after writing header
    # if $needheader variable returned by check_tape = 'y' )
    position_tape "$needheader"
} 

# we look for an empty tape in the jukebox.
next_backup_tape() {
    get_tape_in_drive
    first_tape="$tape_in_drive"

    next_tape
    get_tape_in_drive
    check_tape
    while [ "$tape_ok" != "y" ]
    do
        debug "tape in drive is $tape_in_drive tape_ok was $tape_ok need_header was $need_header "
	next_tape
        get_tape_in_drive
	if [ "$tape_in_drive" = "$first_tape" ]
        then
             log "Fatal Error: No Blank Tapes In Loader [101]" $ERR_FATAL 101
             exit 1
        fi
        check_tape
    done
    position_tape "$needheader"  # stamps a new header if needed. 
}


## The directory has the following format:
#   orig_slotnum|tapeid|full_header|startdate|enddate|machine|daily/weekly/full|dummy|dummy|dummy|dummy|directory
# we may need to extract that info from TAPE_HEADER. TAPE_HEADER has the
# format:
#       TAPE_HEADER="$FORMAT $TAPE_ID Slot $SLOT_NUM $DATE"
write_directory() {
    backup_type="$1"
    machine_name="$2"
    file_name="$3"
    tape_header="$4"
    start_time="$5"
    end_time="$6"
    TAPE_ID=`echo "$tape_header" | awk '{ print $2 }'`
    get_tape_in_drive
    if [ "$machine_name" = "" ]
    then
        dbms_machine_name="localhost"
    else
	dbms_machine_name="$machine_name"
    fi
    echo "$tape_in_drive|$TAPE_ID|$tape_header|${start_time}|${end_time}|${dbms_machine_name}|${backup_type}|dummy|dummy|dummy|dummy|${file_name}" >>$BACKUP_DB        
}

# marks it as 'full' in the database. The format is:
# tapenum|date|slot|full_header|dummy|dummy|dummy|dummy|dummy
mark_tape_full() {
    tape_header="$1"
    get_tape_in_drive
    TAPE_ID=`echo "$tape_header" | awk '{ print $2 }'`    
    DATE=`date`
    echo "$TAPE_ID|$DATE|$tape_in_drive|$tape_header|||||||||" >>$TAPE_DB
}

# Put out a single file, then add it to the directory. If we hit
# EOT then restart on next tape, or abort if no next tape. 
backup_file() {
   backup_type="$1"
   machine_name="$2"
   file_name="$3"

   T=`echo $file_name | awk '{print $1}'`
   if [ "$T" = "#" ]
   then
        return  # it's a comment! 
   fi

   echo "In backup_file $backup_type $machine_name $file_name"

   # okay. We need --ignore-failed-read to back up samba filesystems :-(
   # 
   incremental=""
   case "$backup_type" in
     daily) 
           incremental="-N $INC_DAILY"
          ;;
     weekly)
           incremental="-N $INC_WEEKLY"
         ;;
    esac
   
    start_time=`date`
    # now update our timestamp files:
    case "$backup_type" in
       weekly)
          if [ "$machine_name" = "" ]
          then
              touch $INC_DAILY
          else
	       $TRANSPORT $machine_name touch $INC_DAILY
          fi
           ;;
        full)
          if [ "$machine_name" = "" ]
          then
              touch $INC_DAILY
              touch $INC_WEEKLY
          else
	       $TRANSPORT $machine_name touch $INC_DAILY
	       $TRANSPORT $machine_name touch $INC_WEEKLY
          fi
           ;;
     # otherwise do nada. 
   esac             

   while true
   do
       if [ "$machine_name" = "" ]
       then
         $TAR --one-file-system --ignore-failed-read $incremental -cf - "$file_name" | dd of=$TAPE_DEVICE bs=$BLOCKSIZE
         exitval="$?"
       else
          $TRANSPORT $machine_name $TAR --one-file-system --ignore-failed-read $incremental -cf - "$file_name"  | dd of=$TAPE_DEVICE bs=$BLOCKSIZE
         exitval="$?"
       fi
       if [ "$exitval" = "0" ]
       then
            echo "[$machine_name] Successfully wrote files from directory $file_name."
            break  # finished with this file.
       fi
       log "Backup exited with error code $exitval, trying next tape" $ERR_INFO 106
       # mark this tape as full in the directory:
       mark_tape_full "$TAPE_HEADER"

       # tries to find another empty backup tape, sigh. Exits if we can't find
       # another tape. 
       next_backup_tape  
   done
   end_time=`date`
   write_directory "$backup_type" "$machine_name" "$file_name" "$TAPE_HEADER" "$start_time" "$end_time"
    # and done!
}     

# we will start putting tarballs out, one per directory listed above.
# when we hit the end of a tape (as dictated by getting an i/o error
# back from tar), we re-start that with the

backup_machine() {
    backup_type="$1"
    bm_machine_file="$2"

    debug "backup_machine $backup_type $bm_machine_file"

    if [ "$bm_machine_file" = "${FILES_PREFIX}" ]
    then 
        bm_machine_name=""
    else
        bm_machine_name=`echo $bm_machine_file | sed -e "s%${FILES_PREFIX}_%%"`
    fi

    # okay, now let's start backing up files:
    if [ ! -f $bm_machine_file ]
    then
        log "Internal Error: Got invalid file name '$bm_machine_file'." $ERR_FATAL 108
        exit 1
    fi

    exec < $bm_machine_file
    read FILE
    while [ "$FILE" != "" ]
    do
	# redirect stdin, sigh, 'cause ssh fucks with it.
        backup_file "$backup_type" "$bm_machine_name" "$FILE" </dev/null
        read FILE
    done
}


backup() {
    backup_type="$1"  # daily, weekly, or full.
    machine="$2"    # may be blank, in which case it's all. 
    NUM_SLOTS=`$MTX -f $TAPE_CHANGER status | grep 'Storage Changer' | awk '{ print $5 }'`
    if [ "$backup_type" = "daily" ]
    then
        if [ ! -f "$INC_DAILY" ]
        then
            backup_type="weekly"
        fi
    fi
    if [ "$backup_type" = "weekly" ]
    then
	if [ ! -f "$INC_WEEKLY" ]
        then
	    backup_type="full"
        fi
    fi
    
    load_first_tape  # load the first tape, or abort.

    if [ "$machine" != "" ]
    then
         m="${FILES_PREFIX}_${machine}"
	 if [ ! -f "$m" ]
         then
             echo "No configuration information for machine ${machine} in file ${m}, aborting."
	     log "Illegal machine ${m}, aborting." $ERR_FATAL 111
	     exit 1
         fi
         backup_machine $backup_type "$m"
         return
    fi
   
    # okay, now start backing up machines: Added to get rid of 
    # Emacs squiggle files (sigh!)
    machines_list=`ls ${FILES_PREFIX}_* | fgrep -v '~'`
    if [ "$machines_list" != "" ]
    then
       for m in $machines_list
       do
          backup_machine $backup_type $m
       done
    fi

    if [ -f "${FILES_PREFIX}" ]
    then
        backup_machine $backup_type "$FILES_PREFIX"  # do localhost
    fi
}

# This lists the contents of a tape, given its $TAPE_HEADER. 
list_tape() {
  header="$1"
  slot="$2"
  echo -n "Slot $slot *** TapeID $TAPE_HEADER *** "
  if fgrep "|${header}|" $TAPE_DB >/dev/null 2>/dev/null
  then
      echo " ***IS FULL***"
  else
      echo
  fi
  fgrep "|${header}|" $BACKUP_DB | awk "-F|" ' 
           $6 != "" { print $4,"[",$7,"] Host \"",$6, "\" Directory \"",$12,"\"" } 
           $6 == "" { print $4,"[",$7,"] Host \" localhost \" Directory \"",$12,"\"" }  '
}

# This is called with a tape in the drive. It basically reads the header,
# then tells us what is on the tape. We can also be called with a number.
# if so, we load that tape first (or announce that it's a cleaning tape if
# it is our defined cleaning tape). 
listone() {
   slot="$1"
   if [ "$slot" != "" ]
   then
        if [ "$slot" = "$CLEANING_TAPE" ]
        then
           echo "Slot $slot is the cleaning tape."
           return
        fi
	# sigh, load a tape.
        set_tape_in_drive
        if [ "$tape_in_drive" != "$slot" ]
        then
	    if [ "$tape_in_drive" != "Empty" ]
	    then
		if [ "$NEED_EJECT" = "yes" ]
		then
		    eval $MT_EJECT
		fi
		$MTX -f $TAPE_CHANGER unload  >/dev/null
	   fi
           $MTX -f $TAPE_CHANGER load $slot # if invalid slot, then ...
        fi
   fi
   set_tape_in_drive   
   check_header
   if [ "$TAPE_HEADER" = "" -o "$TAPE_HEADER" = "UNKNOWN" ]
   then
       echo "Tape in slot $tape_in_drive is not a QDBackup tape."
       return
   fi

   list_tape "$TAPE_HEADER" "$tape_in_drive"  # list it to screen!
}
   

listjuke() {
    # sigh, load a tape.
    set_tape_in_drive
    if [ "$tape_in_drive" != "1" ]
    then
	if [ "$tape_in_drive" != "Empty" ]
	then
	    if [ "$NEED_EJECT" = "yes" ]
            then
		eval $MT_EJECT
            fi
	    $MTX -f $TAPE_CHANGER unload >/dev/null # probab
	fi
	$MTX -f $TAPE_CHANGER load 1
    fi

    numtapes=1

    # okay, let's start listing:
    listone
    next_tape
    get_tape_in_drive
    while [ "$tape_in_drive" -ne "1" ]  
    do
	listone
	next_tape  # should skip cleaning tape. 
	get_tape_in_drive
	if [ "$tape_in_drive" = "" ]
	then
	    break
	fi
	if [ "$tape_in_drive" = "Empty" ]
	then
	    break  # sigh!
	fi
	if [ "$tape_in_drive" -ne "1" ]
	then
	    numtapes=`expr $numtapes + 1 `
        fi
    done
    echo "***listjuke: $numtapes listed, exiting. ***"
}

#######################INITIALIZATION ##################
if [ ! -d $BACKUP_DB_DIR ]
then
   mkdir $BACKUP_DB_DIR
fi

COMMAND="$1"

case "$COMMAND" in
  daily) backup "daily" "$2"
       ;;
  weekly) backup "weekly" "$2"
       ;;
  full)  backup "full" "$2"
        ;;
  machine)
         list_machine "$2"  # list all backups for a machine.
     ;;
  latest)
         list_machine_latest "$2"  # list latest backups for a machine
    ;;
  tapes) 
         list_tapes  # list tapes out of database :-(.
      ;;
  ls) 
          listone "$2"  # list the one in the drive *only*. 
      ;;
  list)
          listjuke
            ;;

## DEBUGGING
  next_tape) 
         next_backup_tape
         ;;
  slot_status)
         get_slot_status "$2"
         echo "Slot status = $slot_status"
         ;;
  tape_in_drive)
	get_tape_in_drive
	echo "tape_in_drive='$tape_in_drive'"
	;;
  *)
       echo "qdback: Unknown command $COMMAND, aborting."
       exit 1
     ;;
esac
