#!/usr/bin/perl # Copyright (C) 2015 Sergey Poznyakoff # # 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 3, 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, see . use strict; use Getopt::Long qw(:config gnu_getopt no_ignore_case); use File::Basename; use File::Glob ':bsd_glob'; use Net::Ping; use Pod::Usage; use Pod::Man; use Proc::Daemon; use Sys::Syslog; use Data::Dumper; my $progname = basename($0); my $progdescr = "Dead gateway detector"; my $confdir = "/etc/dgd"; my $conffile = "$confdir/dgd.conf"; my $debug; my $use_syslog; my @links; my $active_link; my %defconfig = ( core => { interval => 60, timeout => 3, probes => 3, tolerance => 2 } ); my %config; my $rxip = '\d{1,3}\.\d{1,3}.\d{1,3}.\d{1,3}'; sub check_prog { my @cmd = split(/\s+/, shift); my $loc = shift; unless (-f $cmd[0]) { error("$loc: file $cmd[0] does not exist"); return 0; } unless (-x $cmd[0]) { error("$loc: file $cmd[0] is not executable"); return 0; } return 1; } my %kw = ( core => { section => { interval => { re => '^\d+$' }, timeout => { re => '^\d+$' }, probes => { re => '^\d+$' }, tolerance => { re => '^\d+$' }, active => 1, up => { check => \&check_prog }, down => { check => \&check_prog }, logpriority => { re => '^0|1$' }, pidfile => 1 } }, syslog => { section => { facility => { re => '^((auth(priv)?)|cron|daemon|ftp|(local[0-7])|user)$' }, tag => 1 } }, link => { section => { name => { mandatory => 1 }, if => 1, ip => { re => "^$rxip\$" }, gw => { re => "^$rxip\$", mandatory => 1 }, ns => 1, net => 1, up => { check => \&check_prog }, down => { check => \&check_prog }, priority => { re => '^\d+$' } }, mandatory => 1 } ); use constant EX_OK => 0; use constant EX_USAGE => 64; # command line usage error use constant EX_DATAERR => 65; # data format error use constant EX_NOINPUT => 66; # cannot open input file use constant EX_UNAVAILABLE => 69; # service unavailable use constant EX_SOFTWARE => 70; # internal software error (not used yet) use constant EX_OSFILE => 72; # critical OS file missing use constant EX_CANTCREAT => 73; # can't create (user) output file use constant EX_CONFIG => 78; # configuration error sub diag { my $prio = shift; my $msg = shift; local %_ = @_; $msg = "$prio: $msg" if $config{core}{logpriority}; $msg = "$_{prefix}: $msg" if defined($_{prefix}); if ($use_syslog) { syslog($prio, $msg); } else { print STDERR "$progname: " if defined($progname); print STDERR "$msg\n" } } sub error { diag('err', @_); } sub debug { my $l = shift; diag('debug', join(' ',@_)) if $debug >= $l; } sub abend { my $code = shift; diag('crit', @_); exit $code; } sub parse_section { my ($conf, $input) = @_; my $ref = $conf; my $quote; my $rootname; while ($input ne '') { my $name; if (!defined($quote)) { if ($input =~ /^"(.*)/) { $quote = ''; $input = $1; } elsif ($input =~ /^(.+?)(?:\s+|")(.*)/) { $name = $1; $input = $2; } else { $name = $input; $input = ''; } } else { if ($input =~ /^([^\\"]*)\\(.)(.*)/) { $quote .= $1 . $2; $input = $3; } elsif ($input =~ /^([^\\"]*)"\s*(.*)/) { $name = $quote . $1; $input = $2; $quote = undef; } else { die "unparsable input $input"; } } if (defined($name)) { $rootname = $name unless defined $rootname; $ref->{$name} = {} unless ref($ref->{$name}) eq 'HASH'; $ref = $ref->{$name}; $name = undef; } } return ($ref, $rootname); } sub check_mandatory { my ($section, $kw, $loc, $s) = @_; my $err = 0; while (my ($k, $d) = each %{$kw}) { if (ref($d) eq 'HASH' and $d->{mandatory} and !exists($section->{$k})) { if (exists($d->{section})) { if ($s) { error("$loc: mandatory section [$k] not present"); ++$err; } } else { error("$loc: mandatory variable \"$k\" not set"); ++$err; } } } return $err; } sub readconfig { my $file = shift; my $conf = shift; my %param = @_; debug(1, "reading $file"); open(my $fd, "<", $file) or do { error("can't open configuration file $file: $!"); return 1 if $param{include}; exit(EX_NOINPUT); }; my $line; my $err; my $section = $conf; my $kw = $param{kw}; my $include = 0; my $rootname; while (<$fd>) { ++$line; chomp; if (/\\$/) { chop; $_ .= <$fd>; redo; } s/^\s+//; s/\s+$//; s/#.*//; next if ($_ eq ""); if (/^\[(.+?)\]$/) { $include = 0; my $arg = $1; $arg =~ s/^\s+//; $arg =~ s/\s+$//; if ($arg eq 'include') { $include = 1; } else { ($section, $rootname) = parse_section($conf, $1); if (ref($param{kw}) eq 'HASH') { if (defined($rootname)) { if (ref($param{kw}{$rootname}) eq 'HASH' and exists($param{kw}{$rootname}{section})) { $kw = $param{kw}{$rootname}{section}; } else { error("$file:$line: unknown section"); $kw = undef; } } else { $kw = $param{kw}; } } } } elsif (/([\w_-]+)\s*=\s*(.*)/) { my ($k, $v) = ($1, $2); $k = lc($k) if $param{ci}; if ($include) { if ($k eq 'path') { $err += readconfig($v, $conf, include => 1, @_); } elsif ($k eq 'pathopt') { $err += readconfig($v, $conf, include => 1, @_) if -f $v; } elsif ($k eq 'glob') { foreach my $file (bsd_glob($v, 0)) { $err += readconfig($file, $conf, include => 1, @_); } } else { error("$file:$line: unknown keyword"); ++$err; } next; } if (defined($kw)) { my $x = $kw->{$k}; if (!defined($x)) { error("$file:$line: unknown keyword $k"); ++$err; next; } elsif (ref($x) eq 'HASH') { if (exists($x->{re})) { if ($v !~ /$x->{re}/) { error("$file:$line: invalid value for $k"); ++$err; next; } if (exists($x->{check}) and !&{$x->{check}}($v, "$file:$line")) { ++$err; next; } } elsif (exists($x->{check})) { if (!&{$x->{check}}($v, "$file:$line")) { ++$err; next; } } elsif (!exists($x->{var}) and !exists($x->{mandatory})) { error("$file:$line: unknown keyword $k"); ++$err; next; } } } $section->{$k} = $v; } else { error("$file:$line: malformed line"); ++$err; next; } } close $fd; # if (defined($param{kw}) and !$param{include}) { # $err += check_mandatory($conf, $param{kw}, "$file:$line", 1); # } return $err; } sub get_default_iface { my $ret; open(my $fd, '-|', "netstat -rn") or abend(EX_OSFILE, "can't start netstat: $!"); while (<$fd>) { chomp; my ($dest,$gw,undef,undef,undef,undef,undef,$iface) = split /\s+/; if ($dest eq "0.0.0.0") { $ret = $iface; last; } } close $fd; return $ret; } sub scan_links { @links = sort { $a->{priority} <=> $b->{priority} } map { if (defined($_->{name})) { $_->{priority} = 100 unless exists $_->{priority}; debug(1, "registered link $_->{name} via $_->{if}"); $_ } else { () } } values %{$config{link}}; abend(EX_CONFIG, "no links configured") if $#links == -1; if (defined($config{core}{active})) { for ($active_link = 0; $active_link <= $#links; ++$active_link) { last if $links[$active_link]->{name} eq $config{core}{active}; } if ($active_link > $#links) { error("no link corresponding to the active one"); $active_link = undef; delete $config{core}{active}; } } unless (defined($active_link)) { my $iface = get_default_iface(); if (defined($iface)) { debug(1, "default gw via $iface"); for ($active_link = 0; $active_link <= $#links; ++$active_link) { last if $links[$active_link]->{if} eq $iface; } if ($active_link > $#links) { debug(1, "no active link configured"); $active_link = undef; } else { debug(1, "active link $links[$active_link]->{name}"); } } else { debug(1, "no active link configured"); $active_link = undef; } } } sub runcmd { my $cmdline = shift; debug(2, "running $cmdline"); if (open(my $fd, '-|', "$cmdline 2>&1")) { while (<$fd>) { diag('notice', "\"$cmdline\": $_"); } close($fd); } elsif ($? == -1) { error("failed to execute \"$cmdline\": $!"); } elsif ($? & 127) { error("\"$cmdline\" died with signal " . ($? & 127)); } elsif ($? >> 8) { error("\"$cmdline\" exited with code " . ($? >> 8)); } else { error("failed to execute \"$cmdline\": $!"); } } sub updown { my ($what, $name) = @_; my $link = (ref($name) eq 'HASH') ? $name : $config{link}{$name}; my $cmd = defined($link->{$what}) ? $link->{$what} : $config{core}{$what}; if (defined($cmd)) { $cmd .= " '$link->{name}'"; $cmd .= " '$link->{if}' '$link->{ip}' '$link->{gw}' '$link->{ns}' '$link->{net}'" if (ref($name) eq 'HASH'); runcmd($cmd); } elsif ($what eq 'up') { runcmd("route add default gw $config{link}{gw}"); } elsif ($what eq 'down') { runcmd("route del default gw"); } else { die "unrecognized action $what"; } } sub link_is_alive { my $arg = shift; $arg = $links[$arg] if ref($arg) ne 'HASH'; return $arg->{alive} >= $config{core}{tolerance}; } sub check_links { my $link; foreach $link (@links) { $link->{alive} = 0; } debug(1, "checking links ".($#links+1).", probes $config{core}{probes}"); for (my $i = 0; $i < $config{core}{probes}; $i++) { foreach $link (@links) { next if link_is_alive($link); debug(3, "ping $link->{name}"); my $p = Net::Ping->new("icmp", $config{core}{timeout}, undef, $link->{if}); ++$link->{alive} if $p->ping($link->{gw}); $p->close(); debug(3, "$link->{name} " . ($link->{alive} ? "alive" : "dead")); } } if (!defined($active_link)) { return 1; } elsif (!link_is_alive($active_link)) { my $name = $links[$active_link]->{name}; error("link $name went down"); updown('down', $name); $active_link = undef; return 1; } return 0; } sub newlink { debug(1, "looking for available link"); for (my $i = 0; $i <= $#links; $i++) { #next if $i == $active_link; debug(2, "$links[$i]->{name}: $links[$i]->{alive}/$config{core}{tolerance}"); if (link_is_alive($i)) { my $name = $links[$i]->{name}; diag('info', "switching to link $name"); updown('up', $name); $active_link = $i; return; } } error("no fallback link"); } sub serialize_link { my $link = shift; my $s; foreach my $k (sort keys %{$link}) { next if ($k eq 'alive'); $s .= ';' if defined $s; $s .= "$k=$link->{$k}"; } return $s; } sub cmplinks { my ($aref, $bref) = @_; return serialize_link($aref) eq serialize_link($bref); } # ######## my $foreground; GetOptions("h" => sub { pod2usage(-message => "$progname: $progdescr", -exitstatus => 0); }, "help" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 2); }, "usage" => sub { pod2usage(-exitstatus => EX_OK, -verbose => 0); }, "debug|d+" => \$debug, "foreground|f" => \$foreground, "config|c=s" => \$conffile) or exit(1); %config = %defconfig; if (readconfig($conffile, \%config, kw => \%kw)) { exit(EX_CONFIG); } abend(EX_USAGE, "too many arguments") if $#ARGV >= 0; print Data::Dumper->Dump([\%config], [qw(config)]) if $debug >= 4; scan_links; unless ($foreground) { $config{core}{syslog}{facility} = 'daemon' unless exists $config{core}{syslog}{facility}; } if (defined($config{core}{syslog}{facility})) { my $tag = $config{core}{syslog}{tag} || $progname; openlog($tag, "pid", $config{core}{syslog}{facility}); } my $pidfile = $config{core}{pidfile}; $SIG{TERM} = $SIG{INT} = $SIG{QUIT} = sub { unlink($pidfile) if defined($pidfile); exit(EX_OK); }; unless ($foreground) { my $daemon = Proc::Daemon->new(work_dir => '/'); my $pid = $daemon->Init; if ($pid) { if (defined($pidfile)) { if (open(my $fd, '>', $pidfile)) { print $fd "$pid\n"; close $fd; } else { error("can't open $pidfile: $!"); } } exit(EX_OK); } } $SIG{HUP} = sub { my %save_link = %{$links[$active_link]} if defined $active_link; my %t = %defconfig; diag('info', "re-reading configuration file"); if (readconfig($conffile, \%t) == 0) { %config = %t; @links = (); scan_links; if (keys(%save_link)) { my $i; for ($i = 0; $i <= $#links; ++$i) { last if cmplinks(\%save_link, $links[$i]); } if ($i > $#links) { debug(1, "active link $save_link{name} was removed"); updown('down', \%save_link); $active_link = undef; } } } }; $use_syslog = defined $config{syslog}{facility}; while (1) { if (check_links()) { newlink(); } elsif ($active_link > 0) { for (my $i = 0; $i < $active_link; $i++) { if (link_is_alive($i) and $links[$i]->{priority} < $links[$active_link]->{priority}) { diag('info', "switching to higher priority link $links[$i]->{name}"); updown('down', $links[$active_link]->{name}); $active_link = $i; updown('up', $links[$active_link]->{name}); } } } sleep($config{core}{interval}); } =head1 NAME dgd - dead gateway detector =head1 SYNOPSIS B [B<-df>] [B<-c> I] [B<--config=>I] [B<--debug>] [B<--foreground>] B B<-h> | B<--help> | B<--usage> =head1 DESCRIPTION Monitors a set of network links, by sending ICMP echo requests to remote gateways. One of the links is assumed to be I, i.e., to be used as a default gateway, others are used as fallback. When active link goes down, B executes a preconfigured B action for that link, then it selects next available link and executes an B action for it. Default B action sets up default gateway via the selected link. Default B action removes default gateway from the routing table. Each link can be assigned a I, an integer value between 0 and 100 (default). When selecting fallback link to replace the dead one, the link with lowest priority is given preference. When a link goes up, its priority is compared to that of the active one. If it is numerically less, the new link is made active, using the same procedure as described above. =head1 CONFIGURATION FILE The configuration is read from file F. The syntax is somewhat traditional. Whitespace is mostly ignored. The B<#> character begins a comment to the end of line. Blank lines are ignored. The file consists of sections and variabes. A section begins with the name of the section in square brackets and continues until the next section begins. Each variable must belong to some section. Names of sections and variables are case-sensitive. Sections can be further divided into subsections, by listing subsection name after the section name and a white space, e.g.: [section subsection] Subsection name must be enclosed in double quotes if it contains whitespace or double-quote characters. Otherwise, quoting is optional. Within a quoted subsection name, double-quote and backslash have to be escaped as B<\"> and B<\\>, respectively. Variables are assigned using the following syntax =over 8 =item I B<=> I =back Whitespace is allowed on both sides of the equals sign. I is read verbatim, it can contain any characters, including whitespace. Very long values can be split over several physical lines, by ending each line excepting the last with a backslash immediately followed by a newline character. =head2 Section B<[include]> Special section B<[include]> can be used to include one or more files into another file. It can contain following variables: =over 4 =item B F Include contents of F. The file must exist. =item B F Same as B, except that F is not required to exist. =item B I Include all files matching B(7) I. It is OK if no file matches the pattern. =back =head2 Section B<[core]> Controls core functionality of the program. =over 4 =item B I Check links each I seconds (default 60). =item B I Ping timeout, in seconds (default 3). =item B I Number of ping probes to run for each gateway (default 3). =item B I Number of probes that must succeed in order for the link to be marked as I. Default is 2 =item B I Name of the active link. If not defined, the link whose B variable matches the default gateway address is assumed active. =item B B<0>|B<1> If B<1>, log the priority with each diagnostic message. =item B I Name of the external command to run when a link goes up. Name of the link in question is passed as argument to I. =item B I Name of the external command to run when a link goes up. Name of the link in question is passed as argument to I. =item B I Write PID of the B daemon process to I. =back =head2 Section B<[syslog]> =over 4 =item B I Syslog facility to use. Allowed values for I are: B, B, B, B, B, B, and B through B. Default is B. =item B I Tag messages with I (default -- name of the program). =back =head2 Section B<[link]> Defines a link. At least one link must be defined. =over 4 =item B I Name of this link. This variable is mandatory, =item B I IP address of the remote gateway. This variable is mandatory, =item B I Name of the network interface. =item B I Name of the external command to run when this link goes up. Overrides B. =item B I Name of the external command to run when this link goes up. Overrides B. =item B I Priority of the link, an integer number between 0 (highest priority) and 100 (lowest priority). Default is 100. =back The variables below are not used directly by B. They are intended to pass additional information to B and B scripts: =over 4 =item B I IP address assigned to the interface. =item B I [I...] Whitespace-separated list of ip addresses of NS servers. =item B I Network that is routed through this link. =back =head1 OPTIONS =over 4 =item B<-c>, B<--config=>I Read configuration parameters from I, instead of F. =item B<-d>, B<--debug> Increase debug level. =item B<-f>, B<--foreground> Remain in the foreground. Print diagnostics on standard error, unless B<[syslog]> configuration section is present. =back The following options cause B to print informational message on the standard error and exit: =over 4 =item B<-h> Print short usage summary. =item B<--help> Display this manual. =item B<--usage> Print invocation syntax summary. =back =head1 FILES =over 4 =item F Default configuration file. =back =head1 SIGNALS =over 4 =item B Instructs B to re-read its configuration file. The new configuration takes effect only if there were no errors in it. =item B, B, B Program terminates. =back =head1 EXIT CODES =over 4 =item B<0> Successful termination. =item B<64> Command line usage error. =item B<66> Couldn't open input file. =item B<72> Failed to run external command. =item B<78> Error in configuration file. =back =head1 AUTHOR Sergey Poznyakoff =cut