#!/bin/perl -w
#############################################################################
#
#                 NOTE: This file under revision control using RCS
#                       Any changes made without RCS will be lost
#
#              $Source: /home/nickb/perl/backup-scripts/RCS/cpiotool-0.65,v $
#            $Revision: 1.2 $
#                $Date: 2001/11/14 23:18:34 $
#              $Author: nickb $
#              $Locker:  $
#               $State: Exp $
#
#              Purpose: interface to cpio
#
#          Description:
#
#           Directions: 'perldoc cpiotool'
#
#     Default Location:
#
#           Invoked by:
#
#
#           Depends on:
#
#	Copyright (c) 2001 Assentive Solutions. All rights reserved.
#
#       This program is free software; you can redistribute it and/or
#       modify it under the terms of version 2 of the GNU General Public
#       License as published by the Free Software Foundation available at
#
#       http://www.gnu.org/copyleft/gpl.html
#
#       This program 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 General Public License for more details.
#
#
#############################################################################

use 5.6.0;
use Fcntl;
use FileHandle;
use File::Basename "basename";
use File::Find;
use Getopt::Long;
use POSIX "strftime";
use Sys::Hostname;
use strict;
use vars '$VERSION';

$VERSION = '0.65a';

my $usage =
q/
cpiotool -c <config file>
cpiotool --help
cpiotool -v

cpiotool [ --cpio <cpio binary> ] [ --level <0-9> ] [ --dd <dd binary> ]
[ -h <remote host> ] [ -b <backup root dir> ] ([ -d <device> ] | [ -f <file> ])
[ --block-size <n> ] [ --reset-atime ] [ --logdir <logdir> ]
[ --keep-logs <0-99999> ] [ --rsh <rsh|ssh binary> ] [ --set <1-99999> ]
[ --maxsize <n> ] [ --ziplog [ <compression util> ] ] [ --confdir <config dir> ]
[ --header <bin|odc|newc|crc|tar|ustar|ascii> ]
[ --notify <addr@domain> [ --notify <addr@domain> ] ... ] [ --verbose ]
[ --debug ]/ . "\n\n";

my $version =
qq/cpiotool version $VERSION, Copyright (C) 2001 Assentive Solutions

This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.  See the GNU General Public License for more
details.\n\n/;

die $usage if (@ARGV) == 0;

# paths
my @COMPRESSPATH = qw( /bin /usr/bin /usr/local/bin );
my @CPIOPATH	 = qw( /usr/local/bin /usr/bin /bin );
my @DDPATH	 = @COMPRESSPATH;
my @MAILPATH	 = @COMPRESSPATH;
my @RSHPATH	 = @COMPRESSPATH;

# constants
my $BASENAME   = basename $0;
my $BYTECOUNT  = 0;
my $DATESTAMP  = strftime "%Y%m%d-%H", localtime;
my $HOSTNAME   = hostname();
my $MEDIACOUNT = 1;

# flags
my ($EXCLUDELIST, $NO_DIRLIST, $NO_INCREMENTAL, $NO_LOGGING,
    $NO_MAXSIZE, $NOTIFY, $REMOTE_TRANSPORT, $TAR_HEADER);

# program-wide vars
my ($AFILE, $ARCHIVE, $ATIME, $BACKUP_ROOT, $BLOCK_SIZE, $BYTES, $CMD,
    $CONF_DIR, $CONFIG_FILE, $CPIO_BIN, $DD, $DEBUG, $DEBUG_LEVEL, $DEVICE,
    @DIRS, @EXCLUDES, $FH, $GNU_CPIO, $HEADER, $HEADER_SIZE, $HELP,
    $KEEP_LOGS, $LEVEL, $LOG, $LOGDIR, $MAILER, $MAXBYTES, $MAXSIZE, @NOTIFY,
    $PADDING, %PARAMS, $RECIP, $REMOTE_HOST, $RSH, $SET, $TIMESTAMP, $TRAILER,
    $v, $VERBOSE, $ZIPLOG);

GetOptions( 'b=s'	   => \$BACKUP_ROOT,	# dir to start backup
	    'block-size=i' => \$BLOCK_SIZE,	# set (n * 512) byte blocks
	    'c=s'	   => \$CONFIG_FILE,	# config file location
	    'confdir=s'	   => \$CONF_DIR,	# config file dir
	    'cpio=s'	   => \$CPIO_BIN,	# location of cpio
	    'dd=s'	   => \$DD,		# dd binary
	    'debug!'	   => \$DEBUG,		# debug
	    'd=s'	   => \$DEVICE,		# tape dev
	    'f=s'	   => \$AFILE,		# archive file (instead of dev)
	    'h=s'	   => \$REMOTE_HOST,	# remote tapehost
	    'header=s'	   => \$HEADER,		# cpio header
	    'help'	   => \$HELP,		# display usage
	    'keep-logs=i'  => \$KEEP_LOGS,	# how long to keep backup logs
	    'level=i'	   => \$LEVEL,		# backup level
	    'logdir=s'	   => \$LOGDIR,		# logdir
	    'maxsize=f'	   => \$MAXSIZE,	# max archive size MB
	    'notify=s'	   => \@NOTIFY,		# email addr
	    'reset-atime!' => \$ATIME,		# reset atime after read
	    'rsh=s'	   => \$RSH,		# rsh binary
	    'set=i'	   => \$SET,		# tapeset number
	    'v'		   => \$v,		# program version
	    'verbose!'	   => \$VERBOSE,	# display warnings
	    'ziplog:s'	   => \$ZIPLOG		# compress log
	   );

# -- sig handlers --
$SIG{HUP}  = sub { warn "$BASENAME: caught HUP\n" if $DEBUG_LEVEL > 1 };
$SIG{PIPE} = sub { warn "$BASENAME: broken pipe: $!\n" };
$SIG{INT}  = sub { warn "$BASENAME: caught INT\n" if $DEBUG_LEVEL > 1 };
my $TRAP   = 'trap "" 0 1 2 3 15 17 18';

# -----------------------------------------------------------------------
#                                main
# -----------------------------------------------------------------------

# load config file
if (defined($CONFIG_FILE))
{
    die "$BASENAME: no such file $CONFIG_FILE\n" unless -e $CONFIG_FILE;
    &parseConfig;
}

# set debug level
$DEBUG_LEVEL = &setDebugLevel;
warn "$BASENAME: debug level $DEBUG_LEVEL\n" if $DEBUG_LEVEL > 1;

# check cmd-line params
&chkOpts;

# create timestamp file for incremental backups
&setTimestamp unless $NO_INCREMENTAL;

# read dirlist: set @DIRS and @EXCLUDES from config file
&readDirs;

# read files to cpio while removing @EXCLUDES
&findFiles(@DIRS);

# send report
&notify('REPORT') if $NOTIFY;

# compress log
if ($DEBUG_LEVEL >= 1) { warn "$BASENAME: compressing $LOG\n" if $ZIPLOG; }
system "$ZIPLOG $LOG" if $ZIPLOG;

# remove old logs
&removeLogs unless $NO_LOGGING;


# -----------------------------------------------------------------------
#                             subroutines
# -----------------------------------------------------------------------

# -----------------------------------------------------------------------
# parseConfig: extract params, @DIRS, and @EXCLUDES from config file
# caller: main
# parameters:
# returns:
# -----------------------------------------------------------------------

sub parseConfig
{
    my @params = qw(block-size conf-dir cpio dd debug device file header
    		    keep-logs level logdir maxsize notify remote-host
		    reset-atime root-dir rsh verbose set ziplog);
    my ($key, $readconf, $readdirs, $readexclude);
    open(CONFIG, $CONFIG_FILE) or die
	"$BASENAME: can't open config file: $!\n";
    LINE: while (<CONFIG>)
    {
	my $line = $_;
	next if $line =~ /^#/;

	if ($line =~ /\[conf]/i)
	{
	    $readconf = 1;
	    $readdirs = 0;
	    $readexclude = 0;
	    next;
	}
	
	if ($line =~ /\[dirlist]/i)
	{
	    $readconf = 0;
	    $readdirs = 1;
	    $readexclude = 0;
	    next;
	}

	if ($line =~ /\[exclude]/i)
	{
	    $readconf = 0;
	    $readdirs = 0;
	    $readexclude = 1;
	    next;
	}

	if ($readconf)
	{
	    foreach $key (@params)
	    {
		if ($line =~ /^($key)\s+(.*)\n$/)
		{
		    chomp($line);
		    $PARAMS{$key} = $2;
		    next LINE;
		}
	    }
	} elsif ($readdirs) {
	    if ($line =~ /^\S+/)
	    {
		chomp($line);
		push(@DIRS, $line);
		next;
	    }

	} else {  # ($readexclude)
	    if ($line =~ /^\S+/)
	    {
		chomp($line);
		push(@EXCLUDES, $line);
		next;
	    }
	}
    }
    close(CONFIG);
}

