mirror of
https://git.proxmox.com/git/proxmox-spamassassin
synced 2025-04-28 12:19:37 +00:00
420 lines
10 KiB
Perl
420 lines
10 KiB
Perl
package Mail::SpamAssassin::Pyzor::Client;
|
|
|
|
# Copyright 2018 cPanel, LLC.
|
|
# All rights reserved.
|
|
# http://cpanel.net
|
|
#
|
|
# <@LICENSE>
|
|
# Licensed to the Apache Software Foundation (ASF) under one or more
|
|
# contributor license agreements. See the NOTICE file distributed with
|
|
# this work for additional information regarding copyright ownership.
|
|
# The ASF licenses this file to you under the Apache License, Version 2.0
|
|
# (the "License"); you may not use this file except in compliance with
|
|
# the License. You may obtain a copy of the License at:
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# </@LICENSE>
|
|
#
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
=encoding utf-8
|
|
|
|
=head1 NAME
|
|
|
|
Mail::SpamAssassin::Pyzor::Client - Pyzor client logic
|
|
|
|
=head1 SYNOPSIS
|
|
|
|
use Mail::SpamAssassin::Pyzor::Client ();
|
|
use Mail::SpamAssassin::Pyzor::Digest ();
|
|
|
|
my $client = Mail::SpamAssassin::Pyzor::Client->new();
|
|
|
|
my $digest = Mail::SpamAssassin::Pyzor::Digest::get( $msg );
|
|
|
|
my $check_ref = $client->check($digest);
|
|
die $check_ref->{'Diag'} if $check_ref->{'Code'} ne '200';
|
|
|
|
my $report_ref = $client->report($digest);
|
|
die $report_ref->{'Diag'} if $report_ref->{'Code'} ne '200';
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
A bare-bones L<Pyzor|http://pyzor.org> client that currently only
|
|
implements the functionality needed for L<Mail::SpamAssassin>.
|
|
|
|
=head1 PROTOCOL DETAILS
|
|
|
|
The Pyzor protocol is not a published standard, and there appears to be
|
|
no meaningful public documentation. What follows is enough information,
|
|
largely gleaned through forum posts and reverse engineering, to facilitate
|
|
effective use of this module:
|
|
|
|
Pyzor is an RPC-oriented, message-based protocol. Each message
|
|
is a simple dictionary of 7-bit ASCII keys and values. Server responses
|
|
always include at least the following:
|
|
|
|
=over
|
|
|
|
=item * C<Code> - Similar to HTTP status codes; anything besides C<200>
|
|
is an error.
|
|
|
|
=item * C<Diag> - Similar to HTTP status reasons: a text description
|
|
of the status.
|
|
|
|
=back
|
|
|
|
(NB: There are additional standard response headers that are useful only for
|
|
the protocol itself and thus are not part of this module's returns.)
|
|
|
|
=head2 Reliability
|
|
|
|
Pyzor uses UDP rather than TCP, so no message is guaranteed to reach its
|
|
destination. A transmission failure can happen in either the request or
|
|
the response; in either case, a timeout error will result. Such errors
|
|
are represented as thrown instances of L<Mail::Pyzor::X::Timeout>.
|
|
|
|
=cut
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
our $VERSION = '0.04';
|
|
|
|
our $DEFAULT_SERVER_HOST = 'public.pyzor.org';
|
|
our $DEFAULT_SERVER_PORT = 24441;
|
|
our $DEFAULT_USERNAME = 'anonymous';
|
|
our $DEFAULT_PASSWORD = '';
|
|
our $DEFAULT_OP_SPEC = '20,3,60,3';
|
|
our $PYZOR_PROTOCOL_VERSION = 2.1;
|
|
our $DEFAULT_TIMEOUT = 3.5;
|
|
our $READ_SIZE = 8192;
|
|
|
|
use IO::Socket::INET ();
|
|
use Digest::SHA qw(sha1 sha1_hex);
|
|
use Mail::SpamAssassin::Util qw(untaint_var);
|
|
|
|
my @hash_order = ( 'Op', 'Op-Digest', 'Op-Spec', 'Thread', 'PV', 'User', 'Time', 'Sig' );
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
=head1 CONSTRUCTOR
|
|
|
|
=head2 new(%OPTS)
|
|
|
|
Create a new pyzor client.
|
|
|
|
=over 2
|
|
|
|
=item Input
|
|
|
|
%OPTS are (all optional):
|
|
|
|
=over 3
|
|
|
|
=item * C<server_host> - The pyzor server host to connect to (default is
|
|
C<public.pyzor.org>)
|
|
|
|
=item * C<server_port> - The pyzor server port to connect to (default is
|
|
24441)
|
|
|
|
=item * C<username> - The username to present to the pyzor server (default
|
|
is C<anonymous>)
|
|
|
|
=item * C<password> - The password to present to the pyzor server (default
|
|
is empty)
|
|
|
|
=item * C<timeout> - The maximum time, in seconds, to wait for a response
|
|
from the pyzor server (defeault is 3.5)
|
|
|
|
=back
|
|
|
|
=item Output
|
|
|
|
=over 3
|
|
|
|
Returns a L<Mail::SpamAssassin::Pyzor::Client> object.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub new {
|
|
my ( $class, %OPTS ) = @_;
|
|
|
|
$OPTS{'server_host'} = untaint_var($OPTS{'server_host'});
|
|
$OPTS{'server_port'} = untaint_var($OPTS{'server_port'});
|
|
$OPTS{'username'} = untaint_var($OPTS{'username'});
|
|
$OPTS{'password'} = untaint_var($OPTS{'password'});
|
|
$OPTS{'timeout'} = untaint_var($OPTS{'timeout'});
|
|
|
|
return bless {
|
|
'server_host' => $OPTS{'server_host'} || $DEFAULT_SERVER_HOST,
|
|
'server_port' => $OPTS{'server_port'} || $DEFAULT_SERVER_PORT,
|
|
'username' => $OPTS{'username'} || $DEFAULT_USERNAME,
|
|
'password' => $OPTS{'password'} || $DEFAULT_PASSWORD,
|
|
'op_spec' => $DEFAULT_OP_SPEC,
|
|
'timeout' => $OPTS{'timeout'} || $DEFAULT_TIMEOUT,
|
|
}, $class;
|
|
}
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
=head1 REQUEST METHODS
|
|
|
|
=head2 report($digest)
|
|
|
|
Report the digest of a spam message to the pyzor server. This function
|
|
will throw if a messaging failure or timeout happens.
|
|
|
|
=over 2
|
|
|
|
=item Input
|
|
|
|
=over 3
|
|
|
|
=item $digest C<SCALAR>
|
|
|
|
The message digest to report, as given by
|
|
C<Mail::SpamAssassin::Pyzor::Digest::get()>.
|
|
|
|
=back
|
|
|
|
=item Output
|
|
|
|
=over 3
|
|
|
|
=item C<HASHREF>
|
|
|
|
Returns a hashref of the standard attributes noted above.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub report {
|
|
my ( $self, $digest ) = @_;
|
|
|
|
my $msg_ref = $self->_get_base_msg( 'report', $digest );
|
|
|
|
$msg_ref->{'Op-Spec'} = $self->{'op_spec'};
|
|
|
|
return $self->_send_receive_msg($msg_ref);
|
|
}
|
|
|
|
=head2 check($digest)
|
|
|
|
Check the digest of a message to see if
|
|
the pyzor server has a report for it. This function
|
|
will throw if a messaging failure or timeout happens.
|
|
|
|
=over 2
|
|
|
|
=item Input
|
|
|
|
=over 3
|
|
|
|
=item $digest C<SCALAR>
|
|
|
|
The message digest to check, as given by
|
|
C<Mail::SpamAssassin::Pyzor::Digest::get()>.
|
|
|
|
=back
|
|
|
|
=item Output
|
|
|
|
=over 3
|
|
|
|
=item C<HASHREF>
|
|
|
|
Returns a hashref of the standard attributes noted above
|
|
as well as the following:
|
|
|
|
=over
|
|
|
|
=item * C<Count> - The number of reports the server has received
|
|
for the given digest.
|
|
|
|
=item * C<WL-Count> - The number of whitelist requests the server has received
|
|
for the given digest.
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=back
|
|
|
|
=cut
|
|
|
|
sub check {
|
|
my ( $self, $digest ) = @_;
|
|
|
|
return $self->_send_receive_msg( $self->_get_base_msg( 'check', $digest ) );
|
|
}
|
|
|
|
# ----------------------------------------
|
|
|
|
sub _send_receive_msg {
|
|
my ( $self, $msg_ref ) = @_;
|
|
|
|
my $thread_id = $msg_ref->{'Thread'} or warn 'No thread ID?';
|
|
|
|
$self->_sign_msg($msg_ref);
|
|
|
|
return $self->_do_send_receive(
|
|
$self->_generate_packet_from_message($msg_ref) . "\n\n",
|
|
$thread_id,
|
|
);
|
|
}
|
|
|
|
sub _get_base_msg {
|
|
my ( $self, $op, $digest ) = @_;
|
|
|
|
die "Implementor error: op is required" if !$op;
|
|
die "error: digest is required" if !$digest;
|
|
|
|
return {
|
|
'User' => $self->{'_username'},
|
|
'PV' => $PYZOR_PROTOCOL_VERSION,
|
|
'Time' => time(),
|
|
'Op' => $op,
|
|
'Op-Digest' => $digest,
|
|
'Thread' => $self->_generate_thread_id()
|
|
};
|
|
}
|
|
|
|
sub _do_send_receive {
|
|
my ( $self, $packet, $thread_id ) = @_;
|
|
|
|
my $sock = $self->_get_connection_or_die();
|
|
|
|
$self->_send_packet( $sock, $packet );
|
|
my $response = $self->_receive_packet( $sock, $thread_id );
|
|
|
|
return 0 if not defined $response;
|
|
|
|
my $resp_hr = { map { ( split(m{: }) )[ 0, 1 ] } split( m{\n}, $response ) };
|
|
|
|
delete $resp_hr->{'Thread'};
|
|
|
|
my $response_pv = delete $resp_hr->{'PV'};
|
|
|
|
if ( $PYZOR_PROTOCOL_VERSION ne $response_pv ) {
|
|
warn "Unexpected protocol version ($response_pv) in Pyzor response!";
|
|
}
|
|
|
|
return $resp_hr;
|
|
}
|
|
|
|
sub _receive_packet {
|
|
my ( $self, $sock, $thread_id ) = @_;
|
|
|
|
my $timeout = $self->{'timeout'} * 1000;
|
|
|
|
my $end_time = time + $self->{'timeout'};
|
|
|
|
$sock->blocking(0);
|
|
my $response = '';
|
|
my $rout = '';
|
|
my $rin = '';
|
|
vec( $rin, fileno($sock), 1 ) = 1;
|
|
|
|
while (1) {
|
|
my $time_left = $end_time - time;
|
|
|
|
if ( $time_left <= 0 ) {
|
|
warn("Did not receive a response from the pyzor server $self->{'server_host'}:$self->{'server_port'} for $self->{'timeout'} seconds!");
|
|
return;
|
|
}
|
|
|
|
my $bytes = sysread( $sock, $response, $READ_SIZE, length $response );
|
|
if ( !defined($bytes) && !$!{'EAGAIN'} && !$!{'EWOULDBLOCK'} ) {
|
|
warn "read from socket: $!";
|
|
}
|
|
|
|
if ( index( $response, "\n\n" ) > -1 ) {
|
|
|
|
# Reject the response unless its thread ID matches what we sent.
|
|
# This prevents confusion among concurrent Pyzor requests.
|
|
if ( index( $response, "\nThread: $thread_id\n" ) != -1 ) {
|
|
last;
|
|
}
|
|
else {
|
|
$response = '';
|
|
}
|
|
}
|
|
|
|
my $found = select( $rout = $rin, undef, undef, $time_left );
|
|
warn "select(): $!" if $found == -1;
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
sub _send_packet {
|
|
my ( $self, $sock, $packet ) = @_;
|
|
|
|
$sock->blocking(1);
|
|
syswrite( $sock, $packet ) or warn "write to socket: $!";
|
|
|
|
return;
|
|
}
|
|
|
|
sub _get_connection_or_die {
|
|
my ($self) = @_;
|
|
|
|
# clear the socket
|
|
undef $self->{'_sock_pid'};
|
|
undef $self->{'_sock'};
|
|
|
|
$self->{'_sock_pid'} ||= $$;
|
|
$self->{'_sock'} ||= IO::Socket::INET->new(
|
|
'PeerHost' => $self->{'server_host'},
|
|
'PeerPort' => $self->{'server_port'},
|
|
'Proto' => 'udp'
|
|
) or die "Cannot connect to $self->{'server_host'}:$self->{'server_port'}: $@ $!";
|
|
return $self->{'_sock'};
|
|
}
|
|
|
|
sub _sign_msg {
|
|
my ( $self, $msg_ref ) = @_;
|
|
|
|
$msg_ref->{'Sig'} = lc Digest::SHA::sha1_hex(
|
|
Digest::SHA::sha1( $self->_generate_packet_from_message($msg_ref) )
|
|
);
|
|
|
|
return 1;
|
|
}
|
|
|
|
sub _generate_packet_from_message {
|
|
my ( $self, $msg_ref ) = @_;
|
|
|
|
return join( "\n", map { "$_: $msg_ref->{$_}" } grep { length $msg_ref->{$_} } @hash_order );
|
|
}
|
|
|
|
sub _generate_thread_id {
|
|
my $RAND_MAX = 2**16;
|
|
my $val = 0;
|
|
$val = int rand($RAND_MAX) while $val < 1024;
|
|
return $val;
|
|
}
|
|
|
|
sub _get_user_pass_hash_key {
|
|
my ($self) = @_;
|
|
|
|
return lc Digest::SHA::sha1_hex( $self->{'username'} . ':' . $self->{'password'} );
|
|
}
|
|
|
|
1;
|