package Net::Firewall::BlockerHelper::backends::linux_ip_route;

use 5.006;
use strict;
use warnings;
use base         qw( Error::Helper Net::Firewall::BlockerHelper::Util );
use Regexp::IPv4 qw($IPv4_re);
use Regexp::IPv6 qw($IPv6_re);

=head1 NAME

Net::Firewall::BlockerHelper::backends::linux_ip_route - Blackhole route backend for Net::Firewall::BlockerHelper.

=head1 VERSION

Version 0.2.0

=cut

our $VERSION = '0.2.0';

=head1 SYNOPSIS

    use Net::Firewall::BlockerHelper;

    my $fw_helper = Net::Firewall::BlockerHelper->new(
            backend => 'linux_ip_route',
            name => 'ssh',
        );

    $fw_helper->init_backend;
    $fw_helper->ban(ban => '1.2.3.4');
    $fw_helper->unban(ban => '1.2.3.4');
    $fw_helper->teardown;

=head1 DESCRIPTION

Blocks whole IPs via null routes using L<ip(8)> from iproute2. Fast for very
large numbers of blocked IPs and requires no firewall at all, making it a
good fallback. The routes are destination based, so what is directly stopped
is traffic headed back to the banned IP, which is enough to keep connections
from completing. For the inbound packets themselves to be dropped as well,
reverse path filtering, rp_filter, needs to be enabled, in which case they
are tossed at the route lookup before entering any firewall chains.

Blocking is per IP and never per service; ports and protocols are not
supported and specifying them is an error.

check verifies the route for each currently banned IP is still present, so
externally flushed routes are noticed and self-heal can re-add them.
teardown deletes the route for each banned IP, tolerating already-removed
routes, and keeps the internal list of bans so re_init can re-add them.

=head1 NOTES

check looks for a ban's route using the type selector, for example
C<ip route show type unreachable 192.0.2.4>. A missing route is reported
by iproute2 as a zero exit with no output, which is what check keys off
of.

CIDR ranges must be passed with the host bits zeroed, such as
C<192.0.2.0/24>. iproute2 rejects prefixes with host bits set, such as
C<192.0.2.4/24>, with "Invalid prefix for given prefix length", which will
show up as a banCidrFailed error.

=head1 METHODS

=head2 new

Initiates the the object.

    - options :: Backend specific options that will be passed to the backend unchecked
            outside of making sure it is a hash ref if defined. See below for furhter info.
        - Default :: {}

    - prefix :: Prefix to use. Must match the regex /^[a-zA-Z0-9]+$/
        - default :: kur

    - name :: Name of this specific instance. This must be specified.
        - default :: undef

Ports and protocols are not supported by this backend and specifying either
is an error.

The options hash accepts the following.

    - blocktype :: The route type to use. 'blackhole' silently drops while
            'unreachable' and 'prohibit' send the corresponding ICMP reject
            back. See ip-route(8).
        - Default :: unreachable

All errors are considered fatal, meaning if new fails it will die.

=cut

