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   875309 use utf8; # so literals and identifiers can be in UTF-8
  4         36  
  4         20  
3 4     4   147 use v5.16; # or later to get "unicode_strings" feature and "charnames"
  4         15  
4 4     4   19 use strict; # quote strings, declare variables
  4         7  
  4         78  
5 4     4   16 use warnings; # on by default
  4         6  
  4         100  
6 4     4   18 use warnings qw(FATAL utf8); # fatalize encoding glitches
  4         7  
  4         549  
7 4     4   1904 use open qw(:std :encoding(UTF-8)); # undeclared streams in UTF-8
  4         4724  
  4         23  
8              
9             # This happens automatically, but to make pledge(2) happy
10             # it has to happen earlier than it would otherwise.
11 4     4   7617 use IO::File;
  4         33844  
  4         450  
12              
13 4     4   45 use Carp;
  4         9  
  4         204  
14 4     4   2486 use Time::HiRes qw< time >;
  4         5481  
  4         18  
15              
16             # ABSTRACT: Easier filters for OpenSMTPd in perl
17             our $VERSION = 'v0.0.1'; # 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 87906 my ( $class, %params ) = @_;
77              
78 19   100     126 $params{on} ||= {};
79 19   100     146 $params{input} ||= \*STDIN;
80 19   100     83 $params{output} ||= \*STDOUT;
81              
82 19         215 STDERR->autoflush;
83 19         929 $params{output}->autoflush;
84              
85             # We expect to read and write bytes from the remote
86 19         586 $_->binmode(':raw') for @params{qw< input output >};
87              
88 19         456 my $check_supported_events;
89             $check_supported_events = sub {
90 49     49   94 my ( $c, $e, $ms ) = @_;
91 49   100     71 my $m = shift @{$ms} || return;
92              
93 36         62 my @s = sort keys %{$c};
  36         133  
94 36 100       93 if ( my @u = grep { !$e->{$_} } @s ) {
  35         112  
95 3 100       10 my $s = @u == 1 ? '' : 's';
96 3         346 croak("Unsupported $m$s @u");
97             }
98              
99 33         147 $check_supported_events->( $c->{$_}, $e->{$_}, $ms ) for @s;
100 19         118 };
101              
102             $check_supported_events->(
103             $params{on},
104 19         135 { 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     160 && $params{on}{filter}{'smtp-in'}{'data-lines'};
114              
115 16         42 my $self = bless \%params, $class;
116 16         67 return $self->_init;
117             }
118              
119             sub _init {
120 17     17   1622 my ($self) = @_;
121              
122 17         49 my $fh = $self->{input};
123 17   50     215 my $blocking = $fh->blocking // die "Unable to get blocking on input: $!";
124 17   50     243 $fh->blocking(0) // die "Unable to set input to non-blocking: $!";
125              
126 17         49 my $timeout = 0.25; # no idea how long we should actually wait
127 17         66 my $now = time;
128              
129 17         30 my %config;
130 17   100     137 while ( not $self->{_ready} and ( time - $now ) < $timeout ) {
131 58800   100     2750031 my $line = $fh->getline // next;
132 8 50       265 STDERR->print("< $line") if $self->{debug};
133 8         21 chomp $line;
134 8         25 $self->_dispatch($line);
135 8         34 $now = time; # continue waiting, we got a line
136             }
137              
138 17   50     622 $fh->blocking($blocking) // die "Unable to reset blocking on input: $!";
139              
140 17         175 return $self;
141             }
142              
143             sub ready {
144 5     5 1 55 my ($self) = @_;
145 5 100       211 croak("Input stream is not ready") unless $self->{_ready};
146              
147 76         128 my @reports = map {"report|smtp-in|$_"}
148 4         8 sort keys %{ $report_events{'smtp-in'} };
  4         53  
149              
150 4         12 my @filters;
151 4         7 for my $subsystem ( sort keys %{ $self->{on}->{filter} } ) {
  4         18  
152 1         1 for ( sort keys %{ $self->{on}->{filter}->{$subsystem} } ) {
  1         4  
153 1         2 my $v = $filter_events{$subsystem}{$_};
154 1 50       4 my $phase = ref $v eq 'CODE' ? $v->($_) : $_;
155 1         4 push @filters, "filter|$subsystem|$phase";
156             }
157             }
158              
159 4         13 for ( @reports, @filters, 'ready' ) {
160 81 50       1494 STDERR->say("> register|$_") if $self->{debug};
161 81         263 $self->{output}->say("register|$_");
162             }
163              
164 4         139 $self->{input}->blocking(1);
165              
166 4         107 while ( defined( my $line = $self->{input}->getline ) ) {
167 98 50       1940 STDERR->print("< $line") if $self->{debug};
168 98         126 chomp $line;
169 98         159 $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   86329 my ( $self, $line ) = @_;
178 143   100     260 $line //= 'undef'; # no unitialized warnings
179 143         362 my ( $type, $extra ) = split /\|/, $line, 2;
180 143   100     248 $type //= 'unsupported'; # no uninitialized warnings
181              
182 143         461 my $method = $self->can("_handle_$type");
183 143 100       341 return $self->$method($extra) if $method;
184              
185 3         372 croak("Unsupported: $line");
186             }
187              
188             # general configuration information in the form of key-value lines
189             sub _handle_config {
190 10     10   22 my ( $self, $config ) = @_;
191              
192 10 100       51 return $self->{_ready} = $config
193             if $config eq 'ready';
194              
195 5         17 my ( $key, $value ) = split /\|/, $config, 2;
196 5         15 $self->{_config}->{$key} = $value;
197              
198 5         13 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   1360 my ( $self, $report ) = @_;
212              
213 109         118 my %report;
214 109         462 @report{@report_fields} = split /\|/, $report, @report_fields;
215              
216 109   100     255 my $event = $report{event} // '';
217 109         162 my $suffix = delete $report{suffix};
218              
219 109         124 my %params;
220 109         210 my @fields = $self->_report_fields_for( @report{qw< subsystem event >} );
221 106 100       415 @params{@fields} = split /\|/, $suffix, @fields
222             if @fields;
223              
224 106   100     261 my $session = $self->{_sessions}->{ $report{session} } ||= {};
225 106         447 $session->{state}->{$_} = $report{$_} for keys %report;
226 106         142 push @{ $session->{events} }, { %report, %params, request => 'report' };
  106         613  
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       224 if ( $event =~ /^tx-(.*)$/ ) {
233 23         43 my $phase = $1;
234              
235 3         10 push @{ $session->{messages} }, $session->{state}->{message} = {}
236 23 100       37 if $phase eq 'begin';
237              
238 23         33 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         6 $message->{result} = $params{result};
243             }
244             elsif ( $phase eq 'rcpt') {
245 4         5 push @{ $message->{'rcpt-to'} }, $params{address};
  4         11  
246 4         18 $message->{result} = $params{result};
247             }
248             else {
249 16         44 $message->{$_} = $params{$_} for keys %params;
250             }
251             }
252             else {
253 83         199 $session->{state}->{$_} = $params{$_} for keys %params;
254             }
255              
256 106         238 my $cb = $self->_cb_for( report => @report{qw< subsystem event >} );
257 106 100       162 $cb->( $event, $session ) if $cb;
258              
259 106         2088 return $session->{events}->[-1];
260             }
261              
262             sub _handle_filter {
263 37     37   15337 my ( $self, $filter ) = @_;
264              
265 37         54 my %filter;
266 37         320 @filter{@filter_fields} = split /\|/, $filter, @filter_fields;
267              
268 37         102 my $suffix = delete $filter{suffix};
269              
270             # For use in error messages
271 37         68 my $subsystem = $filter{subsystem};
272 37         61 my $phase = $filter{phase};
273 37         65 my $session_id = $filter{session};
274 37 100       198 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $phase, $session_id;
275              
276 37         54 my %params;
277 37         110 my @fields = $self->_filter_fields_for( @filter{qw< subsystem phase >} );
278 32 100 100     182 @params{@fields} = split /\|/, $suffix, @fields
279             if defined $suffix and @fields;
280              
281 32 100 100     423 my $session = $self->{_sessions}->{ $filter{session} || '' }
282             or croak("Unknown session $session_id in filter $subsystem|$phase");
283 30         39 push @{ $session->{events} }, { %filter, %params, request => 'filter' };
  30         225  
284              
285             return $self->_handle_filter_data_line( $params{line}, \%filter, $session )
286             if $filter{subsystem} eq 'smtp-in'
287 30 100 66     164 and $filter{phase} eq 'data-line';
288              
289 7         15 my @ret;
290 7 100       34 if ( my $cb = $self->_cb_for( filter => @filter{qw< subsystem phase >} ) )
291             {
292 6         36 @ret = $cb->( $filter{phase}, $session );
293             }
294             else {
295 1         201 carp("No handler for filter $subsystem|$phase, proceeding");
296 1         89 @ret = 'proceed';
297             }
298              
299 7         57 my $decisions = $filter_result_decisions{ $ret[0] };
300 7 100       22 unless ($decisions) {
301 1         86 carp "Unknown return from filter $subsystem|$phase: @ret";
302              
303 1         51 $ret[0] = 'reject';
304 1         4 $decisions = $filter_result_decisions{ $ret[0] };
305             }
306              
307             # Pass something as the reason for the rejection
308 7 100 100     59 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         15 join( ' ', map {"'$_'"} 'decision', @$decisions ),
316 3         343 join( ' ', map {"'$_'"} @ret),
317 7 100       19 ) unless @ret == 1 + @{$decisions};
  7         27  
318              
319             my $response = join '|',
320             'filter-result',
321 7         166 @filter{qw< session opaque-token >},
322             @ret;
323              
324 7 50       29 STDERR->say("> $response") if $self->{debug};
325 7         70 $self->{output}->say($response);
326              
327 7         477 return {%filter};
328             }
329              
330             sub _handle_filter_data_line {
331 23     23   41 my ( $self, $line, $filter, $session ) = @_;
332 23   100     46 $line //= ''; # avoid uninit warnings
333              
334 23         27 my @lines;
335 23 100       31 if ( my $cb
336 23         41 = $self->_cb_for( filter => @{$filter}{qw< subsystem phase >} ) )
337             {
338 13         28 @lines = $cb->( $filter->{phase}, $session, $line );
339             }
340              
341 23         113 my $message = $session->{messages}->[-1];
342 23 100       39 push @{ $message->{'data-line'} }, $line if $self->{_save_data_lines};
  13         27  
343              
344 23 100       39 if ( $line eq '.' ) {
345             my $cb
346 3         11 = $self->_cb_for( filter => $filter->{subsystem}, 'data-lines' );
347 3 100       10 push @lines, $cb->( 'data-lines', $session, $message->{'data-line'} )
348             if $cb;
349              
350             # make sure we end the message;
351 3         84 push @lines, $line;
352             }
353              
354 23 100       39 for ( map { $_ ? split /\n/ : $_ } @lines ) {
  28         70  
355 28 100       61 last if $message->{'sent-dot'};
356              
357             my $response = join '|', 'filter-dataline',
358 25         34 @{$filter}{qw< session opaque-token >}, $_;
  25         61  
359              
360 25 50       44 STDERR->say("> $response") if $self->{debug};
361 25         84 $self->{output}->say($response);
362              
363 25 100       571 $message->{'sent-dot'} = 1 if $_ eq '.';
364             }
365              
366 23         303 return $filter;
367             }
368              
369 109     109   206 sub _report_fields_for { shift->_fields_for( report => \%report_events, @_ ) }
370 37     37   110 sub _filter_fields_for { shift->_fields_for( filter => \%filter_events, @_ ) }
371              
372             sub _fields_for {
373 146     146   268 my ( $self, $type, $map, $subsystem, $item ) = @_;
374              
375 146 100 100     647 if ( $subsystem and $item and my $items = $map->{$subsystem} ) {
      100        
376 140 100       297 return @{ $items->{$item} } if $items->{$item};
  138         384  
377             }
378              
379 8 100       38 $_ = defined $_ ? "'$_'" : "undef" for $subsystem, $item;
380 8         916 croak("Unsupported $type $subsystem|$item");
381             }
382              
383             sub _cb_for {
384 139     139   274 my ( $self, @lookup ) = @_;
385              
386 139         175 my $cb = $self->{on};
387 139   100     726 $cb = $cb->{$_} || {} for @lookup;
388              
389 139 100       321 return $cb if ref $cb eq 'CODE';
390              
391 114         225 return;
392             }
393              
394             1;
395              
396             __END__