# -----------------------------------------------------------------------
# setDebugLevel: set debug level: none, verbose, or debug
# caller: main
# parameters:
# returns: 0, 1, or 2
# -----------------------------------------------------------------------

sub setDebugLevel
{
    my $no_debug;

    ## -- check opts --
    if (defined($DEBUG)) # $DEBUG set on cmd-line to 0 or 1
    {
	if ($DEBUG)   # $DEBUG = 1
	{
	    return 2; # full debug
	} else {      # $DEBUG = 0
	    $no_debug = 1;
	}
    }

    if (defined($VERBOSE)) # $VERBOSE set on cmd-line to 0 or 1
    {
	if ($VERBOSE) # $VERBOSE = 1
	{
	    return 1; # partial debug
	} else {      # $VERBOSE = 0
	    return 0; # no debug
	}
    }

    ## -- check config --
    if (defined($PARAMS{debug}))
    {
	if ($PARAMS{debug} eq '1')
	{
	    $DEBUG = 1 unless $no_debug;
	    return 2 if $DEBUG;  # full debug
	} else {      # $PARAMS{debug} != 1 or $no_debug
	    $no_debug = 1;
	}
    }

    if (defined($PARAMS{verbose}))
    {
	if ($PARAMS{verbose} eq '1')
	{
	    return 1; # partial debug
	} else {      # $PARAMS{verbose} != 1
	    return 0; # no debug
	}
    }

}

# -----------------------------------------------------------------------
# chkOpts: override config file params & check cmd-line args
#	   should be split into smaller routines
# caller: main
# parameters:
# returns:
# -----------------------------------------------------------------------

sub chkOpts
{
    die $version if $v;
    die $usage   if $HELP;

    my ($addr, @addrlist, $DIR, $dir, $tmpaddr, @tmpdirs, $trailer);

    # -- backup root-dir --
    if (defined($BACKUP_ROOT))
    {
	die "$BASENAME: no directory $BACKUP_ROOT\n" unless -d $BACKUP_ROOT;
    } elsif (defined($PARAMS{'root-dir'})){
	die "$BASENAME: no directory $PARAMS{'root-dir'}\n"
	    unless -d $PARAMS{'root-dir'};
	$BACKUP_ROOT = $PARAMS{'root-dir'};
    } else {
	$BACKUP_ROOT = '/';
    }
    warn "$BASENAME: set backup root to $BACKUP_ROOT\n"
	if $DEBUG_LEVEL >= 1;

    # -- cpio binary --
    if (defined($CPIO_BIN))
    {
	die "$BASENAME: $CPIO_BIN not found: $!\n" unless -e $CPIO_BIN;
	die "$BASENAME: $CPIO_BIN not executable: $!\n" unless -x $CPIO_BIN;
	$GNU_CPIO = 1 if &_isGNUcpio($CPIO_BIN);
    } elsif (defined($PARAMS{cpio})) {
	die "$BASENAME: $PARAMS{cpio} not found: $!\n" unless -e $PARAMS{cpio};
	die "$BASENAME: $PARAMS{cpio} not executable: $!\n"
	    unless -x $PARAMS{cpio};
	$CPIO_BIN = $PARAMS{cpio};
	$GNU_CPIO = 1 if &_isGNUcpio($CPIO_BIN);
    } else {
	foreach $DIR (@CPIOPATH)
	{
	    if (-x "$DIR/cpio")
	    {
		$CPIO_BIN = "$DIR/cpio";
		$GNU_CPIO = 1 if &_isGNUcpio($CPIO_BIN);
		last;
	    }
	}
	die "$BASENAME: cannot find cpio.  use --cpio to specify\n"
	    unless defined $CPIO_BIN;
    }

    warn "$BASENAME: using $CPIO_BIN\n" if $DEBUG_LEVEL >= 1;
    $CMD = "$TRAP\; $CPIO_BIN -o";

    # -- dd binary --
    unless ($GNU_CPIO)
    {
	if (defined($DD))
	{
	    die "$BASENAME: $DD not found: $!\n" unless -e $DD;
	    die "$BASENAME: $DD not executable: $!\n" unless -x $DD;
	} elsif (defined($PARAMS{dd})) {
	    die "$BASENAME: $PARAMS{dd} not found: $!\n" unless -e $PARAMS{dd};
	    die "$BASENAME: $PARAMS{dd} not executable: $!\n"
		unless -x $PARAMS{dd};
	    $DD = $PARAMS{dd};
	} else {
	    foreach $DIR (@DDPATH)
	    {
		if (-x "$DIR/dd")
		{
		    $DD = "$DIR/dd";
		    last;
		}
	    }
	    die "$BASENAME: cannot find dd.  use --dd to specify\n"
		unless defined $DD;
	}
	warn "$BASENAME: using $DD for remote write\n" if $DEBUG_LEVEL >= 1;
    }

    # -- rsh binary --
    unless ($GNU_CPIO)
    {
	if (defined($RSH))
	{
	    die "$BASENAME: $RSH not found: $!\n" unless -e $RSH;
	    die "$BASENAME: $RSH not executable: $!\n" unless -x $RSH;
	} elsif (defined($PARAMS{rsh})) {
	    die "$BASENAME: $PARAMS{rsh} not found: $!\n" unless
		-e $PARAMS{rsh};
	    die "$BASENAME: $PARAMS{rsh} not executable: $!\n"
		unless -x $PARAMS{rsh};
	    $RSH = $PARAMS{rsh};
	} else {
	    foreach $DIR (@RSHPATH)
	    {
		if (-x "$DIR/rsh")
		{
		    $RSH = "$DIR/rsh";
		    last;
		} elsif (-x "$DIR/ssh") {
		    $RSH = "$DIR/ssh";
		    last;
		} else {
		    next;
		}
	    }
	    die "$BASENAME: cannot find rsh.  use --rsh to specify\n"
		unless defined $RSH;
	}
	warn "$BASENAME: using $RSH for transport\n" if $DEBUG_LEVEL >= 1;
    }

    # -- cpio header type --
    if (defined($HEADER))
    {
	die "$BASENAME: invalid header type\n" unless
	    $HEADER =~ /(bin|odc|newc|crc|tar|ustar|ascii)/;
	die "$BASENAME: invalid header type\n" if $GNU_CPIO &&
	    $HEADER =~ /ascii/;
	die "$BASENAME: invalid header type\n" if ! $GNU_CPIO &&
	    $HEADER =~ /newc/;
	die "$BASENAME: invalid header type\n" if ! $GNU_CPIO &&
	    $HEADER =~ /bin/;
    } elsif (defined($PARAMS{header})) {
	die "$BASENAME: invalid header type\n" unless
	    $PARAMS{header} =~ /(bin|odc|newc|crc|tar|ustar|ascii)/;
	$HEADER = $PARAMS{header};
	die "$BASENAME: invalid header type\n" if $GNU_CPIO &&
	    $HEADER =~ /ascii/;
	die "$BASENAME: invalid header type\n" if ! $GNU_CPIO &&
	    $HEADER =~ /newc/;
	die "$BASENAME: invalid header type\n" if ! $GNU_CPIO &&
	    $HEADER =~ /bin/;
    } else {
	$HEADER = 'newc' if $GNU_CPIO;
	$HEADER = 'ascii' if ! $GNU_CPIO;
    }

    warn "$BASENAME: set header type $HEADER\n" if $DEBUG_LEVEL >= 1;

    # -- blocking factor --
    if (defined($BLOCK_SIZE))
    {
	die "$BASENAME: block size out of range\n" if $BLOCK_SIZE < 1;
    } elsif (defined($PARAMS{'block-size'})) {
	die "$BASENAME: block size out of range\n" if
	    $PARAMS{'block-size'} < 1;
	$BLOCK_SIZE = $PARAMS{'block-size'};
    } else {
	$BLOCK_SIZE = 128; # (128 blocks * 512 bytes/block) = 65536 bytes/block
    }

    $BYTES = $BLOCK_SIZE * 512;

    # -- atime --
    if (defined($ATIME))
    {
	if ($ATIME)
	{
	    $CMD .= 'a';
	}
    } elsif (defined($PARAMS{'reset-atime'})) {
	if ($PARAMS{'reset-atime'} eq '1')
	{
	    $CMD .= 'a';
	}
    }

    # -- assign header and block-size to $CMD --
    if ($HEADER eq 'ascii')
    {
	if ($BYTES == 5120)
	{
	    $CMD .= 'cvB';
	} else {
	    $CMD .= "cv -C $BYTES";
	}
    } elsif ($GNU_CPIO) {
	if ($BYTES == 5120)
	{
	    $CMD .= "vB -H $HEADER";
	} else {
	    $CMD .= "v -H $HEADER --block-size=$BLOCK_SIZE";
	}
    } else {
	$CMD .= "v -C $BYTES -H $HEADER";
    }

    warn "$BASENAME: set block-size $BYTES bytes\n" if $DEBUG_LEVEL >= 1;

    # -- device --
    if (defined($DEVICE))
    {
	die "$BASENAME: invalid device specification\n" unless
	    $DEVICE =~ /\/dev\//;
	if (defined($AFILE) or defined($PARAMS{file}))
	{
	    die "$BASENAME: cannot specify device file and archive file\n";
	}
    } elsif (defined($PARAMS{device})) {
	die "$BASENAME: invalid device specification\n" unless
	    $PARAMS{device} =~ /\/dev\//;
	if (defined($AFILE) or defined($PARAMS{file}))
	{
	    die "$BASENAME: cannot specify device file and archive file\n";
	}
	$DEVICE = $PARAMS{device};
    } else {
	# cannot check remote tape device so make best guess
	unless (defined($AFILE) or defined($PARAMS{file}))
	{
	    $DEVICE = '/dev/rmt/0';
	    warn "$BASENAME: set default tape device $DEVICE\n"
		if $DEBUG_LEVEL >= 1;
	}
    }

    # -- archive file --
    if (defined($AFILE))
    {
	if (defined($DEVICE) or defined($PARAMS{device}))
	{
	    die "$BASENAME: cannot specify disk file and device file\n";
	}
	$NO_MAXSIZE = 1;
    } elsif (defined($PARAMS{file})) {
	if (defined($DEVICE) or defined($PARAMS{device}))
	{
	    die "$BASENAME: cannot specify disk file and device file\n";
	}
	$AFILE = $PARAMS{file};
	$NO_MAXSIZE = 1;
    } else {
	die "$BASENAME: no archive defined\n" unless defined $DEVICE;
    }

    # -- remote host --
    if (defined($REMOTE_HOST))
    {
	if (defined($DEVICE))
	{
	    if ($GNU_CPIO)
	    {
		$ARCHIVE = "$REMOTE_HOST:$DEVICE";
		$CMD .= " -O $ARCHIVE";
	    } else {
		$ARCHIVE = "| \($TRAP\; $RSH $REMOTE_HOST \'$TRAP\; $DD of=$DEVICE obs=$BYTES conv=noerror\'\)";
		$REMOTE_TRANSPORT = 1;
	    }
	}
	if (defined($AFILE))
	{
	    if ($GNU_CPIO)
	    {
		$ARCHIVE = "$REMOTE_HOST:$AFILE" if defined $AFILE;
		$CMD .= " -F $ARCHIVE";
	    } else {
		$ARCHIVE = "| \($TRAP\; $RSH $REMOTE_HOST \'$TRAP\; $DD of=$AFILE\'\)";
		$REMOTE_TRANSPORT = 1;
	    }
	}
	warn "$BASENAME: set remote archive $ARCHIVE\n" if $DEBUG_LEVEL >= 1;
    } elsif (defined($PARAMS{'remote-host'})) {
	$REMOTE_HOST = $PARAMS{'remote-host'};
	if (defined($DEVICE))
	{
	    if ($GNU_CPIO)
	    {
		$ARCHIVE = "$REMOTE_HOST:$DEVICE";
		$CMD .= " -O $ARCHIVE";
	    } else {
		$ARCHIVE = "| \($TRAP\; $RSH $REMOTE_HOST \'$TRAP\; $DD of=$DEVICE obs=$BYTES conv=noerror\'\)";
		$REMOTE_TRANSPORT = 1;
	    }
	}
	if (defined($AFILE))
	{
	    if ($GNU_CPIO)
	    {
		$ARCHIVE = "$REMOTE_HOST:$AFILE" if defined $AFILE;
		$CMD .= " -F $ARCHIVE";
	    } else {
		$ARCHIVE = "| \($TRAP\; $RSH $REMOTE_HOST \'$TRAP\; $DD of=$AFILE\'\)";
		$REMOTE_TRANSPORT = 1;
	    }
	}
	warn "$BASENAME: set remote archive $ARCHIVE\n" if $DEBUG_LEVEL >= 1;
    } else {
	# device file is local so check here
	if (defined($DEVICE))
	{
	    unless (-c $DEVICE or -l $DEVICE or -b $DEVICE)
	    {
		die "$BASENAME: no device $DEVICE: $!\n";
	    }
	    $ARCHIVE = $DEVICE;
	    if ($GNU_CPIO)
	    {
		$CMD .= " -O $ARCHIVE";
	    } else {
		$CMD .= " >$ARCHIVE";
	    }
	} else {
	    $ARCHIVE = $AFILE;
	    if ($GNU_CPIO)
	    {
		$CMD .= " -F $ARCHIVE";
	    } else {
		$CMD .= " -O $ARCHIVE";
	    }
	}
	warn "$BASENAME: set archive $ARCHIVE\n" if $DEBUG_LEVEL >= 1;
    }

    # -- length of time to keep logs --
    if (defined($KEEP_LOGS))
    {
	if ($KEEP_LOGS < 0 or $KEEP_LOGS > 99999)
	{
	    die "$BASENAME: logging specification $KEEP_LOGS out of range\n";
	}
    } elsif (defined($PARAMS{'keep-logs'})) {
	$KEEP_LOGS = $PARAMS{'keep-logs'};
	if ($KEEP_LOGS < 0 or $KEEP_LOGS > 99999)
	{
	    die "$BASENAME: logging specification $KEEP_LOGS out of range\n";
	}
    } else {
	$KEEP_LOGS = 365;	# default one year
    }
    $NO_LOGGING = 1 if $KEEP_LOGS == 0;
    $ZIPLOG = 0 if $NO_LOGGING;

    # -- define simulated dump-level --
    if (defined($LEVEL))
    {
	if ($LEVEL < 0 or $LEVEL > 9)
	{
	    die "$BASENAME: level $LEVEL out of range\n";
	}
    } elsif (defined($PARAMS{level})) {
	$LEVEL = $PARAMS{level};
	if ($LEVEL < 0 or $LEVEL > 9)
	{
	    die "$BASENAME: level $LEVEL out of range\n";
	}
    } else {
	$LEVEL = 0;		# default: full backup
	warn "$BASENAME: set default backup level 0\n"
	    if $DEBUG_LEVEL >= 1;
    }

    # -- conf-dir --
    if (defined($CONF_DIR))
    {
	die "$BASENAME: no directory $CONF_DIR\n" unless -d $CONF_DIR;
	die "$BASENAME: $CONF_DIR not writable: $!\n" unless -w $CONF_DIR;
    } elsif (defined($PARAMS{'conf-dir'})) {
	$CONF_DIR = $PARAMS{'conf-dir'};
	die "$BASENAME: no directory $CONF_DIR\n" unless -d $CONF_DIR;
	die "$BASENAME: $CONF_DIR not writable: $!\n" unless -w $CONF_DIR;
    } elsif (-d '/share/backup/conf' and -w '/share/backup/conf') {
	$CONF_DIR = '/share/backup/conf';
	warn "$BASENAME: set default conf dir $CONF_DIR\n"
	    if $DEBUG_LEVEL >= 1;
    } else {
        die "$BASENAME: must specify conf dir to run incremental backup\n"
	    unless $LEVEL == 0;
	$NO_INCREMENTAL = 1;
	warn
	"$BASENAME: conf dir unspecified, no incremental data will be written\n"
	    if $DEBUG_LEVEL >= 1;
    }

    # -- dirlist: must be defined in config file --
    if (@DIRS)
    {
	foreach $dir (@DIRS)
	{
	    push (@tmpdirs, $dir) if -d "$BACKUP_ROOT/$dir";
	    warn "$BASENAME: removing dir $BACKUP_ROOT/$dir from list:$!\n"
		unless -d "$BACKUP_ROOT/$dir";
	}
	die "$BASENAME: dirlist invalid\n" unless (@tmpdirs) >= 1;
	@DIRS = @tmpdirs;
    } else {
	warn
	"$BASENAME: dirlist not found. backing up all directories under $BACKUP_ROOT\n"
	    if $DEBUG_LEVEL >= 1;
	$NO_DIRLIST = 1;
    }

    # -- logdir --
    if (defined($LOGDIR))
    {
	die "$BASENAME: directory $LOGDIR not found\n" unless -d $LOGDIR;
	die "$BASENAME: directory $LOGDIR not writable\n" unless -w $LOGDIR;
	warn "$BASENAME: set logdir $LOGDIR\n" if $DEBUG_LEVEL >= 1;
    } elsif (defined($PARAMS{logdir})) {
	$LOGDIR = $PARAMS{logdir};
	die "$BASENAME: directory $LOGDIR not found\n" unless -d $LOGDIR;
	die "$BASENAME: directory $LOGDIR not writable\n" unless -w $LOGDIR;
	warn "$BASENAME: set logdir $LOGDIR\n" if $DEBUG_LEVEL >= 1;
    } elsif (-d "/var/log/backup" and -w "/var/log/backup") {
	$LOGDIR = "/var/log/backup";
	warn "$BASENAME: set logdir $LOGDIR\n" if $DEBUG_LEVEL >= 1;
    } else {
        if ($DEBUG_LEVEL >= 1)
	{
	    warn "$BASENAME: no log directory specified. no logfile will be written\n"
		unless $NO_LOGGING;
	}
	$NO_LOGGING = 1;
	$ZIPLOG = 0;
    }

    # -- max archive size --
    unless ($NO_MAXSIZE)
    {
	if (defined($MAXSIZE))
	{
	    $NO_MAXSIZE = 1 if $MAXSIZE == 0;
	    die "$BASENAME: archive size out of range\n" if $MAXSIZE < 0;
	    $MAXBYTES = $MAXSIZE * 1024000; # size in MB * 1000 * 1024
	    $PADDING  = $BYTES;
	    if ($HEADER =~ /tar/)
	    {
		$HEADER_SIZE = 512; # bytes/file, not counting fname
	    } else {
		$HEADER_SIZE = 110; # bytes/file, not counting fname
		$TRAILER     =   3; # cpio EOF trailer (not present w/tar hdr)
		$trailer     = 124; # cpio EOT trailer
		$MAXBYTES -= $trailer; # final trailer
	    }
	    warn "$BASENAME: set maxsize $MAXBYTES bytes\n"
		if $DEBUG_LEVEL >= 1;
	} elsif (defined($PARAMS{maxsize})) {
	    die "$BASENAME: archive size out of range\n"
		if $PARAMS{maxsize} < 0;
	    $MAXSIZE = $PARAMS{maxsize};
	    $NO_MAXSIZE = 1 if $MAXSIZE == 0;
	    $MAXBYTES = $MAXSIZE * 1024000; # size in MB * 1000 * 1024
	    $PADDING = $BYTES;
	    if ($HEADER =~ /tar/)
	    {
		$HEADER_SIZE = 512; # bytes/file, not counting fname
	    } else {
		$HEADER_SIZE = 110; # bytes/file, not counting fname
		$TRAILER     =   3; # cpio EOF trailer (not present w/tar hdr)
		$trailer     = 124; # cpio EOT trailer
		$MAXBYTES   -= $trailer;# final trailer
	    }
	    warn "$BASENAME: set maxsize $MAXBYTES bytes\n"
		if $DEBUG_LEVEL >= 1;
	} else {
	    $NO_MAXSIZE = 1;
	}
    }

    # -- check notification addr --
    if (@NOTIFY)
    {
	foreach $addr (@NOTIFY)
	{
	    unless ($addr =~ /^(\w+)\@(.*\.\w+)$/)
	    {
		warn "chkOpts(): invalid email address $addr\n"
		    if $DEBUG_LEVEL >= 1;
		next;
	    }
	    $addr =~ /^(\w+)\@(.*\.\w+)$/;
	    $RECIP = $1 . '\@' . $2;
	    push(@addrlist, $RECIP);
	}
	if (@addrlist)
	{
	    foreach $tmpaddr (@addrlist)
	    {
		$NOTIFY .= "$tmpaddr ";
	    }
	} else {
	    $NOTIFY = 0;
	}
    } elsif (defined($PARAMS{notify})) {
	@NOTIFY = split(/\s+/,$PARAMS{notify});
	foreach $addr (@NOTIFY)
	{
	    unless ($addr =~ /^(\w+)\@(.*\.\w+)$/)
	    {
		warn "chkOpts(): invalid email address $addr\n"
		    if $DEBUG_LEVEL >= 1;
		next;
	    }
	    $addr =~ /^(\w+)\@(.*\.\w+)$/;
	    $RECIP = $1 . '\@' . $2;
	    push(@addrlist, $RECIP);
	}
	if (@addrlist)
	{
	    foreach $tmpaddr (@addrlist)
	    {
		$NOTIFY .= "$tmpaddr ";
	    }
	} else {
	    $NOTIFY = 0;
	}
    } else {
	$NOTIFY = 0;
    }

    # -- find /bin/mail or equivalent --
    if ($NOTIFY)
    {
	$MAILER = &_findMailer || warn "$BASENAME: no mailer found\n";
	if (defined($MAILER))
	{
	    $RECIP = $NOTIFY;
	} else {
	    $NOTIFY = 0;
	}

	if ($DEBUG_LEVEL >= 1)
	{
	    warn "$BASENAME: set mailer to $MAILER\n" if defined($MAILER);
	    warn "$BASENAME: recipient(s) $RECIP\n";
	}
    }

    # -- tapeset number: arbitrary user-definable integer
    #    can be used to identify archive, otherwise set to 1 --
    if (defined($SET))
    {
	if ($SET < 1 or $SET > 99999)
	{
	    die "$BASENAME: tapeset specification $SET out of range\n";
	}
	warn "$BASENAME: set tapeset number to $SET\n" if $DEBUG_LEVEL >= 1;
    } elsif (defined($PARAMS{set})) {
	$SET = $PARAMS{set};
	if ($SET < 1 or $SET > 99999)
	{
	    die "$BASENAME: tapeset specification $SET out of range\n";
	}
	warn "$BASENAME: set tapeset number to $SET\n" if $DEBUG_LEVEL >= 1;
    } else {
	$SET = 1;
	warn "$BASENAME: set tapeset number to $SET\n" if $DEBUG_LEVEL >= 1;
    }

    # -- logfile compressor --
    if (defined($ZIPLOG) && -x $ZIPLOG)
    {
	$ZIPLOG = 0 if $NO_LOGGING;
	$ZIPLOG .= " -f9" if $ZIPLOG =~ /gzip/;
	$ZIPLOG .= " -f9" if $ZIPLOG =~ /bzip2/;
	warn "$BASENAME: using \'$ZIPLOG\' to compress log\n"
	    if $DEBUG_LEVEL >= 1;
    } elsif (defined($PARAMS{ziplog}) && -x $PARAMS{ziplog}) {
	$ZIPLOG = $PARAMS{ziplog};
	$ZIPLOG = 0 if $NO_LOGGING;
	$ZIPLOG .= " -f9" if $ZIPLOG =~ /gzip/;
	$ZIPLOG .= " -f9" if $ZIPLOG =~ /bzip2/;
	warn "$BASENAME: using \'$ZIPLOG\' to compress log\n"
	    if $DEBUG_LEVEL >= 1;
    } elsif (defined($ZIPLOG) && ! -x $ZIPLOG) {
	$ZIPLOG = 0 if $NO_LOGGING;
	$ZIPLOG = &_findZiplog;
    } elsif (defined($PARAMS{ziplog}) && ! -x $PARAMS{ziplog}) {
	$ZIPLOG = 0 if $NO_LOGGING;
	$ZIPLOG = &_findZiplog;
    } else {
	$ZIPLOG = 0;
    }
}

# -----------------------------------------------------------------------
# _isGNUcpio: check version of cpio
# caller: chkOpts()
# parameters: path to cpio binary
# returns: true (1) or false (0)
# -----------------------------------------------------------------------

sub _isGNUcpio
{
    my $CPIO_BIN = shift;
    my $version = `$CPIO_BIN --version 2>/dev/null`;
    chomp($version);
    return 0 unless $version =~ /GNU cpio/;
    return 1;
}

# -----------------------------------------------------------------------
# _findMailer: find /bin/mail or equivalent in @MAILPATH
# caller: chkOpts()
# parameters:
# returns: absolute path of mailer or undef
# -----------------------------------------------------------------------

sub _findMailer
{
    my $DIR;
    my $mailer;
    my @mailers = qw(mailx mail);

    OUTER: foreach $mailer (@mailers)
    {
	foreach $DIR (@MAILPATH)
	{
	    if (-x "$DIR/$mailer")
	    {
		$mailer = "$DIR/$mailer";
		return $mailer;
		last OUTER;
	    }
	}
    }
    return undef unless defined($mailer);
}

# -----------------------------------------------------------------------
# _findZiplog: find absolute path of compression util in @COMPRESSPATH
# caller: chkOpts()
# parameters:
# returns: absolute path of compression util or false (0)
# -----------------------------------------------------------------------

