The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Captive::Portal::Role::Firewall;

use strict;
use warnings;

=head1 NAME

Captive::Portal::Role::Firewall - firewall methods for Captive::Portal

=head1 DESCRIPTION

Does all stuff needed to dynamically update iptables and ipset.

=cut

our $VERSION = '3.13';

use Log::Log4perl qw(:easy);
use Try::Tiny;

use Role::Basic;
requires qw(
  cfg
  spawn_cmd
  list_sessions_from_disk
  get_session_lock_handle
  read_session_handle
  delete_session_from_disk
);

# Role::Basic exports ALL subroutines, there is currently no other way to
# prevent exporting private methods, sigh
#
my ($_fw_install_rules);

=head1 ROLES

=over

=item $capo->fw_trigger_clients(@ip_addresses)

Send a ping to the clients to check IDLE state if USE_FPING in config file is true.

=cut

sub fw_trigger_clients {
  my $self     = shift;
  my @ips2ping = @_;

  return unless @ips2ping;

  unless ( $self->cfg->{USE_FPING} ) {
    DEBUG "USE_FPING OFF in config, don't trigger targets";
    return;
  }

  my @cmd = (
    'sudo', 'fping',
    @{ $self->cfg->{FPING_OPTIONS} },
    @ips2ping,
    {
      timeout           => 2,
      ignore_exit_codes => [ 1, ],
    },
  );

  DEBUG "fping, trigger @ips2ping";

  my $error;
  try { $self->spawn_cmd(@cmd) } catch { $error = $_ };

  # ignore fping timeouts
  if ( $error && $error !~ m/timed out/i ) {
    ERROR $error;
  }
}

=item $capo->fw_ipset_version()

fw_ipset_version checks for the ipset() major version number. ipset() version >= 4 introduced an incompatible API and CLI. 

=cut

sub fw_ipset_version {
  my $self = shift;

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking ipset major version number 4';
    return 4;
  }

  my @cmd = qw(sudo ipset --version);

  my ( $stdout, $error );
  try { ($stdout) = $self->spawn_cmd(@cmd) } catch { $error = $_ };

  LOGDIE $error if $error;

  my ($ipset_major_version) = $stdout =~ m/^ipset\s+v?([\d+]).*$/ims;

  return $ipset_major_version;
}

=item $capo->fw_start_session($ip_address, $mac_address)

Add tuple IP/MAC to the ipset named I<capo_sessions_ipset>. Members of this ipset have Internet access and are no longer redirected to the login/splash page crossing the gateway.

=cut

sub fw_start_session {
  my $self = shift;

  my $ip = shift
    or LOGDIE("missing session IP");

  my $mac = shift
    or LOGDIE("missing session MAC");

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking start session';
    return 1;
  }

  my @cmd = ( 'sudo', 'ipset', '--add', 'capo_sessions_ipset', "$ip,$mac" );

  my $error;
  try { $self->spawn_cmd(@cmd) } catch { $error = $_ };

  die "$error\n" if $error;

  return;
}

=item $capo->fw_stop_session($ip_address, $mac_address)

Delete tuple IP/MAC from the ipset named I<capo_sessions_ipset>.

=cut

sub fw_stop_session {
  my $self = shift;

  my $ip = shift
    or LOGDIE("missing session IP");

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking stop session';
    return;
  }

  my @cmd = ( 'sudo', 'ipset', '--del', 'capo_sessions_ipset', $ip, );

  my $error;
  try { $self->spawn_cmd(@cmd) } catch { $error = $_ };

  die "$error\n" if $error;

  return;
}

=item $capo->fw_reload_sessions()

This method is called during startup of the Captive::Portal when the old state of the clients must be preserved. Reads the sessions from disc cache and calls fw_start_session for all ACTIVE clients. 

=cut

sub fw_reload_sessions {
  my $self = shift;

  DEBUG "reload firewall rules for cached sessions";

  # list all the cached sessions from disk and install rules
  foreach my $ip ( $self->list_sessions_from_disk ) {

    # fetch session data, lock timeout 1s

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

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

    unless ($session) {
      DEBUG "delete empty or malformed session for $ip";
      $self->delete_session_from_disk($ip);
      next;
    }

    next unless $session->{STATE} eq 'active';

    my $error;
    try { $self->fw_start_session( $ip, $session->{MAC} ) }
    catch { $error = $_ };

    if ($error) {
      ERROR($error);
      $self->delete_session_from_disk($ip);
    }
  }
}

