#!/usr/bin/perl -T -w ############################################################################# package postfwdfilter::Server; # This code is Copyright (C) 2001 Morgan Stanley Dean Witter, and # is distributed according to the terms of the GNU Public License # as found at . # # # 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. # # Based on MSDW::SMTP::Server --- SMTP server for content-scanning proxy # Written by Bennett Todd use strict; use warnings; use IO::Socket; use File::Temp qw(mkstemp); # protocol type hash my %protos = ( HE => 'SMTP', EH => 'ESMTP', # currently unsupported LH => 'LMTP', ); # preload and compile some regexps my %spatterns = ( end => qr/[\r\n]*$/, data => qr/^data/i, helo => qr/^(he|[el]h)lo\s+/i, rset => qr/^rset\s*/i, mailfrom => qr/^mail\s+from:\s*\s]+)>?.*/i, rcptto => qr/^rcpt\s+to:\s*\s]+)>?.*/i, xforward => qr/^xforward\s*/i, xattr => qr/^([^=]+)=(.*)$/i, tail => qr/\s*$/, white => qr/\s+/, dotdot => qr/^\.\./, ); sub new { my ($this, @opts) = @_; my $class = ref($this) || $this; my $self = bless { @opts }, $class; $self->{sock} = IO::Socket::INET->new( LocalAddr => $self->{interface}, LocalPort => $self->{port}, Proto => 'tcp', Type => SOCK_STREAM, Listen => 65536, Reuse => 1, ); die "$0: socket bind failure: $!\n" unless defined $self->{sock}; $self->{state} = 'just bound', $self->{queue} ||= '/var/tmp'; return $self; } sub accept { my ($self, @opts) = @_; %$self = (%$self, @opts); ($self->{"s"}, $self->{peeraddr}) = $self->{sock}->accept or die "$0: accept failure: $!\n"; $self->{state} = ' accepted'; } sub chat { my ($self) = @_; local(*_); if ($self->{state} !~ /$spatterns{data}/) { return 0 unless defined($_ = $self->getline); s/$spatterns{end}//; $self->{state} = $_; if (s/$spatterns{helo}//) { if (defined $protos{my $p = uc($1)}) { $self->{proto} = $protos{$p}; } else { die "$0: unsupported protocol type '".$p."LO'\n"; } s/$spatterns{tail}//; s/$spatterns{white}/ /g; $self->{ehelo} = $_; } elsif (s/$spatterns{rset}//) { delete $self->{to}; delete $self->{recipients}; } elsif (s/$spatterns{mailfrom}/$1/) { $self->{from} = $_; delete $self->{to}; delete $self->{recipients}; } elsif (s/$spatterns{rcptto}/$1/) { s/$spatterns{tail}//; s/$spatterns{white}/ /g; $self->{to} = $_; push @{$self->{recipients}}, $_; } elsif (s/$spatterns{xforward}//) { foreach (split /\s+/) { if (m/$spatterns{xattr}/) { $self->{'x'.lc($1)} = $2; } } } elsif (/$spatterns{data}/) { $self->{to} = $self->{recipients}; } } else { $self->reset_queuefile(); $self->{dirty} = 1; while (defined($_ = $self->getline)) { if ($_ eq ".\r\n") { return $self->{state} = '.'; } s/$spatterns{dotdot}/\./; print { $self->{data} } ("$_") or die "$0: write error saving data: $!\n"; } return(0); } return $self->{state}; } sub getline { my ($self) = @_; local ($/) = "\r\n"; return $self->{"s"}->getline; } sub print { my ($self, @msg) = @_; $self->{"s"}->print(@msg); } sub ok { my ($self, @msg) = @_; @msg = ("250 2.0.0 Ok.") unless @msg; $self->print("@msg\r\n") or die "$0: write error acknowledging $self->{state}: $!\n"; } # create a new spool file sub new_queuefile { my ($self) = @_; ($self->{data}, $self->{queuefile}) = mkstemp($self->{queue}."/tXXXXXXXXXX"); die "$0: error creating temp file $self->{queuefile}: $!\n" unless defined $self->{data}; } # reset our spool file sub reset_queuefile { my ($self) = @_; if (defined $self->{queuefile} and -e $self->{queuefile}) { if (defined $self->{dirty}) { seek($self->{data},0,0) or die "$0: can not rewind file: $!\n"; truncate($self->{data},0) or die "$0: can not truncate file: $!\n"; } } else { $self->new_queuefile(); } delete $self->{dirty}; } # remove our spool file sub remove_queuefile { my ($self) = @_; unlink $self->{queuefile} if defined $self->{queuefile}; } # put a message into quarantine sub quarantine { my ($self,$dir) = @_; my $result = my $qfile = undef; if (defined $self->{data}) { my $orig = $self->{data}; seek($orig,0,0) or die "$0: can not rewind file: $!\n"; ($qfile, $result) = mkstemp($dir."/qXXXXXXXXXXXXXXXX"); if ($qfile and $result) { map { print { $qfile } ("$_") } (<$orig>); $qfile->close(); } } return $result; } 1; # EOF postfwdfilter::Server ############################################################################# package postfwdfilter::Client; # This code is Copyright (C) 2001 Morgan Stanley Dean Witter, and # is distributed according to the terms of the GNU Public License # as found at . # # # 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. # # Based on MSDW::SMTP::Server --- SMTP server for content-scanning proxy # Written by Bennett Todd use strict; use warnings; use IO::Socket; my %cpatterns = ( end => qr/\r\n$/, dot => qr/^\./, smtp => qr/^\d{3}-/, ); sub new { my ($this, @opts) = @_; my $class = ref($this) || $this; my $self = bless { timeout => 300, @opts }, $class; $self->{sock} = IO::Socket::INET->new( PeerAddr => $self->{interface}, PeerPort => $self->{port}, Timeout => $self->{timeout}, Proto => 'tcp', Type => SOCK_STREAM, ); die "$0: socket connect failure: $!\n" unless defined $self->{sock}; return $self; } sub hear { my ($self) = @_; my ($tmp, $reply); return undef unless $tmp = $self->{sock}->getline; while ($tmp =~ m/$cpatterns{smtp}/) { $reply .= $tmp; return undef unless $tmp = $self->{sock}->getline; } $reply .= $tmp; $reply =~ s/$cpatterns{end}//; return $reply; } sub say { my ($self, @msg) = @_; return unless @msg; $self->{sock}->print("@msg", "\r\n") or die "$0: write error: $!\n"; } # send message content to the upstream server sub yammer { my ($self,$header,$content) = (@_); # add our own header $self->{sock}->print($header) or die "$0: write error: $!\n"; # content type... my $type = ref($content); # ... scalar unless ($type) { $self->{sock}->print($content) or die "$0: write error: $!\n"; # ... scalar reference } elsif ($type eq 'SCALAR') { $self->{sock}->print($$content) or die "$0: write error: $!\n"; # ... file handle } elsif ($type eq 'GLOB') { seek ($content,0,0) or die "$0: can not rewind file: $!\n"; local (*_); local ($/) = "\r\n"; while (<$content>) { s/$cpatterns{dot}/../; $self->{sock}->print($_) or die "$0: write error: $!\n"; } # ... unknown } else { die "$0: unsupported content type '$type'"; } $self->{sock}->print(".\r\n") or die "$0: write error: $!\n"; } 1; # EOF postfwdfilter::Client ############################################################################# package postfwdfilter; # # This code is Copyright (C) 2008 Jan P. Kessler, and # is distributed according to the terms of the GNU Public License # as found at . # # # 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. # # Written by Jan P. Kessler ### INIT ### # modules use strict; use warnings; use IO::Socket qw(SOCK_STREAM); use Sys::Hostname qw(hostname); use Sys::Syslog qw(:DEFAULT setlogsock); use POSIX qw(setsid setlocale strftime LC_ALL); use Getopt::Long 2.25 qw(:config no_ignore_case bundling); # optional modules BEGIN { # load Time::HiRes if available eval { require Time::HiRes }; if ($@) { warn "$@"; warn "Failed to include optional module Time::HiRes."; } else { Time::HiRes->import( qw(time) ); } # include smtp components import postfwdfilter::Server; import postfwdfilter::Client; } # load Mail::SpamAssassin if available eval { require Mail::SpamAssassin }; my $saversion = ($@) ? '' : Mail::SpamAssassin::Version(); if ($saversion) { eval { require Mail::SpamAssassin::Logger }; if ($@) { warn "$@"; $saversion = ''; warn "Failed to include optional module Mail::SpamAssassin::Logger. Disabling sascan..."; } } else { warn "$@"; warn "Failed to include optional module Mail::SpamAssassin. Disabling sascan..."; } # program name and version my $NAME = 'postfwdfilter'; my $VERSION = '0.99'; my $HOSTNAME = hostname() || 'localhost'; # default: keep sender address for notifications # use --adminmail to change my $ADMIN = '"<>" <<>>'; # command line syntax my $syntax = "syntax: $0 [--verbose] [--daemon] [--throughput] ". "[--children=16] [--minperchild=100] [--maxperchild=200] ". "[--facility=mail] [--name=".$NAME."] ". "[--user=nobody] [--group=nobody] ". "[--deny=550 5.7.1 Access denied] [--discard=250 2.0.0 Ok: blackholed] ". "listen.addr:port talk.addr:port\n"; # daemon mode user and group my $user = 'nobody'; my $group = 'nobody'; # child process limits my $children = 16; my $minperchild = 100; my $maxperchild = 200; # default answers my $denymsg = "550 5.7.1 Access denied"; my $discardmsg = "250 2.0.0 Ok: blackholed"; # directory for temporary files my $queuedir = '/var/tmp/postfwd-queue'; # reset spoolfile after sending my $queuesafe = 1; # quarantine settings my $quarantine = '/var/tmp/postfwd-quarantine'; my @default_quarantine_actions = qw[ DISCARD DENY NOTIFY ]; # scanner settings my $SSC_OK = undef; my $SSC_BAD = 0; my $SSC_ERR = 1; # default SA settings my $safile = '/etc/mail/spamassassin'; my $satimeout = 120; my $saaction = 'PASS'; my $samaxscore = 20; my $samaxsize = 307200; # default AV settings my $virustimeout = 30; my $virusaction = 'NOTIFY'; # scanner definitions: # set 'cmd' for commandline and 'peer' for socket based scanners. socket type AF_INET # is recognised by the 'port' setting. others be treated as AF_UNIX domain connections. my %virusscanners = ( clamscan => { # CMD - command line scanner cmd => '/usr/bin/clamscan', # path to binary opt => '--no-summary', # optional: command line arguments for scan version => '--version', # command line argument to determine version good => qr/\sOK$/m, # 'no virus' pattern evil => qr/(\S+)\s+FOUND$/m, # 'virus found' pattern stop => 1, # stop further virusscanning if virus found }, clamdunix => { # UNIX - socket based scanner peer => '/tmp/clamd.socket', # path to UNIX domain socket pre => "SCAN ", # prefix for sendstring post => "\n", # suffix for sendstring good => qr/\sOK$/, # 'no virus' pattern evil => qr/(\S+)\s+FOUND$/, # 'virus found' pattern stop => 1, # stop further virusscanning if virus found }, clamdinet => { # INET - socket based scanner peer => '127.0.0.1', # hostname/ip address of scanning host port => '3310', # tcp port number pre => "SCAN ", # prefix for sendstring post => "\n", # suffix for sendstring good => qr/\sOK$/, # 'no virus' pattern evil => qr/(\S+)\s+FOUND$/, # 'virus found' pattern stop => 1, # stop further virusscanning if virus found }, # further examples... clamdscan => { cmd => '/usr/bin/clamdscan', opt => '--no-summary', version => '--version', good => qr/\sOK$/m, evil => qr/(\S+)\s+FOUND$/m, stop => 1, }, trend_old => { cmd => '/opt/trend/ISBASE/IScan.BASE/vscan', evil => qr/^\*{3} Found virus (.*?) in file/, stop => 1, }, ); # aliases $virusscanners{clamd} = $virusscanners{clamdunix}; # content scanners my %scanners = ( virus => { init => \&init_virusscanners, scan => \&virscan_data, }, spam => { init => \&init_spamassassin, scan => \&spamscan_data, }, ); # template for virus notification # do NOT specify From: header here, # use --adminmail to change my $virusmail = <<"__EOF__"; To: "<>" <<>> Subject: *****VIRUS***** <> Message-ID: <<>> Date: <> X-Mailer: $NAME $VERSION MIME-Version: 1.0 Content-Type: multipart/alternative; boundary="------------040006050703060200030301" This is a multi-part message in MIME format. --------------040006050703060200030301 Content-Type: text/plain; charset=ISO-8859-15; format=flowed Content-Transfer-Encoding: 7bit Hallo, das System hat in einer an Sie gerichteten E-Mail einen Virus gefunden. Die Zustellung wurde daher verweigert. Hier einige Kenndaten, falls Sie mit der Erkennung nicht einverstanden sind und sich an Ihren Administrator wenden moechten: ========================================================================= Datum: <> Absender: <> Betreff: <> Anhang: <> Virus: <> Quelle: <> Message-ID: <<>> Quarantaene-ID: <<>> ========================================================================= Mit freundlichen Gruessen Ihr Administrator -- $NAME $VERSION auf $HOSTNAME --------------040006050703060200030301 Content-Type: text/html; charset=ISO-8859-15 Content-Transfer-Encoding: 7bit
Hallo,

