File Coverage

blib/lib/OpenSMTPd/Filter.pm
Criterion Covered Total %
statement 194 194 100.0
branch 68 74 91.8
condition 48 53 90.5
subroutine 22 22 100.0
pod 2 2 100.0
total 334 345 96.8


line stmt bran cond sub pod time code
1             package OpenSMTPd::Filter;
2 4     4   933823 use utf8; # so literals and identifiers can be in UTF-8
  4         37  
  4         21  
3 4     4   158 use v5.16; # or later to get "unicode_strings" feature and "charnames"
  4         15  
4 4     4   20 use strict; # quote strings, declare variables
  4         7  
  4         79  
5 4     4   19 use warnings; # on by default
  4         6  
  4         113  
6 4     4   19 use warnings qw(FATAL utf8); # fatalize encoding glitches
  4         8  
  4         228  
7 4     4   2029 use open qw(:std :encoding(UTF-8)); # undeclared streams in UTF-8
  4         4872  
  4         25  
8              
9             # This happens automatically, but to make pledge(2) happy
10             # it has to happen earlier than it would otherwise.
11 4     4   8091 use IO::File;
  4         35528  
  4         466  
12              
13 4     4   38 use Carp;
  4         10  
  4         223  
14 4     4   2615 use Time::HiRes qw< time >;
  4         5771  
  4         19  
