File Coverage

blib/lib/OpenSMTPd/Filter.pm
Criterion Covered Total %
statement 196 196 100.0
branch 73 80 91.2
condition 48 53 90.5
subroutine 20 20 100.0
pod 2 2 100.0
total 339 351 96.5


line stmt bran cond sub pod time code
1             package OpenSMTPd::Filter;
2 5     5   1078729 use utf8; # so literals and identifiers can be in UTF-8
  5         389  
  5         31  
3 5     5   246 use v5.16; # or later to get "unicode_strings" feature and "charnames"
  5         19  
4 5     5   31 use strict; # quote strings, declare variables
  5         11  
  5         228  
5 5     5   24 use warnings; # on by default
  5         11  
  5         341  
6 5     5   29 use warnings qw(FATAL utf8); # fatalize encoding glitches
  5         8  
  5         299  
7 5     5   2875 use open qw(:std :encoding(UTF-8)); # undeclared streams in UTF-8
  5         7299  
  5         29  
8              
9             # This happens automatically, but to make pledge(2) happy
10             # it has to happen earlier than it would otherwise.
11 5     5   122796 use IO::File;
  5         50883  
  5         685  
12              
13 5     5   60 use Carp;
  5         10  
  5         341  
14 5     5   701 use Time::HiRes qw< time >;
  5         1629  
  5         45  
