Rev 62 | Rev 76 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download | RSS feed
#!/usr/bin/env perl
use warnings;
use strict;
# sysinfo
# Author: R. W. Rodolico
# Primary client portion of sysinfo system. Will collect information about its current
# host and create a report containing the information. This report can then be processed
# by process_sysinfo.pl on the collection computer.
# output file consists of an XML file of the form:
# <sysinfo3.0.0>
# <diskinfo name='/dev/xvda3'>
# <fstype>ext3</fstype>
# <mount>/home</mount>
# <size>51606140</size>
# <used>331472</used>
# </diskinfo>
# <network name='eth0'>
# <address>192.168.1.3</address>
# <ip6address>fe80::216:3eff:fefb:4e10</ip6address>
# <ip6networkbits>64</ip6networkbits>
# <mac>00:16:3e:fb:4e:10</mac>
# <mtu>1500</mtu>
# <netmask>255.255.255.0</netmask>
# </network>
# <operatingsystem>
# <codename>squeeze</codename>
# <description>Debian GNU/Linux 6.0.4 (squeeze)</description>
# <distribution>Debian</distribution>
# <kernel>2.6.32-5-xen-686</kernel>
# <os_name>Linux</os_name>
# <os_version>Linux version 2.6.32-5-xen-686 (Debian 2.6.32-41) (ben@decadent.org.uk) (gcc version 4.3.5 (Debian 4.3.5-4) ) #1 SMP Mon Jan 16 19:46:09 UTC 2012</os_version>
# <release>6.0.4</release>
# </operatingsystem>
# <pci name='0000:00:00.0'>
# <class>RAM memory</class>
# <device>MCP55 Memory Controller</device>
# <rev>a2</rev>
# <sdevice>Device cb84</sdevice>
# <slot>0000:00:00.0</slot>
# <svendor>nVidia Corporation</svendor>
# <vendor>nVidia Corporation</vendor>
# </pci>
# <report>
# <client>Staffmasters</client>
# <date>2012-05-01 03:00</date>
# <version>2.0.0</version>
# </report>
# <software name='aptitude'>
# <description>terminal-based package manager (terminal interface only)</description>
# <version>0.6.3-3.2+squeeze1</version>
# </software>
# <system>
# <cpu_speed>1800.103</cpu_speed>
# <cpu_sub>i686</cpu_sub>
# <cpu_type>GenuineIntel</cpu_type>
# <hostname>backup.staffmasters.local</hostname>
# <last_boot>1333259809</last_boot>
# <memory>520852</memory>
# <num_cpu>1</num_cpu>
# </system>
# </sysinfo3.0.0>
#
# Version 1.3 20071104
# added capability of e-mailing the results by itself and external configuration file
# Version 1.3.1 20071110
# added du -sk to explicitly do directory sizes in 'k'. Also, fixed some documentation
# Version 1.3.3 20081104
# modified hostname to hostname -f, and allowed user to place custom value in configuration file
# also, modified to go with Debian standards in preparation to creating a debian package.
# Version 2.0 20081208
# Modified to use different libraries for different OS's in preparation to porting to Windows
# Uses different packages based on which OS it is on.
# Version 3.0 20120923
# Major revision. Most internal intelligence pulled out and put into modules and data transfer format has been changed to YAML
#
# Base system only pulls client name, machine name and machine number, all of which can be set in the configuration file
# if the value is not set, it attempts various means to determine the values and, if it fails, aborts with an error message
# client name -- REQUIRED, must come from configuration file
# machine name -- REQUIRED, if not set via conf file, attempts hostname command (hostname -f) or module getHostName
# machine number -- REQUIRED, if not set via conf file, attempts "echo `hostname -f`-clientname | md5sum" or module getSerial
# modules are stored in "configuration directory/modules" (/etc/sysinfo/modules on most Linux systems) and are processed in
# standard sort order (case sensitive).
# Module filenames may contain alpha-numeric, underscore and the period only (files containing other characters are ignored).
# Modules should set their exit code to 0 for success, and non-zero for failure
# Modules should return 0 or more tab delimited, newline terminated strings, processed as one record per line
# A module return string line is processed as follows:
# category \t [category \t ...] \t key \t value
# example:
# System \t num_cpu \t 1
# System \t Users \t root \t /root/
# (note, if non-zero exit code returned, return value is assumed to be error message and is printed to STDERR)
# sysinfo stores the result in a hash, using categories as the keys (case sensitive), thus, the above results in
# $store{'System'}{'num_cpu'} = '1';
# $store{'System'}{'Users'}{'root'} = '/root';
# upon completion, sysinfo converts the $store hash into an XML or YAML string for transfer
# It then sends it to the main server as defined in the conf file.
# NOTE: YAML is hand crafted to kill any requirements for external libraries
# see sub hashToYAML for details
# Version 3.0.1 20160321
# Renamed to sysinfo-client to not conflict with Linux package sysinfo
# created installer in Perl to not rely on package managers
# default path for configuration file changed to /etc/camp/sysinfo-client.conf
# $VERSION changed to $DATA_VERSION to not conflict with $main::VERSION (script version vs data format version)
#
# Version 3.1.0 20160401
# module and script dirs now arrays to be searched. Idea is that default
# modules/scripts are in installdir/modules or installdir/scripts, and
# user supplied are in /etc/scripts and /etc/modules
# Tightened up the file systems checks, requiring all scripts and modules
# be set 0700 at least, and owned by root
# Transport layers now an array, and if one fails to send the report, the others
# are tried in turn
# Worked on logic for sendReport to give better error checking.
# Doing a search for the configuration file matching cwd, then /etc/camp, then /usr/local/etc/camp
# Self documenting, ie a key for software\tsysinfo-client\version\current version is inserted
#
# Version 3.1.1 20160915 RWR
# set use strict and use warnings, then fixed errors
#
# Version 3.1.2 20160922 RWR
# $exitCode 1 (not applicable to this machine) does not throw warning
#
# Version 3.1.3 20161010 RWR
# Removed extra use warnings
#
# Version 3.1.4 20161023 RWR
# Would error out if moduledir does not exist, added a return
#
# Version 3.1.5 20170327 RWR
# On freeBSD systems, was looking in wrong place for configuration file
#
# Version 3.2.0 20180320 RWR
# Major change in the configuration file format; All entries are loaded into
# hash %configuration, so clientname is no longer $clientname, but is now
# $configuration{'clientname'}
# NOT backwards compatible
# changed configuration to be loaded into hash (vs directly loaded into variables)
# added UUID to configuration file
#
# Version 3.2.1 20180424 RWR
# Finally got a semi-stable version of this running. Fixed a bunch of bugs
# and appears to be working correctly.
# Following are global variables overridden if configuration file exists
my $TESTING = 0; # level's 0 (none) to 3 defined and increase verbosity while decreasing functionality
our $VERSION = '3.2.1';
my $indentLevel = 2; # number of spaces to indent per level in XML or YAML
$indentLevel = 3 if $TESTING;
if ($TESTING) {
use Data::Dumper;
}
# paths to search for configuration file
my @confFileSearchPath = ( '.', '/etc/camp/sysinfo-client', '/etc/camp', '/usr/local/etc/camp/sysinfo-client' );
my $configurationFile = 'sysinfo-client.conf'; # name of the configuration file
my $reportDate = &getReportDate; # set report date
my %configuration = (
'moduleDirs' => [], # search paths for modules
'scriptDirs' => [], # search paths for scripts
'clientName' => '', # Required!! Must be set in conf file (no defaults)
'serialNumber' => '', # serial number of machine
'UUID' => '', # UUID of machine
'transports' => [], # hash with various transports
'hostname' => &getHostName() # fully qualified host name of machine
)
;
my $DATA_VERSION = '3.0.0'; # used in sending the data file. sets version of XML/YAML data file
#######################################################
#
# findFile( $filename, @directories )
#
# Locates a file by searching sequentially in one or more
# directories, returning the first one found
#
# Returns '' if not found
#
#######################################################
sub findFile {
my ( $filename, $directories ) = @_;
for ( my $i = 0; $i < scalar( @{$directories} ); $i++ ) {
my $confFile = $$directories[$i] . '/' . $filename;
return $confFile if ( -f $confFile );
}
return '';
}
#######################################################
#
# loadConfigurationFile($confFile)
#
# Loads configuration file defined by $configurationFile, and dies if not available
# Reads entire contents into memory where it is eval'd in main program
# Parameters: configuration file fully path/file name
# NOTE: conf file must be a valid Perl file
#
#######################################################
sub loadConfigurationFile {
my ( $fileName, @searchPath ) = @_;
my $confFile;
if ( $confFile = &findFile( $fileName, \@searchPath ) ) {
print "Opening configuration from $confFile\n" if $TESTING > 2;
open CONFFILE, "<$confFile" or die "Can not open configuration file $confFile: $!\n";
my $confFileContents = join( '', <CONFFILE> ); # just slurp it into memory
close CONFFILE;
return ($confFileContents);
}
die "Can not find $fileName in any of " . join( "\n\t", @searchPath ) . "\n";
}
#######################################################
#
# sendResults( $parameters, $message, $scriptDirectory )
#
# Sends results of run to server using external script. If external
# script not defined, just print to STDOUT
#
# Parameters
# $parameters - a hash containing the information necessary to make the transfer
# $message - the message to be sent
# $scriptDirectory - path (not filename) of script to be executed
#
# $parameters contains different key/value pairs depending on the script used
# for example, a stand-alone SMTP script may need a username/password,
# smtp server name, port number, from and to address
# while an http transfer may only need a script name
# See the individual scripts to determine what parameters need to be
# filled in.
# The only required parameter is 'sendScript' which must contain the
# name of the script to execute (and it must be located in $scriptDirectory)
# SCRIPT must contain one sub named doit, that accepts three parameters, the hash,
# the message, and, optionally, the script directory
#
# If script not defined, just dump to STDOUT. With a properly set up cron job, the output
# would then be sent via e-mail to an administrative account, possibly root
#
#######################################################
sub sendResults {
my ( $globals, $transports, $message, $scriptDirectory ) = @_;
# die Dumper( $transports );
foreach my $key ( @$transports ) {
if ( $key->{'sendScript'} ) {
print "Trying to find file " . $key->{'sendScript'} . " in " . join( "\n\t", @{$scriptDirectory} ) . "\n" if $TESTING > 2;;
my $sendScript = &findFile( $key->{'sendScript'}, $scriptDirectory );
if ( $sendScript ) {
# load the chosen script into memory
require $sendScript;
# merge the globals in
while ( my ( $gkey, $value ) = each %$globals ) {
$key->{$gkey} = $value;
}
# do variable substitution for any values which need it
foreach my $thisOne ( keys %{$key} ) {
print "$thisOne\n" if $TESTING > 3;;
if ( $key->{$thisOne} =~ m/(\$configuration\{'hostname'\})|(\$reportDate)|(\$configuration\{'clientName'\})|(\$configuration\{'serialNumber'\})/ ) {
$key->{$thisOne} = eval "\"$key->{$thisOne}\"";
}
}
#%$transports{$key}{keys %$globals} = values %$globals;
#print Dumper( $$transports[$key] );
#next;
# execute the "doit" sub from that script
if ( $TESTING > 3 ) {
print $message;
} else {
my $return = &doit( $key, $message );
return $return if ( $return == 1 );
}
} else {
print "Could not find " . $$transports[$key]{'sendScript'} . ", trying next transport\n";
} # if..else
} # if
} # foreach
# if we made it here, we have not sent the report, so just return it to the user
#print $message;
return 1;
}
#######################################################
#
# getReportDate
#
# return current system date as YYYY-MM-DD HH:MM:SS
#
#######################################################
sub getReportDate {
my ($second, $minute, $hour, $dayOfMonth, $month, $year) = localtime();
return sprintf( "%4u-%02u-%02u %02u:%02u:%02u", $year+1900, $month+1, $dayOfMonth, $hour, $minute, $second );
}
#######################################################
#
# getHostName
#
# return hostname from hostname -f
#
#######################################################
sub getHostName {
my $hostname = `hostname -f`;
chomp $hostname;
return $hostname;
}
#######################################################
#
# escapeForYAML
#
# Escapes values put into YAML report
#
#######################################################
sub escapeForYAML {
my $value = shift;
$value =~ s/'/\\'/gi; # escape single quotes
$value =~ s/"/\\"/gi; # escape double quotes
# pound sign indicates start of a comment and thus loses part
# of strings. Surrounding it by double quotes in next statement
# allows
$value = '"' . $value . '"' if ( $value =~ m/[#:]/ );
return $value;
}
#######################################################
#
# hashToYAML( $hashRef, $indent )
#
# Converts a hash to a YAML string
#
# NOTE: This routine recursively calls itself for every level
# in the hash
#
# Parameters
# $hashref - reference (address) of a hash
# $indent - current indent level, defaults to 0
#
# Even though there are some very good libraries that do this
# I chose to hand-code it so sysinfo can be run with no libraries
# loaded. I chose to NOT do a full implementation, so special chars
# that would normally be escaped are not in here.
# However, I followed all the RFC for the values that were given, so
# assume any YAML reader can parse this
# NOTE: YAML appears to give a resulting file 1/3 smaller than the above
# XML, and compresses down in like manner
#
#######################################################
sub hashToYAML {
my ($hashRef, $indent) = @_;
$indent = 0 unless $indent; # default to 0 if not defined
my $output; # where the output is stored
foreach my $key ( keys %$hashRef ) { # for each key in the current reference
print "Looking at $key\n" if $TESTING > 3;
# see http://www.perlmonks.org/?node_id=175651 for isa function
if ( UNIVERSAL::isa( $$hashRef{$key}, 'HASH' ) ) { # is the value another hash?
# NOTE: unlike xml, indentation is NOT optional in YAML, so the following line verifies $indentlevel is non-zero
# and, if it is, uses a default 3 character indentation
$output .= (' ' x $indent ) . &escapeForYAML($key) . ":\n" . # key, plus colon, plus newline
&hashToYAML( $$hashRef{$key}, $indent+($indentLevel ? $indentLevel : 3) ) . # add results of recursive call
"\n";
} elsif ( UNIVERSAL::isa( $$hashRef{$key}, 'ARRAY' ) ) { # is it an array? ignore it
} else { # it is a scalar, so just do <key>value</key>
$output .= (' ' x $indent ) . &escapeForYAML($key) . ': ' . &escapeForYAML($$hashRef{$key}) . "\n";
}
}
return $output;
}
#######################################################
#
# tabDelimitedToHash ($hashRef, $tabdelim)
#
# Takes a tab delimited multi line string and adds it
# to a hash. The final field in each line is considered to
# be the value, and all prior fields are considered to be
# hierachial keys.
#
# Parameters
# $hashref - reference (address) of a hash
# $tabdelim - A tab delimited, newline terminated set of records
#
#
#######################################################
sub tabDelimitedToHash {
my ($hashRef, $tabdelim) = @_;
foreach my $line ( split( "\n", $tabdelim ) ) { # split on newlines, then process each line in turn
$line =~ s/'/\\'/gi; # escape single quotes
my @fields = split( / *\t */, $line ); # get all the field values into array
my $theValue = pop @fields; # the last one is the value, so save it
# now, we build a Perl statement that would create the assignment. The goal is
# to have a string that says something like $$hashRef{'key'}{'key'} = $value;
# then, eval that.
my $command = '$$hashRef'; # start with the name of the dereferenced hash (parameter 1)
while (my $key = shift @fields) { # while we have a key, from left to right
$command .= '{' . "'$key'" . '}'; # build it as {'key'} concated to string
}
$command .= "='$theValue';"; # add the assignment
#print STDERR "$command\n";
eval $command; # eval the string to make the actual assignment
}
}
#######################################################
#
# validatePermission ( $file )
#
# Checks that file is owned by root, and has permission
# 0700 or less
#
# Returns empty string on success, error message
# on failure
#
#######################################################
sub validatePermission {
my $file = shift;
my $return;
# must be owned by root
my $owner = (stat($file))[4];
$return .= " - Bad Owner [$owner]" if $owner;
# must not have any permissions for group or world
# ie, 0700 or less
my $mode = (stat($file))[2];
$mode = sprintf( '%04o', $mode & 07777 );
$return .= " - Bad Permission [$mode]" unless $mode =~ m/0.00/;
return $return ? $file . $return : '';
}
#######################################################
#
# ProcessModules ( $system, $moduleDir )
#
# Processes all modules in $moduleDir, adding result to $system hash
#
# Parameters
# $system - reference (address) of a hash
# $moduleDir - full path to a directory containing executable scripts
#
# Each file in the $moduleDir directory that matches the regex in the grep
# and is executable is run. It is assumed the script will return 0 on success
# or a non-zero on failure
# The output of the script is assumed to be a tab delimited, newline separated
# list of records that should be added to the hash $system. This is done by calling
# &parseModule above.
# on failure, the returned output of the script is assumed to be an error message
# and is displayed on STDERR
#######################################################
sub ProcessModules {
my ( $system, $moduleDir ) = @_;
# open the module directory
return unless -d $moduleDir;
opendir( my $dh, $moduleDir ) || die "Module Directory $moduleDir can not be opened: $!\n";
# and get all files which are executable and contain nothing but alpha-numerics and underscores (must begin with alpha-numeric)
my @modules = grep { /^[a-zA-Z0-9][a-zA-Z0-9_]+$/ && -x "$moduleDir/$_" } readdir( $dh );
closedir $dh;
foreach my $modFile ( sort @modules ) { # for each valid script
if ( my $error = &validatePermission( "$moduleDir$modFile" ) ) {
print STDERR "Not Processed: $error\n";
next;
}
print "Processing module $moduleDir$modFile\n" if $TESTING > 2;
my $output = qx/$moduleDir$modFile $moduleDir/; # execute it and grab the output
my $exitCode = $? >> 8; # process the exitCode
# exitCode 0 - processed normally
# exitCode 1 - not applicable to this machine
if ( $exitCode && $exitCode > 1) { # if non-zero, error, so show an error message
warn "Error in $moduleDir$modFile, [$output]\n";
} else { # otherwise, call tabDelimitedToHash to save the data
&tabDelimitedToHash( $system, $output );
} # if
} # foreach
# add sysinfo-client (me) to the software list, since we're obviously installed
&tabDelimitedToHash( $system, "software\tsysinfo-client\tversion\t$main::VERSION\n" );
}
sub processParameters {
while ( my $parameter = shift ) {
if ( $parameter eq '-v' ) {
print "$main::VERSION\n";
exit;
}
} # while
}
&processParameters( @ARGV );
# load the configuration file
#die "Searching for $configurationFile in = \n" . join( "\n", @confFileSearchPath ) . "\n";
eval ( &loadConfigurationFile( $configurationFile, @confFileSearchPath) );
die Dumper( \%configuration ) . "\n" if $TESTING > 4;
# user did not define a serial number, so make something up
$configuration{'serialNumber'} = '' unless $configuration{'serialNumber'};
# oops, no client name (required) so tell them and exit
die "No client name defined in $configurationFile" unless $configuration{'clientName'};
$TESTING = $configuration{'TESTING'} if defined $configuration{'TESTING'};
my $System; # hash reference that will store all info we are going to send to the server
# some defaults.
$$System{'report'}{'version'} = $DATA_VERSION;
$$System{'report'}{'date'} = $reportDate;
$$System{'report'}{'client'} = $configuration{'clientName'};
$$System{'system'}{'hostname'} = $configuration{'hostname'};
$$System{'system'}{'serial'} = $configuration{'serialNumber'};
$$System{'system'}{'UUID'} = $configuration{'UUID'};
# process any modules in the system
foreach my $moduleDir ( @{$configuration{'moduleDirs'}} ) {
&ProcessModules( $System, "$moduleDir/" );
}
# now, everything ins in $System, so convert it to the proper output format
my $out = "#sysinfo: $VERSION YAML\n---\n" . &hashToYAML( $System ) . "...\n";
#print Data::Dumper->Dump([$System],['System']) if $TESTING>2;
# load some global values for use in the script, if required
my $globals = {
'data version' => $DATA_VERSION,
'report date' => $reportDate,
'client name' => $configuration{'clientName'},
'host name' => $configuration{'hostname'},
'serial number'=> $configuration{'serialNumber'},
'UUID' => $configuration{'UUID'}
};
# and send the results to the server
if ( my $success = &sendResults( $globals, $configuration{'transports'}, $out, $configuration{'scriptDirs'} ) != 1 ) {
print "Error $success while sending report from $configuration{'hostname'}\n";
}
1;