Advertisement
Guest User

postgate

a guest
Apr 9th, 2013
341
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Perl 16.87 KB | None | 0 0
  1. #!/usr/bin/perl -T -w
  2.  
  3. ###
  4. ### postgate, based on postgrey
  5. ###
  6.  
  7. push @INC, "/usr/local/lib/perl5/site_perl/5.12.3/i486-linux-thread-multi";
  8.  
  9.  
  10. package postgate;
  11. use strict;
  12. use Pod::Usage;
  13. use Getopt::Long 2.25 qw(:config posix_default no_ignore_case);
  14. use Net::Server; # used only to find out which version we use
  15. use Net::Server::Multiplex;
  16. use Fcntl ':flock'; # import LOCK_* constants
  17. use Sys::Hostname;
  18. use Sys::Syslog; # used only to find out which version we use
  19. use POSIX qw(strftime setlocale LC_ALL);
  20. use DBI;
  21.  
  22. use vars qw(@ISA);
  23. @ISA = qw(Net::Server::Multiplex);
  24.  
  25. my $VERSION = '0.2';
  26. my $DEFAULT_DBDIR = '/var/spool/postfix/postgate';
  27. my $CONFIG_DIR = '/etc/postfix';
  28.  
  29. my %COUNTER = ();
  30.  
  31. my $dbh;
  32. my $table_quota = "`quota`";
  33. my $table_stat = "`stat`";
  34.  
  35.  
  36. sub read_sender_whitelists($)
  37. {
  38.     my ($self) = @_;
  39.  
  40.     my @whitelist_senders = ();
  41.     for my $f (@{$self->{postgate}{whitelist_senders_files}}) {
  42.         if(open(SENDERS, $f)) {
  43.             while(<SENDERS>) {
  44.                 s/#.*$//; s/^\s+//; s/\s+$//; next if $_ eq '';
  45.                 my ($user, $domain) = split(/\@/, $_, 2);
  46.                 if(/^\/(\S+)\/$/) {
  47.                     # regular expression
  48.                     push @whitelist_senders, qr{$1}i;
  49.                 }
  50.                 elsif(!/^\S+$/) {
  51.                     warn "$f line $.: doesn't look like an address\n";
  52.                 }
  53.                 # postfix access(5) syntax:
  54.                 elsif(defined $domain and $domain ne '') {
  55.                     # user@domain (match also user+extension@domain)
  56.                     push @whitelist_senders, qr{^\Q$user\E(?:\+[^@]+)?\@\Q$domain\E$}i;
  57.                 }
  58.                 elsif(defined $domain) {
  59.                     # user@
  60.                     push @whitelist_senders, qr{^\Q$user\E(?:\+[^@]+)?\@}i;
  61.                 }
  62.                 else {
  63.                     # domain ($user is the domain)
  64.                     push @whitelist_senders, qr{(?:\@|\.)\Q$user\E$}i;
  65.                 }
  66.             }
  67.         }
  68.         else {
  69.             # do not warn about .local file: maybe the user just doesn't have one
  70.             warn "can't open $f: $!\n" unless $f =~ /\.local$/;
  71.         }
  72.         close(SENDERS);
  73.     }
  74.     $self->{postgate}{whitelist_senders} = \@whitelist_senders;
  75. }
  76.  
  77.  
  78. sub read_sender_blacklists($)
  79. {
  80.     my ($self) = @_;
  81.  
  82.     my @blacklist_senders = ();
  83.     for my $f (@{$self->{postgate}{blacklist_senders_files}}) {
  84.         if(open(SENDERS, $f)) {
  85.             while(<SENDERS>) {
  86.                 s/#.*$//; s/^\s+//; s/\s+$//; next if $_ eq '';
  87.                 my ($user, $domain) = split(/\@/, $_, 2);
  88.                 if(/^\/(\S+)\/$/) {
  89.                     # regular expression
  90.                     push @blacklist_senders, qr{$1}i;
  91.                 }
  92.                 elsif(!/^\S+$/) {
  93.                     warn "$f line $.: doesn't look like an address\n";
  94.                 }
  95.                 # postfix access(5) syntax:
  96.                 elsif(defined $domain and $domain ne '') {
  97.                     # user@domain (match also user+extension@domain)
  98.                     push @blacklist_senders, qr{^\Q$user\E(?:\+[^@]+)?\@\Q$domain\E$}i;
  99.                 }
  100.                 elsif(defined $domain) {
  101.                     # user@
  102.                     push @blacklist_senders, qr{^\Q$user\E(?:\+[^@]+)?\@}i;
  103.                 }
  104.                 else {
  105.                     # domain ($user is the domain)
  106.                     push @blacklist_senders, qr{(?:\@|\.)\Q$user\E$}i;
  107.                 }
  108.             }
  109.         }
  110.         else {
  111.             # do not warn about .local file: maybe the user just doesn't have one
  112.             warn "can't open $f: $!\n" unless $f =~ /\.local$/;
  113.         }
  114.         close(SENDERS);
  115.     }
  116.  
  117.     $self->{postgate}{blacklist_senders} = \@blacklist_senders;
  118. }
  119.  
  120. sub mylog($$$)
  121. {
  122.     my ($self, $level, $string) = @_;
  123.     $string =~ s/\%/%%/g; # for Net::Server <= 0.87
  124.     if(!defined $Sys::Syslog::VERSION or $Sys::Syslog::VERSION lt '0.15'
  125.     or !defined $Net::Server::VERSION or $Net::Server::VERSION lt '0.94') {
  126.         # Workaround for a crash when syslog daemon is temporarily not
  127.         # present (for example on syslog rotation).
  128.         # Note that this is not necessary with Sys::Syslog >= 0.15 and
  129.         # Net::Server >= 0.94 thanks to the nofatal Option.
  130.         eval {
  131.             local $SIG{"__DIE__"} = sub { };
  132.             $self->log($level, $string);
  133.         };
  134.     }
  135.     else {
  136.         $self->log($level, $string);
  137.     }
  138. }
  139.  
  140. sub mylog_action($$$;$$)
  141. {
  142.     my ($self, $attr, $action, $reason, $additional_info) = @_;
  143.  
  144.     my @info = ("action=$action");
  145.     push @info, "reason=$reason" if defined $reason;
  146.     push @info, $additional_info if defined $additional_info;
  147.     for my $a (qw(client_address sender recipient)) {
  148.         push @info, "$a=$attr->{$a}" if $attr->{$a};
  149.     }
  150.  
  151.     my $str = join(', ', @info);
  152.     $self->mylog(2, $str);
  153. }
  154.  
  155. sub do_maintenance($$)
  156. {
  157.     my ($self, $now) = @_;
  158.     my $db     = $self->{postgate}{db};
  159.     my $db_env = $self->{postgate}{db_env};
  160.  
  161.     my $total = 0;
  162.  
  163.     my $sth = $dbh->prepare("INSERT INTO $table_quota (email, ts, n) VALUES(?,?,?)");
  164.  
  165.     foreach my $x (keys %COUNTER) {
  166.        if($x ne "" && $COUNTER{$x} > 0) {
  167.           $sth->execute($x, $now, $COUNTER{$x});
  168.           $total += $COUNTER{$x};
  169.        }
  170.     }
  171.  
  172.     $sth->finish;
  173.  
  174.     if($total > 0) {
  175.        my $sth = $dbh->prepare("INSERT INTO $table_stat (ts, n) VALUES(?,?)");
  176.        $sth->execute($now, $total);
  177.        $sth->finish;
  178.     }
  179.  
  180.     %COUNTER = ();
  181.  
  182.     $self->mylog(1, "doing maintenance...");
  183. }
  184.  
  185. sub is_new_instance($$)
  186. {
  187.     my ($self, $inst) = @_;
  188.     return 1 if not defined $inst; # in case the 'instance' parameter
  189.                                    # was not supplied by the client (Exim)
  190.  
  191.     # we keep a list of the last 20 "instances", which identify unique messages
  192.     # so that for example we only put one X-Greylist header per message.
  193.     $self->{postgate}{instances} = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
  194.         unless defined $self->{postgate}{instances};
  195.  
  196.     my $i = $self->{postgate}{instances};
  197.     return 0 if scalar grep { $_ eq $inst } @$i;
  198.    
  199.     # put new value into the array
  200.     unshift @$i, $inst;
  201.     pop @$i;
  202.  
  203.     return 1;
  204. }
  205.  
  206.  
  207. # main routine: based on attributes specified as argument, return policy decision
  208. sub smtpd_access_policy($$)
  209. {
  210.     my ($self, $now, $attr) = @_;
  211.     my $db = $self->{postgate}{db};
  212.     my $sender = '';
  213.  
  214.     if($attr->{sasl_sender} ne "") {
  215.        $sender = $attr->{sasl_sender};
  216.     } elsif($attr->{sasl_username} ne "") {
  217.        $sender = $attr->{sasl_username};
  218.     } else {
  219.        $sender = $attr->{sender};
  220.     }
  221.  
  222.     $COUNTER{$sender}++;
  223.  
  224.     for my $w (@{$self->{postgate}{whitelist_senders}}) {
  225.         if($attr->{sender} =~ $w) {
  226.             $self->mylog_action($attr, 'passed', 'sender whitelist, user/limit=' . $COUNTER{$sender} . "/" . $self->{postgate}{hourly_limit});
  227.             return 'DUNNO';
  228.         }
  229.     }
  230.  
  231.     for my $w (@{$self->{postgate}{blacklist_senders}}) {
  232.         if($attr->{sender} =~ $w) {
  233.             $self->mylog_action($attr, 'discard', 'sender blacklist, user/limit=' . $COUNTER{$sender} . "/" . $self->{postgate}{hourly_limit});
  234.             return 'DISCARD';
  235.         }
  236.     }
  237.  
  238.  
  239.     if($COUNTER{$sender} > $self->{postgate}{hourly_limit}) {
  240.        $self->mylog_action($attr, 'ALERT', $COUNTER{$sender} . "/" . $self->{postgate}{hourly_limit});
  241.        return 'DISCARD';
  242.     }
  243.  
  244.     $self->mylog_action($attr, 'pass', $COUNTER{$sender} . "/" . $self->{postgate}{hourly_limit});
  245.  
  246.     return 'DUNNO';
  247. }
  248.  
  249. sub main()
  250. {
  251.     # save arguments for Net:Server HUP restart
  252.     my @ARGV_saved = @ARGV;
  253.  
  254.     # do not output any localized texts!
  255.     setlocale(LC_ALL, 'C');
  256.  
  257.     # parse options
  258.     my %opt = ();
  259.     GetOptions(\%opt, 'help|h', 'man', 'version', 'noaction|no-action|n',
  260.         'verbose|v', 'quiet|q', 'daemonize|d', 'unix|u=s', 'inet|i=s',
  261.         'user=s', 'group=s', 'dbdir=s', 'pidfile=s', 'delay=i', 'max-age=i',
  262.         'lookup-by-subnet', 'lookup-by-host', 'auto-whitelist-clients:s',
  263.         'whitelist-clients=s@', 'whitelist-senders=s@',
  264.         'syslogfacility|syslog-facility|facility=s',
  265.         'retry-window=s', 'greylist-action=s', 'greylist-text=s', 'privacy',
  266.         'hostname=s', 'exim', 'listen-queue-size=i', 'x-greylist-header=s',
  267.     ) or exit(1);
  268.     # note: lookup-by-subnet can be given for compatibility, but it is default
  269.     # so do not do nothing with it...
  270.     # note: auto-whitelist-clients:s and not auto-whitelist-clients:n so that
  271.     # we can differentiate between --auto-whitelist-clients=0 and
  272.     # auto-whitelist-clients
  273.  
  274.     if($opt{help})     { pod2usage(1) }
  275.     if($opt{man})      { pod2usage(-exitstatus => 0, -verbose => 2) }
  276.     if($opt{version})  { print "postgate $VERSION\n"; exit(0) }
  277.     if($opt{noaction}) { die "ERROR: don't know how to \"no-action\".\n" }
  278.  
  279.     defined $opt{unix} or defined $opt{inet} or
  280.         die "ERROR: --unix or --inet must be specified\n";
  281.  
  282.     # bind only localhost if no host is specified
  283.     if(defined $opt{inet} and $opt{inet}=~/^\d+$/) {
  284.         $opt{inet} = "localhost:$opt{inet}";
  285.     }
  286.  
  287.     # retry window
  288.     my $retry_window = 24*3600*2; # default: 2 days
  289.     if(defined $opt{'retry-window'}) {
  290.         if($opt{'retry-window'} =~ /^(\d+)h$/i) {
  291.             $retry_window = $1 * 3600;
  292.         }
  293.         elsif($opt{'retry-window'} =~ /^\d+$/) {
  294.             $retry_window = $opt{'retry-window'} * 24 * 3600;
  295.         }
  296.         else {
  297.             die "ERROR: --retry-window must be either a number of days or a number\n",
  298.                 "       followed by 'h' for hours ('6h' for example).\n";
  299.         }
  300.     }
  301.  
  302.     # untaint what is given on --dbdir. It is not security sensitive since
  303.     # it is provided by the admin
  304.     if($opt{dbdir}) {
  305.         $opt{dbdir} =~ /^(.*)$/; $opt{dbdir} = $1;
  306.     }
  307.  
  308.     # determine proper "logsock" for Sys::Syslog
  309.     my $syslog_logsock;
  310.     if(defined $Sys::Syslog::VERSION and $Sys::Syslog::VERSION ge '0.15'
  311.     and defined $Net::Server::VERSION and $Net::Server::VERSION ge '0.97') {
  312.         # use 'native' when Sys::Syslog >= 0.15
  313.         $syslog_logsock = 'native';
  314.     }
  315.     elsif($^O eq 'solaris') {
  316.         # 'stream' is broken and 'unix' doesn't work on Solaris: only 'inet'
  317.         # seems to be useable with Sys::Syslog < 0.15
  318.         $syslog_logsock = 'inet';
  319.     }
  320.     else {
  321.         $syslog_logsock = 'unix';
  322.     }
  323.  
  324.     # Workaround: Net::Server doesn't allow for a value of 'listen' higher than 999
  325.     if(defined $opt{'listen-queue-size'} and $opt{'listen-queue-size'} > 999) {
  326.         $opt{'listen-queue-size'} = 999;
  327.     }
  328.  
  329.     # create Net::Server object and run it
  330.     my $server = bless {
  331.         server => {
  332.             commandline      => [ $0, @ARGV_saved ],
  333.             port             => [ $opt{inet} ? $opt{inet} : $opt{unix}."|unix" ],
  334.             proto            => $opt{inet} ? 'tcp' : 'unix',
  335.             user             => $opt{user} || 'postgate',
  336.             group            => $opt{group} || 'postgate',
  337.             dbdir            => $opt{dbdir} || $DEFAULT_DBDIR,
  338.             setsid           => $opt{daemonize} ? 1 : undef,
  339.             pid_file         => $opt{daemonize} ? $opt{pidfile} : undef,
  340.             log_level        => $opt{quiet} ? 1 : ($opt{verbose} ? 3 : 2),
  341.             log_file         => $opt{daemonize} ? 'Sys::Syslog' : undef,
  342.             syslog_logsock   => $syslog_logsock,
  343.             syslog_facility  => $opt{syslogfacility} || 'mail',
  344.             syslog_ident     => 'postgate',
  345.             listen           => $opt{'listen-queue-size'} ? $opt{'listen-queue-size'} : undef,
  346.         },
  347.         postgate => {
  348.             delay            => $opt{delay}     || 300,
  349.             last_maint       => time,
  350.             last_maint_keys  => 0, # do it on the first night
  351.             lookup_by_host   => $opt{'lookup-by-host'},
  352.             awl_clients      => defined $opt{'auto-whitelist-clients'} ?
  353.                 ($opt{'auto-whitelist-clients'} ne '' ?
  354.                     $opt{'auto-whitelist-clients'} : 5) : 5,
  355.             retry_window     => $retry_window,
  356.             greylist_action  => $opt{'greylist-action'} || 'DEFER_IF_PERMIT',
  357.             whitelist_clients_files    => $opt{'whitelist-clients'} ||
  358.                 [ "$CONFIG_DIR/postgate_whitelist_clients" ,
  359.                   "$CONFIG_DIR/postgate_whitelist_clients.local" ],
  360.             whitelist_senders_files => $opt{'whitelist-senders'} ||
  361.                 [ "$CONFIG_DIR/postgate_whitelist_senders" ],
  362.             blacklist_senders_files => $opt{'blacklist-senders'} ||
  363.                 [ "$CONFIG_DIR/postgate_blacklist_senders" ],
  364.             privacy => defined $opt{'privacy'},
  365.             hostname => defined $opt{hostname} ? $opt{hostname} : hostname,
  366.             hourly_limit => $opt{hourly_limit} || 1000,
  367.             exim => defined $opt{'exim'},
  368.         },
  369.     }, 'postgate';
  370.  
  371.     # read black/whitelist
  372.     $server->read_sender_whitelists();
  373.     $server->read_sender_blacklists();
  374.  
  375.     # --privacy requires Digest::SHA
  376.     if($opt{'privacy'}) {
  377.         require Digest::SHA;
  378.     }
  379.  
  380.     $0 = join(' ', @{$server->{server}{commandline}});
  381.     $server->run;
  382.  
  383.     # shouldn't get here
  384.     $server->mylog(1, "Exiting!");
  385.  
  386.     $dbh->disconnect();
  387.  
  388.     exit 1;
  389. }
  390.  
  391. ##### Net::Server::Multiplex methods:
  392.  
  393. # reload whitelists on HUP
  394. sub sig_hup {
  395.     my $self = shift;
  396.     $self->mylog(1, "HUP received: reloading black/whitelists...");
  397.  
  398.     $self->read_sender_whitelists();
  399.     $self->read_sender_blacklists();
  400. }
  401.  
  402. sub post_bind_hook()
  403. {
  404.     my ($self) = @_;
  405.  
  406.     # unix socket permissions should be 666
  407.     if($self->{server}{port}[0] =~ /^(.*)\|unix$/) {
  408.         chmod 0666, $1;
  409.     }
  410. }
  411.  
  412. sub pre_loop_hook()
  413. {
  414.     my ($self) = @_;
  415.  
  416.     # be sure to put in syslog any warnings / fatal errors
  417.     if($self->{server}{log_file} eq 'Sys::Syslog') {
  418.         $SIG{__WARN__} = sub { Sys::Syslog::syslog('warning', '%s', "WARNING: $_[0]") };
  419.         $SIG{__DIE__}  = sub { Sys::Syslog::syslog('crit', '%s', "FATAL: $_[0]"); die @_; };
  420.     }
  421.  
  422.     # write files with mode 600
  423.     umask 0077;
  424.  
  425.     # ensure that only one instance of postgate is running
  426.     my $lock = "$self->{server}{dbdir}/postgate.lock";
  427.     open(LOCK, ">>$lock") or die "ERROR: can't open lock file: $lock\n";
  428.     flock(LOCK, LOCK_EX|LOCK_NB) or die "ERROR: locked: $lock\n";
  429.  
  430.  
  431.     # open database
  432.  
  433.     $dbh = DBI->connect("DBI:SQLite:database=" . $self->{server}{dbdir} . "/postgate.sdb", '', '') || die "cannot open database: " . $self->{server}{dbdir} . "/postgate.sdb";
  434.  
  435.     $dbh->do("CREATE TABLE IF NOT EXISTS $table_quota (email char(128) not null, ts int default 0, n int default 0)");
  436.     $dbh->do("CREATE TABLE IF NOT EXISTS $table_stat (ts int default 0, n int default 0)");
  437. }
  438.  
  439. sub mux_input()
  440. {
  441.     my ($self, $mux, $fh, $in_ref) = @_;
  442.     defined $self->{postgate_attr} or $self->{postgate_attr} = {};
  443.     my $attr = $self->{postgate_attr};
  444.  
  445.  
  446.     # consume entire lines
  447.     while ($$in_ref =~ s/^([^\r\n]*)\r?\n//) {
  448.         next unless defined $1;
  449.         my $in = $1;
  450.         if($in =~ /([^=]+)=(.*)/) {
  451.             # read attributes
  452.             $attr->{substr($1, 0, 512)} = substr($2, 0, 512);
  453.         }
  454.         elsif($in eq '') {
  455.             defined $attr->{request} or $attr->{request}='';
  456.             if($attr->{request} ne 'smtpd_access_policy') {
  457.                 $self->{net_server}->mylog(1, "unrecognized request type: '$attr->{request}'");
  458.             }
  459.             else {
  460.                 my $now = time;
  461.  
  462.                 # decide
  463.                 my $action = $self->{net_server}->smtpd_access_policy($now, $attr);
  464.  
  465.                 # give answer
  466.                 print $fh "action=$action\n\n";
  467.  
  468.                 # attempt maintenance if one hour has passed since the last one
  469.                 my $server = $self->{net_server};
  470.                 if($server->{postgate}{last_maint} &&
  471.                     $now-$server->{postgate}{last_maint} >= 3600)
  472.                 {
  473.                     $server->{postgate}{last_maint} = $now;
  474.                     $server->do_maintenance($now);
  475.                 }
  476.  
  477.                 # close the filehandle if --exim is set
  478.                 if ($self->{net_server}->{postgate}{exim}) {
  479.                     close($fh);
  480.                     last;
  481.                 }
  482.             }
  483.             $self->{postgate_attr} = {};
  484.         }
  485.         else {
  486.             $self->{net_server}->mylog(1, "ignoring garbage: <".substr($in, 0, 100).">");
  487.         }
  488.     }
  489. }
  490.  
  491. sub fatal_hook()
  492. {
  493.     my ($self, $error, $package, $file, $line) = @_;
  494.     # Net::Server calls $self->server_close but, unfortunately,
  495.     # it does exit(0) (with Net::Server 0.97)...
  496.     # It is however useful for init-script to detect non-zero exit codes
  497.     die('ERROR: ' . $error);
  498. }
  499.  
  500. main;
  501.  
  502. __END__
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement