#!/usr/bin/perl
#
# $Revision: 1.40 $
# Copyright 2009-2011 Teleflora
#
# Apply a "patch" to a "Daisy" POS environment.
#
# Intended for use with files produced by the "makepatch.pl" script
# for use in an Altiris deployment model.
#
use strict;
use warnings;
use Getopt::Long;
use POSIX;
use English;
use Digest::MD5;
use File::Basename;

my $CVS_REVISION = '$Revision: 1.40 $';
my $PROGNAME = basename($0);

#
# Command line options.
#
my $VERSION = 0;
my $HELP = 0;
my $RESTART = 1;
my $DAISY = 0;
my $DAISY_DIR = '/d/daisy';
my $LOG_STDERR = 0;
my $CONVERT_TO_ELAVON = 0;
my $INSTALL_FTD_FILES = 0;
my $HARDEN_LINUX = 0;
my $DEBUG = 0;

#
# Alternate os and root for testing.
#
my $ALTROOT = "";
my $ALTOS = "";

#
# Global variables.
#
my $POSDIR = "";
my $TIMESTAMP = strftime("%Y%m%d%H%M%S", localtime());
my $OS;

# Exit status values
my $EXIT_OK = 0;
my $EXIT_COMMAND_LINE = 1;
my $EXIT_MUST_BE_ROOT = 2;
my $EXIT_PATCH_VALIDATION = 3;
my $EXIT_SETTLE_RECS_NOT_ZERO = 4;
my $EXIT_POSTPATCH_CLEANUP = 8;
my $EXIT_STOP_DAISY = 9;
my $EXIT_START_DAISY = 10;
my $EXIT_PATCH_FAILED = 11;
my $EXIT_POSTPATCH = 12;
my $EXIT_PERMS = 15;
my $EXIT_HARDEN_LINUX = 16;
my $EXIT_ELAVON_CONTROL_FILE = 17;
my $EXIT_CARD_CONTROL_FILE = 18;
my $EXIT_CARD_INPUT_FILE = 19;

#
# The patch type can be either "Patch" or "Revision".
#
my $PATCH_TYPE = "Patch";
my @PATCHFILES = ();

#
# The list of Daisy dirs that a patch is going to be applied to.
my @DaisyDirs = ();

GetOptions(
	"version" => \$VERSION,
	"help" => \$HELP,
	"restart!" => \$RESTART,
	"daisy" => \$DAISY,
	"daisydir=s" => \$DAISY_DIR,
	"log-stderror" => \$LOG_STDERR,
	"convert-to-elavon" => \$CONVERT_TO_ELAVON,
	"install-ftd-files" => \$INSTALL_FTD_FILES,
	"harden-linux" => \$HARDEN_LINUX,
	"debug" => \$DEBUG,
	"altroot=s" => \$ALTROOT,
	"altos=s" => \$ALTOS,
) || die "Error: invalid command line option, exiting...\n";


# --version
if ($VERSION) {
	print("$PROGNAME $CVS_REVISION\n");
	exit($EXIT_OK);
}

# --help
if ($HELP) {
	print("$PROGNAME $CVS_REVISION\n");
	usage();
	exit($EXIT_OK);
}

#
# If there are no names of patch file, output usage and exit.
#
if ($#ARGV < 0) {
	usage();
	exit($EXIT_COMMAND_LINE);
}

#
# If daisy not specificed on the command line, then verify the existence
# of either the default daisy database dir or one specified on the command
# line.
#
if ($DAISY == 0) {
	unless (-d $DAISY_DIR) {
		print("Daisy default database dir does not exist: $DAISY_DIR");
		exit($EXIT_COMMAND_LINE);
	}
	$DAISY = 1;
}

$POSDIR = $DAISY_DIR;
unless (-d $POSDIR) {
	print("Daisy default database dir does not exist: $POSDIR");
	exit($EXIT_COMMAND_LINE);
}

#
# Gotta be root or exit.
#
if($EUID != 0) {
	usage();
	logerror("root privileges are required to run this script.\n");
	loginfo("<===== End $PROGNAME $CVS_REVISION =====>");
	exit($EXIT_MUST_BE_ROOT);
}