15              
16             # ABSTRACT: Easier filters for OpenSMTPd in perl
17             our $VERSION = 'v0.0.2'; # VERSION
18              
19             my @report_fields = qw< version timestamp subsystem event session suffix >;
20             my %report_events = (
21             'smtp-in' => {
22             'link-connect' => [qw< rdns fcrdns src dest >],
23             'link-greeting' => [qw< hostname >],
24             'link-identify' => [qw< method identity >],
25             'link-tls' => [qw< tls-string >],
26             'link-disconnect' => [qw< >],
27             'link-auth' => [qw< username result >],
28             'tx-reset' => [qw< message-id >],
29             'tx-begin' => [qw< message-id >],
30             'tx-mail' => [qw< message-id result address >],
31             'tx-rcpt' => [qw< message-id result address>],
32             'tx-envelope' => [qw< message-id envelope-id >],
33             'tx-data' => [qw< message-id result >],
34             'tx-commit' => [qw< message-id message-size >],
35             'tx-rollback' => [qw< message-id >],
36             'protocol-client' => [qw< command >],
37             'protocol-server' => [qw< response >],
38             'filter-report' => [qw< filter-kind name message >],
39             'filter-response' => [qw< phase response param>],
40             'timeout' => [qw< >],
41             },
42             );
43              
44             my @filter_fields
45             = qw< version timestamp subsystem phase session opaque-token suffix >;
46             my %filter_events = (
47             'smtp-in' => {
48             'connect' => [qw< rdns fcrdns src dest >],
49             'helo' => [qw< identity >],
50             'ehlo' => [qw< identity >],
51             'starttls' => [qw< tls-string >],
52             'auth' => [qw< auth >],
53             'mail-from' => [qw< address >],
54             'rcpt-to' => [qw< address >],
55             'data' => [qw< >],
56             'data-line' => [qw< line >],
57             'commit' => [qw< >],
58              
59             'data-lines' => sub {'data-line'}, # special case
60             },
61             );
62              
63             my @filter_result_fields = qw< session opaque-token >;
64             my %filter_result_decisions = (
65              
66             #'dataline' => [qw< line >], # special case
67             'proceed' => [qw< >],
68             'junk' => [qw< >],
69             'reject' => [qw< error >],
70             'disconnect' => [qw< error >],
71             'rewrite' => [qw< parameter >],
72             'report' => [qw< parameter >],
73             );
74              
75             sub new {
76 19     19 1 83786 my ( $class, %params ) = @_;
77              
78 19   100     114 $params{on} ||= {};
79 19   100     135 $params{input} ||= \*STDIN;
80 19   100     89 $params{output} ||= \*STDOUT;
81              
82 19         153 STDERR->autoflush;
83 19         897 $params{output}->autoflush;
84              
85             # We expect to read and write bytes from the remote
86 19         565 $_->binmode(':raw') for @params{qw< input output >};
87              
88 19         453 my $check_supported_events;
89             $check_supported_events = sub {
90 49     49   135 my ( $c, $e, $ms ) = @_;
91 49   100     63 my $m = shift @{$ms} || return;
92              
93 36         68 my @s = sort keys %{$c};
  36         123  
94 36 100       80 if ( my @u = grep { !$e->{$_} } @s ) {
  35         105  
95 3 100       10 my $s = @u == 1 ? '' : 's';
96 3         356 croak("Unsupported $m$s @u");
97             }
98              
99 33         120 $check_supported_events->( $c->{$_}, $e->{$_}, $ms ) for @s;
100 19         113 };
101              
102             $check_supported_events->(
103             $params{on},
104 19         136 { report => \%report_events, filter => \%filter_events },
105             [ "event type", "event subsystem", "event" ]
106             );
107              
108             # Only save data-lines if we're using the helper to process them
109             $params{_save_data_lines}
110             = $params{on}
111             && $params{on}{filter}
112             && $params{on}{filter}{'smtp-in'}
113 16   66     149 && $params{on}{filter}{'smtp-in'}{'data-lines'};
114              
115 16         43 my $self = bless \%params, $class;
116 16         57 return $self->_init;
117             }
118              
119             sub _init {
120 17     17   1545 my ($self) = @_;
121              
122 17         50 my $fh = $self->{input};
123 17   50     192 my $blocking = $fh->blocking // die "Unable to get blocking on input: $!";
124 17   50     179 $fh->blocking(0) // die "Unable to set input to non-blocking: $!";
125              
126 17         46 my $timeout = 0.25; # no idea how long we should actually wait
127 17         63 my $now = time;
128              
129 17         27 my %config;
130 17   100     109 while ( not $self->{_ready} and ( time - $now ) < $timeout ) {
131 63443   100     2750105 my $line = $fh->getline // next;
132 8 50       266 STDERR->print("< $line") if $self->{debug};
133 8         18 chomp $line;
134 8         31 $self->_dispatch($line);
135 8         37 $now = time; # continue waiting, we got a line
136             }
137              
138 17   50     575 $fh->blocking($blocking) // die "Unable to reset blocking on input: $!";
139              
140 17         171 return $self;
141             }
142              
143             sub ready {
144 5     5 1 58 my ($self) = @_;
145 5 100       218 croak("Input stream is not ready") unless $self->{_ready};
146              
147 76         143 my @reports = map {"report|smtp-in|$_"}
148 4         9 sort keys %{ $report_events{'smtp-in'} };
  4         53  
149              
150 4         13 my @filters;
151 4         8 for my $subsystem ( sort keys %{ $self->{on}->{filter} } ) {
  4         19  
152 1         2 for ( sort keys %{ $self->{on}->{filter}->{$subsystem} } ) {
  1         4  
153 1         3 my $v = $filter_events{$subsystem}{$_};
154 1 50       5 my $phase = ref $v eq 'CODE' ? $v->($_) : $_;
155 1         4 push @filters, "filter|$subsystem|$phase";
156             }
157             }
158              
159 4         12 for ( @reports, @filters, 'ready' ) {
160 81 50       1515 STDERR->say("> register|$_") if $self->{debug};
161 81         254 $self->{output}->say("register|$_");
162             }
163              
164 4         118 $self->{input}->blocking(1);
165              
166 4         109 while ( defined( my $line = $self->{input}->getline ) ) {
167 98 50       1954 STDERR->print("< $line") if $self->{debug};
168 98         128 chomp $line;
169 98         169 $self->_dispatch($line);
170             }
171             }
172              
173             # The char "|" may only appear in the last field of a payload, in which
174             # case it should be considered a regular char and not a separator. Other
175             # fields have strict formatting excluding the possibility of having a "|".
176             sub _dispatch {
177 143     143   85703 my ( $self, $line ) = @_;
178 143   100     254 $line //= 'undef'; # no unitialized warnings
179 143         363 my ( $type, $extra ) = split /\|/, $line, 2;
180 143   100     254 $type //= 'unsupported'; # no uninitialized warnings
181              
182 143         471 my $method = $self->can("_handle_$type");
183 143 100       473 return $self->$method($extra) if $method;
184              
185 3         375 croak("Unsupported: $line");
186             }
187              
188             # general configuration information in the form of key-value lines
189             sub _handle_config {
190 10     10   26 my ( $self, $config ) = @_;
191              
192 10 100       35 return $self->{_ready} = $config
193             if $config eq 'ready';
194              
195 5         18 my ( $key, $value ) = split /\|/, $config, 2;
196 5         17 $self->{_config}->{$key} = $value;
197              
198 5         15 return $key, $value;
199             }
200              
201             # Each report event is generated by smtpd(8) as a single line
202             #
203             # The format consists of a protocol prefix containing the stream, the
204             # protocol version, the timestamp, the subsystem, the event and the unique
205             # session identifier separated by "|":
206             #
207             # It is followed by a suffix containing the event-specific parameters, also
208             # separated by "|"
209              
210             sub _handle_report {
211 109     109   1133 my ( $self, $report ) = @_;
212              
213 109         144 my %report;
214 109         474 @report{@report_fields} = split /\|/, $report, @report_fields;
215              
216 109   100     221 my $event = $report{event} // '';
217 109         167 my $suffix = delete $report{suffix};
218              
219 109         123 my %params;
220 109         200 my @fields = $self->_report_fields_for( @report{qw< subsystem event >} );
221 106 100       345 @params{@fields} = split /\|/, $suffix, @fields
222             if @fields;
223              
224 106   100     280 my $session = $self->{_sessions}->{ $report{session} } ||= {};
225 106         392 $session->{state}->{$_} = $report{$_} for keys %report;
226 106         152 push @{ $session->{events} }, { %report, %params, request => 'report' };
  106         608  
227              
228             # If the session disconncted we can't do anything more with it
229             delete $self->{_sessions}->{ $report{session} }
230 106 100       242 if $event eq 'link-disconnect';
231              
232 106 100       245 if ( $event =~ /^tx-(.*)$/ ) {
233 23         44 my $phase = $1;
234              
235 3         23 push @{ $session->{messages} }, $session->{state}->{message} = {}
236 23 100       41 if $phase eq 'begin';
237              
238 23         36 my $message = $session->{messages}->[-1];
239              
240 23 100       41 if ( $phase eq 'mail' ) {
    100          
241 3         7 $message->{'mail-from'} = $params{address};
242 3         7 $message->{result} = $params{result};
243             }
244             elsif ( $phase eq 'rcpt') {
245 4         5 push @{ $message->{'rcpt-to'} }, $params{address};
  4         13  
246 4         8 $message->{result} = $params{result};
247             }
248             else {
249 16         43 $message->{$_} = $params{$_} for keys %params;
250             }
251             }
252             else {
253 83         195 $session->{state}->{$_} = $params{$_} for keys %params;
254             }
255              
256 106         265 my $cb = $self->_cb_for( report => @report{qw< subsystem event >} );
257 106 100       170 $cb->( $event, $session ) if $cb;
258              
259 106         2182 return $session->{events}->[-1];
260             }
261              
262             sub _handle_filter {
263 37     37   12532 my ( $self, $filter ) = @_;
264              
265 37         65 my %filter;
266 37         257 @filter{@filter_fields} = split /\|/, $filter, @filter_fields;
267              
268 37         98 my $suffix = delete $filter{suffix};
269              
270             # For use in error messages
271 37         58 my $subsystem = $filter{subsystem};
272 37         46 my $phase = $filter{phase};
273 37         55 my $session_id = $filter{session};
274 37 100       182 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $phase, $session_id;
275              
276 37         48 my %params;
277 37         90 my @fields = $self->_filter_fields_for( @filter{qw< subsystem phase >} );
278 32 100 100     151 @params{@fields} = split /\|/, $suffix, @fields
279             if defined $suffix and @fields;
280              
281 32 100 100     340 my $session = $self->{_sessions}->{ $filter{session} || '' }
282             or croak("Unknown session $session_id in filter $subsystem|$phase");
283 30         37 push @{ $session->{events} }, { %filter, %params, request => 'filter' };
  30         239  
284              
285             return $self->_handle_filter_data_line( $params{line}, \%filter, $session )
286             if $filter{subsystem} eq 'smtp-in'
287 30 100 66     143 and $filter{phase} eq 'data-line';
288              
289 7         12 my @ret;
290 7 100       24 if ( my $cb = $self->_cb_for( filter => @filter{qw< subsystem phase >} ) )
291             {
292 6         20 @ret = $cb->( $filter{phase}, $session );
293             }
294             else {
295 1         156 carp("No handler for filter $subsystem|$phase, proceeding");
296 1         96 @ret = 'proceed';
297             }
298              
299 7         43 my $decisions = $filter_result_decisions{ $ret[0] };
300 7 100       17 unless ($decisions) {
301 1         66 carp "Unknown return from filter $subsystem|$phase: @ret";
302              
303 1         40 $ret[0] = 'reject';
304 1         3 $decisions = $filter_result_decisions{ $ret[0] };
305             }
306              
307             # Pass something as the reason for the rejection
308 7 100 100     47 push @ret, "550 Nope"
      100        
309             if @ret == 1
310             and ( $decisions->[0] || '' ) eq 'error';
311              
312             carp(
313             sprintf "Incorrect params from filter %s|%s, expected %s got %s",
314             $subsystem, $phase,
315 3         11 join( ' ', map {"'$_'"} 'decision', @$decisions ),
316 3         134 join( ' ', map {"'$_'"} @ret),
317 7 100       13 ) unless @ret == 1 + @{$decisions};
  7         29  
318              
319             my $response = join '|',
320             'filter-result',
321 7         92 @filter{qw< session opaque-token >},
322             @ret;
323              
324 7 50       22 STDERR->say("> $response") if $self->{debug};
325 7         44 $self->{output}->say($response);
326              
327 7         345 return {%filter};
328             }
329              
330             sub _handle_filter_data_line {
331 23     23   40 my ( $self, $line, $filter, $session ) = @_;
332 23   100     59 $line //= ''; # avoid uninit warnings
333              
334 23         32 my @lines;
335 23 100       28 if ( my $cb
336 23         47 = $self->_cb_for( filter => @{$filter}{qw< subsystem phase >} ) )
337             {
338 13         28 @lines = $cb->( $filter->{phase}, $session, $line );
339             }
340              
341 23         101 my $message = $session->{messages}->[-1];
342 23 100       40 push @{ $message->{'data-line'} }, $line if $self->{_save_data_lines};
  13         25  
343              
344 23 100       43 if ( $line eq '.' ) {
345             my $cb
346 3         6 = $self->_cb_for( filter => $filter->{subsystem}, 'data-lines' );
347 3 100       11 push @lines, $cb->( 'data-lines', $session, $message->{'data-line'} )
348             if $cb;
349              
350             # make sure we end the message;
351 3         54 push @lines, $line;
352             }
353              
354 23 100       38 for ( map { $_ ? split /\n/ : $_ } @lines ) {
  28         71  
355 28 100       51 last if $message->{'sent-dot'};
356              
357             my $response = join '|', 'filter-dataline',
358 25         33 @{$filter}{qw< session opaque-token >}, $_;
  25         58  
359              
360 25 50       48 STDERR->say("> $response") if $self->{debug};
361 25         80 $self->{output}->say($response);
362              
363 25 100       624 $message->{'sent-dot'} = 1 if $_ eq '.';
364             }
365              
366 23         296 return $filter;
367             }
368              
369 109     109   233 sub _report_fields_for { shift->_fields_for( report => \%report_events, @_ ) }
370 37     37   85 sub _filter_fields_for { shift->_fields_for( filter => \%filter_events, @_ ) }
371              
372             sub _fields_for {
373 146     146   232 my ( $self, $type, $map, $subsystem, $item ) = @_;
374              
375 146 100 100     607 if ( $subsystem and $item and my $items = $map->{$subsystem} ) {
      100        
376 140 100       271 return @{ $items->{$item} } if $items->{$item};
  138         355  
377             }
378              
379 8 100       42 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $item;
380 8         790 croak("Unsupported $type $subsystem|$item");
381             }
382              
383             sub _cb_for {
384 139     139   259 my ( $self, @lookup ) = @_;
385              
386 139         197 my $cb = $self->{on};
387 139   100     682 $cb = $cb->{$_} || {} for @lookup;
388              
389 139 100       323 return $cb if ref $cb eq 'CODE';
390              
391 114         187 return;
392             }
393              
394             1;
395              
396             __END__