15              
16             # ABSTRACT: Easier filters for OpenSMTPd in perl
17             our $VERSION = 'v0.0.4'; # 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 src >],
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 1065295 my ( $class, %params ) = @_;
82              
83 25   100     204 $params{on} ||= {};
84 25   100     164 $params{input} ||= \*STDIN;
85 25   100     127 $params{output} ||= \*STDOUT;
86              
87 25         213 STDERR->autoflush;
88 25         1383 $params{output}->autoflush;
89              
90             # We expect to read and write bytes from the remote
91 25         906 $_->binmode(':raw') for @params{qw< input output >};
92              
93 25         635 my $check_supported_events;
94             $check_supported_events = sub {
95 55     55   125 my ( $c, $e, $ms ) = @_;
96 55   100     125 my $m = shift @{$ms} || return;
97              
98 42         78 my @s = sort keys %{$c};
  42         141  
99 42 100       115 if ( my @u = grep { !$e->{$_} } @s ) {
  35         119  
100 3 100       8 my $s = @u == 1 ? '' : 's';
101 3         296 croak("Unsupported $m$s @u");
102             }
103              
104 39         214 $check_supported_events->( $c->{$_}, $e->{$_}, $ms ) for @s;
105 25         195 };
106              
107             $check_supported_events->(
108             $params{on},
109 25         209 { 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     228 && $params{on}{filter}{'smtp-in'}{'data-lines'};
119              
120 22         76 my $self = bless \%params, $class;
121 22         84 return $self->_init;
122             }
123              
124             sub _init {
125 23     23   1867 my ($self) = @_;
126              
127 23         82 my $fh = $self->{input};
128 23   50     240 my $blocking = $fh->blocking // die "Unable to get blocking on input: $!";
129 23   50     150 $fh->blocking(0) // die "Unable to set input to non-blocking: $!";
130              
131 23         74 my $timeout = 0.25; # no idea how long we should actually wait
132 23         80 my $now = time;
133              
134 23         37 my %config;
135 23   100     136 while ( not $self->{_ready} and ( time - $now ) < $timeout ) {
136 968228   100     4267819 my $line = $fh->getline // next;
137 8 50       28 STDERR->print("< $line") if $self->{debug};
138 8         14 chomp $line;
139 8         27 $self->_dispatch($line);
140 8         29 $now = time; # continue waiting, we got a line
141             }
142              
143 23   50     323 $fh->blocking($blocking) // die "Unable to reset blocking on input: $!";
144              
145 23         303 return $self;
146             }
147              
148             sub ready {
149 5     5 1 53 my ($self) = @_;
150 5 100       190 croak("Input stream is not ready") unless $self->{_ready};
151              
152 76         149 my @reports = map {"report|smtp-in|$_"}
153 4         10 sort keys %{ $report_events{'smtp-in'} };
  4         59  
154              
155 4         14 my @filters;
156 4         8 for my $subsystem ( sort keys %{ $self->{on}->{filter} } ) {
  4         22  
157 1         3 for ( sort keys %{ $self->{on}->{filter}->{$subsystem} } ) {
  1         4  
158 1         4 my $v = $filter_events{$subsystem}{$_};
159 1 50       5 my $phase = ref $v eq 'CODE' ? $v->($_) : $_;
160 1         7 push @filters, "filter|$subsystem|$phase";
161             }
162             }
163              
164 4         12 for ( @reports, @filters, 'ready' ) {
165 81 50       1842 STDERR->say("> register|$_") if $self->{debug};
166 81         256 $self->{output}->say("register|$_");
167             }
168              
169 4         106 $self->{input}->blocking(1);
170              
171 4         56 while ( defined( my $line = $self->{input}->getline ) ) {
172 98 50       279 STDERR->print("< $line") if $self->{debug};
173 98         206 chomp $line;
174 98         237 $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   138024 my ( $self, $line ) = @_;
183 143   100     332 $line //= 'undef'; # no unitialized warnings
184 143         499 my ( $type, $extra ) = split /\|/, $line, 2;
185 143   100     323 $type //= 'unsupported'; # no uninitialized warnings
186              
187 143         618 my $method = $self->can("_handle_$type");
188 143 100       464 return $self->$method($extra) if $method;
189              
190 3         525 croak("Unsupported: $line");
191             }
192              
193             # general configuration information in the form of key-value lines
194             sub _handle_config {
195 10     10   26 my ( $self, $config ) = @_;
196              
197 10 100       35 return $self->{_ready} = $config
198             if $config eq 'ready';
199              
200 5         10 my ( $key, $value ) = split /\|/, $config, 2;
201 5         10 $self->{_config}->{$key} = $value;
202              
203 5         9 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   233 my ( $self, $report ) = @_;
217              
218 111         186 my %report;
219 111         759 @report{@report_fields} = split /\|/, $report, @report_fields;
220              
221 111   100     335 my $event = $report{event} // '';
222 111         223 my $suffix = delete $report{suffix};
223              
224 111         210 my %params;
225 111         321 my @fields = $self->_fields_for( report => \%report );
226 108 100       513 @params{@fields} = split /\|/, $suffix, @fields
227             if @fields;
228              
229 108   100     378 my $session = $self->{_sessions}->{ $report{session} } ||= {};
230 108         740 $session->{state}->{$_} = $report{$_} for keys %report;
231 108         217 push @{ $session->{events} }, { %report, %params, request => 'report' };
  108         913  
232              
233             # If the session disconncted we can't do anything more with it
234             delete $self->{_sessions}->{ $report{session} }
235 108 100       334 if $event eq 'link-disconnect';
236              
237 108 100       357 if ( $event =~ /^tx-(.*)$/ ) {
238 23         68 my $phase = $1;
239              
240 3         25 push @{ $session->{messages} }, $session->{state}->{message} = {}
241 23 100       55 if $phase eq 'begin';
242              
243 23         45 my $message = $session->{messages}->[-1];
244              
245 23 100       57 if ( $phase eq 'mail' ) {
    100          
246 3         9 $message->{'mail-from'} = $params{address};
247 3         8 $message->{result} = $params{result};
248             }
249             elsif ( $phase eq 'rcpt') {
250 4         8 push @{ $message->{'rcpt-to'} }, $params{address};
  4         14  
251 4         12 $message->{result} = $params{result};
252             }
253             else {
254 16         63 $message->{$_} = $params{$_} for keys %params;
255             }
256             }
257             else {
258 85         307 $session->{state}->{$_} = $params{$_} for keys %params;
259             }
260              
261 108         321 my $cb = $self->_cb_for( report => @report{qw< subsystem event >} );
262 108 100       255 $cb->( $event, $session ) if $cb;
263              
264 108         2058 return $session->{events}->[-1];
265             }
266              
267             sub _handle_filter {
268 37     37   17760 my ( $self, $filter ) = @_;
269              
270 37         56 my %filter;
271 37         390 @filter{@filter_fields} = split /\|/, $filter, @filter_fields;
272              
273 37         94 my $suffix = delete $filter{suffix};
274              
275             # For use in error messages
276 37         63 my $subsystem = $filter{subsystem};
277 37         89 my $phase = $filter{phase};
278 37         67 my $session_id = $filter{session};
279 37 100       214 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $phase, $session_id;
280              
281 37         53 my %params;
282 37         125 my @fields = $self->_fields_for( filter => \%filter );
283 32 100 100     168 @params{@fields} = split /\|/, $suffix, @fields
284             if defined $suffix and @fields;
285              
286 32 100 100     569 my $session = $self->{_sessions}->{ $filter{session} || '' }
287             or croak("Unknown session $session_id in filter $subsystem|$phase");
288 30         47 push @{ $session->{events} }, { %filter, %params, request => 'filter' };
  30         279  
289              
290             return $self->_handle_filter_data_line( $params{line}, \%filter, $session )
291             if $filter{subsystem} eq 'smtp-in'
292 30 100 66     167 and $filter{phase} eq 'data-line';
293              
294 7         14 my @ret;
295 7 100       33 if ( my $cb = $self->_cb_for( filter => @filter{qw< subsystem phase >} ) )
296             {
297 6         39 @ret = $cb->( $filter{phase}, $session );
298             }
299             else {
300 1         367 carp("No handler for filter $subsystem|$phase, proceeding");
301 1         13 @ret = 'proceed';
302             }
303              
304 7         1647 my $decisions = $filter_result_decisions{ $ret[0] };
305 7 100       23 unless ($decisions) {
306 1         177 carp "Unknown return from filter $subsystem|$phase: @ret";
307              
308 1         11 $ret[0] = 'reject';
309 1         3 $decisions = $filter_result_decisions{ $ret[0] };
310             }
311              
312             # Pass something as the reason for the rejection
313 7 100 100     61 push @ret, "550 Nope"
      100        
314             if @ret == 1
315             and ( $decisions->[0] || '' ) eq 'error';
316              
317             carp(
318             sprintf "Incorrect params from filter %s|%s, expected %s got %s",
319             $subsystem, $phase,
320 3         15 join( ' ', map {"'$_'"} 'decision', @$decisions ),
321 3         310 join( ' ', map {"'$_'"} @ret),
322 7 100       15 ) unless @ret == 1 + @{$decisions};
  7         30  
323              
324             my $response = join '|',
325             'filter-result',
326 7         47 @filter{qw< session opaque-token >},
327             @ret;
328              
329 7 50       41 STDERR->say("> $response") if $self->{debug};
330 7         65 $self->{output}->say($response);
331              
332 7         764 return {%filter};
333             }
334              
335             sub _handle_filter_data_line {
336 23     23   47 my ( $self, $line, $filter, $session ) = @_;
337 23   100     54 $line //= ''; # avoid uninit warnings
338              
339 23         32 my @lines;
340 23 100       35 if ( my $cb
341 23         52 = $self->_cb_for( filter => @{$filter}{qw< subsystem phase >} ) )
342             {
343 13         100 @lines = $cb->( $filter->{phase}, $session, $line );
344             }
345              
346 23         146 my $message = $session->{messages}->[-1];
347 23 100       50 push @{ $message->{'data-line'} }, $line if $self->{_save_data_lines};
  13         27  
348              
349 23 100       47 if ( $line eq '.' ) {
350             my $cb
351 3         14 = $self->_cb_for( filter => $filter->{subsystem}, 'data-lines' );
352 3 100       14 push @lines, $cb->( 'data-lines', $session, $message->{'data-line'} )
353             if $cb;
354              
355             # make sure we end the message;
356 3         45 push @lines, $line;
357             }
358              
359 23 100       40 for ( map { $_ ? split /\n/ : $_ } @lines ) {
  28         104  
360 28 100       73 last if $message->{'sent-dot'};
361              
362             my $response = join '|', 'filter-dataline',
363 25         39 @{$filter}{qw< session opaque-token >}, $_;
  25         66  
364              
365 25 50       49 STDERR->say("> $response") if $self->{debug};
366 25         93 $self->{output}->say($response);
367              
368 25 100       902 $message->{'sent-dot'} = 1 if $_ eq '.';
369             }
370              
371 23         204 return $filter;
372             }
373              
374             sub _fields_for {
375 148     148   328 my ( $self, $type, $params ) = @_;
376              
377 148         259 my $subsystem = $params->{subsystem};
378             my ($item, $map) =
379             $type eq 'report' ? ( $params->{event} => \%report_events )
380 148 50       496 : $type eq 'filter' ? ( $params->{phase} => \%filter_events )
    100          
381             : croak "Unknown field type: $type";
382              
383 148 100 100     844 if ( $subsystem and $item and my $items = $map->{$subsystem} ) {
      100        
384 142 100       415 if ( my $fields = $items->{$item} ) {
385 140 100       355 $fields = $self->$fields( $type, $params )
386             if ref $fields eq 'CODE';
387              
388 140         231 return @{$fields};
  140         523  
389             }
390             }
391              
392 8 100       44 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $item;
393 8         1689 croak("Unsupported $type $subsystem|$item");
394             }
395              
396             sub _cb_for {
397 141     141   448 my ( $self, @lookup ) = @_;
398              
399 141         258 my $cb = $self->{on};
400 141   100     1169 $cb = $cb->{$_} || {} for @lookup;
401              
402 141 100       487 return $cb if ref $cb eq 'CODE';
403              
404 116         311 return;
405             }
406              
407             1;
408              
409             __END__