sub main
{
    my $exit_status = $EXIT_OK;
    my $include_crd_dirs = 0;
    my $thisfile = "";

    loginfo("");
    loginfo("");
    loginfo("<===== $PROGNAME $CVS_REVISION =====>");


    determine_os();

    #
    # Scrub the list of patch file names to avoid shell injection attacks.
    #
    foreach $thisfile (@ARGV) {
	loginfo("scrubbing patch file name: $thisfile");
	push(@PATCHFILES, validate_input($thisfile));
    }

    loginfo("BEGIN applying patch files: @PATCHFILES");

    #
    # Apply various criteria to validate the list of patch files...
    # if all patch files do not pass all criteria, then exit.
    #
    if (validate_patchfiles(\@PATCHFILES) != 0) {
	logerror("validation errors for: @PATCHFILES");
	loginfo("<===== End $PROGNAME $CVS_REVISION =====>");
	return($EXIT_PATCH_VALIDATION);
    }

    #
    # If there are any post patch scripts left from a previous application,
    # remove them.
    #
    if (cleanup_postpatch_scripts($include_crd_dirs) != 0) {
	logerror("cleanup of previous post patch scripts failed");
	loginfo("<===== End $PROGNAME $CVS_REVISION =====>");
	return($EXIT_POSTPATCH_CLEANUP);
    }

    #
    # Stop Daisy.
    #
    if ($RESTART) {
	unless (stop_daisy()) {
	    logerror("could not stop Daisy POS");
	    loginfo("<===== End $PROGNAME $CVS_REVISION =====>");
	    return($EXIT_STOP_DAISY);
	}
    }
    else {
	loginfo("Daisy POS not stopped: --norestart specified");
    }


    #
    # Step through each patch file, and apply it; if unsuccessful,
    # log a message and stop applying patches.
    #
    foreach $thisfile (@PATCHFILES) {

	next if ($thisfile eq "");

	if (applypatch($thisfile, $include_crd_dirs)) {
	    loginfo("patch file applied successfully: $thisfile");
	}
	else {
	    logerror("could not apply patch file: $thisfile");
	    $exit_status = $EXIT_PATCH_FAILED;
	    last;
	}
    }

    #
    # If there were any problems with applying the patch,
    # back everything out.
    #
    if ($exit_status != $EXIT_OK) {
	foreach $thisfile (@PATCHFILES) {
	    reverse_patch($thisfile);
	}
    }

    #
    # One of the patches may have created a "postpatch.pl" script.
    # If so, run that after all ".patch" files have been extracted.
    #
    # If there were no errors from the apply patch, then
    #	proceed with applying any post patch scripts.
    #
    if ($exit_status == $EXIT_OK && @DaisyDirs != 0) {
	if (apply_postpatch(\@PATCHFILES, $TIMESTAMP, $include_crd_dirs) != 0) {
	    $exit_status = $EXIT_POSTPATCH;
	}
    }

    #
    # Only run perms fixer if everything OK.
    #
    if ($exit_status == $EXIT_OK && @DaisyDirs != 0) {
	if (run_daisy_perms() != 0) {
	    $exit_status = $EXIT_PERMS;
	}
    }

    #
    # Only run the harden script if everything OK and it's asked for.
    #
    if ($exit_status == $EXIT_OK && @DaisyDirs != 0) {
	if ($HARDEN_LINUX) {
	    if (run_harden_linux() != 0) {
		$exit_status = $EXIT_HARDEN_LINUX;
	    }
	}
    }

    #
    # Restart the POS.
    #
    if ($RESTART) {
	unless (start_daisy()) {
	    logerror("could not start daisy");
	    loginfo("<===== End $PROGNAME $CVS_REVISION =====>");
	    return($EXIT_START_DAISY);
	}
    }
    else {
	loginfo("Daisy not restarted: --norestart specified");
    }


    #
    # All that's left to do is write a log message and report the exit status.
    #
    loginfo("$PROGNAME is terminating with exit status $exit_status");
    loginfo("<===== End $PROGNAME $CVS_REVISION =====>");

    return($exit_status);
}


################################################################
################################################################
################################################################


sub usage
{
	print("Usage:\n");
	print("$PROGNAME --version\n");
	print("$PROGNAME --help\n");
	print("$PROGNAME [options] daisy-x.x.x_A.patch daisy-x.x.x_B.patch ...\n");
	print("\n");
	print("Options:\n");
	print("--harden-linux        run harden_linux.pl after successfully applying patch\n");
	print("--log-stderror        write output to stderr to the log file\n");
	print("--norestart           prevents stopping and then restarting Daisy\n");
	print("\n");
}


#
# Get a field from a control file
#
# Input
#   arg[0]: full path to the control file
#   arg[1]: buffer size
#   arg[2]: index of field begin
#   arg[3]: field length
# 
# Return
#   field on success, empty string on error
#
sub get_control_file_field
{
    my $ctrl_file = $_[0];
    my $bufsize = $_[1];
    my $field_begin = $_[2];
    my $field_size = $_[3];
    my $field = "";
    my $buffer = "";

    if (open(DF, $ctrl_file)) {
	my $rc = sysread(DF, $buffer, $bufsize);
	if (defined($rc) && $rc == $bufsize) {
	    $field = substr($buffer, $field_begin, $field_size);
	}
	else {
	    showerror("Error reading control file: $ctrl_file\n");
	    showerror("Error: $!\n");
	}
	close(DF);
    }
    else {
	showerror("Open of control file failed: $ctrl_file");
    }

    return($field);
}


#
# Determine whether this system is configured to use Elavon
# for credit card payments.
#
# If the terminal id field in the file elavctrl.pos is
# 16 ascii chars long, then the system is assumed to be
# configured to use Elavon.
#
# Input
#   arg[0]: daisy database dir or card dir
#
# Returns 1 for yes, 0 for no
#
sub is_configured_for_elavon
{
    my $daisy_dir = $_[0];
    my $ctrl_file = "$daisy_dir/elavctrl.pos";

    if (-e $ctrl_file) {
	my $terminal_id = get_control_file_field($ctrl_file, 124, 8, 16);
	if ($terminal_id ne "") {
	    if (length($terminal_id) == 16) {
		if ($terminal_id =~ /[[:ascii:]]{16}/) {
		    return(1);
		}
	    }
	}
    }

    return(0);
}