das System hat in einer an Sie gerichteten E-Mail einen Virus gefunden. Die Zustellung wurde daher verweigert.

Hier einige Kenndaten, falls Sie mit der Erkennung nicht einverstanden sind und sich an Ihren Administrator wenden möchten:

        =========================================================================

          Datum:	<>
          Absender:	>><>
          Betreff:	„<>”

          Anhang:	<>
          Virus:	<>
          Quelle:	<>
          Quarantäne:	qid=<<>>, mid=<<>>

        =========================================================================

Mit freundlichen Grüßen
Ihr Administrator

-- 
$NAME $VERSION auf $HOSTNAME


--------------040006050703060200030301-- __EOF__ # end of template2 # gather attribute sequence my %getattr_sequence = ( helo => [ qw[ xhelo rhelo ehelo ] ], name => [ qw[ xname rname ] ], addr => [ qw[ xaddr raddr ] ], ); # preload and compile some regexps my %patterns = ( dot => qr/^\./, stripdotzero => qr/\.0$/, empty => qr/^\r?\n$/, email => qr/^?$/, data => qr/^data/i, deny => qr/^#deny#/, discard => qr/^#discard#/, content => qr/^content-(type|disposition)/i, messageid => qr/^message-id:\s*?\s*$/i, subject => qr/^subject:\s*(.*?)\s*$/i, received => qr/^received:/i, attachment => qr/(file)?name=["\s]*(.*?)[;"\s]*$/i, #received_full => qr/^received:\s+from\s+([^\s]+)\s+\(([^\s]+)\s+\[([^\]]+)\]\)/i, #tls_line_1 => qr/^\s+\(using\s+([^\s]+)\s+with\s+cipher\s+([^\s]+)\s+\(([^\)]+)\)\)/i, #tls_line_2 => qr/^\s+\(Client\s+CN\s+\"([^\"]+)\",\s+Issuer\s+\"([^\"]+)\"\s+\(([^\)]+)\)\)/i, #tls_fingerprint => qr/\bx-tls-client-fingerprint:\s+([0-9A-F:]+)\r?\n/i, received_full => qr/^received: from ([^\s]+) \(([^\s]+) \[([^\]]+)\]\)\s+/i, tls_line_1 => qr/^\(using ([^\s]+) with cipher ([^\s]+) \(([^\)]+)\)\)\s+/i, tls_line_2 => qr/^\(Client CN "([^"]+)", Issuer "([^"]+)" \(([^\)]+)\)\)/i, tls_fingerprint => qr/^x-tls-client-fingerprint: ([0-9A-F:]+)/mi, ); # abbreviate these items for logging my %abbrev_items = ( helo => 60, ehelo => 60, xhelo => 60, rhelo => 60, tlsccn => 60, tlsissuer => 40, tlsstat => 40, reply => 60, attachment => 60, subject => 60, virus => 250, messageid => 250, sahits => 500, ); # do not change this unless you really know why my $verbose = my $sadebug = 0; my $OLDARG0 = $0; my @OLDARGV = @ARGV; # log items will only be shown if available. # privacy issues, enable at your own risk. consider using: # --logitems=..., --logall, --logtls, --logsubject or --logattachments #my @default_logitems = qw[ # client name addr helo ehelo # xname xaddr xhelo rname raddr rhelo # from to messageid size # time sascore sahits # tlsproto tlscipher tlskey tlsfingerprinti # tlsccn tlsissuer tlsstat # virus attachment subject reply #]; my @default_logitems = qw[ client from to helo messageid size scans virus sascore sahits quarantine reply ]; my $unsafe_charset = qr/[^\x20-\x7E]/; my $syslog_name = $NAME; my $syslog_facility = 'mail'; my $syslog_options = 'pid'; # Sys::Syslog < 0.15 dies when syslog daemon is temporarily not # present (for example on syslog rotation) my $syslog_version = (defined $Sys::Syslog::VERSION) ? $Sys::Syslog::VERSION : ''; my $syslog_old = (not($syslog_version) or $syslog_version lt '0.15'); # undef init use vars qw ( $server $daemon $ppid $spamtest $throughput $logsubject $logattachments $logtls $logall $allow_user_commands $syslog_enhanced_charset $sa_opportunistic_expire $taggedmail $srcaddr $srcport $dstaddr $dstport $uid $gid $homedir $syslog_socktype $syslog_ptr $notification $message @scan_sequence @scan_procs @sadomains @sawhitelistfrom @quarantine_actions @logitems @virusscanners %quarantine_actions ); # parse command-line GetOptions( "v|verbose+" => \$verbose, "d|daemon|daemonize" => \$daemon, "children=n" => \$children, "minperchild=n" => \$minperchild, "maxperchild=n" => \$maxperchild, "u|user=s" => \$user, "g|group=s" => \$group, "deny|denymsg=s" => \$denymsg, "discard|discardmsg=s" => \$discardmsg, "throughput" => \$throughput, "hostname|host_name=s" => \$HOSTNAME, "admin|adminmail|admin_mail=s" => \$ADMIN, "allowusercommands|allow_user_commands" => \$allow_user_commands, "quarantine|quarantine_dir=s" => \$quarantine, "virustimeout|virus_timeout=n" => \$virustimeout, "virusaction|virus_action=s" => \$virusaction, "satimeout|sa_timeout=n" => \$satimeout, "saaction|sa_action=s" => \$saaction, "sadebug|sa_debug" => \$sadebug, "safile|sa_file=s" => \$safile, "samaxscore|sa_max_score=s" => \$samaxscore, "salevelaction|sa_level_action=s" => \$samaxscore, # compatibility "samaxsize|sa_max_size=n" => \$samaxsize, "logall|log_all" => \$logall, "logtls|log_tls" => \$logtls, "queue|queuedir|queue_dir=s" => \$queuedir, "queuesafe|queue_safe|safequeue|safe_queue!" => \$queuesafe, "logsubject|log_subject" => \$logsubject, "logattachments|log_attachments" => \$logattachments, "name|syslogname|syslog_name=s" => \$syslog_name, "facility|syslogfacility|syslog_facility=s" => \$syslog_facility, "syslogoptions|syslog_options=s" => \$syslog_options, "syslogsocktype|syslog_socktype=s" => \$syslog_socktype, "syslogold|syslog_old|oldsyslog|old_syslog" => \$syslog_old, "syslogenhancedcharset|syslog_enhanced_charset" => \$syslog_enhanced_charset, "saopportunisticexpire|sa_opportunistic_expire" => \$sa_opportunistic_expire, "opportunisticexpire|opportunistic_expire" => \$sa_opportunistic_expire, # shorter "vscan|virscan|virusscan=s" => sub{ @virusscanners = ( @virusscanners, (split /[,\s]+/, $_[1]) ) }, "sadomains|sa_domains=s" => sub{ @sadomains = ( @sadomains, (split /[,\s]+/, $_[1]) ) }, "sawhitelistfrom|sa_whitelist_from=s" => sub{ @sawhitelistfrom = ( @sawhitelistfrom, (split /[,\s]+/, $_[1]) ) }, "logitems|log_items=s" => sub{ @logitems = ( @logitems, (split /[,\s]+/, $_[1]) ) }, "quarantineactions|quarantine_actions=s" => sub{ @quarantine_actions = ( @quarantine_actions, (split /[,\s]+/, $_[1]) ) }, "scansequence|scan_sequence|sequence=s" => sub{ @scan_sequence = ( @scan_sequence, (split /[,\s]+/, $_[1]) ) }, ) or die $syntax; # basic syntax checks die $syntax unless @ARGV == 2; # untaint arguments if ($ARGV[0] =~ m/^([^:]+):(\d+)$/) { ($srcaddr, $srcport) = ($1, $2); } if ($ARGV[1] =~ m/^([^:]+):(\d+)$/) { ($dstaddr, $dstport) = ($1, $2); } die $syntax unless defined($srcport) and defined($dstport); # initialize logging init_log(); # apply security settings secureme(); # check queue and quarantine directories check_dirs(); # daemonize and remember parent pid daemonize() if ($daemon); my $ppid = $$; # initialize scanners foreach my $scanner (@scan_sequence) { $scanners{$scanner}{ready} = &{$scanners{$scanner}{init}}() if defined $scanners{$scanner}{init}; push @scan_procs, $scanner if $scanners{$scanner}{ready}; } mylogs ('info', "Scanner sequence: ".(join ', ', @scan_procs)) if ($verbose and @scan_procs); # initialize smtp server $server = postfwdfilter::Server->new(interface => $srcaddr, port => $srcport, queue => $queuedir, ); mylogs ('info', "Launched smtp server process: address=$srcaddr, port=$srcport, queue=$queuedir") if $verbose; # show final initialisation message mylogs ('info', "$NAME $VERSION [" ."uid=$user($uid); gid=$group($gid)" ."; smtp=$srcaddr:$srcport->$dstaddr:$dstport" .(($scanners{spam}{ready}) ? "; sa=$saversion" : '') .(($scanners{virus}{ready}) ? "; av=".(join ',',@virusscanners) : '') ."; queue=$queuedir" .(($quarantine) ? "; quarantine=$quarantine" : '') ."] successfully initialized."); ### PARENT ### # This block is the parent daemon, never does an accept, just herds # a pool of children who accept and service connections, and # occasionally kill themselves off my %children; my $please_die = 0; PARENT: while (1) { while (scalar(keys %children) >= $children) { my $child = wait; delete $children{$child} if exists $children{$child}; if ($please_die) { kill 15, keys %children; exit 0; } } my $pid = fork; die "$0: fork failed: $!\n" unless defined $pid; last PARENT if $pid == 0; $children{$pid} = 1; select(undef, undef, undef, 0.1); if ($please_die) { kill 15, keys %children; exit 0; } } # ensure that the parent process has not left the main loop accidentally if ($ppid == $$) { mylogs ('crit', "Parent process left main loop. Terminating..."); kill 15, keys %children; exit 0; } else { $ppid = 0 }; ### CHILD ### # This block is a child service daemon. It inherited the bound # socket created by SMTP::Server->new, it will service a random # number of connection requests in [minperchild..maxperchild] then # exit # initialize spamassassin. integrating here saves some mb # for the parent but delays program execution my $requests = 0; my $lives = $minperchild + (rand($maxperchild - $minperchild)); my (%opts, %request); $0 = basename($OLDARG0).'-child'; mylogs ('info', "child ready for input"); while (1) { $server->accept(%opts); my $client = postfwdfilter::Client->new(interface => $dstaddr, port => $dstport); process_request ($server,$client); $client = undef; delete $server->{"s"}; $requests++; if ($please_die or ($lives-- <= 0)) { mylogs ('info', "child finished after $requests requests"); $server->remove_queuefile() if defined $server; exit 0; } } 1; die "should never come here\n"; ### SUBROUTINES ### # takes a list and returns a unified list, keeping given order sub uniq { undef my %uniq; return grep(!$uniq{$_}++, @_); } # abbreviates a string to a given length and adds '...' sub abbrev { my($len,$str) = @_; $str = substr($str,0,($len - 9)).'...'.substr($str,(length($str) - 6),length($str)) if (length($str) > $len); return $str; } # returns filename sub basename { my $path = shift; $path =~ m@/?([^/]+)$@; return $1 || $path; } # Sys::Syslog < 0.15 sub mylogs_old { my($prio,$msg) = @_; eval { local $SIG{'__DIE__'}; syslog ($prio,$msg) }; } # Sys::Syslog >= 0.15 sub mylogs_new { my($prio,$msg) = @_; syslog ($prio,$msg); } # send log message sub mylogs { my($prio,$msg) = @_; # escape unsafe characters $msg =~ s/$unsafe_charset/?/g; $msg =~ s/\%/%%/g; &{$syslog_ptr} ($prio,$msg); } # finish program sub end_program { mylogs ('notice', "$NAME $VERSION terminating...") if $ppid; $server->remove_queuefile() if defined $server; } # init logging sub init_log { # syslog init $syslog_socktype = ($syslog_old) ? (($^O eq 'solaris') ? 'inet' : 'unix') : 'native'; $syslog_ptr = ($syslog_old) ? \&mylogs_old : \&mylogs_new; setlogsock $syslog_socktype; openlog $syslog_name, $syslog_options, $syslog_facility; mylogs ('info', "set up syslogging Sys::Syslog version $syslog_version") if $verbose; # check for enhanced syslog charset $unsafe_charset = qr/[^\x20-\x7E,\x80-\xFE]/ if $syslog_enhanced_charset; # prepare logitems @logitems = @default_logitems unless @logitems; @logitems = (@logitems, qw[ tlsproto tlscipher tlskey tlsfingerprint tlsccn tlsissuer tlsstat ]) if ($logall or $logtls); push @logitems, 'attachment' if ($logall or $logattachments); push @logitems, 'subject' if ($logall or $logsubject); # reply should be last item if (grep 'reply', @logitems) { @logitems = grep {not($_ eq 'reply');} @logitems; push @logitems, 'reply'; } # log each item only once @logitems = uniq(@logitems); mylogs ('info', "logitems: ".(join ',', @logitems)) if $verbose; } # security settings sub secureme { # change to root dir, set safe locale and file mode setlocale(LC_ALL, 'C'); umask(0077); chdir '/' or die "$NAME: can not chdir to /: $!\n"; # get user- and group-settings $uid = getpwnam($user) or die "$NAME: can not get uid for $user\n"; $gid = getgrnam($group) or die "$NAME: can not get gid for $group\n"; $homedir = (getpwnam($user))[7]; # change user- and group-id $) = "$gid $gid"; $( = $gid; $> = $< = $uid; # cleanup environment $ENV{PATH} = '/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin'; if ($homedir) { $ENV{HOME} = $homedir } else { delete $ENV{HOME} if defined $ENV{HOME} }; delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; mylogs ('info', "Successfully applied security settings uid=$user($uid), gid=$group($gid), home=$ENV{HOME}") if $verbose; } # daemonize sub daemonize { # pretty command line in ps $0 = join (' ', $OLDARG0, @OLDARGV); # close our streams stdin and stdout close STDIN; close STDOUT; close STDERR; open STDIN, "/dev/null" or die "$NAME: can not write to /dev/null: $!\n"; # background execution my $i=fork(); if(!defined $i) { die "$NAME: can not fork new master process\n"; } if($i>0) { exit(0); } setsid() or die "$NAME: can not setsid to background\n"; # catch signals $SIG{__WARN__} = sub { mylogs ('warning', "warning: $_[0]") }; $SIG{__DIE__} = sub { mylogs ('crit', "FATAL: $_[0]") unless ($^S or $please_die); $server->remove_queuefile() if defined $server }; $SIG{TERM} = sub { $please_die = 1; end_program() }; $SIG{INT} = sub { $please_die = 1; end_program() }; # now close stderr, too open STDERR, '>&STDOUT' or die "$NAME: can not duplicate stderr to stdout: $!\n"; mylogs ('info', "Successfully daemonized") if $verbose; } # check queue and quarantine directories sub check_dirs { # check queue settings $queuedir =~ s@/$@@; die "Empty value for queue directory!\n" unless $queuedir; unless (-d $queuedir) { unless (mkdir($queuedir)) { die "$NAME: Can not create queue directory '$queuedir'!\n"; } } mylogs ('info', "Using queue directory '$queuedir'") if $verbose; # check quarantine settings if ($quarantine) { @quarantine_actions = @default_quarantine_actions unless (@quarantine_actions); # remove trailing '/' $quarantine =~ s@/$@@; if ($quarantine) { unless (-d $quarantine) { unless (mkdir($quarantine)) { mylogs ('notice', "Can not create quarantine directory '$quarantine'. Disabling quarantine..."); undef $quarantine; } } if ($quarantine) { mylogs ('info', "Using quarantine directory '$quarantine' on actions: ".(join ', ', @quarantine_actions)) if $verbose; map { $quarantine_actions{$_} = 1 } @quarantine_actions; } } } } # init virusscanners sub init_virusscanners { return undef unless (@virusscanners); my @vhelp = (); foreach (@virusscanners) { # check recognition patterns if (!defined $virusscanners{$_}{evil}) { mylogs ('notice', "scanner '$_' error: no 'evil' mask"); } elsif (!defined $virusscanners{$_}{good}) { mylogs ('notice', "scanner '$_' error: no 'good' mask"); # command line scanner } elsif (defined $virusscanners{$_}{cmd}) { # check executable unless (-x $virusscanners{$_}{cmd}) { mylogs ('notice', "scanner '$_' error: no executable '$virusscanners{$_}{cmd}' found"); } else { mylogs ('info', "Using scanner '$_' -> 'CMD:$virusscanners{$_}{cmd}".((defined $virusscanners{$_}{opt}) ? " $virusscanners{$_}{opt}" : '')."'") if $verbose; # 'scan' function ptr $virusscanners{$_}{scan} = \&cmdscan; # add scanner to list push @vhelp, $_; } # socket based scanner } elsif (defined $virusscanners{$_}{peer}) { # socket type AF_INET or AF_UNIX my $type = (defined $virusscanners{$_}{port}) ? 'INET' : 'UNIX'; mylogs ('info', "Using scanner '$_' -> '$type:$virusscanners{$_}{peer}".(($type eq 'INET') ? ":$virusscanners{$_}{port}" : '')."'") if $verbose; # 'get socket' ptr $virusscanners{$_}{scansocket} = ($type eq 'INET') ? \&netscan_socket_inet : \&netscan_socket_unix; # 'scan' function ptr $virusscanners{$_}{scan} = \&netscan; # add scanner to list push @vhelp, $_; # unknown scanner type } else { mylogs ('notice', "scanner '$_' error: unknown scanner type. please define 'cmd' or 'peer'"); } } if (@virusscanners = @vhelp) { # init notification sender $virusmail = "From: $ADMIN\r\n".$virusmail; } return @virusscanners; } # init spamassassin sub init_spamassassin { return undef unless $saversion; # return code my $result = undef; # prepare patterns @sadomains = map { $_ = qr/$_/i } @sadomains if (@sadomains); @sawhitelistfrom = map { $_ = qr/$_/i } @sawhitelistfrom if (@sawhitelistfrom); # set SA logging level $sadebug = ($verbose > 1) ? 'all' : 'info'; # remove stderr logger when daemonized Mail::SpamAssassin::Logger::remove('stderr') if $daemon; # create SA object if ( $spamtest = Mail::SpamAssassin->new( { debug => $sadebug, dont_copy_prefs => 1, site_rules_filename => $safile, } ) ) { # set up SA syslogging Mail::SpamAssassin::Logger::add( method => 'syslog', socket => $syslog_socktype, facility => $syslog_facility, ident => $syslog_name ); Mail::SpamAssassin::Logger::add_facilities($sadebug); # notify user switch to SA $spamtest->signal_user_changed( { username => $user, user_dir => $homedir, userstate_dir => $homedir, } ); if ($sa_opportunistic_expire) { #$spamtest->init_learner({ learn_to_journal => 1, opportunistic_expire_check_only => 1 }); $spamtest->init_learner({ opportunistic_expire_check_only => 1 }); $spamtest->finish_learner(); #} else { # $spamtest->init_learner({ learn_to_journal => 1 }); # $spamtest->finish_learner(); } # precompile SA ruleset $spamtest->compile_now() if $throughput; mylogs ('info', "Successfully initialized ".($throughput ? 'and compiled ' : '')."Mail::SpamAssassin object") if $verbose; $result = 1; } else { # disable SA on failure mylogs ('notice', "Can not create Mail::SpamAssassin object. Try to run 'spamassassin -D --lint' on the console. Scan will be skipped."); } return $result; } # check sender for spamassassin whitelist # for reliable operation own domains should be # denied for remote senders at mta-level # # POSTFIX EXAMPLE # # /etc/postfix/main.cf: # --------------------- # smtpd_recipient_restrictions = # permit_mynetworks, # ...... # reject_unauth_destination, # check_sender_access hash:/etc/postfix/internal_domains # # /etc/postfix/internal_domains: # ------------------------------ # mydomain1.local REJECT you are not authorized to send FROM this domain # mydomain2.local REJECT you are not authorized to send FROM this domain # sub whitelisted_sender { my @snds = @_; my $result = @sawhitelistfrom; if ($result) { WHITEFROM: for my $snd (@snds) { $snd =~ s/$patterns{email}/$1/; for my $sasnd (@sawhitelistfrom) { last WHITEFROM if ($result = ($snd =~ m/$sasnd/)); } } } return $result; } # check recipient for spamasassin scan sub needs_sascan { my @recs = @_; my $result = not(@sadomains); unless ($result) { SACHECK: for my $rec (@recs) { $rec =~ s/$patterns{email}/$1/; for my $sarec (@sadomains) { last SACHECK if ($result = ($rec =~ m/$sarec/)); } } } return $result; } # fill in taggedmail template values sub prepare_taggedmail { foreach (keys %request) { my $mask = '<<'.uc($_).'>>'; $mask = qr/$mask/; my $repl = join ', ', @{$request{$_}} if defined $request{$_}; $taggedmail =~ s/$mask/$repl/g if ($mask and $repl); } } # put a message into quarantine sub quarantine { my $qfile = undef; if ($qfile = $server->quarantine($quarantine)) { mylogs ('info', "Quarantined '$server->{queuefile}' -> '$qfile'") if $verbose; } else { mylogs ('notice', "Quarantine-error: '$server->{queuefile}' -> '$qfile'"); } return basename($qfile); } # cmdscan sub cmdscan { my ($server,$scanner) = @_; my $code = $SSC_ERR; my $status = ''; my $cmdline = $virusscanners{$scanner}{cmd}.((defined $virusscanners{$scanner}{opt}) ? " $virusscanners{$scanner}{opt}" : '' ).' '.$server->{queuefile}; mylogs ('info', "cmdscan '$scanner' sending: '$cmdline'") if ($verbose > 1); my $output = qx($cmdline 2>&1); if ($output) { map { mylogs ('info', "cmdscan '$scanner' answer: '$_'") } (split /\r?\n/, $output) if ($verbose > 1); if ($output =~ /$virusscanners{$scanner}{evil}/) { $status = $1; $code = $SSC_BAD; } elsif ($output =~ /$virusscanners{$scanner}{good}/) { $code = $SSC_OK; } else { $status = "cmdscan '$scanner' bad answer: '$output'"; } } else { $status = "cmdscan '$scanner' empty output for '$cmdline'"; } return ($code,$status); } # netscan - get unix socket sub netscan_socket_unix { return new IO::Socket::UNIX ( Peer => $virusscanners{$_[0]}{peer}, Type => SOCK_STREAM, ); } # netscan - get inet socket sub netscan_socket_inet { return new IO::Socket::INET ( PeerAddr => $virusscanners{$_[0]}{peer}, PeerPort => $virusscanners{$_[0]}{port}, Timeout => $virusscanners{$_[0]}{timeout}, Type => SOCK_STREAM, Proto => 'tcp', ); } # netscan sub netscan { my ($server,$scanner) = @_; my $code = $SSC_ERR; my $status = ''; my $sendstr = $virusscanners{$scanner}{pre}.$server->{queuefile}.$virusscanners{$scanner}{post}; my $socket = &{$virusscanners{$scanner}{scansocket}}($scanner); if ( $socket ) { mylogs ('info',"netscan '$scanner' sending: '$sendstr'") if ($verbose > 1); print $socket "$sendstr"; $sendstr = <$socket>; chomp($sendstr); mylogs ('info',"netscan '$scanner' answer: '$sendstr'") if ($verbose > 1); if ($sendstr =~ /$virusscanners{$scanner}{evil}/) { $status = $1; $code = $SSC_BAD; } elsif ($sendstr =~ /$virusscanners{$scanner}{good}/) { $code = $SSC_OK; } else { $status = "netscan '$scanner' bad answer: '$sendstr'"; } } else { $status = "netscan '$scanner' could not open connection to $virusscanners{$scanner}{peer}:$virusscanners{$scanner}{port}: $@: $!"; } return ($code,$status); } # parse message content sub parse_data { my $fh = shift; my $received = my $trusted = ''; my $size = my $rcvd = 0; my $getnext = undef; my $action = 'PASS'; # reset filehandle seek ($fh,0,0) or die "$0: can not rewind file: $!\n"; $size = (stat($fh))[7]; local (*_); local ($/) = "\r\n"; my $inheaders = 1; while (<$fh>) { mylogs ('info', "[DATA] debug: $_") if ($verbose > 2); s/$patterns{dot}/../; ## inspect message headers if (defined $inheaders) { # end of headers if (m/$patterns{empty}/) { # parse trusted headers (before first 'Received:') if ($trusted =~ m/$patterns{tls_fingerprint}/) { push @{$request{tlsfingerprint}}, $1; } # parse own received header if ($received =~ s/$patterns{received_full}// ) { my($r_helo,$r_name,$r_addr) = ($1,$2,$3); push @{$request{rhelo}}, $r_helo unless defined $request{rhelo}; push @{$request{rname}}, $r_name unless defined $request{rname}; push @{$request{raddr}}, $r_addr unless defined $request{raddr}; if ($received =~ s/$patterns{tls_line_1}//) { my($r_proto,$r_cipher,$r_keylen) = ($1,$2,$3); push @{$request{tlsproto}}, $r_proto unless defined $request{tlsproto}; push @{$request{tlscipher}}, $r_cipher unless defined $request{tlscipher}; push @{$request{tlskey}}, $r_keylen unless defined $request{tlskey}; if ($received =~ s/$patterns{tls_line_2}//) { my($r_clientcn,$r_issuer,$r_status) = ($1,$2,$3); push @{$request{tlsccn}}, $r_clientcn unless defined $request{tlsccn}; push @{$request{tlsissuer}}, $r_issuer unless defined $request{tlsissuer}; push @{$request{tlsstat}}, $r_status unless defined $request{tlsstat}; } } } undef $trusted; undef $received; undef $inheaders; } elsif ($rcvd < 2){ $rcvd++ if (m/$patterns{received}/); unless ($rcvd) { $trusted .= $_; } elsif ($rcvd == 1) { $received .= $_; } } # get message-id if (not(defined $request{messageid}) and (m/$patterns{messageid}/)) { push @{$request{messageid}}, $1; # get subject } elsif (not(defined $request{subject}) and (m/$patterns{subject}/)) { my $subject = $1 || ''; push @{$request{subject}}, $subject; if ($allow_user_commands) { $action = 'DENY' if ($subject =~ m/$patterns{deny}/); $action = 'DISCARD' if ($subject =~ m/$patterns{discard}/); } } } ## parse whole message for attachment names if (s/$patterns{content}//) { if (m/$patterns{attachment}/) { push @{$request{attachment}}, $2; } else { $getnext = 1; } # or look at next line } elsif (defined $getnext) { if (m/$patterns{attachment}/) { push @{$request{attachment}}, $2; } undef $getnext; } } # end of data # get some message params # save these for later use push @{$request{size}}, $size; @{$request{attachment}} = uniq(@{$request{attachment}}) if defined $request{attachment}; # evaluate result foreach my $attr (keys %getattr_sequence) { REC: foreach my $rec (@{$getattr_sequence{$attr}}) { if ($rec and (defined $request{$rec})) { $request{$attr} = $request{$rec}; last REC; } } } push @{$request{client}}, ($request{name}[0] || 'unknown').'['.($request{addr}[0] || 'unknown').']'; return $action; } # cycles through virusscanners sub virscan_data { # currently unused my $fh = shift; my $action = 'PASS'; my $hasvirus = 0; my $header = ''; # handle timeout eval { local $SIG{'__DIE__'}; local $SIG{'ALRM'} = sub { mylogs ('warning',"[TIMEOUT] skipping virusscan after $virustimeout seconds"); die }; my $prevalert = alarm($virustimeout); # cycle through scanners SCANNER: foreach my $scanner (@virusscanners) { if (defined $virusscanners{$scanner}{scan}) { my ($vcode,$vstatus) = &{$virusscanners{$scanner}{scan}} ($server,$scanner); if (defined $vcode) { if ($vcode) { mylogs ('warning', "scanner '$scanner' error: $vstatus"); } else { $hasvirus++; push @{$request{virus}}, "$scanner:$vstatus"; mylogs ('info', "scanner '$scanner' virus: $vstatus overall: $hasvirus") if $verbose; last SCANNER if $virusscanners{$scanner}{stop}; } } else { push @{$request{virus}}, "$scanner:none"; mylogs ('info', "scanner '$scanner' virus: none overall: $hasvirus") if ($verbose > 1); } } else { push @{$request{virus}}, "$scanner:error"; mylogs ('warning', "scanner '$scanner' error: scan() function undefined"); } } # restore old alarm alarm($prevalert); }; if ($hasvirus) { $action = $virusaction; $taggedmail = $virusmail if ($action eq 'NOTIFY'); } $header = 'virus=<'.(join ',', @{$request{virus}}).'>' if defined $request{virus}; mylogs ('info', "Virusscan: action=$action, $header") if $verbose; return ($action, $header); } # scans message with spamassassin sub spamscan_data { my $fh = shift; my $action = 'PASS'; my $header = ''; if ($request{size}[0] > $samaxsize) { push @{$request{sascore}}, 'skipped'; push @{$request{sahits}}, 'msg('.(sprintf "%.1f",($request{size}[0]/1024)).'k) greater max('.(sprintf "%.1f", ($samaxsize/1024)).'k)'; } elsif (whitelisted_sender (@{$request{from}})) { push @{$request{sascore}}, 'skipped'; push @{$request{sahits}}, 'sender whitelisted'; } elsif (!needs_sascan (@{$request{to}})) { push @{$request{sascore}}, 'skipped'; push @{$request{sahits}}, 'recipient whitelisted'; } else { seek ($fh,0,0) or die "$0: can not rewind file: $!\n"; my $mail = my $status = undef; eval { # handle timeout local $SIG{'__DIE__'}; local $SIG{'ALRM'} = sub { mylogs ('warning',"[TIMEOUT] skipping spamassassin after $satimeout seconds"); die }; my $prevalert = alarm($satimeout); $mail = $spamtest->parse($fh); $status = $spamtest->check($mail); alarm ($prevalert); }; if (defined $mail and defined $status) { my $saspam = $status->is_spam(); my $sascore = $status->get_score(); my $sahits = $status->get_names_of_tests_hit(); my $sareqscore = $status->get_required_score(); push @{$request{sascore}}, (sprintf("%.2f",$sascore)).'/'.$sareqscore.'/'.$samaxscore.'/'.$status->get_autolearn_status(); push @{$request{sahits}}, $sahits; if ($saspam) { if (not($saaction eq 'PASS') and ($sascore > $samaxscore)) { $action = $saaction; } else { $taggedmail = $status->rewrite_mail(); $action='TAG'; } } if ($sa_opportunistic_expire and $status->{'bayes_expiry_due'}) { mylogs('info', "bayes expiry was marked as due, running post-check"); $spamtest->rebuild_learner_caches(); $spamtest->finish_learner(); } } else { push @{$request{sascore}}, 'skipped'; push @{$request{sahits}}, "timeout after $satimeout seconds"; } $status->finish() if defined $status; $mail->finish() if defined $mail; } $header = "sascore=<".(join ',', @{$request{sascore}}).">\r\n\tsahits=<".(join ',', @{$request{sahits}}).">"; return ($action, $header); } # process message content sub process_data { my $fh = shift; my $date = strftime("%a, %d %b %Y %T %Z", localtime); my @procs = @scan_procs; my @scans = (); my $header = ''; my $action = 'PASS'; my $t1 = my $t2 = time(); # date, client and envelope data push @{$request{date}}, $date; map { @{$request{$_}} = $server->{$_} if defined $server->{$_} } (qw[ xname xaddr ]); map { @{$request{$_}} = $server->{$_} if defined $server->{$_} } (qw[ ehelo xhelo ]); @{$request{from}} = $server->{from} if defined $server->{from}; @{$request{to}} = @{$server->{to}} if defined $server->{to}; # parse message content my $last = time(); $action = parse_data($fh); my $hdtime = sprintf("%.1f",(time()) - $last); $hdtime =~ s/$patterns{stripdotzero}//; ($hdtime > 0) and push @scans, 'header:'.$hdtime.'s'; mylogs ('info', "hdscan: action=$action") if $verbose; # cycle through scanners while ($action eq 'PASS' and my $scanner = shift @procs) { my $last = time(); ($action,my $aheader) = &{$scanners{$scanner}{scan}}($fh); my $proctime = sprintf("%.1f",(time()) - $last); $proctime =~ s/$patterns{stripdotzero}//; push @scans, "$scanner:$proctime".'s'; mylogs ('info', "$scanner-scan: action=$action, header='$aheader' time=$proctime".'s') if $verbose; $header .= "\r\n\t$aheader" if $aheader; } # note processing information push @{$request{scans}}, (join ',', @scans) if @scans; # return result $action ||= 'PASS'; $header = "X-Postfwd-Filter: action=$action on $HOSTNAME at $date$header\r\n"; return ($action, $header); } sub process_request { my($server,$client) = @_; my $datacmd = my $datareply = undef; # greetings my $banner = $client->hear; $server->ok($banner); # conversation loop while (my $what = $server->chat) { mylogs ('info', "[ENV] debug: $what") if ($verbose > 1); if($what =~ m/$patterns{data}/) { $datacmd = $what; $server->ok("354 End data with ."); } elsif ($what eq '.') { # evaluate request whether... my ($action,$header) = process_data($server->{data}); # ... to quarantine message and... push @{$request{quarantine}}, quarantine() if ($quarantine and defined $quarantine_actions{$action}); # ... to reject if ($action eq 'DENY') { # deny access to client $server->ok($denymsg); # drop upstream connection silently $client->say("QUIT"); # ... to discard } elsif ($action eq 'DISCARD') { # deny access to client $server->ok($discardmsg); # drop upstream connection silently $client->say("QUIT"); # ... or send it to the upstream server } else { # send the original DATA $client->say($datacmd); # get upstream server's reply $client->hear(); # prepare template prepare_taggedmail() if ($action eq 'NOTIFY'); # send content $client->yammer($header,($taggedmail) ? \$taggedmail : $server->{data}); # get server's reply $datareply = $client->hear(); # and send it to the client $server->ok("$datareply\r\n"); push @{$request{reply}}, $datareply; } # prepare result my $line = "action=<$action>"; foreach my $key (@logitems) { if (defined $request{$key}) { @{$request{$key}} = map { $_ = abbrev($abbrev_items{$key}, $_) if defined $abbrev_items{$key}; $_ = '<'.$_.'>' } @{$request{$key}}; $line .= "; $key=".(join ',', @{$request{$key}}); } } # log request mylogs ('info', "$line"); # cleanup $server->reset_queuefile() if $queuesafe; undef %request; undef $taggedmail; } else { # send server's answer to the client $client->say($what); $server->ok($client->hear); } } } ## END OF SUBS