#!/usr/bin/perl -w # # Usage: greylist.pl [-v] # # Demo delegated Postfix SMTPD policy server. This server implements # greylisting. State is kept in a Berkeley DB database. Logging is # sent to syslogd. # # How it works: each time a Postfix SMTP server process is started # it connects to the policy service socket, and Postfix runs one # instance of this PERL script. By default, a Postfix SMTP server # process terminates after 100 seconds of idle time, or after serving # 100 clients. Thus, the cost of starting this PERL script is smoothed # out over time. # # To run this from /etc/postfix/master.cf: # # policy unix - n n - - spawn # user=nobody argv=/usr/bin/perl /usr/libexec/postfix/greylist.pl # # To use this from Postfix SMTPD, use in /etc/postfix/main.cf: # # smtpd_recipient_restrictions = # ... # reject_unauth_destination # check_policy_service unix:private/policy # ... # # NOTE: specify check_policy_service AFTER reject_unauth_destination # or else your system can become an open relay. # # To test this script by hand, execute: # # % perl greylist.pl # # Each query is a bunch of attributes. Order does not matter, and # the demo script uses only a few of all the attributes shown below: # # request=smtpd_access_policy # protocol_state=RCPT # protocol_name=SMTP # helo_name=some.domain.tld # queue_id=8045F2AB23 # sender=foo@bar.tld # recipient=bar@foo.tld # client_address=1.2.3.4 # client_name=another.domain.tld # instance=123.456.7 # sasl_method=plain # sasl_username=you # sasl_sender= # size=12345 # [empty line] # # The policy server script will answer in the same style, with an # attribute list followed by a empty line: # # action=dunno # [empty line] # # In case of database corruption, this script saves the database as # $database_name.time(), so that the mail system does not get stuck. # use strict; use DB_File::Lock; use Fcntl qw(:flock O_RDWR O_CREAT); use Sys::Syslog qw(:DEFAULT setlogsock); use Getopt::Long; # # configuration data # # greylist status database and greylist time interval. DO NOT create the # greylist status database in a world-writable directory such as /tmp # or /var/tmp. DO NOT create the greylist database in a file system # that can run out of space. my $database_name = '/var/mta/greylist.db'; my $greylist_delay = 300; # domains not to be greylisted my $nogreylist_name = '/etc/postfix/nogreylist'; # # Syslogging options for verbose mode and for fatal errors. # NOTE: comment out the $syslog_socktype line if syslogging does not # work on your system. # my $syslog_socktype = 'unix'; # inet, unix, stream, console my $syslog_facility = 'mail'; my $syslog_options = 'pid'; my $syslog_priority = 'info'; # # Other global variables # my $verbose = 0; my $whitelist = ''; my %attr = (); # # Procedures # # # Log an error and abort. # sub fatal_exit { my $first = shift @_; syslog "err", "fatal: $first", @_; exit 1; } # # # Signal 11 means that we have some kind of database corruption (yes # Berkeley DB should handle this better). Move the corrupted database # out of the way, and start with a new database. # sub sigsegv_handler { my $backup = $database_name . "." . time(); rename $database_name, $backup || fatal_exit "Can't save %s as %s: $!", $database_name, $backup; fatal_exit "Caught signal 11; the corrupted database is saved as $backup"; } $SIG{'SEGV'} = 'sigsegv_handler'; # SMTPD access policy routine. The result is an action just like # it would be specified on the right-hand side of a Postfix access # table. Request attributes are available via the %attr hash. # # The result can be any action that is allowed in a Postfix access(5) map. # # To label mail, return ``PREPEND'' headername: headertext # # In case of success, return ``DUNNO'' instead of ``OK'' so that the # check_policy_service restriction can be followed by other restrictions. # # In case of failure, specify ``DEFER_IF_PERMIT optional text...'' # so that mail can still be blocked by other access restrictions. # sub smtpd_access_policy() { my($key, $value, $ctime, $atime, $now, $age, %db); my $count = 0; # Check if the sender isn't greylist-exempted $key = lc join('/', $attr{'client_address'}, $attr{'sender'}, $attr{'recipient'}); if($key =~ m/($whitelist)/o) { syslog $syslog_priority, "whitelisted: %s as %s", $key, $1 if $verbose; } else { # DB_File::Lock provides write-locking for the database tie %db, 'DB_File::Lock', $database_name, O_CREAT|O_RDWR, 0644, $DB_BTREE, 'write' or fatal_exit "Cannot open database %s: $!", $database_name; syslog $syslog_priority, "open %s", $database_name if $verbose>2; # Lookup this client/sender/recipient $value = defined $db{$key} ? $db{$key} : '0/0/0'; syslog $syslog_priority, "lookup %s: %s", $key, $value if $verbose>1; ($ctime,$atime,$count) = split '/', $value; $now = time(); if ($ctime == 0) { # If this is a new request add client/sender/recipient to the database. $ctime = $now; $count = $atime = 0; } else { # Update last access time and count $atime = $now; $count++; } $value = "$ctime/$atime/$count"; # Update database. Use an exclusive lock to avoid collisions with # other updaters, and to avoid surprises in database readers. $db{$key} = $value; # end of critical section - close & unlock the database untie %db; $age = $now - $ctime; syslog $syslog_priority, "%s: %s, age %d, count %d", $key, $value, $age, $count if $verbose; # return DEFER_IF_PERMIT if greylisted return "defer_if_permit Service is temporarily unavailable" if $age < $greylist_delay; } # in any other case return DUNNO return "dunno"; } # # Main program # # # This process runs as a daemon, so it can't log to a terminal. Use # syslog so that people can actually see our messages. # setlogsock $syslog_socktype; openlog $0, $syslog_options, $syslog_facility; unless(GetOptions('v:+' => \$verbose)) { syslog $syslog_priority, "Invalid option. Usage: %s [-v] [-v] ...", $0; exit 1; } # # Unbuffer standard output. # select((select(STDOUT), $| = 1)[0]); # # Read and compile the whitelist # if(-f $nogreylist_name) { open WH, $nogreylist_name or fatal_exit "Cannot open whitelist %s: $!", $nogreylist_name; # comments begin with # in column 1 $whitelist = join '|', map { chomp; m/^([^#].+)/ ? $1 : () } ; close WH; } syslog $syslog_priority, "Whitelist: %s", $whitelist if $verbose>2; # # Receive a bunch of attributes, evaluate the policy, send the result. # while () { chomp; if (/^([^=]+)=(.*)$/) { $attr{substr($1, 0, 512)} = substr($2, 0, 512); } elsif ($_ eq '') { if ($verbose>2) { for (keys %attr) { syslog $syslog_priority, "Attribute: %s=%s", $_, $attr{$_}; } } fatal_exit "unrecognized request type: '%s'", $attr{'request'} unless $attr{'request'} eq 'smtpd_access_policy'; my $action = smtpd_access_policy(); syslog $syslog_priority, "Action: %s", $action if $verbose>1; print STDOUT "action=$action\n\n"; %attr = (); } else { syslog $syslog_priority, "warning: ignoring garbage: %.100s", $_; } } exit 0; # ;-)