line |
stmt |
bran |
cond |
sub |
pod |
time |
code |
1
|
|
|
|
|
|
|
package Mail::Milter::Authentication::Handler::IPRev; |
2
|
29
|
|
|
29
|
|
17113
|
use 5.20.0; |
|
29
|
|
|
|
|
148
|
|
3
|
29
|
|
|
29
|
|
193
|
use strict; |
|
29
|
|
|
|
|
121
|
|
|
29
|
|
|
|
|
725
|
|
4
|
29
|
|
|
29
|
|
227
|
use warnings; |
|
29
|
|
|
|
|
101
|
|
|
29
|
|
|
|
|
977
|
|
5
|
29
|
|
|
29
|
|
216
|
use Mail::Milter::Authentication::Pragmas; |
|
29
|
|
|
|
|
96
|
|
|
29
|
|
|
|
|
286
|
|
6
|
|
|
|
|
|
|
# ABSTRACT: Handler class for IPRev |
7
|
|
|
|
|
|
|
our $VERSION = '3.20230911'; # VERSION |
8
|
29
|
|
|
29
|
|
7127
|
use base 'Mail::Milter::Authentication::Handler'; |
|
29
|
|
|
|
|
125
|
|
|
29
|
|
|
|
|
2782
|
|
9
|
29
|
|
|
29
|
|
742
|
use Net::DNS; |
|
29
|
|
|
|
|
2977
|
|
|
29
|
|
|
|
|
3148
|
|
10
|
29
|
|
|
29
|
|
217
|
use Net::IP; |
|
29
|
|
|
|
|
105
|
|
|
29
|
|
|
|
|
68938
|
|
11
|
|
|
|
|
|
|
|
12
|
|
|
|
|
|
|
sub default_config { |
13
|
0
|
|
|
0
|
0
|
0
|
return {}; |
14
|
|
|
|
|
|
|
} |
15
|
|
|
|
|
|
|
|
16
|
|
|
|
|
|
|
sub register_metrics { |
17
|
|
|
|
|
|
|
return { |
18
|
28
|
|
|
28
|
1
|
297
|
'iprev_total' => 'The number of emails processed for IPRev', |
19
|
|
|
|
|
|
|
}; |
20
|
|
|
|
|
|
|
} |
21
|
|
|
|
|
|
|
|
22
|
|
|
|
|
|
|
sub grafana_rows { |
23
|
0
|
|
|
0
|
0
|
0
|
my ( $self ) = @_; |
24
|
0
|
|
|
|
|
0
|
my @rows; |
25
|
0
|
|
|
|
|
0
|
push @rows, $self->get_json( 'IPRev_metrics' ); |
26
|
0
|
|
|
|
|
0
|
return \@rows; |
27
|
|
|
|
|
|
|
} |
28
|
|
|
|
|
|
|
|
29
|
|
|
|
|
|
|
sub _dns_error { |
30
|
0
|
|
|
0
|
|
0
|
my ( $self, $type, $data, $error ) = @_; |
31
|
0
|
0
|
|
|
|
0
|
if ( $error eq 'NXDOMAIN' ) { |
|
|
0
|
|
|
|
|
|
32
|
0
|
|
|
|
|
0
|
$self->dbgout( "DNS $type Lookup", "$data gave $error", LOG_DEBUG ); |
33
|
|
|
|
|
|
|
} |
34
|
|
|
|
|
|
|
elsif ( $error eq 'NOERROR' ) { |
35
|
0
|
|
|
|
|
0
|
$self->dbgout( "DNS $type Lookup", "$data gave $error", LOG_DEBUG ); |
36
|
|
|
|
|
|
|
} |
37
|
|
|
|
|
|
|
else { |
38
|
|
|
|
|
|
|
# Could be SERVFAIL or something else |
39
|
0
|
|
|
|
|
0
|
$self->log_error( |
40
|
|
|
|
|
|
|
'DNS ' . $type . ' query failed for ' |
41
|
|
|
|
|
|
|
. $data |
42
|
|
|
|
|
|
|
. ' with ' |
43
|
|
|
|
|
|
|
. $error ); |
44
|
|
|
|
|
|
|
} |
45
|
|
|
|
|
|
|
} |
46
|
|
|
|
|
|
|
|
47
|
|
|
|
|
|
|
sub connect_requires { |
48
|
20
|
|
|
20
|
0
|
243
|
my ($self) = @_; |
49
|
20
|
|
|
|
|
331
|
my @requires = qw{ LocalIP TrustedIP }; |
50
|
20
|
|
|
|
|
633
|
return \@requires; |
51
|
|
|
|
|
|
|
} |
52
|
|
|
|
|
|
|
|
53
|
|
|
|
|
|
|
sub connect_callback { |
54
|
68
|
|
|
68
|
0
|
324
|
my ( $self, $hostname, $ip ) = @_; |
55
|
68
|
100
|
|
|
|
676
|
return if ( $self->is_local_ip_address() ); |
56
|
60
|
100
|
|
|
|
470
|
return if ( $self->is_trusted_ip_address() ); |
57
|
58
|
|
|
|
|
836
|
my $ip_address = $self->ip_address(); |
58
|
58
|
|
|
|
|
440
|
my $i1 = $ip; |
59
|
58
|
|
|
|
|
469
|
my $resolver = $self->get_object('resolver'); |
60
|
|
|
|
|
|
|
|
61
|
58
|
|
|
|
|
176
|
my $lookup_limit = 10; |
62
|
|
|
|
|
|
|
# Make this a config item |
63
|
|
|
|
|
|
|
|
64
|
58
|
|
|
|
|
181
|
my $ptr_list = {}; |
65
|
58
|
|
|
|
|
197
|
my @error_list; |
66
|
|
|
|
|
|
|
|
67
|
|
|
|
|
|
|
my @cname_hosts; |
68
|
|
|
|
|
|
|
|
69
|
58
|
|
|
|
|
1677
|
my $packet = $resolver->query( $ip_address, 'PTR' ); |
70
|
58
|
|
|
|
|
28752
|
$lookup_limit--; |
71
|
58
|
100
|
|
|
|
295
|
if ($packet) { |
72
|
22
|
|
|
|
|
157
|
foreach my $rr ( $packet->answer ) { |
73
|
22
|
100
|
|
|
|
432
|
if ( $rr->type eq "CNAME" ) { |
74
|
2
|
|
|
|
|
121
|
push @cname_hosts, $rr->rdstring; |
75
|
2
|
|
|
|
|
273
|
push @error_list, 'Found CNAME in PTR response'; |
76
|
|
|
|
|
|
|
} |
77
|
22
|
100
|
|
|
|
599
|
if ( $rr->type eq "PTR" ) { |
78
|
20
|
|
|
|
|
733
|
$ptr_list->{ $rr->rdstring } = []; |
79
|
|
|
|
|
|
|
} |
80
|
|
|
|
|
|
|
} |
81
|
|
|
|
|
|
|
} |
82
|
|
|
|
|
|
|
|
83
|
58
|
50
|
|
|
|
3362
|
if ( $resolver->errorstring() ) { |
84
|
0
|
|
|
|
|
0
|
$self->_dns_error( 'PTR', $ip_address, $resolver->errorstring ); |
85
|
0
|
0
|
|
|
|
0
|
push @error_list, 'Error ' . $resolver->errorstring() . " looking up $ip_address PTR" if ( $resolver->errorstring() ne 'unknown error or no error' ); |
86
|
|
|
|
|
|
|
} |
87
|
|
|
|
|
|
|
|
88
|
58
|
|
|
|
|
775
|
foreach my $cname_host ( @cname_hosts ) { |
89
|
2
|
|
|
|
|
14
|
my $packet = $resolver->query( $cname_host, 'PTR' ); |
90
|
2
|
|
|
|
|
500
|
$lookup_limit--; |
91
|
2
|
50
|
|
|
|
14
|
if ($packet) { |
92
|
2
|
|
|
|
|
9
|
foreach my $rr ( $packet->answer ) { |
93
|
|
|
|
|
|
|
#if ( $rr->type eq "CNAME" ) { |
94
|
|
|
|
|
|
|
# NO! We only follow the first level CNAMES |
95
|
|
|
|
|
|
|
# Because anything more is probably busted anyway |
96
|
|
|
|
|
|
|
#} |
97
|
2
|
50
|
|
|
|
22
|
if ( $rr->type eq "PTR" ) { |
98
|
2
|
|
|
|
|
52
|
$ptr_list->{ $rr->rdstring } = []; |
99
|
|
|
|
|
|
|
} |
100
|
|
|
|
|
|
|
} |
101
|
|
|
|
|
|
|
} |
102
|
2
|
50
|
|
|
|
183
|
if ( $resolver->errorstring() ) { |
103
|
0
|
|
|
|
|
0
|
$self->_dns_error( 'PTR', $cname_host, $resolver->errorstring ); |
104
|
0
|
0
|
|
|
|
0
|
push @error_list, 'Error ' . $resolver->errorstring() . " looking up $cname_host PTR" if ( $resolver->errorstring() ne 'unknown error or no error' ); |
105
|
|
|
|
|
|
|
} |
106
|
2
|
|
|
|
|
26
|
last; # Because multiple CNAMES for a given record is also busted |
107
|
|
|
|
|
|
|
} |
108
|
|
|
|
|
|
|
|
109
|
58
|
100
|
|
|
|
378
|
if ( ! keys %$ptr_list ) { |
110
|
36
|
|
|
|
|
200
|
push @error_list, "NOT FOUND"; |
111
|
|
|
|
|
|
|
} |
112
|
|
|
|
|
|
|
|
113
|
58
|
|
|
|
|
327
|
my @lookup_list = sort keys %$ptr_list; |
114
|
|
|
|
|
|
|
DOMAINLOOKUP: |
115
|
58
|
|
|
|
|
254
|
foreach my $domain ( @lookup_list ) { |
116
|
22
|
|
|
|
|
76
|
my $ip_list = []; |
117
|
22
|
|
|
|
|
53
|
my $cname; |
118
|
|
|
|
|
|
|
|
119
|
22
|
50
|
|
|
|
164
|
if ( $ip_address =~ /:/ ) { |
120
|
|
|
|
|
|
|
# We are living in the future! |
121
|
|
|
|
|
|
|
|
122
|
0
|
|
|
|
|
0
|
my $errors6; |
123
|
|
|
|
|
|
|
my $errors4; |
124
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $domain, $lookup_limit ); |
125
|
0
|
0
|
|
|
|
0
|
if ( $cname ) { |
126
|
0
|
|
|
|
|
0
|
push @error_list, 'Found CNAME in AAAA response'; |
127
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit ); |
128
|
0
|
0
|
|
|
|
0
|
if ( ! @$ip_list ) { |
129
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit ); |
130
|
|
|
|
|
|
|
} |
131
|
|
|
|
|
|
|
} |
132
|
0
|
0
|
|
|
|
0
|
if ( ! @$ip_list ) { |
133
|
|
|
|
|
|
|
# We got nothing, try ip4 |
134
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors4, $cname ) = $self->_address_for_domain( 'A', $domain, $lookup_limit ); |
135
|
0
|
0
|
|
|
|
0
|
if ( $cname ) { |
136
|
0
|
|
|
|
|
0
|
push @error_list, 'Found CNAME in A response'; |
137
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit ); |
138
|
0
|
0
|
|
|
|
0
|
if ( ! @$ip_list ) { |
139
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit ); |
140
|
|
|
|
|
|
|
} |
141
|
|
|
|
|
|
|
} |
142
|
|
|
|
|
|
|
} |
143
|
0
|
0
|
|
|
|
0
|
if ( ! @$ip_list ) { |
144
|
0
|
|
|
|
|
0
|
foreach my $error ( @$errors4 ) { |
145
|
0
|
|
|
|
|
0
|
push @error_list, "Error $error looking up $domain A"; |
146
|
|
|
|
|
|
|
} |
147
|
0
|
|
|
|
|
0
|
foreach my $error ( @$errors6 ) { |
148
|
0
|
|
|
|
|
0
|
push @error_list, "Error $error looking up $domain AAAA"; |
149
|
|
|
|
|
|
|
} |
150
|
|
|
|
|
|
|
} |
151
|
|
|
|
|
|
|
|
152
|
|
|
|
|
|
|
} |
153
|
|
|
|
|
|
|
else { |
154
|
|
|
|
|
|
|
|
155
|
22
|
|
|
|
|
96
|
my $errors6; |
156
|
|
|
|
|
|
|
my $errors4; |
157
|
22
|
|
|
|
|
170
|
( $lookup_limit, $ip_list, $errors4, $cname ) = $self->_address_for_domain( 'A', $domain, $lookup_limit ); |
158
|
22
|
100
|
|
|
|
120
|
if ( $cname ) { |
159
|
2
|
|
|
|
|
9
|
push @error_list, 'Found CNAME in A response'; |
160
|
2
|
|
|
|
|
10
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit ); |
161
|
2
|
50
|
|
|
|
13
|
if ( ! @$ip_list ) { |
162
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit ); |
163
|
|
|
|
|
|
|
} |
164
|
|
|
|
|
|
|
} |
165
|
22
|
50
|
|
|
|
146
|
if ( ! @$ip_list ) { |
166
|
|
|
|
|
|
|
# We got nothing, try ip6 |
167
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $domain, $lookup_limit ); |
168
|
0
|
0
|
|
|
|
0
|
if ( $cname ) { |
169
|
0
|
|
|
|
|
0
|
push @error_list, 'Found CNAME in AAAA response'; |
170
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit ); |
171
|
0
|
0
|
|
|
|
0
|
if ( ! @$ip_list ) { |
172
|
0
|
|
|
|
|
0
|
( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit ); |
173
|
|
|
|
|
|
|
} |
174
|
|
|
|
|
|
|
} |
175
|
|
|
|
|
|
|
} |
176
|
22
|
50
|
|
|
|
164
|
if ( ! @$ip_list ) { |
177
|
0
|
|
|
|
|
0
|
foreach my $error ( @$errors4 ) { |
178
|
0
|
0
|
|
|
|
0
|
push @error_list, "Error $error looking up $domain A" if ( $resolver->errorstring() ne 'unknown error or no error' ); |
179
|
|
|
|
|
|
|
} |
180
|
0
|
|
|
|
|
0
|
foreach my $error ( @$errors6 ) { |
181
|
0
|
0
|
|
|
|
0
|
push @error_list, "Error $error looking up $domain AAAA" if ( $resolver->errorstring() ne 'unknown error or no error' ); |
182
|
|
|
|
|
|
|
} |
183
|
|
|
|
|
|
|
} |
184
|
|
|
|
|
|
|
|
185
|
|
|
|
|
|
|
} |
186
|
|
|
|
|
|
|
|
187
|
22
|
|
|
|
|
94
|
$ptr_list->{ $domain } = $ip_list; |
188
|
|
|
|
|
|
|
|
189
|
|
|
|
|
|
|
} |
190
|
|
|
|
|
|
|
|
191
|
58
|
|
|
|
|
178
|
my @match_list; |
192
|
58
|
|
|
|
|
292
|
foreach my $domain ( sort keys %$ptr_list ) { |
193
|
22
|
|
|
|
|
67
|
foreach my $address ( sort @{ $ptr_list->{ $domain } } ) { |
|
22
|
|
|
|
|
94
|
|
194
|
22
|
|
|
|
|
138
|
my $i2 = Net::IP->new($address); |
195
|
22
|
50
|
|
|
|
17053
|
if ( !$i2 ) { |
196
|
0
|
|
|
|
|
0
|
$self->log_error( 'IPRev: Could not parse IP '.$address ); |
197
|
|
|
|
|
|
|
} |
198
|
|
|
|
|
|
|
else { |
199
|
22
|
|
50
|
|
|
231
|
my $is_overlap = $i1->overlaps($i2) || 0; |
200
|
22
|
50
|
|
|
|
1218
|
if ( $is_overlap == $IP_IDENTICAL ) { |
201
|
22
|
|
|
|
|
146
|
$domain =~ s/\.$//; |
202
|
22
|
|
|
|
|
163
|
push @match_list, $domain; |
203
|
|
|
|
|
|
|
} |
204
|
|
|
|
|
|
|
} |
205
|
|
|
|
|
|
|
} |
206
|
|
|
|
|
|
|
} |
207
|
|
|
|
|
|
|
|
208
|
58
|
100
|
|
|
|
283
|
if ( ! @match_list ) { |
209
|
|
|
|
|
|
|
# Failed to match IP against looked up domains |
210
|
36
|
|
|
|
|
170
|
my $comment = join( ',', @error_list ); |
211
|
36
|
|
|
|
|
321
|
$self->dbgout( 'IPRevCheck', "fail - $comment", LOG_DEBUG ); |
212
|
36
|
|
|
|
|
1498
|
my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'fail' ); |
213
|
36
|
|
|
|
|
5456
|
$header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) ); |
214
|
36
|
|
|
|
|
5883
|
$header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) ); |
215
|
36
|
|
|
|
|
9267
|
$self->add_c_auth_header($header); |
216
|
36
|
|
|
|
|
435
|
$self->metric_count( 'iprev_total', { 'result' => 'fail'} ); |
217
|
|
|
|
|
|
|
} |
218
|
|
|
|
|
|
|
else { |
219
|
|
|
|
|
|
|
# We have a pass |
220
|
22
|
|
|
|
|
100
|
my $comment = join( ',', @match_list ); |
221
|
22
|
|
|
|
|
119
|
$self->{'verified_ptr'} = $comment; |
222
|
22
|
|
|
|
|
204
|
$self->dbgout( 'IPRevCheck', "pass - $comment", LOG_DEBUG ); |
223
|
22
|
|
|
|
|
647
|
my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'pass' ); |
224
|
22
|
|
|
|
|
2716
|
$header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) ); |
225
|
22
|
|
|
|
|
3178
|
$header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) ); |
226
|
22
|
|
|
|
|
9463
|
$self->add_c_auth_header($header); |
227
|
22
|
|
|
|
|
222
|
$self->metric_count( 'iprev_total', { 'result' => 'pass'} ); |
228
|
|
|
|
|
|
|
} |
229
|
|
|
|
|
|
|
} |
230
|
|
|
|
|
|
|
|
231
|
|
|
|
|
|
|
sub _address_for_domain { |
232
|
24
|
|
|
24
|
|
111
|
my ( $self, $type, $domain, $lookup_limit ) = @_; |
233
|
|
|
|
|
|
|
|
234
|
24
|
|
|
|
|
129
|
my @fwd_errors; |
235
|
|
|
|
|
|
|
my @ip_list; |
236
|
24
|
|
|
|
|
0
|
my $cname; |
237
|
|
|
|
|
|
|
|
238
|
24
|
|
|
|
|
135
|
my $resolver = $self->get_object('resolver'); |
239
|
|
|
|
|
|
|
|
240
|
24
|
|
|
|
|
108
|
$lookup_limit--; |
241
|
24
|
50
|
|
|
|
146
|
if ( $lookup_limit <= 0 ) { |
242
|
0
|
|
|
|
|
0
|
return ( 0, \@ip_list, [ 'Lookup limit reached' ] ); |
243
|
|
|
|
|
|
|
} |
244
|
24
|
|
|
|
|
108
|
my $packet = $resolver->query( $domain, $type ); |
245
|
|
|
|
|
|
|
|
246
|
24
|
50
|
|
|
|
4829
|
if ($packet) { |
247
|
24
|
|
|
|
|
165
|
foreach my $rr ( $packet->answer ) { |
248
|
24
|
100
|
|
|
|
340
|
if ( lc $rr->type eq 'cname' ) { |
249
|
2
|
|
|
|
|
33
|
$cname = $rr->rdstring; |
250
|
|
|
|
|
|
|
# Multiple CNAMES are broken, but we don't check for that |
251
|
|
|
|
|
|
|
# We just take the last one we found |
252
|
|
|
|
|
|
|
} |
253
|
24
|
100
|
|
|
|
695
|
if ( lc $rr->type eq lc $type ) { |
254
|
22
|
|
|
|
|
522
|
push @ip_list, $rr->rdstring; |
255
|
|
|
|
|
|
|
} |
256
|
|
|
|
|
|
|
} |
257
|
|
|
|
|
|
|
} |
258
|
|
|
|
|
|
|
|
259
|
24
|
50
|
|
|
|
1812
|
if ( $resolver->errorstring() ) { |
260
|
0
|
|
|
|
|
0
|
$self->_dns_error( $type, $domain, $resolver->errorstring ); |
261
|
0
|
|
|
|
|
0
|
push @fwd_errors, 'Error ' . $resolver->errorstring() . " looking up $domain $type"; |
262
|
|
|
|
|
|
|
} |
263
|
|
|
|
|
|
|
|
264
|
24
|
|
|
|
|
519
|
return ( $lookup_limit, \@ip_list, \@fwd_errors, $cname ); |
265
|
|
|
|
|
|
|
} |
266
|
|
|
|
|
|
|
|
267
|
|
|
|
|
|
|
sub close_callback { |
268
|
104
|
|
|
104
|
0
|
361
|
my ( $self ) = @_; |
269
|
104
|
|
|
|
|
398
|
delete $self->{'verified_ptr'}; |
270
|
|
|
|
|
|
|
} |
271
|
|
|
|
|
|
|
|
272
|
|
|
|
|
|
|
1; |
273
|
|
|
|
|
|
|
|
274
|
|
|
|
|
|
|
__END__ |
275
|
|
|
|
|
|
|
|
276
|
|
|
|
|
|
|
=pod |
277
|
|
|
|
|
|
|
|
278
|
|
|
|
|
|
|
=encoding UTF-8 |
279
|
|
|
|
|
|
|
|
280
|
|
|
|
|
|
|
=head1 NAME |
281
|
|
|
|
|
|
|
|
282
|
|
|
|
|
|
|
Mail::Milter::Authentication::Handler::IPRev - Handler class for IPRev |
283
|
|
|
|
|
|
|
|
284
|
|
|
|
|
|
|
=head1 VERSION |
285
|
|
|
|
|
|
|
|
286
|
|
|
|
|
|
|
version 3.20230911 |
287
|
|
|
|
|
|
|
|
288
|
|
|
|
|
|
|
=head1 DESCRIPTION |
289
|
|
|
|
|
|
|
|
290
|
|
|
|
|
|
|
Check reverse IP lookups. |
291
|
|
|
|
|
|
|
|
292
|
|
|
|
|
|
|
=head1 CONFIGURATION |
293
|
|
|
|
|
|
|
|
294
|
|
|
|
|
|
|
No configuration options exist for this handler. |
295
|
|
|
|
|
|
|
|
296
|
|
|
|
|
|
|
=head1 AUTHOR |
297
|
|
|
|
|
|
|
|
298
|
|
|
|
|
|
|
Marc Bradshaw <marc@marcbradshaw.net> |
299
|
|
|
|
|
|
|
|
300
|
|
|
|
|
|
|
=head1 COPYRIGHT AND LICENSE |
301
|
|
|
|
|
|
|
|
302
|
|
|
|
|
|
|
This software is copyright (c) 2020 by Marc Bradshaw. |
303
|
|
|
|
|
|
|
|
304
|
|
|
|
|
|
|
This is free software; you can redistribute it and/or modify it under |
305
|
|
|
|
|
|
|
the same terms as the Perl 5 programming language system itself. |
306
|
|
|
|
|
|
|
|
307
|
|
|
|
|
|
|
=cut |