#
# Function to determine if an arbitrary path is a path to a
# daisy databse directory.
#
sub is_daisy_db_dir
{
	my $path = $_[0];

	# must begin with '/d/'
	return(0) unless ($path =~ /^\/d\//);

	# must be a directory
	return(0) unless (-d $path);

	# skip old daisy dirs
	return(0) if ($path =~ /^\/d\/.+-\d{12}$/);

	# must contain the magic files
	return(0) unless (-e "$path/flordat.tel");
	return(0) unless (-e "$path/control.dsy");

	# must be daisy 8.0+
	return(0) unless (-d "$path/bin");

	return(1);
}


#
# If there are any credit card processing directories in the specified
# daisy database dir, add it to the list.  A credit card processing
# directory is defined to be any directory named 'crd[2-9]' that is
# located within a daisy database directory.
#
sub get_crd_dirs
{
    my @posdirs = shift;
    my @crd_dirs = ();

    foreach my $posdir (@posdirs) {

	my @ccrd_files = glob("$posdir/crd[2-9]");

	foreach my $ccrd_file (@ccrd_files) {
	    if (-d $ccrd_file) {
		push(@crd_dirs, $ccrd_file);
	    }
	}
    }

    return(@crd_dirs);
}


#
# Return a list of verified POS dirs.  For Daisy, remember, there can be
# more than one daisy database dir!
#
sub get_daisy_dirs
{
	my @daisy_dirs = ();
	my $daisy_top_dir = '/d';

	# Daisy POS can have one or more database dirs
	my @daisy_db_dirs = glob("$daisy_top_dir/*");
	for my $daisy_db_dir (@daisy_db_dirs) {

		# must be a directory
		next unless (-d $daisy_db_dir);

		# skip old daisy dirs
		next if ($daisy_db_dir =~ /.+-\d{12}$/);

		# must contain the magic files
		next unless(-e "$daisy_db_dir/flordat.tel");
		next unless(-e "$daisy_db_dir/control.dsy");

		# must be daisy 8.0+
		next unless (-d "$daisy_db_dir/bin");

		push(@daisy_dirs, $daisy_db_dir);
	}

	return(@daisy_dirs);
}


#
# Validate each patchfile from the list of patchfiles
#
# Returns 0 on success, -1 on error
#
sub validate_patchfiles
{
	my @patchfiles = @{$_[0]};
	my @posdirs = get_daisy_dirs();
	my $thisfile = "";

	foreach $thisfile (@patchfiles) {

		determine_patch_type($thisfile);

		# Verify patch file is a file.
		unless (-f "$thisfile") {
			logerror("\"$thisfile\" is not a file.\n");
			return(-1);
		}

		# Check for a zero length file.
		if (-s "$thisfile" == 0) {
			logerror("\"$thisfile\" is a zero length file.\n");
			return(-1);
		}

		# Even if patch file is not zero length, check for empty patch file.
		my @patchfiles = get_patchfile_catalog($thisfile);
		if ($#patchfiles < 0) {
			logerror("Patchfile \"$thisfile\" Contains no Files Within it.\n");
			return(-1);
		}

		# If a Daisy patch file, verify there exists valid daisy db dirs.
		if ($thisfile =~ /daisy-.*.\.patch/) {

			# Make sure one or more Daisy database dirs exist
			unless (@posdirs) {
				usage();
				logerror("No Daisy Directories Found.\n");
				return(-1);
			}
		}

		# If a RTI patch file, unsupported.
		if ($thisfile =~ /RTI-.*.\.patch/) {

			logerror("RTI patch unsupported: $thisfile\n");
			return(-1);
		}

		# Verify file system with the daisy database dir is not 100% full.
		# The code must respect that the possible various daisy db dirs
		# can be on any file system.
		foreach my $posdir (@posdirs) {
			if (get_diskused($posdir) == 100) {
				logerror("filesystem is 100% full: \"$posdir\"\n");
				return(-1);
			}
		}

		loginfo("Patch validated: $thisfile\n");
	}

	return(0);
}


#
# Clean up old post patch script from previous patches...
# Remember, there can be more than one daisy database dir.
#
# A script named "postpatch.pl" might be in the bin directory
# of the POS... if it exists, remove it.
#
# Returns 0 on success, -1 on error
#
sub cleanup_postpatch_scripts
{
    my $include_crd_dirs = $_[0];
    my @posdirs = ();
    my $post_patch_path;

    @posdirs = get_daisy_dirs();

    if ($include_crd_dirs) {
	my @crd_dirs = get_crd_dirs(@posdirs);
	foreach my $ccrd_dir (@crd_dirs) {
	    push(@posdirs, $ccrd_dir);
	}
    }

    foreach my $posdir (@posdirs) {

	$post_patch_path = "$posdir/bin/postpatch.pl";

	if (-f $post_patch_path) {
	    loginfo("Removing previous post patch script: $post_patch_path");
	    mysystem("rm -f $post_patch_path");
	    if (-f $post_patch_path) {
		logerror("Can not remove previous post patch script: $post_patch_path");
		return(-1);
	    }
	}
    }

    return(0);
}


#
# Make a backup of existing files, the ones to be replaced
# by the patch.  First arg is a list of the possible POS dirs -
# remember, that for daisy, there could be more than one.
#
# Returns:
#   1 --> success
#   0 --> copy of original file to backup dir failed
#   0 --> checksum difference between original and backup
#
sub backup_files
{
    my @posdirs = @{$_[0]};
    my @patchfile_catalog = @{$_[1]};

    # Need to know os type in this function
    determine_os();

    #
    # First, loop through all the POS dirs...
    #
    foreach my $posdir (@posdirs) {

	#
	# First, make sure there is a place to put the backups.
	#
	unless (-d "$posdir/backups") {
	    mysystem("mkdir $posdir/backups");
	    mysystem("chmod 750 $posdir/backups");
	    mysystem("chown tfsupport:daisy $posdir/backups");
	}

	#
	# Now, loop through the list of all the files in the patchfile and
	# copy the originals (if there is one) to the backup directory.
	#

	foreach my $thisfile (@patchfile_catalog) {

	    my $src_file = "$posdir/$thisfile";

	    #
	    # Handle platform specific files - like those that have a suffix
	    # of "-FC3" or "-FC5" or "-RHWS5" or "-RHEL5".
	    #
	    if ($src_file =~ /(.*)-$OS$/) {
		$src_file = $1;
	    }

	    #
	    # FIXME: This strategy does not handle files of the same name
	    # under subdirs in $posdir.
	    #

	    my $dst_file = "$posdir/backups/" . basename($thisfile) . ".prepatch.$TIMESTAMP";
	    my $oldmd5 = "";
	    my $newmd5 = "";

	    if (! -f $src_file) {
		loginfo("\"$src_file\" will be a new file from patch. Backup Not Possible.");
		next;
	    }

	    $oldmd5 = get_md5sum("$src_file");

	    mysystem("cp -f $src_file $dst_file");
	    if ($? != 0) {
		logerror("File Backup Failed ($?): \"$src_file\" -> \"$dst_file\"");
		return(0);
	    }

	    $newmd5 = get_md5sum("$dst_file");

	    # Verify MD5sum of our "old" and "new" files. They should be the same.
	    if ($oldmd5 ne $newmd5) {
		logerror("File Backup Failed: MD5 sums do not match");
		logerror("\"$src_file\" ($oldmd5) -> \"$dst_file\" ($newmd5)");
		return(0);
	    }

	    loginfo("Backed Up... $src_file -> $dst_file (MD5=$newmd5)");
	}
    }

    return(1);
}


#
# Apply a patch to a set of daisy dirs.
#
# Side effect
#   sets global var @DaisyDirs to number of Daisy database dirs
#
# Returns:
#   1 --> success
#   0 --> no files in patchfile
#   0 --> could not backup files to be patched
#   0 --> could not extract file from patch tar archive
#
sub applypatch
{
    my $patchfile = $_[0];
    my $include_crd_dirs = $_[1];

    my @patchfile_catalog = ();
    my $old_patch_id = "";
    my $new_patch_id = "";

    determine_patch_type($patchfile);

    log_patch_info($patchfile);

    my @all_posdirs = get_daisy_dirs();

    if ($include_crd_dirs) {
	my @all_crd_dirs = get_crd_dirs(@all_posdirs);
	foreach my $ccrd_dir (@all_crd_dirs) {
	    push(@all_posdirs, $ccrd_dir);
	}
    }

    @DaisyDirs = @all_posdirs;

    # if there are no daisy database dirs, then nothing to do
    if (@DaisyDirs == 0) {
	loginfo("skipping patch application: no eligible daisy database dirs");
	return(1);
    }

    loginfo("BEGIN applying Daisy patch: $patchfile");
    loginfo("applying patch type: $PATCH_TYPE");

    #
    # The patch file contains a set of files to be replaced -
    # get a list of those files from the patch file.
    #
    @patchfile_catalog = get_patchfile_catalog($patchfile);
    if ($#patchfile_catalog < 0) {
	logerror("there are no files in patchfile: $patchfile\n");
	return(0);
    }

    # Backup the files to be replaced
    if (backup_files(\@DaisyDirs, \@patchfile_catalog)) {
	loginfo("files backed up for patch file: $patchfile");
    }
    else {
	logerror("could not backup files for patch file: $patchfile");
	return(0);
    }

    #
    # Unzip the patch file before heading into the loop... otherwise, the
    # patch file has to be uncompressed for each file extraction which takes
    # too much time when dealing with a edir update.
    #
    # And don't use the "mysystem()" function since that puts the output
    # of the command into the log file.
    #
    my $unzipped_patchfile = "";

    $unzipped_patchfile = qx(mktemp /tmp/applypatch.XXXXXXX);
    chomp($unzipped_patchfile);
    system("gzip -dc $patchfile > $unzipped_patchfile");

    #
    # Outer loop through all the Daisy database dirs...
    #
    foreach my $posdir (@DaisyDirs) {

	#
	# Inner loop through the list of all the files in the patchfile and
	# extract the files from patch file
	#
	foreach my $thisfile (@patchfile_catalog) {

	    my $newmd5 = "";

	    # Extract this file.
	    mysystem("tar -C \"$posdir\" -xvf $unzipped_patchfile $thisfile");
	    if($? != 0) {
		logerror("\"$patchfile: $thisfile\": tar exit status $?\n");
		mysystem("rm -f $unzipped_patchfile");
		return(0);
	    }

	    if(! -f "$posdir/$thisfile") {
		logerror("\"$patchfile: $thisfile\": file not present after extract");
		mysystem("rm -f $unzipped_patchfile");
		return(0);
	    }

	    if(-s "$posdir/$thisfile" == 0) {
		logerror("\"$patchfile: $thisfile\": zero byte file after extract");
		mysystem("rm -f $unzipped_patchfile");
		return(0);
	    }

	    $newmd5 = get_md5sum("$posdir/$thisfile");

	    loginfo("Patched \"$posdir/$thisfile\" (New MD5=$newmd5)");
	}
    }

    loginfo("END applying Daisy patch: $patchfile");

    mysystem("rm -f $unzipped_patchfile");

    return(1);
}


#
# Back out a previously applied patch, if the patch was even previously applied.
#
sub reverse_patch
{
    my $patchfile = $_[0];

    my @patchfile_catalog = get_patchfile_catalog($patchfile);
    if ($#patchfile_catalog < 0) {
	logerror("There are no files in patchfile: $patchfile\n");
	return(0);
    }

    loginfo("BEGIN reversing patch: $patchfile");


    foreach my $posdir (@DaisyDirs) {	# outer loop: relevant daisy dirs

	foreach my $thisfile (@patchfile_catalog) {	# inner loop: files in the patch

	    my $src_file = "$posdir/backups/" . basename($thisfile) . ".prepatch.$TIMESTAMP";
	    my $dst_file = "$posdir/$thisfile";

	    #
	    # No file to be backed out.
	    #
	    # This could happen if, for example, we were supposed to apply three
	    # ".patch" files, and, the first ".patch" file hosed before the subsequent
	    # two "backup" processes started. In short, this is a fairly harmless
	    # situation. If there is no backup file, then, we didn't touch anything
	    # to begin with.
	    #
	    if (! -f $src_file) {
		loginfo("Warning: $src_file does not exist. Cannot back-out change.");
		next;
	    }

	    loginfo("Backing out $src_file -> $dst_file");
	    mysystem("cp -f $src_file $dst_file 2> /dev/null");
	    if($? != 0) {
		# We're pretty much hosed at this point.
		logerror("Backout of $src_file $dst_file Failed ($?)");
	    }
	}
    }

    loginfo("END reversing patch: $patchfile");

    return(0);
}


#
# Run any "postscript.pl" script left in a daisy database directory by a
# patch file.
#
# The name of the post patch script is "postpatch.pl" and if it exists,
# it will be in the "bin" directory of a daisy database directory.
#
# Remember, for Daisy, there can be one or more daisy database directories.
#
# Note that, we assume that post patch script "cleans up" if it fails along the
# way. We are giving the post patch script the "TIMESTAMP", which tells the
# script where our "backup" files are located; we are also giving it the path
# to our ".patch" files, so that it knows which files to "reverse out".
#
sub apply_postpatch
{
    my @patchfiles = @{$_[0]};
    my $timestamp = $_[1];
    my $include_crd_dirs = $_[2];
    my $rc = 0;

    determine_os();

    loginfo("BEGIN running post patch scripts: @DaisyDirs");

    foreach my $posdir (@DaisyDirs) {

	my $script_path = "$posdir/bin/postpatch.pl";
	my $script_options = "";

	if (-f $script_path) {

	    my $md5sum = "";
	    $md5sum = get_md5sum($script_path); #Do this *before* dos2unix.

	    # just in case it was mangled by Windows
	    if (-e "/usr/bin/dos2unix") {
		mysystem("/usr/bin/dos2unix $script_path");
		if ($? == 0) {
		    loginfo("/usr/bin/dos2unix ran successfully");
		}
		else {
		    loggerror("/usr/bin/dos2unix returned non-zero exit status: $?");
		}
	    }
	    else {
		loginfo("command is not installed: dos2unix");
	    }
	    mysystem("chown root:root $script_path");
	    mysystem("chmod 555 $script_path");

	    if ($INSTALL_FTD_FILES) {
		$script_options .= "--install-ftd-files";
	    }

	    loginfo("post patch script md5sum: $md5sum");

	    loginfo("post patch cmd: cd $posdir && $script_path $script_options '$posdir' '$timestamp' @patchfiles");

	    mysystem("cd $posdir && $script_path $script_options $posdir \"$timestamp\" @patchfiles");
	    if ($? != 0) {
		logerror("post patch cmd returned non-zero exit status: $?");
		$rc = 1;
	    }
	}
	else {
	    loginfo("post patch script does not exist: $script_path");
	}
    }

    loginfo("END running post patch scripts");

    return($rc);
}

#
# Look in the patch file and get a list of files which will be added.
#
sub get_patchfile_catalog
{
	my $patchfile = $_[0];
	my @files = ();

	open(PIPE, "tar -tzvf $patchfile |");
	while(<PIPE>) {
		chomp;
		my @tarline = split(/\s+/);

		# Listing the contents of the tar archive, the filename
		# is at index 5; be aware that the "filename" might include
		# directories; after splitting, the list should contain:
		#
		#	index 0: file perms		(-rwxr-xr-x)
		#	index 1: file owner/group	(tfsupport/daisy)
		#	index 2: file size		(75856)
		#	index 3: file date		(2010-12-20)
		#	index 4: file time		(10:29:26)
		#	index 5: file name		(updateos.pl)

		my $fileperms = $tarline[0];
		my $filename = $tarline[5];

		# skip a tar header - don't think tar does this anymore
		next if($filename =~ /^(\d+\.\d+\.\d+)(\s+)(Patch)$/);

		# skip a directory within the tar file
		next if ($fileperms =~ /^d/);

		# skip if the file in the tar archive exists as a directory
		# in any of the daisy database dirs
		my @posdirs = get_daisy_dirs();
		foreach my $posdir (@posdirs) {
			next if (-d "$posdir/$filename");
		}

		# ok, put this one on the list
		push(@files, $filename);
	}
	close(PIPE);

	return(@files);
}


#
# Create a log entry indicating what has changed.
#
sub log_patch_info
{
	my $patchfile = $_[0];
	my @patchfile_catalog = ();
	my $patchsize = "";


	loginfo("");
	loginfo("$PROGNAME  " . '$Revision: 1.40 $');
	loginfo("USER: $ENV{'USER'}");
	loginfo("UID: $UID");
	loginfo("EUID: $EUID");
	loginfo("CWD: " . getcwd());
	loginfo("POSDIR: $POSDIR");

	$patchsize = (-s $patchfile);
	loginfo("$PATCH_TYPE File: $patchfile (" . get_md5sum($patchfile) . ")");
	loginfo("$PATCH_TYPE File Size: $patchsize");
	loginfo("$PATCH_TYPE File MD5: " . get_md5sum($patchfile));
	loginfo("$PATCH_TYPE Timestamp: $TIMESTAMP");

	# Log the actual files in the patch.
	@patchfile_catalog = get_patchfile_catalog($patchfile);
	loginfo("$PATCH_TYPE Contents: @patchfile_catalog");
}


sub run_daisy_perms
{
    my @posdirs = ();
    my $perms_cmd = "$POSDIR/bin/dsyperms.pl";
    my $rc = 0;

    @posdirs = get_daisy_dirs();

    foreach my $posdir (@posdirs) {

	if (-e $perms_cmd) {
	    loginfo("running Daisy perms script: $perms_cmd");
	    $rc = mysystem("$perms_cmd $posdir");
	    if ($rc != 0) {
		logerror("Daisy perms script returns non-zero exit status: $rc");
	    }
	}
	else {
	    logerror("Daisy perms script not found: $perms_cmd");
	    $rc = 1;
	}
    }

    return($rc);
}


sub run_harden_linux
{
    my $harden_cmd = "$POSDIR/bin/harden_linux.pl";
    my $rc = 0;

    if (-e $harden_cmd) {
	loginfo("running harden Linux script: $harden_cmd");
	$rc = mysystem("$harden_cmd");
	if ($rc != 0) {
	    logerror("harden Linux script returns non-zero exit status: $rc");
	}
    }
    else {
	logerror("harden Linux script not found: $harden_cmd");
	$rc = 1;
    }

    return($rc);
}


#
# Return the percentage of disk being used.
# This gives us a clue as to whether the disk is already "at capacity".
#
sub get_diskused
{
	my $directory = $_[0];
	my $returnval = -1;


	if(! -d "$directory") {
		return(-1);
	}


	open(PIPE, "df -h $directory |");
	while(<PIPE>) {
		next until(/(\s+)(\d+)(\%)(\s+)/);
		$returnval = $2;
		last;
	}
	close(PIPE);

	return($returnval);
}


sub get_md5sum
{
	my $filename = $_[0];
	my $ctx;
	my $md5sum = "";

	if(! -f "$filename") {
		return("");
	}

	open(FILE, "< $filename");
	$ctx = Digest::MD5->new;
	$ctx->addfile(*FILE);
	$md5sum = $ctx->hexdigest;
	close(FILE);

	return($md5sum);
}


sub determine_os
{
	$OS = "";

	# We could also use /usr/bin/lsb-release
	open(FILE, "< /etc/redhat-release");
	while(<FILE>) {

		# ES 7
		# Redhat Enterprise Linux Server 7
		if( (/(release)(\s+)(7)/)
		||  (/(CentOS)([[:print:]]+)(\s)(7)/) ) {
			$OS = "RHEL7";
			last;
		}

		# ES 6
		# Redhat Enterprise Linux Server 6
		if( (/(release)(\s+)(6)/)
		||  (/(CentOS)([[:print:]]+)(\s)(6)/) ) {
			$OS = "RHEL6";
			last;
		}

		# ES 5
		# Redhat Enterprise Linux Server 5
		if( (/(release)(\s+)(5)/)
		||  (/(CentOS)([[:print:]]+)(\s)(5)/) ) {
			$OS = "RHEL5";
			last;
		}

		# EL 4
		# Redhat Enterprise Linux Server 4
		if( (/(release)(\s+)(4)/) 
		||  (/(CentOS)([[:print:]]+)(\s)(4)/) ) {
			$OS = "RHEL4";
			last;
		}
	}
	close(FILE);

	return($OS);
}


sub loginfo
{
	my $message = $_[0];
	my $logtime = strftime("%Y-%m-%d %H:%M:%S", localtime());

	chomp($message);
	open LOGFILE, ">> $POSDIR/log/RTI-Patches.log";
		print LOGFILE "$logtime ($PROGNAME-$$) <I> $message\n";
	close LOGFILE;

	return "";
}


sub logerror
{
	my $message = $_[0];
	my $logtime = strftime("%Y-%m-%d %H:%M:%S", localtime());

	chomp($message);
	open LOGFILE, ">> $POSDIR/log/RTI-Patches.log";
		print LOGFILE "$logtime ($PROGNAME-$$) <E> $message\n";
	close LOGFILE;

	return "";
}


# PCI 6.5.6
# "some string; `cat /etc/passwd`" -> "some string cat /etc/passwd"
# "`echo $ENV; $(sudo ls)`" -> "echo ENV sudo ls"
# etc.
sub validate_input
{
	my $var = $_[0];
	my $temp = "";

	if(! $var) {
		return "";
	}

	$temp = $var;

	# "`bad command`" -> "bad command"
	$temp =~ s/\`//g;

	# "$(bad command)" -> "bad command"
	$temp =~ s/(\$\()(.*.)(\))/$2/g;


	# "stuff ; bad command" -> "stuff bad command"
	$temp =~ s/\;//g;

	# "stuff && bad command" -> "stuff bad command"
	$temp =~ s/\&//g;

	# "stuff | bad command" -> "stuff bad command"
	$temp =~ s/\|//g;

	# "stuff > bad command" -> "stuff bad command"
	$temp =~ s/\>//g;

	# "stuff < bad command" -> "stuff bad command"
	$temp =~ s/\<//g;

	# Filter non printables
	$temp =~ s/[[:cntrl:]]//g;


	return($temp);
}

#
# Subroutine to run system commands.  This logs an entry.
#
sub mysystem
{
	my $command = $_[0];
	my $returnval = "";
	my $log_file = "$POSDIR/log/RTI-Patches.log";

	my $stderr_dest = ($LOG_STDERR) ? "$log_file" : "/dev/null";

	system("$command >> $log_file 2>> $stderr_dest");
	$returnval = WEXITSTATUS($?);
	loginfo("$command Returned: $returnval");

	return ($returnval)
}

#
# Currently, a file is classified as a "Patch" or a "Revision" solely
# by whether it has the substring "revision" *anywhere* in it's name!
#
sub determine_patch_type
{
	$PATCH_TYPE = "Patch";
	if ($_[0] =~ m/revision/) {
		$PATCH_TYPE = "Revision";
	}
}


#
# Start Daisy.
#
# returns
#   1 on success
#   0 if error
#
sub start_daisy
{
    if ($OS eq 'RHEL5' || $OS eq 'RHEL6') {
	loginfo("[start_daisy] changing to run level 3");
	mysystem("/sbin/telinit 3");
	if ($? != 0) {
	    logerror("[start_daisy] could not change to run level 3");
	    return(0);
	}
    }

    if ($OS eq 'RHEL7') {
	my @tty_list = qw(
	    getty@tty1
	    getty@tty2
	    getty@tty3
	    getty@tty4
	    getty@tty5
	    getty@tty6
	    getty@tty7
	    getty@tty8
	    getty@tty9
	    getty@tty11
	);

	foreach (@tty_list) {
	    system("systemctl start $_");
	}

	loginfo("[daisy_start] gettys restarted");
    }

    return(1);
}

#
# Stop Daisy POS.
#
# returns
#   1 on success
#   0 if error
#
sub stop_daisy
{
    if ( ($OS eq 'RHEL5') || ($OS eq 'RHEL6') ) {
	loginfo("[stop_daisy] changing to run level 4");
	mysystem("/sbin/telinit 4");
	if ($? != 0) {
	    logerror("[stop_daisy] could not change to run level 4");
	    return(0);
	}
    }

    if ($OS eq 'RHEL7') {
	my @tty_list = qw(
	    getty@tty1
	    getty@tty2
	    getty@tty3
	    getty@tty4
	    getty@tty5
	    getty@tty6
	    getty@tty7
	    getty@tty8
	    getty@tty9
	    getty@tty11
	);

	foreach (@tty_list) {
	    system("systemctl stop $_");
	}

	showinfo("[stop_daisy] gettys stopped");
    }

    loginfo("[stop_daisy] stopping all Daisy processes");
    if (-f "$POSDIR/utils/killemall") {
	mysystem("$POSDIR/utils/killemall");
	if ($? != 0) {
	    logerror("[stop_daisy] could not stop Daisy processes");
	    return(0);
	}
    }

    sleep(5);	# give the system a chance to settle down

    return(1);
}


exit(main());


__END__


#################################################
#########   D O C U M E N T A T I O N   #########
#################################################

=pod


=head1 NAME

I<applypatch.pl> - driver script for applying Daisy patch files


=head1 VERSION

This documentation refers to version: $Revision: 1.40 $


=head1 SYNOPSIS

applypatch.pl B<--help>

applypatch.pl B<--version>

applypatch.pl [B<--daisy>] [B<--[no]restart>] [B<--log-stderror>] [B<--harden-linux>] patchfile ...


=head1 DESCRIPTION

The F<applypatch.pl> script is used to apply "patch files"
on Daisy systems.
The format of "patch files" are simply compressed tar archives.
They may contain one or more files that are to replace corresponding files on
a Daisy POS installation and may also contain an optional script named
F<postpatch.pl> that is patch specific and if present,
is run after successfully applying the patch.
For example, the a patch file can contain new directory files,
matching Daisy programs - which are binaries and are platform specific -
to handle the directory files, and
a corresponding post patch script.
The patch application process can handle installation on a
Daisy POS that is running or it can stop the POS at the
beginning of the process and start it at the end of the
process.

After successfully applying the patch and running the post patch script
(if it exists), the F<dsyperms.pl> script is run.
After running the F<dsyperms.pl> script, the F<harden_linux.pl> script
is run if the B<--harden-linux> command line option is specified.

Upon successful execution, the script exits with an exit status of 0.
See the section on B<EXIT STATUS> for unsuccessful exits.

=head2 Command Line Options

=over 4

=item B<--version>

Output the version number of the script and exit.

=item B<--help>

Output a short help message and exit.

=item B<--daisy>

Optional, if specified, assume a Daisy POS system, but
the default Daisy database directory must exist or
the script will exit with a non-zero exit status.

=item B<--[no]restart>

Optional, with a default value of true, if specified, the Daisy POS will
be stopped before the patch is applied, and started after the patch
has been applied.
To prevent the Daisy POS from being stopped, use the "no" prefix.

=item B<--log-stderror>

Optional, with a default value of false, if specified,
any error output from a command run via the
Perl "system" builtin will be logged to the standard log file.

=item B<--harden-linux>

Optional, if specified, run the OSTools F<harden_linux.pl> script
after applying the patch.


=back


=head1 EXAMPLES

Normally, the F<applypatch.pl> script is used with the Kaseya system
to install patches on a system.
NOTE, any script or program run by Kaseya will be run as "root".
The command line used by Kaseya to install a directory patch would be:

    applypatch.pl --norestart --log-stderr SEP14FD_ALL.patch


=head1 FILES

=over 4

=item F</d/daisy/config>

Daisy config directory.

=item F</d/daisy/log>

Daisy log directory.

=item F</d/daisy/log/RTI-Patches.log>

The F<applypatch.pl> log file.

=item F</d/daisy/backups>

The original instances of files to be replaced by the
contents of the patch file are copied to this directory.

=item F<postpatch.pl>

An optional script that may be contained in a patch file.

=back


=head1 EXIT STATUS

=over 4

=item Exit status 0 ($EXIT_OK)

Successful completion.

=item Exit status 1 ($EXIT_COMMAND_LINE)

In general, there was an issue with the syntax of the command line.

=item Exit status 2 ($EXIT_MUST_BE_ROOT)

The script must run as root or with sudo(1).

=item Exit status 3 ($EXIT_PATCH_VALIDATION)

The patch file did not pass validation.

=item Exit status 8 ($EXIT_POSTPATCH_CLEANUP)

Previous instances of the post patch script could not be removed.

=item Exit status 9 ($EXIT_STOP_DAISY)

Could not stop the Daisy POS.

=item Exit status 10 ($EXIT_START_DAISY)

Could not start the Daisy POS.

=item Exit status 11 ($EXIT_PATCH_FAILED)

Could not successfully apply the patch file.

=item Exit status 12 ($EXIT_POSTPATCH)

An error occurred while running the post patch script
contained in the patch file.

=item Exit status 15 ($EXIT_PERMS)

The F<dsyperms.pl> script returned a non-zero exit status.

=item Exit status 16 ($EXIT_HARDEN_LINUX)

The F<harden_linux.pl> script returned a non-zero exit status.

=back


=head1 SEE ALSO

I<postpatch.pl>, I<dsyperms.pl>, I<harden_linux.pl>


=cut
