#!/usr/bin/perl

##############################################
# iauthd for doing DNSBL lookups, implimented in perl. Can be extended easily to also handle LOC/SASL
# 
# Requirements:
#   You need to install some perl dependancies for this to run.
#
#   Debian/ubuntu/mint:
#     apt-get install libpoe-perl libpoe-component-client-dns-perl libterm-readkey-perl libfile-slurp-perl libtime-duration-perl
#   
#   fedora/redhat/centos:
#     yum install perl-POE perl-POE-Component-Client-DNS perl-TermReadKey perl-slurp perl-Time-Duration
#  
#   freebsd:
#     ports dns/p5-POE-Component-Client-DNS devel/p5-Time-Duration devel/p5-File-Slurp devel/p5-Term-ReadKey
#  
#   or via cpan:
#     cpan install Term::ReadKey POE::Component::Client::DNS File::Slurp Time::Duration
#
# Installation:
#   Copy somewhere convenient
#
# Usage:
#   iauth.pl -f /path/to/config
#
# Configuration:
#
#   * Config directives begin with #IAUTHD and are one per line
#   * Because configuration begins with a #, it can piggy back on existing 
#     ircd.conf file. ircd will ignore it. Handy for those using linesync.
#   * Syntax is: #IAUTHD <directive> <arguments>
# 
# 
# Description of config directives:
#
#     POLICY: 
#        see docs/readme.iauth section on Set Policy Options
# 
#     DNSTIMEOUT:
#          seconds to time out for DNSBL lookups. Default is 5
#
#     DNSBL <key=value [key=value..]>
#        where keys are:
#          server   -  dnsbl server to look up, eg dnsbl.sorbs.net
#          bitmask  -  matches if response is true after being bitwise-and'ed with mask
#          index    -  matches if response is exactly index (comma seperated values ok)
#          class    -  assigns the user to the named class
#          mark     -  marks the user with the given mark
#          block    -  all - blocks connection if matched
#                      anonymous - blocks connection unless LOC/SASL
#          whitelist- listed users wont be blocked by any rbl            
#          cachetime - Override default cache timeout. Useful for whitelists you run yourself
#  
#     DEBUG:      - values greater than 0 turn iauth debugging on in the ircd
#
# Example:

#IAUTH POLICY RTAWUwFr
#IAUTH CACHETIME 86400
#IAUTH BLOCKMSG Sorry! Your connection has been rejected because of your internet address's poor reputation.
#IAUTH DNSBL server=dnsbl.sorbs.net index=2,3,4,5,6,7,9 mark=sorbs block=anonymous
#IAUTH DNSBL server=dnsbl.dronebl.org index=2,3,5,6,7,8,9,10,13,14,15 mark=dronebl block=anonymous
#IAUTH DNSBL server=rbl.efnetrbl.org index=4 mark=tor
#IAUTH DNSBL server=rbl.efnetrbl.org index=1,2,3,5 mark=efnetrbl block=anonymous

#
# ircd.conf:
#
# IAuth {
#    program = "/usr/bin/perl" "iauthd.pl" "-v" "-d" "-c" "ircd.conf";
# };
#
# Debugging:
#  * oper up first
#  * set snomask /quote mode yournick +s 262144
#
########################3

=head1 NAME

iauthd.pl - a perl based iauthd daemon supporting DNSBL lookups

=head1 SYNOPSIS

iauthd.pl [options] --config=configfile.conf

    Options: (short)
    --help    (-h)    Print this message
    --config  (-c)    Config file to read
    --debug   (-d)    Turn on debugging in the ircd
    --verbose (-v)    Turn on debugging in iauthd

=cut

use strict;
use warnings;

use Getopt::Long;
use Pod::Usage;

use POE qw ( Wheel::SocketFactory Wheel::ReadWrite Filter::Line  Driver::SysRW );
use POE::Driver::SysRW;
use POE::Filter::Line;        
use POE::Wheel::ReadWrite;
use POE::Component::Client::DNS;

use Term::ReadKey;
use POSIX;
use File::Slurp;
use Data::Dumper;

