#! /usr/bin/perl -w

# Ce script génère une alarme si une session BGP flappe sur un
# routeur.

# Le flap d'une session peut être grave parce que le dampening
# n'est pas effectif après la réinitialisation d'un peer, mais
# les routes vont subir un dampening quand elles seront
# annoncées à un client BGP, et si le client BGP n'a pas de
# session BGP avec un autre fournisseur de transit, les routes
# deviendront inaccessibles pour ce client.
# Normalement un peer ne devrait pas tenter de remonter une session
# sans un intervalle minimum entre les sessions mais ce n'est pas garanti
# (c'est du vécu).

# Le script utilise une base de données pour récupérer le nombre
# de transitions à l'état "Established" pour chaque peer BGP du
# routeur. La base est remplie toutes les 5 minutes. Les
# enregistrements ne sont jamais plus vieux qu'une heure.
# Si le nombre de transitions a décru, c'est que le routeur a
# rebooté, puisqu'évidemment le nombre de transitions à l'état
# "Established" ne peut qu'augmenter.

# Si des flaps sont détectés, et que le problème est réparé, ce script
# va continuer à renvoyer une alarme (on mesure un nombre max de flaps
# par intervalle de temps, donc au pire il renverra une alarme pendant toute
# la durée de cet intervalle une fois le problème réparé).
# Le plus simple dans ce cas est de programmer une intervention sur le service
# Nagios le temps de l'intervalle utilisé dans le check (par exemple
# si on a une alarme de check flaps sur 15 minutes, on programme une intervention
# de 15 minutes le temps que les données de flap anciennes soient invalidées).
# On peut aussi détruire l'historique de la base pour ce routeur, par exemple :
#      DELETE FROM established_transitions WHERE router ='212.43.193.49'
#         AND peer ='62.240.250.154';

# Pour faire face à l'alerte, en journée : voir avec le peer ce qui se
# passe. En heures d'intervention : fermer la session pour un peering
# jusqu'au lendemain, mais si c'est un transit voir avec le transiteur
# ce qui se passe.

use strict;
use Getopt::Std;
use DBI;
use vars qw/$opt_c $opt_w/;
# use Data::Dumper;

sub usage {
    my ($arg) = @_;

    # Pour Nagios, c'est la 1ère ligne  qui compte...
    print "UNKNOWN : usage incorrect\n";

    if (defined($arg)) {
        print "\n$arg\n",
    }

    print <<EOF;

Usage:
$0 -c flap_spec -w flap_spec router 
Génère une alarme quand un peer sur le routeur génère des flaps.
Si flap_spec = 5,300:10,600 alors une alarme est déclenchée
quand 5 flaps ou plus ont eu lieu en moins de 300 s, ou 10 flaps
ou plus en moins de 600 s.

EOF
   exit(3); # UNKNOWN
}

my $debug = 0;

my $mysql_file = "/usr/local/etc/nagios.mysql.cnf";
my $mysql_group = "nagios_check";
my $mysql_db = "bgp";

getopt("c:w:");
my $critical_flap_spec = $opt_c;
my $warning_flap_spec = $opt_w;

if (! ($critical_flap_spec || $warning_flap_spec) ) {
      usage("Une des deux options -c ou -w doit être présente !");
}

my $router = shift || usage();

print "Critical flap check option = $critical_flap_spec.\n" if $debug;
print "Warning flap check option = $warning_flap_spec.\n" if $debug;

# Messages Nagios
my $warning_alarm;
my $critical_alarm;

# Les checks qui vont être faits.
my @flap_checks;

if ($critical_flap_spec) {
    push(@flap_checks, @{  parse_flap_spec($critical_flap_spec, "critical") });
}

if ($warning_flap_spec) {
    push(@flap_checks, @{ parse_flap_spec($warning_flap_spec, "warning") });
}

# On trie les checks suivant leur intervalle, cela permet de ne pas
# avoir à éxécuter tous les checks plus tard.
@flap_checks = sort { $a->[1] <=> $b->[1] } @flap_checks;

my $dbh = DBI->connect(
                       "dbi:mysql:dbname=$mysql_db;" .
                       "mysql_read_default_file=$mysql_file;" .
                       "mysql_read_default_group=$mysql_group"
                   );

if (not defined($dbh)) {
    print "UNKNOWN : impossible de se connecter à la base $mysql_db\n";
    exit(3); # UNKNOWN
}

# On récupère toutes les données pour ce routeur.
# Je sais que la table contient peu d'enregistrements
# (les données les plus anciennes sont effacées quand les
# données les plus récentes sont insérées), mais si cela change,
# il faudra ajouter une clause comme
# WHERE date > DATE_SUB(CURDATE(), INTERVAL 1 HOUR)
# On parcourt les enregistrements du plus récent au plus vieux.

my $request = "
    SELECT peer, UNIX_TIMESTAMP(date), transitions FROM established_transitions
        WHERE router = '$router' 
        ORDER BY date DESC
";

print "$request" if $debug;

my $sql_data = $dbh->selectall_arrayref($request);

if (! $sql_data) {
    print "UNKNOWN : impossible de récupérer les données dans la base : $DBI::errstr";
    exit(3); # UNKNOWN
}

my %peer_data;
my $first_row = 1;

for my $sql_row (@{$sql_data}) {

    my $peer = $sql_row->[0];
    my $timestamp = $sql_row->[1];
    my $flap_counter = $sql_row->[2];

    print "\nAt " . localtime($timestamp) .
                " ($timestamp) -> $flap_counter flaps for peer $peer.\n" if $debug; 

    # Les données les plus récentes ne doivent pas être plus vieilles que 15 minutes.
    if ($first_row) {
        print "(First row)\n" if $debug;
        if ( $timestamp < ( time() - 15 * 60 ) ) {
            print "UNKNOWN : données périmées dans la base pour $router";
            exit(3); # UNKNOWN
        }
        else {
            $first_row = 0;
        }
    }


    if ($peer_data{$peer}{last_flap_counter}) {

        # On a déjà vu au moins un enregistrement...
        print "At least one record seen for $peer previously.\n" if $debug;

        my $last_flap_counter = $peer_data{$peer}{last_flap_counter};
        my $last_timestamp = $peer_data{$peer}{last_timestamp};
        my @peer_flap_checks = @{ $peer_data{$peer}{flap_checks} };

        if (! @peer_flap_checks) {
            print "No check for peer $peer.\n" if $debug;
            next;
        }

        my $flap_increment = $last_flap_counter - $flap_counter;
        my $time_increment = $last_timestamp - $timestamp;

        if ( $flap_increment < 0 ) {
            # Le routeur a rebooté, seule possibilité pour que le nombre de
            # transitions vers l'état "Established" diminue.
            # Les enregistrements plus anciens sont tous caducs.
            print "Flap count reset, older data skipped for $router.\n" if $debug;
            last;
        }

        else {

            #$peer_data{$peer}{total_flaps} += 2; # Pour tester, 2 flaps / 5 minutes
            $peer_data{$peer}{total_flaps} += $flap_increment;
            $peer_data{$peer}{total_time} += $time_increment;
            $peer_data{$peer}{last_flap_counter} = $flap_counter;
            $peer_data{$peer}{last_timestamp} = $timestamp;

            my $total_flaps = $peer_data{$peer}{total_flaps};
            my $total_time = $peer_data{$peer}{total_time};
            print "$total_flaps total flaps / $total_time s.\n" if $debug;


            # Les checks pour des durées inférieures à l'intervalle
            # courant peuvent être supprimés.
            while ( @peer_flap_checks
                         && $peer_flap_checks[0]->[1] < $total_time ) {
                
                my $removed_check = splice(@peer_flap_checks, 0, 1);
                print "Check suppressed for $peer:\n" if $debug;
                print_flap_checks([$removed_check]) if $debug;
                $peer_data{$peer}{flap_checks} = \@peer_flap_checks;
                    
            }

            print "Checks for $peer :\n" if $debug;
            print_flap_checks(\@peer_flap_checks) if $debug;

            for my $flap_check (@peer_flap_checks) {
                check_flap($flap_check, $total_flaps, $total_time, $peer);
            }
        }
    }
    else {
        # Premier enregistrement pour ce peer.
        print "First record for peer $peer.\n" if $debug;

        # On store les checks par peer, ce qui permet de les supprimer
        # pour ce peer quand les checks ne sont plus nécessaires.
        $peer_data{$peer}{flap_checks} = \@flap_checks;
        $peer_data{$peer}{last_flap_counter} = $flap_counter;
        $peer_data{$peer}{last_timestamp} = $timestamp;
    }

}
    
