#!/usr/local/bin/perl
#
#  Copyright (c) 2004, SWITCH - Teleinformatikdienste fuer Lehre und Forschung
#  All rights reserved.
#
#  Redistribution and use in source and binary forms, with or without
#  modification, are permitted provided that the following conditions are met:
#
#   * Redistributions of source code must retain the above copyright notice,
#     this list of conditions and the following disclaimer.
#   * Redistributions in binary form must reproduce the above copyright notice,
#     this list of conditions and the following disclaimer in the documentation
#     and/or other materials provided with the distribution.
#   * Neither the name of SWITCH nor the names of its contributors may be
#     used to endorse or promote products derived from this software without
#     specific prior written permission.
#
#  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
#  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
#  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
#  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
#  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
#  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
#  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
#  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
#  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
#  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
#  POSSIBILITY OF SUCH DAMAGE.
#
#  $Author: peter $
#
#  $Id: NfSen.pm 57 2006-07-27 11:36:40Z peter $
#
#  $LastChangedRevision: 57 $

package NfSen;

use strict;
use POSIX qw(strftime);
use Fcntl qw(:DEFAULT :flock);
use Time::Local;
use NfSenRRD;
use Log;

my @ProfileKeys = (
	'_comment_',	# Array of comment lines starting with '#'
	'name', 		# name of profile
	'tstart', 		# Start time of profile
	'tend', 		# End time of profile
	'sourcelist',	# ':' separated list of sources
	'filter',		# Name of filter file
	'updated',		# Time of last update 
	'expire',		# Max lifetime of profile data in hours 0 = no expire time
	'maxsize',		# Max size of profile in bytes	0 = no limit
	'size',			# Current size of profile in bytes
	'type',			# Profile type: 0: life, 1: history profile, 2: continuous profile
	'locked',		# somebody is working on this profile
	'status',		# status of profile
);

#
# Parse Expire string:
# Valid format:
#	<num> d|day|days	number of days
#	<num> h|hour|hours	number of hours
#	<num>				number of hours
# any combination of days and hours is valid
# returns the number of hours ( days converted to hours )
# for new expire value. '0' for no expire value
# -1 if parsing string failed.
sub ParseExpire {
	my $expire = shift;
	
	$expire = lc $expire;
	$expire =~ s/day[s]{0,1}/d/;
	$expire =~ s/hour[s]{0,1}/h/;
	$expire =~ s/^\s*(\d+)\s*$/$1h/;

	my $lifetime = undef;	# Get overwritten if a valid value is found

	my ( $value ) = $expire =~ /(\d+)\s*d\b/i;
	if ( defined $value ) {
		$lifetime = 24 * $value;
	} 
	( $value ) = $expire =~ /(\d+)\s*h\b/i;
	if ( defined $value ) {
		$lifetime = defined $lifetime ? $lifetime + $value : $value;
	}

	return defined $lifetime ? $lifetime : -1;

} # End of ParseExpire


#
# Parse Max size string:
# Valid format:
#	<num> k|kb			number of KB for profile
#	<num> m|mb			number of MB for profile
#	<num> g|gb			number of GB for profile
#	<num> t|tb			number of TB for profile
#	<num>				number of MB for profile
# returns the number of bytes for the profile.
# '0' for no expire value
# -1 if parsing string failed.
sub ParseMaxsize {
	my $maxsize = shift;

	$maxsize = lc $maxsize;
	$maxsize =~ s/^\s*(\d+\.{0,1}\d*)\s*$/$1m/;
	my ($value, $scale) = $maxsize =~ /\s*(\d+\.{0,1}\d*)\s*([kmgt]{1})b{0,1}\b/;
	if ( defined $value ) {
		$value *= 1024 if $scale eq 'k';
		$value *= 1024 * 1024 if $scale eq 'm';
		$value *= 1024 * 1024 * 1024 if $scale eq 'g';
		$value *= 1024 * 1024 * 1024 * 1024 if $scale eq 't';
	} else {
		$value = -1;
	}

	return $value;
} # End of ParseMaxsize