use Time::Duration;

my $DEFAULT_CACHETIME = 60 * 60 * 24;

my $STARTTIME = time();

my $VERSION = "7";

my %clients;
my %dnsbl_cache;

my $count_pass = 0;
my $count_reject = 0;

my %dnsbl_counters;

my $poe_heap;


my %options;
GetOptions( \%options, 'help', 'config:s', 'debug', 'verbose') or confess("Error");

pod2usage(1) if ($options{'help'} or !$options{'config'});

my %config = read_configfile($options{'config'});

my $named = POE::Component::Client::DNS->spawn(
    Alias => "named",
    Timeout => ($config{'dnstimeout'} ? $config{'dnstimeout'} : 5)
);

# Create the POE object with callbacks
POE::Session->create (
  inline_states => {
   _start => \&poe_start,
   #_stop  => \&poe_stop,
   myinput_event => \&myinput_event,
   myerror_event => \&myerror_event,
   myint_event => \&myint_event,
   myresponse_event => \&myresponse_event,
  }
);

# Start the event loop
POE::Kernel->run();
exit 0;


#####
#
# Subs
#
#####

sub poe_print {
    my $str = join(' ', @_);
    if($poe_heap) {
        $poe_heap->{stdio}->put($str);
    }
    else {
        print "$str\n";
    }
}

sub debug {
    my $str = join(' ', @_);
    if($options{'debug'}) {
        poe_print("> :$str");
    }
}

sub poe_start {
    my ( $kernel, $heap ) = @_[ KERNEL, HEAP ];
    
    handle_startup();
    
    # Start the terminal reader/writer.
    $heap->{stdio} = POE::Wheel::ReadWrite->new (
        InputHandle => \*STDIN,
        OutputHandle => \*STDOUT,
        InputEvent => "myinput_event",
        Filter => POE::Filter::Line->new(),
        ErrorEvent => "myerror_event",
    );
    $kernel->sig(INT => "myint_event");

    $poe_heap = $heap;
}

sub poe_stop {
    my ( $kernel, $heap ) = @_[ KERNEL, HEAP ];
    poe_print "Doing poe_stop";
    #$kernel->alias_remove($fileio_uuid);
}

sub myinput_event {
    my ( $kernel, $heap, $line ) = @_[ KERNEL, HEAP, ARG0 ];
    #debug("read a line...... '$line'");
    return unless($line);

    my @line = split / /, $line;

    my $source = shift @line;
    my $message = shift @line;
    my $args = join(' ', @line);

    return unless(defined $message);

    # warning, this one can contain passwords...
    #debug("<-- $line");

    #print "Parsed source=$source, message=$message\n";
    if($message eq 'C') { #client introduction: <remoteip> <remoteport> <localip> <localport>
        my ($ip, $port, $serverip, $serverport) = split( / /, $args);

        if(!defined $ip) {
            debug("Got a C without a valid IP. Ignoring");
            return;
        }
        handle_client($kernel, $heap, $source, $ip, $port, $serverip, $serverport);
    }
    elsif($message eq 'D') { #Client disconnect
        debug("Client $source disconnected.");
        if(exists $clients{$source}) {
            client_delete($clients{$source});
        }
    }
    elsif($message eq 'F') { #Client has ssl cert: <fingerprint>
    }
    elsif($message eq 'R') { #Client authed with sasl or loc: <account>
        my $account = $args;
        handle_auth($kernel, $heap, $source, $account);
    }
    elsif($message eq 'N') { #hostname received: <hostname>
    }
    elsif($message eq 'd') { #hostname timed out
    }
    elsif($message eq 'P') { #Client Password: :<password>
    }
    elsif($message eq 'U') { #client username: <username> <hostname> <servername> :<user info ...>
    }
    elsif($message eq 'u') { #client username: <username>
    }
    elsif($message eq 'n') { #client nickname: <nickname>
    }
    elsif($message eq 'H') { #Hurry up: <class>
        handle_hurry($source, $args);
    }
    elsif($message eq 'T') { #Client Registered
    }
    elsif($message eq 'E') { #Error: :<aditional text>
        debug("ircd complaining of error: $args");
    }
    elsif($message eq 'e') { #Error: :<aditional text>
        if($args eq 'rehash') {
            debug("Got a rehash. Rereading config file");
            %config = read_configfile($options{'config'});
        }
    }
    elsif($message eq 'M') { #Server name an dcapacity: <servername> <capacity>
    }
    elsif($message eq 'X') { #extension query reply: <servername> <routing> :<reply>
    }
    elsif($message eq 'x') { #extension query reply not linked: <servername> <routing> :Server not online
    }
    elsif($message eq 'W' || $message eq 'w') { #webirc received from client (or W trusted client): <pass> <user> <host> <ip>
        my ($pass, $user, $host, $ip) = split(/ /, $args);
        debug("Got a W line: $source - pass=<notshown>, user=$user, host=$host, ip=$ip");
        if($message eq 'W') { #untrusted ones are ignored TODO: send a kill? (k)
            debug("Got an untrusted WEBIRC attempt. Ignoring.");
        }
        else {
            handle_webirc($kernel, $heap, $source, $pass, $user, $host, $ip);
        }

    }
    else {
        debug("Got unknown message '$message' from server");
    }
}