sub _findZiplog
{
    my $compress;
    my $ziplog;
    foreach $compress (@COMPRESSPATH)
    {
	if (-x "$compress/compress")
	{
	    $ziplog = "$compress/compress -f";
	    warn "$BASENAME: set default log compressor \'$ziplog\'\n"
		if $DEBUG_LEVEL >= 1;
	    return $ziplog;
	    last;
	}
    }
    if ($DEBUG_LEVEL >= 1)
    {
	warn "$BASENAME: could not find log compressor\n" unless
	    -x $ziplog;
    }
    return 0 unless -x $ziplog;
}

# -----------------------------------------------------------------------
# setTimestamp: write and/or remove timestamp files for this tapeset
# caller: main
# parameters:
# returns:
# -----------------------------------------------------------------------

sub setTimestamp
{
    my $file = "$CONF_DIR/level$LEVEL-s$SET.timestamp";

    # reset incremental data by removing timestamps of corresponding tapeset
    # if $LEVEL == 0
    if ($LEVEL == 0)
    {
	my @flist = glob("$CONF_DIR/level*-s$SET.timestamp");
	my $oldfile;
	foreach $oldfile (@flist)
	{
	    unlink $oldfile;
	}
	sysopen(TS, $file, O_RDWR|O_CREAT) or die
	    "$BASENAME: could not open $file: $!\n";
	close(TS);
    } else {
	if (-f $file)
	{
	    my $time = time();
	    utime($time, $time, $file) or die
		"$BASENAME: could not touch $file:\n";
	} else {
	    sysopen(TS, $file, O_RDWR|O_CREAT) or die
		"$BASENAME: could not open $file: $!\n";;
	    close(TS);
	}
    }
}

# -----------------------------------------------------------------------
# readDirs: basic checking on @DIRS. remove later.
# caller: main
# parameters:
# returns:
# -----------------------------------------------------------------------

sub readDirs
{
    if ($NO_DIRLIST)
    {
	##@DIRS = ($BACKUP_ROOT);
	@DIRS = qw(.);
    }
    die "$BASENAME: no directories specified\n" unless (@DIRS) >= 1;
    if ($DEBUG_LEVEL >= 1)
    {
	warn "$BASENAME: excluding @EXCLUDES\n" if @EXCLUDES;
    }
}

# -----------------------------------------------------------------------
# findFiles: open IO handle(s) and start appropriate file-finder
# caller: main
# parameters: @DIRS from readDirs()
# returns:
# -----------------------------------------------------------------------

sub findFiles
{
    $EXCLUDELIST = 1 if (@EXCLUDES) >= 1;
    my $DIR;
    my @DIRS = @_;
    my $retval;

    if ($NO_LOGGING)
    {
	$CMD .= " $ARCHIVE" if $REMOTE_TRANSPORT;
	warn "findFiles(): $CMD\n" if $DEBUG_LEVEL >= 1;
	chdir $BACKUP_ROOT;
	$FH = FileHandle->new("| $CMD") || die
	    "findFiles(): can't fork: $!\n";
	$FH->autoflush(1);
    } else {
	$LOG = "$LOGDIR/level$LEVEL-s$SET.$DATESTAMP";
	warn "findFiles(): set log to $LOG\n" if $DEBUG_LEVEL >= 1;
	if ($REMOTE_TRANSPORT)
	{
	    $CMD .= " 2>>$LOG $ARCHIVE";
	    warn "findFiles(): $CMD\n" if $DEBUG_LEVEL >= 1;
	    chdir $BACKUP_ROOT;
	    $FH = FileHandle->new("| $CMD") || die
		"findFiles(): can't fork: $!\n";
	    $FH->autoflush(1);
	} else {
	    warn "findFiles(): $CMD\n" if $DEBUG_LEVEL >= 1;
	    chdir $BACKUP_ROOT;
	    $FH = FileHandle->new("| $CMD 2>>$LOG") || die
		"findFiles(): can't fork: $!\n";
	    $FH->autoflush(1);
	}
    }

    if ($NO_INCREMENTAL or $LEVEL == 0)
    {
	foreach $DIR (@DIRS)
	{
	    chdir $BACKUP_ROOT;
	    warn "findFiles(): finding files in $DIR\n"
		if $DEBUG_LEVEL > 1;
	    find({ wanted => \&_allFiles, no_chdir => 1 }, $DIR);
	}
    } else {
	&_findLastTimestamp;
	foreach $DIR (@DIRS)
	{
	    chdir $BACKUP_ROOT;
	    warn "findFiles(): finding files in $DIR\n"
		if $DEBUG_LEVEL > 1;
	    find({ wanted => \&_newFiles, no_chdir => 1 }, $DIR);
	}
    }
    warn "findFiles(): waiting for write\n" if $DEBUG_LEVEL > 1;
    $retval = $FH->close;
    my $waitpid = wait;
    if ($DEBUG_LEVEL > 1)
    {
	warn "findFiles(): wait status $? from IO\n";
	warn "findFiles(): reaped $waitpid\n" if $waitpid;
	warn "findFiles(): closed IO\n" if $retval;
    }
}

# -----------------------------------------------------------------------
# _allFiles: print files to $FH after checking @EXCLUDES
# caller: findFiles()
# parameters:
# returns:
# -----------------------------------------------------------------------

sub _allFiles
{
    my $file = $File::Find::name;
    my $fsize;
    my $excluded;
    my $newbytecount;
    my $remove;

    $file = $1 if ($file =~ /^\.\/(.+)$/);

    if ($EXCLUDELIST)
    {
	foreach $excluded (@EXCLUDES)
	{
	    if ($file =~ /$excluded/)
	    {
		$remove = 1;
		last;
	    }
	}
	if ($remove)
	{
	    warn "_allFiles(): --> exclude $file\n" if $DEBUG_LEVEL > 1;
	    $remove = undef;
	} else {
	    unless ($NO_MAXSIZE)
	    {
		$fsize = (lstat($file))[7];
		if ($newbytecount = &_incrByteCount($fsize,$file))
		{
		    warn "_allFiles(): sleeping\n" if $DEBUG_LEVEL > 1;
		    sleep;
		    &_resetIO($newbytecount);
		}
	    }
	    print $FH "$file\n";
	    if ($DEBUG_LEVEL > 1)
	    {
		if ($NO_MAXSIZE)
		{
		    warn "_allFiles(): write $file\n";
		} else {
		    warn "_allFiles(): write $file $fsize bytes\n";
		}
	    }
	}
    } else {
	unless ($NO_MAXSIZE)
	{
	    $fsize = (lstat($file))[7];
	    if ($newbytecount = &_incrByteCount($fsize,$file))
	    {
		warn "_allFiles(): sleeping\n" if $DEBUG_LEVEL > 1;
		sleep;
		&_resetIO($newbytecount);
	    }
	}
	print $FH "$file\n";
	if ($DEBUG_LEVEL > 1)
	{
	    if ($NO_MAXSIZE)
	    {
		warn "_allFiles(): write $file\n";
	    } else {
		warn "_allFiles(): write $file $fsize bytes\n";
	    }
	}
    }
}

# -----------------------------------------------------------------------
# _newFiles: print files newer than $timestampfile to WTRFH after
#	     checking @EXCLUDES
# caller: findFiles()
# parameters:
# returns:
# -----------------------------------------------------------------------

