- publishing free software manuals

Articles > smtpwrap - a simple wrapper for SMTP greylisting with inetd

16 September 2004 - Brian Gough (Network Theory Ltd)

This perl script is a simplified version of greylisting for MTAs that run from inetd. It acts as a wrapper and so should work with any MTA, including older mailers (such as Exim-3) which do not have direct support for greylisting. This article shows how to configure Exim-3 for greylisting with smtpwrap (Exim3 is one of the standard MTAs in Debian stable).

Greylisting helps in blocking spam by delaying connections from unknown hosts. This delay gives the operators of block-lists a sufficient time window to detect the origin of spam and list the hosts before anyone accepts mail from them.

More information about greylisting can be found at greylisting.org. Note that greylisting must be combined with RBLs to be effective at stopping spam.

This minimal implementation is intended for greylisting on lightly loaded servers, such as personal domains.

On receiving a connection it checks whether the netblock of the incoming ip address (/24) has been previously seen, by looking it up in a Gdbm file. Connections from new netblocks are given a "421 Temporary failure" until 1 hour has elapsed.

$ telnet 123.245.78.9 25
Trying 123.245.78.9...
Connected to 123.245.78.9.
Escape character is '^]'.
421 Temporary failure (65.43.210.123 please wait 3600 seconds)
Connection closed by foreign host.
$

Legitimate mailservers will simply retry a short time later if they receive a "421 Temporary failure" message, so no mail should be lost or bounced.

Previously-seen ip addresses are passed straight through to the standard MTA (the script uses "exec" to start the MTA with the current stdin/stdout).

$ telnet 123.245.78.9 25   # after 1 hour has elapsed
Trying 123.245.78.9...
Connected to 123.245.78.9.
Escape character is '^]'.
220 example.com ESMTP Exim 3.35 #1 Thu, 16 Sep 2004 15:16:43 +0000

The script keeps its state in the file /tmp/greylist.gdbm (default location). It is safe to delete this file occasionally, a new file will be created on the next attempt and any pending hosts will simply have to retry an additional time. If the /tmp directory is often cleared (e.g. by frequent reboots) you may wish to move the Gdbm file to a more permanent location, so that incoming mailers do not give up on repeated retries. You can change the location and default timeout using the parameters at the top of the script. The file is locked in blocking mode with flock(), and the lock is cleared by the operating system when a process exits, so the risk of a stale lock should be minimised. Logging goes to syslog.

To use the script from inetd, change the standard inetd.conf entry to use smtpwrap, with the usual MTA command line as a parameter. Note that the next argument to inetd after the smtpwrap entry is the name of the process displayed by ps, and not part of command line. Here is an example for Exim 3:

smtp  stream  tcp  nowait  mail  /usr/local/bin/smtpwrap exim /usr/sbin/exim -bs
Testing the configuration on a different port (e.g. 2525) would be recommended before putting it live. To do so, add a new entry to /etc/services for a "testsmtp" port and replace the "smtp" parameter at the start of the line in inet.conf to "testsmtp".

Here is an example of smtpwrap in action (from the syslog output /var/log/mail.log):

Sep 18 19:36:05 smtpwrap[22823]: ip=<219.148.49.197> net=<219.148.49> start=<> now=<1095536165> 
Sep 18 19:36:05 smtpwrap[22823]: 219.148.49.197 connection deferred for 3600 seconds 
Sep 18 19:36:44 smtpwrap[22824]: ip=<82.192.67.204> net=<82.192.67> start=<1095534796> now=<1095536204> 
Sep 18 19:36:44 smtpwrap[22824]: 82.192.67.204 connection deferred for 2192 seconds 
Sep 18 19:36:45 smtpwrap[22825]: ip=<200.145.205.1> net=<200.145.205> start=<1095534352> now=<1095536205> 
Sep 18 19:36:45 smtpwrap[22825]: 200.145.205.1 connection deferred for 1747 seconds 
Sep 18 19:37:37 smtpwrap[22826]: ip=<194.176.32.142> net=<194.176.32> start=<1095519505> now=<1095536257> 
Sep 18 19:37:37 smtpwrap[22826]: 194.176.32.142 connection accepted 
Sep 18 19:39:56 smtpwrap[22832]: ip=<216.203.101.22> net=<216.203.101> start=<1095535676> now=<1095536396> 
Sep 18 19:39:56 smtpwrap[22832]: 216.203.101.22 connection deferred for 2880 seconds 
The "start" and "now" entries are Unix timestamps (seconds since 00:00 1 Jan 1970).

