#!/usr/bin/perl
#
# THIS SOFTWARE IS NOT PART OF NMIS AND IS COPYRIGHTED, PROTECTED AND LICENSED
# BY OPMANTEK.
#
# YOU MUST NOT MODIFY OR DISTRIBUTE THIS CODE
#
# This code is NOT Open Source
#
# IT IS IMPORTANT THAT YOU HAVE READ CAREFULLY AND UNDERSTOOD THE END USER
# LICENSE AGREEMENT THAT WAS SUPPLIED WITH THIS SOFTWARE.   BY USING THE
# SOFTWARE  YOU ACKNOWLEDGE THAT (1) YOU HAVE READ AND REVIEWED THE LICENSE
# AGREEMENT IN ITS ENTIRETY, (2) YOU AGREE TO BE BOUND BY THE AGREEMENT, (3)
# THE INDIVIDUAL USING THE SOFTWARE HAS THE POWER, AUTHORITY AND LEGAL RIGHT
# TO ENTER INTO THIS AGREEMENT ON BEHALF OF YOU (AS AN INDIVIDUAL IF ON YOUR
# OWN BEHALF OR FOR THE ENTITY THAT EMPLOYS YOU )) AND, (4) BY SUCH USE, THIS
# AGREEMENT CONSTITUTES BINDING AND ENFORCEABLE OBLIGATION BETWEEN YOU AND
# OPMANTEK LTD.
#
# Opmantek is a passionate, committed open source software company - we really
# are.  This particular piece of code was taken from a commercial module and
# thus we can't legally supply under GPL. It is supplied in good faith as
# source code so you can get more out of NMIS.  According to the license
# agreement you can not modify or distribute this code, but please let us know
# if you want to and we will certainly help -  in most cases just by emailing
# you a different agreement that better suits what you want to do but covers
# Opmantek legally too.
#
# contact opmantek by emailing code@opmantek.com
#
# All licenses for all software obtained from Opmantek (GPL and commercial)
# are viewable at http://opmantek.com/licensing
#
# *****************************************************************************

use strict;
use Data::Dumper;
use File::Basename;
use File::Temp;
use POSIX qw();
use Cwd;
use FindBin;
use IO::Handle;

die "The OMK Support Tool must be run with root privileges, terminating now.\n"
		if ($> != 0);

my $VERSION = "1.6.0";

my $usage = "Opmantek OMK Support Tool Version $VERSION\n
Usage: ".basename($0)." action=collect\n
action=collect: collect general support info in an archive file\n\n";
my $args = getArgs(@ARGV);

die $usage if (!defined $args->{action} or $args->{action} ne "collect");

my $configfile = $args->{config} || "opCommon.nmis";
my $maxzip = $args->{maxzipsize} || 15*1024*1024; # 15meg
my $maxlogsize = $args->{maxlogsize} || 8*1024*1024; # 8 meg for individual log files
my $tail = 7000;																# last 7000 lines

# first, load the global config
my $globalconf = loadOmkConfTable(conf => $configfile, 
																	dir => "$FindBin::RealBin/../conf");
die "ERROR: cannot read the configuration file $FindBin::/RealBin/../conf/$configfile!\n" 
		if (ref($globalconf) ne "HASH" or !keys %$globalconf);

# make tempdir
my $td = File::Temp::tempdir("/tmp/opmantek-support.XXXXXX", CLEANUP => 1);