sub myerror_event {
    my ( $kernel, $heap ) = @_[ KERNEL, HEAP ];
    debug("Everything either went to hell or we got to the end.  Shutting down...");
    exit 0;
    #delete $heap->{wheels}->{$fileio_uuid};
    $kernel->yield("_stop");
}

sub myresponse_event {
    my ( $kernel, $heap, $response ) = @_[ KERNEL, HEAP, ARG0 ];
    #debug("Got a response ... ");
    my @result;
    if(!defined $response->{response}) {
        debug("got an empty response.. probably a timeout");
    }
    else {
        foreach my $answer ($response->{response}->answer()) {
            debug( 
               "$response->{host} = ",
               $answer->type(), " ", 
               $answer->rdatastr(),
            );
            push @result, $answer->rdatastr();
        }
    }
    handle_dnsbl_response($kernel, $heap, $response->{'host'}, \@result, 0);
}



sub read_configfile {
    my $file = shift;
    my %config;
    my @dnsbls;
    my $cfgnum = 0;
    $config{'dnsbls'} = \@dnsbls;
    $config{'blockmsg'} = "Your internet address has been rejected due to reputation (DNSBL).";
    $config{'cachetime'} = $DEFAULT_CACHETIME;
    debug("Reading $file...");
    send_newconfig();
    poe_print "A * version :Nefarious iauthd.pl $VERSION";
    foreach my $line (read_file($file)) {
        chomp $line;
    	if($line =~ /^\#IAUTH\s(\w+)(\s+(.+))?/) {
	    my $directive = $1;
	    my $args = $3;
            $cfgnum++;
            send_config("$cfgnum: $directive $args");
            #debug("Got a config line: $line");
	    #debug("    directive is $directive");
	    #debug("    arg is $args");
            if($directive eq 'POLICY') {
                $config{'policy'} = $args;
            }
	    elsif($directive eq 'DNSBL') {
	    	my %dnsblconfig;
		foreach my $arg (split /\s+/, $args) {
		    if($arg =~ /(\w+)\=(.+)/) { #key=val pair
                        my $k = $1;
                        my $v = $2;
                        $dnsblconfig{$k} = $v;
                    }
                    else {
                        $dnsblconfig{$arg} = 1;
                    }
		}
                $dnsblconfig{'cfgnum'} = $cfgnum;
                push @dnsbls, \%dnsblconfig;
                $dnsbl_counters{$dnsblconfig{'cfgnum'}} = 0;
	    }
	    elsif($directive eq 'DEBUG') {
	    	$config{'debug'} = 1;
	    }
            elsif($directive eq 'DNSTIMEOUT') {
                $config{'dnstimeout'} = $args;
            }
            elsif($directive eq 'BLOCKMSG') {
                $config{'blockmsg'} = $args;
            }
            elsif($directive eq 'CACHETIME') {
                $config{'cachetime'} = $args;
            }
            else {
                debug("Unknown IAUTH directive '$directive'");
            }
        }
    }
    #print Dumper(\%config);
    return %config;
}

