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