Group
Extension

CrowdSec-Client/lib/CrowdSec/Client.pm

package CrowdSec::Client;

use strict;
use Date::Parse;
use HTTP::Request::Common;
use JSON;
use LWP::UserAgent;
use Moo;
use POSIX "strftime";

our $VERSION = '0.04';

# Default alert template
our %DEFAULTS = (
    duration  => '4h',
    labels    => undef,
    origin    => 'CrowdSec::Client',
    reason    => 'Banned by CrowdSec::Client',
    scenario  => 'Banned by CrowdSec::Client',
    simulated => JSON::false,
    type      => 'ban',
);

# Watcher accessors
has machineId => ( is => 'ro' );
has password  => ( is => 'ro' );
has token     => ( is => 'rw' );
has tokenVal  => ( is => 'rw' );
has strictSsl => ( is => 'ro', default => 1, );
has baseUrl   => ( is => 'ro' );

# Bouncer accessors
has apiKey => ( is => 'ro' );

# Common accessors
has userAgent => (
    is      => 'ro',
    default => sub {
        return LWP::UserAgent->new(
            $_[0]->strictSsl
            ? ()
            : (
                ssl_opts => {
                    verify_hostname => 0,
                    SSL_verify_mode => 0
                }
            )
        );
    }
);

has autoLogin => ( is => 'ro' );

has error => ( is => 'rw' );

sub login {
    my ($self) = @_;
    my $error = 0;
    return 1 if $self->tokenIsvalid;
    foreach my $k (qw(machineId password baseUrl)) {
        unless ( $self->{$k} ) {
            $self->error("Missing parameter: $k");
            $error++;
        }
    }
    return if $error;
    my $request = POST(
        $self->baseUrl . '/v1/watchers/login',
        Accept       => 'application/json',
        Content_Type => 'application/json',
        Content      => JSON::to_json(
            {
                machine_id => $self->machineId,
                password   => $self->password,
                scenarios  => [],
            }
        )
    );
    my $response = $self->userAgent->request($request);
    if ( $response->is_success ) {
        eval {
            my $tmp = JSON::from_json( $response->content );
            $self->token( $tmp->{token} );
            $self->tokenVal( str2time( $tmp->{expire} ) );
        };
        if ($@) {
            $self->error("Bad response content from CrowdSec server: $@");
            return;
        }
        if ( !$self->token and !$self->tokenVal ) {
            $self->error(
                "Missing token and expire fields in CrowdSec response: "
                  . $response->content );
        }
        return 1;
    }
    else {
        $self->error(
            "Bad response from CrowdSec server: " . $response->status_line );
        return;
    }
}

sub banIp {
    my ( $self, $ip, %params ) = @_;
    unless ( $ip ) {
        $self->error('Usage: banIp($ip, \%options)');
        return;
    }
    if ( $self->autoLogin ) {
        unless ( $self->login ) {
            return;
        }
    }
    unless ( $self->token ) {
        $self->error("No valid token");
        return;
    }
    my %prm = ( %DEFAULTS, %params );
    $prm{simulated} = $prm{simulated} ? JSON::true : JSON::false;
    my $stamp    = strftime "%Y-%m-%dT%H:%M:%SZ", gmtime(time);
    my $decision = [
        {
            capacity   => 0,
            created_at => $stamp,
            decisions  => [
                {
                    duration => $prm{duration},
                    origin   => $prm{origin},
                    scenario => $prm{scenario},
                    scope    => 'Ip',
                    type     => $prm{type},
                    value    => $ip,
                }
            ],
            events           => [],
            events_count     => 1,
            labels           => $prm{labels},
            leakspeed        => '0',
            message          => $prm{reason},
            scenario         => $prm{reason},
            scenario_hash    => '',
            scenario_version => '',
            simulated        => $prm{simulated},
            source           => {
                ip    => $ip,
                scope => 'Ip',
                value => $ip,
            },
            start_at => $stamp,
            stop_at  => $stamp,
        }
    ];
    my $request = POST(
        $self->baseUrl . '/v1/alerts',
        Authorization => 'Bearer ' . $self->token,
        Content_Type  => 'application/json',
        Content       => JSON::to_json($decision),
    );
    my $response = $self->userAgent->request($request);
    if ( $response->is_success ) {
        my $response_content = $response->content;
        my $res              = eval { JSON::from_json($response_content)->[0] };
        if ($@) {
            $self->error(
                "CrowdSec didn't return an array: " . $response->content );
            return;
        }
        return $res;
    }
    else {
        print "Échec de la requête : " . $response->status_line . "\n";
        return;
    }
}

