File Coverage

blib/lib/Mail/MtPolicyd/Plugin/Greylist.pm
Criterion Covered Total %
statement 15 140 10.7
branch 0 36 0.0
condition 0 15 0.0
subroutine 5 25 20.0
pod 2 15 13.3
total 22 231 9.5


line stmt bran cond sub pod time code
1             package Mail::MtPolicyd::Plugin::Greylist;
2              
3 2     2   1894 use Moose;
  2         3  
  2         14  
4 2     2   9142 use namespace::autoclean;
  2         5  
  2         21  
5              
6             our $VERSION = '2.02'; # VERSION
7             # ABSTRACT: This plugin implements a greylisting mechanism with an auto whitelist.
8              
9             extends 'Mail::MtPolicyd::Plugin';
10             with 'Mail::MtPolicyd::Plugin::Role::Scoring';
11             with 'Mail::MtPolicyd::Plugin::Role::UserConfig' => {
12             'uc_attributes' => [ 'enabled' ],
13             };
14              
15 2     2   230 use Mail::MtPolicyd::Plugin::Result;
  2         3  
  2         35  
16 2     2   537 use Time::Piece;
  2         8211  
  2         14  
17 2     2   141 use Time::Seconds;
  2         4  
  2         2931  
18              
19              
20             has 'enabled' => ( is => 'rw', isa => 'Str', default => 'on' );
21              
22             has 'score' => ( is => 'rw', isa => 'Maybe[Num]' );
23             has 'mode' => ( is => 'rw', isa => 'Str', default => 'passive');
24              
25             has 'defer_message' => ( is => 'rw', isa => 'Str', default => 'defer greylisting is active');
26             has 'append_waittime' => ( is => 'rw', isa => 'Bool', default => 1 );
27              
28             has 'min_retry_wait' => ( is => 'rw', isa => 'Int', default => 60*5 );
29             has 'max_retry_wait' => ( is => 'rw', isa => 'Int', default => 60*60*2 );
30              
31             has 'use_autowl' => ( is => 'rw', isa => 'Bool', default => 1 );
32             has 'autowl_threshold' => ( is => 'rw', isa => 'Int', default => 3 );
33             has 'autowl_expire_days' => ( is => 'rw', isa => 'Int', default => 60 );
34              
35             has 'autowl_table' => ( is => 'rw', isa => 'Str', default => 'autowl' );
36              
37             has 'query_autowl' => ( is => 'rw', isa => 'Bool', default => 1 );
38             has 'create_ticket' => ( is => 'rw', isa => 'Bool', default => 1 );
39              
40             with 'Mail::MtPolicyd::Role::Connection' => {
41             name => 'db',
42             type => 'Sql',
43             };
44             with 'Mail::MtPolicyd::Role::Connection' => {
45             name => 'memcached',
46             type => 'Memcached',
47             };
48              
49             with 'Mail::MtPolicyd::Plugin::Role::SqlUtils';
50              
51             sub run {
52 0     0 1   my ( $self, $r ) = @_;
53 0           my $ip = $r->attr('client_address');
54 0           my $sender = $r->attr('sender');
55 0           my $recipient = $r->attr('recipient');
56 0           my @triplet = ($sender, $ip, $recipient);
57 0           my $session = $r->session;
58              
59 0           my $enabled = $self->get_uc( $session, 'enabled' );
60 0 0         if( $enabled eq 'off' ) {
61 0           return;
62             }
63              
64 0 0 0       if( $self->use_autowl && $self->query_autowl ) {
65             my ( $is_autowl ) = $r->do_cached('greylist-is_autowl', sub {
66 0     0     $self->is_autowl( $r, @triplet );
67 0           } );
68 0 0         if( $is_autowl ) {
69 0           $self->log($r, 'client on greylist autowl');
70 0           return $self->success( $r );
71             }
72             }
73              
74 0     0     my ( $ticket ) = $r->do_cached('greylist-ticket', sub { $self->get_ticket($r, @triplet) } );
  0            
75 0 0         if( defined $ticket ) {
76 0 0         if( $self->is_valid_ticket( $ticket ) ) {
77 0           $self->log($r, join(',', @triplet).' has a valid greylisting ticket');
78 0 0 0       if( $self->use_autowl && ! $r->is_already_done('greylist-autowl-add') ) {
79 0           $self->add_autowl( $r, @triplet );
80             }
81 0           $self->remove_ticket( $r, @triplet );
82 0           return $self->success( $r );
83             }
84 0           $self->log($r, join(',', @triplet).' has a invalid greylisting ticket. wait again');
85 0           return( $self->defer( $ticket ) );
86             }
87              
88 0 0         if( $self->create_ticket ) {
89 0           $self->log($r, 'creating new greylisting ticket');
90 0           $self->do_create_ticket($r, @triplet);
91 0           return( $self->defer );
92             }
93 0           return;
94             }
95              
96             sub defer {
97 0     0 1   my ( $self, $ticket ) = @_;
98 0           my $message = $self->defer_message;
99 0 0 0       if( defined $ticket && $self->append_waittime ) {
100 0           $message .= ' ('.( $ticket - time ).'s left)'
101             }
102 0           return( Mail::MtPolicyd::Plugin::Result->new(
103             action => $message,
104             abort => 1,
105             ) );
106             }
107              
108             sub success {
109 0     0 0   my ( $self, $r ) = @_;
110 0 0 0       if( defined $self->score && ! $r->is_already_done('greylist-score') ) {
111 0           $self->add_score($r, $self->name => $self->score);
112             }
113 0 0 0       if( $self->mode eq 'accept' || $self->mode eq 'dunno' ) {
114 0           return( Mail::MtPolicyd::Plugin::Result->new(
115             action => $self->mode,
116             abort => 1,
117             ) );
118             }
119 0           return;
120             }
121              
122             sub _extract_sender_domain {
123 0     0     my ( $self, $sender ) = @_;
124 0           my $sender_domain;
125              
126 0 0         if( $sender =~ /@/ ) {
127 0           ( $sender_domain ) = $sender =~ /@([^@]+)$/;
128             } else { # fallback to just the sender?
129 0           $sender_domain = $sender;
130             }
131              
132 0           return($sender_domain);
133             }
134              
135             sub is_autowl {
136 0     0 0   my ( $self, $r, $sender, $client_ip ) = @_;
137 0           my $sender_domain = $self->_extract_sender_domain( $sender );
138              
139             my ( $row ) = $r->do_cached('greylist-autowl-row', sub {
140 0     0     $self->get_autowl_row( $sender_domain, $client_ip );
141 0           } );
142              
143 0 0         if( ! defined $row ) {
144 0           $self->log($r, 'client is not on autowl');
145 0           return(0);
146             }
147              
148 0           my $last_seen = $row->{'last_seen'};
149 0           my $expires = $last_seen + ( ONE_DAY * $self->autowl_expire_days );
150 0           my $now = Time::Piece->new->epoch;
151 0 0         if( $now > $expires ) {
152 0           $self->log($r, 'removing expired autowl row');
153 0           $self->remove_autowl_row( $sender_domain, $client_ip );
154 0           return(0);
155             }
156              
157 0 0         if( $row->{'count'} < $self->autowl_threshold ) {
158 0           $self->log($r, 'client has not yet reached autowl_threshold');
159 0           return(0);
160             }
161              
162 0           $self->log($r, 'client has valid autowl row. updating row');
163 0           $self->incr_autowl_row( $sender_domain, $client_ip );
164 0           return(1);
165             }
166              
167             sub add_autowl {
168 0     0 0   my ( $self, $r, $sender, $client_ip ) = @_;
169 0           my $sender_domain = $self->_extract_sender_domain( $sender );
170              
171             my ( $row ) = $r->do_cached('greylist-autowl-row', sub {
172 0     0     $self->get_autowl_row( $sender_domain, $client_ip );
173 0           } );
174              
175 0 0         if( defined $row ) {
176 0           $self->log($r, 'client already on autowl, just incrementing count');
177 0           $self->incr_autowl_row( $sender_domain, $client_ip );
178 0           return;
179             }
180              
181 0           $self->log($r, 'creating initial autowl entry');
182 0           $self->create_autowl_row( $sender_domain, $client_ip );
183 0           return;
184             }
185              
186             sub get_autowl_row {
187 0     0 0   my ( $self, $sender_domain, $client_ip ) = @_;
188 0           my $sql = sprintf("SELECT * FROM %s WHERE sender_domain=? AND client_ip=?",
189             $self->autowl_table );
190 0           return $self->execute_sql($sql, $sender_domain, $client_ip)->fetchrow_hashref;
191             }
192              
193             sub create_autowl_row {
194 0     0 0   my ( $self, $sender_domain, $client_ip ) = @_;
195 0           my $timestamp =
196             my $sql = sprintf("INSERT INTO %s VALUES(NULL, ?, ?, 1, %d)",
197             $self->autowl_table, Time::Piece->new->epoch );
198 0           $self->execute_sql($sql, $sender_domain, $client_ip);
199 0           return;
200             }
201              
202             sub incr_autowl_row {
203 0     0 0   my ( $self, $sender_domain, $client_ip ) = @_;
204 0           my $sql = sprintf(
205             "UPDATE %s SET count=count+1, last_seen=%d WHERE sender_domain=? AND client_ip=?",
206             $self->autowl_table,
207             Time::Piece->new->epoch );
208 0           $self->execute_sql($sql, $sender_domain, $client_ip);
209 0           return;
210             }
211              
212             sub remove_autowl_row {
213 0     0 0   my ( $self, $sender_domain, $client_ip ) = @_;
214 0           my $sql = sprintf("DELETE FROM %s WHERE sender_domain=? AND client_ip=?",
215             $self->autowl_table );
216 0           $self->execute_sql($sql, $sender_domain, $client_ip);
217 0           return;
218             }
219              
220             sub expire_autowl_rows {
221 0     0 0   my ( $self ) = @_;
222 0           my $timeout = ONE_DAY * $self->autowl_expire_days;
223 0           my $now = Time::Piece->new->epoch;
224 0           my $sql = sprintf("DELETE FROM %s WHERE ? > last_seen + ?",
225             $self->autowl_table );
226 0           $self->execute_sql($sql, $now, $timeout);
227 0           return;
228             }
229              
230             sub get_ticket {
231 0     0 0   my ( $self, $r, $sender, $ip, $rcpt ) = @_;
232 0           my $key = join(",", $sender, $ip, $rcpt );
233 0 0         if( my $ticket = $self->_memcached_handle->get( $key ) ) {
234 0           return( $ticket );
235             }
236 0           return;
237             }
238              
239             sub is_valid_ticket {
240 0     0 0   my ( $self, $ticket ) = @_;
241 0 0         if( time > $ticket ) {
242 0           return 1;
243             }
244 0           return 0;
245             }
246              
247             sub remove_ticket {
248 0     0 0   my ( $self, $r, $sender, $ip, $rcpt ) = @_;
249 0           my $key = join(",", $sender, $ip, $rcpt );
250 0           $self->_memcached_handle->delete( $key );
251 0           return;
252             }
253              
254             sub do_create_ticket {
255 0     0 0   my ( $self, $r, $sender, $ip, $rcpt ) = @_;
256 0           my $ticket = time + $self->min_retry_wait;
257 0           my $key = join(",", $sender, $ip, $rcpt );
258 0           $self->_memcached_handle->set( $key, $ticket, $self->max_retry_wait );
259 0           return;
260             }
261              
262             sub init {
263             my $self = shift;
264             if( $self->use_autowl ) {
265             $self->check_sql_tables( %{$self->_table_definitions} );
266             }
267             }
268              
269             has '_table_definitions' => ( is => 'ro', isa => 'HashRef', lazy => 1,
270             default => sub { {
271             'autowl' => {
272             'mysql' => 'CREATE TABLE %TABLE_NAME% (
273             `id` int(11) NOT NULL AUTO_INCREMENT,
274             `sender_domain` VARCHAR(255) NOT NULL,
275             `client_ip` VARCHAR(39) NOT NULL,
276             `count` INT UNSIGNED NOT NULL,
277             `last_seen` INT UNSIGNED NOT NULL,
278             PRIMARY KEY (`id`),
279             UNIQUE KEY `domain_ip` (`client_ip`, `sender_domain`),
280             KEY(`client_ip`),
281             KEY(`sender_domain`)
282             ) ENGINE=MyISAM DEFAULT CHARSET=latin1',
283             'SQLite' => 'CREATE TABLE %TABLE_NAME% (
284             `id` INTEGER PRIMARY KEY AUTOINCREMENT,
285             `sender_domain` VARCHAR(255) NOT NULL,
286             `client_ip` VARCHAR(39) NOT NULL,
287             `count` INT UNSIGNED NOT NULL,
288             `last_seen` INTEGER NOT NULL
289             )',
290             },
291             } },
292             );
293              
294             sub cron {
295 0     0 0   my $self = shift;
296 0           my $server = shift;
297              
298 0 0         if( grep { $_ eq 'hourly' } @_ ) {
  0            
299 0           $server->log(3, 'expiring greylist autowl...');
300 0           $self->expire_autowl_rows;
301             }
302              
303 0           return;
304             }
305              
306             __PACKAGE__->meta->make_immutable;
307              
308             1;
309              
310             __END__
311              
312             =pod
313              
314             =encoding UTF-8
315              
316             =head1 NAME
317              
318             Mail::MtPolicyd::Plugin::Greylist - This plugin implements a greylisting mechanism with an auto whitelist.
319              
320             =head1 VERSION
321              
322             version 2.02
323              
324             =head1 DESCRIPTION
325              
326             This plugin implements a greylisting mechanism with an auto whitelist.
327              
328             If a client connects it will return an defer and create a greylisting "ticket"
329             for the combination of the address of the sender, the senders address and the
330             recipient address. The ticket will be stored in memcached and will contain the time
331             when the client was seen for the first time. The ticket will expire after
332             the max_retry_wait timeout.
333              
334             The client will be defered until the min_retry_wait timeout has been reached.
335             Only in the time between the min_retry_wait and max_retry_wait the request will
336             pass the greylisting test.
337              
338             When the auto-whitelist is enabled (default) a record for every client which
339             passes the greylisting test will be stored in the autowl_table.
340             The table is based on the combination of the sender domain and client_address.
341             If a client passed the test at least autowl_threshold (default 3) times the greylisting
342             test will be skipped.
343             Additional an last_seen timestamp is stored in the record and records which are older
344             then the autowl_expire_days will expire.
345              
346             Please note the greylisting is done on a triplet based on the
347              
348             client_address + sender + recipient
349              
350             The auto-white list is based on the
351              
352             client_address + sender_domain
353              
354             =head1 PARAMETERS
355              
356             =over
357              
358             =item (uc_)enabled (default: on)
359              
360             Enable/disable this check.
361              
362             =item score (default: empty)
363              
364             Apply an score to this message if it _passed_ the greylisting test. In most cases you want to assign a negative score. (eg. -10)
365              
366             =item mode (default: passive)
367              
368             The default is to return no action if the client passed the greylisting test and continue.
369              
370             You can set this 'accept' or 'dunno' if you want skip further checks.
371              
372             =item defer_message (default: defer greylisting is active)
373              
374             This action is returned to the MTA if a message is defered.
375              
376             If a client retries too fast the time left till min_retry_wait is reach will be appended to the string.
377              
378             =item min_retry_wait (default: 300 (5m))
379              
380             A client will have to wait at least for this timeout. (in seconds)
381              
382             =item max_retry_wait (default: 7200 (2h))
383              
384             A client must retry to deliver the message before this timeout. (in seconds)
385              
386             =item use_autowl (default: 1)
387              
388             Could be used to disable the use of the auto-whitelist.
389              
390             =item autowl_threshold (default: 3)
391              
392             How often a client/sender_domain pair must pass the check before it is whitelisted.
393              
394             =item autowl_expire_days (default: 60)
395              
396             After how many days an auto-whitelist entry will expire if no client with this client/sender pair is seen.
397              
398             =item autowl_table (default: autowl)
399              
400             The name of the table to use.
401              
402             The database handle specified in the global configuration will be used. (see man mtpolicyd)
403              
404             =item query_autowl, create_ticket (default: 1)
405              
406             This options could be used to disable the creation of a new ticket or to query the autowl.
407              
408             This can be used to catch early retries at the begin of your configuration before more expensive checks a processes.
409              
410             Example:
411              
412             <Plugin greylist>
413             module = "Greylist"
414             score = -5
415             mode = "passive"
416             create_ticket = 0
417             query_autowl = 0
418             </Plugin>
419             # ... a lot of RBL checks, etc...
420             <Plugin ScoreGreylist>
421             module = "ScoreAction"
422             threshold = 5
423             <Plugin greylist>
424             module = "Greylist"
425             score = -5
426             mode = "passive"
427             </Plugin>
428             </Plugin>
429              
430             This will prevent early retries from running thru all checks.
431              
432             =back
433              
434             =head1 AUTHOR
435              
436             Markus Benning <ich@markusbenning.de>
437              
438             =head1 COPYRIGHT AND LICENSE
439              
440             This software is Copyright (c) 2014 by Markus Benning <ich@markusbenning.de>.
441              
442             This is free software, licensed under:
443              
444             The GNU General Public License, Version 2, June 1991
445              
446             =cut