if ($args->{action} eq "collect")
{
		# collect evidence
		my $timelabel = POSIX::strftime("%Y-%m-%d-%H%M",localtime);
		my $targetdir = "$td/omk-collect.$timelabel";
		mkdir($targetdir);
		print "collecting support evidence...\n";
		my $status = collect_evidence($targetdir,$args);
		die "failed to collect evidence: $status\n" if ($status);

		# nmis and its support tool found, run that as well
		my $nmiszfn;
		if (-d $globalconf->{'nmis_dir'} && -f $globalconf->{'nmis_dir'}."/admin/support.pl" && !$args->{no_other_tools})
		{
			print "\nFound local NMIS installation with NMIS support tool, which will be run next:\n\n";

			# check uninteresting old nmis-support zips
			opendir(D, "/tmp");
			my @oldones = grep(/^nmis-support-.*\.(zip|tar.gz|tgz)$/i, readdir(D));
			closedir(D);

			(system($globalconf->{'nmis_dir'}."/admin/support.pl action=collect no_system_stats=1 no_other_tools=1") >> 8)
					and warn "NMIS support tool failed: $!\n";

			opendir(D, "/tmp");
			my @newones = grep(/^nmis-support-.*\.(zip|tar.gz|tgz)$/i, readdir(D));
			closedir(D);
			for my $cand (@newones)
			{
				if (!grep($cand eq $_, @oldones))
				{
					$nmiszfn = "/tmp/$cand";
					last;
				}
			}
		}

		print "\nEvidence collection complete, zipping things up...\n";

		# do we have zip? or only tar+gz?
		my $canzip=0;
		$status = system("zip --version >/dev/null 2>&1");
		$canzip=1 if (POSIX::WIFEXITED($status) && !POSIX::WEXITSTATUS($status));

		my $zfn = "/tmp/omk-support-$timelabel.".($canzip?"zip":"tgz");

		# zip mustn't become too large, hence we possibly tail/truncate some or all log files
		opendir(D,"$targetdir/logs") or return "can't read $targetdir/logs dir: $!";
		my @shrinkables = map { "$targetdir/logs/$_" } (grep($_ !~ /^\.{1,2}$/, readdir(D)));
		closedir(D);
		while (1)
		{
				# test zip, shrink logfiles, repeat until small enough or out of shrinkables
				my $curdir = getcwd;
				chdir($td);							# so that the zip file doesn't have to whole /tmp/this/n/that/ path in it
				if ($canzip)
				{
					$status = system("zip","-q","-r",$zfn, "omk-collect.$timelabel");
				}
				else
				{
					$status = system("tar","-czf",$zfn,"omk-collect.$timelabel");
				}
				chdir($curdir);

				die "cannot create support zip file $zfn: $!\n"
						if (POSIX::WEXITSTATUS($status));
				last if (-s $zfn < $maxzip);

				# hmm, too big: shrink the log files one by one until the size works out
				unlink($zfn);
				print "zipfile too big, trying to shrink some logfiles...\n";
				if (my $nextfile = pop @shrinkables)
				{
						$status = shrinkfile($nextfile,$tail);
						die "shrinking of $nextfile failed: $status\n" if ($status);
				}
				else
				{
						# nothing left to try :-(
						die "\nPROBLEM: cannot reduce zip file size any further!\nPlease rerun $0 with maxzipsize=N higher than $maxzip.\n";
				}
		}

		print "\nAll done.\n\nCollected system information is in $zfn\n";
		print "NMIS information is in $nmiszfn\n\n" if ($nmiszfn);
		print "Please include ".($nmiszfn? "these zip files": "this zip file"). " when you contact
the NMIS Community or the Opmantek Team.\n\n";
}

# remove tempdir (done automatically on exit)
exit 0;


# shrinks given file to the last maxlines lines
# returns undef if ok, error message otherwise
sub shrinkfile
{
		my ($fn,$maxlines)=@_;

		my $tfn = File::Temp->new(DIR => $td, UNLINK => 1 );
		system("tail -$maxlines $fn > $tfn") == 0 or return "can't tail $fn: $!";
		rename($tfn,"$fn.truncated") or return "couldn't rename $tfn to $fn.truncated: $!";
		unlink($fn) or return "couldn't remove $fn: $!\n";

		return undef;
}

# collects logfiles, config, generic information from var
# and parks all of that in/beneath target dir
# the target dir must already exist
#
# args: no_system_stats, optional; if set, skip the system stats
# returns nothing if ok, error message otherwise
sub collect_evidence
{
		my ($targetdir,$args) = @_;
		my $basedir = $globalconf->{'<omk_base>'};

		mkdir("$targetdir/system_status");
		# dump a recursive file list, ls -haRH does NOT work as it won't follow links except given on the cmdline
		system("find -L $basedir -type d | xargs ls -laH > $targetdir/system_status/filelist.txt") == 0
				or warn "can't list nmis dir: $!\n";

		# get md5 sums of the relevant installation files
		print "please wait while we collect file status information...\n";
		system("find -L $basedir -type f|grep -v -e /.git/ -e /logs/|xargs md5sum -- >$targetdir/system_status/md5sum 2>&1");

		# verify the relevant users and groups, dump groups and passwd (not shadow)
		system("cp","/etc/group","/etc/passwd","$targetdir/system_status/");
		system("id nmis >$targetdir/system_status/nmis_userinfo 2>&1");
		system("id apache >$targetdir/system_status/web_userinfo 2>&1");
		system("id www-data >>$targetdir/system_status/web_userinfo 2>&1");
		# dump the process table
		system("ps ax >$targetdir/system_status/processlist.txt") == 0
				or warn "can't list processes: $!\n";
		# dump the memory info, free
		system("cp","/proc/meminfo","$targetdir/system_status/meminfo") == 0
				or warn "can't save memory information: $!\n";
		chmod(0644,"$targetdir/system_status/meminfo"); # /proc/meminfo isn't writable
		system("free >> $targetdir/system_status/meminfo");

		system("df >> $targetdir/system_status/disk_info");
		system("mount >> $targetdir/system_status/disk_info");

		system("uname -av > $targetdir/system_status/uname");
		mkdir("$targetdir/system_status/osrelease");
		system("cp -a /etc/*release /etc/*version $targetdir/system_status/osrelease/ 2>/dev/null");

		if (!$args->{no_system_stats})
		{
			print "please wait while we gather statistics for about 15 seconds...\n";
			# dump 5 seconds of vmstat, two runs of top, 5 seconds of iostat
			# these tools aren't necessarily installed, so we ignore errors
			system("vmstat 1 5 > $targetdir/system_status/vmstat");
			system("top -b -n 2 > $targetdir/system_status/top");
			system("iostat -kx 1 5 > $targetdir/system_status/iostat");
		}

		system("date > $targetdir/system_status/date");

		# copy /etc/hosts, /etc/resolv.conf, interface and route status
		system("cp","/etc/hosts","/etc/resolv.conf","/etc/nsswitch.conf","$targetdir/system_status/") == 0
				or warn "can't save dns configuration files: $!\n";
		system("/sbin/ifconfig -a > $targetdir/system_status/ifconfig") == 0
				or warn "can't save interface status: $!\n";
		system("/sbin/route -n > $targetdir/system_status/route") == 0
				or warn "can't save routing table: $!\n";

		# capture the cron files, root's and nmis's tabs
		mkdir("$targetdir/system_status/cron");
		system("cp -a /etc/cron* $targetdir/system_status/cron") == 0
				or warn "can't save cron files: $!\n";

		system("crontab -u root -l > $targetdir/system_status/cron/crontab.root 2>/dev/null");
		system("crontab -u nmis -l > $targetdir/system_status/cron/crontab.nmis 2>/dev/null");

		# capture the apache configs
		my $apachehome = -d "/etc/apache2"? "/etc/apache2": -d "/etc/httpd"? "/etc/httpd" : undef;
		if ($apachehome)
		{
			my $apachetarget = "$targetdir/system_status/apache";
			mkdir ($apachetarget) if (!-d $apachetarget);
			# on centos/RH there are symlinks pointing to all the apache module binaries, we don't
			# want these (so  -a or --dereference is essential)
			system("cp -a $apachehome/* $apachetarget");
			# and save a filelist for good measure
			system("ls -laHR $apachehome > $apachetarget/filelist");
		}


		# copy the install log if there is one
		if (-f "$basedir/install.log")
		{
				system("cp","$basedir/install.log",$targetdir) == 0
						or warn "can't copy install.log: $!\n";
		}
		# ditto the manifest
		if (-f "$basedir/manifest")
		{
			system("cp","$basedir/manifest", $targetdir) == 0
					or warn "can't copy manifest: $!\n";
		}


		# copy all known stuff in conf, but DON'T recurse, that might pick up huge amounts
		# of stray stuff!
		mkdir("$targetdir/conf",0755);
		system("cp $basedir/conf/* $targetdir/conf/ 2>/dev/null"); # this will complain about dirs
		# a bunch of dirs we know are safe
		for my $dir ( ( map { "$basedir/conf/$_" } (qw(charts trend phrasebooks config_parsers compliance_policies
config_sets.d nmisd parser_plugins))),
									$globalconf->{'<omk_schedule>'} )
		{
			next if (!-e $dir);
			system("cp","-r",$dir,"$targetdir/conf/") == 0
					or warn "can't copy $dir to $targetdir/conf/: $!\n";
		}

		# collect log files (current and newest .N rotated)
		mkdir("$targetdir/logs");
		my @logfiles;
		opendir(D, $globalconf->{"<omk_logs>"});
		@logfiles = map { $globalconf->{"<omk_logs>"}."/$_" } (grep /\.log(\.1)?$/, readdir(D));
		closedir(D);

		# capture relevant mongo status data, if the mongo shell client is a/v,
		# and if a mongodb is configured and reachable
		# note: this currently queries only the ONE generic database server, not any
		# possibly different per-app mongodbs.
		my $status = system("type mongo >/dev/null 2>&1");
		if (POSIX::WIFEXITED($status) && !POSIX::WEXITSTATUS($status))
		{
			# capture the mongo log (or at least part of it)
			push @logfiles, "/var/log/mongodb/mongod.log" if (-f "/var/log/mongodb/mongod.log");

			# figure out all the databases we might be interested in
			my %dbstocheck;
			for my $k (keys %$globalconf)
			{
				$dbstocheck{$globalconf->{$k}} = 1 if ($k =~ /^([a-zA-Z]+_)?db_name$/);
			}

			open("F", "|mongo --username $globalconf->{db_username} --password $globalconf->{db_password} --host $globalconf->{db_server} --port $globalconf->{db_port} admin >$targetdir/system_status/mongo_status 2>&1") or warn "can't run mongo: $!\n";
			F->autoflush(1);

			# fire the most essential commands blindly, don't want the complexity of ipc::run here
			for my $cmd ("show databases", "db.hostInfo()", "db.serverStatus()",
									 "show users", "show roles")
			{
				print F "print('--- $cmd ---')\n$cmd\n";
			}
			for my $dbname (sort keys %dbstocheck)
			{
				print F "print('--- changing to db $dbname ---')\nuse $dbname\n";
				for my $cmd ("db.stats()", "db.printCollectionStats()",
										 "db.getCollectionInfos()", "show users", "show roles")
				{
					print F "print('--- $cmd on $dbname ---')\n$cmd\n";
				}
				# finding and showing index details is a bit more work
				print F 'var known = db.getCollectionNames(); for(var i=0, len=known.length; i < len; i++) { print("--- examining collection "+known[i]+" ---"); var r = db.getCollection(known[i]).stats(); print(tojson(r)); }'."\n";
			}
			print F "exit\n";
			close(F);
		}

		for my $aperrlog ("/var/log/httpd/error_log", "/var/log/apache/error.log", "/var/log/apache2/error.log")
		{
				push @logfiles, $aperrlog if (-f $aperrlog);
		}
		for my $lfn (@logfiles)
		{
			# log files that are larger than maxlogsize are automatically tail'd
			if (-s $lfn > $maxlogsize)
			{
				warn "logfile $lfn is too big, truncating to $maxlogsize bytes.\n";
				my $targetfile=basename($lfn);
				system("tail -c $maxlogsize $lfn > $targetdir/logs/$targetfile") == 0
						or warn "couldn't truncate $lfn!\n";
			}
			else
			{
				system("cp","$lfn","$targetdir/logs") == 0
						or warn "ATTENTION: can't copy logfile $lfn: $!\n";
			}
		}


		return undef;
}

# some small helper functions that used to come from OMK::Common, but
# were cloned here because the support tool should run w/o non-core dependencies (and OMK::Common does have some)
sub getArgs 										# cloned as-is
{
  my @argue = @_;
  my (%nvp, $name, $value, $line, $i);
  for ($i=0; $i <= $#argue; ++$i) {
    if ($argue[$i] =~ /.+=/) {
      ($name,$value) = split("=",$argue[$i],2);
      $nvp{$name} = $value;
    }
    else { print STDERR "Invalid command argument: $argue[$i]\n"; }
  }
  return \%nvp;
}

sub loadOmkConfTable						# simplified version
{
  my %args = @_;

  my $conf = $args{conf};
  my $dir = $args{dir};
  my $debug = $args{debug} || 0;

  # add extension if missing
  $conf .= ".nmis" if ($conf !~ /\./);

	# read fresh config file
	my %configdata = do "$dir/$conf";
	# failed?
	return undef if (keys(%configdata) == 1 
									 && exists($configdata{''})
									 && !defined($configdata{''}));

	# convert to single level
	for my $k (keys %configdata)
	{
		next if (ref($configdata{$k}) ne "HASH");
		for my $kk (keys %{$configdata{$k}})
		{
			$configdata{$kk} = $configdata{$k}->{$kk};
		}
		delete $configdata{$k};
	}

	# check for config variables and process each config element again.
	for my $key (keys %configdata)
	{
		if ( $key =~ /^<.*>$/ )
		{
			for my $value (keys %configdata)
			{
				if ( $configdata{$value} =~ /<.*>/ )
				{
					$configdata{$value} =~ s/$key/$configdata{$key}/;
				}
			}
		}
	}
	return \%configdata;
}