Be sure to add RBL entries to your mail configuration to block spam hosts which retry. For example, with Exim 3 add the following directives in the exim.conf file:

rbl_domains = sbl-xbl.spamhaus.org/reject
recipients_reject_except = postmaster@*:abuse@*:webmaster@*  # mail to these addresses will not be blocked

You can download the smtpwrap script directly here: smtpwrap.pl.

There is also a script for periodically removing old entries from the greylisting database: smtpwrap-clean.pl.

It would be possible to extend the idea of wrapping existing mailers to cover the whole smtp "conversation" using a bidirectional pipe between the wrapper and the mailer, to allow more flexible blocking where the connection could be dropped any appropriate point. Similarly, the delay period could be flexible, based on RBL checks, geolocation or other parameters. However, the current script is sufficiently effective and has the advantage of being simple.

#!/usr/bin/perl
# smtpwrap.pl -- simple greylisting wrapper, force unknown hosts to wait 1 hour
# $Id: smtpwrap.pl,v 1.6 2005/10/08 12:09:40 bjg Exp $
#
# Copyright (C) 2004, Network Theory Ltd
# 
# 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 2 of the License, 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.

$SIG{"__DIE__"} = sub { syslog ('crit', "fatal error: $!");
                        exec(@ARGV) ; exit ; };  # FAILSAFE EXIT
use strict;
use Socket;
use GDBM_File;
use Sys::Syslog;
use Fcntl qw(:DEFAULT :flock);

my $FILE = "/tmp/greylist.gdbm";
my $WAIT_TIME = 3600;

openlog("smtpwrap", "pid", "mail");

my $mysockaddr = getpeername(STDIN) or die;
my ($port, $myaddr) = sockaddr_in($mysockaddr) or die;
my $ip = inet_ntoa($myaddr) or die;
my $net = $ip ; $net =~ s/\.\d+$//;

if ($ip eq "127.0.0.1" || $net eq "192.168.0") {
    syslog ('notice', "allowing local host $ip");
    goto end ;
};

sysopen(FILE, "$FILE.lock", O_RDONLY | O_CREAT) or die;
flock(FILE, LOCK_EX);

my %hash; 
tie %hash, 'GDBM_File', $FILE, &GDBM_WRCREAT, 0640 or die;
my $allow = $hash{$net};
my $now = time();

syslog ('debug', "ip=<$ip> net=<$net> now=<$now> allow=<$allow>");

if (!defined($allow)) {
    $allow = $now + $WAIT_TIME;
    $hash{$net} = $allow;  
} elsif ($now > $allow) {
    $allow = $now;
    $hash{$net} = $allow;
}

untie %hash or die; 
unlink "$FILE.lock";
flock(FILE, LOCK_UN);
close(FILE);

my $time_remaining = $allow - $now;

if ($time_remaining > 0) {
    print "421 Temporary failure ($ip please wait $time_remaining seconds)\n";
    syslog ('notice', "$ip connection deferred for $time_remaining seconds");
    exit;
}

end:
    closelog();

exec(@ARGV);
exit;

#!/usr/bin/perl
# smtpwrap-clean.pl -- remove old entries from greylisting database
#
# use with a /etc/cron.d entry like this:
#
#  # clean the greylisting database once a day
#  0 12 * * *     mail   /usr/local/bin/smtpwrap-clean.pl
#
# $Id: smtpwrap-clean.pl,v 1.2 2004/10/07 13:24:07 bjg Exp $
#
# Copyright (C) 2004, Network Theory Ltd
# 
# 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 2 of the License, 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.

use strict;
use GDBM_File;
use Fcntl qw(:DEFAULT :flock);

my $FILE = "/tmp/greylist.gdbm";

sysopen(FILE, "$FILE.lock", O_RDONLY | O_CREAT) or die;
flock(FILE, LOCK_EX);

my %hash; 
tie %hash, 'GDBM_File', $FILE, &GDBM_WRCREAT, 0640 or die "$!";

my $now = time();

my $k; my $v; my @remove;

while (($k,$v) = each %hash) {
    my $age = int(($now - $v)/(24 * 60 * 60));
    # print "checking $k (age = $age days)\n";
    push(@remove, $k) if  $age > 35;
}

for $k (@remove) {
    print "deleting $k\n";
    delete $hash{$k};
}

untie %hash or die; 
unlink "$FILE.lock";
flock(FILE, LOCK_UN);
close(FILE);