#!/usr/local/bin/perl
#
# Copyright 2014 Double Precision, Inc.  See COPYING for
# distribution information.
#
# To use this filter, at least perl 5.10 is required:
#
# * Must set $sysconfdir/filters/perlfilter-numprocs to 1:
#
#  echo 1 > $sysconfdir/filters/perlfilter-numprocs
#
# $sysconfdir is the Courier configuration directory, usually
# /usr/lib/courier/etc or /etc/courier.
#
# * Set perlfilter-mode to "all" (typical, see courierfilter(8) man page).
#
#  echo all > $sysconfdir/filters/perlfilter-mode
#
# * Set up the $sysconfdir/filters/perlfilter-ratelimit configuration file.
# The contents of this file are "name=value" settings:
#
# WINDOW=<n>
# MESSAGES=<n>
# LIMIT=<n>
# COUNT=(r|m)
# EXTERNAL=(0 or 1)
#
# The default configuration settings are:
#
# WINDOW=3600
# MESSAGES=1000
# LIMIT=100
# COUNT=r
# EXCLUDE=127.0.0.1
# EXTERNAL=0
#
# Default values do not need to be explicitly given in the config file, only
# any non-default valus.
#
# This means: use a one hour window (3600 seconds) of up to 1000 messages.
# We save messages that were processed in the last hour, but only up to
# the last 1000 messages in that window, so that a busy server that receives
# lots of mail is not going to have to waste precious RAM saving information
# about all mail received in the last hour.
#
# Anyway, the rate limiting module will accept up to 100 receipients ("r",
# the alternative setting is "m", number of messages) from the same sender.
#
# What constitutes a sender is either the sender's authenticated ID, or the
# sender's IP address.
#
# EXCLUDE is a comma separated list of authenticated IDs and/or IP addresses
# that are excluded from being rate limited. Put in here a list of your
# backup MXs that send you lots of mail. This is only needed if your backup
# MX authenticate, or their IP addresses have relayclient privileges via
# RELAYCLIENT. If a backup MX is just an ordinary sender that dumps mail on
# you for your domains, no different than any other mail source, this is
# not needed. The rate limiting applies only to clients with relaying
# privileges (by IP address or via authentication). This is the default with
# EXTERNAL=0. Setting EXTERNAL to 1 applies rate limiting to all senders, in
# which case any backup MXs not subject to rate limiting should be
# EXCLUDED
#
# * Set the pathname to this script
#
# echo $pathname >$sysconfdir/filters/perlfilter
#
# * Start it
#
# filterctl start perlfilter

use IO::File;
use feature 'state';
use Data::Dumper;

my $sysconfdir = $ENV{sysconfdir};

# Read configuration file

my $init_config = sub {

    my $config =
	IO::File->new("$sysconfdir/filters/perlfilter-ratelimit", "r");

    # Default settings
    my %default_config = (
	"WINDOW" => 3600,
	"MESSAGES" => 1000,
	"LIMIT" => 100,
	"COUNT" => "r",
	"EXCLUDE" => "127.0.0.1",
	"EXTERNAL" => 0,
	);

    if ($config)
    {
	foreach my $line (<$config>)
	{
	    chomp $line;
	    $line =~ s/[\s\"]//g;
	    $default_config{$1} = $2 if $line =~ /^([^=]*)=(.*)/;
	}
	close($config);
    }

    # EXCLUDE is a comma-separated list. Turn it into a lookup hash.

    $default_config{EXCLUDE} = {
	map {
	    ($_ => 1);
	} split(/,+/, $default_config{EXCLUDE})
    };

    return \%default_config;
    return {};
};

# Private static variables

state $counts = {};
state $messages = [];
state $config = $init_config->();

# The number of the filedescriptor that's connected to the socket is
# passed to us on STDIN.

my $filedesc=shift @ARGV;

my $socket=IO::File->new("+<&$filedesc");

die "$!" unless defined $socket;

my $line;
my $first=1;
my $errmsg="200 Ok";

#
# Read lines from the socket.  Each line contains a filename.  An empty line
# terminates the list.  The first line is the filename of the datafile
# containing the message text.  The subsequent lines are filename(s) of
# control files.
#

while (defined ($line=<$socket>))
{
my $msg;

	chomp $line;
	last unless $line;

	if ($first)
	{
		$msg=filterdata($line);
	}
	else
	{
		$msg=filtercontrol($line);
	}
	$first=0;
	$errmsg=$msg if $msg;
}

$errmsg .= "\n" unless $errmsg =~ /\n$/;
print $socket $errmsg;

$socket->close;

sub filterdata
{
    my $filename = shift;

    return "";
}

sub filtercontrol
{
    my $filename=shift;

    my $authname="";
    my %envs;
    my $count=0;

    my $fh=IO::File->new($filename, "r");

    if ($fh)
    {
	foreach my $line (<$fh>)
	{
	    chomp $line;

	    # See comctlfile.h

	    $authname=$1 if $line =~ /^i(.*)/;
	    $envs{$1}=$2 if $line =~ /^O([^=]*)=(.*)/;
	    ++$count if $line =~ /^r/;
	}
	close($fh);
    }

    # Does rate limiting apply?

    $envs{RELAYCLIENT}=1 if $config->{EXTERNAL};
    return unless exists $envs{RELAYCLIENT};

    # Yes it does, but on a per message basis
    $count=1 if $config->{COUNT} eq "m";

    # Ok, who do we charge this again?

    my $who_is_this;

    if ($authname)
    {
	$who_is_this=$authname;
    }
    elsif ($envs{TCPREMOTEIP})
    {
	$who_is_this=$envs{TCPREMOTEIP};

	$who_is_this=~s/^::ffff://;  # Convert IPv6 to IPv4 format
    }
    else
    {
	# Shouldn't happen

	return "";
    }

    # Give these guys a pass
    return "" if exists $config->{EXCLUDE}{$who_is_this};

    # First, purge stale cache data

    my $now=time;

    my $c;

    while ( ($c=scalar @$messages) >= $config->{MESSAGES} ||
	($c && $messages->[0]->{timestamp} < $now - $config->{WINDOW}))
    {
	my $oldest=shift @$messages;

	delete $counts->{$oldest->{who}}
	    if ($counts->{$oldest->{who}} -= $oldest->{count}) == 0;
    }

    # Now, drop this one into the cache
    my $new_count = (($counts->{$who_is_this} //= 0) += $count);

    push @$messages, {
	who => $who_is_this,
	count => $count,
	timestamp => $now,
    };

    # To dump the contents of the filter's state, create an empty
    # perlfilter-dump file. Note, it must be writable by the filtering
    # process, which runs under a non-root, but a system userid (typically
    # "mail" or "daemon"). The next time this filter wakes up, it will write
    # its current state to the perlfilter-dump file.

    if (-z "$sysconfdir/filters/perlfilter-dump")
    {
	my $f = IO::File->new("$sysconfdir/filters/perlfilter-dump", "w");

	if ($f)
	{
	    print $f Data::Dumper::Dumper( {
		counts => $counts,
		buffer => $messages,
		});
	    close($f);
	}
    }

    # Decide this message's fate

    return $new_count > $config->{LIMIT}
    ? "552 $who_is_this is sending too much mail, slow down.":"";
}
