Called by fcron hourly to look for scheduled messages. StatusMail.pm declares an object which is used to create the mail messages.
Signed-off-by: Tim FitzGeorge ipfr@tfitzgeorge.me.uk --- src/statusmail/StatusMail.pm | 530 +++++++++++++++++++++++++++++++++++++++++++ src/statusmail/statusmail.pl | 422 ++++++++++++++++++++++++++++++++++ 2 files changed, 952 insertions(+) create mode 100644 src/statusmail/StatusMail.pm create mode 100755 src/statusmail/statusmail.pl
diff --git a/src/statusmail/StatusMail.pm b/src/statusmail/StatusMail.pm new file mode 100644 index 000000000..fb37c3663 --- /dev/null +++ b/src/statusmail/StatusMail.pm @@ -0,0 +1,530 @@ +#!/usr/bin/perl + +############################################################################ +# # +# Send log and status emails for IPFire # +# # +# This is free software; you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation; either version 3 of the License, or # +# (at your option) any later version. # +# # +# This is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with IPFire; if not, write to the Free Software # +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # +# # +# Copyright (C) 2018 - 2019 The IPFire Team # +# # +############################################################################ + +use strict; +use warnings; + +use lib "/usr/lib/statusmail"; + +package StatusMail; + +use base qw/EncryptedMail/; + +############################################################################ +# Constants +############################################################################ + +use constant { SEC => 0, + MIN => 1, + HOUR => 2, + MDAY => 3, + MON => 4, + YEAR => 5, + WDAY => 6, + YDAY => 7, + ISDST => 8, + MONSTR => 9 }; + +use constant MONTHS => qw( Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec ); + +use constant LOGNAME => '/var/log/messages'; + +############################################################################ +# Configuration variables +############################################################################ + +my @monthnames = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', + 'Sep', 'Oct', 'Nov', 'Dec'); +my %months; + +############################################################################ +# Variables +############################################################################ + +my %address_lookup_cache; + +############################################################################ +# Function prototypes +############################################################################ + +sub calculate_period( $$ ); +sub get_period_start(); +sub get_period_end(); +sub get_number_weeks(); +sub cache( $;$ ); +sub lookup_ip_address( $$ ); +sub set_host_name( $$$ ); +sub split_string( $$$ ); + +############################################################################ +# Initialisation code +############################################################################ + + +foreach (my $monindex = 0 ; $monindex < MONTHS ; $monindex++) +{ + $months{(MONTHS)[$monindex]} = $monindex; +} + +#------------------------------------------------------------------------------ +# sub new +# +# Class constructor +#------------------------------------------------------------------------------ + +sub new +{ + my $invocant = shift; + + my $class = ref($invocant) || $invocant; + + my $self = $class->SUPER::new( @_ ); + + $self->{last_time} = 0; + $self->{last_mon} = 0; + $self->{last_day} = 0; + $self->{last_hour} = 0; + + bless( $self, $class ); + + return $self; +} + +#------------------------------------------------------------------------------ +# sub calculate_period( value, unit ) +# +# Calculates the limits of the period covered by the message +# +# Parameters: +# value Number of units +# unit Unit of time +#------------------------------------------------------------------------------ + +sub calculate_period( $$ ) +{ + my ( $self, $value, $unit ) = @_; + + use Time::Local; + + my $start_time = 0; + my @start_time = (); + my $end_time = 0; + my @end_time = (); + my $weeks_covered = 0; + + @end_time = localtime(); + + $end_time[SEC] = 0; + $end_time[MIN] = 0; + + $end_time = timelocal( @end_time ); + + if ($unit eq 'months') + { + # Go back the specified number of months + + @start_time = @end_time; + + $start_time[MON] -= $value; + if ($start_time[MON] < 0 ) + { + $start_time[MON] += 12; + $start_time[YEAR]--; + } + + $start_time = timelocal( @start_time ); + } + else + { + my $hours = $value; + + # Go back the specified number of hours, days or weeks + + $hours *= 24 if ($unit eq 'days'); + $hours *= 24 * 7 if ($unit eq 'weeks'); + + $start_time = timelocal( @end_time ) - ($hours * 3600); + @start_time = localtime( $start_time ); + } + + # Adjust end to end of previous hour rather than start of current hour + + $end_time--; + @end_time = localtime( $end_time ); + + # Add the alphabetic month to the end of the time lists + + push @start_time, $monthnames[ $start_time[MON] ]; + push @end_time, $monthnames[ $end_time[MON] ]; + + # Calculate how many archive files have to be read + + my $week_start = $start_time - ($start_time[WDAY] * 86400) - ($start_time[HOUR] * 3600) + 3600; + $weeks_covered = int( (time() - $week_start) / (86400 * 7) ); + + $self->{'start_time_array'} = @start_time; + $self->{'start_time'} = $start_time; + $self->{'end_time_array'} = @end_time; + $self->{'end_time'} = $end_time; + $self->{'weeks_covered'} = $weeks_covered; + $self->{'period'} = "$value$unit"; + $self->{'period'} =~ s/s$//; + $self->{'total_days'} = ($end_time - $start_time) / 86400; +} + + +#------------------------------------------------------------------------------ +# sub get_period() +# +# Returns the period covered by a report. +#------------------------------------------------------------------------------ + +sub get_period() +{ + my $self = shift; + + return $self->{'period'}; +} + + +#------------------------------------------------------------------------------ +# sub get_period_start() +# +# Returns the start of the period covered by a report. +#------------------------------------------------------------------------------ + +sub get_period_start() +{ + my $self = shift; + + return wantarray ? @{$self->{'start_time_array'}} : $self->{'start_time'}; +} + + +#------------------------------------------------------------------------------ +# sub get_period_end() +# +# Returns the end of the period covered by a report. +#------------------------------------------------------------------------------ + +sub get_period_end() +{ + my $self = shift; + + return wantarray ? @{$self->{'end_time_array'}} : $self->{'end_time'}; +} + + +#------------------------------------------------------------------------------ +# sub get_number_weeks() +# +# Returns the number of complete weeks covered by a report. +#------------------------------------------------------------------------------ + +sub get_number_weeks() +{ + my $self = shift; + + return $self->{'weeks_covered'}; +} + + +#------------------------------------------------------------------------------ +# sub cache( name [, item] ) +# +# Either caches an item or returns the cached item. +# +# Parameters: +# Name name of item +# Item item to be cached (optional) +# +# Returns: +# Cached item if no item specified, undef otherwise +#------------------------------------------------------------------------------ + +my %cache; + +sub cache( $;$ ) +{ + my ($self, $name, $item) = @_; + + if ($item) + { + $cache{$name} = $item; + } + else + { + return $cache{$name}; + } + + return undef; +} + + +#------------------------------------------------------------------------------ +# sub clear_cache() +# +# Clears any cached values. +#------------------------------------------------------------------------------ + +sub clear_cache() +{ + %cache = (); +} + + +#------------------------------------------------------------------------------ +# sub get_message_log_line() +# +# Gets the next line from the message log. +# Will cache log entries if the period covered is short. +#------------------------------------------------------------------------------ + +sub get_message_log_line +{ + my $self = shift; + my $line; + + if (exists $self->{logindex}) + { + # Reading from the cache + + if ($self->{logindex} < @{ $self->{logcache} }) + { + return $self->{logcache}[$self->{logindex}++]; + } + else + { + # End of cache - reset to start again on next call + + $self->{logindex} = 0; + return undef; + } + } + + $self->{logfile} = $self->{'weeks_covered'} if (not exists $self->{logfile} or $self->{logfile} < 0); + + LINE: + while (1) + { + if (not exists $self->{fh} or (exists $self->{fh} and eof $self->{fh})) + { + # Reading from a file and need to open a file + + FILE: + while ($self->{logfile} >= 0) + { + my $name = $self->{logfile} < 1 ? LOGNAME : LOGNAME . '.' . $self->{logfile}; + $self->{logfile}--; + + if (-r $name) + { + # Not compressed + + open $self->{fh}, '<', $name or die "Can't open $name: $!"; + $self->{year} = (localtime( (stat(_))[9] ))[YEAR]; + last FILE; + } + elsif (-r "$name.gz") + { + # Compressed + + open $self->{fh}, "gzip -dc $name.gz |" or next; + $self->{year} = (localtime( (stat(_))[9] ))[YEAR]; + last FILE; + } + + # Not found - go back for next file + } + + if ($self->{logfile} < -1) + { + # No further files - reset to start again on next call + + delete $self->{fh}; + return undef; + } + } + + if (exists $self->{fh}) + { + # Reading from a file + + $line = readline $self->{fh}; + + if (eof $self->{fh}) + { + if ($self->{logfile} < 0) + { + # No further files - reset to start again on next call + + delete $self->{fh}; + return undef; + } + # Go back for next file + + close $self->{fh}; + next LINE; + } + + my ($mon, $day, $hour) = unpack 'Lsxs', $line; + + if ($mon != $self->{last_mon} or $day != $self->{last_day} or $hour != $self->{last_hour}) + { + # Hour, day or month changed. Convert to unix time so we can work out + # whether the message time falls between the limits we're interested in. + # This is complicated by the lack of a year in the logged information, + # so assume the current year, and adjust if necessary. + + my @time; + + $time[YEAR] = $self->{year}; + + ($time[MON], $time[MDAY], $time[HOUR], $time[MIN], $time[SEC]) = split /[\s:]+/, $line; + $time[MON] = $months{$time[MON]}; + + $self->{time} = timelocal( @time ); + + if ($self->{time} > time()) + { + # We can't have times in the future, so this must be the previous year. + + $self->{year}--; + $time[YEAR]--; + $self->{time} = timelocal( @time ); + $self->{last_time} = $self->{time}; + } + elsif ($self->{time} < $self->{last_time}) + { + # Time should be increasing, so we must have gone over a year boundary. + + $self->{year}++; + $time[YEAR]++; + $self->{time} = timelocal( @time ); + $self->{last_time} = $self->{time}; + } + + ($self->{last_mon}, $self->{last_day}, $self->{last_hour}) = ($mon, $day, $hour); + } + + # Check to see if we're within the specified limits. + # Note that the minutes and seconds may be incorrect, but since we only deal + # in hour boundaries this doesn't matter. + + next LINE if ($self->{time} < $self->{start_time}); + + if ($self->{time} > $self->{end_time}) + { + # After end time - reset to start again on next call + + close $self->{fh}; + delete $self->{fh}; + $self->{logfile} = $self->{'weeks_covered'}; + + return undef; + } + + # Cache the entry if the time covered is less than two days + + push @{$self->{logcache}}, $line if ($self->{'total_days'} <= 2); + + return $line; + } + } + + return $line; +} + + +#------------------------------------------------------------------------------ +# sub lookup_ip_address( string ) +# +# Converts an IP Address to a URL +#------------------------------------------------------------------------------ + +sub lookup_ip_address( $$ ) +{ + my ($self, $address) = @_; + + use Socket; + + return $address_lookup_cache{$address} if (exists $address_lookup_cache{$address}); + + my $name = gethostbyaddr( inet_aton( $address ), AF_INET ) || ""; + + $address_lookup_cache{$address} = $name; + + return $name; +} + + +#------------------------------------------------------------------------------ +# sub set_host_name( address, name ) +# +# Records the mapping from an IP address to a name +#------------------------------------------------------------------------------ + +sub set_host_name( $$$ ) +{ + my ($self, $address, $name) = @_; + + return unless ($address and $name); + return if ($address eq $name); + + if (exists $address_lookup_cache{$address}) + { + $address_lookup_cache{$address} = "" if ($address_lookup_cache{$address} ne $name); + } + else + { + $address_lookup_cache{$address} = $name; + } +} + + +#------------------------------------------------------------------------------ +# sub spilt_string( string, size ) +# +# Splits a string into multiple lf separated lines +#------------------------------------------------------------------------------ + +sub split_string( $$$ ) +{ + my ($self, $string, $size) = @_; + + my $out = ''; + + while (length $string > $size) + { + $string =~ s/(.{$size,}?)\s+//; + last unless ($1); + $out .= $1 . "\n"; + } + + $out .= $string; + + return $out; +} + +1; diff --git a/src/statusmail/statusmail.pl b/src/statusmail/statusmail.pl new file mode 100755 index 000000000..4ced65880 --- /dev/null +++ b/src/statusmail/statusmail.pl @@ -0,0 +1,422 @@ +#!/usr/bin/perl + +############################################################################ +# # +# Send log and status emails for IPFire # +# # +# This is free software; you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation; either version 3 of the License, or # +# (at your option) any later version. # +# # +# This is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with IPFire; if not, write to the Free Software # +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # +# # +# Copyright (C) 2018 - 2019 The IPFire Team # +# # +############################################################################ +# Main script for statusmail. # +# # +# Usually called by fcron when it will check to see if any schedules are # +# due in which case the schedule will be executed. If the schedule # +# produces any output it is sent as an email to the recipients given in # +# the schedule. Emails are always signed using GPG and will be encrypted # +# if an encryption keys is available for the user. # +# # +# Can also be run with the name of a schedule as an argument in which case # +# the schedule is executed immediately regrardless of whether it is due or # +# not. # +# # +# If run from a terminal additional debugging will be turned on and log # +# messages will be output to the terminal. # +############################################################################ + +use strict; +use warnings; + +use Sys::Syslog qw(:standard :macros); + +use lib "/usr/lib/statusmail"; + +require "/var/ipfire/general-functions.pl"; +require "${General::swroot}/lang.pl"; + +use StatusMail; + +############################################################################ +# Configuration variables +# +# These variables give the locations of various files used by this script +############################################################################ + +my $lib_dir = "/usr/lib/statusmail"; +my $plugin_dir = "$lib_dir/plugins"; +my $stylesheet = "$lib_dir/stylesheet.css"; +my $mainsettings = "${General::swroot}/main/settings"; +my $mailsettings = "${General::swroot}/dma/mail.conf"; +my $contactsettings = "${General::swroot}/statusmail/contact_settings"; +my $schedulesettings = "${General::swroot}/statusmail/schedule_settings"; +my $debug = 0; + +############################################################################ +# Function prototypes +############################################################################ + +# Used by plugins + +sub add_mail_item( % ); + +# Local functions + +sub send_email( $ ); +sub execute_schedule( $$ ); +sub abort( $ ); +sub log_message( $$ ); +sub debug( $$ ); + +############################################################################ +# Variables +############################################################################ + +my %mainsettings = (); +my %sections = (); +my $contacts = {}; +my $schedules = {}; +my %mailsettings = (); + + +############################################################################ +# Main function +############################################################################ + +openlog( "statusmail", "nofatal", LOG_USER); +log_message LOG_INFO, "Starting log and status email processing"; + +# Check for existence of settings files + +exit unless (-r $contactsettings); +exit unless (-e $mailsettings); +exit unless (-r $schedulesettings); + +# Read settings + +General::readhash($mailsettings, %mailsettings); +General::readhash($mainsettings, %mainsettings); + +unless ($mailsettings{'USEMAIL'} eq 'on') +{ + log_message LOG_WARNING, "Email disabled"; + exit; +}; + +eval qx|/bin/cat $contactsettings| if (-r $contactsettings); +eval qx|/bin/cat $schedulesettings| if (-r $schedulesettings); + +# Scan for plugins + +opendir DIR, $plugin_dir or abort "Can't open Plug-in directory $plugin_dir: $!"; + +foreach my $file (readdir DIR) +{ + next unless ($file =~ m/.pm$/); + + debug 1, "Initialising plugin $file"; + + require "$plugin_dir/$file"; +} + +# Check command line parameters + +if (@ARGV) +{ + # Command line parameters provided - try to execute the named schedule. + + my ($schedule) = $ARGV[0]; + + if (exists $$schedules{$schedule}) + { + execute_schedule( $schedule, $$schedules{$schedule} ); + } + else + { + print "Schedule '$schedule' not found\n"; + } + + closelog; + exit; +} + +# Look for a due schedule + +my (undef, undef, $hour, $mday, undef, undef, $wday, undef, undef) = localtime; + +$hour = 1 << $hour; +$wday = 1 << $wday; +$mday = 1 << $mday; + +foreach my $schedule (keys %$schedules) +{ + next unless ($$schedules{$schedule}{'enable'} eq 'on'); # Must be enabled + + next unless ($$schedules{$schedule}{'mday'} & $mday or # Must be due today + $$schedules{$schedule}{'wday'} & $wday); + + next unless ($$schedules{$schedule}{'hours'} & $hour); # Must be due this hour + + debug 1, "Schedule $schedule due"; + + execute_schedule( $schedule, $$schedules{$schedule} ); +} + +closelog; + +exit; + +#------------------------------------------------------------------------------ +# sub execute_schedule( name, schedule ) +# +# Executes the specified schedule as long as at least one of the contacts is +# enabled. +# +# Parameters: +# name name of Schedule +# schedule reference of Schedule hash to be executed +#------------------------------------------------------------------------------ + +sub execute_schedule( $$ ) +{ + my ($name, $schedule) = @_; + my @contacts; + my $status = 0; + + # Check that at least one of the contacts is enabled + + foreach my $contact (split '|', $$schedule{'email'}) + { + push @contacts, $contact if (exists $$contacts{$contact} and $$contacts{$contact}{'enable'} eq 'on'); + } + + if (not @contacts) + { + debug 1, "No enabled contacts"; + return; + } + + log_message LOG_INFO, "Executing status mail schedule $name"; + + # Look for a theme stylesheet + + my $theme_stylesheet = "$lib_dir/$mainsettings{'THEME'}.css"; + $stylesheet = $theme_stylesheet if (-r $theme_stylesheet); + + # Create message + + my $message = new StatusMail( 'format' => $$schedule{'format'}, + 'subject' => $$schedule{'subject'}, + 'to' => [ @contacts ], + 'sender' => $mailsettings{'SENDER'}, + 'max_lines_per_item' => $$schedule{'lines'}, + 'stylesheet' => $stylesheet ); + + if (not $message) + { + log_message LOG_WARNING, "Failed to create message object: $!"; + return; + } + + $message->calculate_period( $$schedule{'period-value'}, $$schedule{'period-unit'} ); + + $message->add_text( "$Lang::tr{'statusmail period from'} " . localtime( $message->get_period_start ) . + " $Lang::tr{'statusmail period to'} " . localtime( $message->get_period_end ) . "\n" ); + + # Loop through the various log items + + foreach my $section ( sort keys %sections ) + { + debug 3, "Section $section"; + $message->add_section( $section ); + + foreach my $subsection ( sort keys %{ $sections{$section} } ) + { + debug 3, "Subsection $subsection"; + $message->add_subsection( $subsection ); + + foreach my $item ( sort keys %{ $sections{$section}{$subsection} } ) + { + debug 3, "Item $item"; + + # Is the item enabled? + + my $key = $sections{$section}{$subsection}{$item}{'ident'}; + + next unless (exists $$schedule{"enable_$key"} and $$schedule{"enable_$key"} eq 'on'); + next unless ($sections{$section}{$subsection}{$item}{'format'} eq 'both' or + $sections{$section}{$subsection}{$item}{'format'} eq $$schedule{'format'}); + + # Yes. Call the function to get it's content - with option if necessary + + debug 2, "Process item $section :: $subsection :: $item"; + + $message->add_title( $item ); + + my $function = $sections{$section}{$subsection}{$item}{'function'}; + + if (exists $$schedule{"value_$key"}) + { + $status += &$function( $message, $$schedule{"value_$key"} ); + } + else + { + $status += &$function( $message ); + } + } + + $message->clear_cache; + } + } + + # End the Message + + if ($status > 0) + { + debug 1, "Send mail message"; + $message->send; + } +} + + +#------------------------------------------------------------------------------ +# sub add_mail_item( params ) +# +# Adds a possible status item to the section and subsection specified. This +# function is called from the BEGIN block of the plugin. +# +# Any errors cause the item to be ignored without raising an error. +# +# Parameters: +# params hash containing details of the item to be added: +# section name of the section containing this item +# subsection name of the subsection containing this item +# item name of the item +# function function called to add item to message +# format available formats for the item 'html', 'text' or 'both' +# option hash specifying option parameter (optional) +# +# option can specify either a selection or an integer. For a selection it +# contains: +# type must be 'option' +# values array of strings representing the possible options +# +# For an integer option contains: +# type must be 'integer' +# min minimum valid value of parameter +# max maximum valid value of parameter +#------------------------------------------------------------------------------ + +sub add_mail_item( % ) +{ + my %params = @_; + + # Check for all required parameters + + return unless (exists $params{'section'} and + exists $params{'subsection'} and + exists $params{'item'} and + exists $params{'function'} ); + + # Check the option + + if ($params{'option'}) + { + return unless (ref $params{'option'} eq 'HASH'); + + if ($params{'option'}{'type'} eq 'select') + { + return unless (ref $params{'option'}{'values'} eq 'ARRAY' and @{ $params{'option'}{'values'} } > 1); + } + elsif ($params{'option'}{'type'} eq 'integer') + { + return unless (exists $params{'option'}{'min'} and + exists $params{'option'}{'max'} and + $params{'option'}{'min'} < $params{'option'}{'max'}); + } + else + { + return; + } + } + + $params{'format'} = 'both' unless (exists $params{'format'}); + + # Record that the option exists + + $sections{$params{'section'}}{$params{'subsection'}}{$params{'item'}} = { 'function' => $params{'function'}, + 'format' => $params{'format'}, + 'ident' => $params{'ident'} }; +} + + +#------------------------------------------------------------------------------ +# sub abort( message ) +# +# Aborts the update run, printing out an error message. +# +# Parameters: +# message Message to be printed +#------------------------------------------------------------------------------ + +sub abort( $ ) +{ +my ($message) = @_; + + log_message( LOG_ERR, $message ); + croak $message; +} + + +#------------------------------------------------------------------------------ +# sub log_message( level, message ) +# +# Logs a message to the system log. If the script is run from the terminal +# then the message is also printed locally. +# +# Parameters: +# level Severity of message +# message Message to be logged +#------------------------------------------------------------------------------ + +sub log_message( $$ ) +{ + my ($level, $message) = @_; + + print "($level) $message\n" if (-t STDIN); + syslog( $level, $message ); +} + + +#------------------------------------------------------------------------------ +# sub debug( level, message ) +# +# Optionally logs a debug message +# +# Parameters: +# level Debug level +# message Message to be logged +#------------------------------------------------------------------------------ + +sub debug( $$ ) +{ + my ($level, $message) = @_; + + if (($level <= $debug) or + ($level == 1 and -t STDIN)) + { + log_message LOG_DEBUG, $message; + } +}