File Coverage

lib/Mail/Pyzor/Client.pm
Criterion Covered Total %
statement 78 89 87.6
branch 10 22 45.4
condition 12 24 50.0
subroutine 18 19 94.7
pod 3 3 100.0
total 121 157 77.0


line stmt bran cond sub pod time code
1             package Mail::Pyzor::Client;
2              
3             # Copyright 2018 cPanel, LLC.
4             # All rights reserved.
5             # http://cpanel.net
6             #
7             # This is free software; you can redistribute it and/or modify it under the
8             # Apache 2.0 license.
9              
10 2     2   297137 use strict;
  2         14  
  2         60  
11 2     2   15 use warnings;
  2         4  
  2         203  
12              
13             =encoding utf-8
14              
15             =head1 NAME
16              
17             Mail::Pyzor::Client - Pyzor client logic
18              
19             =head1 SYNOPSIS
20              
21             use Mail::Pyzor::Client ();
22             use Mail::Pyzor::Digest ();
23              
24             my $client = Mail::Pyzor::Client->new();
25              
26             my $digest = Mail::Pyzor::Digest::get( $msg );
27              
28             my $check_ref = $client->check($digest);
29             die $check_ref->{'Diag'} if $check_ref->{'Code'} ne '200';
30              
31             my $report_ref = $client->report($digest);
32             die $report_ref->{'Diag'} if $report_ref->{'Code'} ne '200';
33              
34             =head1 DESCRIPTION
35              
36             A bare-bones L client that currently only
37             implements the functionality needed for L.
38              
39             =head1 PROTOCOL DETAILS
40              
41             The Pyzor protocol is not a published standard, and there appears to be
42             no meaningful public documentation. What follows is enough information,
43             largely gleaned through forum posts and reverse engineering, to facilitate
44             effective use of this module:
45              
46             Pyzor is an RPC-oriented, message-based protocol. Each message
47             is a simple dictionary of 7-bit ASCII keys and values. Server responses
48             always include at least the following:
49              
50             =over
51              
52             =item * C - Similar to HTTP status codes; anything besides C<200>
53             is an error.
54              
55             =item * C - Similar to HTTP status reasons: a text description
56             of the status.
57              
58             =back
59              
60             (NB: There are additional standard response headers that are useful only for
61             the protocol itself and thus are not part of this module’s returns.)
62              
63             =head2 Reliability
64              
65             Pyzor uses UDP rather than TCP, so no message is guaranteed to reach its
66             destination. A transmission failure can happen in either the request or
67             the response; in either case, a timeout error will result. Such errors
68             are represented as thrown instances of L.
69              
70             =cut
71              
72             #----------------------------------------------------------------------
73              
74             our $DEFAULT_SERVER_HOST = 'public.pyzor.org';
75             our $DEFAULT_SERVER_PORT = 24441;
76             our $DEFAULT_USERNAME = 'anonymous';
77             our $DEFAULT_PASSWORD = '';
78             our $DEFAULT_OP_SPEC = '20,3,60,3';
79             our $PYZOR_PROTOCOL_VERSION = 2.1;
80             our $DEFAULT_TIMEOUT = 3.5;
81             our $READ_SIZE = 8192;
82              
83 2     2   849 use Mail::Pyzor::SHA ();
  2         4  
  2         40  
84 2     2   983 use IO::Socket::INET ();
  2         32967  
  2         48  
85 2     2   966 use IO::SigGuard ();
  2         505  
  2         57  
86 2     2   794 use Mail::Pyzor::X ();
  2         5  
  2         2482  
