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 |