#!/usr/bin/perl -sw
##
## Razor2::Client::Agent -- UI routines for razor agents.
##
## Copyright (c) 2002, Vipul Ved Prakash.  All rights reserved.
## This code is free software; you can redistribute it and/or modify
## it under the same terms as Perl itself.
##
## $Id: Agent.pm,v 1.98 2006/10/18 06:15:08 rsoderberg Exp $

package Razor2::Client::Agent;

use strict;
use warnings;
use Getopt::Long;
use IO::File;

use Razor2::String qw(fisher_yates_shuffle);

use parent qw(Razor2::Client::Core);
use parent qw(Razor2::Client::Config);
use parent qw(Razor2::Logger);
use parent qw(Razor2::String);
use Razor2::Preproc::Manager;

our $PROTOCOL = $Razor2::Client::Version::PROTOCOL;
our $VERSION  = '2.88';

sub new {
    my ( $class, $breed ) = @_;

    # For Taint Friendliness
    delete $ENV{PATH};
    delete $ENV{BASH_ENV};

    my @valid_program_names = qw(
      razor-check
      razor-report
      razor-revoke
      razor-admin
    );

    my $ok = 0;
    foreach (@valid_program_names) { $breed =~ /$_$/ and $ok = $_; }
    unless ($ok) {
        if ( $breed =~ /razor-client$/ ) {

            # We no longer create symlinks, but for backwards compatibility
            # return success.
            exit 0;
        }
        die "Invalid program name, must be one of: @valid_program_names\n";
    }

    $ok =~ /razor-(.*)$/;
    my %me = (
        name_version     => "Razor-Agents v$VERSION",                                # used in register
        breed            => $1,
        preproc          => new Razor2::Preproc::Manager( no_deHTMLcomment => 1 ),
        preproc_vr8      => new Razor2::Preproc::Manager( no_deHTML => 1 ),
        global_razorhome => '/etc/razor',
    );

    return bless \%me, $class;
}

sub do_conf {
    my $self = shift;

    # parse config-related cmd-line args
    #

    # identity is parsed later after razorhome is fully resolved

    if ( $self->{opt}->{config} ) {
        if ( $self->{opt}->{create_conf} ) {
            $self->{razorconf} = $self->{opt}->{config};
        }
        elsif ( -r $self->{opt}->{config} ) {
            $self->{razorconf} = $self->{opt}->{config};
        }
        else {
            return $self->error("Can't read conf file: $self->{opt}->{config}");
        }
    }
    if ( $self->{opt}->{razorhome} ) {
        if ( -d $self->{opt}->{razorhome} ) {
            $self->{razorhome} = $self->{opt}->{razorhome};
        }
        else {
            return $self->error("Can't read: $self->{opt}->{razorhome}")
              unless $self->{opt}->{create_conf};
        }

        # once razorhome is successfully overridden, override the global razorhome as well.
        $self->{global_razorhome} = $self->{razorhome};
    }
    return unless $self->read_conf();

    if ( $self->{opt}->{create_conf} ) {
        $self->{force_discovery}           = 1;
        $self->{force_bootstrap_discovery} = 1;
        $self->log( 8, " -create will force complete discovery" );
    }
    if ( $self->{opt}->{force_discovery} ) {
        $self->{force_discovery}           = 1;
        $self->{force_bootstrap_discovery} = 1;
        $self->log( 8, " -discover will force complete discovery" );
    }
    if ( $self->{opt}->{debug} && !$self->{opt}->{debuglevel} ) {
        $self->{conf}->{debuglevel} ||= 9;
        $self->{conf}->{debuglevel} = 9 if $self->{conf}->{debuglevel} < 9;
    }

    #
    # Note: we start logging before we process '-create' ,
    # so logfile will not go into a newly created razorhome
    #
    #my $logto = $self->{opt}->{debug} ? "stdout" : "file:$self->{conf}->{logfile}";
    my $logto;
    if ( $self->{opt}->{debug} ) {
        $logto = 'stdout';
    }
    elsif ( $self->{conf}->{logfile} eq 'syslog' ) {
        $logto = 'syslog';
    }
    elsif ( $self->{conf}->{logfile} eq 'sys-syslog' ) {
        $logto = 'sys-syslog';
    }
    elsif ( $self->{conf}->{logfile} eq 'none' ) {
        $logto = 'none';
    }
    elsif ( $self->{conf}->{logfile} eq 'stdout' ) {
        $logto = 'stdout';
    }
    elsif ( $self->{conf}->{logfile} eq 'stderr' ) {
        $logto = 'stderr';
    }
    else {
        $logto = "file:$self->{conf}->{logfile}";
    }
    if ( exists $self->{conf}->{logfile} ) {
        my $debuglevel = exists $self->{conf}->{debuglevel} ? $self->{conf}->{debuglevel} : 9;
        my $logger = Razor2::Logger->new(
            LogDebugLevel => $debuglevel,
            LogTo         => $logto,
            LogPrefix     => $self->{breed},
            LogTimestamp  => 1,
            DontDie       => 1,
            Log2FileDir   => defined( $self->{conf}->{tmp_dir} ) ? $self->{conf}->{tmp_dir} : "/tmp",
        );
        $self->{logref} = ref($logger) ? $logger : 0;

        # log error strings at loglevel 11.  Pick a high number 'cuz
        # if its really an error, it will be in errstr for caller
        $self->{logerrors} = 11;
    }
    $self->logobj( 15, "cmd-line options", $self->{opt} );
    $self->{preproc}->{rm}->{log} = $self->{logref};

    # creates razorhome, and sets $self->{razorhome} if successful
    return $self->errprefix("Could not create 'razorhome'") unless $self->create_home_conf();
    $self->compute_identity;

    $self->log( 5, "computed razorhome=$self->{razorhome}, conf=$self->{razorconf}, ident=$self->{identity}" );
    return 1;
}

# if a debug log statement requires extra work, check this call before doing it.
sub logll {
    my ( $self, $loglevel ) = @_;
    return unless $self->{logref};
    return 1 if ( $self->{logref}->{LogDebugLevel} >= $loglevel );
    return;
}

sub create_home_conf {
    my $self = shift;

    unless ( $self->{opt}->{create_conf} ) {
        #
        # if the global razorhome exists, don't create anything
        # without '-create' option
        #
        return 1 if ( -d $self->{global_razorhome} );

        #
        # if there is not global razorhome,
        # try to create razorhome one anyway.
        # if it fails, thats ok.
        #
        $self->create_home( $self->{razorhome_computed} );
        $self->errstrrst;    # nuke error string
        return 1;
    }

    #
    # user passed in 'create' option, so create.
    #
    my $rhome =
        $self->{opt}->{razorhome}
      ? $self->{opt}->{razorhome}
      : $self->{razorhome_computed};

    if ($rhome) {

        if ( -d $rhome ) {
            $self->log( 6, "Not creating razorhome $rhome, already exists" );
        }
        else {
            return unless $self->create_home($rhome);
        }
    }

    if ( $self->{opt}->{config} ) {

        # if create and conf specified, exit if write is not successful
        #
        $self->{razorconf} = $self->{opt}->{config};
        return $self->write_conf();

    }
    else {

        # else just try and create, if fail ok.
        #
        $self->compute_razorconf();
        $self->{razorconf} ||= $self->{computed_razorconf};
        $self->write_conf();
        $self->errstrrst;    # nuke error string
    }
    return 1;
}

# wrapper for log
sub log {
    my $self  = shift;
    my $level = shift;
    my $msg   = shift;

    if ( $self->{logref} ) {
        return $self->{logref}->log( $level, $msg );
    }
    elsif ( $self->{opt}->{debug} ) {
        print " Razor-Log: $msg\n" if $self->{opt}->{debug};
    }
}

sub log2file {
    my $self = shift;
    return unless $self->{logref};
    return $self->{logref}->log2file(@_);
}

sub doit {
    my $self = shift;
    my $args = shift;
    my $r;

    $self->log( 2, " $self->{name_version} starting razor-$self->{breed} $self->{args}" );

    #    $self->log(9,"uname -a: ". `uname -a`) if $self->logll(9);

    $r = $self->checkit($args)  if $self->{breed} eq 'check';
    $r = $self->adminit($args)  if $self->{breed} eq 'admin';
    $r = $self->reportit($args) if $self->{breed} eq 'report';
    $r = $self->reportit($args) if $self->{breed} eq 'revoke';

    # return exit code
    # 0, 1 => ok
    #  > 1 => error  (caller should prolly print $self->errstr)
    #
    if ( $r > 1 ) {
        my $msg = $self->errstr;
        $self->log( 1, "razor-$self->{breed} error: " . $msg );
    }
    else {
        $self->log( 8, "razor-$self->{breed} finished successfully." );
    }
    return $r;
}

sub _help {
    my ( $self, $breed ) = @_;

    chomp( my $all = <<EOFALL);
            -h  Print this usage message.
            -v  Print version number and exit
            -d  Turn on debugging.  Logs to stdout.
            -s  Simulate Only.  Does not connect to server.
    -conf=file  Use this config file instead of <razorhome>/razor.conf
     -home=dir  Use this as razorhome
   -ident=file  Use this identity file instead of <razorhome>/identity
           -rs  Use this razor server instead of reading .lst
EOFALL
    chomp( my $sigs = <<EOFSIGS);
            -H  Compute and print signature.
   -S |  --sig  Accept a signatures to check on the command line
        -e eng  Engine used to compute sig, integer
      -ep4 val  String value required when engine == 4
EOFSIGS

    chomp( my $mbox = <<EOFMBOX);
   -M | --mbox  Accept a mailbox name on the command line (default)
                If no filename, mbox, or signatures, input read from stdin.
EOFMBOX

    my %b;
    $b{check} = <<EOFCHECK;

razor-check [options] [ filename | -M mbox | -S signatures | < filename ]
$all
$sigs
$mbox

See razor-check(1) manpage for details.

EOFCHECK

    $b{report} = <<EOFREPORT;

razor-report [options] [ filename | -M mbox | -S signatures -e engine]
$all
$sigs
$mbox
       -i file  Use identity from this file
            -f  Stay in foreground.
            -a  Authenticate only.  Exit 0 if authenticated, 1 if not
                Stays in foreground.

See razor-report(1) manpage for details.

EOFREPORT

    $b{admin} = <<EOFREGISTER;

razor-admin [options] [ -register | -create | -discover ]
$all
       -create  Create razorhome, does discover, does not register
     -discover  Discover Razor servers: write .lst files
     -register  Register a new identity
    -user name  Request 'name' when registering (requires -register)
    -pass pass  Request 'password' when registering (requires -register)
            -l  Make new identity the the default identity.
                Used only when registering.

See razor-admin(1) manpage for details.

EOFREGISTER

    $b{revoke} = <<EOFREVOKE;

razor-revoke [options] filename
$all
$mbox
       -i file  Use identity from this file
            -f  Stay in foreground.
            -a  Authenticate only.  exit 0 if authenticated, 1 if not
                Stays in foreground.

See razor-revoke(1) manpage for details.

EOFREVOKE

    my $future = <<EOFFUTURE;
EOFFUTURE

    return $b{ $self->{breed} };
}

# maybe this should be in Client::Config
#
sub read_options {
    my ( $self, $agent ) = @_;
    $self->{args} = join ' ', @ARGV;
    Getopt::Long::Configure("no_ignore_case");
    my %opt;
    #
    # These options override what is loaded in config file
    # the names on the right should match keys in config file
    #
    my $ret = GetOptions(
        's'            => \$opt{simulate},
        'd'            => \$opt{debug},
        'verbose'      => \$opt{debug},
        'v'            => \$opt{version},
        'h'            => \$opt{usage},
        'help'         => \$opt{usage},
        'H'            => \$opt{printhash},
        'C=s'          => \$opt{printcleaned},
        'sig=s'        => \$opt{sig},
        'S=s'          => \$opt{sig},
        'e=s'          => \$opt{sigengine},
        'ep4=s'        => \$opt{sigep4},
        'mbox'         => \$opt{mbox},
        'M'            => \$opt{mbox},
        'n'            => \$opt{negative},
        'conf=s'       => \$opt{config},
        'config=s'     => \$opt{config},
        'home=s'       => \$opt{razorhome},
        'f'            => \$opt{foreground},
        'noml'         => \$opt{noml},
        'user=s'       => \$opt{user},
        'u=s'          => \$opt{user},
        'pass=s'       => \$opt{pass},
        'a'            => \$opt{authen_only},
        'rs=s'         => \$opt{server},
        'server=s'     => \$opt{server},
        'r'            => \$opt{register},
        'register'     => \$opt{register},
        'l'            => \$opt{symlink},
        'i=s'          => \$opt{identity},
        'ident=s'      => \$opt{identity},
        'create'       => \$opt{create_conf},
        'logfile=s'    => \$opt{logfile},
        'discover'     => \$opt{force_discovery},
        'dl=s'         => \$opt{debuglevel},
        'debuglevel=s' => \$opt{debuglevel},
        'whitelist=s'  => \$opt{whitelist},
        'lm=s'         => \$opt{logic_method},
        'le=s'         => \$opt{logic_engines},
    );

    if ( $ret == 0 ) {
        $self->error("failed to parse command line options.\n");
        return;
    }

    # remove elements not set in the cmd-line
    foreach ( keys %opt ) { delete $opt{$_} unless defined $opt{$_}; }

    if ( $opt{usage} ) {
        $self->error( $self->_help );
        return;
    }
    elsif ( $opt{mbox} && $opt{sig} ) {
        $self->error("--mbox and --sig are mutually exclusive.\n");
        return;
    }
    elsif ( $opt{sig} && !$opt{sigengine} ) {
        $self->error("--sig requires -e (engine used to generate sig)\n");
        return;
        #
        # fixme - require ep4 if -e 4 is used ?
        #
    }
    elsif ( $opt{version} ) {
        $self->error("Razor Agents $VERSION, protocol version $PROTOCOL");
        return;
    }
    $self->{opt} = \%opt;
    return 1;
}

# returns 0 if match (spam)
# returns 1 if no match (legit)
# returns 2 if error
sub checkit {

    my $self = shift;
    my $args = shift;

    # check for spam.
    # input can be one of
    #   file - single mail
    #   mbox - many  mail
    #   sig  - 1 or more sigs
    #   or a filehandle provided via args

    my $objects;
    if ( $self->{conf}->{sig} ) {
        my @sigs;
        #
        # cmd-line sigs
        #
        # prepare 1 mail object per sig
        #
        foreach my $sig ( split ',', $self->{conf}->{sig} ) {
            $sig =~ s/^\s*//;
            $sig =~ s/\s*$//;
            my $hr = {
                eng => $self->{conf}->{sigengine},
                sig => $sig,
            };
            $hr->{ep4} = "7542-10";
            $hr->{ep4} = $self->{conf}->{sigep4} if $self->{conf}->{sigep4};
            push @sigs, $hr;
        }
        $self->log( 5, "received " . ( scalar @sigs ) . " valid cmd-line sigs." );
        $objects = $self->prepare_objects( \@sigs ) or return 2;
    }
    else {

        my $mails = $self->parse_mbox($args) or return 2;

        $objects = $self->prepare_objects($mails) or return 2;

        #
        # if mail is whitelisted, its not spam.
        # flag it so it we don't check it against server
        #
        foreach my $obj (@$objects) {
            if ( $self->local_check($obj) ) {
                $obj->{skipme} = 1;
                $obj->{spam}   = 0;
            }
            else {
                next;
            }
        }

    }

    # compute_sigs needs server info like ep4, so get_server_info first
    $self->get_server_info() or return 2;
    my $printable_sigs = $self->compute_sigs($objects) or return 2;

    if ( $self->{opt}->{printhash} ) {
        my $i = 0;
        foreach (@$printable_sigs) {
            if ( $self->{opt}->{sigengine} ) {
                next unless (/ e$self->{opt}->{sigengine}: /);
            }
            print "$_\n";
            $i++;
        }
        $self->log( 4, "Done. Printed $i sig(s) for " . scalar(@$objects) . " mail(s)" );
    }
    if ( $self->{opt}->{printcleaned} ) {
        my $totalp = 0;
        my $totalc = 0;
        foreach my $obj (@$objects) {
            my $n = 0;
            mkdir("$self->{opt}->{printcleaned}/cleaned");
            foreach ( $obj->{headers}, @{ $obj->{bodyparts_cleaned} } ) {
                my $fn = "$self->{opt}->{printcleaned}/cleaned/mail$obj->{id}." . $n++;
                $self->write_file( $fn, $_ );
                $totalc++;
            }
            $n = 0;
            mkdir("$self->{opt}->{printcleaned}/uncleaned");
            foreach ( $obj->{headers}, @{ $obj->{bodyparts} } ) {
                my $fn = "$self->{opt}->{printcleaned}/uncleaned/mail$obj->{id}." . $n++;
                $self->write_file( $fn, $_ );
                $totalp++;
            }
        }
        $self->log( 4, "Done. $totalp uncleaned, $totalc cleaned mails saved in $self->{opt}->{printcleaned}" );
        print "Done. $totalp uncleaned, $totalc cleaned mails saved in $self->{opt}->{printcleaned}\n";
        return 1;

    }

    return 1 if $self->{opt}->{printhash};

    # only check good objects
    my @goodones;    # this should be optimized!
    foreach my $obj (@$objects) {
        next if $obj->{skipme};
        push @goodones, $obj;
    }
    unless ( scalar @goodones ) {
        $self->log( 4, "Done.  No valid mail or signatures to check." );
        return 1;
    }

    if ( $self->{conf}->{simulate} ) {
        $self->log( 4, "Done. (simulate only)" );
        return 1;
    }

    #
    # Connect to catalogue server
    #
    $self->{s}->{list} = $self->{s}->{catalogue};
    $self->nextserver();
    $self->connect() or return 2;

    #
    # Check against server
    #
    $self->check( \@goodones ) or return 2;
    $self->disconnect() or return 2;

    #
    # print out responses and exit
    #
    my $only1check = ( scalar(@$objects) == 1 ) ? 1 : 0;
    my $has_spam = 0;
    foreach my $obj (@$objects) {

        $obj->{spam} = 0 if $obj->{skipme};
        $obj->{spam} = 0 unless defined $obj->{spam};

        if ( $obj->{spam} > 0 ) {
            return 0 if $only1check;
            $has_spam = 1;
            print $obj->{id} . "\n";
            next;

        }
        elsif ( $obj->{spam} == 0 ) {
            return 1 if $only1check;
            print "-" . $obj->{id} . "\n" if $self->{conf}->{negative};
            next;

        }
        else {
            # error
            #
            $self->logobj( 1, "bad 'spam' in checkit", $obj );
            return 2 if $only1check;
            print "-" . $obj->{id} . "\n" if $self->{conf}->{negative};
            next;
        }
    }
    return 0 if $has_spam;
    return 1;
}

# returns 0 if success
# returns 2 if error
sub adminit {
    my $self = shift;

    my $done_something = 0;

    if ( $self->{opt}->{create_conf} ) {
        $done_something++;

        # $self->create_home_conf() is always checked
    }

    if (   $self->{opt}->{force_discovery}
        || $self->{opt}->{create_conf} ) {
        $done_something++;

        # get_server_info() calls nextserver() which calls discovery()
        $self->get_server_info() or return 2;
    }

    if ( $self->{opt}->{register} ) {
        $done_something++;
        my $r = $self->registerit();
        return $r if $r;
    }

    unless ($done_something) {
        $self->error("An option needs to be specified,  -h for help.");
        return 2;
    }

    return 0;
}

# returns 0 if success
# returns 2 if error
sub registerit {
    my ( $self, $auto ) = @_;

    unless ( $self->{razorhome} || $self->{opt}->{identity} ) {
        $self->errprefix("Unable to register without a valid razorhome or identity");
        return 2;
    }

    my $ident;

    if (   exists $self->{opt}->{user}
        && ( $ident = $self->get_ident )
        && $ident->{user} eq $self->{opt}->{user} ) {
        $self->error("You are already registered as user=$ident->{user} in $self->{razorhome}");
        return 2;
    }
    if ( $self->{conf}->{simulate} ) {
        $self->log( 5, "Done - simulate only." );
        return 0;
    }

    if ( $self->{opt}->{create_conf} ) {
        $self->log( 3, "Register create successful." );
        return 0;
    }

    if ($auto) {
        $self->log( 3, "Write test underway" );
        my ($ident) = {
            user => 'writetest',
            pass => 'writetest',
        };
        my ($fn);
        unless ( $fn = $self->save_ident($ident) ) {
            $self->log( 3, "Unable to write identity to home" );
            return 2;
        }
        unlink($fn) or return 2;
        $self->log( 3, "Write test completed" );
    }

    $self->get_server_info() or return 2;
    $self->connect()         or return 2;

    $self->log( 3, "Attempting to register." );

    # attempt to register the user/pass
    $ident = $self->register_identity( $self->{opt}->{user}, $self->{opt}->{pass} );

    $self->disconnect() or return 2;

    unless ( ref $ident ) {
        $self->log( 3, "Failed to register identity." );
        return 2;
    }

    if ( my $fn = $self->save_ident($ident) ) {
        my $msg = "Register successful.  Identity stored in $fn";
        $self->log( 3, $msg );
        print "$msg\n";
        return 0;
    }
    else {
        $self->log( 3, "Register failed." );
        return 2;
    }
}

#
# handles report and revoke
#
# returns 0 if success
# returns 2 if error
sub reportit {

    my ( $self, $args ) = @_;

    my $ident = $self->get_ident;
    unless ($ident) {
        $self->log( 3, "Razor2 identity not found.  Attempting to register automatically." );
        if ( $self->registerit("auto") ) {
            $self->log( 3, "Automatic registration failed." );
            $self->errprefix( "Bootstrap Error: Your Razor2 identity was not found.\n   " . "  If you haven't registered, please do so:\n" . "     \"razor-admin -register -user=[name/email_address] -pass=[password]\".\n" . "     (Further information can be found in the razor-admin(1) manpage)\n" . "  If you did register, please ensure your identity symlink (or file) is in order.\n" );
            return 2;
        }
        $ident = $self->get_ident;
        unless ($ident) {
            $self->log( 3, "Unable to load automatically registered identity." );
            $self->errprefix( "Bootstrap Error: Your Razor2 identity was not found.\n   " . "  If you haven't registered, please do so:\n" . "     \"razor-admin -register -user=[name/email_address] -pass=[password]\".\n" . "     (Further information can be found in the razor-admin(1) manpage)\n" . "  If you did register, please ensure your identity symlink (or file) is in order.\n" );
            return 2;
        }
    }

    if ( !$self->{opt}{foreground}
        && ( @ARGV < 1 || $ARGV[0] eq "-" || $ARGV[0] eq "" ) ) {
        if ( -t STDIN ) {
            $self->error( "Unable to read from a TTY using STDIN while forked. \n" . "Doing so leads to undefined behaviour in certain shells." );
            return 2;
        }
    }

    # background myself
    unless ( $self->{opt}->{foreground} ) {
        chdir '/';
        fork && return 0;
        POSIX::setsid;

        # close 0, 1, 2;
    }

    if ( $self->{opt}->{authen_only} ) {
        $self->authenticate($ident) or return;
        $self->log( 5, "Done - authenticate only." );
        return 0 if $self->{authenticated};
        return 2;
    }

    my $mails = $self->parse_mbox($args) or return 2;

    my $objects = $self->prepare_objects($mails) or return 2;

    # compute_sigs needs server info like ep4, so get_server_info first
    $self->get_server_info() or return 2;

    my $printable_sigs = $self->compute_sigs($objects) or return 2;

    if ( $self->{opt}->{printhash} ) {
        foreach (@$printable_sigs) {
            if ( $self->{opt}->{sigengine} ) {
                next unless (/ e$self->{opt}->{sigengine}: /);
            }
            print "$_\n";
        }
        exit 0;
    }

    if ( $self->{conf}->{simulate} ) {
        $self->log( 4, "Done. (simulate only)" );
        exit 0;
    }
    unless ( scalar @$objects ) {
        $self->log( 4, "Done.  No valid mail or signatures to check." );
        exit 1;
    }

    $self->{s}->{list} = $self->{s}->{nomination};
    $self->nextserver();
    $self->connect()            or return 2;
    $self->authenticate($ident) or return 2;
    $self->report($objects)     or return 2;
    $self->disconnect()         or return 2;

    if ( $self->{opt}->{foreground} ) {
        foreach my $obj (@$objects) {

            # my $line = debugobj($obj->{r});
            # $line =~ /(\S+=\S+)/s;  # could be res=0|1, err=xxx
            # print "$obj->{id}: $1\n";
            #print "$obj->{id}\n" if $obj->{r}->{res} == '1';
        }
    }
    return 0;
}