sub handle_startup {
    poe_print "G 1";
    poe_print "V :Nefarious2 iauthd.pl version $VERSION";

    #TODO: send the config version of this..
    poe_print "O RTAWUwFr";

    #print "a\n";
    #print "s\n";
    debug("Starting up");
    send_stats();
}


sub handle_client {
    my ($kernel, $heap, $source, $ip, $port, $serverip, $serverport) = @_;
    debug("Handling client connect: $source from $ip");

    if(exists $clients{$source}){ #existing entry. 
        debug("ERROR: Found existing entry for client $source (ip=$ip). Something got left hanging? Exiting..");
        exit 1;
    }
    #add client to list
    debug("Adding new entry for client $source (ip=$ip)");
    my $client = { id=>$source, 
                   ip=>$ip, 
                   port=>$port, 
                   serverip=>$serverip, 
                   serverport=>$serverport,
                   whitelist=>0, 
                   block=>0, 
                   marks=>{}, 
                   class=>undef, 
                   hurry=>0,
                   lookups=>{},
                 };
    $clients{$source} = $client;

    if($ip =~ /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/) {
        my $pi = join('.', reverse(split(/\./,$ip)));

        foreach my $dnsbl (@{$config{'dnsbls'}}) {
            my $server = $dnsbl->{'server'};

            #Mark the lookup as pending.. (1)
            $client->{'lookups'}->{$dnsbl->{'cfgnum'}} = 1;
            debug("Looking up client $source: $pi.$server");

            #Use global or specific cachetime setting
            my $cachetime = $config{'cachetime'};
            if(exists $dnsbl->{'cachetime'}) {
                $cachetime = $dnsbl->{'cachetime'};
            }

            #purge from the cache if it matches...
            if(    exists $dnsbl_cache{"$pi.$server"} 
                && exists $dnsbl_cache{"$pi.$server"}->{'ts'} 
                && $dnsbl_cache{"$pi.$server"}->{'ts'} < ( time() - $cachetime) ) {
                
                debug("Deleting stale cache entry for $pi.$server");
                delete $dnsbl_cache{"$pi.$server"};
            }

            #Look up in the cache
            if(exists $dnsbl_cache{"$pi.$server"}) { #Found a cache entry
                my $cache_entry = $dnsbl_cache{"$pi.$server"};
                debug("Found dnsbl cache entry for $pi.$server");
                if(defined $cache_entry->{'result'}) { #got a completed lookup in the cache
                    handle_dnsbl_response($kernel, $heap, "$pi.$server", $cache_entry->{'result'}, 1);
                }
                else { #we started looking it up but no reply yet
                    debug("Cache pending... on $pi.$server");
                }
            }
            else { #This lookup is not in the cache yet
                #Adding pending cache entry
                debug("Adding cache entry for pending lookup $pi.$server");
                $dnsbl_cache{"$pi.$server"} = { result=>undef, ts=>time()};

                #Begin a POE lookup on the dnsbl
                my $response = $named->resolve(
                    event => "myresponse_event",
                    host => "$pi.$server",
                    context => { },
                );
                if($response) {
                    $kernel->yield(response => $response);
                }
            }
        } #each dnsbl
    }
    else {
        debug("Unknown IP format: $ip, probably ipv6 or something... ignoring");
    }
}

sub handle_webirc {
    my ($kernel, $heap, $source, $pass, $user, $newhost, $newip) = @_;

    if(exists $clients{$source}) {
        my $client = $clients{$source};

        #Save some values to recreate the client
        my $port = $client->{'port'};
        my $serverip = $client->{'serverip'};
        my $serverport = $client->{'serverport'};
        my $washurry = $client->{'hurry'};

        #Delete the client record, we need to start over
        client_delete($clients{$source});
        #Create a new client and start fresh
        handle_client($kernel, $heap, $source, $newip, $port, $serverip, $serverport);
        if($washurry) {
            $clients{$source}->{'hurry'} = 1;
        }
    }
    else {
        debug("Got a webirc for a client we don't know about? Ignored.");
    }

}

