File Coverage

blib/lib/Captive/Portal/Role/Firewall.pm
Criterion Covered Total %
statement 43 221 19.4
branch 9 86 10.4
condition 0 15 0.0
subroutine 11 36 30.5
pod 10 10 100.0
total 73 368 19.8


line stmt bran cond sub pod time code
1             package Captive::Portal::Role::Firewall;
2              
3 6     6   42899 use strict;
  6         16  
  6         284  
4 6     6   35 use warnings;
  6         14  
  6         440  
5              
6             =head1 NAME
7              
8             Captive::Portal::Role::Firewall - firewall methods for Captive::Portal
9              
10             =head1 DESCRIPTION
11              
12             Does all stuff needed to dynamically update iptables and ipset.
13              
14             =cut
15              
16             our $VERSION = '4.10';
17              
18 6     6   32 use Log::Log4perl qw(:easy);
  6         12  
  6         77  
19 6     6   5214 use Try::Tiny;
  6         15  
  6         548  
20              
21 6     6   46 use Role::Basic;
  6         29  
  6         59  
22             requires qw(
23             cfg
24             spawn_cmd
25             list_sessions_from_disk
26             get_session_lock_handle
27             read_session_handle
28             delete_session_from_disk
29             );
30              
31             # Role::Basic exports ALL subroutines, there is currently no other way to
32             # prevent exporting private methods, sigh
33             #
34             my ($_fw_install_rules);
35              
36             =head1 ROLES
37              
38             =over
39              
40             =item $capo->fw_start_session($ip_address, $mac_address)
41              
42             Add tuple IP/MAC to the ipset named I. Members of this ipset have Internet access and are no longer redirected to the login/splash page crossing the gateway.
43              
44             Also insert this IP into capo_activity_ipset, needed for stateful restarts.
45              
46             =cut
47              
48             sub fw_start_session {
49 1     1 1 4 my $self = shift;
50              
51 1 50       5 my $ip = shift
52             or LOGDIE("missing session IP");
53              
54 1 50       6 my $mac = shift
55             or LOGDIE("missing session MAC");
56              
57 1 50       27 if ( $self->cfg->{MOCK_FIREWALL} ) {
58 1         3 DEBUG 'MOCK_FIREWALL, mocking start session';
59 1         8 return 1;
60             }
61              
62 0         0 my @cmd1 = ( 'ipset', '-exist', 'add', 'capo_sessions_ipset', "$ip,$mac" );
63 0         0 my @cmd2 = ( 'ipset', '-exist', 'add', 'capo_activity_ipset', "$ip" );
64              
65 0         0 my $error;
66             try {
67 0     0   0 $self->spawn_cmd(@cmd1);
68 0         0 $self->spawn_cmd(@cmd2);
69             }
70 0     0   0 catch { $error = $_ };
  0         0  
71              
72 0 0       0 die "$error\n" if $error;
73              
74 0         0 return;
75             }
76              
77             =item $capo->fw_stop_session($ip_address, $mac_address)
78              
79             Delete tuple IP/MAC from the ipset named I.
80              
81             =cut
82              
83             sub fw_stop_session {
84 2     2 1 6 my $self = shift;
85              
86 2 50       10 my $ip = shift
87             or LOGDIE("missing session IP");
88              
89 2 50       9 if ( $self->cfg->{MOCK_FIREWALL} ) {
90 2         10 DEBUG 'MOCK_FIREWALL, mocking stop session';
91 2         23 return;
92             }
93              
94 0         0 my @cmd = ( 'ipset', '-exist', 'del', 'capo_sessions_ipset', $ip, );
95              
96 0         0 my $error;
97 0     0   0 try { $self->spawn_cmd(@cmd) } catch { $error = $_ };
  0         0  
  0         0  
98              
99 0 0       0 die "$error\n" if $error;
100              
101 0         0 return;
102             }
103              
104             =item $capo->fw_reload_sessions()
105              
106             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.
107              
108             =cut
109              
110             sub fw_reload_sessions {
111 0     0 1 0 my $self = shift;
112              
113 0         0 DEBUG "reload firewall rules for cached sessions";
114              
115             # list all the cached sessions from disk and install rules
116 0         0 foreach my $ip ( $self->list_sessions_from_disk ) {
117              
118             # fetch session data, lock timeout 1s
119              
120 0         0 my $lock_handle = $self->get_session_lock_handle(
121             key => $ip,
122             blocking => 1,
123             shared => 0,
124             timeout => 1_000_000, # 1_000_000 us = 1s
125             );
126              
127 0         0 my $session = $self->read_session_handle($lock_handle);
128              
129 0 0       0 unless ($session) {
130 0         0 DEBUG "delete empty or malformed session for $ip";
131 0         0 $self->delete_session_from_disk($ip);
132 0         0 next;
133             }
134              
135 0 0       0 next unless $session->{STATE} eq 'active';
136              
137 0         0 my $error;
138 0     0   0 try { $self->fw_start_session( $ip, $session->{MAC} ) }
139 0     0   0 catch { $error = $_ };
  0         0  
140              
141 0 0       0 if ($error) {
142 0         0 ERROR($error);
143 0         0 $self->delete_session_from_disk($ip);
144             }
145             }
146             }
147              
148             =item $capo->fw_status()
149              
150             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).
151              
152             =cut
153              
154             sub fw_status {
155 6     6 1 17 my $self = shift;
156              
157 6         13 my ( $sessions, $error );
158 6     6   69 try { $sessions = $self->fw_list_sessions } catch { $error = $_ };
  6         207  
  0         0  
159              
160 6 50       131 return if $error;
161 6 50       24 return unless defined $sessions;
162              
163 6         19 my $count = scalar keys %$sessions;
164 6         32 DEBUG "firewall status: running, $count sessions installed";
165              
166 6         75 return $count;
167             }
168              
169             =item $capo->fw_list_sessions()
170              
171             Parses the output of:
172             ipset list capo_sessions_ipset
173              
174             and returns a hashref for the tuples { ip => mac, ... }
175              
176             =cut
177              
178             sub fw_list_sessions {
179 6     6 1 12 my $self = shift;
180              
181 6 50       28 if ( $self->cfg->{MOCK_FIREWALL} ) {
182 6         34 DEBUG 'MOCK_FIREWALL, mocking ipset';
183 6         74 return {};
184             }
185              
186 0         0 my @cmd = qw(ipset list capo_sessions_ipset);
187              
188 0         0 my ( $stdout, $error );
189 0     0   0 try { ($stdout) = $self->spawn_cmd(@cmd) } catch { $error = $_ };
  0         0  
  0         0  
190              
191 0 0       0 LOGDIE $error if $error;
192              
193 0         0 my @lines = split "\n+", $stdout;
194              
195             # ipv4 address in quad decimal
196 0         0 my $ip_quad_dec_rx = qr(\d{1,3} \. \d{1,3} \. \d{1,3} \. \d{1,3})x;
197              
198             # regex for MAC address matching
199 0         0 my $hex_digit_rx = qr/[A-F,a-f,0-9]/;
200 0         0 my $mac_rx = qr/(?:$hex_digit_rx{2}:){5} $hex_digit_rx{2}/x;
201              
202             ####
203             # parse the output of:
204             # ipset list capo_sessions_ipset
205             #
206             # this looks like:
207             #----------------
208             # Name: capo_sessions_ipset
209             # Type: bitmap:ip,mac
210             # References: 2
211             # Default binding:
212             # Header: from: 10.10.0.0 to: 10.10.0.255
213             # Members:
214             # 10.10.0.2,00:15:2C:FA:BB:80
215             # 10.10.0.3,00:15:2C:FA:DB:80
216             # 10.10.0.15,00:11:63:9C:9B:85
217             # 10.10.0.21,00:1F:4F:EC:B9:42
218             # 10.10.0.30,00:54:81:21:7B:01
219             # ...
220             # Bindings:
221              
222 0         0 my $sessions = {};
223 0         0 foreach my $line (@lines) {
224              
225             # skip emtpy lines from ipset list
226 0 0       0 next if $line =~ m/^\s*$/;
227              
228             # skip comment lines from ipset list
229 0 0       0 next if $line =~ m/:\s|:\Z/;
230              
231 0         0 $line =~ m/^\s* ($ip_quad_dec_rx) , ($mac_rx) \s* $/x;
232 0         0 my $ip = $1;
233 0         0 my $mac = $2;
234              
235 0 0 0     0 unless ( defined $ip && defined $mac ) {
236 0         0 ERROR "Couldn't parse line: $line";
237 0         0 next;
238             }
239              
240 0         0 $sessions->{$ip} = uc $mac;
241             }
242              
243 0         0 return $sessions;
244             }
245              
246             =item $capo->fw_list_activity()
247              
248             Reads and flushes the ipset 'capo_activity_ipset' and returns a hashref for the tuples { ip => timeout, ... }
249              
250             Captive::Portal doesn't rely on JavaScript or any other client technology to test for idle clients. A cronjob must call periodically:
251              
252             capo-ctl.pl [-f capo.cfg] [-l log4perl.cfg] purge
253              
254             in order to detect idle clients. The firewall rules add active clients to the ipset 'capo_activity_ipset' and the purger reads this set for activity checks.
255              
256             =cut
257              
258             sub fw_list_activity {
259 3     3 1 8 my $self = shift;
260              
261 3 50       13 if ( $self->cfg->{MOCK_FIREWALL} ) {
262 3         32 DEBUG 'MOCK_FIREWALL, mocking ipset';
263 3         37 return {};
264             }
265              
266 0           my ( $stdout, $error );
267             try {
268 0     0     ($stdout) = $self->spawn_cmd(qw(ipset list capo_activity_ipset));
269             }
270             catch {
271 0     0     $error = $_;
272 0           };
273              
274 0 0         LOGDIE $error if $error;
275              
276 0           my @lines = split "\n+", $stdout;
277              
278             # ipv4 address in quad decimal
279 0           my $ip_quad_dec_rx = qr(\d{1,3} \. \d{1,3} \. \d{1,3} \. \d{1,3})x;
280              
281             ####
282             # parse the output of:
283             # ipset list capo_activity_ipset
284             #
285             # this looks like:
286             #----------------
287             # Name: capo_activity_ipset
288             # ...
289             # Type: bitmap:ip
290             # Header: range 10.10.0.0-10.10.255.255 timeout 600
291             # Size in memory: 1048688
292             # References: 0
293             # Members:
294             # 10.10.7.7 timeout 98
295              
296 0           my $active_clients = {};
297 0           foreach my $line (@lines) {
298              
299             # skip emtpy lines from ipset list
300 0 0         next if $line =~ m/^\s*$/;
301              
302             # skip comment lines from ipset list
303 0 0         next if $line =~ m/:\s|:\Z/;
304              
305 0           $line =~ m/^\s* ($ip_quad_dec_rx) \s+ timeout \s+ (\d+) /x;
306 0           my $ip = $1;
307 0           my $timeout = $2;
308              
309 0 0 0       unless ( defined $ip && defined $timeout ) {
310 0           ERROR "Couldn't parse line: $line";
311 0           next;
312             }
313              
314 0           $active_clients->{$ip} = $timeout;
315             }
316              
317 0           return $active_clients;
318             }
319              
320             =item $capo->fw_clear_sessions()
321              
322             Flushes the ipset 'capo_sessions_ipset', normally used in start/stop scripts, see capo-ctl.pl.
323              
324             =cut
325              
326             sub fw_clear_sessions {
327 0     0 1   my $self = shift;
328              
329 0           $self->$_fw_install_rules('flush_capo_sessions');
330             }
331              
332             =item $capo->fw_start()
333              
334             Calls the firewall templates in the order flush, init, mangle, nat and filter, see the corresponding firewall templates under I. After the init step the ipsets are filled via I from disc cache.
335              
336             =cut
337              
338             sub fw_start {
339 0     0 1   my $self = shift;
340              
341 0 0         if ( $self->cfg->{MOCK_FIREWALL} ) {
342 0           DEBUG 'MOCK_FIREWALL, mocking start firewall';
343 0           return 1;
344             }
345              
346             # proper order of steps is essential for uninterrupted reloads
347              
348 0           foreach my $step (qw/flush init mangle nat filter/) {
349              
350 0           $self->$_fw_install_rules($step);
351              
352             # after the init step prefill the capo_sessions
353             # with cached sessions from disk
354 0 0         $self->fw_reload_sessions if $step eq 'init';
355             }
356             }
357              
358             =item $capo->fw_stop()
359              
360             Calls the firewall template I, see the corresponding firewall template under I.
361              
362             =cut
363              
364             sub fw_stop {
365 0     0 1   my $self = shift;
366              
367 0 0         if ( $self->cfg->{MOCK_FIREWALL} ) {
368 0           DEBUG 'MOCK_FIREWALL, mocking stop firewall';
369 0           return 1;
370             }
371              
372 0           $self->$_fw_install_rules('flush');
373             }
374              
375             =item $capo->fw_purge_sessions()
376              
377             Detect idle sessions, mark them as IDLE in disk cache and remove entry in ipset.
378              
379             =cut
380              
381             sub fw_purge_sessions {
382 0     0 1   my $self = shift;
383              
384 0           DEBUG 'running ' . __PACKAGE__ . ' fw_purge_sessions ...';
385              
386 0 0         if ( $self->cfg->{MOCK_FIREWALL} ) {
387 0           DEBUG 'MOCK_FIREWALL, mocking purge';
388 0           return 1;
389             }
390              
391 0           my $this_run = time();
392              
393             ######
394             # 3 sources of information about a session
395             #
396             # - session cache on disk with ip/mac/user/state/timestamps/...
397             # - ipset capo_sessions_ipset with ip address as key, mac address as value
398             # - ipset capo_activity_ipset with ip address as key, mac address as value
399             #
400              
401 0           my $fw_sessions = $self->fw_list_sessions;
402 0           my $fw_activity = $self->fw_list_activity;
403              
404             # Walk over all disk sessions, be aware, only current session is locked!
405              
406             # There will be race conditions with running fcgi processes
407             # for sessions not currently handled (locked), but see below
408             # for handling these races.
409             #
410             # This is by intention not locking for a long time and delaying
411             # http responses!
412              
413 0           foreach my $ip ( $self->list_sessions_from_disk ) {
414              
415 0           my ( $lock_handle, $error );
416             try {
417              
418             # get the EXCL lock for the session file
419             # hold this lock until next loop iteration
420             # via lexical scope of $lock_handle
421             #
422 0     0     $lock_handle = $self->get_session_lock_handle(
423             key => $ip,
424             blocking => 1,
425             shared => 0, # EXCL
426             timeout => 50_000, # 50_000 us -> 50ms
427             );
428              
429             }
430 0     0     catch { $error = $_ };
  0            
431              
432 0 0         if ($error) {
433 0           WARN $error; # could not get the EXCL lock, skip this session
434 0           next; # session
435             }
436              
437 0           my $session = $self->read_session_handle($lock_handle);
438              
439 0 0         unless ($session) {
440 0           DEBUG "delete empty or malformed session: $ip";
441 0           $self->delete_session_from_disk($ip);
442              
443 0           next; # session
444             }
445              
446             # The session ip must also be in the ipset capo_sessions_ipset.
447             # fetch and delete it. If there are still ipset entries
448             # left after the loop over all sessions, handle it as error
449             # or as race condition at end of the purger
450              
451 0           my $fw_session_entry = delete $fw_sessions->{$ip};
452              
453             # tmp store for easier logging, no other functionality
454 0           my $mac = $session->{MAC};
455 0           my $user = $session->{USERNAME};
456              
457             ######## let's start
458              
459             ###########################################################
460             # remove old sessions with STATES like (logout, idle, max-session-...)
461             # after KEEP_OLD_STATE_PERIOD
462             ###########################################################
463              
464 0 0         if ( $session->{STATE} ne 'active' ) {
465              
466             # remove really old sessions not in active STATE
467 0 0         if ( $this_run - $session->{STOP_TIME} >
468             $self->cfg->{KEEP_OLD_STATE_PERIOD} )
469             {
470 0           DEBUG "$user/$ip/$mac" . ' -> delete old session from disk cache';
471              
472 0           my $error;
473 0     0     try { $self->delete_session_from_disk($ip) } catch { $error = $_ };
  0            
  0            
474              
475 0 0         ERROR $error if $error;
476             }
477              
478 0           next; # session
479             }
480              
481             ###############################################################
482             # SESSION_MAX limit reached, stop/mark active and idle sessions
483             ###############################################################
484              
485 0           my $session_start = $session->{START_TIME};
486 0           my $session_max = $self->cfg->{SESSION_MAX};
487              
488 0 0 0       if ( ( $this_run - $session_start > $session_max )
      0        
489             && ( $session->{STATE} eq 'active' || $session->{STATE} eq 'idle' ) )
490             {
491              
492 0           INFO "$user/$ip/$mac -> stopped, MAX_SESSION limit";
493              
494 0           my $error;
495 0     0     try { $self->fw_stop_session($ip) } catch { $error = $_ };
  0            
  0            
496              
497 0 0         ERROR $error if $error;
498              
499 0           $session->{STATE} = 'max-session-timeout';
500 0           $session->{STOP_TIME} = $this_run;
501              
502 0           undef $error;
503             try {
504 0     0     $self->write_session_handle( $lock_handle, $session );
505             }
506 0     0     catch { $error = $_ };
  0            
507              
508 0 0         ERROR $error if $error;
509              
510 0           next; # session
511             }
512              
513 0 0         next unless $session->{STATE} eq 'active';
514              
515             ################################################################
516             # below this point we handle only sessions with STATE = active
517             ################################################################
518              
519             ###########################################################
520             # ipset-entry was missing for current session at
521             # mainloop entry. Maybe it was a race condition.
522             # Check if there is still no ipset-entry for this session
523             # now we have the lock.
524             #
525             # We don't check this unconditionally for every session,
526             # this would be to expansive for thousand of clients.
527             ###########################################################
528              
529 0 0 0       if ( ( not defined $fw_session_entry )
530             and ( not defined $self->fw_list_sessions->{$ip} ) )
531             {
532              
533 0           WARN "$user/$ip/$mac -> delete session, ipset-entry missing";
534              
535 0           my $error;
536 0     0     try { $self->delete_session_from_disk($ip); } catch { $error = $_ };
  0            
  0            
537              
538 0 0         ERROR $error if $error;
539              
540 0           next; # session
541             }
542              
543             ###########################################################
544             ###########################################################
545             # now start with the IDLE check for this active session
546             ###########################################################
547             ###########################################################
548              
549             ###########################################################
550             # packets seen from this client within last IDLE_TIME period?
551             # the capo_activity_ipset has the internal countdown timer
552             # set with IDLE_TIME, great thanks to the ipset developers!
553             ###########################################################
554              
555 0 0         next if exists $fw_activity->{$ip};
556              
557             ###########################################################
558             ###########################################################
559             # after that the client wasn't seen for IDLE_TIME
560             ###########################################################
561             ###########################################################
562              
563 0           INFO "$user/$ip/$mac -> session is IDLE";
564              
565 0           $session->{STATE} = 'idle';
566 0           $session->{STOP_TIME} = $this_run;
567              
568 0           undef $error;
569             try {
570 0     0     $self->fw_stop_session($ip);
571 0           $self->write_session_handle( $lock_handle, $session );
572             }
573 0     0     catch { $error = $_ };
  0            
574 0 0         ERROR $error if $error;
575              
576 0           next; # session
577              
578             } # session mainloop end
579              
580             ###########################################################
581             # Handle remaining ipset session entries with
582             # no corresponding session file. Be careful,
583             # maybe a race condition between purger and fcgi script
584             # was the reason for that inconsistency
585             ###########################################################
586              
587 0           foreach my $ip ( keys %{$fw_sessions} ) {
  0            
588              
589             # check if there is still no session file for that ipset entry
590             #
591 0           my ( $lock_handle, $error );
592             try {
593              
594             # get the EXCL lock for the session
595             # hold this lock until next loop iteration
596             #
597 0     0     $lock_handle = $self->get_session_lock_handle(
598             key => $ip,
599             blocking => 1,
600             shared => 0,
601             timeout => 50_000, # 50_000 us -> 50ms
602             );
603              
604             }
605 0     0     catch { $error = $_ };
  0            
606              
607 0 0         if ($error) {
608 0           WARN $error;
609 0           next;
610             }
611              
612 0           my $session = $self->read_session_handle($lock_handle);
613              
614             # skip, now we have a valid session for this ipset session entry
615 0 0         next if $session;
616              
617             # Still no session for this ipset session entry, but
618             # we have the lock, now we can check if the ipset entry
619             # is still set
620              
621 0 0         next unless defined $self->fw_list_sessions->{$ip};
622              
623 0           WARN "$ip -> delete ipset entry without session file";
624              
625 0           undef $error;
626 0     0     try { $self->fw_stop_session($ip) } catch { $error = $_ };
  0            
  0            
627 0 0         ERROR $error if $error;
628              
629 0           next;
630             }
631             }
632              
633             # ATTENTION
634             # private method, not exported to Captive::Portal
635             #
636             # $capo->$_fw_install_rules($template_name);
637             #
638             # Reads the template, sanitize it and call the commands in the template file via spawn_cmd
639             #
640              
641             $_fw_install_rules = sub {
642             my $self = shift;
643             my $step = shift
644             or LOGDIE "missing param 'step'";
645              
646             my $cmds;
647             my $template = "firewall/${step}.tt";
648             my $tmpl_vars = {
649             %{ $self->cfg->{IPTABLES} },
650             IDLE_TIME => $self->cfg->{IDLE_TIME},
651             ipv4_aton => $self->can('ipv4_aton'),
652             };
653              
654             DEBUG "get the firewall $step commands via template $template";
655              
656             $self->{template}->process( $template, $tmpl_vars, \$cmds )
657             or LOGDIE( $self->{template}->error . "\n" );
658              
659             ##############################################
660             # mangle the command lines
661             #
662              
663             # remove comment lines
664             $cmds =~ s/^ \s* \# .* $ \n//xmg;
665              
666             # remove empty lines
667             $cmds =~ s/^ \s* $ \n//xmg;
668              
669             # concat continuation lines
670             $cmds =~ s/\\ \s* $ \n \s*/ /xmg;
671              
672             # remove leading whitespace
673             $cmds =~ s/^ \s* //xmg;
674              
675             my @cmds = split( /\n/, $cmds );
676              
677             #
678             #################################################
679              
680             foreach my $cmd (@cmds) {
681             my @cmd = split( /\s+/, $cmd );
682              
683             my $error;
684             try { $self->spawn_cmd(@cmd) } catch { $error = $_ };
685              
686             die $error if $error;
687             }
688             };
689              
690             1;
691              
692             =back
693              
694             =head1 AUTHOR
695              
696             Karl Gaissmaier, C<< >>
697              
698             =head1 LICENSE AND COPYRIGHT
699              
700             Copyright 2010-2013 Karl Gaissmaier, all rights reserved.
701              
702             This distribution is free software; you can redistribute it and/or modify it
703             under the terms of either:
704              
705             a) the GNU General Public License as published by the Free Software
706             Foundation; either version 2, or (at your option) any later version, or
707              
708             b) the Artistic License version 2.0.
709              
710             =cut
711              
712             # vim: sw=2