#!/usr/bin/env perl # Copyright (c) 2025, R. W. Rodolico # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # 2. 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. # # 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 HOLDER 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. # 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 a YAML file of the form: # # # ext3 # /home # 51606140 # 331472 # # #
192.168.1.3
# fe80::216:3eff:fefb:4e10 # 64 # 00:16:3e:fb:4e:10 # 1500 # 255.255.255.0 #
# # squeeze # Debian GNU/Linux 6.0.4 (squeeze) # Debian # 2.6.32-5-xen-686 # Linux # 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 # 6.0.4 # # # RAM memory # MCP55 Memory Controller # a2 # Device cb84 # 0000:00:00.0 # nVidia Corporation # nVidia Corporation # # # Staffmasters # 2012-05-01 03:00 # 2.0.0 # # # terminal-based package manager (terminal interface only) # 0.6.3-3.2+squeeze1 # # # 1800.103 # i686 # GenuineIntel # backup.staffmasters.local # 1333259809 # 520852 # 1 # #
# # 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/ # (exit codes and STDERR use: see sensors/Documentation/README.md) # 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 (superseded: see sensors/Documentation/README.md) # # 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 # $config{'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. # # Version 3.3.0 20190419 RWR # Converted to use YAML config file # # Version 3.4.0 20191111 RWR # adding logging with priority. logging is a hash inside of %configuration which contains the following # $config{ 'logging' } = { # 'log type' => 'string', # 'log level' => #, # 'other params' => something, # }; # # The default log type is cache, which builds an array of all messages passed. When the log type is changed, the cache is # checked for values and, if they exist, they are dumped to the log, then removed. # # Currently, the only log type is 'file', which has one other additional parameter, 'log path' which # points to the actual log to be created. The log is NOT limited in size, so use something else to # do that. # log level is an integer which is compared the a priority passed to the logging function. The # higher log level is set, the more verbose the log. # 0 - Normal, basically logs when the program starts and ends, and any warnings. # 1 - a little more information about flow # 2 - Gives ending information on structures # 3 - Gives a lot of info about structures when they are initialized and at the end # 4 - Crazy. Dumps just about every structure every time they are changed # # $TESTING has been set to a binary. If true, the report is not sent via the transports, but is dumped to /tmp/sysinfo.testing.yaml # # Version 3.4.1 20191117 RWR # Added syslog as a possible option for logging. # # Version 3.5.4 20200317 RWR # changed so report->version will show the version of sysinfo, not the data version # # Version 3.5.5 20200317 RWR # bug fix for bsd/opnsense # # Version 3.6.0 20220607 RWR # Changed the way upload works. It automatically now passes an additional parameter, upload_type, which with the new upload # script will choose a directory, allowing one script to handle various uploads # additionally, if the key 'postRunScript' is in the config, it is assumed to be the name of a script to be run after sysinfo # completes it's task. This can be used to download/update the program, add new modules, etc.. # # Version 3.6.1 20230308 RWR # fixed a problem where it did not correctly find the source directory when called from a symbolic link # # Version 3.7.0 20240517 RWR # Added code to put everything from config file except for moduleDirs, transports and scriptDirs into the report. This allows # users to arbitrarily choose to send additional information by simply adding it to the config file. # # Version 3.7.1 20240518 RWR # Added code to ensure output is UTF-8 # # Version 3.7.2 20240601 RWR # Added code to undefine &doit if it is already defined, ie if one transport fails and we want to try another one, # we were getting a "can not redefine doit" and the transports would fail. The line added is # undef &doit if exists &doit; # # Version 4.0.0 20250331 RWR # Adding support for MS Windows # Removed requirement for modules to be executable # Version 4.0.1 20260517 RWR # Naming normalization and installer compatibility updates # # Version 3.8.0 20261011 Copilot # Stage 1 framework rewrite per Documentation/Spec.md (client/parser/config/transports/library/test harness) # Version 3.8.0 20261012 Copilot # P1 review fixes: TESTING config behavior, transport/script loading robustness, timeout process-group cleanup, # secure temp files, postRunScript lookup/order, JSON encoding fix, and sensor discovery execution filters; # exec wrapped in a block so perl no longer warns on every run (R1 follow-up, Claude) # (Claude, 2026-10-13: report passed to transports as UTF-8 bytes; fixes "Wide character" from save_local) # Version 3.8.1 20261018 Claude # Repository split: the client now lives in unix/ (no code change in this file); client version only, data version stays v3.8.0 use warnings; use strict; use open ':std', ':encoding(utf8)'; use utf8; use version; our $VERSION = version->declare('v3.8.1'); our $DATA_VERSION = version->declare('v3.8.0'); use FindBin; use File::Basename qw(basename dirname); use Cwd (); use File::Spec; use JSON::PP; use Encode (); use Getopt::Long; use POSIX qw(WNOHANG strftime); use Time::HiRes qw(time sleep); use File::Temp qw(tempfile); use lib "$FindBin::RealBin", "$FindBin::RealBin/.."; use sysinfoconf qw( loadConfigurationFile getLastConfigurationPath readServerInfo getLastServerInfoPath findFile logIt ); my $periodicOverrideFile = '/tmp/sysinfo.firstrun'; my $sourceDir = dirname(Cwd::abs_path(__FILE__)); ####################################################### # # defaultConfig() # # Builds the baseline configuration hash used before loading # any config file values. # # Parameters: # none # # Returns: # hash reference containing default settings # # Side Effects: # none # ####################################################### sub defaultConfig { return { 'logging' => { 'log type' => 'cache', 'log level' => 0, }, 'moduleDirs' => ["$sourceDir/sensors"], 'scriptDirs' => ["$sourceDir/transports"], 'clientName' => '', 'serialNumber' => '', 'UUID' => '', 'hostname' => getHostName(), 'outputType' => 'yaml', 'sensorTimeout' => 120, 'transports' => { '3' => { 'name' => 'saveLocal', 'sendScript' => 'save_local', 'output directory' => "$sourceDir/reports", }, }, }; } ####################################################### # # timeStamp( $fileSafe ) # # Returns local date/time in report format or in file-safe # format when requested. # # Parameters: # $fileSafe - boolean; true returns YYYY-MM-DD_HH-MM-SS # # Returns: # scalar timestamp string # # Side Effects: # none # ####################################################### sub timeStamp { my $fileSafe = shift; if ($fileSafe) { return strftime('%Y-%m-%d_%H-%M-%S', localtime); } return strftime('%Y-%m-%d %H:%M:%S', localtime); } ####################################################### # # getHostName() # # Finds the machine hostname using hostname -f first, with # fallback to hostname. # # Parameters: # none # # Returns: # scalar hostname string (possibly empty) # # Side Effects: # executes hostname commands # ####################################################### sub getHostName { my $hostname = qx(hostname -f 2>/dev/null); chomp $hostname; if (!$hostname) { $hostname = qx(hostname 2>/dev/null); chomp $hostname; } return $hostname; } ####################################################### # # validatePermission( $file, $testing ) # # Validates sensor file ownership and mode per sysinfo policy. # # Parameters: # $file - full path to candidate sensor file # $testing - boolean; when true permission checks are skipped # # Returns: # empty string on success, error text on failure # # Side Effects: # reads file metadata via stat # ####################################################### sub validatePermission { my ($file, $testing) = @_; return '' if $testing; return "$file - Not a file" unless -f $file; my $owner = (stat($file))[4]; my $mode = sprintf('%04o', (stat($file))[2] & 07777); my $error = ''; $error .= " - Bad Owner [$owner]" if $owner; $error .= " - Bad Permission [$mode]" unless $mode =~ /0.00/; return $error ? $file . $error : ''; } ####################################################### # # detectConfigType( $lines ) # # Detects whether config content is YAML, JSON, or # DataTransport based on first lines. # # Parameters: # $lines - array reference of config file lines # # Returns: # lowercase config type string, or undef if unknown # # Side Effects: # none # ####################################################### sub detectConfigType { my $lines = shift; return undef unless $lines && ref($lines) eq 'ARRAY' && @{$lines}; if ($lines->[0] =~ m/^.*(yaml|datatransport).*/i) { return lc $1; } for (my $line = 0; $line < 5 && defined $lines->[$line]; $line++) { return 'yaml' if $lines->[$line] =~ /^#.*YAML/i; return 'yaml' if $lines->[$line] =~ /^---/; return 'yaml' if $lines->[$line] =~ /^\s*\w+:\s*/; return 'json' if $lines->[$line] =~ /^#.*JSON/i; return 'json' if $lines->[$line] =~ /^\s*[\[{]/; } return undef; } ####################################################### # # loadConfig( $confLines, $type ) # # Parses raw config lines into a configuration hash using # YAML, JSON, or DataTransport decode. # # Parameters: # $confLines - array reference of file lines # $type - optional config type override # # Returns: # hash reference with parsed settings # # Side Effects: # loads parser modules dynamically; dies on parse errors # ####################################################### sub loadConfig { my ($confLines, $type) = @_; my $config = {}; $type = detectConfigType($confLines) unless $type; die "Cannot determine configuration file type\n" unless $type; $type = lc $type; my $raw = join('', @{$confLines}); if ($type eq 'yaml') { require YAML::Tiny; my $yaml = YAML::Tiny->read_string($raw); die "Failed to parse YAML config\n" unless $yaml && ref($yaml->[0]) eq 'HASH'; $config = $yaml->[0]; } elsif ($type eq 'json') { $config = decode_json($raw); } elsif ($type eq 'datatransport') { require DataTransport; my $dt = DataTransport->new(); $config = $dt->decode($raw); die "Failed to parse DataTransport config\n" unless $config && ref($config) eq 'HASH'; } else { die "Unknown configuration file type: $type\n"; } $config->{'dataType'} = $type; return $config; } ####################################################### # # mergeConfigWithDefaults( $defaults, $loaded ) # # Merges loaded configuration values over defaults and # normalizes required framework keys. # # Parameters: # $defaults - hash reference with baseline settings # $loaded - hash reference from config parser # # Returns: # hash reference containing merged config # # Side Effects: # none # ####################################################### sub mergeConfigWithDefaults { my ($defaults, $loaded) = @_; my %merged = %{$defaults}; foreach my $key (keys %{$loaded}) { $merged{$key} = $loaded->{$key}; } if (!exists($merged{'moduleDirs'}) || ref($merged{'moduleDirs'}) ne 'ARRAY' || !@{$merged{'moduleDirs'}}) { $merged{'moduleDirs'} = ["$sourceDir/sensors"]; } if (!exists($merged{'scriptDirs'}) || ref($merged{'scriptDirs'}) ne 'ARRAY' || !@{$merged{'scriptDirs'}}) { $merged{'scriptDirs'} = ["$sourceDir/transports"]; } if (!exists($merged{'transports'}) || ref($merged{'transports'}) ne 'HASH') { $merged{'transports'} = $defaults->{'transports'}; } $merged{'hostname'} = getHostName() unless defined($merged{'hostname'}) && $merged{'hostname'} ne ''; $merged{'serialNumber'} = '' unless defined $merged{'serialNumber'}; $merged{'UUID'} = '' unless defined $merged{'UUID'}; $merged{'sensorTimeout'} = 120 unless defined($merged{'sensorTimeout'}) && $merged{'sensorTimeout'} =~ /^\d+$/; return \%merged; } ####################################################### # # applyServerInfoOverrides( $config, $serverInfo ) # # Applies server.info values onto config using precedence # and non-empty override rules. # # Parameters: # $config - hash reference for active configuration # $serverInfo - hash reference from readServerInfo # # Returns: # none # # Side Effects: # mutates $config in place # ####################################################### sub applyServerInfoOverrides { my ($config, $serverInfo) = @_; foreach my $key (qw(clientName hostname serialNumber UUID location os_type tags)) { next unless exists $serverInfo->{$key}; next unless defined $serverInfo->{$key}; if ($key eq 'tags') { next unless ref($serverInfo->{$key}) eq 'ARRAY' && @{$serverInfo->{$key}}; } else { next if $serverInfo->{$key} eq ''; } $config->{$key} = $serverInfo->{$key}; } } ####################################################### # # mergeCLIOptions( $config, $cliOptions ) # # Merges command-line flags into config and finalizes # runtime defaults. # # Parameters: # $config - hash reference for active configuration # $cliOptions - hash reference of parsed CLI options # # Returns: # hash reference (same structure, updated values) # # Side Effects: # mutates $config values for runtime flags # ####################################################### sub mergeCLIOptions { my ($config, $cliOptions) = @_; if (defined $cliOptions->{'verbose'}) { $config->{'logging'}{'log level'} = $cliOptions->{'verbose'}; } if (defined $cliOptions->{'outputType'}) { $config->{'outputType'} = lc($cliOptions->{'outputType'}); } $config->{'outputType'} = 'yaml' unless defined($config->{'outputType'}) && $config->{'outputType'}; my $configTesting = 0; if (defined($config->{'TESTING'})) { $configTesting = $config->{'TESTING'} ? 1 : 0; } $config->{'testing'} = ($cliOptions->{'test'} || $configTesting) ? 1 : 0; $config->{'periodic'} = $cliOptions->{'periodic'} ? 1 : 0; $config->{'sensorTimeout'} = 120 unless defined($config->{'sensorTimeout'}) && $config->{'sensorTimeout'} =~ /^\d+$/; return $config; } ####################################################### # # tabDelimitedToHash( $hashRef, $tabDelimited ) # # Parses sensor STDOUT in tab-delimited format and merges # accepted key paths into the report hash. # # Parameters: # $hashRef - report hash reference to update # $tabDelimited - scalar string with sensor output lines # # Returns: # hash reference with rejected count and first rejected line # # Side Effects: # mutates $hashRef in place # ####################################################### sub tabDelimitedToHash { my ($hashRef, $tabDelimited) = @_; my $rejected = 0; my $firstRejected = ''; return { 'rejected' => 0, 'firstRejected' => '', } unless defined($tabDelimited) && $tabDelimited =~ /\S/; foreach my $line (split(/\n/, $tabDelimited)) { next if $line !~ /\S/; my @fields = split(/ *\t */, $line, -1); if (@fields < 2) { $rejected++; $firstRejected = $line if $firstRejected eq ''; next; } my $value = pop @fields; my $rejectLine = 0; foreach my $keyPart (@fields) { if (!defined($keyPart) || $keyPart eq '') { $rejectLine = 1; last; } } if ($rejectLine) { $rejected++; $firstRejected = $line if $firstRejected eq ''; next; } my $cursor = $hashRef; for (my $i = 0; $i <= $#fields; $i++) { my $key = $fields[$i]; my $isLeaf = $i == $#fields; if ($isLeaf) { if (exists $cursor->{$key} && ref($cursor->{$key}) eq 'HASH') { $rejectLine = 1; last; } $cursor->{$key} = $value; next; } if (exists $cursor->{$key}) { if (ref($cursor->{$key}) ne 'HASH') { $rejectLine = 1; last; } } else { $cursor->{$key} = {}; } $cursor = $cursor->{$key}; } if ($rejectLine) { $rejected++; $firstRejected = $line if $firstRejected eq ''; } } return { 'rejected' => $rejected, 'firstRejected' => $firstRejected, }; } ####################################################### # # _firstLine( $text ) # # Returns the first non-empty trimmed line, capped for # status message storage. # # Parameters: # $text - scalar multiline text # # Returns: # scalar first non-empty line, or empty string # # Side Effects: # none # ####################################################### sub _firstLine { my $text = shift; return '' unless defined $text; foreach my $line (split(/\n/, $text)) { $line =~ s/^\s+|\s+$//g; next if $line eq ''; return substr($line, 0, 200); } return ''; } ####################################################### # # _readAll( $path ) # # Reads a complete file into memory. # # Parameters: # $path - full path to file # # Returns: # scalar file content, or empty string on read failure # # Side Effects: # reads from filesystem # ####################################################### sub _readAll { my $path = shift; return '' unless -f $path; open my $fh, '<', $path or return ''; local $/; my $content = <$fh>; close($fh); return defined($content) ? $content : ''; } ####################################################### # # runSensor( $sensorPath, $moduleDirArg, $timeout, $envRef ) # # Executes one sensor in a subprocess, captures stdout/stderr, # and applies timeout with process-group termination. # # Parameters: # $sensorPath - executable sensor path # $moduleDirArg - argv[1] module directory with trailing slash # $timeout - timeout in seconds # $envRef - hash reference of SYSINFO_* env vars # # Returns: # hash reference with stdout/stderr/exit metadata # # Side Effects: # forks child process, writes temporary files, sends signals # ####################################################### sub runSensor { my ($sensorPath, $moduleDirArg, $timeout, $envRef) = @_; my ($stdoutHandle, $stdoutFile) = tempfile('sysinfo.stdout.XXXXXX', DIR => '/tmp', UNLINK => 0); my ($stderrHandle, $stderrFile) = tempfile('sysinfo.stderr.XXXXXX', DIR => '/tmp', UNLINK => 0); my $start = time; my $pid = fork(); die "fork failed: $!\n" unless defined $pid; if ($pid == 0) { setpgrp(0, 0); foreach my $key (keys %{$envRef}) { if (defined $envRef->{$key}) { $ENV{$key} = $envRef->{$key}; } else { delete $ENV{$key}; } } open(STDIN, '<', '/dev/null') or do { print STDERR "open /dev/null failed: $!\n"; POSIX::_exit(127); }; open(STDOUT, '>&', $stdoutHandle) or do { print STDERR "open stdout temp failed: $!\n"; POSIX::_exit(127); }; open(STDERR, '>&', $stderrHandle) or do { print STDERR "open stderr temp failed: $!\n"; POSIX::_exit(127); }; # braces keep perl from warning "Statement unlikely to be reached" on every run { exec {$sensorPath} $sensorPath, $moduleDirArg }; print STDERR "exec failed for $sensorPath: $!\n"; POSIX::_exit(127); } close($stdoutHandle); close($stderrHandle); my $timedOut = 0; my $status; while (1) { my $wait = waitpid($pid, WNOHANG); if ($wait == $pid) { $status = $?; last; } if ((time - $start) > $timeout) { $timedOut = 1; kill 'TERM', -$pid; my $termWaitStart = time; while ((time - $termWaitStart) < 5) { my $termWait = waitpid($pid, WNOHANG); if ($termWait == $pid) { $status = $?; last; } sleep 0.1; } kill 'KILL', -$pid; if (!defined $status) { waitpid($pid, 0); $status = $?; } last; } sleep 0.05; } my $stdout = _readAll($stdoutFile); my $stderr = _readAll($stderrFile); unlink($stdoutFile) if -e $stdoutFile; unlink($stderrFile) if -e $stderrFile; my $seconds = sprintf('%.1f', time - $start); my %result = ( 'timed_out' => $timedOut, 'status_raw' => $status, 'stdout' => $stdout, 'stderr' => $stderr, 'seconds' => $seconds, ); if (!$timedOut) { if (($status & 127) != 0) { $result{'exit_code'} = 128 + ($status & 127); $result{'signal'} = ($status & 127); } else { $result{'exit_code'} = ($status >> 8); } } return \%result; } ####################################################### # # buildSensorEnvironment( $config, $configuredValues, $serverInfoValues ) # # Builds SYSINFO_* environment variables used by all sensor # subprocesses. # # Parameters: # $config - active runtime config hash reference # $configuredValues - original configured UUID/serial values # $serverInfoValues - values sourced from server.info # # Returns: # hash reference of environment values # # Side Effects: # none # ####################################################### sub buildSensorEnvironment { my ($config, $configuredValues, $serverInfoValues) = @_; my %env; $env{'SYSINFO_CONFIGURED_UUID'} = defined($configuredValues->{'UUID'}) ? $configuredValues->{'UUID'} : ''; $env{'SYSINFO_CONFIGURED_SERIAL'} = defined($configuredValues->{'serialNumber'}) ? $configuredValues->{'serialNumber'} : ''; if (exists $serverInfoValues->{'UUID'}) { $env{'SYSINFO_SERVERINFO_UUID'} = $serverInfoValues->{'UUID'}; } else { $env{'SYSINFO_SERVERINFO_UUID'} = undef; } if (exists $serverInfoValues->{'serialNumber'}) { $env{'SYSINFO_SERVERINFO_SERIAL'} = $serverInfoValues->{'serialNumber'}; } else { $env{'SYSINFO_SERVERINFO_SERIAL'} = undef; } if ($config->{'periodic'}) { $env{'SYSINFO_PERIODIC'} = 1; } else { $env{'SYSINFO_PERIODIC'} = undef; } return \%env; } ####################################################### # # discoverSensors( $moduleDirs, $testing, $config ) # # Scans configured module directories and returns candidate # sensors with permission validation details. # # Parameters: # $moduleDirs - array reference of sensor directories # $testing - boolean test mode flag # $config - config hash reference for logging # # Returns: # array reference of sensor metadata hashes # # Side Effects: # reads directory entries and logs skipped directories # ####################################################### sub discoverSensors { my ($moduleDirs, $testing, $config) = @_; my @existingDirs; foreach my $moduleDir (@{$moduleDirs}) { if (-d $moduleDir) { push @existingDirs, $moduleDir; } else { logIt($config, 1, "Sensor directory missing, skipping: $moduleDir"); } } if (!@existingDirs) { push @existingDirs, "$sourceDir/sensors" if -d "$sourceDir/sensors"; } my %seen; my @found; foreach my $moduleDir (@existingDirs) { opendir(my $dh, $moduleDir) or next; my @names = sort grep { /^[a-zA-Z0-9][a-zA-Z0-9_]*$/ } readdir($dh); closedir($dh); foreach my $name (@names) { next if $seen{$name}; my $path = "$moduleDir/$name"; next unless -f $path; if (!-x $path) { logIt($config, 1, "Skipping non-executable sensor file: $path"); next; } $seen{$name} = 1; push @found, { 'name' => $name, 'path' => $path, 'dir' => $moduleDir, 'permissionError' => validatePermission($path, $testing), }; } } return \@found; } ####################################################### # # processSensors( $report, $config, $sensorEnv ) # # Executes discovered sensors and records framework status # under sysinfo.sensors while merging valid output lines. # # Parameters: # $report - report hash reference # $config - runtime configuration hash reference # $sensorEnv - hash reference of SYSINFO_* values # # Returns: # none # # Side Effects: # mutates $report and executes sensor subprocesses # ####################################################### sub processSensors { my ($report, $config, $sensorEnv) = @_; $report->{'sysinfo'}{'sensors'} = {} unless exists $report->{'sysinfo'}{'sensors'}; my $sensors = discoverSensors($config->{'moduleDirs'}, $config->{'testing'}, $config); foreach my $sensor (@{$sensors}) { my $sensorStatus = { 'seconds' => 0.0, }; $report->{'sysinfo'}{'sensors'}{$sensor->{'name'}} = $sensorStatus; if ($sensor->{'permissionError'}) { $sensorStatus->{'status'} = 'skipped_permission'; $sensorStatus->{'message'} = substr($sensor->{'permissionError'}, 0, 200); next; } my $run = runSensor($sensor->{'path'}, $sensor->{'dir'} . '/', $config->{'sensorTimeout'}, $sensorEnv); $sensorStatus->{'seconds'} = $run->{'seconds'} + 0; if ($run->{'timed_out'}) { $sensorStatus->{'status'} = 'timeout'; my $message = _firstLine($run->{'stderr'}); $sensorStatus->{'message'} = $message if $message ne ''; next; } $sensorStatus->{'exit_code'} = $run->{'exit_code'} if defined $run->{'exit_code'}; if ($run->{'exit_code'} == 0) { my $parsed = tabDelimitedToHash($report, $run->{'stdout'}); $sensorStatus->{'status'} = 'ok'; if ($parsed->{'rejected'} > 0) { $sensorStatus->{'rejected_lines'} = $parsed->{'rejected'}; my $msg = substr($parsed->{'firstRejected'}, 0, 200); $sensorStatus->{'message'} = $msg if $msg ne ''; } } else { # exit-code contract: sensors/Documentation/README.md my %statusFor = (1 => 'wrong_os', 2 => 'not_applicable', 3 => 'not_due'); $sensorStatus->{'status'} = $statusFor{$run->{'exit_code'}} || 'error'; my $message = _firstLine($run->{'stderr'}); $sensorStatus->{'message'} = $message if $message ne ''; } } } ####################################################### # # initReport( $config, $configPath, $serverInfoPath ) # # Creates the base report document including compatibility # report/system rows and sysinfo framework metadata. # # Parameters: # $config - runtime configuration hash reference # $configPath - resolved config file path used for run # $serverInfoPath - resolved server.info path or empty # # Returns: # hash reference containing initialized report # # Side Effects: # none # ####################################################### sub initReport { my ($config, $configPath, $serverInfoPath) = @_; my %ignore = ( 'moduleDirs' => 1, 'scriptDirs' => 1, 'transports' => 1, 'logging' => 1, 'sensorTimeout' => 1, 'update' => 1, 'testing' => 1, 'outputType' => 1, 'dataType' => 1, 'periodic' => 1, ); my $report = {}; $report->{'report'}{'version'} = $VERSION->normal; $report->{'report'}{'date'} = timeStamp(); $report->{'report'}{'client'} = $config->{'clientName'}; $report->{'system'}{'hostname'} = defined($config->{'hostname'}) ? $config->{'hostname'} : ''; $report->{'system'}{'serial'} = defined($config->{'serialNumber'}) ? $config->{'serialNumber'} : ''; $report->{'system'}{'UUID'} = defined($config->{'UUID'}) ? $config->{'UUID'} : ''; foreach my $key (keys %{$config}) { next if $ignore{$key}; $report->{'system'}{$key} = $config->{$key}; } $report->{'software'}{'sysinfo-client'}{'version'} = $VERSION->normal; $report->{'sysinfo'}{'client_version'} = $VERSION->normal; $report->{'sysinfo'}{'data_version'} = $DATA_VERSION->normal; $report->{'sysinfo'}{'config_file'} = $configPath; $report->{'sysinfo'}{'server_info_file'} = $serverInfoPath if $serverInfoPath; return $report; } ####################################################### # # substituteTransportPlaceholders( $value, $config, $reportDate ) # # Replaces transport placeholder tokens with literal values # without using eval. # # Parameters: # $value - scalar template value # $config - runtime configuration hash reference # $reportDate - report timestamp string # # Returns: # scalar substituted string # # Side Effects: # none # ####################################################### sub substituteTransportPlaceholders { my ($value, $config, $reportDate) = @_; return $value unless defined $value; my %replace = ( q{$configuration{'hostname'}} => $config->{'hostname'} // '', q{$configuration{'clientName'}} => $config->{'clientName'} // '', q{$configuration{'serialNumber'}} => $config->{'serialNumber'} // '', q{$config{'hostname'}} => $config->{'hostname'} // '', q{$config{'clientName'}} => $config->{'clientName'} // '', q{$config{'serialNumber'}} => $config->{'serialNumber'} // '', q{$reportDate} => $reportDate // '', q{$hostname} => $config->{'hostname'} // '', ); foreach my $token (keys %replace) { my $replacement = $replace{$token}; $value =~ s/\Q$token\E/$replacement/g; } return $value; } ####################################################### # # loadTransportScript( $scriptPath ) # # Loads one transport script safely after clearing any # previously loaded main::doit subroutine. # # Parameters: # $scriptPath - full path to transport script file # # Returns: # boolean success (1 when main::doit is available) # # Side Effects: # loads Perl code into main namespace # ####################################################### sub loadTransportScript { my $scriptPath = shift; { no strict 'refs'; undef &main::doit if defined &main::doit; } delete $INC{$scriptPath} if exists $INC{$scriptPath}; my $loadResult = do $scriptPath; if (!defined($loadResult)) { my $error = $@ || $! || 'unknown load error'; return (0, $error); } return defined(&main::doit) ? (1, '') : (0, 'loaded script does not define main::doit'); } ####################################################### # # sendResults( $globals, $transports, $message, $scriptDirectories, $config ) # # Attempts transports by numeric priority and stops on first # transport returning success code 1. # # Parameters: # $globals - hash reference of transport globals # $transports - hash reference of transport definitions # $message - serialized report body # $scriptDirectories - array reference of transport directories # $config - runtime config hash reference for logging # # Returns: # 1 on successful send, 0 after fallback to STDOUT # # Side Effects: # loads transport scripts, executes send path, may print report # ####################################################### sub sendResults { my ($globals, $transports, $message, $scriptDirectories, $config) = @_; foreach my $priority (sort { $a <=> $b } keys %{$transports}) { my $transport = $transports->{$priority}; next unless ref($transport) eq 'HASH'; next unless $transport->{'sendScript'}; my $sendScriptPath = findFile($transport->{'sendScript'}, $scriptDirectories); if (!$sendScriptPath) { logIt($config, 1, "Could not find transport script $transport->{'sendScript'} at priority $priority"); next; } my ($loaded, $loadError); my $loadEval = eval { ($loaded, $loadError) = loadTransportScript($sendScriptPath); 1; }; if (!$loadEval || !$loaded) { my $error = !$loadEval ? $@ : $loadError; chomp $error if defined $error; logIt($config, 0, "Transport priority $priority load failed for $sendScriptPath: $error"); next; } my %params = %{$transport}; foreach my $gkey (keys %{$globals}) { $params{$gkey} = $globals->{$gkey}; } foreach my $key (keys %params) { next if ref($params{$key}); $params{$key} = substituteTransportPlaceholders($params{$key}, $config, $globals->{'report date'}); } my $return; my $callEval = eval { # transports write/send the report as-is, without a UTF-8 layer, so hand # them UTF-8 bytes; character text (e.g. an em dash in a dpkg description) # made save_local print "Wide character" on every run (cron mail) $return = main::doit(\%params, Encode::encode('UTF-8', $message)); 1; }; if (!$callEval) { my $error = $@; chomp $error if defined $error; logIt($config, 0, "Transport priority $priority runtime failure in $sendScriptPath: $error"); next; } logIt($config, 1, "Transport priority $priority returned " . (defined($return) ? $return : 'undef')); return 1 if defined($return) && $return == 1; } logIt($config, 0, 'All transports failed; printing report to STDOUT'); print $message; return 0; } ####################################################### # # runPostRunScript( $config, $configPath ) # # Executes optional post-run helper script after report # processing completes. # # Parameters: # $config - runtime configuration hash reference # $configPath - config file path passed to post script # # Returns: # none # # Side Effects: # executes external script via system # ####################################################### sub runPostRunScript { my ($config, $configPath) = @_; return unless exists $config->{'postRunScript'}; return unless ref($config->{'postRunScript'}) eq 'HASH'; my $name = $config->{'postRunScript'}{'script name'}; return unless $name; my @searchPath = ($sourceDir, @{$config->{'scriptDirs'}}); my $scriptPath = findFile($name, \@searchPath); if ($scriptPath && -x $scriptPath) { system($scriptPath, $configPath); } } ####################################################### # # hashToReportString( $report, $outputType ) # # Serializes report hash to JSON or YAML output text. YAML # includes required #sysinfo header line. # # Parameters: # $report - report hash reference # $outputType - desired output type (yaml/json) # # Returns: # scalar serialized report text # # Side Effects: # dynamically loads YAML::Tiny for yaml path # ####################################################### sub hashToReportString { my ($report, $outputType) = @_; $outputType = lc($outputType // 'yaml'); if ($outputType eq 'json') { return JSON::PP->new->utf8(0)->canonical(1)->encode($report); } require YAML::Tiny; my $yaml = YAML::Tiny->new($report)->write_string(); return "#sysinfo: " . $DATA_VERSION->normal . " YAML\n" . $yaml; } ####################################################### # # help() # # Prints command-line usage and option descriptions. # # Parameters: # none # # Returns: # none # # Side Effects: # writes help text to STDOUT # ####################################################### sub help { print basename($0) . " $VERSION (data $DATA_VERSION)\n"; print <<'END_HELP'; sysinfo_client [options] Options: --version display version and exit -v, --verbose increase verbosity (repeatable) --help display this help text -f, --config path to configuration file --configtype yaml, json, datatransport -o, --outputType yaml or json -p, --periodic run periodic sensors this cycle -t, --test test mode (writes /tmp/sysinfo.testing.yaml) END_HELP } ####################################################### # # getOptions() # # Parses command-line arguments and handles --help and # --version exits. # # Parameters: # none # # Returns: # hash reference of option values # # Side Effects: # may print output and exit process # ####################################################### sub getOptions { my $overrides = {}; Getopt::Long::Configure('bundling'); GetOptions( $overrides, 'periodic|p', 'config|f=s', 'configtype=s', 'outputType|o=s', 'help', 'version', 'verbose|v+', 'test|t', ) or die "Error parsing command line\n"; if ($overrides->{'help'}) { help(); exit 0; } if ($overrides->{'version'}) { print basename($0) . " $VERSION (data $DATA_VERSION)\n"; exit 0; } return $overrides; } ####################################################### # # main() # # Executes one complete framework cycle: load config, # apply overrides, run sensors, serialize report, and send. # # Parameters: # none # # Returns: # process exit code (0 on completed run) # # Side Effects: # filesystem reads/writes, subprocess execution, # transport delivery attempts # ####################################################### sub main { my $cliOptions = getOptions(); if (!$cliOptions->{'test'} && lc($^O) ne 'mswin32' && $> != 0) { die "Error: Must be run as root\n"; } if ($cliOptions->{'periodic'}) { open my $periodicFlag, '>', $periodicOverrideFile; print {$periodicFlag} "1\n"; close($periodicFlag); } my $configFile = $cliOptions->{'config'} // 'sysinfo_client.yaml'; my $configType = $cliOptions->{'configtype'} // ''; my $loadedLines = loadConfigurationFile($configFile); my $loadedConfig = loadConfig($loadedLines, $configType); my $config = mergeConfigWithDefaults(defaultConfig(), $loadedConfig); my %configuredValues = ( 'UUID' => $config->{'UUID'}, 'serialNumber' => $config->{'serialNumber'}, ); my $serverInfo = readServerInfo(); applyServerInfoOverrides($config, $serverInfo); mergeCLIOptions($config, $cliOptions); die "No clientName defined\n" unless defined($config->{'clientName'}) && $config->{'clientName'} ne ''; my $configPath = getLastConfigurationPath(); my $serverInfoPath = getLastServerInfoPath(); my $report = initReport($config, $configPath, $serverInfoPath); my $sensorEnv = buildSensorEnvironment($config, \%configuredValues, $serverInfo); processSensors($report, $config, $sensorEnv); $report->{'software'}{'sysinfo-client'}{'version'} = $VERSION->normal; my $out = hashToReportString($report, $config->{'outputType'}); if ($config->{'testing'}) { open my $testOut, '>', '/tmp/sysinfo.testing.yaml' or die "Could not write /tmp/sysinfo.testing.yaml: $!\n"; print {$testOut} $out; close($testOut); } else { my @existingScriptDirs; foreach my $scriptDir (@{ $config->{'scriptDirs'} }) { if (-d $scriptDir) { push @existingScriptDirs, $scriptDir; } else { logIt($config, 1, "Transport directory missing, skipping: $scriptDir"); } } if (!@existingScriptDirs && -d "$sourceDir/transports") { push @existingScriptDirs, "$sourceDir/transports"; } my $globals = { 'upload_type' => 'sysinfo', 'data version' => $DATA_VERSION->normal, 'report date' => $report->{'report'}{'date'}, 'client name' => $config->{'clientName'}, 'host name' => $config->{'hostname'}, 'serial number' => $config->{'serialNumber'}, 'UUID' => $config->{'UUID'}, 'fileType' => $config->{'outputType'}, }; sendResults($globals, $config->{'transports'}, $out, \@existingScriptDirs, $config); } unlink($periodicOverrideFile) if -e $periodicOverrideFile; runPostRunScript($config, $configPath); return 0; } unless (caller) { exit main(); } 1;