if ($critical_alarm) {
    print $critical_alarm;
    exit(2); # CRITICAL
}
elsif ($warning_alarm) {
    print $warning_alarm;
    exit(1); # WARNING
}
else {
    print "OK\n";
    exit(0); # OK
}

########################################################
# Fonctions utilitaires
########################################################

# Cette fonction parse la spécification donnée sur la ligne
# de commande voir l'usage).
sub parse_flap_spec {

    my $flap_spec = shift;
    my $alarm_type = shift; # critical ou warning

    my @flap_checks;

    print "Flap spec = $flap_spec.\n" if $debug;
    my @flap_check_strings = split(/:/, $flap_spec);

    for my $flap_check_string (@flap_check_strings) {
        if ($flap_check_string =~ /(\d+),(\d+)/) {
            my $max_flaps = $1;
            my $max_interval = $2;
            # Les mesures plus vieilles qu'une heure sont supprimées de la base.
            if ( $max_interval > 3600 ) {
                usage("La base doit être modifiée pour un intervalle > 1 heure !");
            }
            # On ne rentre la mesure que toutes les 5 minutes.
            if ( $max_interval < 300 ) {
                usage("La base doit être modifiée pour un intervalle < 5 min !");
            }
            print "$alarm_type max_flaps = $max_flaps max_interval = $max_interval\n"
                if $debug;
            push(@flap_checks, [$max_flaps, $max_interval, $alarm_type]);
        }
        else {
            usage("Syntaxe erronée '$flap_check_string' !");
        }
    }
    return \@flap_checks;
}

# Cette fonction fait le check proprement dit.
sub check_flap {

    my $flap_check = shift;
    my $total_flaps = shift;
    my $total_time = shift;
    my $peer = shift;

    my $max_flaps = $flap_check->[0];
    my $max_interval = $flap_check->[1];
    my $alarm_type = $flap_check->[2];

    print "check_flap ($alarm_type max $max_flaps flaps / $max_interval s) : ".
             "$total_flaps flaps / $total_time s.\n" if $debug;

    if ( $total_time <= $max_interval ) {
        if ( $total_flaps >=  $max_flaps ) {

            my $msg = "$peer $total_flaps flaps / $total_time s" .
                      " (max $max_flaps / $max_interval)";
            if ($alarm_type eq "critical") {
                $critical_alarm .= "CRITICAL: $msg\n";
            }
            elsif ($alarm_type eq "warning") {
                $warning_alarm .= "WARNING: $msg\n";
            }
        }
        else {
            print "check_flap OK.\n" if $debug;
        }
    }
    else {
        print "Total time $total_time >  $max_interval, skipping.\n" if $debug;
    }

}

# Fonction pour débugguer
sub print_flap_checks {
    my @flap_checks = @{shift()};
    print "None!\n" unless @flap_checks;
    for my $check (@flap_checks) {
        my ($max_flaps, $max_interval, $alarm_type) = @{$check};
        print "Check $alarm_type max_flaps = $max_flaps max_interval = $max_interval\n";
    }
}

