#!/bin/sh
#! -*-perl-*-
# This file is part of NetSNMP::Sendmail
# Copyright (C) 2019-2020 Sergey Poznyakoff <gray@gnu.org>
#
# 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 <http://www.gnu.org/licenses/>.
eval 'exec perl -x -wS $0 ${1+"$@"}'
    if 0;

use strict;
use warnings;
use File::Temp;
use File::Basename;
use IPC::Cmd qw(can_run);
use POSIX qw(strftime);
use Fcntl;
use Getopt::Long qw(:config gnu_getopt no_ignore_case require_order);
use Pod::Usage;

sub addts {
    my $fd = shift;
    print $fd "# Line added by $0 at "
	      . strftime("%Y-%m-%dT%H:%M:%S", localtime)
	      . "\n";
}

my $my_ts_rx = qr{^# Line added by $0 at };

use constant {
    EX_OK => 0,           # Success
    EX_UNCHANGED => 1,    # Files not changed
    EX_FATAL => 2,        # Fatal error
    EX_USAGE => 64        # Usage error
};

use constant {
    CMD_SETUP => 0,
    CMD_REMOVE => 1
};

my $suppress_level = 0;
my $dry_run;
my @updated_services;

use constant {
    L_INFO => 0,
    L_NOTICE => 1,
    L_WARN => 2,
    L_ERR => 3
};

use constant MAX_SUPPRESS_LEVEL => L_WARN;

sub printlog {
    my $level = shift;
    if ($suppress_level <= $level) {
	my $fh = (($level >= L_WARN) ? \*STDERR : \*STDOUT);
	print $fh "$0: ".join(' ',@_)."\n";
    }
}

my @restart_commands = (
    [qw(systemctl restart $service)],
    [qw(service $service restart)],
    [qw(/etc/init.d/$service restart)],
    [qw(/etc/rc.d/$service restart)],
);
my %restart_override;

sub restart_service {
    my $service = shift;

    my $cmd = $restart_override{$service};
    return if $cmd && $cmd eq 'no';

    printlog(L_NOTICE, "restarting $service");
    return if $dry_run;

    if ($cmd) {
	printlog(L_NOTICE, "running $cmd");
	system($cmd);
    } else {
	foreach $cmd (@restart_commands) {
	    my @c = map { s/\$service/$service/g; $_ } @$cmd;
	    if (can_run($c[0])) {
		printlog(L_NOTICE, "running @c");
		system(@c);
		return if ($? == 0);
	    }
	}
    }
}

sub file_replace {
    my ($file, $newfile) = @_;
    my $bk = "$file~";
    unlink $bk if -e $bk;
    rename $file, $bk or die "can't rename $file to $bk: $!";
    unless (rename $newfile, $file) {
	printlog(L_WARN,
		 "can't rename $newfile to $file: $!; restoring from backup");
	unless (rename $bk, $file) {
	    printlog(L_ERR, "failed to rename $bk to $file: $!");
	    exit(EX_FATAL);
	}
    }
}

sub file_remline {
    my ($file, $fd, $line, $endline) = @_;
    $endline //= $line;
    printlog(L_NOTICE,
	     ($endline == $line) ? "editing $file: removing line $line"
	     : "editing $file: removing lines $line-$endline");
    return if $dry_run;
    my $ofd = File::Temp->new(DIR => dirname($file), UNLINK => $dry_run);
    seek($fd, 0, SEEK_SET) or die "seek $file: $!";
    my $ln = 0;
    while (<$fd>) {
	$ln++;
	next if ($line <= $ln && $ln <= $endline);
	print $ofd $_;
    }
    close $ofd;
    close $fd;
    file_replace($file, $ofd->filename);
}

sub scan_snmpd_conf {
    my ($file, $fd) = @_;

    my $line = 0;
    my $comline;
    my $insert_line;

    while (<$fd>) {
	++$line;
	chomp;
	s/^\s+//;
	
	if (/$my_ts_rx/) {
	    $comline = $line;
	    next;
	}
	
	if (/^perl\s+use\s+NetSNMP::Sendmail/) {
	    if ($comline && $comline + 1 == $line) {
		return (1, $comline, $line);
	    } else {
		return (1, $line);
	    }
	}
	
	if (/^perl\s+use/) {
	    $insert_line = $line;
	}
    }
    return (0, $insert_line || $line + 1);
}

sub update_snmpd_conf {
    my ($ifile, $ifd, $insert_line, $stmt) = @_;

    seek($ifd, 0, SEEK_SET) or die "seek: $!";
    my $ofd = File::Temp->new(DIR => dirname($ifile), UNLINK => $dry_run);
    my $line = 0;
    while (<$ifd>) {
	chomp;
	++$line;
	if ($insert_line && $line == $insert_line) {
	    printlog(L_NOTICE, "editing $ifile (line $line)");
	    addts $ofd;
	    print $ofd "$stmt\n";
	    $insert_line = undef;
	}
	print $ofd "$_\n";
    }
    if ($insert_line) {
	printlog(L_NOTICE, "editing $ifile (append)");
	addts $ofd;
	print $ofd "$stmt\n";
    }
    close $ofd;
    file_replace($ifile, $ofd->filename) unless ($dry_run);
}

sub edit_snmpd_conf {
    my ($command, $name, $stmt) = @_;
    my $u = umask(077);
    if (open(my $fd, '<', $name)) {
	my ($found, $line, $endline) = scan_snmpd_conf($name, $fd);
	if ($command == CMD_SETUP) {
	    if ($found) {
		printlog(L_INFO, "$name:".($endline ? $endline : $line).": NetSNMP::Sendmail already enabled");
	    } else {
		update_snmpd_conf($name, $fd, $line, $stmt);
	        push @updated_services, 'snmpd';
	    }
	} elsif ($found) {
	    file_remline($name, $fd, $line, $endline);
            push @updated_services, 'snmpd';
	}
	close $fd;
    } else {
	printlog(L_ERR, "can't open $name: $!");
	exit(EX_FATAL);
    }
    umask($u);
}

sub check_file {
    my $file = shift;
    if (-f $file) {
	if (! -r $file) {
	    printlog(L_ERR, "$file is not readable");
	    exit(EX_FATAL);
	}
    } else {
	printlog(L_ERR, "$file does not exist");
	exit(EX_FATAL);
    }
}

# Check if NetSNMP::Sendmail is available.
# To avoid namespace contamination, do it in a subprocess.
# The module requires SmtpAgent.pm, whic spits out lots of messages
# to STDERR when loaded outside of snmpd, so first redirect stderr
# to /dev/null.
sub check_module {
    my $pid = fork();
    die "fork failed" unless defined $pid;
    if ($pid == 0) {
	open(STDERR, '>', '/dev/null');
	require NetSNMP::Sendmail;
	exit 0;
    }
    wait;
    if ($?) {
	printlog(L_ERR, "NetSNMP::Sendmail doesn't seem to be installed");
	exit(EX_FATAL);
    }
}

sub scan_sendmail_mc {
    my $fd = shift;
    my $name;
    my $last_nl;
    my $comline;
    my $line = 0;
    while (<$fd>) {
	$line++;
	$last_nl = chomp;
	s/^\s+//;

	if (/$my_ts_rx/) {
	    $comline = $line;
	    next;
	}
	if (/^define\(\s*`?STATUS_FILE'?\s*,\s*`?(.+?)'?\s*\)/) {
	    $name = $1;
	    last
	}
    }

    return ($name, $last_nl,
	    ($comline && $comline + 1 == $line) ? ($comline, $line) : ($line));
}

sub edit_sendmail_mc {
    my ($command, $file, $default_statfile) = @_;
    if (open(my $fd, '+<', $file)) {
	my $need_make;
	my ($statfile, $last_nl, $line, $endline) = scan_sendmail_mc($fd);
	if ($command == CMD_SETUP) {
	    if ($statfile) {
		printlog(L_INFO,
			 "$file:".($endline ? $endline : $line).
			 ": status file $statfile already enabled");
	    } else {
		$statfile = $default_statfile;
		printlog(L_NOTICE, "editing $file");
		unless ($dry_run) {
		    seek($fd, 0, SEEK_END) or die "seek: $!";
		    print $fd "\n" unless $last_nl;
		    addts $fd;
		    print $fd "define(`STATUS_FILE', `$statfile')\n";
		}
	        push @updated_services, 'sendmail';
	        $need_make = !$dry_run;
	    }

	    if (-f $statfile) {
		printlog(L_INFO, "$statfile exists");
	    } else {
	        printlog(L_NOTICE, "creating $statfile");
	        unless ($dry_run) {
		    if (open($fd, '>', $statfile)) {
			close($fd);
		    } else {
		        warn "failed to create $statfile: $!";
		    }
		}
	    }
	} elsif ($statfile) {
	    if ($endline) {
		file_remline($file, $fd, $line, $endline);
   	        push @updated_services, 'sendmail';
	        $need_make = !$dry_run;
	    } else {
		printlog(L_INFO,
			 "$file:$line: retaining status file setup: not configured by $0");
	    }
	}
	close $fd;

	if ($need_make) {
	    my $sendmail_dir = dirname($file);
	    printlog(L_NOTICE, "running make in $sendmail_dir");
	    system("make "
		   . ($dry_run ? '-n ' : '')
		   . "-C $sendmail_dir");
	}
    } else {
	printlog(L_ERR, "can't open $file: $!");
	exit(EX_FATAL);
    }
}

# Main
my $snmpd_conf = '/etc/snmp/snmpd.conf';
my $sendmail_mc = '/etc/mail/sendmail.mc';
my $sendmail_statfile = '/etc/mail/sendmail.st';
my $sendmail_bindir;

my $command = CMD_SETUP;

GetOptions('quiet|q+' => \$suppress_level,
	   'dry-run|n' => \$dry_run,
	   'status-file=s' => \$sendmail_statfile,
	   'bindir=s' => \$sendmail_bindir,
	   'restart=s' => sub {
	       my ($name,$cmd) = split /=/, $_[1], 2;
	       $restart_override{$name} = $cmd;
	   },
	   'configure' => sub { $command = CMD_SETUP },
	   'deconfigure' => sub { $command = CMD_REMOVE },
	   'help' => sub {
	       pod2usage(-exitstatus => EX_OK, -verbose => 2);
	   },
	   'usage' => sub {
	       pod2usage(-exitstatus => EX_OK, -verbose => 0);
	   }
) or pod2usage(-exitstatus => EX_USAGE, -verbose => 0, -output => \*STDERR);

pod2usage(-exitstatus => EX_USAGE, -verbose => 0, -output => \*STDERR)
    if @ARGV;

if ($suppress_level > MAX_SUPPRESS_LEVEL) {
    $suppress_level = MAX_SUPPRESS_LEVEL;
}

check_module;
check_file($snmpd_conf);
check_file($sendmail_mc);
check_file('/etc/mail/Makefile');

unless ($sendmail_bindir) {
    if (-d "/usr/lib/sm.bin") {
	$sendmail_bindir = "/usr/lib/sm.bin";
    }
}

my $stmt = 'perl use NetSNMP::Sendmail';
if ($sendmail_bindir) {
    $stmt .= ' qw(:config bindir /usr/lib/sm.bin)';
}
$stmt .= ';';

edit_sendmail_mc($command, $sendmail_mc, $sendmail_statfile);
edit_snmpd_conf($command, $snmpd_conf, $stmt);

map { restart_service($_) } @updated_services;

exit(@updated_services ? EX_OK : EX_UNCHANGED);

__END__
=head1 NAME

netsnmp-sendmail-setup - sets up Sendmail monitoring via SNMP

=head1 SYNOPSIS

B<netsnmp-sendmail-setup>
[B<-nq>]
[B<--bindir=I<DIR>>]
[B<--configure>]
[B<--deconfigure>]
[B<--dry-run>]
[B<--status-file=I<FILE>>]
[B<--quiet>]
[B<--restart=I<service>=I<command>>]
    
B<netsnmp-sendmail-setup> B<--help> | B<--usage>

=head1 DESCRIPTION

Sets up B<sendmail> and B<snmpd> for obtaining Sendmail statistics via
SNMP. First, it checks whether the Sendmail configuration source
F</etc/mail/sendmail.mc> contains the B<STATUS_FILE> clause and adds it
if not. Then, it creates the status file and runs B<make> in the F</etc/mail>
directory. Finally, the file F</etc/snmp/snmpd.conf> is scanned for the
B<perl use NetSNMP::Sendmail> statement. It is added if not already present.
Each added configuration line is preceded by a comment stating that it
was added by the script.

When run with the B<--deconfigure> option, the reverse operation is
performed.  The B<NetSNMP::Sendmail> configuration statement is removed
from the snmpd configuration unconditionally.  The B<STATUS_FILE> clause
is removed from F</etc/mail/sendmail.mc> only if it is preceded by the
B<netsnmp-sendmail-setup> comment marker.  The status file itself is
never removed.

=head1 OPTIONS

=over 4

=item B<--bindir=I<DIR>>

Some installations place Sendmail binaries in a separate directory, which
is not included in the B<$PATH> environment variable.  Use this option to
inform B<NetSNMP::Sendmail> about this.

Notice for the users of Debian-based systems: the F</usr/lib/sm.bin>
directory is picked up automatically.

=item B<--configure>

A no-op option included for symmetry with B<--deconfigure>.    

=item B<--deconfigure>

Remove the configuration statements previously added to the snmdp and
sendmail configuration files.    
    
=item B<-n>, B<--dry-run>

Dry run mode.  Don't modify any files, just print what would have been done
and exit with the appropriate error code (see B<EXIT STATUS> section).

Use the B<--quiet> option to control the amount of data printed.

=item B<-q>, B<--quiet>

Quiet mode.  When used once, suppresses informative output.  When used twice,
suppresses both informative output and notification messages about modified
files.

=item B<--restart=I<service>=I<command>>

Use I<command> to restart system service I<service> (either B<snmpd> or
B<sendmail>).  Use B<--restart=I<service>=no> to skip restarting this
particular I<service>.

In the absence of this option, B<netsnmp-sendmail-setup> uses the first
available command from the following list:

    systemctl restart $service
    service $service restart
    /etc/init.d/$service restart
    /etc/rc.d/$service restart

=item B<--status-file=I<FILE>>

Name of the Sendmail status file to use when generating the
B<define(STATUS_FILE)> statement in the Sendmail configuration file.

=item B<--help>

Displays short help message.

=item B<--usage>

Displaye short usage message.

=back

=head1 FILES

=over 4

=item F</etc/mail/Makefile>

This file is supposed to recreate the B<sendmail.cf> file from B<sendmail.mc>
if no special goal is given.

=item F</etc/snmp/snmpd.conf>

Default B<snmpd> configuration file.  This file must exist.

=item F</etc/mail/sendmail.mc>

Default source file for creating B<sendmail.cf>.

=item F</etc/mail/sendmail.st>

Default statistics file to use. Can be changed using the B<--status-file>
option.

=item F</usr/lib/sm.bin>

Default directory for Sendmail binaries on Debian-based installations. If
exists, it will be used in the B<NetSNMP::Sendmail> configuration.

This can be changed using the B<--bindir> command line option.

=back

=head1 EXIT STATUS

=over 4

=item B<0>

Success.

=item B<1>

Success, no modification was necessary.

=item B<2>

Fatal error occurred.

=item B<64>

Command line usage error.

=back

=head1 SEE ALSO

B<NetSNMP::Sendmail>(3).

=head1 BUGS

The Sendmail configuration directory and the name of the B<snmpd>
configuration file are hardcoded.

The command relies on B<make -C /etc/mail> to create F<sendmail.cf> from
F<sendmail.mc>.

=cut