=item $capo->fw_status()

Counts the members of the ipset 'capo_sessions_ipset'. Returns the number of members in this set on success (maybe 0) or undef on error (e.g. ipset undefined).

=cut

sub fw_status {
  my $self = shift;

  my ( $sessions, $error );
  try { $sessions = $self->fw_list_sessions } catch { $error = $_ };

  return if $error;
  return unless defined $sessions;

  my $count = scalar keys %$sessions;
  DEBUG "firewall status: running, $count sessions installed";

  return $count;
}

=item $capo->fw_list_sessions()

Parses the output of:
    ipset list capo_sessions_ipset

and returns a hashref for the tuples { ip => mac, ... }

=cut

sub fw_list_sessions {
  my $self = shift;

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking ipset';
    return {};
  }

  my @cmd;

  # sigh, ipset() changed API after version >= 4
  #
  if ($self->cfg->{IPTABLES}{ipset_version} >= 4 ) {
    @cmd = qw(sudo ipset --list capo_sessions_ipset);
  } else {
    @cmd = qw(sudo ipset -n --list capo_sessions_ipset);
  }

  my ( $stdout, $error );
  try { ($stdout) = $self->spawn_cmd(@cmd) } catch { $error = $_ };

  LOGDIE $error if $error;

  my @lines = split "\n+", $stdout;

  # ipv4 address in quad decimal
  my $ip_quad_dec_rx = qr(\d{1,3} \. \d{1,3} \. \d{1,3} \. \d{1,3})x;

  # regex for MAC address matching
  my $hex_digit_rx = qr/[A-F,a-f,0-9]/;
  my $mac_rx       = qr/(?:$hex_digit_rx{2}:){5} $hex_digit_rx{2}/x;

  ####
  # parse the output of:
  #    ipset list capo_sessions_ipset
  #
  # this looks like:
  #----------------
  # Name: capo_sessions_ipset
  # Type: macipmap
  # References: 2
  # Default binding:
  # Header: from: 10.10.0.0 to: 10.10.0.255
  # Members:
  # 10.10.0.2,00:15:2C:FA:BB:80
  # 10.10.0.3,00:15:2C:FA:DB:80
  # 10.10.0.15,00:11:63:9C:9B:85
  # 10.10.0.21,00:1F:4F:EC:B9:42
  # 10.10.0.30,00:54:81:21:7B:01
  # ...
  # Bindings:

  my $sessions = {};
  foreach my $line (@lines) {

    # skip emtpy lines from ipset list
    next if $line =~ m/^\s*$/;

    # skip comment lines from ipset list
    next if $line =~ m/:\s|:\Z/;

    $line =~ m/^\s* ($ip_quad_dec_rx) , ($mac_rx) \s* $/x;
    my $ip  = $1;
    my $mac = $2;

    unless ( $ip && $mac ) {
      ERROR "Couldn't parse line: $line";
      next;
    }

    $sessions->{$ip} = uc $mac;
  }

  return $sessions;
}

=item $capo->fw_list_activity()

Reads and flushes the ipset 'capo_activity_ipset'  and returns a hashref for the tuples { ip => mac, ... }

Captive::Portal doesn't rely on JavaScript or any other client technology to test for idle clients. A cronjob must call periodically:

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

in order to detect idle clients. The firewall rules add active clients to the ipset 'capo_activity_ipset' and the purger reads and flushes this set with this method.

=cut

