Merge branch 'master' of github.com:msimerson/qpsmtpd-dev

Conflicts:
	Makefile.PL
This commit is contained in:
Matt Simerson 2013-04-24 13:37:49 -07:00
commit 632b42ecee
24 changed files with 360 additions and 269 deletions

View File

@ -17,6 +17,7 @@
karma: sprinkled karma awards throughout other plugins
- limit poor karma hosts to 1 concurrent connection
- allow +3 conncurrent connections to hosts with good karma
- limit recipients to 1 for senders with negative karma
Sanitize spamd_sock path for perl taint mode - Markus Ullmann

View File

@ -36,11 +36,11 @@ sub MY::libscan {
}
sub MY::postamble {
qq[
qq[
testcover :
\t cover -delete && \\
HARNESS_PERL_SWITCHES=-MDevel::Cover \$(MAKE) test && \\
cover
HARNESS_PERL_SWITCHES=-MDevel::Cover \$(MAKE) test && \\
cover
]
}

1
STATUS
View File

@ -15,6 +15,7 @@ in Perl Best Practices is also fair game.
So far, the main changes between the release and dev branches have focused
on these goals:
- plugins use is_immune and is_naughty instead of a local methods
- plugins log a single entry summarizing their disposition
- plugin logs prefixed with keywords: pass, fail, skip, error
- plugins use 'reject' and 'reject_type' settings

4
config.sample/log2sql Normal file
View File

@ -0,0 +1,4 @@
# comments are allowed
dsn = DBI:mysql:database=qpsmtpd;host=db;port=3306
user = qplog
pass = can mysql have 6 spaces in a passphrase?

View File

@ -6,6 +6,10 @@
# plugins/http_config for details.
# http_config http://localhost/~smtpd/config/ http://www.example.com/smtp.pl?config=
# tls should load before count_unrecognized_commands
# to support legacy port 465, tls must load before connection plugins
#tls
# hosts_allow does not work with the tcpserver deployment model!
# perldoc plugins/hosts_allow for an alternative.
#
@ -23,8 +27,6 @@ ident/geoip
fcrdns
quit_fortune
# tls should load before count_unrecognized_commands
#tls
earlytalker
count_unrecognized_commands 4

View File

@ -127,6 +127,40 @@ plugins in plugins/logging, specifically the L<plugins/logging/warn> and
L<plugins/logging/adaptive> files for examples of how to write your own
logging plugins.
=head1 plugin authors
While plugins can log anything they like, a few logging conventions in use:
=over 4
=item * at LOGINFO, log a single entry summarizing their disposition
=item * log messages are prefixed with keywords: pass, fail, skip, error
=over 4
=item pass: tests were run and the message passed
=item fail: tests were run and the message failed
=item fail, tolerated: tests run, msg failed, reject disabled
=item skip: tests were not run
=item error: tried to run tests but failure(s) encountered
=item info: additional info, not to be used for plugin summary
=back
=item * when tests fail and reject is disabled, use the 'fail, tolerated' prefix
=back
When these conventions are adhered to, the logs/summarize tool outputs each
message as a single row, with a small x showing failed tests and a large X
for failed tests that caused message rejection.
=head1 Internal support for pluggable logging
Any code in the core can call C<$self->log()> and those log lines will be

View File

@ -224,7 +224,7 @@ sub get_reject {
my $reject = $self->{_args}{reject};
if (defined $reject && !$reject) {
$self->log(LOGINFO, "fail, reject disabled" . $log_mess);
$self->log(LOGINFO, "fail, tolerated" . $log_mess);
return DECLINED;
}
@ -303,6 +303,12 @@ sub is_immune {
$self->log(LOGINFO, "skip, whitelisted sender");
return 1;
}
return;
}
sub is_naughty {
my $self = shift;
if ($self->connection->notes('naughty')) {
# see plugins/naughty
@ -323,7 +329,7 @@ sub adjust_karma {
my $karma = $self->connection->notes('karma') || 0;
$karma += $value;
$self->log(LOGDEBUG, "karma adjust: $value ($karma)");
$self->log(LOGDEBUG, "karma $value ($karma)");
$self->connection->notes('karma', $karma);
return $value;
}

View File

@ -6,21 +6,19 @@ use warnings;
use Cwd;
use Data::Dumper;
use DBIx::Simple;
use IO::File;
use File::stat;
use Time::TAI64 qw/ tai2unix /;
$Data::Dumper::Sortkeys = 1;
my $dsn = 'DBI:mysql:database=qpsmtpd;host=db;port=3306';
my $user = 'qplog';
my $pass = 't0ps3cret';
my $logdir = get_log_dir();
my @logfiles = get_logfiles($logdir);
my (%plugins, %os, %message_ids);
my $has_cleanup;
my $db = get_db();
check_plugins_table();
foreach my $file (@logfiles) {
my ($fid, $offset) = check_logfile($file);
@ -208,6 +206,7 @@ sub parse_logfile {
#warn "type: $type\n";
if ($type eq 'plugin') {
next if $plugin eq 'naughty'; # housekeeping only
next if $plugin eq 'karma' && 'karma adjust' eq substr($message,0,12);
insert_plugin($msg_id, $plugin, $message);
}
elsif ($type eq 'queue') {
@ -529,12 +528,70 @@ sub get_score {
sub get_db {
my $db = DBIx::Simple->connect($dsn, $user, $pass)
my %dbv = get_config('log2sql');
$dbv{dsn} ||= 'DBI:mysql:database=qpsmtpd;host=db;port=3306';
$dbv{user} ||= 'qplog';
$dbv{pass} ||= 't0ps3cret';
print Dumper(\%dbv);
my $db = DBIx::Simple->connect($dbv{dsn}, $dbv{user}, $dbv{pass})
or die DBIx::Simple->error;
return $db;
}
sub get_config {
my $file = shift or die "missing file name\n";
my %values;
foreach my $line ( get_config_contents( $file ) ) {
next if $line =~ /^#/;
chomp $line;
my ($key,$val) = split /\s*=\s*/, $line, 2;
$values{$key} = $val;
};
return %values;
};
sub get_config_contents {
my $name = shift;
my @config_dirs = qw[ config ../config log plugins ];
foreach my $dir ( @config_dirs ) {
next if ! -f "$dir/$name";
my $fh = IO::File->new();
if ( ! $fh->open( "$dir/$name", '<' ) ) {
warn "unable to open config file $dir/$name\n";
next;
};
my @contents = <$fh>;
return @contents;
};
};
sub check_plugins_table {
my $rows = exec_query( 'SELECT COUNT(*) FROM plugin');
return if scalar @$rows != 0;
my @lines = get_config_contents('registry.txt');
foreach my $line ( @lines ) {
next if $line =~ /^\s*#/; # ignore comments
chomp $line;
next if ! $line;
my ($id, $name, $abb3, $abb5, $aliases) = split /\s+/, $line, 5;
my $q = "REPLACE INTO plugin (id,name,abb3,abb5) VALUES (??)";
print "query: $q, $id, $name, $abb3, $abb5\n";
exec_query($q, [$id, $name, $abb3, $abb5 ]);
next if ! $aliases;
foreach my $alias ( split /\s*,\s*/, $aliases ) {
next if ! $alias;
my $aq = "REPLACE INTO plugin_aliases (plugin_id,name) VALUES (??)";
print "aqury: $aq, $id, $alias\n";
exec_query($aq, [$id, $alias]);
};
};
};
sub exec_query {
my $query = shift;
my $params = shift;
@ -550,10 +607,11 @@ sub exec_query {
}
#warn "err: $err\n";
if ($query =~ /INSERT INTO/) {
my ($table) = $query =~ /INSERT INTO (\w+)\s/;
if ($query =~ /(?:REPLACE|INSERT) INTO/) {
my ($table) = $query =~ /(?:REPLACE|INSERT) INTO (\w+)\s/;
$db->query($query, @params);
die "$db->error\n$err" if $db->error ne 'DBI error: ';
warn "$db->error\n$err" if $db->error ne 'DBI error: ';
return if $query =~ /^REPLACE/;
my $id = $db->last_insert_id(undef, undef, $table, undef) or die $err;
return $id;
}

View File

@ -13,35 +13,34 @@
DROP TABLE IF EXISTS `log`;
CREATE TABLE `log` (
`id` int(11) unsigned NOT NULL auto_increment,
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`inode` int(11) unsigned NOT NULL,
`size` int(11) unsigned NOT NULL,
`name` varchar(30) NOT NULL default '',
`created` datetime default NULL,
PRIMARY KEY (`id`)
`name` varchar(30) NOT NULL DEFAULT '',
`created` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# Dump of table message
# ------------------------------------------------------------
DROP TABLE IF EXISTS `message`;
CREATE TABLE `message` (
`id` int(11) unsigned NOT NULL auto_increment,
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`file_id` int(10) unsigned NOT NULL,
`connect_start` datetime NOT NULL,
`ip` int(10) unsigned NOT NULL,
`qp_pid` int(10) unsigned NOT NULL,
`result` tinyint(3) NOT NULL default '0',
`distance` mediumint(8) unsigned default NULL,
`time` decimal(3,2) unsigned default NULL,
`os_id` tinyint(3) unsigned default NULL,
`hostname` varchar(128) default NULL,
`helo` varchar(128) default NULL,
`mail_from` varchar(128) default NULL,
`rcpt_to` varchar(128) default NULL,
PRIMARY KEY (`id`),
`result` tinyint(3) NOT NULL DEFAULT '0',
`distance` mediumint(8) unsigned DEFAULT NULL,
`time` decimal(3,2) unsigned DEFAULT NULL,
`os_id` tinyint(3) unsigned DEFAULT NULL,
`hostname` varchar(128) DEFAULT NULL,
`helo` varchar(128) DEFAULT NULL,
`mail_from` varchar(128) DEFAULT NULL,
`rcpt_to` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `file_id` (`file_id`),
CONSTRAINT `message_ibfk_1` FOREIGN KEY (`file_id`) REFERENCES `log` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
@ -54,12 +53,12 @@ CREATE TABLE `message` (
DROP TABLE IF EXISTS `message_plugin`;
CREATE TABLE `message_plugin` (
`id` int(11) unsigned NOT NULL auto_increment,
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`msg_id` int(11) unsigned NOT NULL,
`plugin_id` int(4) unsigned NOT NULL,
`result` tinyint(4) NOT NULL,
`string` varchar(128) default NULL,
PRIMARY KEY (`id`),
`string` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `msg_id` (`msg_id`),
KEY `plugin_id` (`plugin_id`),
CONSTRAINT `message_plugin_ibfk_1` FOREIGN KEY (`plugin_id`) REFERENCES `plugin` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
@ -67,16 +66,15 @@ CREATE TABLE `message_plugin` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# Dump of table os
# ------------------------------------------------------------
DROP TABLE IF EXISTS `os`;
CREATE TABLE `os` (
`id` tinyint(3) unsigned NOT NULL auto_increment,
`name` varchar(36) default NULL,
PRIMARY KEY (`id`)
`id` tinyint(3) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(36) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
LOCK TABLES `os` WRITE;
@ -114,81 +112,14 @@ UNLOCK TABLES;
DROP TABLE IF EXISTS `plugin`;
CREATE TABLE `plugin` (
`id` int(4) unsigned NOT NULL auto_increment,
`name` varchar(35) character set utf8 NOT NULL default '',
`abb3` char(3) character set utf8 default NULL,
`abb5` char(5) character set utf8 default NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `abb3` (`abb3`),
`id` int(4) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(35) CHARACTER SET utf8 NOT NULL DEFAULT '',
`abb3` char(3) CHARACTER SET utf8 DEFAULT NULL,
`abb5` char(5) CHARACTER SET utf8 DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `abb5` (`abb5`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
LOCK TABLES `plugin` WRITE;
/*!40000 ALTER TABLE `plugin` DISABLE KEYS */;
INSERT INTO `plugin` (`id`, `name`, `abb3`, `abb5`)
VALUES
(1,'hosts_allow','alw','allow'),
(2,'ident::geoip','geo','geoip'),
(3,'ident::p0f','p0f',' p0f'),
(5,'karma','krm','karma'),
(6,'dnsbl','dbl','dnsbl'),
(7,'relay','rly','relay'),
(9,'earlytalker','ear','early'),
(15,'helo','hlo','helo'),
(16,'tls','tls',' tls'),
(20,'dont_require_anglebrackets','rab','drabs'),
(21,'unrecognized_commands','cmd','uncmd'),
(22,'noop','nop','noop'),
(23,'random_error','rnd','rande'),
(24,'milter','mtr','mlter'),
(25,'content_log','log','colog'),
(30,'auth::vpopmail_sql','aut','vpsql'),
(31,'auth::vpopmaild','vpd','vpopd'),
(32,'auth::vpopmail','vpo','vpop'),
(33,'auth::checkpasswd','ckp','chkpw'),
(34,'auth::cvs_unix_local','cvs','cvsul'),
(35,'auth::flat_file','flt','aflat'),
(36,'auth::ldap_bind','ldp','aldap'),
(40,'badmailfrom','bmf','badmf'),
(41,'badmailfromto','bmt','bfrto'),
(42,'rhsbl','rbl','rhsbl'),
(44,'resolvable_fromhost','rfh','rsvfh'),
(45,'sender_permitted_from','spf',' spf'),
(50,'badrcptto','bto','badto'),
(51,'rcpt_map','rmp','rcmap'),
(52,'rcpt_regex','rcx','rcrex'),
(53,'qmail_deliverable','qmd',' qmd'),
(55,'rcpt_ok','rok','rcpok'),
(58,'bogus_bounce','bog','bogus'),
(59,'greylisting','gry','greyl'),
(60,'headers','hdr','headr'),
(61,'loop','lop','loop'),
(62,'uribl','uri','uribl'),
(63,'domainkeys','dk','dkey'),
(64,'dkim','dkm','dkim'),
(65,'spamassassin','spm','spama'),
(66,'dspam','dsp','dspam'),
(70,'virus::aveclient','vav','avirs'),
(71,'virus::bitdefender','vbd','bitdf'),
(72,'virus::clamav','cav','clamv'),
(73,'virus::clamdscan','cad','clamd'),
(74,'virus::hbedv','hbv','hbedv'),
(75,'virus::kavscanner','kav','kavsc'),
(76,'virus::klez_filter','klz','vklez'),
(77,'virus::sophie','sop','sophe'),
(78,'virus::uvscan','uvs','uvscn'),
(80,'queue::qmail-queue','qqm','queue'),
(81,'queue::maildir','qdr','qudir'),
(82,'queue::postfix-queue','qpf','qupfx'),
(83,'queue::smtp-forward','qfw','qufwd'),
(84,'queue::exim-bsmtp','qxm','qexim'),
(98,'quit_fortune','for','fortu'),
(99,'connection_time','tim','time');
/*!40000 ALTER TABLE `plugin` ENABLE KEYS */;
UNLOCK TABLES;
# Dump of table plugin_aliases
# ------------------------------------------------------------
@ -197,33 +128,10 @@ DROP TABLE IF EXISTS `plugin_aliases`;
CREATE TABLE `plugin_aliases` (
`plugin_id` int(11) unsigned NOT NULL,
`name` varchar(35) character set utf8 NOT NULL default '',
KEY `plugin_id` (`plugin_id`),
CONSTRAINT `plugin_id` FOREIGN KEY (`plugin_id`) REFERENCES `plugin` (`id`) ON UPDATE CASCADE
`name` varchar(35) CHARACTER SET utf8 NOT NULL DEFAULT '',
UNIQUE KEY `plugin_id` (`plugin_id`,`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
LOCK TABLES `plugin_aliases` WRITE;
/*!40000 ALTER TABLE `plugin_aliases` DISABLE KEYS */;
INSERT INTO `plugin_aliases` (`plugin_id`, `name`)
VALUES
(60,'check_basicheaders'),
(44,'require_resolvable_fromhost'),
(21,'count_unrecognized_commands'),
(9,'check_earlytalker'),
(40,'check_badmailfrom'),
(50,'check_badrcptto'),
(58,'check_bogus_bounce'),
(15,'check_spamhelo'),
(3,'ident::p0f_3a0'),
(80,'queue::qmail_2dqueue'),
(22,'noop_counter');
/*!40000 ALTER TABLE `plugin_aliases` ENABLE KEYS */;
UNLOCK TABLES;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;

View File

@ -6,9 +6,13 @@ use warnings;
use Cwd;
use Data::Dumper;
use File::Tail;
use Getopt::Std;
$Data::Dumper::Sortkeys = 1;
our $opt_l = 0;
getopts('l');
my (%plugins, %plugin_aliases, %seen_plugins, %pids);
my %hide_plugins = map { $_ => 1 } qw/ hostname /;
@ -32,7 +36,7 @@ my %formats = (
ip => "%-15.15s",
hostname => "%-20.20s",
distance => "%5.5s",
'ident::geoip' => "%-20.20s",
'ident::geoip' => $opt_l ? "%-20.20s" : "%-6.6s",
'ident::p0f' => "%-10.10s",
count_unrecognized_commands => "%-5.5s",
unrecognized_commands => "%-5.5s",
@ -269,18 +273,20 @@ sub print_auto_format {
next;
}
my $wide = $opt_l ? 20 : 8;
if (defined $pids{$pid}{helo_host} && $plugin =~ /helo/) {
$format .= " %-18.18s";
push @values, substr(delete $pids{$pid}{helo_host}, -18, 18);
$format .= " %-$wide.${wide}s";
push @values, substr(delete $pids{$pid}{helo_host}, -$wide, $wide);
push @headers, 'HELO';
}
elsif (defined $pids{$pid}{from} && $plugin =~ /from/) {
$format .= " %-20.20s";
push @values, substr(delete $pids{$pid}{from}, -20, 20);
$format .= " %-$wide.${wide}s";
push @values, substr(delete $pids{$pid}{from}, -$wide, $wide);
push @headers, 'MAIL FROM';
}
elsif (defined $pids{$pid}{to} && $plugin =~ /to|rcpt|recipient/) {
$format .= " %-20.20s";
$format .= " %-$wide.${wide}s";
push @values, delete $pids{$pid}{to};
push @headers, 'RCPT TO';
}
@ -299,7 +305,7 @@ sub print_auto_format {
$format .= "\n";
printf("\n$format", @headers) if (!$printed || $printed % 20 == 0);
printf($format, @values);
print Data::Dumper::Dumper($pids{$pid}) if keys %{$pids{$pid}};
#print Data::Dumper::Dumper($pids{$pid}) if keys %{$pids{$pid}};
$printed++;
}
@ -308,6 +314,7 @@ sub show_symbol {
return ' o' if $mess eq 'TLS setup returning';
return ' o' if $mess eq 'pass';
return ' -' if $mess eq 'skip';
return ' x' if 'fail, tolerated' eq substr($mess, 0, 15);
return ' X' if $mess eq 'fail';
return ' -' if $mess =~ /^skip[,:\s]/i;
return ' o' if $mess =~ /^pass[,:\s]/i;
@ -347,6 +354,8 @@ sub populate_plugins_from_registry {
open my $F, '<', $file;
while (defined(my $line = <$F>)) {
next if $line =~ /^#/; # discard comments
chomp $line;
next if ! $line;
my ($id, $name, $abb3, $abb5, $aliases) = split /\s+/, $line;
next if !defined $name;
$plugins{$name} = {id => $id, abb3 => $abb3, abb5 => $abb5};

View File

@ -136,11 +136,12 @@ sub auth_checkpassword {
my $status = $?;
if ($status != 0) {
$self->log(LOGNOTICE, "authentication failed ($status)");
$self->log(LOGNOTICE, "fail, auth failed: $status");
return (DECLINED);
}
$self->connection->notes('authuser', $user);
$self->log(LOGINFO, "pass, auth success with $method");
return (OK, "auth_checkpassword");
}

View File

@ -353,7 +353,7 @@ sub handle_sig_pass {
elsif ($prs->{neutral}) {
$self->add_header($mess);
$self->log(LOGINFO, "pass, valid signature, neutral policy");
$self->log(LOGINFO, $mess);
$self->log(LOGDEBUG, $mess);
return DECLINED;
}
elsif ($prs->{reject}) {
@ -364,7 +364,7 @@ sub handle_sig_pass {
"fail, valid sig, reject policy");
}
# this should never happen
# this should never happen,
$self->add_header($mess);
$self->log(LOGERROR, "pass, valid sig, no policy results");
$self->log(LOGINFO, $mess);
@ -449,14 +449,17 @@ sub get_keydir {
sub save_signatures_to_note {
my ($self, $dkim) = @_;
my %domains;
foreach my $sig ($dkim->signatures) {
next if $sig->result ne 'pass';
my $doms = $self->connection->notes('dkim_pass_domains') || [];
next if grep /$sig->domain/, @$doms; # already in the list
push @$doms, $sig->domain;
$self->connection->notes('dkim_pass_domains', $doms);
$self->log(LOGINFO, "info, added " . $sig->domain);
$domains{$sig->domain} = 1;
}
return if 0 == scalar keys %domains;
my $doms = $self->connection->notes('dkim_pass_domains') || [];
push @$doms, keys %domains;
$self->log(LOGDEBUG, "info, signed by: ". join(',', keys %domains) );
$self->connection->notes('dkim_pass_domains', $doms);
}
sub send_message_to_dkim {

View File

@ -6,6 +6,10 @@ Domain-based Message Authentication, Reporting and Conformance
=head1 SYNOPSIS
DMARC is an extremely reliable means to authenticate email.
=head1 DESCRIPTION
From the DMARC Draft: "DMARC operates as a policy layer atop DKIM and SPF. These technologies are the building blocks of DMARC as each is widely deployed, supported by mature tools, and is readily available to both senders and receivers. They are complementary, as each is resilient to many of the failure modes of the other."
DMARC provides a way to exchange authentication information and policies among mail servers.
@ -14,10 +18,10 @@ DMARC benefits domain owners by preventing others from impersonating them. A dom
DMARC benefits mail server operators by providing them with an extremely reliable (as opposed to DKIM or SPF, which both have reliability issues when used independently) means to block forged emails. Is that message really from PayPal, Chase, Gmail, or Facebook? Since those organizations, and many more, publish DMARC policies, operators have a definitive means to know.
=head1 HOW IT WORKS
=head1 HOWTO
=head2 Protect a domain with DMARC
See Section 10 of the draft: Domain Owner Actions
1. Deploy DKIM & SPF
@ -25,33 +29,47 @@ See Section 10 of the draft: Domain Owner Actions
3. Publish a "monitor" record, ask for data reports
4. Roll policies from monitor to reject
=head2 Publish a DMARC policy
=head3 Publish a DMARC policy
_dmarc IN TXT "v=DMARC1; p=reject; pct=100; rua=mailto:dmarc-feedback@example.com;"
v=DMARC1; (version)
p=none; (disposition policy : reject, quarantine, none (monitor))
sp=reject; (subdomain policy: default, same as p)
rua
adkim=s; (dkim alignment: s=strict, r=relaxed)
aspf=r; (spf alignment: s=strict, r=relaxed)
rua=mailto: dmarc-feedback\@$zone; (aggregate reports)
ruf=mailto: dmarc-feedback\@$zone.com; (forensic reports)
rua=mailto: dmarc-feedback@example.com; (aggregate reports)
ruf=mailto: dmarc-feedback@example.com; (forensic reports)
rf=afrf; (report format: afrf, iodef)
ri=8400; (report interval)
pct=50; (percent of messages to filter)
=head2 Validate messages with DMARC
=head1 DRAFT
1. install this plugin
2. install a public suffix list in config/public_suffix_list. See http://publicsuffix.org/list/
3. activate this plugin (add to config/plugins)
Be sure to run the DMARC plugin after the SPF & DKIM plugins. Configure the SPF and DKIM messages to not reject mail.
=head2 Parse dmarc feedback reports into a database
See http://www.taugh.com/rddmarc/
=head1 MORE INFORMATION
http://www.dmarc.org/draft-dmarc-base-00-02.txt
https://github.com/qpsmtpd-dev/qpsmtpd-dev/wiki/DMARC-FAQ
=head1 TODO
2. provide dmarc feedback to domains that request it
3. If a message has multiple 'From' recipients, reject it
4. Rejections with a 550 (perm) or 450 (temp)
=head1 IMPLEMENTATION
1. Primary identifier is RFC5322.From field (From: header)
@ -99,11 +117,10 @@ sub data_post_handler {
my $from_host = $self->get_from_host($transaction) or return DECLINED;
my $org_host = $self->get_organizational_domain($from_host);
if (!$self->exists_in_dns($from_host)) {
if (!$self->exists_in_dns($org_host)) {
$self->log(LOGINFO, "fail, $from_host not in DNS");
return $self->get_reject("RFC5322.From host does not exist");
}
# 6. Receivers should reject email if the domain appears to not exist
if (!$self->exists_in_dns($from_host) && !$self->exists_in_dns($org_host)) {
$self->log(LOGINFO, "fail, $from_host not in DNS");
return $self->get_reject("RFC5322.From host appears non-existent");
}
# 11.2. Determine Handling Policy
@ -129,6 +146,12 @@ sub data_post_handler {
# Domain Owner. See Section 6.2 for details.
return DECLINED if lc $policy->{p} eq 'none';
my $pct = $policy->{pct} || 100;
if ( $pct != 100 && int(rand(100)) >= $pct ) {
$self->log("fail, tolerated, policy, sampled out");
return DECLINED;
};
return $self->get_reject("failed DMARC policy");
}
@ -279,9 +302,16 @@ sub get_organizational_domain {
# $self->log( LOGINFO, "i: $i, $tld" );
#warn "i: $i - tld: $tld\n";
if (grep /$tld/, $self->qp->config('public_suffix_list')) {
if (grep /^$tld/, $self->qp->config('public_suffix_list')) {
$greatest = $i + 1;
next;
}
# check for wildcards (ex: *.uk should match co.uk)
$tld = join '.', '\*', reverse((@labels)[0 .. $i-1]);
if (grep /^$tld/, $self->qp->config('public_suffix_list')) {
$greatest = $i + 1;
};
}
return $from_host if $greatest == scalar @labels; # same
@ -295,37 +325,49 @@ sub get_organizational_domain {
sub exists_in_dns {
my ($self, $domain) = @_;
# the DMARC draft suggests rejecting messages whose From: domain does not
# exist in DNS. That's as far as it goes. So I went back to the ADSP (from
# where DMARC this originated, which in turn led me to the ietf-dkim email
# list where a handful of 'experts' failed to agree on The Right Way to
# perform this test. And thus no direction was given.
# As they point out:
# MX records aren't mandatory.
# A or AAAA records as fallback aren't reliable either.
# 6. Receivers should endeavour to reject or quarantine email if the
# RFC5322.From purports to be from a domain that appears to be
# either non-existent or incapable of receiving mail.
# I chose to query the name and match NS,MX,A,or AAAA records. Since it gets
# repeated for the for the Organizational Name, if it fails, there's no
# delegation from the TLD.
# I went back to the ADSP (from where DMARC this originated, which in turn
# led me to the ietf-dkim email list where a handful of 'experts' failed to
# agree on The Right Way to test domain validity. No direction was given.
# They point out:
# MX records aren't mandatory.
# A or AAAA records as fallback aren't reliable.
# I chose to query the From: domain name and match NS,MX,A,or AAAA records.
# Since this search gets repeated for the Organizational Name, if it
# fails for the O.N., there's no delegation from the TLD.
my $res = $self->init_resolver(8);
my $query = $res->query($domain, 'NS') or do {
return 1 if $self->host_has_rr('NS', $res, $domain);
return 1 if $self->host_has_rr('MX', $res, $domain);
return 1 if $self->host_has_rr('A', $res, $domain);
return 1 if $self->host_has_rr('AAAA', $res, $domain);
}
sub host_has_rr {
my ($self, $type, $res, $domain) = @_;
my $query = $res->query($domain, $type) or do {
if ($res->errorstring eq 'NXDOMAIN') {
$self->log(LOGDEBUG, "fail, non-existent domain: $domain");
return;
}
return if $res->errorstring eq 'NOERROR';
$self->log(LOGINFO, "error, looking up $domain: " . $res->errorstring);
return;
};
my $matches = 0;
for my $rr ($query->answer) {
next if $rr->type !~ /(?:NS|MX|A|AAAA)/;
next if $rr->type ne $type;
$matches++;
}
if (0 == $matches) {
$self->log(LOGDEBUG, "fail, no records for $domain");
$self->log(LOGDEBUG, "no $type records for $domain");
}
return $matches;
}
};
sub fetch_dmarc_record {
my ($self, $zone) = @_;

View File

@ -246,7 +246,7 @@ sub helo_handler {
my ($self, $transaction, $host) = @_;
if (!$host) {
$self->log(LOGINFO, "fail, no helo host");
$self->log(LOGINFO, "fail, tolerated, no helo host");
return DECLINED;
}

View File

@ -244,9 +244,10 @@ sub register {
#$self->prune_db(); # keep the DB compact
$self->register_hook('connect', 'connect_handler');
$self->register_hook('rcpt_pre', 'rcpt_handler');
$self->register_hook('data', 'data_handler');
$self->register_hook('data_post', 'data_handler');
$self->register_hook('disconnect', 'disconnect_handler');
$self->register_hook('received_line', 'rcpt_handler');
}
sub hook_pre_connection {
@ -256,8 +257,6 @@ sub hook_pre_connection {
my $remote_ip = $args{remote_ip};
#my $max_conn = $args{max_conn_ip};
my $db = $self->get_db_location();
my $lock = $self->get_db_lock($db) or return DECLINED;
my $tied = $self->get_db_tie($db, $lock) or return DECLINED;
@ -323,23 +322,43 @@ sub connect_handler {
}
sub rcpt_handler {
my ($self, $transaction, $recipient, %args) = @_;
my ($self, $transaction, $addr) = @_;
my $recipients = scalar $self->transaction->recipients;
return DECLINED if $recipients < 2; # only one recipient
return DECLINED if $self->is_immune();
my $karma = $self->connection->notes('karma_history');
return DECLINED if $karma > 0; # good karma, no limit
my $recipients = scalar $self->transaction->recipients or do {
$self->log(LOGDEBUG, "info, no recipient count");
return DECLINED;
};
my $history = $self->connection->notes('karma_history');
if ( $history > 0 ) {
$self->log(LOGDEBUG, "info, good history");
return DECLINED;
};
my $karma = $self->connection->notes('karma');
if ( $karma > 0 ) {
$self->log(LOGDEBUG, "info, good connection");
return DECLINED;
};
# limit # of recipients if host has negative or unknown karma
return $self->get_reject("too many recipients");
return (DENY, "too many recipients for karma $karma (h: $history)");
}
sub data_handler {
my ($self, $transaction) = @_;
return DECLINED if !$self->qp->connection->relay_client;
$self->adjust_karma(5); # big karma boost for authenticated user/IP
return DECLINED if $self->is_immune();
return DECLINED if $self->is_naughty(); # let naughty do it
# cutting off a naughty sender at DATA prevents having to receive the message
my $karma = $self->connection->notes('karma');
if ( $karma < -3 ) { # bad karma
return $self->get_reject("very bad karma: $karma");
};
return DECLINED;
}

View File

@ -201,8 +201,10 @@ sub rcpt_handler {
$self->log(LOGINFO, "pass, bouncesaying with program"), $k++ if $rv == 0x13;
if ($rv == 0x14) {
my $s = $transaction->sender->address;
return (DENY, "mailing lists do not accept null senders")
if (!$s || $s eq '<>');
if (!$s || $s eq '<>') {
$self->adjust_karma(-1);
return (DENY, "mailing lists do not accept null senders");
};
$self->log(LOGINFO, "pass, ezmlm list");
$k++;
}

View File

@ -6,18 +6,18 @@ rcpt_ok
=head1 SYNOPSIS
this plugin checks the standard rcpthosts config
Validate that we accept mail for a recipient using a qmail rcpthosts file
=head1 DESCRIPTION
Check the recipient hostname and determine if we accept mail to that host.
Check the envelope recipient hostname and determine if we accept mail to that host.
This is functionally identical to qmail's rcpthosts implementation, consulting
both rcpthosts and morercpthosts.cdb.
=head1 CONFIGURATION
It should be configured to be run _LAST_!
It should be configured as the _LAST_ recipient plugin!
=cut
@ -30,6 +30,8 @@ use Qpsmtpd::DSN;
sub hook_rcpt {
my ($self, $transaction, $recipient, %param) = @_;
return (OK) if $self->is_immune(); # relay_client or whitelist
# Allow 'no @' addresses for 'postmaster' and 'abuse'
# qmail-smtpd will do this for all users without a domain, but we'll
# be a bit more picky. Maybe that's a bad idea.
@ -37,7 +39,6 @@ sub hook_rcpt {
return (OK) if $self->is_in_rcpthosts($host);
return (OK) if $self->is_in_morercpthosts($host);
return (OK) if $self->qp->connection->relay_client; # failsafe
# default of relaying_denied is obviously DENY,
# we use the default "Relaying denied" message...

View File

@ -2,12 +2,15 @@
# additional plugins running, add them here.
# Fields are whitespace delimited. Columns are ordered by numeric plugin ID.
#
# the order of plugins in this file determines the order they appear in
# summary output
#
#id name abb3 abb5 aliases
#
201 hosts_allow alw allow
202 ident::geoip geo geoip
203 ident::p0f p0f p0f
204 ident::p0f_3a0 p0f p0f
203 ident::p0f p0f p0f ident::p0f_3a0,ident::p0f_3a1
205 karma krm karma
206 dnsbl dbl dnsbl
207 relay rly relay check_relay,check_norelay,relay_only
@ -26,13 +29,13 @@
#
# Authentication
#
400 auth::auth_vpopmail_sql aut vpsql
401 auth::auth_vpopmaild vpd vpopd
402 auth::auth_vpopmail vpo vpop
403 auth::auth_checkpasswd ckp chkpw
404 auth::auth_cvs_unix_local cvs cvsul
405 auth::auth_flat_file flt aflat
406 auth::auth_ldap_bind ldp aldap
400 auth::auth_vpopmail_sql avq avsql
401 auth::auth_vpopmaild avd vpopd
402 auth::auth_vpopmail avp vpop
403 auth::auth_checkpassword ack chkpw
404 auth::auth_cvs_unix_local acv cvsul
405 auth::auth_flat_file aff aflat
406 auth::auth_ldap_bind ald aldap
407 auth::authdeny dny adeny
#
# Sender / Envelope From
@ -80,11 +83,11 @@
#
# Queue Plugins
#
800 queue::qmail-queue qqm queue
800 queue::qmail-queue qqm queue queue::qmail_2dqueue
801 queue::maildir qdr qudir
802 queue::postfix-queue qpf qupfx
803 queue::smtp-forward qfw qufwd
804 queue::exim-bsmtp qxm qexim
802 queue::postfix-queue qpf qupfx queue::postfix_2dqueue
803 queue::smtp-forward qfw qufwd queue::smtp_2dqueue
804 queue::exim-bsmtp qxm qexim queue::exim_2dbsmtp
900 quit_fortune for fortu

View File

@ -116,7 +116,7 @@ sub hook_mail {
return Qpsmtpd::DSN->temp_resolver_failed($self->get_reject_type(),
'');
}
$self->log(LOGINFO, 'fail, missing result, reject disabled');
$self->log(LOGINFO, 'fail, tolerated, missing result');
return DECLINED;
};
@ -127,7 +127,7 @@ sub hook_mail {
if (!$self->{_args}{reject}) {
;
$self->log(LOGINFO, "fail, reject disabled, $result");
$self->log(LOGINFO, "fail, tolerated, $result");
return DECLINED;
}

View File

@ -96,28 +96,17 @@ sub mail_handler {
return (DECLINED, "SPF - null sender");
}
if ($self->qp->connection->relay_client) {
$self->log(LOGINFO, "skip, relay_client");
return (DECLINED, "SPF - relaying permitted");
}
if (!$self->{_args}{reject}) {
$self->log(LOGINFO, "skip, reject disabled");
return (DECLINED);
}
my $client_ip = $self->qp->connection->remote_ip;
my $from = $sender->user . '@' . lc($sender->host);
my $helo = $self->qp->connection->hello_host;
my $scope = $from ? 'mfrom' : 'helo';
my %req_params = (
versions => [1, 2], # optional
scope => $scope,
ip_address => $client_ip,
ip_address => $self->qp->connection->remote_ip,
);
if ($scope =~ /^mfrom|pra$/) {
$req_params{identity} = $from;
$req_params{identity} = $from;
$req_params{helo_identity} = $helo if $helo;
}
elsif ($scope eq 'helo') {
@ -144,44 +133,43 @@ sub mail_handler {
return (DECLINED, "SPF - no response");
}
if (!$reject) {
$self->log(LOGINFO, "fail, no reject policy ($code: $why)");
return (DECLINED, "SPF - $code: $why");
}
# SPF result codes: pass fail softfail neutral none error permerror temperror
return $self->handle_code_none($reject, $why) if $code eq 'none';
if ($code eq 'fail') {
$self->adjust_karma(-1);
return $self->handle_code_fail($reject, $why);
}
elsif ($code eq 'softfail') {
$self->adjust_karma(-1);
return $self->handle_code_softfail($reject, $why);
}
elsif ($code eq 'pass') {
if ($code eq 'pass') {
$self->adjust_karma(1);
$transaction->notes('spf_pass_host', lc $sender->host);
$self->log(LOGINFO, "pass, $code: $why");
return (DECLINED);
}
elsif ($code eq 'neutral') {
$self->log(LOGINFO, "fail, $code, $why");
return (DENY, "SPF - $code: $why") if $reject >= 5;
if (!$reject) {
$self->log(LOGINFO, "skip, tolerated ($code: $why)");
return (DECLINED, "SPF - $code: $why");
}
elsif ($code eq 'error') {
$self->log(LOGINFO, "fail, $code, $why");
# SPF result codes: pass fail softfail neutral none error permerror temperror
return $self->handle_code_none($reject, $why) if $code eq 'none';
return $self->handle_code_fail($reject, $why) if $code eq 'fail';
return $self->handle_code_softfail($reject, $why) if $code eq 'softfail';
if ($code eq 'neutral') {
if ($reject >= 5 ) {
$self->log(LOGINFO, "fail, $code, $why");
return (DENY, "SPF - $code: $why");
};
$self->log(LOGINFO, "fail, tolerated, $code, $why");
return (DECLINED);
}
if ($code =~ /(?:permerror|error)/ ) {
$self->log(LOGINFO, "fail, $code, $why") if $reject > 3;
return (DENY, "SPF - $code: $why") if $reject >= 6;
return (DENYSOFT, "SPF - $code: $why") if $reject > 3;
$self->log(LOGINFO, "fail, tolerated, $code, $why");
return (DECLINED);
}
elsif ($code eq 'permerror') {
$self->log(LOGINFO, "fail, $code, $why");
return (DENY, "SPF - $code: $why") if $reject >= 6;
return (DENYSOFT, "SPF - $code: $why") if $reject > 3;
}
elsif ($code eq 'temperror') {
if ($code eq 'temperror') {
$self->log(LOGINFO, "fail, $code, $why");
return (DENYSOFT, "SPF - $code: $why") if $reject >= 2;
$self->log(LOGINFO, "fail, tolerated, $code, $why");
return (DECLINED);
}
$self->log(LOGINFO, "SPF from $from was $code: $why");
@ -196,33 +184,37 @@ sub handle_code_none {
return (DENY, "SPF - none: $why");
}
$self->log(LOGINFO, "pass, none, $why");
$self->log(LOGINFO, "skip, tolerated, none, $why");
return DECLINED;
}
sub handle_code_fail {
my ($self, $reject, $why) = @_;
$self->adjust_karma(-1);
if ($reject >= 2) {
$self->log(LOGINFO, "fail, $why");
return (DENY, "SPF - forgery: $why") if $reject >= 3;
return (DENYSOFT, "SPF - fail: $why");
}
$self->log(LOGINFO, "pass, fail tolerated, $why");
$self->log(LOGINFO, "fail, tolerated, $why");
return DECLINED;
}
sub handle_code_softfail {
my ($self, $reject, $why) = @_;
$self->adjust_karma(-1);
if ($reject >= 3) {
$self->log(LOGINFO, "fail, soft, $why");
return (DENY, "SPF - fail: $why") if $reject >= 4;
return (DENYSOFT, "SPF - fail: $why") if $reject >= 3;
}
$self->log(LOGINFO, "pass, softfail tolerated, $why");
$self->log(LOGINFO, "fail, tolerated, soft, $why");
return DECLINED;
}

View File

@ -178,7 +178,7 @@ sub data_post_handler {
if ($transaction->data_size > 500_000) {
$self->log(LOGINFO,
"skip: too large (" . $transaction->data_size . ")");
"skip, too large (" . $transaction->data_size . ")");
return (DECLINED);
}
@ -424,7 +424,7 @@ sub reject {
if ($score < $reject) {
if ($ham_or_spam eq 'Spam') {
$self->log(LOGINFO, "fail, $status < $reject, $learn");
$self->log(LOGINFO, "fail, tolerated, $status < $reject, $learn");
return DECLINED;
}
else {

View File

@ -149,12 +149,16 @@ sub hook_connect {
my ($self, $transaction) = @_;
my $local_port = $self->qp->connection->local_port;
return DECLINED unless defined $local_port && $local_port == 465; # SMTPS
if ( ! defined $local_port || $local_port != 465 ) { # SMTPS
$self->log(LOGDEBUG, "skip, not SMTPS");
return DECLINED;
};
unless (_convert_to_ssl($self)) {
$self->log(LOGINFO, "fail, unable to establish SSL");
return (DENY_DISCONNECT, "Cannot establish SSL session");
}
$self->log(LOGWARN, "Connected via SMTPS");
$self->log(LOGINFO, "pass, connect via SMTPS");
return DECLINED;
}

4
run
View File

@ -2,8 +2,8 @@
#
# You might want/need to to edit these settings
QPUSER=smtpd
# limit qpsmtpd to 150MB memory, should be several times what is needed.
MAXRAM=150000000
# limit qpsmtpd to 300MB memory
MAXRAM=300000000
BIN=/usr/local/bin
PERL=/usr/bin/perl

View File

@ -12,7 +12,7 @@ my $test_email = 'matt@tnpi.net';
sub register_tests {
my $self = shift;
$self->register_test('test_get_organizational_domain', 2);
$self->register_test('test_get_organizational_domain', 3);
$self->register_test("test_fetch_dmarc_record", 3);
$self->register_test("test_discover_policy", 1);
}
@ -55,7 +55,8 @@ sub test_get_organizational_domain {
my $transaction = $self->qp->transaction;
cmp_ok( $self->get_organizational_domain('test.www.tnpi.net'), 'eq', 'tnpi.net' );
cmp_ok( $self->get_organizational_domain('www.example.co.uk'), 'eq', 'example.co.uk' )
cmp_ok( $self->get_organizational_domain('www.example.co.uk'), 'eq', 'example.co.uk' );
cmp_ok( $self->get_organizational_domain('plus.google.com'), 'eq', 'google.com' );
};
sub test_discover_policy {