#!/usr/bin/perl 
##
##  pb4sd -- POP-before-SMTP Daemon
##  Copyright (c) 2001 Ralf S. Engelschall <rse@engelschall.com> 
##
##  This program is derived from Bennett Todd <bet@rahul.net>'s 
##  pop-before-smtp 1.28 (http://people.oven.com/bet/pop-before-smtp/)
##

use File::Tail;
use DB_File;
use Net::Netmask;
use Date::Parse;
use Getopt::Long;
use Fcntl ':flock';
use POSIX qw(getpid setsid);
use IO;

#   logfile parsing patters
my $pattern = {
    #   QPopper 4.0.x (OpenPKG)
    'qpopper' => 
        '^(... .. ..:..:..) (?:<\S+>|\S+) (?:/\S+?)?q?popper\S*\[\d+\]: ' .
        '\([^)]*\) POP login by user "[^"]+" at \([^)]+\) (\d+.\d+.\d+.\d+)$',
    #   Qpopper 3.x
    'popper3' => 
        '^(\w{3} \w{3} \d{2} \d{2}:\d{2}:\d{2} \d{4}) \[\d+\] ' .
        ' Stats:\s+\w+ \d \d \d \d [\w\.]+ (\d+\.\d+\.\d+\.\d+)',
    #   UW ipop3d/imapd
    'ipop3d' =>
        '^(... .. ..:..:..) \S+ (?:ipop3d|imapd)\[\d+\]: ' .
        '(?:Login|Authenticated|Auth) user=\S+ host=(?:\S+)?' .
        '\[(\d+\.\d+\.\d+\.\d+)\](?: nmsgs=\d+/\d+)?$',
    #   GNU pop3d
    'popd3d' => 
        '^(... .. ..:..:..) \S+ gnu-pop3d\[\d+\]: ' .
        'User .* logged in with mailbox .* from (\d+\.\d+\.\d+\.\d+)$',
    #   Cyrus
    'cyrus' => 
        '^(... .. ..:..:..) \S+ (?:pop3d|imapd)\[\d+\]: ' .
        'login: \S*\[(\d+\.\d+\.\d+\.\d+)\] \S+ \S+',
    #   Courier-IMAP
    'courier' => 
        '^(... .. ..:..:..) \S+ imaplogin: ' .
        'LOGIN, user=\S+, ip=\[(\d+\.\d+\.\d+\.\d+)\]$',
    #   Qmail pop3d
    'pop3d' => 
        '^(... .. ..:..:..) \S+ vpopmail\[\d+\]: ' .
        'vchkpw: login \[\S+\] from (\d+\.\d+\.\d+\.\d+)$',
    #   cucipop
    'cucipop' => 
        '^(... .. ..:..:..) \S+ cucipop\[\d+\]: \S+ ' .
        '(\d+\.\d+\.\d+\.\d+) \d+, \d+ \(\d+\), \d+ \(\d+\)',
    #   popa3d 
    'popa3d' => 
        '^(... .. ..:..:..) \S+ popa3d\[\d+\]: Authentication passed for ' .
        '\S+ -- \[(\d+.\d+.\d+.\d+)\]$',
    #   Teapop with logging via OSSP fsl (OpenPKG)
    'teapop' =>
        '^(... .. ..:..:..) \S+ <\S+> teapop\[\d+\]: Successful login for ' .
        '\S+ \[\d+.\d+.\d+.\d+\] from \S+ \[(\d+.\d+.\d+.\d+)\]$',
};

#   parameters and their defaults
my $daemon     = 0;
my $infile     = 'qpopper.log';
my $dbfile     = 'qpopper';
my $popserver  = 'qpopper';
my @exclude    = ();
my $grace      = 1800;
my $logfile    = 'pb4s.log';
my $pidfile    = 'pb4s.pid';

#   option parsing
GetOptions(
    "daemon!"      => \$daemon,
    "infile=s"     => \$infile,
    "dbfile=s"     => \$dbfile,
    "popserver=s"  => \$popserver,
    "exclude=s@"   => \@exclude,
    "grace=i"      => \$grace,
    "logfile=s"    => \$logfile,
    "pidfile=s"    => \$pidfile,
) or die "Usage: p4bs [--daemon]\n" .
         "            [--infile=filename]\n" .
         "            [--dbfile=filename]\n" .
         "            [--popserver=type]\n" .
         "            [--exclude=a.b.c.d/x]\n" .
         "            [--grace=seconds]\n" .
         "            [--logfile=filename]\n" .
         "            [--pidfile=filename]\n";

#   make sure filenames are specified as absolute paths
die "--infile requires absolute filename" if ($infile !~ m|^/|);

#   make sure it is a known pop server
die "unknown pop server '$popserver'" if (not defined($pattern->{$popserver}));

#   make sure input logfile exists
die "logfile '$infile' not found" if (not -f $infile);

#   create tail object
my $lf = File::Tail->new(
    name               => $infile,
    interval           => 1,
    adjustafter        => 3,
    maxinterval        => 2,
    resetafter         => 30,
    ignore_nonexistant => 1,
    tail               => 0,
    reset_tail         => -1
) || die "unable to create tail object for '$infile'";