87              
88             my @hash_order = ( 'Op', 'Op-Digest', 'Op-Spec', 'Thread', 'PV', 'User', 'Time', 'Sig' );
89              
90             #----------------------------------------------------------------------
91              
92             =head1 CONSTRUCTOR
93              
94             =head2 new(%OPTS)
95              
96             Create a new pyzor client.
97              
98             =over 2
99              
100             =item Input
101              
102             %OPTS are (all optional):
103              
104             =over 3
105              
106             =item * C - The pyzor server host to connect to (default is
107             C)
108              
109             =item * C - The pyzor server port to connect to (default is
110             24441)
111              
112             =item * C - The username to present to the pyzor server (default
113             is C)
114              
115             =item * C - The password to present to the pyzor server (default
116             is empty)
117              
118             =item * C - The maximum time, in seconds, to wait for a response
119             from the pyzor server (defeault is 3.5)
120              
121             =back
122              
123             =item Output
124              
125             =over 3
126              
127             Returns a L object.
128              
129             =back
130              
131             =back
132              
133             =cut
134              
135             sub new {
136 5     5 1 28612 my ( $class, %OPTS ) = @_;
137              
138             return bless {
139             '_server_host' => $OPTS{'server_host'} || $DEFAULT_SERVER_HOST,
140             '_server_port' => $OPTS{'server_port'} || $DEFAULT_SERVER_PORT,
141             '_username' => $OPTS{'username'} || $DEFAULT_USERNAME,
142             '_password' => $OPTS{'password'} || $DEFAULT_PASSWORD,
143             '_op_spec' => $DEFAULT_OP_SPEC,
144 5   66     101 '_timeout' => $OPTS{'timeout'} || $DEFAULT_TIMEOUT,
      66        
      66        
      66        
      66        
145             }, $class;
146             }
147              
148             #----------------------------------------------------------------------
149              
150             =head1 REQUEST METHODS
151              
152             =head2 report($digest)
153              
154             Report the digest of a spam message to the pyzor server. This function
155             will throw if a messaging failure or timeout happens.
156              
157             =over 2
158              
159             =item Input
160              
161             =over 3
162              
163             =item $digest C
164              
165             The message digest to report, as given by
166             C.
167              
168             =back
169              
170             =item Output
171              
172             =over 3
173              
174             =item C
175              
176             Returns a hashref of the standard attributes noted above.
177              
178             =back
179              
180             =back
181              
182             =cut
183              
184             sub report {
185 2     2 1 470 my ( $self, $digest ) = @_;
186              
187 2         9 my $msg_ref = $self->_get_base_msg( 'report', $digest );
188              
189 1         4 $msg_ref->{'Op-Spec'} = $self->{'_op_spec'};
190              
191 1         3 return $self->_send_receive_msg($msg_ref);
192             }
193              
194             =head2 check($digest)
195              
196             Check the digest of a message to see if
197             the pyzor server has a report for it. This function
198             will throw if a messaging failure or timeout happens.
199              
200             =over 2
201              
202             =item Input
203              
204             =over 3
205              
206             =item $digest C
207              
208             The message digest to check, as given by
209             C.
210              
211             =back
212              
213             =item Output
214              
215             =over 3
216              
217             =item C
218              
219             Returns a hashref of the standard attributes noted above
220             as well as the following:
221              
222             =over
223              
224             =item * C - The number of reports the server has received
225             for the given digest.
226              
227             =item * C - The number of whitelist requests the server has received
228             for the given digest.
229              
230             =back
231              
232             =back
233              
234             =back
235              
236             =cut
237              
238             sub check {
239 2     2 1 502 my ( $self, $digest ) = @_;
240              
241 2         7 return $self->_send_receive_msg( $self->_get_base_msg( 'check', $digest ) );
242             }
243              
244             # ----------------------------------------
245              
246             sub _send_receive_msg {
247 2     2   5 my ( $self, $msg_ref ) = @_;
248              
249 2 50       7 my $thread_id = $msg_ref->{'Thread'} or die 'No thread ID?';
250              
251 2         6 $self->_sign_msg($msg_ref);
252              
253 2         9 return $self->_do_send_receive(
254             $self->_generate_packet_from_message($msg_ref) . "\n\n",
255             $thread_id,
256             );
257             }
258              
259             sub _get_base_msg {
260 5     5   69 my ( $self, $op, $digest ) = @_;
261              
262 5 100       25 die "Implementor error: op is required" if !$op;
263 4 100       31 die "error: digest is required" if !$digest;
264              
265             return {
266 2         17 'User' => $self->{'_username'},
267             'PV' => $PYZOR_PROTOCOL_VERSION,
268             'Time' => time(),
269             'Op' => $op,
270             'Op-Digest' => $digest,
271             'Thread' => $self->_generate_thread_id()
272             };
273             }
274              
275             sub _do_send_receive {
276 2     2   6 my ( $self, $packet, $thread_id ) = @_;
277              
278 2         6 my $sock = $self->_get_connection_or_die();
279              
280 2         3067 $self->_send_packet( $sock, $packet );
281 2         2294 my $response = $self->_receive_packet( $sock, $thread_id );
282              
283 2         9 my $resp_hr = { map { ( split(m{: }) )[ 0, 1 ] } split( m{\n}, $response ) };
  8         28  
284              
285 2         8 delete $resp_hr->{'Thread'};
286              
287 2         3 my $response_pv = delete $resp_hr->{'PV'};
288              
289 2 50       14 if ( $PYZOR_PROTOCOL_VERSION ne $response_pv ) {
290 0         0 warn "Unexpected protocol version ($response_pv) in Pyzor response!";
291             }
292              
293 2         11 return $resp_hr;
294             }
295              
296             sub _receive_packet {
297 2     2   6 my ( $self, $sock, $thread_id ) = @_;
298              
299 2         7 my $timeout = $self->{'_timeout'} * 1000;
300              
301 2         6 my $end_time = time + $self->{'_timeout'};
302              
303 2         27 $sock->blocking(0);
304 2         6 my $response = '';
305 2         3 my $rout = '';
306 2         4 my $rin = '';
307 2         15 vec( $rin, fileno($sock), 1 ) = 1;
308              
309 2         6 while (1) {
310 2         5 my $time_left = $end_time - time;
311              
312 2 50       8 if ( $time_left <= 0 ) {
313 0         0 die Mail::Pyzor::X->create( 'Timeout', "Did not receive a response from the pyzor server $self->{'_server_host'}:$self->{'_server_port'} for $self->{'_timeout'} seconds!" );
314             }
315              
316 2         28 my $bytes = IO::SigGuard::sysread( $sock, $response, $READ_SIZE, length $response );
317 2 0 33     663 if ( !defined($bytes) && !$!{'EAGAIN'} && !$!{'EWOULDBLOCK'} ) {
      0        
318 0         0 warn "read from socket: $!";
319             }
320              
321 2 50       11 if ( index( $response, "\n\n" ) > -1 ) {
322              
323             # Reject the response unless its thread ID matches what we sent.
324             # This prevents confusion among concurrent Pyzor reqeusts.
325 2 50       11 if ( index( $response, "\nThread: $thread_id\n" ) != -1 ) {
326 2         4 last;
327             }
328             else {
329 0         0 $response = '';
330             }
331             }
332              
333 0         0 my $found = IO::SigGuard::select( $rout = $rin, undef, undef, $time_left );
334 0 0       0 warn "select(): $!" if $found == -1;
335             }
336              
337 2         7 return $response;
338             }
339              
340             sub _send_packet {
341 0     0   0 my ( $self, $sock, $packet ) = @_;
342              
343 0         0 $sock->blocking(1);
344 0 0       0 IO::SigGuard::syswrite( $sock, $packet ) or warn "write to socket: $!";
345              
346 0         0 return;
347             }
348              
349             sub _get_connection_or_die {
350 1     1   430 my ($self) = @_;
351             $self->{'_sock'} ||= IO::Socket::INET->new(
352             'PeerHost' => $self->{'_server_host'},
353 1   33     19 'PeerPort' => $self->{'_server_port'},
354             'Proto' => 'udp'
355             );
356              
357 1 50       11 if ( !$self->{'_sock'} ) {
358 1         13 die "Cannot connect to $self->{'_server_host'}:$self->{'_server_port'}: $@ $!";
359             }
360 0         0 return $self->{'_sock'};
361             }
362              
363             sub _sign_msg {
364 2     2   5 my ( $self, $msg_ref ) = @_;
365              
366             $msg_ref->{'Sig'} = lc Mail::Pyzor::SHA::sha1_hex(
367             Mail::Pyzor::SHA::sha1( $self->_generate_packet_from_message($msg_ref) ) . #
368             ':' . #
369 2         6 $msg_ref->{'Time'} . #
370             ':' . #
371             $self->_get_user_pass_hash_key() #
372             );
373              
374 2         5 return 1;
375             }
376              
377             sub _generate_packet_from_message {
378 4     4   7 my ( $self, $msg_ref ) = @_;
379              
380 4         9 return join( "\n", map { "$_: $msg_ref->{$_}" } grep { length $msg_ref->{$_} } @hash_order );
  28         82  
  32         72  
381             }
382              
383             sub _generate_thread_id {
384 2     2   5 my $RAND_MAX = 2**16;
385 2         3 my $val = 0;
386 2         9 $val = int rand($RAND_MAX) while $val < 1024;
387 2         16 return $val;
388             }
389              
390             sub _get_user_pass_hash_key {
391 2     2   5 my ($self) = @_;
392              
393 2         8 return lc Mail::Pyzor::SHA::sha1_hex( $self->{'_username'} . ':' . $self->{'_password'} );
394             }
395              
396             1;