sub _newFiles
{
    my ($dev,$ino,$mode,$nlink,$uid,$gid);
    my $timestampfile = $TIMESTAMP;
    my $max_file_age = -M $timestampfile;
    my $file = $File::Find::name;
    my $fsize;
    my $excluded;
    my $newbytecount;
    my $remove;

    $file = $1 if ($file =~ /^\.\/(.+)$/);

    if ($EXCLUDELIST)
    {
	foreach $excluded (@EXCLUDES)
	{
	    if ($file =~ /$excluded/)
	    {
		$remove = 1;
		last;
	    }
	}
	if ($remove)
	{
	    # debug
	    (($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
	    (-M _ < $max_file_age) && warn
	    "_newFiles(): --> exclude $file\n" if $DEBUG_LEVEL > 1;
	    $remove = undef;
	} else {
	    unless ($NO_MAXSIZE)
	    {
		if ((($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		    (-M _ < $max_file_age))
		{
		    $fsize = (lstat($file))[7];
		    if ($newbytecount = &_incrByteCount($fsize,$file))
		    {
			warn "_newFiles(): sleeping\n" if $DEBUG_LEVEL > 1;
			sleep;
			&_resetIO($newbytecount);
		    }
		}
	    }
	    (($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
	    (-M _ < $max_file_age) && print $FH "$file\n";
	    # debug
	    if ($DEBUG_LEVEL > 1)
	    {
		if ($NO_MAXSIZE)
		{
		    (($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		    (-M _ < $max_file_age) && warn
		    "_newFiles(): write $file\n";
		} else {
		    (($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		    (-M _ < $max_file_age) && warn
		    "_newFiles(): write $file $fsize bytes\n";
		}
	    }
	}
    } else {
	unless ($NO_MAXSIZE)
	{
	    if ((($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		(-M _ < $max_file_age))
	    {
		$fsize = (lstat($file))[7];
		if ($newbytecount = &_incrByteCount($fsize,$file))
		{
		    warn "_newFiles(): sleeping\n" if $DEBUG_LEVEL > 1;
		    sleep;
		    &_resetIO($newbytecount);
		}
	    }
	}
	(($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
	(-M _ < $max_file_age) && print $FH "$file\n";
	# debug
	if ($DEBUG_LEVEL > 1)
	{
	    if ($NO_MAXSIZE)
	    {
		(($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		(-M _ < $max_file_age) && warn
		"_newFiles(): write $file\n";
	    } else {
		(($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
		(-M _ < $max_file_age) && warn
		"_newFiles(): write $file $fsize bytes\n";
	    }
	}
    }
}

# -----------------------------------------------------------------------
# _findLastTimestamp: find $TIMESTAMP in $CONF_DIR
# caller: findFiles()
# parameters:
# returns:
# -----------------------------------------------------------------------

sub _findLastTimestamp
{
    my $i;
    for ($i = $LEVEL - 1; $i >= 0; $i--)
    {
	if (-f "$CONF_DIR/level$i-s$SET.timestamp")
	{
	    $TIMESTAMP = "$CONF_DIR/level$i-s$SET.timestamp";
	    warn "$BASENAME: found timestampfile $TIMESTAMP\n"
		if $DEBUG_LEVEL >= 1;
	    last;
	}
    }
    die "$BASENAME: no timestamp file\n" unless defined($TIMESTAMP);
}

# -----------------------------------------------------------------------
# _incrByteCount: increment $BYTECOUNT and check against $MAXBYTES
# caller: _allFiles() or _newFiles()
# parameters: $fsize (in bytes), $file (filename)
# returns: 0 or $total if $BYTECOUNT >= $MAXBYTES
# -----------------------------------------------------------------------

sub _incrByteCount
{
    my $retval;
    my $fsize	    = $_[0];
    my $file	    = $_[1];
    my $flength	    = length($file);
    my $header_size = $HEADER_SIZE + $flength;
    my $padding	    = &_padTrailer($fsize);
    my $total	    = $fsize + $header_size + $padding;

    die "_incrByteCount(): file $file size $fsize bytes larger than\nmedia capacity\n"
	if $total > $MAXBYTES;

    # -- increment bytecount --
    $BYTECOUNT += $total;

    warn "_incrByteCount(): +$header_size bytes header\n" if $DEBUG_LEVEL > 1;
    warn "_incrByteCount(): +$padding bytes padding\n" if $DEBUG_LEVEL > 1;
    warn "_incrByteCount(): count $BYTECOUNT bytes\n" if $DEBUG_LEVEL > 1;

    if ($BYTECOUNT >= $MAXBYTES)
    {
	my $msg = "_incrByteCount(): reached $BYTECOUNT bytes\nchange media on $ARCHIVE\n";
	warn "_incrByteCount(): waiting for write\n" if $DEBUG_LEVEL > 1;
	$retval = $FH->close;
	my $waitpid = wait;
	if ($DEBUG_LEVEL > 1)
	{
	    warn "_incrByteCount(): wait status $? from IO\n";
	    warn "_incrByteCount(): reaped $waitpid\n" if $waitpid;
	    warn "_incrByteCount(): closed IO\n" if $retval;
	}

	if ($NOTIFY)
	{
	    &notify('EOT',$msg);
	} else {
	    print $msg;
	}

	unless ($NO_LOGGING)
	{
	    open(LOG, ">>$LOG");
	    print LOG "EOT $MEDIACOUNT\n";
	    close(LOG);
	}
	++$MEDIACOUNT;
	return $total;
    } else {
	return 0;
    }
}

# -----------------------------------------------------------------------
# _padTrailer: calculate trailer padding
# caller: _incrByteCount()
# parameters: $fsize in bytes
# returns: $padding in bytes
# -----------------------------------------------------------------------

sub _padTrailer
{
    my $fsize = shift;
    my $modulus;
    my $padding;

    $padding += $TRAILER if defined($TRAILER);

    if ($fsize < $PADDING) # file smaller than block-size i.e. $PADDING
    {
	$padding = $PADDING - $fsize;
	return $padding;
    } elsif ($fsize == $PADDING) { # file size equal to block-size
	$padding = $PADDING;       # return full block of padding
	return $padding;
    } else {                       # file larger than block-size
	$modulus = $fsize % $PADDING;
	if ($modulus == 0)
	{
	    $padding = $PADDING;
	    return $padding;       # return full block of padding
	} else {
	    $padding = $modulus;
	    return $padding;
	}
    }
}

# -----------------------------------------------------------------------
# _resetIO: reopen IO handle closed by _incrByteCount() and reset
#	    $BYTECOUNT
# caller: _allFiles() or _newFiles()
# parameters:
# returns:
# -----------------------------------------------------------------------

sub _resetIO
{
    my $newbytecount = shift;

    if ($NO_LOGGING or $REMOTE_TRANSPORT)
    {
	unless ($NO_LOGGING)
	{
	    warn "_resetIO(): appending to $LOG\n" if $DEBUG_LEVEL > 1;
	}
	chdir $BACKUP_ROOT;
	$FH = FileHandle->new("| $CMD") || die
	    "_resetIO(): can't fork: $!\n";
	$FH->autoflush(1);
    } else {
	warn "_resetIO(): appending to $LOG\n" if $DEBUG_LEVEL > 1;
	chdir $BACKUP_ROOT;
	$FH = FileHandle->new("| $CMD 2>>$LOG") || die
	    "_resetIO(): can't fork: $!\n";
	$FH->autoflush(1);
    }
    $BYTECOUNT = $newbytecount;
}

# -----------------------------------------------------------------------
# removeLogs: pass $LOGDIR to _oldLogs()
# caller: main
# parameters:
# returns:
# -----------------------------------------------------------------------

sub removeLogs
{
    find(\&_oldLogs, $LOGDIR);
}

# -----------------------------------------------------------------------
# _oldLogs: find and remove old logfiles in $LOGDIR for removeLogs()
# caller: removeLogs()
# parameters:
# returns:
# -----------------------------------------------------------------------

sub _oldLogs
{
    my ($dev,$ino,$mode,$nlink,$uid,$gid);
    my $min_file_age = $KEEP_LOGS;
    my $file = $File::Find::name;

    if ($DEBUG_LEVEL >= 1)
    {
	(($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
	(int(-M _) > $min_file_age) &&
	warn "$BASENAME: unlinking $file\n"
	    if $file =~ /$LOGDIR\/level$LEVEL-s$SET\.\d{8}-\d{2}/;
    }

    (($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
    (int(-M _) > $min_file_age) &&
     unlink $file if $file =~ /$LOGDIR\/level$LEVEL-s$SET\.\d{8}-\d{2}/;

}

# -----------------------------------------------------------------------
# notify: send email msg
# caller: main and/or _IO_hander()
# parameters: (msg_type (REPORT|EOT), msg (EOT only))
# returns:
# -----------------------------------------------------------------------

sub notify
{
    my $msg_type = shift;
    
    if ($msg_type eq 'REPORT')
    {
	my $subject = "-s \'level $LEVEL backup on $HOSTNAME\'";

	if ($NO_LOGGING)
	{
	    system "$MAILER $subject $RECIP";
	} else {
	    my $blocks = &_getBlocks;
	    my $errors = &_getErrors;
	    open(MAILER, "| $MAILER $subject $RECIP");
	    print MAILER "size:\n$blocks\n\nerrors:\n$errors\n";
	    close(MAILER);
	}
    } else { # $msg_type eq 'EOT'
	my $msg = shift;
	my $subject = "-s \'end of media notification on $HOSTNAME\'";
	open(MAILER, "| $MAILER $subject $RECIP");
	print MAILER $msg;
	close(MAILER);
	warn "notify(): sent notification\n" if $DEBUG_LEVEL >= 1;
    }
}

# -----------------------------------------------------------------------
# _getBlocks: add block count in $LOG
# caller: notify()
# parameters:
# returns: block string (success) or "" (failure)
# -----------------------------------------------------------------------

sub _getBlocks
{
    my $blockcount;
    my $msg;

    open(LOG, $LOG) or die "$BASENAME: can't open log $LOG for reading: $!\n";
    while (<LOG>)
    {
	my $line = $_;
	if ($line =~ /(^[0-9]+)\sblock/)
	{
	    $blockcount += $1;
	    next;
	}
    }

    close(LOG);

    if ($blockcount && $NO_MAXSIZE)
    {
	$msg = "total $blockcount block(s)";
	warn "_getBlocks(): returning \'$msg\' to $MAILER\n"
	    if $DEBUG_LEVEL > 1;
	return $msg;
    } elsif ($blockcount) {
	$msg = "total $blockcount block(s) on $MEDIACOUNT volume(s)";
	warn "_getBlocks(): returning \'$msg\' to $MAILER\n"
	    if $DEBUG_LEVEL > 1;
	return $msg;
    } else {
	return "";
    }

}

# -----------------------------------------------------------------------
# _getErrors: find errors in $LOG
# caller: notify()
# parameters:
# returns: error string (success) or "" (failure)
# -----------------------------------------------------------------------

sub _getErrors
{
    my $cpio_bin = basename($CPIO_BIN);
    my (@errors, $err, $errors);
    open(LOG, $LOG) or die "$BASENAME: can't open log $LOG for reading: $!\n";
    while (<LOG>)
    {
	my $line = $_;
	if ($line =~ /^$CPIO_BIN:\s.*/ || $line =~ /^$cpio_bin:\s.*/)
	{
	    push(@errors, $line);
	}
    }
    close(LOG);
    foreach $err (@errors)
    {
	$errors .= $err;
    }
    if (defined($errors))
    {
	return $errors;
	warn "_getErrors(): returned error string \`$errors\' to $MAILER\n"
	    if $DEBUG_LEVEL > 1;
    } else {
	return "";
    }
}

# -----------------------------------------------------------------------
#                             documentation
# -----------------------------------------------------------------------

=head1 NAME

cpiotool - wrapper for cpio

=head1 SYNOPSIS

B<cpiotool> B<-c> E<lt>config fileE<gt>

B<cpiotool> B<--help>

B<cpiotool> B<-v>

B<cpiotool> [ B<--cpio> E<lt>cpio binaryE<gt> ]
[ B<--level> E<lt>0-9E<gt> ] [ B<--dd> E<lt>dd binaryE<gt> ]
[ B<-h> E<lt>remote hostE<gt> ] [ B<-b> E<lt>backup root dirE<gt> ]
([ B<-d> E<lt>deviceE<gt> ] | [ B<-f> E<lt>fileE<gt> ])
[ B<--block-size> E<lt>nE<gt> ] [ B<--reset-atime> ]
[ B<--logdir> E<lt>logdirE<gt>] [ B<--keep-logs> E<lt>0-99999E<gt> ]
[ B<--rsh> E<lt>rsh|ssh binaryE<gt> ] [ B<--set> E<lt>1-99999E<gt> ]
[ B<--maxsize> E<lt>nE<gt> ] [ B<--ziplog> [ E<lt>compression utilE<gt> ] ]
[ B<--confdir> E<lt>config dirE<gt> ]
[ B<--header> E<lt>bin|odc|newc|crc|tar|ustar|asciiE<gt> ]
[ B<--notify> E<lt>addr@domainE<gt> [ B<--notify> E<lt>addr@domainE<gt> ] ... ]
[ B<--verbose> ] [ B<--debug> ]

=head1 DESCRIPTION

I<cpiotool> is a config-file-based wrapper for cpio(1L).  the wrapper
combines several commonly-used cpio copy-out options with additional options
for supporting multilevel incremental backups, email notification using
mail(1), logging, and include/exclude lists.  options specified on the
command-line override config-file parameters.  include/exclude lists are only
available using the config file.

=head1 OPTIONS

B<cpiotool> displays usage and exits if no options are specified.
specifying a single option such as B<--verbose> will start a level 0 backup
from the root directory with suitable defaults:

C<cpio -ov -H newc --block-size=128 -O /dev/rmt/0>

=over 4

=item B<-c>

absolute path of config file.  see PARAMETERS.

=item B<--help>

print usage and exit.

=item B<-v>

print version and exit.

=item B<--cpio>

absolute path of cpio binary.  if not specified, B<cpiotool> will search
/usr/local/bin, /usr/bin, and /bin.

=item B<--level>

backup level 0-9, similar to dump(8).  level 0 is a full backup.  A level
number above 0 specifies an incremental backup of files modified more
recently than the highest-numbered previous level backup.  defaults to 0.

=item B<--dd>

absolute path of dd(1) binary. dd is used for remote writes if GNU cpio is
not detected.

=item B<-h>

remote host.  name of remote host to write archive.  the host must be
accessible via rsh(1) or a drop-in replacement such as ssh(1).  GNU cpio
only recognizes rsh, so the replacement should be called 'rsh' if GNU cpio
is used.

=item B<-b>

backup root directory.  top-level directory where B<cpiotool> recursively
searches for files to be archived.  defaults to '/'.

=item B<-d>

device file to write archive.  defaults to '/dev/rmt/0'.  mutually exclusive
with B<-f>.

=item B<-f>

absolute path of disk file to write archive.  mutually exclusive with B<-d>.

=item B<--block-size>

cpio option to set I/O block size to (n * 512) bytes.  must be positive
integer.  defaults to 128, i.e.  65536 bytes.

=item B<--reset-atime>

reset file access time after reading.  default behavior updates atime
as file is read for backup.

=item B<--logdir>

absolute path of directory to write backup log.  defaults to '/var/log/backup'.

=item B<--keep-logs>

number of days, 0-99999, to keep logs in this tapeset.  logs in this
tapeset older than B<--keep-logs> days are removed.  setting this number to
0 disables logging.  default is 365.

=item B<--rsh>

absolute path of alternate remote transport: rsh(1) or ssh(1).  alternate
remote transport is used if GNU cpio is not detected.

=item B<--set>

tapeset number.  arbitrary integer, 1-99999, used to identify file groupings
that share the same incremental backup data and logfiles.  tapeset number
determines which timestamp is used to set maximum file age for files in the
same backup group.  log files are also named by tapeset number.  defaults to 1.

=item B<--maxsize>

maximum archive size in MB, specified as integer or floating-point.
B<cpiotool> keeps a running byte-count and uses this value to determine when
to request additional media.  when using native cpio headers, B<cpiotool>
subtracts a full block from the maxsize value for end-of-archive padding,
124 bytes for the final end-of-archive trailer, 110 bytes per file plus the
length of the filename for each header, and 3 bytes per file to allow for
each trailer. when using tar or ustar headers, B<cpiotool> subtracts 512
bytes per file plus the length of the filename from the maxsize value to
allow for each header, and subtracts an additional number of bytes from
the maxsize value to allow for padding up to each block-boundry. the maxsize
value is only used when writing to a device.  if set to 0 or unspecified,
the cpio binary will send media requests to the controlling terminal.

=item B<--ziplog>

if specified backup log will be compressed with compress(1).  B<--ziplog>
takes an optional argument of the absolute path to an alternate compression
utility, e.g. '--ziplog /usr/local/bin/bzip2'.

=item B<--confdir>

configuration directory.  B<cpiotool> writes incremental backup timestamps
in this directory.  defaults to '/share/backup/conf'.

=item B<--header>

cpio option to set archive header type.  must be one of B<bin>, B<odc>, B<newc>,
B<crc>, B<tar>, B<ustar> or B<ascii>.  see cpio(1L) for details.  defaults to
B<newc>, or B<ascii> if B<newc> is unavailable.

=item B<--notify>

email address of recipient to send error report.  B<cpiotool> uses '/bin/mail'
to send the message, so the system must have a properly configured MTA for this
option to work.  more than one address can be specified using multiple
B<--notify> options.

=item B<--verbose>

display verbose output to STDERR.

=item B<--debug>

display very verbose output to STDERR.

=back

=head1 PARAMETERS

the config file is divided in three sections.  the [CONF] section lists
general configuration options.  [DIRLIST] is a list of directories relative
to the backup root directory to be archived.  [EXCLUDES] is a list of
directories relative to the backup root directory that should be excluded
from the archive.  comments begin with '#'.  config file parameters in the
[CONF] section can be overridden on the command-line.  config file defaults
are the same as command-line defaults.  see OPTIONS.

=head2 [CONF]

the [CONF] section accepts the following keys.  values are specified after
whitespace.

=over 4

=item B<root-dir>

top level directory where B<cpiotool> recursively searches for files to be
archived.

=item B<cpio>

absolute path of cpio.

=item B<level>

backup level 0-9.

=item B<dd>

absolute path of dd binary.  used if GNU cpio is not detected.

=item B<remote-host>

name of remote host to write archive.

=item B<device>

device file to write archive.  mutually exclusive with B<file>.

=item B<file>

disk file to write archive.  mutually exclusive with B<device>.

=item B<block-size>

positive integer that sets I/O block size to (n * 512) bytes.

=item B<reset-atime>

set to 1 to reset file access time after file is read by cpio.

=item B<logdir>

directory to write backup log.

=item B<set>

tapeset number, 1-99999.

=item B<keep-logs>

number of days to keep backup logs of specified tapeset, 0-99999.  setting to
0 disables logging.

=item B<rsh>

absolute path of alternate transport.  used if GNU cpio is not detected.

=item B<maxsize>

floating-point number that sets maximum archive size in MB.  used only if
archive is a device.  set to 0 to disable.

=item B<conf-dir>

directory where B<cpiotool> writes timestamp files.

=item B<header>

sets cpio header type.  must be one of B<bin>, B<odc>, B<newc>, B<crc>, B<tar>,
B<ustar> or B<ascii>.

=item B<notify>

email address(es) to send error report.  requires '/bin/mail' and properly
configured MTA.  multiple addresses can be separated by whitespace.

=item B<ziplog>

absolute path of compression utility used to compress backup log.  if set to
1 B<cpiotool> will use compress.

=item B<verbose>

set to 1 to display verbose output to STDERR.

=item B<debug>

set to 1 to display very verbose output to STDERR.

=back

=head2 [DIRLIST]

list of subdirectories relative to the B<root-dir> to be backed up.  if this
section is empty B<cpiotool> will archive all files and directories under
the B<root-dir>.  note, directories must be specified relative to the
B<root-dir>.  using absolute paths will break the file-finding routine.

=head2 [EXCLUDES]

list of subdirectories relative to the B<root-dir> to be excluded from the
archive.  if this section is empty B<cpiotool> will not exclude any files.
note, directories must be specified relative to the B<root-dir>.

=head2 SAMPLE CONFIGURATION

    # cpiotool level 2 config

    [CONF]
    root-dir		/home/nickb
    cpio		/usr/local/bin/cpio
    level		2
    #remote-host
    device		/dev/fd0
    #file
    block-size		20
    logdir		/var/log/backup
    set			3
    keep-logs		14
    conf-dir		/usr/local/backup/conf
    header		ustar
    notify		ops@domain.org backup@domain.org
    maxsize		1.44
    ziplog		/usr/local/bin/bzip2
    verbose		1

    [DIRLIST]
    # relative to root-dir
    GNUstep/Library/AfterStep
    site

    [EXCLUDE]
    GNUstep/Library/AfterStep/non-configurable
    GNUstep/Library/AfterStep/start/Applications/Editors
    trash
    unused

    # end config

=head1 MULTIPLE-VOLUME ARCHIVES

the cpio binary supports archives that span multiple volumes.  B<cpiotool>
implements this in one of 3 ways, provided the archive is written to a
device:

=over 4

=item 1.

if B<--maxsize> is unspecified, the cpio binary will detect end of media
and send a request to the controlling terminal.  this is easiest but requires
the backup to be run manually from a terminal.  it is also time-consuming
and error-prone in the event a restore is needed, as each tape in the series
must be read in order to restore a file at the end of the archive.  if
one of the tapes in the set is corrupt, it's possible that all data on
subsequent tapes in the archive will be inaccessible.

=item 2.

if B<--maxsize> is specified but B<--notify> is unspecified or a mailer
is not found, B<cpiotool> will estimate the total size of all cpio headers
and keep a running total of the number of bytes written to the device.
when the number approaches B<--maxsize>, IO to the cpio binary is closed
and a request for media is printed to standard out.  sending a HUP signal
to B<cpiotool> continues the backup.  this option still requires the
backup to be run manually from a terminal, but has the advantage of writing
archives separately to individual tapes rather than writing one large archive
across multiple tapes.  this decreases time required for restores and reduces
the potential for data loss.

=item 3.

if B<--maxsize> and B<--notify> are specified, and a mailer is found,
B<cpiotool> writes archives in the same manner as B<2> above, but sends
media requests to the email address specified in B<--notify>.  this
allows some degree of automation as no interaction with a terminal is
required.

=back

=head1 VOLUME MANAGEMENT

not supported (yet).  eventually B<cpiotool> will store tape information in
a mysql database, but this is a project for the distant future.

=head1 INCREMENTAL BACKUPS

the simplified concept of tapesets, along with the use of timestamp files,
determines which files to archive for incremental (level 1-9) backups.
if a configuration directory is specified B<cpiotool> writes a timestamp file
called C<level[level]-s[set].timestamp>.  the last modification time of every
file in each directory in the [DIRLIST] is compared to the last
modification time of the timestamp file to determine if the file should be
archived.  timestamp files are simply zero-length files with a naming
convention recognized by B<cpiotool>.  creating, removing, or changing the
modification times of timestamp files will affect the behavior of
B<cpiotool>.

=head1 README

backup script that uses cpio

=head1 PREREQUISITES

perl 5.6.0 or newer

=head1 VERSION

B<cpiotool> v0.65a, Copyright (C) 2001 Assentive Solutions

=head1 AUTHOR

Nick Balthaser <nb9001@yahoo.com>

=head1 OSNAMES

freebsd
solaris

=head1 BUGS

=over 4

=item *

if the remote transport (rsh or ssh) hangs up while using non-GNU cpio,
IO stops and cpiotool exits.

=item *

when running in a terminal in the background and writing to a remote host with
dd, sending SIGINT by pressing ^C can have unpredictable
results, even if B<cpiotool> is running under a subshell or nohup.

=item *

error "dd: unexpected short write, wrote 0 bytes, expected <n>" while writing
to remote device.  the remote dd detected end of media and quit prematurely.
this can mean the B<--maxsize> value is greater than the capacity of the
media.  now that _padTrailer() is in place this should happen far less
often.  adjusting B<--maxsize> can compensate for this in the interim.

=item *

no error checking on the B<--maxsize> value.  if an incorrect value is
specified, the cpio binary and/or dd may reach end-of-media before the
byte-counter.  this will cause dd to hang up abruptly on remote backups
or conflicting media requests on local backups.

=item *

if a single file being backed up is larger than the media capacity,
the cpio binary could reach end-of-media before the byte-counter and
conflicting media requests would be sent.  currently an exception is thrown
and the script exits if this happens.  the only solution currently
is to disable B<--maxsize> by setting it to 0 or leaving it unspecified.
this allows the cpio binary to create a single archive that spans multiple
tapes, which can be unreliable.  see B<MULTIPLE-VOLUME ARCHIVES> above.

=item *

the number of bytes subtracted from the B<--maxsize> value to allow for
headers is an (improving) rough estimate and may be inaccurate.  in the
meantime adjusting B<--maxsize> can compensate.

=item *

the log-remover might not recognize logs with a compressed suffix (e.g.
.gz, .bz2).

=item *

for full functionality B<cpiotool> relies on the presence of mail or mailx,
dd, and rsh or ssh.  dd always exits on SIGINT.  it's not always possible
to trap SIGINT.

=item *

no database interface, reliance on easily-manipulated timestamp files.

=back


=head1 SCRIPT CATEGORIES

UNIX/System_administration

=cut
