#!/usr/bin/perl =pod =head1 NAME nagios-ttsd.pl =head1 SYNOPSIS Poll a Nagios host for alerts and translate them from text to speech bounded by filters established within the configuration file. =head1 CONFIGURATION The configuration file has 3 sections: main, filters, and translations. The main section contains information on where Nagios is on the network, how often to poll it, and the username and password to use to log into Nagios to retrieve service and host status. The account used for retrieving status should only be able to view statuses; do not give it permission to execute commands, leave comments, etc. Finally, there is a debug variable in the main section that will cause the script to output verbose debugging information to a file if you set the value of debug to a file name. The filters section allows you to filter what alerts are retrieved from Nagios based on service name, host name, and service or host state. The translations section contains a list of words and replacements for them. Often you will find that text to speech libraries do not handle English spellings properly, so being able to replace proper spellings with phoenetic spellings can make alerts sound much more natural when spoken. Place new words or phrases between EOF markers and put %% between the word or phrase to catch and the translation of the phrase. Additionally the translations section contains host_phrase_template and service_phrase_template variables; you can alter the template for host and service problem phrases to your liking. See the included sample configuration file for more information on configuring this program. =cut use strict; use LWP; use Win32::OLE; use Config::IniFiles; use CGI; use FindBin; my $CF_FILE = shift || "$FindBin::Bin/nagios-ttsd.ini"; my %HOST_STATES = qw( PENDING 1 UP 2 DOWN 4 UNREACHABLE 8 ); my %SVC_STATES = qw( PENDING 1 OK 2 WARNING 4 UNKNOWN 8 CRITICAL 16 ); my $CAN_SSL = 0; eval { require Net::SSLeay; import Net::SSLeay; }; $CAN_SSL = 1 unless $@; my $CFG = read_config($CF_FILE); my $DEBUG = $CFG->{'main'}->{'debug'} || ''; my $VERBOSE = $CFG->{'main'}->{'verbose'} || 0; validate_config($CFG, $CAN_SSL, \%HOST_STATES, \%SVC_STATES); debug("Starting"); my $TTS = Win32::OLE->new("Sapi.SpVoice") || die "Sapi.SpVoice failed"; $TTS->{'Voice'} = $TTS->GetVoices->Item($CFG->{'main'}->{'voice'}); my $SLEEP = $CFG->{'main'}->{'polling_interval'}; my @TRANSLATIONS = @{$CFG->{'translations'}->{'phrase_list'}}; my $HOST_PHRASE = $CFG->{'translations'}->{'host_phrase_template'}; my $SERVICE_PHRASE = $CFG->{'translations'}->{'service_phrase_template'}; while (1) { speak("Checking for host alerts") if $VERBOSE; my @h = check_for_host_alerts($CFG, \%HOST_STATES); speak_alerts($HOST_PHRASE, \@TRANSLATIONS, @h); speak("Checking for service alerts") if $VERBOSE; my @s = check_for_service_alerts($CFG, \%SVC_STATES); speak_alerts($SERVICE_PHRASE, \@TRANSLATIONS, @s); speak("Sleep for $SLEEP seconds") if $VERBOSE; sleep $SLEEP; } exit 0; sub read_config { my $cfg_file = shift; my %ini; tie %ini, 'Config::IniFiles', (-file => $cfg_file); return \%ini; } sub validate_config { my $cfg = shift; my $has_ssl = shift; my $host_states = shift; my $svc_states = shift; my @errors; my @main_req = qw( nagios_url nagios_user nagios_pass polling_interval voice ); for my $param (@main_req) { if ($cfg->{'main'}->{$param} eq '') { push(@errors, "Missing $param in main"); } } my $nagios_url = $cfg->{'main'}->{'nagios_url'}; if ($nagios_url ne '') { if ($nagios_url !~ m/^http/i) { push(@errors, "Invalid nagios_url, must start with http or https"); } if (($nagios_url =~ m/^https/i) && ($has_ssl == 0)) { push(@errors, "nagios_url uses SSL but Net::SSLeay is not present"); } $cfg->{'main'}->{'nagios_url'} =~ s/\/$//; } my $interval = $cfg->{'main'}->{'polling_interval'}; push(@errors, "polling_interval in main must be a number") unless $interval =~ m/^\d+$/; my @ss = split(/\s+/, $cfg->{'filters'}->{'service_statuses'}); if (scalar(@ss) == 0) { $cfg->{'filters'}->{'service_statuses'} = ''; } else { my $ss_regexp = join('|', keys %{$svc_states}); for my $s (@ss) { if ($s !~ m/${ss_regexp}/i) { push(@errors, "Invalid service state $s specified"); } } } if ($cfg->{'filters'}->{'service_regexp'} eq '') { $cfg->{'filters'}->{'service_regexp'} = '.'; } my @hs = split(/\s+/, $cfg->{'filters'}->{'host_statuses'}); if (scalar(@hs) == 0) { $cfg->{'filters'}->{'host_statuses'} = ''; } else { my $hs_regexp = join('|', keys %{$host_states}); for my $s (@hs) { if ($s !~ m/${hs_regexp}/i) { push(@errors, "Invalid host state $s specified"); } } } if ($cfg->{'filters'}->{'host_regexp'} eq '') { $cfg->{'filters'}->{'host_regexp'} = '.'; } if (scalar(@errors) > 0) { warn "Configuration file validation failed\n"; die join("\n", @errors); } debug("Configuration file validated"); } sub debug { return if $DEBUG eq ''; if (! defined($main::DEBUG_FD)) { open($main::DEBUG_FD, ">> $DEBUG") || die "Can't append to debug file $DEBUG: $!"; } my $msg = shift; print {$main::DEBUG_FD} scalar(localtime(time)) . ": $msg\n"; } sub speak { my $msg = shift; debug("Speaking '$msg'"); $TTS->Speak($msg, 0); $TTS->WaitUntilDone(-1); } sub speak_alerts { my $phrase_template = shift; my $translations_ref = shift; my @alerts = @_; for my $item (@alerts) { my $phrase = substitute_phrase($item, $phrase_template); for my $t (@$translations_ref) { my ($match, $replace) = split(/\s*%%/, $t); debug("Translation: s/$match/$replace/g"); $phrase =~ s/$match/$replace/gie; # Substitute in $N variables from user-supplied replacements eval "\$phrase = qq{$phrase}"; } speak($phrase); } } sub check_for_host_alerts { my $cfg = shift; my $host_states = shift; my $base = $cfg->{'main'}->{'nagios_url'}; my $statuses_val = get_value_of_desired_states( $cfg->{'filters'}->{'host_statuses'}, $host_states); # http://www.example.com/nagios/cgi-bin/status.cgi?hostgroup=all # &style=hostdetail&hoststatustypes=12 my $url = "${base}/cgi-bin/status.cgi?hostgroup=all&noheader=yes&" . "style=hostdetail&hoststatustypes=${statuses_val}"; my $content = get_content($cfg, $url); debug("HOST content:\n==========\n$content\n==========="); my @alerts = parse_host_content($content, $cfg); return @alerts; } sub check_for_service_alerts { my $cfg = shift; my $service_states = shift; my $base = $cfg->{'main'}->{'nagios_url'}; my $statuses_val = get_value_of_desired_states( $cfg->{'filters'}->{'service_statuses'}, $service_states); # http://www.example.com/nagios/cgi-bin/status.cgi?host=all& # servicestatustypes=28 my $url = "${base}/cgi-bin/status.cgi?host=all&noheader=yes&" . "servicestatustypes=${statuses_val}"; my $content = get_content($cfg, $url); debug("STATUS content:\n==========\n$content\n==========="); my @alerts = parse_service_content($content); return filter_alerts(\@alerts, $cfg); } sub get_value_of_desired_states { my $states_string = shift; my $states_hash_ref = shift; my @wanted_states; if ($states_hash_ref ne '') { for my $state (split(/\s+/, $states_string)) { debug("Adding state $state to wanted states array"); push(@wanted_states, uc($state)); } } else { @wanted_states = keys %{$states_hash_ref}; } my $statuses_value = 0; for my $key (@wanted_states) { $statuses_value += $states_hash_ref->{$key}; } return $statuses_value; } sub get_content { my $cfg = shift; my $url = shift; my $user = $cfg->{'main'}->{'nagios_user'}; my $pass = $cfg->{'main'}->{'nagios_pass'}; my $ua = NagiosClient->new($user, $pass); debug("Retrieving URL $url"); my $response = $ua->get($url); if (! $response->is_success) { die("Could not retrieve $url: " . $response->status_line . "\n"); } return $response->content; } sub parse_host_content { my $content = shift; my $cfg = shift; my @alerts; while ($content =~ m% .+? # Host name >([^<]+) .+? # Status ([^<]+) .+? # Time nowrap>([^<]+) .+? # Duration nowrap>([^<]+) .+? # Status Information >([^<]+) .+? %xsmgi) { my $alert = { 'type' => 'host', 'host' => decode_html($1), 'status' => decode_html($2), 'time' => decode_html($3), 'duration' => decode_html($4), 'information' => decode_html($5) }; if ($DEBUG ne '') { my $msg = "Host: "; for my $field (keys %$alert) { $msg .= "$field:$alert->{$field} "; } debug($msg); } push(@alerts, $alert); } return @alerts; } sub parse_service_content { my $content = shift; my @alerts; my $host; # HTML::Parser won't parse Nagios HTML, neither will HTML::ExtractTable (tried), # have to do it manually. Blech. Oh how nice it would be to have status.cgi # generate XML! while ($content =~ m% (?: ([^<]+)|) .+? # ' Service description >([^<]+) .+? # Status CLASS='status[A-Z]+'>([A-Z]+) .+? # ' Time nowrap>([^<]+) .+? # Duration nowrap>([^<]+) .+? # Attempts >([^<]+) .+? # Status Information >([^<]+) .+? %xsmgi) { # Host might be empty if this is a host with multiple alerts $host = ($1 ne '') ? $1 : $host; my $alert = { 'type' => 'service', 'host' => decode_html($host), 'service' => decode_html($2), 'status' => decode_html($3), 'time' => decode_html($4), 'duration' => decode_html($5), 'attempts' => decode_html($6), 'information' => decode_html($7) }; if ($DEBUG == 1) { print "Service: "; for my $field (keys %$alert) { print "$field:$alert->{$field} "; } print "\n"; } push(@alerts, $alert); } return @alerts; } sub decode_html { my $string = shift; $string = CGI::unescapeHTML($string); $string =~ s/nbsp//g; return $string; } sub filter_alerts { my $alerts_ref = shift; my $cfg = shift; my $host_regexp = $cfg->{'filters'}->{'host_regexp'}; my $service_regexp = $cfg->{'filters'}->{'service_regexp'}; my @filtered; for my $alert (@$alerts_ref) { my $host = $alert->{'host'}; my $service = $alert->{'service'}; if ($host !~ /$host_regexp/) { debug("Host:'$host' Service:'$service' - host does not match, skipping"); next; } if ($service !~ /$service_regexp/) { debug("Host:'$host' Service:'$service' - service does not match, skipping"); next; } debug("Host:'$host' Service:'$service' - host and service match!"); push(@filtered, $alert); } return @filtered; } sub substitute_phrase { my $vars_ref = shift; my $template = shift; my $phrase = $template; for my $var (keys %$vars_ref) { $phrase =~ s/\%$var/$vars_ref->{$var}/gie; } return $phrase; } # Simple wrapper class to provide an overriden get_basic_credentials method # to LWP::UserAgent so we can login as the user / password in the config # file package NagiosClient; use strict; use base qw(LWP::UserAgent); our $USER = ''; our $PASS = ''; sub new { my $class = shift; $NagiosClient::USER = shift; $NagiosClient::PASS = shift; return $class->SUPER::new(); } sub get_basic_credentials { main::debug("Returning credentials"); return ($USER, $PASS); } 1;