sub handle_auth {
    my ( $kernel, $heap, $source, $account ) = @_;
    my $client = $clients{$source};

    debug("Client authed as $account");
    $client->{'account'} = $account;
    handle_client_update($client);
}

#Got a DNS reply, or found a cached one.
sub handle_dnsbl_response {
    my ( $kernel, $heap, $host, $results, $iscached ) = @_;
    my $lookup_string;
    #Save the answer in the cache.

    $dnsbl_cache{$host} = { result=>$results, ts=>time()} unless($iscached);

    $host =~ /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.(.+)$/;

    my $host_ip = "$4.$3.$2.$1";
    my $dnsbl_server = "$5";
    debug("Got a ". ($iscached?"cache hit":"DNS reply") . " for $host_ip from $dnsbl_server..." . @$results . " replies...");

    #If this result is a hit for any dnsbls, find related clients and mark/block/whitelist etc
    foreach my $ip (@$results) {
        if($ip =~ /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/) {
            my $value = $4;
            #debug("Looking at response value $value from $host");

            foreach my $config_dnsbl (@{$config{'dnsbls'}}) {
                next unless($config_dnsbl->{'server'} eq $dnsbl_server);
                my $flag;
                if(exists $config_dnsbl->{'index'}) {
                    foreach my $index (split(/,/, $config_dnsbl->{'index'})) {
                        if($value eq $index) {
                            $flag++;
                        }
                    }
                }
                if(exists $config_dnsbl->{'bitmask'}) {
                    foreach my $bitmask (split(/,/, $config_dnsbl->{'bitmask'})) {
                        if($bitmask & $value) {
                            $flag++;
                        }
                    }
                }
                if($flag) {
                    #Go through all the client records. Check if this positive dnsbl hit affects them
                    foreach my $client_id (keys %clients) {
                        my $client = $clients{$client_id};
                        if($client->{'ip'} eq $host_ip) {
                            #We found a client in the queue which matches this
                            #dnsbl. Mark them and flag them etc
                            debug("client $client->{id} matches $config_dnsbl->{server} result $value");
                            #$dnsbl_counters{$config_dnsbl->{'cfgnum'}}++;
                            foreach my $field (qw( whitelist block class )) {
                                if($config_dnsbl->{$field}) {
                                    $client->{$field} = $config_dnsbl->{$field};
                                }
                            }
                            if($config_dnsbl->{'mark'}) {
                                $client->{'marks'}->{$config_dnsbl->{'mark'}} = $config_dnsbl;
                            }
                            $client->{'hits'}->{$config_dnsbl->{'cfgnum'}} = 1;
                        } #client matches reply
                    } #each client
                }
                # #each index
            } #each dnsbl

        }
        else {
            debug("Unable to parse dnsbl result: $ip");
        }
    } #foreach @results
    foreach my $client (values %clients) {
        if($client->{'hits'}) {
            foreach my $cfgnum (keys %{$client->{'hits'}}) {
                $dnsbl_counters{$cfgnum}++;
                delete $client->{'hits'};
            }
        }
    }

    #Clear all pending states on all clients with matching ips waiting on any related dnsbls.
    foreach my $dnsbl (@{$config{'dnsbls'}}) {
        if($dnsbl_server eq $dnsbl->{'server'}) {
            foreach my $client (values %clients) {
                if($client->{'ip'} eq $host_ip) {
                    if($client->{'lookups'}->{$dnsbl->{'cfgnum'}}) {
                        $client->{'lookups'}->{$dnsbl->{'cfgnum'}} = 0;
                        handle_client_update($client);
                    }
                }
            }
        }
    }

}

