#!/usr/bin/perl -w
# -*- cperl -*-
# $Id$

use strict;
use Data::Dumper;
use Mail::SPF::Query;
use Net::DNS;
use Sys::Syslog;
use IO::Handle;
use Getopt::Std;
use Fcntl ':flock';
use DBI;


my $greylist_db = "/var/spool/postfix/db/greylist/greylist.sqlite"; # chrooted!
my $whitelist_expiry = 30 * 86400;

my @greylist_time = ( 0, 2, 5, 15, 60 );

## global vars
my ($dbh, %attrs, $res);

sub policy {
  my $suspect = 0;

  ## already whitelisted?
  my $wl_status = get_status();
  if($wl_status eq 'W') {
    return 'DUNNO';
  } elsif($wl_status eq 'G') {
    return "450 Greylisted, try again later";
  } elsif($wl_status eq 'OK') {
    return "OK";
  }

  ## no reverse lookup
  if($attrs{client_name} eq 'unknown') {
    mylog('debug', 'no reverse lookup for %s', $attrs{client_address});
    $suspect++;
  }

  ## HELO
  if($attrs{helo_name} !~ /\./) { # host without domain
    mylog('debug', 'HELO name %s is not FQDN', $attrs{helo_name});
    $suspect++;
  } elsif($attrs{helo_name} =~ /^\[?\d+\.\d+\.\d+\.\d+\]?$/) {
    mylog('debug', 'HELO name %s is IP addr', $attrs{helo_name});
    $suspect++;
  } else { # check helo name in DNS
    unless(has_A_or_MX($attrs{helo_name})) { # helo name not in DNS
      mylog('debug', 'HELO name %s has no A or MX records',
	    $attrs{helo_name});
      $suspect++;
    }
  }

  ## dialups
  if(my $r = check_rbls(["dul.dnsbl.sorbs.net", "dynablock.njabl.org",
			 "dialups.visi.com" ],
			$attrs{client_address})) {
    mylog('debug', 'dialup (%s): %s', $r, $attrs{client_address});
    $suspect++;
  }

  ## SPF
  my $spf = new Mail::SPF::Query(ip => $attrs{client_address},
				 sender => $attrs{sender},
				 helo => $attrs{helo_name},
				 trusted => 1,
				 guess => 1);
  unless (defined $spf) {
    mylog('debug','new Mail::SPF::Query failed');
  } else {
    my ($result, $smtp_comment) = $spf->result();
    mylog('debug', 'SPF result %s/%s', $result, $smtp_comment);
    if($result !~ /pass|none/) {
      $suspect++;
    }
  }

  mylog('debug', 'suspect level %d', $suspect);

  if($suspect == 0) {
    set_whitelisted();
    return "DUNNO";
  } else {
    set_greylisted($greylist_time[$suspect]);
    return "450 Greylisted, try again later";
  }
}


## main
openlog("meta-greylist", "pid", "mail");
mylog('info', 'starting');

my %opts;
getopts('d', \%opts);
my $debug = $opts{d};

$res = Net::DNS::Resolver->new;
$res->persistent_udp(1);


autoflush STDOUT 1;

while(<>) {
  chomp;

  my $answer;

  if(/^$/) {
    if($attrs{request} ne 'smtpd_access_policy') {
      mylog('info', 'request = %s != smtpd_access_policy', $attrs{request});
      $answer = "DUNNO";
    } elsif($attrs{protocol_state} ne 'RCPT') {
      mylog('info', 'protocol_state = %s != RCPT', $attrs{protocol_state});
      $answer = "DUNNO";
    } else {
      ($attrs{sender_domain} = $attrs{sender}) =~ s/^.*\@//g;
      open_db();
      $answer = policy(\%attrs);
      close_db();
    }

    chomp($answer);
    mylog('debug', 'action=%s', $answer);
    printf("action=%s\n\n", $answer);
    %attrs = ();
    next;
  }
  my($k,$v) = split(/=/,$_,2);
  $attrs{$k} = $v;
  mylog('debug', 'attr %s=%s', $k, $v);
}
mylog('info', 'terminating (EOF)');

sub mylog {
  my $level = shift;
  if($level eq 'debug' && $debug) {
    $level = 'info';
  }
  if($level ne 'debug') {
    syslog($level, @_);
  }
}

sub has_A_or_MX {
  my $fqdn = shift;

  my $q = $res->send($fqdn,'A');
  return undef unless $q;
  foreach my $rr ($q->answer) {
    mylog('debug','has_A_or_MX (A): %s RR %s', $fqdn, $rr->type);
    if($rr->type eq 'A') {
       return 1;
    }
  }
  $q = $res->send($fqdn,'MX');
  return undef unless $q;
  foreach my $rr ($q->answer) {
    mylog('debug','has_A_or_MX (MX): %s RR %s', $fqdn, $rr->type);
    if($rr->type eq 'MX') {
      return 1;
    }
  }
  return 0;
}

sub check_rbl {
  my $rbl = shift;
  my $ipaddr = shift;

  my $revaddr = join('.',reverse(split(/\./,$ipaddr))) . '.' . $rbl;

  my $q = $res->send($revaddr, 'A');
  #mylog('debug', 'check_rbl %s', $revaddr);
  return undef unless $q;
  foreach my $rr ($q->answer) {
    if($rr->type eq 'A') {
      return 1;
    }
  }
  return 0;
}

sub check_rbls {
  my $rbls = shift;
  my $ipaddr = shift;

  foreach my $rbl (@$rbls) {
    if(check_rbl($rbl, $ipaddr)) {
      return $rbl;
    }
  }
  return undef;
}

