#! /usr/bin/perl # $Id: assassind,v 1.1 2002/04/28 19:08:22 dave Exp $ # $Source: /var/cvs/src/assassind/assassind,v $ # Copyright (c) 2002 Dave Carrigan package Assassind; use strict; use Net::Server::PreFork; use Net::SMTP::Server::Client; use IO::File; use Getopt::Long; use Data::Dumper; use Mail::SpamAssassin; use Mail::SpamAssassin::NoMailAudit; #use Mail::Audit; use Net::SMTP; use Error qw(:try); our @ISA = qw(Net::Server::PreFork); our $VERSION = '1.0.1'; sub dead_letter { my($self, $client, $message) = @_; my $filename = join("/", $self->{assassind}->{dead_letters}, sprintf("assassind.%d.%d.%f.dead", time(), $$, rand)); my $dead = IO::File->new; unless ($dead->open(">$filename")) { $self->log(0, "Can't open dead letter file $filename: $!"); return; } chmod 0600, $filename; try { if (defined $message) { $dead->print($message, "\n") or throw Error -text => "Can't print to dead letter: $!"; } foreach (@{$client->{TO}}) { $dead->print("TO $_\n") or throw Error -text => "Can't print to dead letter: $!"; } $dead->print("FROM ", $client->{FROM}, "\n") or throw Error -text => "Can't print to dead letter: $!"; $dead->print($client->{MSG}) or throw Error -text => "Can't print to dead letter: $!"; } catch Error with { my $e = shift; $self->log(0, "Warning!!!! Couldn't print dead letter: " . $e->stringify); }; unless ($dead->close) { $self->log(0, "Warning!!!! Could not close the dead letter file: $!"); } } sub relay_message { my($self, $client) = @_; my $start = time; my $msg_resp; # Now read in message my $message = $client->{MSG}; # Skip processing message over 256K (need to make this an option) if ( length($message) < ($self->{assassind}->{maxsize} * 1024) ) { my @msglines = split (/\r?\n/, $message); my $arraycont = @msglines; for(0..$arraycont) { $msglines[$_] .= "\r\n"; } # Audit the message my $mail = Mail::SpamAssassin::NoMailAudit->new ( data => \@msglines, add_From_line => 0 ); my $assassin = $self->{assassind}->{assassin}; # Check spamminess and rewrite mail if high spam factor or option -a (tag All) my $status = $assassin->check($mail); if ( $status->is_spam || $self->{assassind}->{tagall} ) { $status->rewrite_mail; } # Build the message to send back $msg_resp = join '',$mail->header,"\n",@{$mail->body}; # Log what we did, FWIW my $was_it_spam; if($status->is_spam) { $was_it_spam = 'identified spam'; } else { $was_it_spam = 'clean message'; } my $msg_score = int($status->get_hits); my $msg_threshold = int($status->get_required_hits); #$current_user ||= '(unknown)'; $self->log(2, "$was_it_spam ($msg_score/$msg_threshold) in ". sprintf("%3d", time - $start) ." seconds."); $status->finish(); } else { $msg_resp = $message; $self->log(2, "Scanning skipped due to size (". length($message) .")"); } # my $message = [split(/\r?\n/, $client->{MSG})]; # my $auditor = Mail::Audit->new(data => $message); # my $assassin = $self->{assassind}->{assassin}; # my $status = $assassin->check($auditor); # my $score = $status->get_hits; # my $spam_color = 'red'; # foreach my $color (qw(green blue yellow orange)) { # if ($score <= $self->{assassind}->{$color}) { # $spam_color = $color; # last; # } # } # $auditor->put_header('X-Spam-Color', $spam_color); # my $is_spam =$status->is_spam? 'Yes' : 'No'; # $auditor->put_header('X-Spam-Status', # sprintf("%s, hits=%.2f required=%.2f tests=%s", # $is_spam, # $status->get_hits, # $status->get_required_hits, # $status->get_names_of_tests_hit)); # if ($spam_color ne 'green') { # foreach (split(/\n/, $status->get_report)) { # $auditor->put_header('X-Spam-Report', $_); # } # } # $status->finish; my $smtp = Net::SMTP->new($self->{assassind}->{relayhost}, Hello => $self->{assassind}->{heloname}); unless (defined $smtp) { $self->log(1, "Connection to SMTP server failed"); $self->dead_letter($client); return; } try { $smtp->mail($client->{FROM}); throw Error -text => sprintf("Relay failed; server said %s %s", $smtp->code, $smtp->message) unless $smtp->ok; foreach (@{$client->{TO}}) { $smtp->recipient($_); throw Error -text => sprintf("Relay failed; server said %s %s", $smtp->code, $smtp->message) unless $smtp->ok; } $smtp->data($msg_resp); # $smtp->data; throw Error -text => sprintf("Relay failed; server said %s %s", $smtp->code, $smtp->message) unless $smtp->ok; # $smtp->datasend($auditor->header); # $smtp->datasend("\n"); # foreach (@{$auditor->body}) { # $smtp->datasend($_ . "\r\n"); # } # $smtp->dataend; # throw Error -text => sprintf("Relay failed; server said %s %s", # $smtp->code, $smtp->message) unless $smtp->ok; $smtp->quit; throw Error -text => sprintf("Relay failed; server said %s %s", $smtp->code, $smtp->message) unless $smtp->ok; $self->log(4, "Message relayed successfully."); } catch Error with { my $e = shift; $self->dead_letter($client, $e->stringify); }; } sub process_request { my $self = shift; my $client = Net::SMTP::Server::Client->new($self->{server}->{client}); if ($client->process) { $self->log(4, "Received message"); $SIG{TERM} = sub { $self->dead_letter($client, "Process interrupted by SIGTERM"); }; $self->relay_message($client); $SIG{TERM} = sub { exit 0; }; } else { $self->log(1, "An error occurred while receiving message"); } $self->{assassind}->{instance} = 1 unless defined $self->{assassind}->{instance}; exit 0 if $self->{assassind}->{instance} > $self->{assassind}->{maxrequests}++; } my $relayhost = 'localhost'; my $host = 'localhost'; my $port = 2025; my $maxrequests = 20; my $dead_letters = '/var/tmp'; my $pidfile = '/var/run/assassind.pid'; my $user = 'mail'; my $group = 'mail'; my $tagall = 0; my $maxsize = 256; my $heloname = 'spamfilter.localdomain'; # my $auto_whitelist = 0; # my $stop_at_threshold = 0; my %options = (port => \$port, host => \$host, relayhost => \$relayhost, 'dead-letters' => \$dead_letters, pid => \$pidfile, user => \$user, group => \$group, maxrequests => \$maxrequests, tagall => \$tagall, maxsize => \$maxsize, heloname => \$heloname ); usage(1) unless GetOptions(\%options, 'port=i', 'host=s', 'relayhost=s', 'maxrequests=i', 'dead-letters=s', 'user=s', 'group=s', 'pid=s', 'tagall=i', 'maxsize=i', 'heloname=s', 'auto-whitelist', 'stop-at-threshold', 'debug', 'help'); usage(0) if $options{help}; my $assassin = Mail::SpamAssassin->new({ 'dont_copy_prefs' => 1, 'stop_at_threshold' => $options{'stop_at_threshold'} || 0, 'debug' => $options{'debug'} || 0 }); $options{'auto-whitelist'} and eval { require Mail::SpamAssassin::DBBasedAddrList; # create a factory for the persistent address list my $addrlistfactory = Mail::SpamAssassin::DBBasedAddrList->new(); $assassin->set_persistent_address_list_factory ($addrlistfactory); }; $assassin->compile_now(); $/ = "\n"; # argh, Razor resets this! Bad Razor! my $server = bless { server => {host => $host, port => [ $port ], log_file => 'Sys::Syslog', syslog_ident => 'spampd', syslog_facility => 'mail', background => 1, pid_file => $pidfile, user => $user, group => $group, }, assassind => {maxrequests => $maxrequests, relayhost => $relayhost, dead_letters => $dead_letters, tagall => $tagall, maxsize => $maxsize, assassin => $assassin, heloname => $heloname, }, }, 'Assassind'; $server->run; sub usage { print < [B<--port=n>] [B<--host=host>] [B<--relayhost=hostname[:port]>] [B<--user=username>] [B<--group=groupname>] [B<--maxrequests=n>] [B<--dead-letters=/path>] [B<--pid=filename>] [B<--tagall=n>] [B<--maxsize=n>] [B<--auto-whitelist>] [B<--stop-at-threshold>] [B<--debug>] [B<--heloname=hostname>] B B<--help> =head1 DESCRIPTION I is a relaying SMTP proxy that filters spam using SpamAssassin. The proxy is designed to be robust in the face of exceptional errors, and will (hopefully) never lose a message. I is meant to be used as a system-wide message processor, so the proxy does not make any changes to existing message contents or headers; instead choosing just to add three headers of its own, which end users can use to make decisions about filtering (or not filtering) their spam. The most important header that I adds is the B header. This header will have one of five values: I, I, I, I and I. Green messages are very unlikely to be spam, while red messages are almost guaranteed to be spam. You can use this header as the basis for your own message filtering rules, using any common message filtering system (procmail, sieve, etc.). I also adds a B filter. This header is the same as the header generated by the standard SpamAssassin message processor, and contains the message's SpamAssassin score and other information. Finally, I adds one or more B headers, which contain a plain-text report of the rules that SpamAssassin used to assign the message its score. I logs all aspects of its operation to syslog(8), using the mail syslog facility. =head1 OPERATION I is meant to operate as a mail relay that sits between the Internet and your internal mail system. The three most common configurations include =over 5 =item Running between firewall and internal mail server The firewall would be configured to forward all of its mail to the port that I listens on, and I would relay its messages to port 25 of your internal server. I could either run on its own host (and listen on any port) or it could run on the mail server (and listen on any port except port 25). This is I default mode of operation. =item Running on the firewall with an internal mail server I would accept messages on port 25 and forward them to the mail server that is also listening on port 25. Note that I does not do anything other than check for spam, so it is not suitable as an anti-relay system. If your current mail system is configured correctly for anti-relaying, it should continue to work correctly in this configuration, but you may want to verify this using one of the standard open-relay blackhole testing systems. =item Running on the mail server, which is not behind a firewall In this configuration I would listen on port 25, while your mail server would be configured to listen on some other port. =back OPTIONS =over 5 =item B<--port=n> Specifies what port I listens on. By default, it listens on port 2025. =item B<--relayhost=hostname[:port]> Specifies the hostname where I will relay all messages. Defaults to I. If the port is not provided, that defaults to 25. =item B<--user=username> =item B<--group=groupname> Specifies the user and group that the proxy will run as. Default is I/I. =item B<--maxrequests=n> I works by forking child servers to handle each message. The B parameter specifies how many requests will be handled before the child exits. Since a child never gives back memory, a large message can cause it to become quite bloated; the only way to reclaim the memory is for the child to exit. The default is 20. =item B<--dead-letters=/path> Specifies the directory where I will store any message that it fails to deliver. The default is F. You should periodically examine this directory to see if there are any messages that couldn't be delivered. B This path should not be on the same partition as your mail server's message spool, because if your mail server rejects a message because of a full disk, I will not be able to save the message, and it will be lost. =item B<--pid=filename> Specifies a filename where I will write its process ID so that it is easy to kill it later. The directory that will contain this file must be writable by the I user. The default is F. =item B<--green=n> =item B<--blue=n> =item B<--yellow=n> =item B<--orange=n> Specifies the spam score thresholds for each color. The defaults are 5, 6, 10 and 20. Anything over 20 will have a color of red. =back =head1 EXAMPLES =over 5 =item Running between firewall and internal mail server This is I's default configuration, where it listens on port 2025 on the same host as the mail server. assassind =item Running on the firewall with an internal mail server assassind --port=25 --relayhost=internal.serv.er =item Running on the mail server, which is not behind a firewall This scenario assumes that the real mail server is running on port 2025 of the same host. assassind --port=25 --relayhost=localhost:2025 =back =head1 AUTHOR Dave Carrigan, This program is Copyright © 2002, Dave Carrigan. All rights reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Perl. This program is distributed "as is", without warranty of any kind, either expressed or implied, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose. The entire risk as to the quality and performance of the program is with you. Should the program prove defective, you assume the cost of all necessary servicing, repair or correction. =head1 SEE ALSO perl(1), Spam::Assassin(3), http://www.rudedog.org/assassind/ =head1 BUGS Due to the nature of Perl's SMTP::Server module, a SMTP message is stored completely in memory. However, as soon as the module receives its entire message data from the SMTP client, it returns a 250, signifying to the client that the message has been delivered. However, this means that there is a period of time where the message is vulnerable to being lost if the I process is killed before it has relayed or saved the message. Caveat Emptor!