sub new {
	my ( $blank, %opts ) = @_;

	my $self = {
		perror        => undef,
		error         => undef,
		errorLine     => undef,
		errorFilename => undef,
		errorString   => "",
		errorExtra    => {
			all_errors_fatal => 1,
			# all_fatal is what Error::Helper 2.1.0 actually checks; all_errors_fatal
			# is kept for the name documented in its POD
			all_fatal        => 1,
			flags            => {
				1  => 'notInited',
				6  => 'invalidPrefixSpecified',
				7  => 'invalidName',
				8  => 'optionsNotHash',
				9  => 'noBanItem',
				10 => 'banItemNotIP',
				12 => 'backendInitError',
				13 => 'banFailed',
				14 => 'unbanFailed',
				15 => 'listFailed',
				16 => 'reInitFailed',
				17 => 'teardownFailed',
				18 => 'alreadyInited',
				20 => 'blocktypeInvalid',
				23 => 'initFailed',
				24 => 'checkFailed',
				25 => 'flushFailed',
				26 => 'portsNotSupported',
				27 => 'protocolsNotSupported',
				28 => 'banCidrFailed',
				29 => 'unbanCidrFailed',
				30 => 'cidrItemNotCidr',
				31 => 'cidrNotSupported',
				32 => 'listCidrFailed',
			},
			fatal_flags      => {},
			perror_not_fatal => 0,
		},
		options => {
			blocktype => 'unreachable',
		},
		ports        => [],
		protocols    => [],
		testing      => undef,
		test_data    => undef,
		prefix       => 'kur',
		frontend_obj => undef,
		inited         => 0,
		banned         => {},
		banned_cidr    => {},
		cidr_supported => 1,
	};
	bless $self;

	# blocking is per IP; ports and protocols can not be expressed via routes
	if ( defined( $opts{ports} ) && ref( $opts{ports} ) eq 'ARRAY' && defined( $opts{ports}[0] ) ) {
		$self->{perror}      = 1;
		$self->{error}       = 26;
		$self->{errorString} = 'the route backend blocks whole IPs and does not support ports';
		$self->warn;
	}
	if ( defined( $opts{protocols} ) && ref( $opts{protocols} ) eq 'ARRAY' && defined( $opts{protocols}[0] ) ) {
		$self->{perror}      = 1;
		$self->{error}       = 27;
		$self->{errorString} = 'the route backend blocks whole IPs and does not support protocols';
		$self->warn;
	}

	# make sure prefix is sane if defiend
	if ( defined( $opts{prefix} ) && $opts{prefix} !~ /^[a-zA-Z0-9]+$/ ) {
		$self->{perror} = 1;
		$self->{error}  = 6;
		$self->{errorString}
			= '"' . $opts{prefix} . '" is not a valid prefix as it does not match the regex /^[a-zA-Z0-9]+$/';
		$self->warn;
	} elsif ( defined( $opts{prefix} ) ) {
		$self->{prefix} = $opts{prefix};
	}

	# make sure we have a name and that it is valid
	if ( !defined( $opts{name} ) ) {
		$self->{perror}      = 1;
		$self->{error}       = 7;
		$self->{errorString} = 'name is undef';
		$self->warn;
	} elsif ( $opts{name} !~ /^[a-zA-Z0-9\-]+$/ ) {
		$self->{perror}      = 1;
		$self->{error}       = 7;
		$self->{errorString} = 'name set to "' . $opts{name} . '" which does not match the regexp  /^[a-zA-Z0-9\-]+$/';
		$self->warn;
	}
	$self->{name} = $opts{name};

	# used internally for testing
	if ( defined( $opts{testing} ) ) {
		$self->{testing} = $opts{testing};
	}
	if ( defined( $opts{frontend_obj} ) ) {
		$self->{frontend_obj} = $opts{frontend_obj};
	}

	if ( defined( $opts{options} ) ) {
		if ( ref( $opts{options} ) ne 'HASH' ) {
			$self->{perror}      = 1;
			$self->{error}       = 8;
			$self->{errorString} = 'ref for options is "' . ref( $opts{options} ) . '" and not HASH';
			$self->warn;
		}
		$self->{options} = $opts{options};

		if ( defined( $opts{options}{blocktype} )
			&& $opts{options}{blocktype} ne 'unreachable'
			&& $opts{options}{blocktype} ne 'blackhole'
			&& $opts{options}{blocktype} ne 'prohibit' )
		{
			$self->{perror} = 1;
			$self->{error}  = 20;
			$self->{errorString}
				= '$opts{options}{blocktype} is "'
				. $opts{options}{blocktype}
				. '" and not "unreachable", "blackhole", or "prohibit"';
			$self->warn;
		} elsif ( !defined( $opts{options}{blocktype} ) ) {
			$self->{options}{blocktype} = 'unreachable';
		}
	} ## end if ( defined( $opts{options} ) )

	return $self;
} ## end sub new

# Internal helper. Returns the iproute2 command for adding, removing, or
# looking up the null route that blocks the passed IP. Every command this
# backend runs comes from here.
#
# The family is chosen per IP rather than per instance, since a single
# instance bans both. IPv4 gets no flag, as that is iproute2's default, and
# IPv6 gets -6.
#
# The route type is placed differently depending on the action, which is the
# one subtlety worth knowing about. For add and del the type is part of the
# route specification and is written bare, immediately before the address. For
# show it is a filter on which routes to list rather than part of a spec, and
# iproute2 only accepts it there behind the literal "type" keyword. Writing it
# bare for show would be a syntax error, and writing "type" for add would be
# too.
#
# Note this only ever builds the command; running it and interpreting the exit
# status is the caller's job.
#
# Args:
#
#     action - The iproute2 route subcommand, as a plain string. One of 'add'
#              to install the null route, 'del' to remove it, or 'show' to
#              look it up, which is what check and list use to see whether a
#              ban is still in place. Anything else is passed through
#              unchecked and would produce an invalid command.
#
#     ip     - The address to route, as a plain string. Expected to be an
#              already validated and lowercased IPv4 or IPv6 address. The
#              family is decided by matching against $IPv4_re, so anything
#              that is not valid IPv4 is treated as IPv6.
#
# Returns the command as a single string ready to hand to the runner. The
# route type comes from the blocktype option, which is 'unreachable' by
# default and may also be 'blackhole' or 'prohibit'; the three differ in what,
# if anything, is sent back to the sender.
#
#     $self->_route_command( 'add', '10.0.0.1' );
#     #   ip route add unreachable 10.0.0.1
#
#     $self->_route_command( 'del', '2001:db8::1' );
#     #   ip -6 route del unreachable 2001:db8::1
#
#     # show puts the type behind the type keyword
#     $self->_route_command( 'show', '10.0.0.1' );
#     #   ip route show type unreachable 10.0.0.1
#
#     # with blocktype set to blackhole
#     $self->_route_command( 'add', '10.0.0.1' );
#     #   ip route add blackhole 10.0.0.1
sub _route_command {
	my ( $self, $action, $ip ) = @_;

	my $family = ( $ip =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
	my $type = ( $action eq 'show' ) ? 'type ' : '';

	return 'ip' . $family . ' route ' . $action . ' ' . $type . $self->{options}{blocktype} . ' ' . $ip;
}

=head2 init

Initiates the backend. Null routes need no setup, so no commands are run.

    $backend->init;

=cut

sub init {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( $self->{inited} ) {
		$self->{error}       = 18;
		$self->{errorString} = 'backend has already been inited';
		$self->warn;
	}

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = 'inited';
	}

	$self->{inited} = 1;
} ## end sub init

=head2 ban

Bans an IP. The value of ban is validated as being a IPv4 or IPv6 address
and lowercased, then a route of the configured C<blocktype> is added for it
via C<ip route add> (C<ip -6 route add> for IPv6). Banning an already
banned IP is a noop.

    $backend->ban(ban => $ip);

=cut

sub ban {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( !$self->{inited} ) {
		$self->{error}       = 1;
		$self->{errorString} = 'backend has not been inited';
		$self->warn;
		return;
	}

	if ( !defined( $opts{ban} ) ) {
		$self->{error}       = 9;
		$self->{errorString} = 'Nothing specified for the value ban';
		$self->warn;
		return;
	} elsif ( ref( $opts{ban} ) ne '' ) {
		$self->{error}       = 10;
		$self->{errorString} = 'Bad ref type for ban... ref is "' . ref( $opts{ban} ) . '"';
		$self->warn;
		return;
	} elsif ( $opts{ban} !~ /\A$IPv4_re\z/
		&& $opts{ban} !~ /\A$IPv6_re\z/ )
	{
		$self->{error}       = 10;
		$self->{errorString} = 'ban item,"' . $opts{ban} . '", does not appear to be a IPv4 or IPv6 IP';
		$self->warn;
		return;
	}

	# lowercase so the same IPv6 IP in differing cases can't result in duplicate entries
	$opts{ban} = lc( $opts{ban} );

	if ( $self->{banned}{ $opts{ban} } ) {
		if ( $self->{testing} ) {
			$self->{frontend_obj}->{test_data} = 'already banned';
		}
		return;
	}

	my $command = $self->_route_command( 'add', $opts{ban} );

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = [$command];
	} else {
		my $output = `$command 2>&1`;
		if ( $? != 0 ) {
			$self->{error} = 13;
			$self->{errorString}
				= 'ban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
			$self->warn;
		}
	}

	$self->{banned}{ $opts{ban} } = 1;
} ## end sub ban

