The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/env perl

use strict;
use warnings;

our $VERSION = '3.13';

=head1 NAME

capo-ctl.pl - Controller script for Captive::Portal

=head1 ABSTRACT

Controller script to start, stop, clear, list and purge iptables/ipsets and session entries.

=head1 SYNOPSIS

 capo-ctl.pl [-f capo.cfg] [-l log4perl.cfg] ACTION

=cut

use sigtrap qw(die untrapped normal-signals);

use Pod::Usage qw(pod2usage);
use FindBin qw($Bin $Script);
use lib "$Bin/../lib";

use Log::Log4perl qw(:easy);
use Getopt::Long qw(GetOptions);
use Try::Tiny;
use Captive::Portal;
use Captive::Portal::LockHandle;

$ENV{PATH} = '/sbin:/bin:/usr/sbin:/usr/bin';

select(STDERR) and $| = 1;
select(STDOUT) and $| = 1;

#####################################################################
# put scriptname in process table instead of plain 'perl'
# but safe the pathname for pod2usage, sigh
#####################################################################
my $pathname = $0;
$0 = $Script;

#####################################################################
# handle cmdline options and args
#####################################################################

my $cfg_file =
     $ENV{CAPTIVE_PORTAL_CONFIG}
  || -e "$Bin/../etc/local/config.pl" && "$Bin/../etc/local/config.pl"
  || -e "$Bin/../etc/config.pl" && "$Bin/../etc/config.pl";

my $log4perl =
     $ENV{CAPTIVE_PORTAL_LOG4PERL}
  || -e "$Bin/../etc/local/log4perl.conf" && "$Bin/../etc/local/log4perl.conf"
  || -e "$Bin/../etc/log4perl.conf" && "$Bin/../etc/log4perl.conf";

GetOptions(
    'loggfile=s' => \$log4perl,
    'file=s'     => \$cfg_file,
  )
  or pod2usage(
    {
        -input   => $pathname,
        -exitval => 1,
        -verbose => 1,
        -output  => \*STDERR
    }
  );

if ( $log4perl && -f $log4perl ) {
    Log::Log4perl->init($log4perl);
}
else {
    Log::Log4perl->easy_init($DEBUG);
}

=head1 OPTIONS

=over 4

=item B<--file> capo.cfg

Captive::Portal config file. By default

    $ENV{CAPTIVE_PORTAL_CONFIG} ||
    $Bin/../etc/local/config.pl ||
    $Bin/../etc/config.pl

=item B<--logg> log4perl.cfg

Log::Log4perl config file. By default

    $ENV{CAPTIVE_PORTAL_LOG4PERL}   ||
    $Bin/../etc/local/log4perl.conf ||
    $Bin/../etc/log4perl.conf

=back

=cut

# dispatch table
my $actions = {
    start       => \&start_fw,
    stop        => \&stop_fw,
    start_clear => \&start_clear_fw,
    purge       => \&purge_sessions,
    status      => \&fw_status,
    clear       => \&clear_sessions,
    list        => \&list_sessions,
};

=head1 ARGUMENTS

One and only one ACTION is needed.

=over 4

=item B<start>

start the firewall, reload old sessions

=item B<stop>

stop the firewall, keep current sessions

=item B<purge>

purge idle sessions, periodically used by cron

=item B<start_clear>

start the firewall, flush old sessions

=item B<clear>

clear current sessions from iptables/ipsets and sessions dir

=item B<status>

check the firewall status

=item B<list>

list  sessions from iptables/ipsets and sessions dir

=back

=cut

my $action = shift;

pod2usage(
    {
        -input   => $pathname,
        -message => "ACTION missing\n",
        -exitval => 1,
        -output  => \*STDERR
    }
) unless $action;

pod2usage(
    {
        -input   => $pathname,
        -message => "ACTION '$action' not supported\n",
        -exitval => 1,
        -output  => \*STDERR
    }
) unless ( exists $actions->{$action} );

#####################################################################
# create Captive::Portal object and run the requested ACTION
#####################################################################

DEBUG "create new Captive Portal object";
my $capo = Captive::Portal->new( cfg_file => $cfg_file );

my $lock_file = $capo->cfg->{LOCK_FILE};

DEBUG "#################### ACTION $action START #######################";
my $exit_code = $actions->{$action}->($capo);
DEBUG "#################### ACTION $action END   #######################";

exit $exit_code || 0;

#####################################################################
########################## end of main ##############################
#####################################################################

# define ACTIONS