#   create network block
my $nt = {};
foreach my $exclude (@exclude) {
    my $nb = new Net::Netmask ($exclude) || die;
    $nb->storeNetblock($nt);
}

#   create DB hash file
my %db;
my $dbh = tie %db, 'DB_File', $dbfile, O_CREAT|O_RDWR, 0666, $DB_HASH
    || die "cannot open DB file '$dbfile': $!\n";

#   create DB hash file descriptor
my $fd = $dbh->fd;
open(DB_FH, "+<&=$fd") || die "cannot open '$dbfile' filehandle: $!\n";

#   delete database
flock(DB_FH, LOCK_EX) || die "(exclusive) lock failed: $!\n";
foreach $k (keys(%db)) {
    delete $db{$k}; 
}
flock(DB_FH, LOCK_UN) or die "unlock failed: $!\n";

#   open logfile
my $log = new IO::File ">>$logfile" || die;
$log->autoflush(1);

#   establish signal handlers
$SIG{__DIE__} = sub {
    $log->print("[".localtime(time())."] DIE error=".join(" ", @_)."\n");
    die @_;
};

#   start/stop logging
$log->print("[".localtime(time())."] STARTUP\n");
$SIG{'QUIT'} = $SIG{'INT'} = $SIG{'TERM'} = sub {
    $log->print("[".localtime(time())."] SHUTDOWN\n");
    exit(0);
};

#   optionally daemonize
if ($daemon) {
    my ($pid, $sess_id, $i);

    #   fork and exit parent 
    if ($pid = fork()) { 
        exit(0); 
    }

    #   detach from the terminal
    $sess_id = POSIX::setsid();

    #   prevent possibility of acquiring a controling terminal
    $SIG{'HUP'} = 'IGNORE'; 
    if ($pid = fork()) {
        exit(0);
    }

    #   create pidfile
    open(PID, ">$pidfile") || die;
    printf(PID "%d\n", POSIX::getpid());
    close(PID);

    #   change working directory
    chdir("/");

    #   clear file creation mask
    umask(0);

    #   close stdio file descriptors
    close(STDIN);
    close(STDOUT);
    close(STDERR);

    #   re-open stdio file descriptors to /dev/null
    open(STDIN,  "+>/dev/null");
    open(STDOUT, "+>&STDIN");
    open(STDERR, "+>&STDIN");
}

my $t = {}; # ip to expire table
my $q = []; # ip/expire stack
while (1) {
    my $line = $lf->read();
    my $now = time();
    if ($line =~ m/$pattern->{$popserver}/o) {
        my ($timestamp, $ipaddr) = ($1, $2);

        #   log recognition of entry
        $log->print("[".localtime($now)."] SEE client=".$ipaddr.
                    " login=".localtime(str2time($timestamp))."\n");

        #   calculate expire time
        my $expire = str2time($timestamp) || next;
        $expire += $grace;

        #   skip if grace period is already expired or ip is excluded
        next if ($expire < $now);
        next if (findNetblock($ipaddr, $nt));

        #   push ip/expire onto stack
        push @{$q}, [$ipaddr, $expire];

        #   remember ip
        my $already_enabled = exists($t->{$ipaddr});
        $t->{$ipaddr} = $expire;

        #   skip if ip was already enabled
        if ($already_enabled) {
            $log->print("[".localtime($now)."] UPD client=".$ipaddr." logout=".localtime($expire)."\n");
            next;
        }

        #   lock database
        flock(DB_FH, LOCK_EX);

        #   add entry to database
        $db{$ipaddr} = "OK";
        $log->print("[".localtime($now)."] ADD client=".$ipaddr." logout=".localtime($expire)."\n");

        #   purge expired database entries
        while ($q->[0][1] < $now) {
            if ($q->[0][1] == $t->{$q->[0][0]}) {
                $log->print("[".localtime($now)."] DEL client=".$q->[0][0]." logout=".localtime($q->[0][1])."\n");
                delete $t->{$q->[0][0]};
                delete $db{$q->[0][0]};
            }
            shift @{$q};
        }

        #   synchronize database
        $dbh->sync();

        #   unlock database
        flock(DB_FH, LOCK_UN);
    }
}

__DATA__

=pod

=head1 NAME

pb4sd -- POP-before-SMTP Daemon

=head1 SYNOPSIS

B<p4bsd>
[--daemon]
[--infile=filename]
[--dbfile=filename]
[--popserver=type]
[--exclude=a.b.c.d/x]
[--grace=seconds]
[--logfile=filename]
[--pidfile=filename]

=head1 DESCRIPTION

B<pb4sd> is a little daemon program which watches a POP/IMAP server's
logfile for successful client authentications and writes the
corresponding IP addresses into a Berkeley-DB hash file. This hash file
then can be used by the MTA to allow relaying access.

For debugging purposes you can dump the generated hash file with
Berkeley-DB's C<db_dump -p> and query it selectively via Postfix's
C<postmap -q>.

=cut