=head2 unban

Unbans an IP. The value of ban is validated as being a IPv4 or IPv6 address
and lowercased, then its route is removed via C<ip route del>
(C<ip -6 route del> for IPv6). Unbanning an IP that is not banned is a
noop.

    $backend->unban(ban => $ip);

=cut

sub unban {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( !$self->{inited} ) {
		$self->{error}       = 1;
		$self->{errorString} = 'backend has not been inited';
		$self->warn;
		return;
	}

	if ( !defined( $opts{ban} ) ) {
		$self->{error}       = 9;
		$self->{errorString} = 'Nothing specified for the value ban';
		$self->warn;
		return;
	} elsif ( ref( $opts{ban} ) ne '' ) {
		$self->{error}       = 10;
		$self->{errorString} = 'Bad ref type for ban... ref is "' . ref( $opts{ban} ) . '"';
		$self->warn;
		return;
	} elsif ( $opts{ban} !~ /\A$IPv4_re\z/
		&& $opts{ban} !~ /\A$IPv6_re\z/ )
	{
		$self->{error}       = 10;
		$self->{errorString} = 'ban item,"' . $opts{ban} . '", does not appear to be a IPv4 or IPv6 IP';
		$self->warn;
		return;
	}

	# lowercase so the same IPv6 IP in differing cases can't result in duplicate entries
	$opts{ban} = lc( $opts{ban} );

	if ( !$self->{banned}{ $opts{ban} } ) {
		if ( $self->{testing} ) {
			$self->{frontend_obj}->{test_data} = 'not banned';
		}
		return;
	}

	my $command = $self->_route_command( 'del', $opts{ban} );

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = $command;
	} else {
		my $output = `$command 2>&1`;
		if ( $? != 0 ) {
			$self->{error} = 14;
			$self->{errorString}
				= 'unban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
			$self->warn;
		}
	}

	delete( $self->{banned}{ $opts{ban} } );
} ## end sub unban

=head2 ban_cidr

Bans a CIDR range by adding a route of the configured C<blocktype> for it
via C<ip route add>, the same way a single IP is handled. The value of ban
is validated as being a IPv4 or IPv6 CIDR range and lowercased. Banning an
already banned range is a noop.

    $backend->ban_cidr(ban => '1.2.3.0/24');

=cut

sub ban_cidr {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( !$self->{inited} ) {
		$self->{error}       = 1;
		$self->{errorString} = 'backend has not been inited';
		$self->warn;
		return;
	}

	if ( !defined( $opts{ban} ) ) {
		$self->{error}       = 9;
		$self->{errorString} = 'Nothing specified for the value ban';
		$self->warn;
		return;
	} elsif ( ref( $opts{ban} ) ne '' ) {
		$self->{error}       = 30;
		$self->{errorString} = 'Bad ref type for ban... ref is "' . ref( $opts{ban} ) . '"';
		$self->warn;
		return;
	} elsif ( !$self->_valid_cidr( $opts{ban} ) ) {
		$self->{error}       = 30;
		$self->{errorString} = 'ban item,"' . $opts{ban} . '", does not appear to be a IPv4 or IPv6 CIDR';
		$self->warn;
		return;
	}

	# lowercase so the same IPv6 CIDR in differing cases can't result in duplicate entries
	$opts{ban} = lc( $opts{ban} );

	if ( $self->{banned_cidr}{ $opts{ban} } ) {
		if ( $self->{testing} ) {
			$self->{frontend_obj}->{test_data} = 'already banned';
		}
		return;
	}

	# _route_command detects the family from the whole string, which a CIDR
	# would break, so the family is taken from the address portion here
	my ($addr) = ( $opts{ban} =~ m!\A(.+)/[0-9]+\z! );
	my $family = ( $addr =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
	my $command = 'ip' . $family . ' route add ' . $self->{options}{blocktype} . ' ' . $opts{ban};

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = [$command];
	} else {
		my $output = `$command 2>&1`;
		if ( $? != 0 ) {
			$self->{error} = 28;
			$self->{errorString}
				= 'ban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
			$self->warn;
		}
	}

	$self->{banned_cidr}{ $opts{ban} } = 1;
} ## end sub ban_cidr

=head2 unban_cidr

Unbans a CIDR range by deleting its route via C<ip route del>. The value of
ban is validated as being a IPv4 or IPv6 CIDR range and lowercased.
Unbanning a range that is not banned is a noop.

    $backend->unban_cidr(ban => '1.2.3.0/24');

=cut

sub unban_cidr {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( !$self->{inited} ) {
		$self->{error}       = 1;
		$self->{errorString} = 'backend has not been inited';
		$self->warn;
		return;
	}

	if ( !defined( $opts{ban} ) ) {
		$self->{error}       = 9;
		$self->{errorString} = 'Nothing specified for the value ban';
		$self->warn;
		return;
	} elsif ( ref( $opts{ban} ) ne '' ) {
		$self->{error}       = 30;
		$self->{errorString} = 'Bad ref type for ban... ref is "' . ref( $opts{ban} ) . '"';
		$self->warn;
		return;
	} elsif ( !$self->_valid_cidr( $opts{ban} ) ) {
		$self->{error}       = 30;
		$self->{errorString} = 'ban item,"' . $opts{ban} . '", does not appear to be a IPv4 or IPv6 CIDR';
		$self->warn;
		return;
	}

	# lowercase so the same IPv6 CIDR in differing cases can't result in duplicate entries
	$opts{ban} = lc( $opts{ban} );

	if ( !$self->{banned_cidr}{ $opts{ban} } ) {
		if ( $self->{testing} ) {
			$self->{frontend_obj}->{test_data} = 'not banned';
		}
		return;
	}

	# _route_command detects the family from the whole string, which a CIDR
	# would break, so the family is taken from the address portion here
	my ($addr) = ( $opts{ban} =~ m!\A(.+)/[0-9]+\z! );
	my $family = ( $addr =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
	my $command = 'ip' . $family . ' route del ' . $self->{options}{blocktype} . ' ' . $opts{ban};

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = $command;
	} else {
		my $output = `$command 2>&1`;
		if ( $? != 0 ) {
			$self->{error} = 29;
			$self->{errorString}
				= 'unban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
			$self->warn;
		}
	}

	delete( $self->{banned_cidr}{ $opts{ban} } );
} ## end sub unban_cidr

=head2 list_cidr

List banned CIDR ranges. Returns an array of the currently banned CIDR
ranges. Single IPs are not included; for those see L</list>.

    my @banned_cidrs = $backend->list_cidr;

=cut

sub list_cidr {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = 'list_cidr';
	}

	return keys( %{ $self->{banned_cidr} } );
}

=head2 list

List banned IPs. Returns an array of the currently banned single IPs. CIDR
ranges are not included; for those see L</list_cidr>.

    my @banned = $backend->list;

=cut

sub list {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = 'list';
	}

	return keys( %{ $self->{banned} } );
}

=head2 re_init

Tells the backend to re-init it's self.

This will call teardown and init again. After that it will
re-added all previously added bans.

    $backend->re_init;

=cut