sub testIp {
    my ( $self, $ip ) = @_;
    my $error = 0;;
    foreach my $k (qw(apiKey baseUrl)) {
        unless ( $self->{$k} ) {
            $self->error("Missing parameter: $k");
            $error++;
        }
    }
    unless ($ip) {
        $self->error('Missing IP');
        $error++;
    }
    return (0, $self->error) if $error;
    my $response = $self->userAgent->get(
        $self->baseUrl . "/v1/decisions?ip=$ip",
        Accept      => 'application/json',
        'X-Api-Key' => $self->apiKey,
    );
    if ( $response->is_success ) {
        my @decisions;
        return 0 if !$response->content or $response->content eq 'null';
        eval {
            my $tmp = JSON::from_json( $response->content );
            @decisions = @$tmp;
        };
        if ($@) {
            $self->error("Bad response content from CrowdSec server: $@");
            return (0, $self->error);
        }
        return 0 unless @decisions;
        foreach my $decision (@decisions) {
            if ( $decision->{type} and $decision->{type} eq 'ban' ) {
                return 1;
            }
        }
        return 0;
    };
}

sub tokenIsvalid {
    my ($self) = @_;
    return ( $self->tokenVal and ( $self->tokenVal > time ) );
}

1;
__END__

=head1 NAME

CrowdSec::Client - CrowdSec client

=head1 SYNOPSIS

  # Bouncer
  use CrowdSec::Client;
  my $client = CrowdSec::Client->new({
    apiKey => "myApiKey",
    baseUrl   => "http://127.0.0.1:8080",
  });
  if ( $client->testIp('1.2.3.4') ) {
    print STDERR "IP banned by crowdsec\n";
  }

  # Watcher
  use CrowdSec::Client;
  my $client = CrowdSec::Client->new({
    machineId => "myid",
    password  => "mypass",
    baseUrl   => "http://127.0.0.1:8080",
    autoLogin => 1;
  });
  $client->banIp('2.3.4.5') or die( $client->error );
  $client->banIp('1.2.3.4', duration => '5h', reason => 'Ban by my app')
    or die( $client->error );

=head1 DESCRIPTION

CrowdSec::Client is a simple CrowdSec Client. It permits one to query Crowdsec
database or to ban an IP.

=head2 Constructor

CrowdSec::Client requires a hashref as argument with the following keys:

=over

=item B<Common parameters>

=over

=item B<baseUrl> I<(required) to ban>: the base URL to connect to local CrowdSec
server. Example: B<http://localhost:8080>.

=item B<userAgent> I<(optional)>: a L<LWP::UserAgent> object. If noone is
given, a new LWP::UserAgent will be created.

=item B<autoLogin>: indicates that CrowdSec::Client has to login automatically
when C<banIp()> is called. Else you should call manually C<login()> method.

=item B<strictSsl>: I<(default: 1)>. If set to 0, and if B<userAgent> isn't
set, the internal LWP::UserAgent will ignore SSL errors.

=back

=item B<Watcher parameters>

=over

=item B<machineId> I<(required)>: the watcher identifier given by Crowdsec
I<(see L</Enrollment>)>.

=item B<password> I<(required)>: the watcher password

=back

=item B<Bouncer parameters>

=over

=item B<apiKey> I<(required)>: the Crowdsec API key

=back

=back

=head2 Methods

=head3 banIp()

banIp adds the given IP into decisions. Usage:

  $client->banIp( $ip, %parameters );

Parameters:

=over

=item B<duration> I<(default: 4h)>: the duration of the decision

=item B<origin> I<(default: "CrowdSec::Client")>

=item B<reason> I<(default: "Banned by CrowdSec::Client")>

=item B<scenario> I<(default: "Banned by CrowdSec::Client"))>

=item B<simulated> I<(default: 0)>: if set to 1, the flag "simulated" is added

=item B<type> I<(default: "ban")>

=back

=head1 Enrollment

=head2 Bouncer

To get a Crowdsec API key, you can use:

  $ sudo cscli bouncers add myBouncerName

=head2 Watcher

To get a Watcher password, you can use:

  $ sudo cscli machines add MyId --password myPassword

=head1 SEE ALSO

L<CrowdSec|https://crowdsec.net/>

=head1 AUTHOR

Xavier Guimard E<lt>xguimard@linagora.muE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2023 by L<Linagora|https://linagora.com>

License: AGPL-3.0 (see LICENSE file)

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.