sub parse_mbox {
    my ( $self, $args ) = @_;

    my @mails;
    my @message;
    my $passed_fh = 0;
    my $aref;

    # There are different kinds of mbox formats, we just split on simplest case.
    # djb defines mbox, mboxrd, mboxcl, mboxcl2
    # http://www.qmail.org/qmail-manual-html/man5/mbox.html
    #
    # non-mbox support added, thanx to Aaron Hopkins <aaron@die.net>

    if ( exists $$args{"fh"} ) {
        @ARGV = ();
        push @ARGV, $$args{'fh'};
        $passed_fh = 1;
    }
    elsif ( exists $$args{"aref"} ) {
        $aref = $$args{"aref"};
    }
    elsif ( !scalar @ARGV ) {
        push @ARGV, "-";
    }

    if ( $$args{'aref'} ) {
        my @foo = ( \join '', @{ $$args{'aref'} } );
        return \@foo;
    }

    foreach my $file (@ARGV) {
        my $fh      = new IO::File;
        my @message = ();
        if ( ref $file ) {
            $fh = $file;
        }
        else {
            open $fh, '<', $file or return $self->error("Can't open $file: $!");
        }

        my $line = <$fh>;
        next unless $line;

        if ( $line =~ /^From / ) {
            $self->log( 8, "reading  mbox formatted mail from " . ( $file eq '-' ? "<stdin>" : $file ) );
            while (1) {
                push @message, $line;
                $line = <$fh>;
                if ( !defined($line) || $line =~ /^From / ) {
                    push @mails, \join( '', @message );
                    @message = ();
                    last unless defined $line;
                }
            }
        }
        else {
            $self->log( 8, "reading straight RFC822 mail from " . ( $file eq '-' ? "<stdin>" : $file ) );
            push @mails, \join( '', map { s/^(>*From )/>$1/; $_ } $line, <$fh> );
        }
        close $fh unless $passed_fh;
    }

    my $cnt = scalar @mails;
    $self->log( 6, "read $cnt mail" . ( $cnt > 1 ? 's' : '' ) );

    return \@mails;
}

sub raise_error {
    my ( $self, $errstr ) = @_;
    my $str;
    if ( ref $self ) {
        $str = $self->errstr;
    }
    $str = $errstr if $errstr;
    my ($code) = $str =~ /Razor Error (\d+):/;
    $code = 255 unless $code;
    print "FATAL: $str";
    exit $code;
}

# returns 1 if mail should be skipped
#
sub local_check {
    my ( $self, $obj ) = @_;
    my ( $headers, $body ) = split /\n\r*\n/, ${ $obj->{orig_mail} }, 2;

    $headers =~ s/\n\s+//sg;    # merge multi-line headers

    if ( $self->{conf}->{ignorelist} ) {
        if ( $headers =~ /\n((X-)?List-Id[^\n]+)/i ) {
            my $listid = $1;
            my ($line1) = substr( ${ $obj->{orig_mail} }, 0, 50 ) =~ /^([^\n]+)/;
            $self->log( 5, "Mailing List post; mail " . $obj->{id} . " not spam." );

            #$self->log (5,"Mailing List post; mail ". $obj->{id} ." not spam.\n  $line1\n  $listid");
            return 1;
        }
    }
    return 0 if $self->{no_whitelist};
    if ( -s $self->{conf}->{whitelist} ) {
        $self->read_whitelist;
        foreach my $sh ( keys %{ $self->{whitelist} } ) {
            if ( $sh ne 'sha1' ) {
                while ( $headers =~ /^$sh:\s+(.*)$/img ) {
                    last unless $1;
                    my $fc = $1;
                    $self->log( 13, "whitelist checking headers for match $sh: $fc" );
                    foreach my $address ( @{ $self->{whitelist}->{$sh} } ) {
                        if ( $fc =~ /$address/i ) {
                            $self->log( 3, "ignoring mail $obj->{id}, whitelisted by rule: $sh: $address" );
                            return 1;
                        }
                    }
                }
            }
        }
        $self->log( 12, "Whitelist rules did not match mail $obj->{id}" );
    }
    elsif ( $self->{conf}->{whitelist} ) {
        $self->log( 6, "skipping whitelist file (empty?): $self->{conf}->{whitelist}" );
        $self->{no_whitelist} = 1;
    }
    return 0;
}

sub read_whitelist {
    my ($self) = @_;
    return if $self->{whitelist};

    my %whitelist;
    my $lines = $self->read_file( $self->{conf}->{whitelist}, 0, 1 );
    for (@$lines) {
        s/^\s*//;
        next if /^#/;
        chomp;
        my ( $type, $value ) = split /\s+/, $_, 2;
        $type =~ y/A-Z/a-z/ if $type;
        push @{ $whitelist{$type} }, $value if ( $type && $value );
    }
    $self->{whitelist} = \%whitelist;
    $self->log( 8, "loaded " . scalar( keys %whitelist ) . " different types of whitelist" );

    #$self->logobj (15,"loaded whitelist:", \%whitelist);
    return 1;
}

sub logerr {
    my ( $self, $msg ) = @_;
    $msg = $self->errstr unless $msg;
    $self->log( 1, "$self->{breed} error: " . $msg );
    return;
}

# see nextserver() for explanation of how data is stored
#
sub get_server_info {
    my $self = shift;

    unless ( exists $self->{s} ) { $self->{s} = {}; }

    if ( $self->{opt}->{server} ) {    # cmd-line
        $self->{s}->{list} = [ $self->{opt}->{server} ];
        $self->log( 8, "Using cmd-line server ($self->{opt}->{server}), skipping .lst files" );
    }
    else {
        $self->readservers;
    }
    $self->loadservercache;

    #$self->logobj(6,"find_closest_server server info (before nextserver)", $self->{s});
    $self->{loaded_servers} = 1;
    return $self->nextserver;          # this will connect and get state info if not cached
}

# see nextserver() for explanation of how data is stored
#
sub readservers {
    my $self = shift;

    unless ( exists $self->{s} ) { $self->{s} = {}; }

    # read .lst files
    foreach my $lf (qw(discovery nomination catalogue)) {

        my $h = $self->read_file( $self->{conf}->{"listfile_$lf"}, 0, 1 ) or next;
        $self->{s}->{$lf} = [];
        foreach (@$h) {
            push @{ $self->{s}->{$lf} }, $1
              if /^(([^\.\s]+\.)+[^\.\s]+(:\S+)?)/;
        }
        if ( defined( $self->{s}->{$lf} ) && ref( $self->{s}->{$lf} ) ) {
            $self->log( 11, "Read " . scalar( @{ $self->{s}->{$lf} } ) . " from server listfile: " . $self->{conf}->{"listfile_$lf"} );
        }
    }
    foreach my $lf (qw(discovery nomination catalogue)) {
        next unless defined( $self->{s}->{$lf} );
        next unless ref( $self->{s}->{$lf} );
        next unless @{ $self->{s}->{$lf} } > 1;
        fisher_yates_shuffle( $self->{s}->{$lf} );
    }
    if ( $self->{breed} =~ /^check/ ) {
        $self->{s}->{list}     = $self->{s}->{catalogue};
        $self->{s}->{listfile} = $self->{conf}->{listfile_catalogue};    # for discovery()
    }
    else {
        $self->{s}->{list}     = $self->{s}->{nomination};
        $self->{s}->{listfile} = $self->{conf}->{listfile_nomination};    # for discovery()
    }
}

sub loadservercache {
    my $self = shift;

    #
    # Read in server-specific config, using defaults for stuff not found
    #
    # NOTE: this reads all server.*.conf files in razor home, not just those in .lst
    #

    # load defaults for .lst servers
    foreach (qw(nomination catalogue)) {
        next unless $self->{s}->{$_};
        foreach my $server ( @{ $self->{s}->{$_} } ) {
            next if $self->{s}->{allconfs}->{$server};    # avoid repeats
            $self->{s}->{allconfs}->{$server} = $self->default_server_conf();
            $self->log( 9, "Assigning defaults to $server" );
        }
    }
    my @fns;
    my $sep = '\.';
    $sep = '_' if $^O eq 'VMS';
    if ( opendir my $dh, $self->{razorhome} ) {
        @fns = map { s/_/./g; "$self->{razorhome}/$_"; } grep /^server$sep[\S]+\.conf$/, readdir $dh;
        @fns = map { /^(\S+)$/, $1 } @fns;                # untaint
        closedir $dh;
    }
    foreach (@fns) {
        /server\.(.+)\.conf$/ and my $sn = $1;
        next unless $sn;
        $self->{s}->{allconfs}->{$sn} = $self->read_file( $_, $self->{s}->{allconfs}->{$sn} );
        if ( $self->{s}->{allconfs}->{$sn} ) {

            #$self->log(8,"Loaded server specific conf info for $sn");
        }
        else {
            $self->log( 5, "loadservercache skipping $_" );
        }
    }

    return $self;
}

sub writeservers {
    my $self = shift;

    unless ( $self->{razorhome} ) {
        $self->log( 5, "no razorhome, not caching server info to disk" );
        return;
    }

    foreach ( @{ $self->{s}->{modified_lst} } ) {
        my $fn = $self->{conf}->{"listfile_$_"};
        $self->write_file( $fn, $self->{s}->{$_}, 0, 0, 1 )
          || $self->log( 5, "writeservers skipping .lst file: $fn" );
    }
    $self->log( 11, "No bootstrap_discovery (DNS) recently, not recording .lst files" )
      unless scalar( @{ $self->{s}->{modified_lst} } );
    $self->{s}->{modified_lst} = [];

    foreach ( @{ $self->{s}->{modified} } ) {
        my $fn     = "$self->{razorhome}/server.$_.conf";
        my $header = "#\n# Autogenerated by $self->{name_version}, " . localtime() . "\n";
        $self->write_file( $fn, $self->{s}->{allconfs}->{$_}, 0, $header )
          || $self->debug("writeservers skipping $fn");
    }
    $self->{s}->{modified} = [];
    $self->errstrrst;    # nuke error string if write errors
    return $self;
}