sub re_init {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	# teardown is best effort here as a partially or fully wiped setup is
	# exactly what re_init needs to recover from; init cleans up any remnants
	{
		local $@;
		eval { $self->teardown; };
	}
	$self->init;

	my @to_ban = keys( %{ $self->{banned} } );

	my @re_init_test_data;
	foreach my $item (@to_ban) {
		my $command = $self->_route_command( 'add', $item );

		if ( $self->{testing} ) {
			push( @re_init_test_data, $command );
		} else {
			my $output = `$command 2>&1`;
			if ( $? != 0 ) {
				$self->{error} = 13;
				$self->{errorString}
					= 'ban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
				$self->warn;
			}
		}
	} ## end foreach my $item (@to_ban)

	# CIDR ranges are re-added the same way, but the family must be taken from
	# the address portion as _route_command can not detect it from a CIDR
	foreach my $item ( keys( %{ $self->{banned_cidr} } ) ) {
		my ($addr) = ( $item =~ m!\A(.+)/[0-9]+\z! );
		my $family = ( $addr =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
		my $command = 'ip' . $family . ' route add ' . $self->{options}{blocktype} . ' ' . $item;

		if ( $self->{testing} ) {
			push( @re_init_test_data, $command );
		} else {
			my $output = `$command 2>&1`;
			if ( $? != 0 ) {
				$self->{error} = 28;
				$self->{errorString}
					= 'ban failed. non-zero exit code for the command... "' . $command . '"... output... ' . $output;
				$self->warn;
			}
		}
	} ## end foreach my $item ( keys( %{ $self->{banned_cidr}...}))

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = \@re_init_test_data;
	}

	$self->{inited} = 1;
} ## end sub re_init

=head2 teardown

Tears down the setup for the backend by deleting the route for each
currently banned IP. Routes that were already removed externally are
tolerated, so re_init works after an external wipe. The internal list of
bans is kept, so a following re_init will re-add them.

    $backend->teardown;

=cut

sub teardown {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	$self->{inited} = 0;

	my @commands;
	foreach my $item ( keys( %{ $self->{banned} } ) ) {
		push( @commands, $self->_route_command( 'del', $item ) );
	}

	# CIDR ranges are removed the same way, but the family must be taken from
	# the address portion as _route_command can not detect it from a CIDR
	foreach my $item ( keys( %{ $self->{banned_cidr} } ) ) {
		my ($addr) = ( $item =~ m!\A(.+)/[0-9]+\z! );
		my $family = ( $addr =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
		push( @commands, 'ip' . $family . ' route del ' . $self->{options}{blocktype} . ' ' . $item );
	}

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = \@commands;
	} else {
		# failures are tolerated as the route may already have been removed
		# externally, which is exactly what re_init recovers from
		foreach my $item (@commands) {
			my $output = `$item 2>&1`;
		}
	}
} ## end sub teardown

=head2 stop

Alias for L</teardown>, provided for parity with the fail2ban C<actionstop>
concept.

    $backend->stop;

=cut

sub stop {
	my ( $self, %opts ) = @_;

	return $self->teardown(%opts);
}

=head2 check

Verifies the route for each currently banned single IP is still present.
Returns a true value if they all are and a false value if any are missing.
Routes for banned CIDR ranges are not checked. This is the equivalent of
fail2ban's C<actioncheck>.

    if ( !$backend->check ) {
        $backend->re_init;
    }

=cut

sub check {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	my @commands;
	foreach my $item ( sort( keys( %{ $self->{banned} } ) ) ) {
		push( @commands, $self->_route_command( 'show', $item ) );
	}

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = \@commands;
		return 1;
	}

	foreach my $item (@commands) {
		my $output = `$item 2>&1`;
		# ip route show exits zero even when nothing matches, so a missing
		# route shows up as a lack of output rather than a non-zero exit
		if ( $? != 0 || $output !~ /\S/ ) {
			return 0;
		}
	}

	return 1;
} ## end sub check

=head2 flush

Removes all currently banned IPs at once by deleting their routes and
forgetting them. This is the equivalent of fail2ban's C<actionflush>.

    $backend->flush;

=cut

