Before moving to qpsmtpd I was using the (now-defunct) greylite package. This package had two good features that I miss in qpsmtpd:
1. Tracking by IP/sender/recip but subsequent whitelisting by IP only. What this means is that when we first hear from a server, we expect it to retry the same message (all parts of connection triple). Once the server has jumped through this hoop, however, we can assume it will do the same for any subsequent pair of sender and recipient. Further greylisting mail at this point will only annoy other local users who must wait for the inevitable black_timeout to pass before their mail gets through. I have added a new parameter (ip_only_whitelist) that implements the greylite model for this (default off). 2. Database Cleanup As bots spam us, we will tend to get more and more useless entries that bog down the system and take up filesystem space. Every 800 times through (on average) greylite would make a pass through its database to remove stale keys. I have added this functionality to the plugin, basing the "staleness" on the grey_timeout for non-whitelisted entries and the white_timeout for the rest. In my code the default is set to 800 (like greylite). 3. Logging For those of us using the syslog logging the log lines were set much too high, with regular messages being logged at CRIT. I have rationalized that. Let me know if there's anything else you need to commit this patch. Cheers, Colin --- greylisting.dist 2010-09-09 08:40:21.000000000 -0400 +++ greylisting 2010-09-09 14:02:58.000000000 -0400 @@ -43,6 +43,13 @@ Whether to include the recipient in tracking connections. Default: 0. +=item ip_only_whitelist <bool> + +Once a server has proven itself using the connection elements you have selected +(remote_ip, sender and/or recipient), further messages from that server will be +accepted immediately (whitelisted) on the basis of remote_ip alone. (The white_timeout +parameter is still honoured.) + =item deny_late <bool> Whether to defer denials during the 'mail' hook until 'data_post' @@ -78,6 +85,14 @@ greylisting off globally if using per_recipient configs). Default: denysoft. +=item cleanup <interval> + +Every <interval> recipients processed (on average) the database +will be cleaned. Connection triplets older than grey_timeout or +white_timeout (as appropriate) will be deleted to keep the database +from growing forever. Default: 800. +(To disable cleanup, specify an interval of 0.) + =item db_dir <path> Path to a directory in which the greylisting DB will be stored. This @@ -129,7 +144,7 @@ my ($QPHOME) = ($0 =~ m!(.*?)/([^/]+)$!); my $DB = "denysoft_greylist.dbm"; my %PERMITTED_ARGS = map { $_ => 1 } qw(per_recipient remote_ip sender recipient - black_timeout grey_timeout white_timeout deny_late mode db_dir); + black_timeout grey_timeout white_timeout deny_late mode db_dir ip_only_whitelist cleanup); my %DEFAULTS = ( remote_ip => 1, @@ -139,6 +154,8 @@ grey_timeout => 3 * 3600 + 20 * 60, white_timeout => 36 * 24 * 3600, mode => 'denysoft', + ip_only_whitelist => 0, + cleanup => 800, ); sub register { @@ -147,7 +164,7 @@ map { split /\s+/, $_, 2 } $self->qp->config('denysoft_greylist'), %arg }; if (my @bad = grep { ! exists $PERMITTED_ARGS{$_} } sort keys %$config) { - $self->log(LOGALERT, "invalid parameter(s): " . join(',',@bad)); + $self->log(LOGERROR, "invalid parameter(s): " . join(',',@bad)); } $self->{_greylist_config} = $config; unless ($config->{recipient} || $config->{per_recipient}) { @@ -190,7 +207,7 @@ return DECLINED unless $note; # Decline if ALL recipients are whitelisted if (($transaction->notes('whitelistrcpt')||0) == scalar($transaction->recipients)) { - $self->log(LOGWARN,"all recipients whitelisted - skipping"); + $self->log(LOGINFO,"all recipients whitelisted - skipping"); return DECLINED; } return DENYSOFT, $note; @@ -218,75 +235,100 @@ last if $dbdir ||= $d && -d $d && $d; } my $db = "$dbdir/$DB"; - $self->log(LOGINFO,"using $db as greylisting database"); + $self->log(LOGDEBUG,"using $db as greylisting database"); my $remote_ip = $self->qp->connection->remote_ip; my $fmt = "%s:%d:%d:%d"; # Check denysoft db unless (open LOCK, ">$db.lock") { - $self->log(LOGCRIT, "opening lockfile failed: $!"); + $self->log(LOGERROR, "opening lockfile failed: $!"); return DECLINED; } unless (flock LOCK, LOCK_EX) { - $self->log(LOGCRIT, "flock of lockfile failed: $!"); + $self->log(LOGERROR, "flock of lockfile failed: $!"); close LOCK; return DECLINED; } my %db = (); unless (tie %db, 'AnyDBM_File', $db, O_CREAT|O_RDWR, 0600) { - $self->log(LOGCRIT, "tie to database $db failed: $!"); + $self->log(LOGERROR, "tie to database $db failed: $!"); close LOCK; return DECLINED; } + if ($config->{cleanup} && ! int(rand $config->{cleanup})) { + # every $config->{cleanup} times (on average) clean out stale DB entries + my $now = time; + while (my ($key,$val) = each %db) { + my ($ts, $new, $black, $white) = split /:/, $db{$key}; + if (!$white && (time - $ts > $config->{grey_timeout})) { + $self->log(LOGDEBUG,"Cleaning up expired black or grey key $key (ts: " . localtime($ts) . ", now: " . localtime($now).")"); + delete $db{$key}; + } + if ($white && (time - $ts > $config->{white_timeout})) { + $self->log(LOGDEBUG,"Cleaning up expired white key $key (ts: " . localtime($ts) . ", now: " . localtime($now).")"); + delete $db{$key}; + } + } + } my @key; push @key, $remote_ip if $config->{remote_ip}; push @key, $sender->address || '' if $config->{sender}; push @key, $rcpt->address if $rcpt && $config->{recipient}; - my $key = join ':', @key; + my $fullkey = join ':', @key; + my $ipowl_key = $remote_ip if $config->{ip_only_whitelist}; + my $key = ($ipowl_key && $db{$ipowl_key})?$ipowl_key:$fullkey; my ($ts, $new, $black, $white) = (0,0,0,0); if ($db{$key}) { ($ts, $new, $black, $white) = split /:/, $db{$key}; - $self->log(LOGERROR, "ts: " . localtime($ts) . ", now: " . localtime); + $self->log(LOGDEBUG, "ts: " . localtime($ts) . ", now: " . localtime); if (! $white) { # Black IP - deny, but don't update timestamp if (time - $ts < $config->{black_timeout}) { $db{$key} = sprintf $fmt, $ts, $new, ++$black, 0; - $self->log(LOGCRIT, "key $key black DENYSOFT - $black failed connections"); + $self->log(LOGINFO, "key $key black DENYSOFT - $black failed connections"); untie %db; close LOCK; return $config->{mode} eq 'testonly' ? DECLINED : DENYSOFT, $DENYMSG; } # Grey IP - accept unless timed out elsif (time - $ts < $config->{grey_timeout}) { - $db{$key} = sprintf $fmt, time, $new, $black, 1; - $self->log(LOGCRIT, "key $key updated grey->white"); + if ($ipowl_key) { + # whitelist based on IP address only. Tidy up full key. + delete $db{$fullkey}; + $db{$ipowl_key} = sprintf $fmt, time, $new, $black, 1; + $self->log(LOGINFO, "host $remote_ip updated grey->white (matched $key)"); + } else { + $db{$key} = sprintf $fmt, time, $new, $black, 1; + $self->log(LOGINFO, "key $key updated grey->white"); + } untie %db; close LOCK; return DECLINED; } else { - $self->log(LOGERROR, "key $key has timed out (grey)"); + $self->log(LOGINFO, "key $key has timed out (grey)"); } } # White IP - accept unless timed out else { if (time - $ts < $config->{white_timeout}) { $db{$key} = sprintf $fmt, time, $new, $black, ++$white; - $self->log(LOGCRIT, "key $key is white, $white deliveries"); + $self->log(LOGINFO, "key $key is white, $white deliveries"); untie %db; close LOCK; return DECLINED; } else { - $self->log(LOGERROR, "key $key has timed out (white)"); + $self->log(LOGINFO, "key $key has timed out (white)"); } } } # New ip or entry timed out - record new and return DENYSOFT - $db{$key} = sprintf $fmt, time, ++$new, $black, 0; - $self->log(LOGCRIT, "key $key initial DENYSOFT, unknown"); + delete $db{$ipowl_key} if $ipowl_key; + $db{$fullkey} = sprintf $fmt, time, ++$new, $black, 0; + $self->log(LOGINFO, "key $key initial DENYSOFT, unknown"); untie %db; close LOCK; return $config->{mode} eq 'testonly' ? DECLINED : DENYSOFT, $DENYMSG;