1;

=for markdown [![testsuite](https://github.com/cpan-authors/Razor2-Client-Agent/actions/workflows/testsuite.yml/badge.svg)](https://github.com/cpan-authors/Razor2-Client-Agent/actions/workflows/testsuite.yml)

=head1 NAME

Razor2::Client::Agent - Command-line interface for Vipul's Razor spam detection

=head1 SYNOPSIS

    use Razor2::Client::Agent;

    my $agent = Razor2::Client::Agent->new('razor-check');
    $agent->read_options() or $agent->raise_error;
    $agent->do_conf()      or $agent->raise_error;
    my $rc = $agent->doit({});
    exit $rc;

=head1 DESCRIPTION

Razor2::Client::Agent provides the user interface layer for Vipul's Razor,
a distributed, collaborative spam detection and filtering network.  It
implements the command-line tools B<razor-check>, B<razor-report>,
B<razor-revoke>, and B<razor-admin>.

This module inherits from L<Razor2::Client::Core> (network protocol),
L<Razor2::Client::Config> (configuration management), L<Razor2::Logger>
(logging), and L<Razor2::String> (utility functions).

Typical usage is through the command-line programs rather than calling
this module directly.  See L<razor-check(1)>, L<razor-report(1)>,
L<razor-revoke(1)>, and L<razor-admin(1)>.

=head1 METHODS

=over 4

=item B<new($breed)>

Constructor.  C<$breed> is the full program name, which must end with one
of C<razor-check>, C<razor-report>, C<razor-revoke>, or C<razor-admin>.
The breed determines which operations are available.

Deletes C<$ENV{PATH}> and C<$ENV{BASH_ENV}> for taint safety.

=item B<read_options($agent)>

Parses command-line options via L<Getopt::Long>.  Returns true on success,
false on error (error message available via C<errstr()>).

=item B<do_conf()>

Processes configuration: resolves razorhome, reads the config file, sets up
logging, and creates the home directory if C<-create> was specified.
Must be called after C<read_options()>.

=item B<doit($args)>

Main dispatcher.  Calls the appropriate handler based on the breed:
C<checkit()> for check, C<adminit()> for admin, C<reportit()> for report
and revoke.

Returns 0 for match (spam), 1 for no match (not spam), or 2 for error.

=item B<checkit($args)>

Checks mail against the Razor catalogue servers.  Accepts input as
filenames, mbox files, signatures on the command line, or a filehandle
in C<$args>.

Return values: 0 = spam detected, 1 = not spam, 2 = error.

=item B<reportit($args)>

Reports mail as spam (or revokes a previous report).  Requires a valid
Razor identity; attempts automatic registration if none is found.
Backgrounds itself unless C<-f> (foreground) is specified.

Return values: 0 = success, 2 = error.

=item B<adminit($args)>

Handles administrative tasks: creating razorhome (C<-create>), server
discovery (C<-discover>), and identity registration (C<-register>).

Return values: 0 = success, 2 = error.

=item B<parse_mbox($args)>

Parses input into individual mail messages.  Supports mbox format
(splitting on C<^From > lines), single RFC 822 messages, filehandle
input (via C<< $args->{fh} >>), and array reference input (via
C<< $args->{aref} >>).

Returns an array reference of scalar references to mail content.

=item B<local_check($obj)>

Performs local whitelist and mailing-list checks.  Returns true if the
mail should be skipped (not checked against the server).

=item B<read_whitelist()>

Loads the whitelist file specified in the configuration.  The whitelist
maps header names to patterns; matching mail is skipped.

=item B<get_server_info()>

Reads server lists, loads cached server configurations, and resolves the
next server to connect to.  Called before network operations.

=item B<raise_error($errstr)>

Prints a fatal error message and exits with the Razor error code extracted
from the message, or 255 if no code is found.

=item B<log($level, $msg)>

Logs a message at the given debug level.  Uses the L<Razor2::Logger>
instance if available, otherwise prints to STDOUT in debug mode.

=item B<logll($loglevel)>

Returns true if the current debug level is at or above C<$loglevel>.
Use this to guard expensive log message construction.

=back

=head1 CONFIGURATION

See L<razor-agent.conf(5)> for configuration file format and options.

The razorhome directory (default F<~/.razor/>, system-wide F</etc/razor/>)
stores configuration files, server lists, identity files, and logs.

=head1 SEE ALSO

L<razor-check(1)>, L<razor-report(1)>, L<razor-revoke(1)>,
L<razor-admin(1)>, L<razor-agent.conf(5)>, L<razor-whitelist(5)>,
L<Razor2::Client::Core>, L<Razor2::Client::Config>

=head1 AUTHORS

Vipul Ved Prakash, E<lt>mail@vipul.netE<gt>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut
