The Mailmunge email filtering framework https://www.mailmunge.org/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

257 lines
7.8 KiB

use strict;
use warnings;
package Mailmunge::Test::Rspamd;
use base qw(Mailmunge::Test);
use Mailmunge::Constants;
use IO::Socket::INET;
use JSON::Any;
sub rspamd_check
{
my ($self, $ctx, $host, $port, $timeout) = @_;
$timeout = 300 unless defined $timeout;
my $sock = IO::Socket::INET->new(PeerHost => $host,
PeerPort => $port,
Proto => 'tcp',
Timeout => 5);
if (!$sock) {
return { response => Mailmunge::Response->TEMPFAIL(message => 'Unable to connect to rspamd') };
}
local $SIG{ALRM} = sub { die("Timeout"); };
my $ans;
eval {
alarm($timeout);
$ans = $self->_rspamd_check_aux($ctx, $sock);
};
alarm(0);
$sock->close();
if ($@ =~ /Timeout/) {
return { response => Mailmunge::Response->TEMPFAIL(message => 'Calling rspamd timed out') };
}
if (!$ans) {
return { response => Mailmunge::Response->TEMPFAIL(message => 'Calling rspamd failed: ' . $@) };
}
# If we got back just a Mailmunge::Response, wrap it
if (ref($ans) eq 'Mailmunge::Response') {
return { response => $ans };
}
return $ans;
}
sub _rspamd_check_aux
{
my ($self, $ctx, $sock) = @_;
my $in_fh = $self->filter->inputmsg_fh();
if (!$in_fh) {
return Mailmunge::Response->TEMPFAIL(message => 'Unable to open message file');
}
my $headers = $self->_build_rspamd_request_headers($ctx);
# Send the request
$sock->print($headers);
$sock->print("\r\n");
my $buf;
while(read($in_fh, $buf, 4096)) {
$sock->print($buf);
}
close($in_fh);
$sock->flush();
# Read the results
my $results = $self->_read_rspamd_results($sock);
if (!$results) {
return Mailmunge::Response->TEMPFAIL(message => 'Failed to read results from rspamd');
}
if (!ref($results)) {
# If we get back a scalar, it's an error message
return Mailmunge::Response->TEMPFAIL(message => $results);
}
if ($results->{is_skipped}) {
return { response => Mailmunge::Response->CONTINUE(),
results => $results };
}
if (!$results->{action}) {
return { response => Mailmunge::Response->TEMPFAIL(message => 'rspamd results did not contain an "action" key'),
results => $results };
}
# Build a response based on "action"
my $resp;
# Note that if rspamd recommends a policy, we leave it
# to the caller to decide to implement the policy
if ($results->{action} eq 'greylist'|| $results->{action} eq 'soft reject') {
$resp = Mailmunge::Response->TEMPFAIL(message => 'Please try again later');
} elsif ($results->{action} eq 'reject') {
$resp = Mailmunge::Response->REJECT(message => 'Message rejected due to unacceptable content');
} else {
$resp = Mailmunge::Response->CONTINUE();
}
return { response => $resp, results => $results };
}
sub _build_rspamd_request_headers
{
my ($self, $ctx) = @_;
my $size = -s $self->filter->inputmsg;
my $hdrs =
"POST /checkv2 HTTP/1.0\r\n" .
"Content-Length: $size\r\n" .
"From: " . $ctx->sender . "\r\n" .
"IP: " . $ctx->hostip . "\r\n" .
"Helo: " . $ctx->helo . "\r\n" .
"From: " . $ctx->sender . "\r\n" .
"Queue-Id: " . $ctx->qid . "\r\n";
foreach my $r (@{$ctx->recipients}) {
$hdrs .= "Rcpt: " . $r . "\r\n";
}
return $hdrs;
}
sub _read_rspamd_results
{
my ($self, $sock) = @_;
my $resp = $sock->getline();
$resp =~ s/\s+$//;
if ($resp !~ m|^HTTP/\d+\.\d+ (\d+) (.*)|) {
return "Could not interpret rspamd response: $resp";
}
if ($1 ne '200') {
return "Unsuccessful response from rspamd: $resp";
}
# Read the headers
while($resp = $sock->getline()) {
$resp =~ s/\s+$//;
last if $resp eq '';
if ($resp =~ /^Content-Type:\s*(.*)/i) {
if (lc($1) ne 'application/json') {
return "Expecting application/json response from rspamd; found $1";
}
}
}
my $results;
# Read the JSON blob
local $/;
my $json = <$sock>;
eval {
$results = JSON::Any->jsonToObj($json);
};
if (!$results) {
return "Unable to parse rspamd response as JSON";
}
if (ref($results) ne 'HASH') {
return "Expecting a HASH response from rspamd, found " . (ref($results) || "'$results'");
}
return $results;
}
1;
__END__
=head1 NAME
Mailmunge::Test::Rspamd - run a message through rspamd
=head1 ABSTRACT
This class connects to an L<rspamd|https://www.rspamd.com/> daemon and
passes the input message to rspamd for evaluation.
=head1 SYNOPSIS
package MyFilter;
use Mailmunge::Test::Rspamd;
sub filter_begin {
my ($self, $ctx) = @_;
my $test = Mailmunge::Test::Rspamd->new($self);
my $ans = $test->rspamd_check($ctx, '127.0.0.1', 11333);
my $resp = $ans->{response};
if (!$ans->{results}) {
# Failure of some kind - timeout, rspamd not running, etc.
# Specific error message will be in $ans->{response}->message
return $self->action_tempfail($ctx, $resp->message);
}
# We have rspamd results; you can inspect $ans->{results}
# to decide what action to take, or use the code below to take
# action based on $ans->{respones}; $ans->{response} is a
# Mailmunge::Response object with a suggested response
if ($self->action_from_response($ctx, $resp)) {
# Rspamd suggested an action, which we took
return;
}
# Must be: $resp->is_success so continue with rest of filter
}
=head1 CLASS METHODS
=head2 Mailmunge::Test::Rspamd->new($filter)
Constructs a new Mailmunge::Test::Rspamd object and stores a copy
of $filter in it.
=head1 INSTANCE METHODS
=head2 rspamd_check($ctx, $host, $port [, $timeout])
Connects to the rspamd daemon on the given $host and $port and asks it
to evaluate the current message. $timeout is an overall timeout in
seconds for rspamd to reply; if not supplied, it defaults to 300
seconds.
The return value from C<rspamd_check> is a hash with the following
elements:
=over
=item response
A Mailmunge::Response object with the suggested response to the message.
If something went wrong with rspamd, then the C<response> element will
be the only element in the hash. Its C<status> will be set to
C<TEMPFAIL> and its C<message> will contain an error message.
=item results
If rspamd successfully scanned the message, the C<results> element
will be a hash containing the rspamd response. This data structure
is described in detail at L<https://www.rspamd.com/doc/architecture/protocol.html#rspamd-http-reply>. It is up to the caller of C<rspamd_check>
to inspect the reply from rspamd and call appropriate functions such
as C<action_reject>, etc.
If rspamd did not successfully scan the message, then there will be
no C<results> element.
=back
=head1 SEE ALSO
rspamd at L<https://www.rspamd.com/>
=head1 AUTHOR
Dianne Skoll <dianne@skollsoft.com>
=head1 LICENSE
This code is licensed under the terms of the GNU General Public License,
version 2.