sub flush {
	my ( $self, %opts ) = @_;

	$self->errorblank;

	if ( !$self->{inited} ) {
		$self->{error}       = 1;
		$self->{errorString} = 'backend has not been inited';
		$self->warn;
		return;
	}

	my @commands;
	foreach my $item ( keys( %{ $self->{banned} } ) ) {
		push( @commands, $self->_route_command( 'del', $item ) );
	}

	# CIDR ranges are removed the same way, but the family must be taken from
	# the address portion as _route_command can not detect it from a CIDR
	foreach my $item ( keys( %{ $self->{banned_cidr} } ) ) {
		my ($addr) = ( $item =~ m!\A(.+)/[0-9]+\z! );
		my $family = ( $addr =~ /\A$IPv4_re\z/ ) ? '' : ' -6';
		push( @commands, 'ip' . $family . ' route del ' . $self->{options}{blocktype} . ' ' . $item );
	}

	if ( $self->{testing} ) {
		$self->{frontend_obj}->{test_data} = \@commands;
	} else {
		foreach my $item (@commands) {
			my $output = `$item 2>&1`;
			if ( $? != 0 ) {
				$self->{error} = 25;
				$self->{errorString}
					= 'flush failed. non-zero exit code for the command... "' . $item . '"... output... ' . $output;
				$self->warn;
			}
		}
	} ## end else [ if ( $self->{testing} ) ]

	$self->{banned}      = {};
	$self->{banned_cidr} = {};
} ## end sub flush

=head1 ERROR CODES / FLAGS

Error handling is provided by L<Error::Helper>. All
errors are considered fatal.

=head2 1, notInited

The backend has not been inited yet.

=head2 6, invalidPrefixSpecified

The specified prefix did not match /^[a-zA-Z0-9]+$/.

=head2 7, invalidName

The name is either undef or does not match /^[a-zA-Z0-9\-]+$/.

=head2 8, optionsNotHash

The item passed to new for options is not a hash.

=head2 9, noBanItem

No IP or CIDR range specified to ban or unban.

=head2 10, banItemNotIP

The item to ban is not an IP. Either wrong ref type or regexp
test using L<Regexp::IPv4> and L<Regexp::IPv6> failed.

=head2 12, backendInitError

Failed to init the backend.

=head2 13, banFailed

Failed to ban the item.

=head2 14, unbanFailed

Failed to unban the item.

=head2 15, listFailed

Failed to get a list of bans.

=head2 16, reInitFailed

Failed to re_init the backend.

=head2 17, teardownFailed

Failed to teardown the backend.

=head2 18, alreadyInited

init called, but the backend has already been inited.

=head2 20, blocktypeInvalid

The option blocktype is not "unreachable", "blackhole", or "prohibit".

=head2 23, initFailed

One of the required commands for init failed.

=head2 24, checkFailed

The backend check raised an error.

=head2 25, flushFailed

Failed to flush the bans.

=head2 26, portsNotSupported

Ports were specified, but the backend does not support ports.

=head2 27, protocolsNotSupported

Protocols were specified, but the backend does not support protocols.

=head2 28, banCidrFailed

Failed to ban the CIDR range.

=head2 29, unbanCidrFailed

Failed to unban the CIDR range.

=head2 30, cidrItemNotCidr

The item to ban is not a CIDR range. Either wrong ref type or it is not an
IPv4 or IPv6 address followed by a prefix length valid for its family.

=head2 31, cidrNotSupported

The backend does not support CIDR bans.

=head2 32, listCidrFailed

Failed to get a list of CIDR bans.

=head1 AUTHOR

Zane C. Bowers-Hadley, C<< <vvelox at vvelox.ent> >>

=head1 BUGS

Please report any bugs or feature requests to C<bug-net-firewall-blockerhelper at rt.cpan.org>, or through
the web interface at L<https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-Firewall-BlockerHelper>.

=head1 LICENSE AND COPYRIGHT

This software is Copyright (c) 2023 by Zane C. Bowers-Hadley.

This is free software, licensed under:

  The GNU Lesser General Public License, Version 2.1, February 1999


=cut

1;    # End of Net::Firewall::BlockerHelper