#
# Tests string for a valid date.
# Dates are recongized valid between 1.1.1970 00:00 and 19.1.2038 14:14
#	The date may include '-' for better readability
#	Format: yyyymmddHHmm, yyyy-mm-dd-HH-MM
sub ValidISO {
	my $time = shift;

	# strinp '-' chars
	$time =~ s/\-//g;

	# any non - number char is invalid
	return 0 if $time =~ /[^\d]+/;

	# outside required timeframe
	return 0 if $time < 197001010000 || $time > 203801191414;

	return 1;

} # End of ValidISO

#
# Convert a a UNIX time value into ISO format yyyymmddHHMM
sub UNIX2ISO {
	my $time = shift;

    my @tmp 	= localtime($time);
	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime($time);

	$year += 1900;
	my $tstring = $year;
	$mon++;
	$tstring	.= $mon  < 10 ? "0" . $mon  : $mon;
	$tstring	.= $mday < 10 ? "0" . $mday : $mday;
	$tstring	.= $hour < 10 ? "0" . $hour : $hour;
	$tstring	.= $min  < 10 ? "0" . $min  : $min;

    return $tstring;

} # End of UNIX2ISO

#
# Create a more readable value scaled in TB, GB, MB, and KB
sub ScaledValue {
	my $value = shift;

	my $scale = 1024 * 1024 * 1024 * 1024;
	if ( $value >=  $scale ) {
		return sprintf "%.1f TB", $value / $scale;
	} elsif ( $value >= ( $scale /= 1024 ) ) {
		return sprintf "%.1f GB", $value / $scale;
	} elsif ( $value >= ( $scale /= 1024 ) ) {
		return sprintf "%.1f MB", $value / $scale;
	} elsif ( $value >=  1024 ) {
		return sprintf "%.1f KB", $value / 1024;
	} else {
		return "$value";
	}

} # End of ScaledValue

# Convert a an ISO time value into UNIX format
sub ISO2UNIX {
	my $isotime = shift;

	if ( !ValidISO($isotime) ) {
		return 0;
	}

	$isotime =~ s/\-//g;	# allow '-' to structur time string

    # 2004 02 13 12 45 /
	my $sec = 0;
	my ( $year, $mon, $mday, $hour, $min ) = $isotime =~ /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})/;
	$mon--;

	# round down to nearest 5 min slot
	my $diff = $min % 5;
	if ( $diff ) {
		$min -= $diff;
	}

	my $unixtime = Time::Local::timelocal($sec,$min,$hour,$mday,$mon,$year);

    return $unixtime;

} # End of ISO2UNIX

sub SubdirHierarchy {
	my $t 		= shift;	# UNIX time format

	my $layout	= $NfConf::SUBDIRLAYOUT;

	if ( !defined $layout || $layout == 0 ) {
		return '';
	}

	my @subdir_def = (
		"",
		"%Y/%m/%d",
		"%Y/%m/%d/%H",
		"%Y/%W/%u",
		"%Y/%W/%u/%H",
		"%Y/%j",
		"%Y/%j/%H",
		"%F",
		"%F/%H"
	);

	if ( $layout > $#subdir_def ) {
		return undef;
	}

	return strftime $subdir_def[$layout], localtime($t);

} # End of SubdirHierarchy

sub EmptyProfile {

	my %empty;
	# Make sure all fields are set
	foreach my $key ( @ProfileKeys ) {
		$empty{$key} = undef;
	}

	$empty{'_comment_'}		= [];
	$empty{'name'}			= undef;
	$empty{'tstart'}		= 0;
	$empty{'tend'}			= 0;
	$empty{'sourcelist'}	= '';
	$empty{'filter'}		= '';
	$empty{'updated'}		= 0;
	$empty{'expire'}		= 0;
	$empty{'maxsize'}		= 0;
	$empty{'size'}			= 0;
	$empty{'type'}			= 0;
	$empty{'locked'}		= 0;
	$empty{'status'}		= 'empty';

	return %empty;

} # End of EmptyProfile

