File Coverage

blib/lib/OpenSMTPd/Filter.pm
Criterion Covered Total %
statement 196 196 100.0
branch 70 76 92.1
condition 48 53 90.5
subroutine 22 22 100.0
pod 2 2 100.0
total 338 349 96.8


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