sub fw_list_activity {
  my $self = shift;

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking ipset';
    return {};
  }

  my ( $stdout, $error );
  try {
    $self->spawn_cmd(qw(sudo ipset --flush capo_activity_swap_ipset));

    $self->spawn_cmd(
      qw(sudo ipset --swap capo_activity_ipset capo_activity_swap_ipset));

    # sigh, ipset() changed API after version >= 4
    #
    if ( $self->cfg->{IPTABLES}{ipset_version} >= 4 ) {
      ($stdout) =
        $self->spawn_cmd(qw(sudo ipset --list capo_activity_swap_ipset));
    }
    else {
      ($stdout) =
        $self->spawn_cmd(qw(sudo ipset -n --list capo_activity_swap_ipset));
    }

  }
  catch {
    $error = $_;
  };

  LOGDIE $error if $error;

  my @lines = split "\n+", $stdout;

  # ipv4 address in quad decimal
  my $ip_quad_dec_rx = qr(\d{1,3} \. \d{1,3} \. \d{1,3} \. \d{1,3})x;

  # regex for MAC address matching
  my $hex_digit_rx = qr/[A-F,a-f,0-9]/;
  my $mac_rx       = qr/(?:$hex_digit_rx{2}:){5} $hex_digit_rx{2}/x;

  ####
  # parse the output of:
  #    ipset list capo_activity_ipset
  #
  # this looks like:
  #----------------
  # Name: capo_activity_ipset
  # Type: macipmap
  # References: 2
  # Default binding:
  # Header: from: 10.10.0.0 to: 10.10.0.255
  # Members:
  # 10.10.0.2,00:15:2C:FA:BB:80
  # 10.10.0.3,00:15:2C:FA:DB:80
  # 10.10.0.15,00:11:63:9C:9B:85
  # 10.10.0.21,00:1F:4F:EC:B9:42
  # 10.10.0.30,00:54:81:21:7B:01
  # ...
  # Bindings:

  my $active_clients = {};
  foreach my $line (@lines) {

    # skip emtpy lines from ipset list
    next if $line =~ m/^\s*$/;

    # skip comment lines from ipset list
    next if $line =~ m/:\s|:\Z/;

    $line =~ m/^\s* ($ip_quad_dec_rx) , ($mac_rx) \s* $/x;
    my $ip  = $1;
    my $mac = $2;

    unless ( $ip && $mac ) {
      ERROR "Couldn't parse line: $line";
      next;
    }

    $active_clients->{$ip} = uc $mac;
  }

  return $active_clients;
}

=item $capo->fw_clear_sessions()

Flushes the ipset 'capo_sessions_ipset', normally used in start/stop scripts, see capo-ctl.pl.

=cut

sub fw_clear_sessions {
  my $self = shift;

  $self->$_fw_install_rules('flush_capo_sessions');
}

=item $capo->fw_start()

Calls the firewall templates in the order flush, init, mangle, nat and filter, see the corresponding firewall templates under I<templates/orig/firewall/>. After the init step the ipsets are filled via I<fw_reload_sessions> from disc cache.

=cut

sub fw_start {
  my $self = shift;

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking start firewall';
    return 1;
  }

  # proper order of steps is essential for uninterrupted reloads

  foreach my $step (qw/flush init mangle nat filter/) {

    $self->$_fw_install_rules($step);

    # after the init step prefill the capo_sessions
    # with cached sessions from disk
    $self->fw_reload_sessions if $step eq 'init';
  }
}

=item $capo->fw_stop()

Calls the firewall template I<flush>, see the corresponding firewall template under I<templates/orig/firewall/>.

=cut

sub fw_stop {
  my $self = shift;

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking stop firewall';
    return 1;
  }

  $self->$_fw_install_rules('flush');
}

=item $capo->fw_purge_sessions()

Detect idle sessions, mark them as IDLE in disk cache and remove entry in ipset.

=cut