sub start_fw {
    my $capo = shift;

    # try 30s to get the lock or die
    my $lock_handle = Captive::Portal::LockHandle->new(
        file     => $lock_file,
        shared   => 0,
        blocking => 1,
        timeout  => 30_000_000,
    ) or LOGDIE "Couldn't get the lock";

    DEBUG 'starting capo firewall ...';
    $capo->fw_start;

    return 0;
}

sub stop_fw {
    my $capo = shift;

    # try 30s to get the lock or die
    my $lock_handle = Captive::Portal::LockHandle->new(
        file     => $lock_file,
        shared   => 0,
        blocking => 1,
        timeout  => 30_000_000,
    ) or LOGDIE "Couldn't get the lock";

    DEBUG 'stopping capo firewall ...';
    $capo->fw_stop;

    return 0;
}

sub start_clear_fw {
    my $capo = shift;

    # try 30s to get the lock or die
    my $lock_handle = Captive::Portal::LockHandle->new(
        file     => $lock_file,
        shared   => 0,
        blocking => 1,
        timeout  => 30_000_000,
    ) or LOGDIE "Couldn't get the lock";

    DEBUG 'try to clear disk session records';
    $capo->clear_sessions_from_disk;

    DEBUG 'starting capo firewall ...';
    $capo->fw_start;

    return 0;
}

sub purge_sessions {
    my $capo = shift;

    DEBUG 'purging idle and malformed sessions ...';

    if ( defined $capo->fw_status ) {

        my $lock_handle = Captive::Portal::LockHandle->new(
            file     => $lock_file,
            shared   => 0,
            blocking => 0,
            try      => 3,
        ) or LOGDIE "Couldn't get the lock";

        $capo->fw_purge_sessions;

    }
    else {

        # It's a hack, requestet by Bing, sigh.
        # Normally this is an error condition, but the cronjob
        # would fill the mailbox.
        WARN "Can't purge, firewall rules not loaded!";
        return 1;
    }

    return 0;
}

sub fw_status {
    my $capo = shift;

    DEBUG 'check status of capo firewall ...';

    my $ipset_entries = $capo->fw_status;

    if ( defined $ipset_entries ) {
        print "OK, firewall running and $ipset_entries loaded.\n";
        return 0;
    }
    else {
        print "NOT OK, firewall rules not loaded.\n";
        return 1;
    }
}

sub list_sessions {
    my $capo = shift;

    DEBUG 'listing ipset members ...';

    print '-' x 80 . "\n";
    print "IPSET MEMBERS:\n";
    print '-' x 80 . "\n";

    my $ipset_members = $capo->fw_list_sessions;

    if ( defined $ipset_members ) {
        foreach my $ip ( sort keys %$ipset_members ) {
            printf "%-15.15s|%-17.17s\n", $ip, $ipset_members->{$ip};
        }
    } else {
        print "Firewall stopped!\n";
    }

    print '-' x 80 . "\n";

    DEBUG 'listing capo sessions ...';

    my @sessions;

    foreach my $key ( $capo->list_sessions_from_disk ) {

        my $lock_handle = $capo->get_session_lock_handle(
            key      => $key,
            blocking => 1,
            shared   => 1,
            timeout  => 1_000_000,    # 1_000_000 us = 1s
        );

        my $session = $capo->read_session_handle($lock_handle);

        next unless $session;

        push @sessions,
          [
            $session->{IP},    $session->{MAC},
            $session->{STATE}, $session->{USERNAME},
            $session->{USER_AGENT},
          ];
    }

    print "SESSIONS:\n";
    print '-' x 80 . "\n";
    printf "%-15.15s|%-17.17s|%-12.12s|%-14.14s|%s\n",
      qw(IP MAC STATE USERNAME USER_AGENT);
    foreach my $session_data (@sessions) {
        printf "%-15.15s|%-17.17s|%-12.12s|%-14.14s|%-40.40s ...\n",
          @$session_data;
    }
    print '-' x 80 . "\n";

    return 0;
}

sub clear_sessions {
    my $capo = shift;

    $capo->clear_sessions_from_disk;

    $capo->fw_clear_sessions if defined $capo->fw_status;

    return 0;
}

=head1 AUTHOR

Karl Gaissmaier, C<< <gaissmai at cpan.org> >>

=head1 LICENSE AND COPYRIGHT

Copyright 2010-2012 Karl Gaissmaier, all rights reserved.

This distribution is free software; you can redistribute it and/or modify it
under the terms of either:

a) the GNU General Public License as published by the Free Software
Foundation; either version 2, or (at your option) any later version, or

b) the Artistic License version 2.0.

=cut