#!/usr/bin/perl -w # # syslog.monitor - monitors incoming syslog packets and reports to mon # # Author: Lars Marowsky-Brée, lars@marowsky-bree.de # # Copyright (C) 1999 Lars Marowsky-Brée # # This program 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 2 of the License, or # (at your option) any later version. # # This program 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 this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # ### Nothing to see below this line. ### Abandon hope all ye who enter here ### Here be dragons! ############################################################################# # Me, use modules? no, me never use modules package main; use strict; use Socket; use Net::hostent; use Time::HiRes qw (time alarm sleep gettimeofday); use Mon::Client; use POSIX qw(setsid strftime); # automagically inserted by CVS my $VERSION = '$Id: syslog.monitor,v 1.2 2004/11/15 14:45:19 vitroth Exp $'; ############################################################################# # Global variables # Map syslog facility numbers to names my %Num2Facility = ( 0 => 'kern', 1 => 'user', 2 => 'mail', 3 => 'daemon', 4 => 'auth', 5 => 'syslog', 6 => 'lpr', 7 => 'news', 8 => 'uucp', 9 => 'cron', 10 => 'authpriv', 11 => 'ftp', 12 => 'reserved-12', 13 => 'reserved-13', 14 => 'reserved-14', 15 => 'reserved-15', 16 => 'local0', 17 => 'local1', 18 => 'local2', 19 => 'local3', 20 => 'local4', 21 => 'local5', 22 => 'local6', 23 => 'local7', ); # Map syslog level numbers to names my %Num2Level = ( 0 => 'emerg', 1 => 'alert', 2 => 'crit', 3 => 'err', 4 => 'warn', 5 => 'notice', 6 => 'info', 7 => 'debug' ); # Contains a list of LogEntry object init params my %Checks = (); # Hash of hostgroup members hostnames, indexed by hostgroup name my %GROUP_MEMBERS; # IP -> hostname resolving my %IP2Host; # IP -> hostgroup resolving my %IP2Group; # array of references to LogEntry objects, indexed by hostname my %ChecksPerHost; # array of references to LogEntry objects per hostgroup my %ChecksPerGroup; # Global Mon::Client object my $mon; # The configuration is read into this hash my %CONF; ############################################################################# # Setup my ($conf_file) = @ARGV; if (!defined($conf_file) || $conf_file eq "") { die "No configuration file given"; } &ReadConf($conf_file); if ($CONF{'daemon_mode'} == 1) { if ($CONF{'logfile'} ne '') { &daemonize; } else { &Log(2,"You can't summon a daemon while talking to the public"); } } # We need some information from the mon server now... &ChatMonServer; # Parse the hosts, resolve them etc &ParseHosts; # Build the cache, precompile the checks &BuildChecks; # Open listener port my $proto = getprotobyname('udp'); socket(SOCKET, Socket::PF_INET, Socket::SOCK_DGRAM, $proto) || die "Could not create listening socket: $!"; bind(SOCKET, scalar Socket::sockaddr_in($CONF{'bind_port'}, Socket::inet_aton($CONF{'bind_ip'}))) || die "Could not bind authentication socket: $!"; # prepare to select my ($whence,$line,$rin,$rout); $rin = ''; vec($rin, fileno(SOCKET), 1) = 1; # At which time we did the last full walk of the chains my $last_full_walk = time; # Msg - contains the currently processed message # LastMsg - contains the last Msg hash, per host my (%LastMsg,%Msg); ############################################################################# LOOP: while (1) { if (!select($rout = $rin, undef, undef, $CONF{'select_timeout'})) { &Log(7,"select timeout"); next LOOP; } # Read the incoming UDP packet if (!($whence = recv(SOCKET, $line, 8192, 0) )) { &Log(3,"recv error: $!"); next LOOP; } # Parse the incoming UDP packet envelope my ($src_port,$src_ip) = sockaddr_in($whence); $src_ip = inet_ntoa($src_ip); chomp($line); &Log(7,"Received syslog message from $src_ip"); # If this IP does not resolve to a hostname, it is bogus if (!defined($IP2Host{$src_ip})) { &Log(3,"Received unauthorized message from $src_ip, ignoring"); next LOOP; } my ($level,$facility,$msg); if ($line =~ /^\<(\d+)\>([^:]+): (.*)$/o) { # Decode the message %Msg = (); $Msg{'src_port'} = $src_port; $Msg{'src_ip'} = $src_ip; $Msg{'host'} = $IP2Host{$src_ip}; $Msg{'level'} = $1 & 7; $Msg{'Level'} = $Num2Level{$1 & 7}; $Msg{'facility'} = $Num2Facility{$1 >> 3}; $Msg{'msg'} = $3; $Msg{'time'} = time; $Msg{'group'} = $IP2Group{$src_ip}; # Log the message if necessary &OwnLog(\%Msg); # Walk through the processing hooks here... my $check; PER_HOST: foreach $check (@{$ChecksPerHost{ $Msg{'host'} }}) { if ($check->check(\%Msg) == 1) { last PER_HOST; } } PER_GROUP: foreach $check (@{$ChecksPerGroup{ $Msg{'group'}}}) { if ($check->check(\%Msg) == 1) { last PER_GROUP; } } # Store message for further reference %{$LastMsg{$src_ip}} = %Msg; } elsif ($line =~ /^last message repeated (\d+) times$/o) { my $count = $1; # Handle repetition - last msg from the host is still available # in %LastMsg{$src_ip} &Log(7,"Last message repeated $count times"); } else { &Log(2,"Unknown input ignored: $line"); } } continue { # Before continuing, always check if the checks need to be run, # so that the low threshold can be triggered if ($last_full_walk - time > $CONF{'full_walk_timeout'}) { &Log(7,"Full walk triggered after $CONF{'full_walk_timeout'} seconds"); my ($check_ary); foreach $check_ary (@ChecksPerHost{keys %ChecksPerHost}, @ChecksPerGroup{keys %ChecksPerGroup} ) { my ($check); foreach $check (@$check_ary) { &Log(7,"Running for ".$check->{'group'}."/".$check->{'host'}); $check->check({'level' => 7, 'Level' => $Num2Level{7}, 'msg' => 'SYSLOG.MONITOR: SELECT TIMEOUT', 'time' => time, }); } # foreach $check } # foreach $check_ary } # if } # continue ############################################################################# sub BuildChecks { &Log(6,"Building check cache, precompiling objects"); # First, build the per-host cache my ($group); foreach $group (keys %{$CONF{'checks-per-host'}}) { if (defined($GROUP_MEMBERS{$group})) { # Build the "per-host" checks my ($host); foreach $host (@{$GROUP_MEMBERS{$group}}) { &Log(6,"Building per host checks for $group/$host"); my ($check); CHECK: foreach $check (@{$CONF{'on-host'}{$group}{$host}},@{$CONF{'checks-per-host'}{$group}}) { if (!defined($Checks{$check})) { &Log(3,"Undefined check $check for $host, ignoring"); next CHECK; } push @{$ChecksPerHost{$host}},LogEntry->new($Checks{$check},$group,$host); } } } else { &Log(3,"Unknown hostgroup $group referenced in config file"); } } # Second, build the per-group cache foreach $group (keys %{$CONF{'checks-per-group'}}) { if (defined($GROUP_MEMBERS{$group})) { &Log(6,"Building per group checks for $group"); my $check; CHECK: foreach $check (@{$CONF{'checks-per-group'}{$group}}) { if (!defined($Checks{$check})) { &Log(3,"Undefined check $check for group $group, ignoring"); next CHECK; } push @{$ChecksPerGroup{$group}},LogEntry->new($Checks{$check},$group,'ALL'); } } else { &Log(3,"undefined group $group, ignoring"); next GROUP; } } &Log(6,"Finished building check cache"); } sub FormatTime { # Prints the time like a proper Cisco my ($time) = @_; return strftime("%b %e %H:%M:%S", localtime($time)) .sprintf(".%03d",($time - int($time)) * 1000 ); } # Log a message if the priority is high enough sub Log { my ($prio,$msg) = @_; if ($prio <= $CONF{'loglevel'}) { my $line = &FormatTime(time). sprintf(": %- 6.6s: %s\n", $Num2Level{$prio},$msg); if ($CONF{'logfile'} ne "") { open(LOG,">>$CONF{'logfile'}") || die "Could not open logfile!"; print LOG $line; close(LOG); } else { print $line; } } } sub OwnLog { # Log the message to the file specified in syslog.conf my ($r) = @_; return if ($CONF{'syslogfile'} eq ""); my $f = $CONF{'syslogfile'}; # Ok, logfile is defined. do the substitutions my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime($$r{'time'}); $year += 1900; print $f."\n"; $f =~ s/\%H/$$r{host}/; $f =~ s/\%L/$$r{Level}/; $f =~ s/\%l/$$r{level}/; $f =~ s/\%F/$$r{facility}/; $f =~ s/\%G/$$r{group}/; $f =~ s/\%D/sprintf "%04d-%02d-%02d",$year,$mon,$mday/e; # Make sure everything is still okay $f =~ s/[^A-Za-z0-9\.\-\/]//og; open(F,">>$f"); print F &FormatTime($$r{'time'}).sprintf(" %s %s.%s: %s\n", $$r{'host'}, $$r{'facility'},$$r{'Level'},$$r{'msg'}); close(F); } sub ChatMonServer { # Setup the mon connection $mon = Mon::Client->new( host => $CONF{'mon_host'}, username => $CONF{'mon_user'}, password => $CONF{'mon_pass'}); &Log(6,"Connecting to mon host $CONF{'mon_host'}"); # Retrieve information from the mon server about hostgroups if (!defined ($mon->connect)) { &Log(2,"Could not connect to server: " . $mon->error); die; } my %opstatus; if (!(%opstatus = $mon->list_opstatus)) { &Log(2,"could not get opstatus: " . $mon->error); $mon->disconnect; die; } # We are only interested in hostgroups which have the "syslog" service # defined, and thus are able to process our traps my ($group); foreach $group (keys %opstatus) { if (defined($opstatus{$group}{'syslog'})) { my (@hosts) = $mon->list_group($group); @{$GROUP_MEMBERS{$group}} = @hosts; } } # We don't need the TCP connection anymore from here on. # This might change in the future if Mon::Client ever sends # traps via tcp $mon->disconnect; } # Parse the hostnames, and fill in the %IP2Host / %IP2Group sub ParseHosts { my ($group,$host); &Log(6,"Resolving hostnames and building cache"); foreach $group (keys %GROUP_MEMBERS) { HOST: foreach $host (@{$GROUP_MEMBERS{$group}}) { my $h = gethostbyname($host); if (!defined($h)) { &Log(3,"Failed to resolve $host, ignoring"); next HOST; } if (@{$h->addr_list} > 1 ) { my $addr; for $addr ( @{$h->addr_list} ) { $IP2Host{inet_ntoa($addr)} = $host; $IP2Group{inet_ntoa($addr)} = $group; } } else { $IP2Host{inet_ntoa($h->addr)} = $host; $IP2Group{inet_ntoa($h->addr)} = $group; } } } } # Send a trap to the mon server sub SendTrap { my ($l) = @_; my ($typ,$opstatus,$sum,$dtl); if ($l->{status} == 0) { $opstatus = 'ok'; $sum = $l->{host}.": ".$l->{desc}." ok since " .localtime($l->{status_time}); $dtl = "\nHappened ".scalar(@{$l->{matches}})." within " .$l->{period}."s"; } elsif ($l->{status} == -1) { $opstatus = 'fail'; $sum = $l->{host}.": ".$l->{desc}." occured too seldom since " .localtime($l->{status_time}); $dtl = "\nLast time was " .localtime($l->{last_match}); } elsif ($l->{status} == 1) { $opstatus = 'fail'; $sum = $l->{host}.": ".$l->{desc}." occured too often since " .localtime($l->{status_time}); $dtl = "\nHappened ".scalar(@{$l->{matches}})." within " .$l->{period}."s\n"; # Include copy of the line which triggered the trap $dtl .= ${$l->{'last_matched_msg'}}{'msg'}."\n"; } else { &Log(0,"BUG: Unknown status in SendTrap"); return undef; } &Log(4,"Sending trap: ".$l->{'group'}." $opstatus $sum"); # Send the trap $mon->send_trap( group => $l->{'group'}, service => 'syslog', retval => 1, opstatus => $opstatus, summary => $sum, detail => $dtl) || &Log(2, "trap sending failed: ".$mon->error); } sub ReadConf { my ($conf) = @_; if ($conf !~ /^[a-z0-9\.\-\/]+$/oi) { &Log(1,"Security violation: $conf contains illegal characters"); die; } # Setup defaults %CONF = ( 'select_timeout' => 10, 'full_walk_timeout' => 30, 'bind_ip' => '0.0.0.0', 'bind_port' => 514, 'logfile' => '', 'daemon_mode' => 0, 'syslogfile' => "", ); if (!open(CONF,"<$conf")) { &Log(2,"Failed to open configuration file"); die; } my ($l,$lineno); my $level = 'global'; my ($CHECKNAME,$GROUPNAME); while (defined($l = <CONF>)) { chomp $l; $l =~ s/^\s*//; $l =~ s/\s*$//; $lineno++; next if $l =~ /^#/; if ($level eq 'global') { if ($l =~ /^full_walk_timeout\s+(.*)$/o) { $CONF{'full_walk_timeout'} = &dhmstos($1); next; } elsif ($l =~ /^select_timeout\s+(.*)$/o) { $CONF{'select_timeout'} = &dhmstos($1); next; } elsif ($l =~ /^loglevel\s+(\d)$/o) { $CONF{'loglevel'} = $1; next; } elsif ($l =~ /^logfile\s+([a-z0-9\.\-\/]*)$/io) { $CONF{'logfile'} = $1; next; } elsif ($l =~ /^syslogfile\s+([\%a-z0-9\.\-\/]+)$/io) { $CONF{'syslogfile'} = $1; next; } elsif ($l =~ /^daemon_mode\s*$/o) { $CONF{'daemon_mode'} = 1; next; } elsif ($l =~ /^bind_ip\s+(\d+\.\d+\.\d+\.\d+)$/o) { $CONF{'bind_ip'} = $1; next; } elsif ($l =~ /^bind_port\s+(\d+)$/o) { $CONF{'bind_port'} = $1; next; } elsif ($l =~ /^mon_host\s+(\S+)$/o) { $CONF{'mon_host'} = $1; next; } elsif ($l =~ /^mon_user\s+(\S+)$/o) { $CONF{'mon_user'} = $1; next; } elsif ($l =~ /^mon_pass\s+(\S+)$/o) { $CONF{'mon_pass'} = $1; next; } elsif ($l =~ /^check\s+(\S+)$/o) { $level = 'check'; $CHECKNAME = lc($1); $Checks{$CHECKNAME} = { 'name' => lc($1), 'period' => 300, 'min' => -1, 'max' => 1, 'final' => 0, 'desc' => 'I was too lazy to write a proper configuration file', }; next; } elsif ($l =~ /^group\s+(.*)$/o) { $level = 'group'; $GROUPNAME = $1; next; } elsif ($l eq "") { next; } ################ END GLOBAL CONFIGURATION FILE OPTIONS } elsif ($level eq 'check') { if ($l =~ /^period\s+(.*)$/o) { $Checks{$CHECKNAME}{'period'} = &dhmstos($1); next; } elsif ($l =~ /^min\s+(\-?\d+)$/o) { $Checks{$CHECKNAME}{'min'} = $1; next; } elsif ($l =~ /^max\s+(\-?\d+)$/o) { $Checks{$CHECKNAME}{'max'} = $1; next; } elsif ($l =~ /^desc\s+(.*)$/o) { $Checks{$CHECKNAME}{'desc'} = $1; next; } elsif ($l =~ /^pattern\s+(.*)$/o) { $Checks{$CHECKNAME}{'pattern'} = $1; next; } elsif ($l =~ /^final\s*$/o) { $Checks{$CHECKNAME}{'final'} = 1; next; } elsif ($l eq "") { # blank line indicates end of check block $level = 'global'; $CHECKNAME = ''; next; } #### END OF "CHECK" part } elsif ($level eq 'group') { if ($l =~ /^per-host\s+(.*)$/o) { @{$CONF{'checks-per-host'}{$GROUPNAME}} = split(/\s+/,$1); next; } elsif ($l =~ /^per-group\s+(.*)$/o) { @{$CONF{'checks-per-group'}{$GROUPNAME}} = split(/\s+/,$1); next; } elsif ($l =~ /^on-host\s+(\S+)\s+(.*)$/o) { @{$CONF{'on-host'}{$GROUPNAME}{$1}} = split(/\s+/,$2); next; } elsif ($l eq "") { $level = 'global'; $GROUPNAME = ''; next; } } &Log(3,"Error while parsing configuration file, line $lineno: $l"); } } # # convert a string like "20m" into seconds # sub dhmstos { my ($str) = @_; my ($s); if ($str =~ /^\s*(\d+(?:\.\d+)?)([dhms])\s*$/i) { if ($2 eq "m") { $s = $1 * 60; } elsif ($2 eq "h") { $s = $1 * 60 * 60; } elsif ($2 eq "d") { $s = $1 * 60 * 60 * 24; } else { $s = $1; } } else { return undef; } $s; } sub daemonize { chdir '/' or die "Can't chdir to /: $!"; open STDIN, '/dev/null' or die "Can't read /dev/null: $!"; open STDOUT, '>/dev/null' or die "Can't write to /dev/null: $!"; defined(my $pid = fork) or die "Can't fork: $!"; exit if $pid; setsid or die "Can't start a new session: $!"; open STDERR, '>&STDOUT' or die "Can't dup stdout: $!"; } ############################################################################## package LogEntry; # Some of the more important stuff happens here use strict; use Time::HiRes qw (time alarm sleep); BEGIN { use Exporter (); use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS); # set the version for version checking $VERSION = 0.01; @ISA = qw(Exporter); @EXPORT = qw(); %EXPORT_TAGS = ( ); # eg: TAG => [ qw!name1 name2! ], # your exported package globals go here, # as well as any optionally exported functions @EXPORT_OK = qw(); } use vars @EXPORT_OK; # non-exported package globals go here use vars qw(); sub new { my $proto = shift; my $class = ref($proto) || $proto; my $self = { }; bless ($self, $class); # Initialise with remaining arguments if (@_) { $self->init(@_); } return $self; } sub init (\%$$) { my ($self,$INIT,$group,$host) = @_; # We load some values from the INIT hash %{$self} = %{$INIT}; # After changing the pattern, it is sensible to reset our counters @{$self->{matches}} = (); $self->{group} = $group; $self->{host} = $host; # 0 : did not trigger # -1 : triggered because of too few matches # 1 : triggered because of too many matches $self->{status} = 0; $self->{status_time} = time; $self->{last_match} = 0; # The checkitem is a piece of code which we precompile here. my $code = 'sub { my ($r)=@_; if ('.$$INIT{'pattern'} .') { return 1; } else { return 0 } }'; &::Log(7,"Compiling: $code"); $self->{matcher} = eval $code; if ($@) { &::Log(2,"Error while compiling ".$$INIT{'name'}." ignoring"); $self->{matcher} = sub { return 0; }; } return $self->{matcher}; } sub check { my ($self,$msg) = @_; &::Log(7,"Checking ".$self->{desc}); my $code; eval { $code = &{$self->{matcher}}($msg); }; if ($@) { &::Log(2,"$self->{desc}: Fatal error while matching: $@"); return 0; } my $t = time; # Trim our data backlog while ( (scalar(@{$self->{matches}})>0) && ($t-$self->{matches}[0] > $self->{period})) { shift @{$self->{matches}} } if ($code == 1) { &::Log(7,"$self->{desc}: Matched"); # Pattern matched. Record timestamp. push @{$self->{matches}},$t; $self->{last_match} = $t; # Keep a copy of the last match %{$self->{last_matched_msg}} = %{$msg}; } my $count = scalar(@{$self->{matches}}); my $age = $t-$self->{status_time}; # First, we check if we matched too often. We don't check for # the age here since nothing is going to magically lower the match # counter. if (($count > $self->{max})) { &::Log(7,"$self->{desc}: Matched too often within period"); $self->trigger(1); # if we are below the threshold, and our age is at least # period (we need to check for the age - otherwise, we might # later on receive more messages and be alright / too high) } elsif (($count < $self->{min}) && ($age >= $self->{period})) { &::Log(7,"$self->{desc}: Matched too seldom within period"); $self->trigger(-1); # same in blue for the "ok" condition } elsif (($count > $self->{min}) && ($age >= $self->{period})) { &::Log(7,"$self->{desc}: Roger"); $self->trigger(0); } &::Log(7,"$self->{desc}: Current counter: $count"); # Abort processing if we are a final check and matched if ( ($code == 1) && ($self->{'final'} == 1) ) { &::Log(7,"$self->{desc}: Terminating walk due to final check"); return 1; } else { return 0; } &::Log(0,"Here are dragons"); die; } sub trigger { my ($self,$status) = @_; return if ($status == $self->{status}); &::Log(6,"$self->{desc}: Status change: ".$self->{status}."->".$status ." Counter: ".scalar(@{$self->{matches}})); $self->{status} = $status; $self->{status_since} = time; # We had a status change and need to send the right trap &::SendTrap($self); }