File Coverage

blib/lib/Sagan/Monitoring.pm
Criterion Covered Total %
statement 26 173 15.0
branch 0 72 0.0
condition 0 36 0.0
subroutine 9 12 75.0
pod 3 3 100.0
total 38 296 12.8


line stmt bran cond sub pod time code
1             package Sagan::Monitoring;
2              
3 1     1   58204 use 5.006;
  1         4  
4 1     1   4 use strict;
  1         3  
  1         29  
5 1     1   5 use warnings;
  1         2  
  1         33  
6 1     1   560 use JSON;
  1         10578  
  1         6  
7 1     1   136 use File::Path qw(make_path);
  1         2  
  1         76  
8 1     1   431 use File::ReadBackwards;
  1         1745  
  1         26  
9 1     1   7 use Carp;
  1         1  
  1         44  
10 1     1   449 use File::Slurp;
  1         26242  
  1         65  
11 1     1   545 use Time::Piece;
  1         8727  
  1         14  
12              
13             =head1 NAME
14              
15             Sagan::Monitoring - LibreNMS JSON SNMP extend and Nagios style check for Sagan stats
16              
17             =head1 VERSION
18              
19             Version 1.2.0
20              
21             =cut
22              
23             our $VERSION = '1.2.0';
24              
25             =head1 SYNOPSIS
26              
27             use Sagan::Monitoring;
28              
29             my $args = {
30             mode => 'librenms',
31             drop_percent_warn => .75;
32             drop_percent_crit => 1,
33             files=>{
34             'ids'=>'/var/log/sagan/alert-ids.json',
35             'foo'=>'/var/log/sagan/alert-foo.json',
36             },
37             };
38              
39             my $sm=Sagan::Monitoring->new( $args );
40             my $returned=$sm->run;
41             $sm->print;
42             exit $returned->{alert};
43              
44             =head1 METHODS
45              
46             =head2 new
47              
48             Initiate the object.
49              
50             The args are taken as a hash ref. The keys are documented as below.
51              
52             The only must have is 'files'.
53              
54             This assumes that stats-json.subtract_old_values is set to 'true'
55             for Sagan.
56              
57             - drop_percent_warn :: Drop percent warning threshold.
58             - Default :: .75;
59            
60             - drop_percent_crit :: Drop percent critical threshold.
61             - Default :: 1
62            
63             - files :: A hash with the keys being the instance name and the values
64             being the Eve files to read. ".total" is not a valid instance name.
65             Similarly anything starting with a "." should be considred reserved.
66              
67             - max_age :: How far back to read in seconds.
68             - Default :: 360
69              
70             - mode :: 'librenms' or 'nagios' as to how to print the output.
71             - Default :: librenms
72              
73             my $args = {
74             drop_percent_warn => .75;
75             drop_percent_crit => 1,
76             mode => 'librenms',
77             max_age => 360,
78             files=>{
79             'ids'=>'/var/log/sagan/stats-ids.json',
80             'foo'=>'/var/log/sagan/stats-foo.json',
81             },
82             };
83              
84             my $sm=Sagan::Monitoring->new( $args );
85              
86             =cut
87              
88             sub new {
89 0     0 1   my %args;
90 0 0         if ( defined( $_[1] ) ) {
91 0           %args = %{ $_[1] };
  0            
92             }
93              
94             # init the object
95 0           my $self = {
96             'drop_percent_warn' => '.75',
97             'drop_percent_crit' => '1',
98             max_age => 360,
99             mode => 'librenms',
100             cache => '/var/cache/sagan_monitoring.json',
101             };
102 0           bless $self;
103              
104             # reel in the numeric values
105 0           my @thresholds = ( 'drop_percent_warn', 'drop_percent_crit', 'max_age' );
106 0           for my $threshold (@thresholds) {
107 0 0         if ( defined( $args{$threshold} ) ) {
108 0           $self->{$threshold} = $args{$threshold};
109 0 0         if ( $args{$threshold} !~ /[0-9\.]+/ ) {
110 0           confess( '"' . $threshold . '" with a value of "' . $args{$threshold} . '" is not numeric' );
111             }
112             }
113             }
114              
115             # get the mode and make sure it is valid
116 0 0 0       if (
    0 0        
117             defined( $args{mode} )
118             && ( ( $args{mode} ne 'librenms' )
119             && ( $args{mode} ne 'nagios' ) )
120             )
121             {
122 0           confess( '"' . $args{mode} . '" is not a understood mode' );
123             }
124             elsif ( defined( $args{mode} ) ) {
125 0           $self->{mode} = $args{mode};
126             }
127              
128             # make sure we have files specified
129 0 0 0       if ( ( !defined( $args{files} ) )
130             || ( !defined( keys( %{ $args{files} } ) ) ) )
131             {
132 0           confess('No files specified');
133             }
134             else {
135 0           $self->{files} = $args{files};
136             }
137              
138 0 0         if ( defined( $self->{files}{'.total'} ) ) {
139 0           confess('".total" is not a valid instance name');
140             }
141              
142 0           return $self;
143             }
144              
145             =head2 run
146              
147             This runs it and collects the data. Also updates the cache.
148              
149             This will return a LibreNMS style hash.
150              
151             my $returned=$sm->run;
152              
153             =cut
154              
155             sub run {
156 0     0 1   my $self = $_[0];
157              
158             # this will be returned
159 0           my $to_return = {
160             data => { '.total' => { f_dropped => 0, total => 0, } },
161             version => 1,
162             error => '0',
163             errorString => '',
164             alert => '0',
165             alertString => ''
166             };
167              
168             # figure out the time slot we care about
169 0           my $from = time;
170 0           my $till = $from - $self->{max_age};
171              
172             # process the files for each instance
173 0           my @instances = keys( %{ $self->{files} } );
  0            
174 0           my @alerts;
175             my $current_till;
176 0           foreach my $instance (@instances) {
177              
178             # ends processing for this file
179 0           my $process_it = 1;
180              
181             # open the file for reading it backwards
182 0           my $bw;
183 0           eval {
184             $bw = File::ReadBackwards->new( $self->{files}{$instance} )
185 0 0         or die( 'Can not read "' . $self->{files}{$instance} . '"... ' . $! );
186             };
187 0 0         if ($@) {
188 0           $to_return->{error} = '2';
189 0 0         if ( $to_return->{errorString} ne '' ) {
190 0           $to_return->{errorString} = $to_return->{errorString} . "\n";
191             }
192 0           $to_return->{errorString} = $to_return->{errorString} . $instance . ': ' . $@;
193 0           $process_it = 0;
194             }
195              
196             # get the first line, if possible
197 0           my $line;
198 0 0         if ($process_it) {
199 0           $line = $bw->readline;
200             }
201 0   0       while ( $process_it
202             && defined($line) )
203             {
204 0           eval {
205 0           my $json = decode_json($line);
206 0           my $timestamp = $json->{timestamp};
207              
208             # if current till is not set, set it
209 0 0 0       if ( !defined($current_till)
      0        
210             && defined($timestamp)
211             && $timestamp =~ /^[0-9]+\-[0-9]+\-[0-9]+T[0-9]+\:[0-9]+\:[0-9\.]+[\-\+][0-9]+/ )
212             {
213              
214             # get the number of hours
215 0           my $hours = $timestamp;
216 0           $hours =~ s/.*[\-\+]//g;
217 0           $hours =~ s/^0//;
218 0           $hours =~ s/[0-9][0-9]$//;
219              
220             # get the number of minutes
221 0           my $minutes = $timestamp;
222 0           $minutes =~ s/.*[\-\+]//g;
223 0           $minutes =~ s/^[0-9][0-9]//;
224              
225 0           my $second_diff = ( $minutes * 60 ) + ( $hours * 60 * 60 );
226              
227 0 0         if ( $timestamp =~ /\+/ ) {
228 0           $current_till = $till + $second_diff;
229             }
230             else {
231 0           $current_till = $till - $second_diff;
232             }
233             }
234 0           $timestamp =~ s/\..*$//;
235 0           my $t = Time::Piece->strptime( $timestamp, '%Y-%m-%dT%H:%M:%S' );
236              
237             # stop process further lines as we've hit the oldest we care about
238 0 0         if ( $t->epoch <= $current_till ) {
239 0           $process_it = 0;
240             }
241              
242             # we found the entry we are looking for if
243             # this matches, so process it
244 0 0 0       if ( defined( $json->{event_type} )
245             && $json->{event_type} eq 'stats' )
246             {
247             # we can stop processing now as this is what we were looking for
248 0           $process_it = 0;
249              
250             # holds the found new alerts
251 0           my @new_alerts;
252              
253             my $new_stats = {
254             uptime => $json->{stats}{uptime},
255             total => $json->{stats}{captured}{total},
256             drop => $json->{stats}{captured}{drop},
257             ignore => $json->{stats}{captured}{ignore},
258             threshold => $json->{stats}{captured}{threshold},
259             after => $json->{stats}{captured}{after},
260             match => $json->{stats}{captured}{match},
261             bytes => $json->{stats}{captured}{bytes_total},
262             bytes_ignored => $json->{stats}{captured}{bytes_ignored},
263             max_bytes_log_line => $json->{stats}{captured}{max_bytes_log_line},
264             eps => $json->{stats}{captured}{eps},
265             f_total => $json->{stats}{flow}{total},
266             f_dropped => $json->{stats}{flow}{dropped},
267 0           alert => 0,
268             alertString => '',
269             };
270              
271             # find the drop percentages
272 0 0         if ( $new_stats->{total} != 0 ) {
273 0           $new_stats->{drop_percent} = ( $new_stats->{drop} / $new_stats->{total} ) * 100;
274 0           $new_stats->{drop_percent} = sprintf( '%0.5f', $new_stats->{drop_percent} );
275             }
276             else {
277 0           $new_stats->{total_percent} = 0;
278             }
279 0 0         if ( $new_stats->{f_total} != 0 ) {
280             $new_stats->{f_drop_percent}
281 0           = ( $new_stats->{f_dropped} / $new_stats->{f_total} ) * 100;
282 0           $new_stats->{f_drop_percent} = sprintf( '%0.5f', $new_stats->{f_drop_percent} );
283             }
284             else {
285 0           $new_stats->{f_drop_percent} = 0;
286             }
287              
288             # check for drop percent alerts
289 0 0 0       if ( $new_stats->{drop_percent} >= $self->{drop_percent_warn}
290             && $new_stats->{drop_percent} < $self->{drop_percent_crit} )
291             {
292 0           $new_stats->{alert} = 1;
293             push( @new_alerts,
294             $instance
295             . ' drop_percent warning '
296             . $new_stats->{drop_percent} . ' >= '
297 0           . $self->{drop_percent_warn} );
298             }
299 0 0         if ( $new_stats->{drop_percent} >= $self->{drop_percent_crit} ) {
300 0           $new_stats->{alert} = 2;
301             push( @new_alerts,
302             $instance
303             . ' drop_percent critical '
304             . $new_stats->{drop_percent} . ' >= '
305 0           . $self->{drop_percent_crit} );
306             }
307              
308             # check for f_drop percent alerts
309 0 0 0       if ( $new_stats->{f_drop_percent} >= $self->{drop_percent_warn}
310             && $new_stats->{f_drop_percent} < $self->{drop_percent_crit} )
311             {
312 0           $new_stats->{alert} = 1;
313             push( @new_alerts,
314             $instance
315             . ' f_drop_percent warning '
316             . $new_stats->{f_drop_percent} . ' >= '
317 0           . $self->{drop_percent_warn} );
318             }
319 0 0         if ( $new_stats->{f_drop_percent} >= $self->{drop_percent_crit} ) {
320 0           $new_stats->{alert} = 2;
321             push( @new_alerts,
322             $instance
323             . ' f_drop_percent critical '
324             . $new_stats->{f_drop_percent} . ' >= '
325 0           . $self->{drop_percent_crit} );
326             }
327              
328             # add stuff to .total
329 0           my @intance_keys = keys( %{$new_stats} );
  0            
330 0           foreach my $total_key (@intance_keys) {
331 0 0 0       if ( $total_key ne 'alertString' && $total_key ne 'alert' ) {
332 0 0         if ( !defined( $to_return->{data}{'.total'}{$total_key} ) ) {
333 0           $to_return->{data}{'.total'}{$total_key} = $new_stats->{$total_key};
334             }
335             else {
336             $to_return->{data}{'.total'}{$total_key}
337 0           = $to_return->{data}{'.total'}{$total_key} + $new_stats->{$total_key};
338             }
339             }
340             }
341              
342 0           $to_return->{data}{$instance} = $new_stats;
343             }
344              
345             };
346              
347             # get the next line
348 0           $line = $bw->readline;
349             }
350              
351             }
352              
353             # find the drop percentages
354 0 0         if ( $to_return->{data}{'.total'}{total} != 0 ) {
355             $to_return->{data}{'.total'}{drop_percent}
356 0           = ( $to_return->{data}{'.total'}{drop} / $to_return->{data}{'.total'}{total} ) * 100;
357 0           $to_return->{data}{'.total'}{drop_percent} = sprintf( '%0.5f', $to_return->{data}{'.total'}{drop_percent} );
358             }
359             else {
360 0           $to_return->{data}{'.total'}{drop_percent} = 0;
361             }
362 0 0         if ( $to_return->{data}{'.total'}{f_dropped} != 0 ) {
363             $to_return->{data}{'.total'}{f_drop_percent}
364 0           = ( $to_return->{data}{'.total'}{f_dropped} / $to_return->{data}{'.total'}{f_total} ) * 100;
365 0           $to_return->{data}{'.total'}{f_drop_percent} = sprintf( '%0.5f', $to_return->{data}{'.total'}{f_drop_percent} );
366             }
367             else {
368 0           $to_return->{data}{'.total'}{f_drop_percent} = 0;
369             }
370              
371             # check for drop percent alerts
372 0 0 0       if ( $to_return->{data}{'.total'}{drop_percent} >= $self->{drop_percent_warn}
373             && $to_return->{data}{'.total'}{drop_percent} < $self->{drop_percent_crit} )
374             {
375 0           $to_return->{alert} = 1;
376             push( @alerts,
377             'total drop_percent warning '
378             . $to_return->{data}{'.total'}{drop_percent} . ' >= '
379 0           . $self->{drop_percent_warn} );
380             }
381 0 0         if ( $to_return->{data}{'.total'}{drop_percent} >= $self->{drop_percent_crit} ) {
382 0           $to_return->{alert} = 2;
383             push( @alerts,
384             'total drop_percent critical '
385             . $to_return->{data}{'.total'}{drop_percent} . ' >= '
386 0           . $self->{drop_percent_crit} );
387             }
388              
389             # check for f_drop percent alerts
390 0 0 0       if ( $to_return->{data}{'.total'}{f_drop_percent} >= $self->{drop_percent_warn}
391             && $to_return->{data}{'.total'}{f_drop_percent} < $self->{drop_percent_crit} )
392             {
393 0           $to_return->{alert} = 1;
394             push( @alerts,
395             'total f_drop_percent warning '
396             . $to_return->{data}{'.total'}{f_drop_percent} . ' >= '
397 0           . $self->{drop_percent_warn} );
398             }
399 0 0         if ( $to_return->{data}{'.total'}{f_drop_percent} >= $self->{drop_percent_crit} ) {
400 0           $to_return->{alert} = 2;
401             push( @alerts,
402             'total f_drop_percent critical '
403             . $to_return->{data}{'.total'}{f_drop_percent} . ' >= '
404 0           . $self->{drop_percent_crit} );
405             }
406              
407             # join any found alerts into the string
408 0           $to_return->{alertString} = join( "\n", @alerts );
409 0           $to_return->{data}{'.total'}{alert} = $to_return->{'alert'};
410              
411             # write the cache file on out
412 0           eval {
413 0           my $json = JSON->new->utf8->canonical(1);
414 0           my $new_cache = $json->encode($to_return) . "\n";
415 0           open( my $fh, '>', $self->{cache} );
416 0           print $fh $new_cache;
417 0           close($fh);
418             };
419 0 0         if ($@) {
420 0           $to_return->{error} = '1';
421 0           $to_return->{alert} = '3';
422 0           $to_return->{errorString} = 'Failed to write new cache JSON file, "' . $self->{cache} . '".... ' . $@;
423              
424             # set the nagious style alert stuff
425 0           $to_return->{alert} = '3';
426 0 0         if ( $to_return->{alertString} eq '' ) {
427 0           $to_return->{alertString} = $to_return->{errorString};
428             }
429             else {
430 0           $to_return->{alertString} = $to_return->{errorString} . "\n" . $to_return->{alertString};
431             }
432             }
433              
434 0           $self->{results} = $to_return;
435              
436 0           return $to_return;
437             }
438              
439             =head2 print_output
440              
441             Prints the output.
442             $sm->print_output;
443              
444             =cut
445              
446             sub print_output {
447 0     0 1   my $self = $_[0];
448              
449 0 0         if ( $self->{mode} eq 'nagios' ) {
450 0 0         if ( $self->{results}{alert} eq '0' ) {
    0          
    0          
    0          
451 0           print "OK - no alerts\n";
452 0           return;
453             }
454             elsif ( $self->{results}{alert} eq '1' ) {
455 0           print 'WARNING - ';
456             }
457             elsif ( $self->{results}{alert} eq '2' ) {
458 0           print 'CRITICAL - ';
459             }
460             elsif ( $self->{results}{alert} eq '3' ) {
461 0           print 'UNKNOWN - ';
462             }
463 0           my $alerts = $self->{results}{alertString};
464 0           chomp($alerts);
465 0           $alerts = s/\n/\, /g;
466 0           print $alerts. "\n";
467             }
468             else {
469 0           my $json = JSON->new->utf8->canonical(1);
470 0           print $json->encode( $self->{results} ) . "\n";
471             }
472             }
473              
474             =head1 LibreNMS HASH
475              
476             + $hash{'alert'} :: Alert status.
477             - 0 :: OK
478             - 1 :: WARNING
479             - 2 :: CRITICAL
480             - 3 :: UNKNOWN
481            
482             + $hash{'alertString'} :: A string describing the alert. Defaults to
483             '' if there is no alert.
484            
485             + $hash{'error'} :: A integer representing a error. '0' represents
486             everything is fine.
487            
488             + $hash{'errorString'} :: A string description of the error.
489            
490             + $hash{'data'}{$instance} :: Values migrated from the
491             instance. Non *_percent values are created via computing the difference
492             from the previously saved info. *_percent is based off of the delta
493             in question over the packet delta. Delta are created for packet,
494             drop, ifdrop, and error. Percents are made for drop, ifdrop, and
495             error.
496            
497             + $hash{'data'}{'.total'} :: Total values of from all the
498             intances. Any percents will be recomputed.
499            
500              
501             The stat keys are migrated as below.
502            
503             uptime => $json->{stats}{uptime},
504             total => $json->{stats}{captured}{total},
505             drop => $json->{stats}{captured}{drop},
506             ignore => $json->{stats}{captured}{ignore},
507             threshold => $json->{stats}{captured}{theshold},
508             after => $json->{stats}{captured}{after},
509             match => $json->{stats}{captured}{match},
510             bytes => $json->{stats}{captured}{bytes_total},
511             bytes_ignored => $json->{stats}{captured}{bytes_ignored},
512             max_bytes_log_line => $json->{stats}{captured}{max_bytes_log_line},
513             eps => $json->{stats}{captured}{eps},
514             f_total => $json->{stats}{flow}{total},
515             f_dropped => $json->{stats}{flow}{dropped},
516              
517             =head1 AUTHOR
518              
519             Zane C. Bowers-Hadley, C<< >>
520              
521             =head1 BUGS
522              
523             Please report any bugs or feature requests to C, or through
524             the web interface at L. I will be notified, and then you'll
525             automatically be notified of progress on your bug as I make changes.
526              
527              
528              
529              
530             =head1 SUPPORT
531              
532             You can find documentation for this module with the perldoc command.
533              
534             perldoc Sagan::Monitoring
535              
536              
537             You can also look for information at:
538              
539             =over 4
540              
541             =item * RT: CPAN's request tracker (report bugs here)
542              
543             L
544              
545             =item * CPAN Ratings
546              
547             L
548              
549             =item * Search CPAN
550              
551             L
552              
553             =back
554              
555             =head * Git
556              
557             L
558              
559             =item * Web
560              
561             L
562              
563             =head1 ACKNOWLEDGEMENTS
564              
565              
566             =head1 LICENSE AND COPYRIGHT
567              
568             This software is Copyright (c) 2022 by Zane C. Bowers-Hadley.
569              
570             This is free software, licensed under:
571              
572             The Artistic License 2.0 (GPL Compatible)
573              
574              
575             =cut
576              
577             1; # End of Sagan::Monitoring