sub get_status {
  my($sth, @r, $ret);

  $ret = eval {

    my $x = $dbh->selectall_arrayref("SELECT * FROM forwarders
              WHERE hostname = ?", {}, $attrs{client_name});
    if(@$x) {
      mylog('debug', '%s is forwarder', $attrs{client_name});
      return 'OK';
    }

    $sth = $dbh->prepare_cached("SELECT status, expire FROM greylist
                                 WHERE client = ? AND helo = ?
                                 AND sender = ?");
    $sth->execute($attrs{client_address}, $attrs{helo_name},
		  $attrs{sender_domain});
    if(@r = $sth->fetchrow_array) {
      if($r[0] eq 'W') {
	$dbh->do("UPDATE greylist SET expire = ?, count = count + 1
                  WHERE client = ? AND helo = ? AND sender = ?", {},
		 time() + $whitelist_expiry,
		 $attrs{client_address}, $attrs{helo_name},
		 $attrs{sender_domain});
	mylog('info', '%s:%s:%s is whitelisted', $attrs{client_address},
	      $attrs{helo_name}, $attrs{sender_domain});
	return 'W';
      } elsif($r[0] eq 'G') {
	if($r[1] >= time()) {
	  mylog('info', '%s:%s:%s is greylisted', $attrs{client_address},
		$attrs{helo_name}, $attrs{sender_domain});
	  return 'G';
	} else {
	  mylog('info', '%s:%s:%s changing to whitelisted',
		$attrs{client_address},	$attrs{helo_name},
		$attrs{sender_domain});
	  set_whitelisted();
	  return 'W';
	}
      } else {
	mylog('err', 'impossible status row: %s', $r[0]);
	return 'W';
      }
    } else {
      mylog('debug', '%s:%s:%s is not in DB', $attrs{client_address},
	    $attrs{helo_name}, $attrs{sender_domain});
      return 'U';
    }
  };
  if($@) {
    mylog('err', 'is_whitelisted: SQL error: %s', $dbh->errstr);
    return 'W';
  }
  return $ret;
}

sub set_whitelisted {
  eval {
    my $x = $dbh->selectall_arrayref("SELECT * FROM greylist WHERE
              client = ? AND helo = ? AND sender = ?", {},
				       $attrs{client_address},
				       $attrs{helo_name},
				       $attrs{sender_domain});
    if(@$x) {
      $dbh->do("UPDATE greylist SET status = 'W', expire = ?, count = 1
              WHERE client = ? AND helo = ? AND sender = ?", {},
	       time() + $whitelist_expiry,
	       $attrs{client_address}, $attrs{helo_name},
	       $attrs{sender_domain});
    } else {
      $dbh->do("INSERT INTO greylist (client, helo, sender, status, expire,
                count) VALUES(?,?,?,'W',?,1)", {},
	       $attrs{client_address}, $attrs{helo_name}, $attrs{sender_domain},
	       time() + $whitelist_expiry);
      mylog('info', '%s:%s:%s set to whitelisted', $attrs{client_address},
	    $attrs{helo_name}, $attrs{sender_domain});
    }
  };
  if($@) {
    mylog('err', 'set_whitelisted: SQL error: %s', $@);
  }
}

sub set_greylisted {
  my $duration = shift;

  unless(defined $duration) {
    $duration = $greylist_time[-1];
  }

  eval {
    $dbh->do("INSERT INTO greylist (client, helo, sender, status, expire, count)
              VALUES(?,?,?,'G',?,0)", {},
	     $attrs{client_address}, $attrs{helo_name}, $attrs{sender_domain},
	     time() + $duration * 60);
    mylog('info', '%s:%s:%s set to greylisted (%d)', $attrs{client_address},
	  $attrs{helo_name}, $attrs{sender_domain}, $duration);
  };
  if($@) {
    mylog('err', 'set_greylisted: SQL error: %s', $@);
  }
}

sub open_db {
  unless($dbh = DBI->connect("dbi:SQLite:dbname=$greylist_db",'','',
                         { RaiseError => 1,
			   PrintError => 0,
			   AutoCommit => 1})) {
    mylog("error connecting to DB %s", $greylist_db);
    exit 1;
  }
  $dbh->{HandleError} = \&log_db_error;
  $dbh->{ShowErrorStatement} = 1;
  eval {
    $dbh->do("select client, helo, sender, status, expire, count
                      from greylist where expire = 0");
  };
  if ($@) {
    eval {
      $dbh->do("create table greylist (
                       client text,
                       helo text,
                       sender text,
                       status text,
                       expire integer,
                       count integer)");
      $dbh->do("create unique index greylist_main
                       on greylist(client, helo, sender)");
      $dbh->do("create index greylist_time
                       on greylist(expire)");
      mylog('info', 'initialized DB %s', $greylist_db);
    };
    if ($@) {
      mylog('err', "error creating DB: %s", $@);
      exit 2;
    }
  }

  eval {
    $dbh->do("select hostname from forwarders");
  };
  if ($@) {
    eval {
      $dbh->do("create table forwarders (
                hostname text primary key)");
      mylog('info', 'created table forwarders');
    };
    if($@) {
      mylog('err', 'error creating table forwarders');
      exit 3;
    }
  }
}

sub log_db_error {
  my $msg = shift;

  mylog('err', 'DBI error: %s', $msg);
  return undef;
}

sub close_db {
  $dbh->disconnect();
}

#
# $Log$
#