#The client has been updated. Check if its done
sub handle_client_update {
    my $client = shift;
    my $pending = 0;
    foreach my $v (values %{$client->{'lookups'}}) {
        $pending += $v;
    }
    if($client->{'hurry'}) {
        debug("Client $client->{id} has Hurry set and $pending pending requests");
        if($pending < 1) {
            if($client->{'whitelist'}) {
                client_pass($client);
            }
            elsif( ($client->{'block'} eq 'all') 
                   || ($client->{'block'} eq 'anonymous' && !$client->{'account'})) {
                client_reject($client, $config{'blockmsg'});
            }
            else {
                client_pass($client);
            }
        }
    }
    else {
        debug("Client $client->{id} has $pending pending requests");
    }
}

sub handle_hurry {
    my $source = shift;
    my $class = shift;
    my $client = $clients{$source};

    if(!$client) {
        debug("ERROR: Got a hurry for a client we arent even holding on to!");
        return;
    }
    debug("Handling a hurry on $source");

    $client->{'hurry'} = 1;
    handle_client_update($client);
}

sub client_pass {
    my $client = shift;
    debug("Passing client ". $client->{'id'} . ' ('. $client->{'ip'} . ')');
    #print Dumper($client);
    foreach my $mark (keys %{$client->{'marks'}}) {
        send_mark($client->{'id'}, $client->{'ip'}, $client->{'port'}, 'MARK', $mark);
    }
    send_done($client->{'id'}, $client->{'ip'}, $client->{'port'}, $client->{'class'}?$client->{'class'}:undef);
    $count_pass++;
    client_delete($client);
    send_stats();
}

sub client_reject {
    my $client = shift;
    my $reason = shift;
    debug("Rejecting client " . $client->{'id'} . ' ('. $client->{'ip'} . "): $reason");
    send_kill($client->{'id'}, $client->{'ip'}, $client->{'port'}, $reason);
    $count_reject++;
    client_delete($client);
    send_stats();
}

sub client_delete {
    my $client = shift;
    debug("Deleting client from hash tables");
    delete($clients{$client->{'id'}});
}

sub send_mark {
    my $id = shift;
    my $remoteip = shift;
    my $remoteport = shift;
    my $marktype = shift;
    my $markdata = shift;

    return unless($markdata);

    poe_print "m $id $remoteip $remoteport $marktype $markdata";
}

sub send_done {
    my $id = shift;
    my $remoteip = shift;
    my $remoteport = shift;
    my $class = shift;

    if($class) {
        poe_print "D $id $remoteip $remoteport $class";
    }
    else {
        poe_print "D $id $remoteip $remoteport";
    }
}

sub send_kill {
    my $id = shift;
    my $remoteip = shift;
    my $remoteport = shift;
    my $reason = shift;

    poe_print "k $id $remoteip $remoteport :$reason";
}

sub send_newconfig {
    poe_print "a";
}
sub send_config {
    my $config = shift;
    poe_print "A * iauthd.pl :$config";
}

sub send_stats {
    my $up = POSIX::strftime "%a %b %e %H:%M:%S %Y", localtime($STARTTIME);
    my $uptime = duration(time() - $STARTTIME);
    poe_print "s";
    poe_print "S iauthd.pl :Up since $up ($uptime)";
    poe_print "S iauthd.pl :Cache size: ". %dnsbl_cache . "";
    poe_print "S iauthd.pl :Total Passed: $count_pass";
    poe_print "S iauthd.pl :Total Rejected: $count_reject";
    foreach my $config_dnsbl (@{$config{'dnsbls'}}) {
            my $d = $config_dnsbl->{'server'};
            if(exists $config_dnsbl->{'index'}) {
               $d .= " (" . $config_dnsbl->{'index'}. ")";
            }
            if(exists $config_dnsbl->{'bitmask'}) {
               $d .= " (" . $config_dnsbl->{'bitmask'}. ")";
            }
            my $c = 0;
            if( exists $dnsbl_counters{$config_dnsbl->{'cfgnum'}}) {
                $c = $dnsbl_counters{$config_dnsbl->{'cfgnum'}};
            }
            
            poe_print "S iauthd.pl :$d: $c";
    }
}


