2012-04-29 10:35:59 +02:00
|
|
|
#!perl -w
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
=head1 NAME
|
|
|
|
|
|
|
|
geoip - provide geographic information about mail senders.
|
|
|
|
|
2012-04-08 02:11:16 +02:00
|
|
|
=head1 SYNOPSIS
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
Use MaxMind's GeoIP databases and the Geo::IP perl module to report geographic
|
|
|
|
information about incoming connections.
|
|
|
|
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
|
|
|
|
This plugin saves geographic information in the following connection notes:
|
|
|
|
|
|
|
|
geoip_country - 2 char country code
|
2012-06-23 02:29:42 +02:00
|
|
|
geoip_country_name - english name of country
|
2012-06-22 11:38:01 +02:00
|
|
|
geoip_continent - 2 char continent code
|
2012-06-23 02:29:42 +02:00
|
|
|
geoip_city - english name of city
|
2012-06-22 11:38:01 +02:00
|
|
|
geoip_distance - distance in kilometers
|
|
|
|
|
|
|
|
And adds entries like this to your logs:
|
|
|
|
|
2012-06-23 02:29:42 +02:00
|
|
|
(connect) ident::geoip: NA, US, United States, 1319 km
|
|
|
|
(connect) ident::geoip: AS, IN, India, 13862 km
|
2012-06-22 11:38:01 +02:00
|
|
|
(connect) ident::geoip: fail: no results
|
2012-06-23 02:29:42 +02:00
|
|
|
(connect) ident::geoip: NA, CA, Canada, 2464 km
|
|
|
|
(connect) ident::geoip: NA, US, United States, 2318 km
|
|
|
|
(connect) ident::geoip: AS, PK, Pakistan, 12578 km
|
|
|
|
(connect) ident::geoip: AS, TJ, Tajikistan, 11965 km
|
|
|
|
(connect) ident::geoip: EU, AT, Austria, 8745 km
|
|
|
|
(connect) ident::geoip: AS, IR, Iran, Islamic Republic of, 12180 km
|
|
|
|
(connect) ident::geoip: EU, BY, Belarus, 9030 km
|
|
|
|
(connect) ident::geoip: AS, CN, China, 11254 km
|
|
|
|
(connect) ident::geoip: NA, PA, Panama, 3163 km
|
2012-06-22 11:38:01 +02:00
|
|
|
|
|
|
|
Calculating the distance has three prerequsites:
|
|
|
|
|
|
|
|
1. The MaxMind city database (free or subscription)
|
|
|
|
2. The Math::Complex perl module
|
|
|
|
3. The IP address of this mail server (see CONFIG)
|
|
|
|
|
|
|
|
Other plugins can utilize the geographic notes to alter the
|
|
|
|
connection, reject, greylist, etc.
|
|
|
|
|
|
|
|
=head1 CONFIG
|
|
|
|
|
|
|
|
The following options can be appended in this plugins config/plugins entry.
|
|
|
|
|
|
|
|
=head2 distance <IP Address>
|
|
|
|
|
|
|
|
Enables geodesic distance calculation. Will calculate the distance "as the
|
|
|
|
crow flies" from the remote mail server. Accepts a single argument, the IP
|
|
|
|
address to calculate the distance from. This will typically be the public
|
|
|
|
IP of your mail server.
|
|
|
|
|
|
|
|
ident/geoip [ distance 192.0.1.5 ]
|
|
|
|
|
|
|
|
Default: none. (no distance calculations)
|
|
|
|
|
2013-03-23 06:03:28 +01:00
|
|
|
=head2 too_far <distance in km>
|
|
|
|
|
|
|
|
Assign negative karma to connections further than this many km.
|
|
|
|
|
|
|
|
Default: none
|
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
=head2 db_dir </path/to/GeoIP>
|
|
|
|
|
|
|
|
The path to the GeoIP database directory.
|
|
|
|
|
|
|
|
ident/geoip [ db_dir /etc/GeoIP ]
|
|
|
|
|
|
|
|
Default: /usr/local/share/GeoIP
|
|
|
|
|
|
|
|
=head1 LIMITATIONS
|
|
|
|
|
|
|
|
The distance calculations are more concerned with being fast than accurate.
|
|
|
|
The MaxMind location data is collected from whois and is of limited accuracy.
|
|
|
|
MaxMind offers more accurate data for a fee.
|
|
|
|
|
|
|
|
For distance calculations, the earth is considered a perfect sphere. In
|
|
|
|
reality, it is not. Accuracy should be within 1%.
|
|
|
|
|
|
|
|
This plugin does not update the GeoIP databases. You may want to.
|
|
|
|
|
|
|
|
=head1 CHANGES
|
|
|
|
|
|
|
|
2012-06 - Matt Simerson - added GeoIP City support, continent, distance
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
2012-05 - Matt Simerson - added geoip_country_name note, added tests
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
=head1 SEE ALSO
|
2012-05-07 09:36:00 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
MaxMind: http://www.maxmind.com/
|
|
|
|
|
|
|
|
Databases: http://geolite.maxmind.com/download/geoip/database
|
|
|
|
|
|
|
|
It may become worth adding support for Geo::IPfree, which uses another
|
|
|
|
data source: http://software77.net/geo-ip/
|
|
|
|
|
|
|
|
=head1 ACKNOWLEDGEMENTS
|
|
|
|
|
|
|
|
Stevan Bajic, the DSPAM author, who suggested SNARE, which describes using
|
|
|
|
geodesic distance to determine spam probability. The research paper on SNARE
|
|
|
|
can be found here:
|
|
|
|
http://smartech.gatech.edu/bitstream/handle/1853/25135/GT-CSE-08-02.pdf
|
2004-08-29 09:47:25 +02:00
|
|
|
|
|
|
|
=cut
|
|
|
|
|
2012-05-22 22:40:39 +02:00
|
|
|
use strict;
|
|
|
|
use warnings;
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-05-22 22:40:39 +02:00
|
|
|
use Qpsmtpd::Constants;
|
2012-06-22 11:38:01 +02:00
|
|
|
#use Geo::IP; # eval'ed in register()
|
|
|
|
#use Math::Trig; # eval'ed in set_distance_gc
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-05-22 22:40:39 +02:00
|
|
|
sub register {
|
2012-06-22 11:38:01 +02:00
|
|
|
my ($self, $qp ) = shift, shift;
|
|
|
|
|
|
|
|
$self->log(LOGERROR, "Bad arguments") if @_ % 2;
|
|
|
|
$self->{_args} = { @_ };
|
|
|
|
$self->{_args}{db_dir} ||= '/usr/local/share/GeoIP';
|
|
|
|
|
2012-05-22 22:40:39 +02:00
|
|
|
eval 'use Geo::IP';
|
|
|
|
if ( $@ ) {
|
|
|
|
warn "could not load Geo::IP";
|
|
|
|
$self->log( LOGERROR, "could not load Geo::IP" );
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
# Note that opening the GeoIP DB only in register has caused problems before:
|
|
|
|
# https://github.com/smtpd/qpsmtpd/commit/29ea9516806e9a8ca6519fcf987dbd684793ebdd#plugins/ident/geoip
|
|
|
|
# Opening the DB anew for every connection is horribly inefficient.
|
|
|
|
# Instead, attempt to reopen upon connect if the DB connection fails.
|
|
|
|
$self->open_geoip_db();
|
|
|
|
|
|
|
|
$self->init_my_country_code();
|
|
|
|
|
2012-05-22 22:40:39 +02:00
|
|
|
$self->register_hook( 'connect', 'connect_handler' );
|
|
|
|
};
|
|
|
|
|
|
|
|
sub connect_handler {
|
|
|
|
my $self = shift;
|
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
# reopen the DB if Geo::IP failed due to DB update
|
|
|
|
$self->open_geoip_db();
|
2012-05-07 09:36:00 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
my $c_code = $self->set_country_code() or do {
|
2012-06-25 08:52:27 +02:00
|
|
|
$self->log( LOGINFO, "skip, no results" );
|
2012-05-22 22:40:39 +02:00
|
|
|
return DECLINED;
|
|
|
|
};
|
2012-06-22 11:38:01 +02:00
|
|
|
$self->qp->connection->notes('geoip_country', $c_code);
|
2012-05-22 22:40:39 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
my $c_name = $self->set_country_name();
|
2012-06-23 02:29:42 +02:00
|
|
|
my ($city, $continent_code, $distance) = '';
|
2012-05-07 09:36:00 +02:00
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
if ( $self->{_my_country_code} ) {
|
|
|
|
$continent_code = $self->set_continent( $c_code );
|
2012-06-23 02:29:42 +02:00
|
|
|
$city = $self->set_city_gc();
|
2012-06-22 11:38:01 +02:00
|
|
|
$distance = $self->set_distance_gc();
|
|
|
|
};
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-06-23 02:29:42 +02:00
|
|
|
my @msg_parts;
|
|
|
|
push @msg_parts, $continent_code if $continent_code && $continent_code ne '--';
|
|
|
|
push @msg_parts, $c_code if $c_code;
|
|
|
|
#push @msg_parts, $c_name if $c_name;
|
|
|
|
push @msg_parts, $city if $city;
|
2013-03-23 06:03:28 +01:00
|
|
|
if ( $distance ) {
|
|
|
|
push @msg_parts, "\t$distance km";
|
|
|
|
if ( $self->{_args}{too_far} && $distance > $self->{_args}{too_far} ) {
|
|
|
|
$self->adjust_karma( -1 );
|
|
|
|
};
|
|
|
|
};
|
2012-06-23 02:29:42 +02:00
|
|
|
$self->log(LOGINFO, join( ", ", @msg_parts) );
|
2004-08-29 09:47:25 +02:00
|
|
|
|
2012-04-22 23:00:53 +02:00
|
|
|
return DECLINED;
|
2004-08-29 09:47:25 +02:00
|
|
|
}
|
2012-06-22 11:38:01 +02:00
|
|
|
|
|
|
|
sub open_geoip_db {
|
|
|
|
my $self = shift;
|
|
|
|
|
|
|
|
# this might detect if the DB connection failed. If not, this is where
|
|
|
|
# to add more code to do it.
|
|
|
|
return if ( defined $self->{_geoip_city} || defined $self->{_geoip} );
|
|
|
|
|
|
|
|
# The methods for using GeoIP work differently for the City vs Country DB
|
|
|
|
# save the handles in different locations
|
|
|
|
my $db_dir = $self->{_args}{db_dir};
|
|
|
|
foreach my $db ( qw/ GeoIPCity GeoLiteCity / ) {
|
|
|
|
if ( -f "$db_dir/$db.dat" ) {
|
|
|
|
$self->log(LOGDEBUG, "using db $db");
|
|
|
|
$self->{_geoip_city} = Geo::IP->open( "$db_dir/$db.dat" );
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
# can't think of a good reason to load country if city data is present
|
|
|
|
if ( ! $self->{_geoip_city} ) {
|
|
|
|
$self->log(LOGDEBUG, "using default db");
|
|
|
|
$self->{_geoip} = Geo::IP->new(); # loads default Country DB
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
sub init_my_country_code {
|
|
|
|
my $self = shift;
|
|
|
|
my $ip = $self->{_args}{distance} or return;
|
|
|
|
$self->{_my_country_code} = $self->get_country_code( $ip );
|
|
|
|
};
|
|
|
|
|
|
|
|
sub set_country_code {
|
|
|
|
my $self = shift;
|
|
|
|
return $self->get_country_code_gc() if $self->{_geoip_city};
|
|
|
|
my $remote_ip = $self->qp->connection->remote_ip;
|
|
|
|
my $code = $self->get_country_code();
|
|
|
|
$self->qp->connection->notes('geoip_country', $code);
|
|
|
|
return $code;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub get_country_code {
|
|
|
|
my $self = shift;
|
|
|
|
my $ip = shift || $self->qp->connection->remote_ip;
|
|
|
|
return $self->get_country_code_gc( $ip ) if $self->{_geoip_city};
|
|
|
|
return $self->{_geoip}->country_code_by_addr( $ip );
|
|
|
|
};
|
|
|
|
|
|
|
|
sub get_country_code_gc {
|
|
|
|
my $self = shift;
|
|
|
|
my $ip = shift || $self->qp->connection->remote_ip;
|
|
|
|
$self->{_geoip_record} = $self->{_geoip_city}->record_by_addr($ip) or return;
|
|
|
|
return $self->{_geoip_record}->country_code;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub set_country_name {
|
|
|
|
my $self = shift;
|
|
|
|
return $self->set_country_name_gc() if $self->{_geoip_city};
|
|
|
|
my $remote_ip = $self->qp->connection->remote_ip;
|
|
|
|
my $name = $self->{_geoip}->country_name_by_addr( $remote_ip ) or return;
|
|
|
|
$self->qp->connection->notes('geoip_country_name', $name);
|
|
|
|
return $name;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub set_country_name_gc {
|
|
|
|
my $self = shift;
|
|
|
|
return if ! $self->{_geoip_record};
|
|
|
|
my $remote_ip = $self->qp->connection->remote_ip;
|
|
|
|
my $name = $self->{_geoip_record}->country_name() or return;
|
|
|
|
$self->qp->connection->notes('geoip_country_name', $name);
|
|
|
|
return $name;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub set_continent {
|
|
|
|
my $self = shift;
|
|
|
|
return $self->set_continent_gc() if $self->{_geoip_city};
|
|
|
|
my $c_code = shift or return;
|
|
|
|
my $continent = $self->{_geoip}->continent_code_by_country_code( $c_code )
|
|
|
|
or return;
|
|
|
|
$self->qp->connection->notes('geoip_continent', $continent);
|
|
|
|
return $continent;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub set_continent_gc {
|
|
|
|
my $self = shift;
|
|
|
|
return if ! $self->{_geoip_record};
|
|
|
|
my $continent = $self->{_geoip_record}->continent_code() or return;
|
|
|
|
$self->qp->connection->notes('geoip_continent', $continent);
|
|
|
|
return $continent;
|
|
|
|
};
|
|
|
|
|
2012-06-23 02:29:42 +02:00
|
|
|
sub set_city_gc {
|
|
|
|
my $self = shift;
|
|
|
|
return if ! $self->{_geoip_record};
|
|
|
|
my $remote_ip = $self->qp->connection->remote_ip;
|
|
|
|
my $city = $self->{_geoip_record}->city() or return;
|
|
|
|
$self->qp->connection->notes('geoip_city', $city);
|
|
|
|
return $city;
|
|
|
|
};
|
|
|
|
|
2012-06-22 11:38:01 +02:00
|
|
|
sub set_distance_gc {
|
|
|
|
my $self = shift;
|
|
|
|
return if ! $self->{_geoip_record};
|
|
|
|
|
|
|
|
my ($self_lat, $self_lon) = $self->get_my_lat_lon() or return;
|
|
|
|
my ($sender_lat, $sender_lon) = $self->get_sender_lat_lon() or return;
|
|
|
|
|
|
|
|
eval 'use Math::Trig qw(great_circle_distance deg2rad)';
|
|
|
|
if ( $@ ) {
|
|
|
|
$self->log( LOGERROR, "can't calculate distance, Math::Trig not installed");
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
|
|
|
|
# Notice the 90 - latitude: phi zero is at the North Pole.
|
|
|
|
sub NESW { deg2rad($_[0]), deg2rad(90 - $_[1]) };
|
|
|
|
my @me = NESW($self_lon, $self_lat );
|
|
|
|
my @sender = NESW($sender_lon, $sender_lat);
|
|
|
|
my $km = great_circle_distance(@me, @sender, 6378);
|
|
|
|
$km = sprintf("%.0f", $km);
|
|
|
|
|
|
|
|
$self->qp->connection->notes('geoip_distance', $km);
|
|
|
|
#$self->log( LOGINFO, "distance $km km");
|
|
|
|
return $km;
|
|
|
|
};
|
|
|
|
|
|
|
|
sub get_my_lat_lon {
|
|
|
|
my $self = shift;
|
|
|
|
return if ! $self->{_geoip_city};
|
|
|
|
|
|
|
|
if ( $self->{_latitude} && $self->{_longitude} ) {
|
|
|
|
return ( $self->{_latitude}, $self->{_longitude} ); # cached
|
|
|
|
};
|
|
|
|
|
|
|
|
my $ip = $self->{_args}{distance} or return;
|
|
|
|
my $record = $self->{_geoip_city}->record_by_addr($ip) or do {
|
|
|
|
$self->log( LOGERROR, "no record for my Geo::IP location");
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
|
|
|
|
$self->{_latitude} = $record->latitude();
|
|
|
|
$self->{_longitude} = $record->longitude();
|
|
|
|
|
|
|
|
if ( ! $self->{_latitude} || ! $self->{_longitude} ) {
|
|
|
|
$self->log( LOGNOTICE, "could not get my lat/lon");
|
|
|
|
};
|
|
|
|
return ( $self->{_latitude}, $self->{_longitude} );
|
|
|
|
};
|
|
|
|
|
|
|
|
sub get_sender_lat_lon {
|
|
|
|
my $self = shift;
|
|
|
|
|
|
|
|
my $lat = $self->{_geoip_record}->latitude();
|
|
|
|
my $lon = $self->{_geoip_record}->longitude();
|
|
|
|
if ( ! $lat || ! $lon ) {
|
|
|
|
$self->log( LOGNOTICE, "could not get sender lat/lon");
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
return ($lat, $lon);
|
|
|
|
};
|
|
|
|
|