qpsmtpd with postfix - a tutorial

This is the english translation of a talk I held at the Linuxwochen 2004 in Eisenstadt. You can also read the German original.

What is qpsmtpd?

qpsmtpd is an SMTP server written perl with a simple but powerful plugin system. Originally written as a replacement for qmails SMTP server, it can also be used with Postfix or sendmail as "backend".

The plugin system makes it possible to write filters very quickly and easily - consequently there are already quite a few, especially for rejecting spam and viruses (blackhole lists, SpamAssassin, greylisting, ClamAV, ...)

What is qpsmtpd not?

An out-of-the-box Solution. If you use it, you have to be prepared to write your own filters or adapt existing ones to your needs. Or seen the other way around: If you want or need to write your own filters, you should have a look at qpsmtpd (perl is somewhat more readable than sendmail rules).

Target Configuration

I already mentioned that qpsmtpd can be used with different backends: Qmail is used by most, and therefore tested best, but because of its licencing terms it isn't included in many Linux distributions. The Postfix plugin was written by the author of this tutorial and has been processing a few thousand mails per day for almost a year now. The SMTP plugin (which can be used for sendmail and other SMTP servers) is weak on error detection and should be used in a production environment only under carefully controlled circumstances [Note: This may not be true any more - I remember some discussion about rewriting it but haven't checked recently].

Therefore this tutorial will only cover the combination of xinetd, qpsmtpd and Postfix.

Preparations

Postfix

First you have to install Postfix. This should be included in most Linux installations, so you can use the package manager of your choice.

But we want to replace Postfix' own smtpd with qpsmtpd, so we have to assure that Postfix doesn't listen on port 25. To do this we can either remove the line

smtp      inet  n       -       n       -       -       smtpd

from /etc/postfix/master.cf or restrict inet_interfaces in /etc/postfix/main.cf apropriately (sometimes it is useful to let qpsmtpd listen on the externel interface and postfix on localhost or an internal interface).

Perl Modules

Which perl modules you need depends on the plugins you want to use. In any case you need Net::DNS, older versions also needed Mail::Address. If those are already included in your distribution, use your package manager to install them, otherwise use CPAN:

localhost:/ 0:13 127# perl -MCPAN -e shell
Terminal does not support AddHistory.

cpan shell -- CPAN exploration and modules installation (v1.7601)
ReadLine support available (try 'install Bundle::CPAN')

cpan> install Mail::Address
CPAN: Storable loaded ok
Going to read /var/cache/cpan/Metadata
  Database was generated on Mon, 17 May 2004 15:32:57 GMT
CPAN: LWP::UserAgent loaded ok
Fetching with LWP:
  ftp://cpan.inode.at/authors/01mailrc.txt.gz
Going to read /var/cache/cpan/sources/authors/01mailrc.txt.gz
Fetching with LWP:
  ftp://cpan.inode.at/modules/02packages.details.txt.gz
Going to read /var/cache/cpan/sources/modules/02packages.details.txt.gz
  Database was generated on Tue, 18 May 2004 20:34:45 GMT
Fetching with LWP:
  ftp://cpan.inode.at/modules/03modlist.data.gz
Going to read /var/cache/cpan/sources/modules/03modlist.data.gz
Going to write /var/cache/cpan/Metadata
Mail::Address is up to date.

cpan> install Net::DNS
Net::DNS is up to date.

cpan> quit
Terminal does not support GetHistory.
Lockfile removed.
perl -MCPAN -e shell  10.23s user 0.87s system 24% cpu 44.812 total

(we can discuss the problems of mixing different package management systems another time)

Qpsmtpd Installation

Download from http://smtpd.develooper.com/get.html.

Create a new user smtpd:

useradd -G postdrop smtpd

It is important that this user is a member of group postdrop, otherwise it cannot pass on mail directly to postfix.

Then unpack qpsmtpd:

cd ~smtpd
tar xvfz ~/tmp/qpsmtpd-0.27.1.tar.gz
chown -R root:root qpsmtpd-0.27.1 
ln -s qpsmtpd-0.27.1 qpsmtpd 

To make this a bit more FHS conforming, we move configuration to /etc and log files to /var:

mkdir /var/log/qpsmtpd 
chown smtpd:wheel /var/log/qpsmtpd
chmod 750 /var/log/qpsmtpd
rm -rf qpsmtpd/log     
ln -s /var/log/qpsmtpd qpsmtpd/log 

mv qpsmtpd/config /etc/qpsmtpd
ln -s /etc/qpsmtpd qpsmtpd/config

The user smtpd also needs its own tmp directory:

mkdir ~smtpd/tmp
chown smtpd ~smtpd/tmp
chmod 700 ~smtpd/tmp

Patches

To use qpsmtpd with xinetd you also need a small patch, which wasn't included in version 0.27.1 [Note: Is it included in 0.28? I have to check]. The tarball also contains an example configuration for xinetd and a wrapper script for generating the log files.

Some plugins also have to be patched.

xinetd

Install if necessary. The sample configuration may have to be tweaked, too (e.g., you may want to bind only to certain interfaces).

Configuration

All configuration files are in ~smtpd/qpsmtpd/config resp. in /etc/qpsmtpd. The most important one is called plugins und lists all plugins which should be called.

Here you have to replace queue/qmail-queue with queue/postfix-queue.

Testing

% telnet myhost smtp
Trying 192.168.1.2
Connected to localhost.
Escape character is '^]'.
220 localhost.localdomain ESMTP qpsmtpd 0.27.1 ready; send us your mail, but not your spam.
ehlo x
250-localhost.localdomain Hi localhost.localdomain [127.0.0.1]
250-PIPELINING
250 8BITMIME
mail from: <>
250 <>, sender OK - how exciting to get mail from you!
rcpt to: 
250 hjp@myhost, recipient ok
data
354 go ahead

.
250 Queued!  (Queue-Id: 5C3A4170031)
quit
221 localhost.localdomain closing connection. Have a wonderful day.
Connection closed by foreign host.

After this an empty mail should be in the mailbox of the user hjp.

Other plugins

You absolutely need (in my not at all humble opinion) a plugin which can determine during the SMTP dialog whether the recipient exists. In times of spam and worms, it is Evil™ to first accept a mail ant then generate a bounce. There are several plugins which can get local users from different sources, e.g. the qmail configuration or the vpopmail database. For some reason none of them is included in the base distribution, so you have to download one from a different source. I use my own aliases plugin, which takes the list of local users from a simple text file /etc/qpsmtpd/aliases. The format was inspired by Sendmail's aliases file (hence the name), but has since changed considerably. Here is an excerpt from a typical file:

hjp,peter.holzer@wsr.ac.at,wifo.at: hjp@asherah.wsr.ac.at (denysoft_greylist, spamassassin_reject_threshold=10)
postmaster@,wsr.ac.at,wifo.at: sysadm@wsr.ac.at
sysadm@wsr.ac.at: hjp@wsr.ac.at,gina@wsr.ac.at

Here all combinations of hjp and peter.holzer with the domains wsr.ac.at and wifo.at are equivalent and will be delivered to hjp@asherah.wsr.ac.at with the options denysoft_greylist and spamassassin_reject_threshold set (which can be utilized by other plugins). Similarly for postmaster, which also works without a domain (signified by the empty domain between @ and the first comma). Aliases are resolved recursively. An alias with local part * will match all adresses which don't match any explicitely mentioned local part.

Quite effective against current spambots is the Greylisting plugin.

This plugin keeps a history, so smtpd must have write permissions either on the config directory (bad idea) or on ~smtpd/qpsmtpd/var/db (which doesn'exist by default).

There are some MTAs which do not respond kindly to being greylisted. Among them are GroupWise (which is just broken) and some load balancing setups (e.g., the one used by chello in Austria and the one used by YahooGroups). So if you use greylisting you will almost certainly need to except some hosts from it, e.g., with my client_options plugin or Gavin Carrs whitelist plugin.

% telnet localhost smtp
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 localhost.localdomain ESMTP qpsmtpd 0.27.1 ready; send us your mail, but not your spam.
helo foo
250 localhost.localdomain Hi localhost.localdomain [127.0.0.1]; I am so happy to meet you.
mail from: <>
250 <>, sender OK - how exciting to get mail from you!
rcpt to: 
450 This mail is temporarily denied
quit
221 localhost.localdomain closing connection. Have a wonderful day.

Nach Ablauf der blacklist-Periode:

% telnet localhost smtp
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 localhost.localdomain ESMTP qpsmtpd 0.27.1 ready; send us your mail, but not your spam.
helo foo
250 localhost.localdomain Hi localhost.localdomain [127.0.0.1]; I am so happy to meet you.
mail from: <>
250 <>, sender OK - how exciting to get mail from you!
rcpt to: 
250 hjp@foo, recipient ok
rcpt to: 
250 postmaster@foo, recipient ok
data
354 go ahead
Subject: bliub

.
250 Queued!  (Queue-Id: A3BC1170031)
quit

Writing Plugins

The principle is simple: For each SMTP command (and some events) a plugin can register a method to be called. These methods can alter the state of the transaction (especially set and query transaction notes and finally return one of the following values:

DECLINED
Not my cup of tea, continue.
OK
Command has been processed successfully.
DENYSOFT
Command processing has failed, but the client should retry later.
DENY
Command processing has failed, the client should not retry.
DENYHARD
Command processing has failed and the client should be disconnected.

The easiest way to write plugins is to look at existing plugins and modify one of them.

addsig

This simple plugin adds a signature matching the sender's domain to every mail.

sub register {
  my ($self, $qp) = @_;
  $self->register_hook("data_post", "addsig");
}

sub addsig {
  my ($self, $transaction) = @_;

  my $domain = $transaction->sender->host;

  $domain =~ tr/-a-zA-Z0-9.//cd;

  if (open(F, "</etc/qpsmtpd/sigs/$domain")) {
    while (<F>) {
      $transaction->body_write($_);
    }
    close(F);
  }

  return (DECLINED);
}

count_denies

This plugin is a little more complicated. It is called every time another plugin returns DENY or DENYSOFT and increments a counter (stored as a note). For each recipient this counter is checked and after a configurable number of errors the connection is terminated.


=head1 NAME

count_denies - Count denies and disconnect when we have too many

=head1 DESCRIPTION

Disconnect the client if it receives too many denies.
Good for rejecting thwarting dictionary attacks.

=head1 CONFIGURATION

Takes one parameter, the number of allowed denies
before we disconnect the client.  Defaults to 4.

=cut

sub register {
  my ($self, $qp, @args) = @_;
  $self->register_hook("deny", "check_deny");
  $self->register_hook("rcpt", "check_rcpt");

  if (@args > 0) {
    $self->{_deny_max} = $args[0];
    $self->log(1, "WARNING: Ignoring additional arguments.") if (@args > 1);
  } else {
    $self->{_deny_max} = 4;
  }

  $qp->connection->notes('deny_count', 0);

}

sub check_deny {
  my ($self, $cmd) = @_[0,2];
  
  my $deny_count = $self->qp->connection->notes('deny_count');
  $self->log(6, "check_deny: Deny count $deny_count");
  $self->qp->connection->notes('deny_count', $deny_count+1);

  return DECLINED;
}

sub check_rcpt {
    my ($self, $transaction, $rcpt) = @_;

  my $deny_count = 
    $self->qp->connection->notes('deny_count');
  $self->log(6, "check_rcpt: Deny count $deny_count");
  if ($deny_count >= $self->{_deny_max}) {
    $self->log(5, "Closing connection. Too many unrecognized commands.");
    return (DENYHARD, "Closing connection. $deny_count denied commands.");
  }
  return DECLINED;
}