sub fw_purge_sessions {
  my $self = shift;

  DEBUG 'running ' . __PACKAGE__ . ' fw_purge_sessions ...';

  if ( $self->cfg->{MOCK_FIREWALL} ) {
    DEBUG 'MOCK_FIREWALL, mocking purge';
    return 1;
  }

  my $this_run = time();

  ######
  # 3 sources of information about a session
  #
  # - session cache on disk with ip/mac/user/state/timestamps/...
  # - ipset capo_sessions_ipset   with ip address as key, mac address as value
  # - ipset capo_activity_ipset   with ip address as key, mac address as value
  #

  my $fw_sessions = $self->fw_list_sessions;
  my $fw_activity = $self->fw_list_activity;

  # what clients need a trigger before going idle?
  my @trigger_targets;

  # Walk over all disk sessions, be aware, only current session is locked!

  # There will be race conditions with running fcgi processes
  # for sessions not currently handled (locked), but see below
  # for handling these races.
  #
  # This is by intention not locking for a long time and delaying
  # http responses!

  foreach my $ip ( $self->list_sessions_from_disk ) {

    my ( $lock_handle, $error );
    try {

      # get the EXCL lock for the session file
      # hold this lock until next loop iteration
      # via lexical scope of $lock_handle
      #
      $lock_handle = $self->get_session_lock_handle(
        key      => $ip,
        blocking => 1,
        shared   => 0,         # EXCL
        timeout  => 50_000,    # 50_000 us -> 50ms
      );

    }
    catch { $error = $_ };

    if ($error) {
      WARN $error;    # could not get the EXCL lock, skip this session
      next;           # session
    }

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

    unless ($session) {
      DEBUG "delete empty or malformed session: $ip";
      $self->delete_session_from_disk($ip);

      next;           # session
    }

    # The session ip must also be in the ipset capo_sessions_ipset.
    # fetch and delete it. If there are still ipset entries
    # left after the loop over all sessions, handle it as error
    # or as race condition at end of the purger

    my $fw_session_entry = delete $fw_sessions->{$ip};

    # tmp store for easier logging, no other functionality
    my $mac  = $session->{MAC};
    my $user = $session->{USERNAME};

    ######## let's start

    ###########################################################
    # remove old, inactive sessions after KEEP_OLD_STATE_PERIOD
    ###########################################################

    if ( $session->{STATE} ne 'active' ) {

      # remove really old sessions not in active STATE
      if ( $this_run - $session->{STOP_TIME} >
        $self->cfg->{KEEP_OLD_STATE_PERIOD} )
      {
        DEBUG "$user/$ip/$mac" . ' -> delete old session from disk cache';

        my $error;
        try { $self->delete_session_from_disk($ip) } catch { $error = $_ };

        ERROR $error if $error;
      }

      next;    # session
    }

    ###############################################################
    # SESSION_MAX limit reached, stop/mark active and idle sessions
    ###############################################################

    my $session_start = $session->{START_TIME};
    my $session_max   = $self->cfg->{SESSION_MAX};

    if ( ( $this_run - $session_start > $session_max )
      && ( $session->{STATE} eq 'active' ) )
    {

      INFO "$user/$ip/$mac -> stopped, MAX_SESSION limit";

      my $error;
      try { $self->fw_stop_session($ip) } catch { $error = $_ };

      ERROR $error if $error;

      $session->{STATE}     = 'max-session-timeout';
      $session->{STOP_TIME} = $this_run;

      undef $error;
      try {
        $self->write_session_handle( $lock_handle, $session );
      }
      catch { $error = $_ };

      ERROR $error if $error;

      next;    # session
    }

    if ( ( $this_run - $session_start > $session_max )
      && ( $session->{STATE} eq 'idle' ) )
    {

      INFO "$user/$ip/$mac -> stopped, MAX_SESSION limit";

      $session->{STATE} = 'max-session-timeout';

      undef $error;
      try {
        $self->write_session_handle( $lock_handle, $session );
      }
      catch { $error = $_ };

      ERROR $error if $error;

      next;    # session
    }

    next unless $session->{STATE} eq 'active';
    ################################################################
    # below this point we handle only sessions with STATE = active
    ################################################################

    ###########################################################
    # ipset-entry was missing for current session at
    # mainloop entry. Maybe it was a race condition.
    # Check if there is still no ipset-entry for this session
    # now we have the lock.
    #
    # We don't check this unconditionally for every session,
    # this would be to expansive for thousand of clients.
    ###########################################################

    if (  ( not defined $fw_session_entry )
      and ( not defined $self->fw_list_sessions->{$ip} ) )
    {

      WARN "$user/$ip/$mac -> delete session, ipset-entry missing";

      my $error;
      try { $self->delete_session_from_disk($ip); } catch { $error = $_ };

      ERROR $error if $error;

      next;    # session
    }

    ###########################################################
    ###########################################################
    # now start with the IDLE check for this active session
    ###########################################################
    ###########################################################

    ###########################################################
    # packets seen from this client during last purger period
    # and not marked as idle candidate, skip to next session
    ###########################################################

    if (  ( exists $fw_activity->{$ip} )
      and ( not $session->{IDLE_SINCE} ) )
    {
      next;    # session
    }

    ###########################################################
    # packets seen from this client during last purger period
    # but sometimes before marked as idle candidate, reset
    # it to active
    ###########################################################

    if ( exists $fw_activity->{$ip} and $session->{IDLE_SINCE} ) {

      DEBUG "$user/$ip/$mac -> withdraw idle candidate";

      # update avtivity
      $session->{IDLE_SINCE} = undef;

      my $error;
      try {
        $self->write_session_handle( $lock_handle, $session );
      }
      catch { $error = $_ };
      ERROR $error if $error;

      next;    # session
    }

    ###########################################################
    ###########################################################
    # after that the client wasn't seen during purger period
    ###########################################################
    ###########################################################

    ###########################################################
    # max IDLE time reached?
    ###########################################################

    my $idle_since = $session->{IDLE_SINCE} || $this_run;

    if ( $this_run - $idle_since >= $self->cfg->{IDLE_TIME} ) {

      INFO "$user/$ip/$mac -> session is IDLE";

      $session->{STATE}     = 'idle';
      $session->{STOP_TIME} = $this_run;

      my $error;
      try {
        $self->fw_stop_session($ip);
        $self->write_session_handle( $lock_handle, $session );
      }
      catch { $error = $_ };
      ERROR $error if $error;

      next;    # session

    }

    ###########################################################
    # IDLE_TIME not reached, trigger the client
    # the first time it's an idle candidate
    ###########################################################

    unless ( defined $session->{IDLE_SINCE} ) {

      DEBUG "$user/$ip/$mac -> idle candidate";

      push @trigger_targets, $ip;

      # mark as idle candidate
      $session->{IDLE_SINCE} = $this_run;

      my $error;
      try {
        $self->write_session_handle( $lock_handle, $session );
      }
      catch { $error = $_ };
      ERROR $error if $error;

      next;    # session
    }

  }    # session mainloop end

  ###########################################################
  # trigger idle clients not seen during last purger period
  ###########################################################

  $self->fw_trigger_clients(@trigger_targets);

  ###########################################################
  # Handle remaining ipset session entries with
  # no corresponding session file. Be careful,
  # maybe a race condition between purger and fcgi script
  # was the reason for that inconsistency
  ###########################################################

  foreach my $ip ( keys %{$fw_sessions} ) {

    # check if there is still no session file for that ipset entry
    #
    my ( $lock_handle, $error );
    try {

      # get the EXCL lock for the session
      # hold this lock until next loop iteration
      #
      $lock_handle = $self->get_session_lock_handle(
        key      => $ip,
        blocking => 1,
        shared   => 0,
        timeout  => 50_000,    # 50_000 us -> 50ms
      );

    }
    catch { $error = $_ };

    if ($error) {
      WARN $error;
      next;
    }

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

    # skip, now we have a valid session for this ipset session entry
    next if $session;

    # Still no session for this ipset session entry, but
    # we have the lock, now we can check if the ipset entry
    # is still set

    next unless defined $self->fw_list_sessions->{$ip};

    WARN "$ip -> delete ipset entry without session file";

    undef $error;
    try { $self->fw_stop_session($ip) } catch { $error = $_ };
    ERROR $error if $error;

    next;
  }
}

# ATTENTION
# private method, not exported to Captive::Portal
#
# $capo->$_fw_install_rules($template_name);
#
# Reads the template, sanitize it and call the commands in the template file via spawn_cmd
#

$_fw_install_rules = sub {
  my $self = shift;
  my $step = shift
    or LOGDIE "missing param 'step'";

  my $cmds;
  my $template = "firewall/${step}.tt";
  my $tmpl_vars =
    { %{ $self->cfg->{IPTABLES} }, ipv4_aton => $self->can('ipv4_aton'), };

  DEBUG "get the firewall $step commands via template $template";

  $self->{template}->process( $template, $tmpl_vars, \$cmds )
    or LOGDIE( $self->{template}->error . "\n" );

  ##############################################
  # mangle the command lines
  #

  # remove comment lines
  $cmds =~ s/^ \s* \# .* $ \n//xmg;

  # remove empty lines
  $cmds =~ s/^ \s* $ \n//xmg;

  # concat continuation lines
  $cmds =~ s/\\ \s* $ \n \s*/ /xmg;

  # remove leading whitespace
  $cmds =~ s/^ \s* //xmg;

  my @cmds = split( /\n/, $cmds );

  #
  #################################################

  foreach my $cmd (@cmds) {
    my @cmd = split( /\s+/, $cmd );

    my $error;
    try { $self->spawn_cmd(@cmd) } catch { $error = $_ };

    die $error if $error;
  }
};

1;

=back

=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

# vim: sw=2