sub InfoFileExists {
	my $name = shift;

	return -f "$NfConf::PROFILESTATDIR/$name/profile.dat" ? 1 : 0;

} # End of InfoFileExists

sub ProfileList {

	opendir(PROFILEDIR, "$NfConf::PROFILESTATDIR" ) or
		$Log::ERROR = "Can't open profiles directory: $!", 
		return [];

	my @AllProfiles = grep {  -f "$NfConf::PROFILESTATDIR/$_/profile.dat" && $_ !~ /^\./ } 
						readdir(PROFILEDIR);

	closedir PROFILEDIR;

	$Log::ERROR = undef;
	return @AllProfiles

} # End of ProfileList

#
# Returns the profile info hash, if successfull
# else returns EmptyProfile and sets Log::ERROR
sub ReadProfile {
	my $name = shift;

	my %profileinfo = EmptyProfile();
	my $description = [];

	$Log::ERROR    = undef;
	my %empty      = EmptyProfile();
	$empty{'name'} = $name;
	
	if ( ! -f "$NfConf::PROFILESTATDIR/$name/profile.dat" ) {
		$Log::ERROR = "Profile info file missing for profile: '$name'.",
		return %empty;
	}

	sysopen(ProFILE, "$NfConf::PROFILESTATDIR/$name/profile.dat", O_RDONLY) or
		$Log::ERROR = "Can't open profile data file for profile: $name: $!",
		return %empty;

	flock ProFILE, LOCK_SH;

	while ( <ProFILE> ) {
		chomp;
		next if $_ =~ /^\s*$/;	# Skip empty lines
		if ( $_ =~ /^\s*#/ ) {
			push @$description, "$_\n";
			next;
		}
		my ($key, $value) = split /\s*=\s*/;
		if ( !defined $key ) {
			warn "Error reading profile information. Unparsable line: '$_'";
			$Log::ERROR = "Error reading profile information. Unparsable line: '$_'";
		} 
		if ( exists $empty{"$key"} ) {
			$profileinfo{$key} = $value;
		} else {
			warn "Error reading profile information. Unknown key: '$key'";
			$Log::ERROR =  "Error reading profile information. Unknown key: '$key'";
		}
	}
	$profileinfo{'_comment_'} = $description;
	flock ProFILE, LOCK_UN;
	close ProFILE;

	# Make sure all fields are set
	foreach my $key ( @ProfileKeys ) {
		next if defined $profileinfo{$key};
		$profileinfo{$key} = $empty{$key};
		warn "Empty key '$key' in profile '$name'";
	}

	if ( $profileinfo{'name'} ne $name ) {
		$Log::ERROR = "Corrupt stat file. Needs to be rebuilded";
		return %empty;
	}

	if ( defined $Log::ERROR ) {
		return %empty;
	}

	return %profileinfo;

} # End of ReadProfile

#
# Returns the profile info hash, if successfull
# else if already locked, return EmptyProfile with 'locked' = 1
# else returns EmptyProfile and sets Log::ERROR
sub LockProfile {
	my $name = shift;

	my %profileinfo;
	my $description = [];

	$Log::ERROR    = undef;
	my %empty      = EmptyProfile();
	$empty{'name'} = $name;

	if ( ! -f "$NfConf::PROFILESTATDIR/$name/profile.dat" ) {
		$Log::ERROR = "Profile info file missing for profile: '$name'.",
		return %empty;
	}

	sysopen(ProFILE, "$NfConf::PROFILESTATDIR/$name/profile.dat", O_RDWR) or
		$Log::ERROR = "Can't open profile data file for profile: $name: $!",
		return %empty;

	flock ProFILE, LOCK_EX;

	while ( <ProFILE> ) {
		chomp;
		next if $_ =~ /^\s*$/;	# Skip empty lines
		if ( $_ =~ /^\s*#/ ) {
			push @$description, "$_\n";
			next;
		}
		my ($key, $value) = split /\s*=\s*/;
		if ( !defined $key ) {
			warn "Error reading profile information. Unparsable line: '$_'";
		} 
		if ( exists $empty{"$key"} ) {
			$profileinfo{$key} = $value;
		} else {
			warn "Error reading profile information. Unknown key: '$key'";
		}
	}
	$profileinfo{'_comment_'} = $description;

	# Make sure all fields are set
	foreach my $key ( @ProfileKeys ) {
		next if defined $profileinfo{$key};
		$profileinfo{$key} = $empty{$key};
		warn "Empty key '$key' in profile '$name'";
	}

	if ( $profileinfo{'name'} ne $name ) {
		flock ProFILE, LOCK_UN;
		close ProFILE;
		$Log::ERROR = "Corrupt stat file. Needs to be rebuilded";
		return %empty;
	}

	# Is it already locked?
	if ( $profileinfo{'locked'} ) {
		flock ProFILE, LOCK_UN;
		close ProFILE;
		$empty{'locked'} = 1;
		return %empty;
	}

	$profileinfo{'locked'} = 1;
	seek ProFILE, 0,0;

	if ( scalar @$description > 0 ) {
		print ProFILE @{$profileinfo{'_comment_'}};
	}
	foreach my $key ( @ProfileKeys ) {
		next if $key eq '_comment_';
		if ( !defined $profileinfo{$key} ) {
			print ProFILE "$key = \n";
		} else {
			print ProFILE "$key = $profileinfo{$key}\n";
		}
	}

	my $fpos = tell ProFILE;
	truncate ProFILE, $fpos;

	flock ProFILE, LOCK_UN;
	if ( !close ProFILE ) {
		$Log::ERROR = "Failed to close profileinfo of profile '$name': $!.",
		return %empty;
	}

	return %profileinfo;

} # End of LockProfile

sub WriteProfile {
	my $profileref = shift;

	my $name = $$profileref{'name'};
	
	if ( length $name == 0 ) {
		$Log::ERROR = "While writing profile stat file. Corrupt data ref",
		return undef;
	}

	$Log::ERROR = undef;
	sysopen(ProFILE, "$NfConf::PROFILESTATDIR/$name/profile.dat", O_RDWR|O_CREAT) or
		$Log::ERROR = "Can't open profile data file for profile $name: $!\n",
		return undef;

	flock ProFILE, LOCK_EX;
	seek ProFILE, 0,0;

	print ProFILE @{$$profileref{'_comment_'}};
	foreach my $key ( @ProfileKeys ) {
		next if $key eq '_comment_';
		if ( !defined $$profileref{$key} ) {
			print ProFILE "$key = \n";
		} else {
			print ProFILE "$key = $$profileref{$key}\n";
		}
	}

	my $fpos = tell ProFILE;
	truncate ProFILE, $fpos;

	flock ProFILE, LOCK_UN;
	if ( !close ProFILE ) {
		$Log::ERROR = "Failed to close profileinfo of profile '$name': $!.",
		return undef;
	}

	return 1;

} # End of WriteProfile

sub EmptyStat {
	my $statinfo = {};
	foreach my $db ( @NfSenRRD::RRDdb ) {
		$$statinfo{lc $db} = -1;
	}
	return $statinfo;

} # End of EmptyStat

sub ReadStatInfo {
	my $profileref	= shift;
	my $source 		= shift;
	my $subdirs		= shift;
	my $tstamp		= shift;

	$Log::ERROR = undef;
	my $statinfo = EmptyStat();
	my $name = $$profileref{'name'};
	
	if ( !open(NFDUMP, "$NfConf::PREFIX/nfdump -I -r $NfConf::PROFILEDATADIR/$name/$source/$subdirs/nfcapd.$tstamp |") ) {
		$Log::ERROR = "$name/$source/$subdirs/nfcapd.$tstamp: Can't get statinfo!";
		return $statinfo;
	} else {
		my ( $label, $value );
		while ( my $line = <NFDUMP> ) {
			chomp $line;
			( $label, $value ) = split ':\s', $line;

			# we use everywhere 'traffic' instead of bytes
			$label =~ s/bytes/traffic/i;

			$$statinfo{lc $label} = $value;
		}
		close NFDUMP;
	}

	return $statinfo;

} # End of ReadStatInfo

sub root_process {

	my $run_as   = $<;
	my ($login,$pass,$root_id,$gid) = getpwnam('root');

	return $run_as == $root_id;

} # End of root_process

sub DropPriv {
	my $requested_user = shift;

	$Log::ERROR = undef;
	if ( !defined $requested_user ) {
		$requested_user = $NfConf::USER;
	}

	my $run_as  = $<;

	my ($login,$pass,$www_uid,$gid) = getpwnam($NfConf::WWWUSER);
	if ( !defined $login ) {
		$Log::ERROR = "NFSEN user '$NfConf::WWWUSER' not found on this system";
		return undef;
	}

	my $www_gid;
	if ( defined $NfConf::WWWGROUP ) {
		if ( ! ($www_gid  = getgrnam($NfConf::WWWGROUP)) ) {
			$Log::ERROR = "NFSEN group '$NfConf::WWWGROUP' not found on this system";
			return undef;
		}
	}
	
	# when our uid == configured www user uid
	if ( $run_as == $www_uid ) {
		return 1;
	}

	# Try USER
	my $nf_uid;
	($login,$pass,$nf_uid,$gid) = getpwnam($NfConf::USER);
	if ( !defined $login ) {
		$Log::ERROR = "NFSEN user '$NfConf::USER' not found on this system";
		return undef;
	}

	# when our uid == configured netflow user uid
	if ( $run_as == $nf_uid ) {
		return 1;
	}

	# we can change our uid/gid only as root
	if ( !root_process() ) {
		$Log::ERROR = "Want to run as user 'root', '$NfConf::USER' or '$NfConf::WWWUSER'. Current uid is '$run_as'";
		return undef;
	}

	$( = $www_gid;
	$) = "$www_gid $(";

	my $uid = $requested_user eq $NfConf::WWWUSER ? $www_uid : $nf_uid;
	$> = $uid;

	if( $> != $uid ){
		$Log::ERROR = "Couldn't become uid \"$uid\"";
		return undef;
	}
	my @groups = split ' ', $);

	if( $www_gid != shift @groups ){
		$Log::ERROR = "Couldn't become gid \"$www_gid\"";
		return undef;
	}
	# print "Priv: $uid, $www_gid\n";

	return 1;

} # End of DropPriv

#
# Verify a given nfdump filter
# If filter syntx ok returns an empty array @out, otherwise
# @out contains the error message.
sub VerifyFilter {
	my $filter = shift;

	my @out;
	if ( !open(FILTER, "$NfConf::PREFIX/nfdump -Z '$filter' 2>&1 |") ) {
		push @out, "Can't run filter check: $!";
	} else {
		while ( <FILTER> ) {
			push @out, $_;
		}
		close FILTER;
	}

	# unlink $_tmp;
	return @out;

} # End of VerifyFilter

sub QueryFrontends {
	my $socket  = shift;
	my $opts	= shift;

	foreach my $entry ( @NfConf::plugins ) {
		my $plugin	  = $$entry[1];
		if ( -f "$NfConf::FRONTEND_PLUGINDIR/${plugin}.php" ) {
			print $socket "$plugin\n";
		}
	}

	if ( defined $Log::ERROR ) {
		print $socket "ERR $Log::ERROR\n";
	} else {
		print $socket "OK Listing complete\n" if exists $$opts{'quiet'};
	}

	return;

} # End of QueryFrontends

# Query user for yes or no
sub UserInput {
	my $text = shift;

	my $answer = '';
	while ( $answer !~ /^[y|n]$/ ) {
		print "$text [y/n] ";
		$answer = <STDIN>;
		$answer =~ s/^yes$/y/i;
		$answer =~ s/^no$/n/i;
		chomp $answer;
	}
	return $answer;
} # End of UserInput

1;
