File Coverage

blib/lib/Net/DNSBL/Client.pm
Criterion Covered Total %
statement 180 191 94.2
branch 82 108 75.9
condition 34 44 77.2
subroutine 17 20 85.0
pod 8 8 100.0
total 321 371 86.5


line stmt bran cond sub pod time code
1             package Net::DNSBL::Client;
2 8     8   364164 use strict;
  8         22  
  8         322  
3 8     8   47 use warnings;
  8         13  
  8         238  
4 8     8   219 use 5.008;
  8         42  
  8         308  
5              
6 8     8   59 use Carp;
  8         13  
  8         648  
7 8     8   9769 use Net::DNS::Resolver;
  8         2317713  
  8         306  
8 8     8   83 use IO::Select;
  8         13  
  8         19504  
9              
10             our $VERSION = '0.205';
11              
12             =head1 NAME
13              
14             Net::DNSBL::Client - Client code for querying multiple DNSBLs
15              
16             =head1 SYNOPSIS
17              
18             use Net::DNSBL::Client;
19             my $c = Net::DNSBL::Client->new({ timeout => 3 });
20              
21             $c->query_ip('127.0.0.2', [
22             { domain => 'simple.dnsbl.tld' },
23             { domain => 'masked.dnsbl.tld', type => 'mask', data => '0.0.0.255' },
24             { domain => 'txt.dnsbl.tld', type => 'txt' },
25             { domain => 'need-a-key.example.net' }],
26             { lookup_keys => { 'need-a-key.example.net' => 'my_secret_key' }});
27              
28             # And later...
29             my $answers = $c->get_answers();
30              
31             =head1 METHODS
32              
33             =head2 Class Methods
34              
35             =over 4
36              
37             =item new ( $args )
38              
39             Returns a new Net::DNSBL::Client object.
40              
41             $args is a hash reference and may contain the following key-value pairs:
42              
43             =over 4
44              
45             =item resolver
46              
47             (optional) A Net::DNS::Resolver object. If not provided, a new resolver will be created.
48              
49             =item timeout
50              
51             (optional) An integer number of seconds to use as the upper time limit
52             for the query. If not provided, the default is 10 seconds. If provided,
53             timeout must be a I integer.
54              
55             =back
56              
57             =back
58              
59             =cut
60              
61             sub new
62             {
63 10     10 1 98248 my ($class, $args) = @_;
64 10         52 my $self = {
65             resolver => undef,
66             timeout => 10,
67             };
68 10         58 foreach my $possible_arg (keys(%$self)) {
69 20 100       99 if( exists $args->{$possible_arg} ) {
70 2         7 $self->{$possible_arg} = delete $args->{$possible_arg};
71             }
72             }
73 10 100       44 if (scalar(%$args)) {
74 3         20 croak("Unknown arguments to new: " .
75 1         9 join(', ', (sort { $a cmp $b } keys(%$args))));
76             }
77              
78             # Timeout must be a positive integer
79 9 100 100     162 if (($self->{timeout} !~ /^\d+$/) || $self->{timeout} <= 0) {
80 2         25 croak("Timeout must be a positive integer");
81             }
82              
83 7 50       118 $self->{resolver} = Net::DNS::Resolver->new() unless $self->{resolver};
84              
85 7         1551 $self->{in_flight} = 0;
86 7         108 $self->{early_exit} = 0;
87              
88 7         21 bless $self, $class;
89 7         31 return $self;
90             }
91              
92             =head2 Instance Methods
93              
94             =over 4
95              
96             =item get_resolver ( )
97              
98             Returns the Net::DNS::Resolver object used for DNS queries.
99              
100             =item get_timeout ( )
101              
102             Returns the timeout in seconds for queries.
103              
104             =item set_timeout ( $secs )
105              
106             Sets the timeout in seconds for queries.
107              
108             =item query_is_in_flight ( )
109              
110             Returns non-zero if "query" has been called, but "get_answers" has not
111             yet been called. Returns zero otherwise.
112              
113             =item query_ip ( $ipaddr, $dnsbls [, $options])
114              
115             Issues a set of DNS queries. Note that the I method returns as
116             soon as the DNS queries have been issued. It does I wait for
117             DNS responses to come in. Once I has been called, the
118             Net::DNSBL::Client object is said to have a query I. I
119             may not be called again while a query is in flight.
120              
121             $ipaddr is the text representation of an IPv4 or IPv6 address.
122              
123             $dnsbls is a reference to a list of DNSBL entries; each DNSBL entry
124             is a hash with the following members:
125              
126             =over 4
127              
128             =item domain
129              
130             (required) The domain to query. For example, I.
131              
132             =item type
133              
134             (optional) The type of DNSBL. Possible values are I, meaning
135             that any returned A record indicates a hit, I, meaning that one
136             of the returned A records must exactly match a given IP address,
137             I, meaning that one of the returned A records must evaluate to
138             non-zero when bitwise-ANDed against a given IP address, or I
139             meaning that TXT records should be looked up and returned (rather than
140             A records)a. If omitted, type defaults to I
141              
142             =item data
143              
144             (optional) For the I and I types, this data specifies the
145             required match or the bitwise-AND mask. In the case of a I type,
146             the data can be something like "0.0.0.4", or an integer like "8". In the
147             latter case, the integer I must range from 1 to 255 and is equivalent
148             to 0.0.0.I.
149              
150             =item userdata
151              
152             (optional) This element can be any scalar or reference that you like.
153             It is simply returned back unchanged in the list of hits.
154              
155             =back
156              
157             $options, if supplied, is a hash of options. Currently, three options
158             are defined:
159              
160             =over 4
161              
162             =item early_exit
163              
164             If set to 1, querying will stop after the first positive result is
165             received, even if other DNSBLs are being queried. Default is 0.
166              
167             =item return_all
168              
169             If set to 1, then the return value from I will contain
170             all DNSBLs that were supplied to I, even if the DNSBL did not
171             hit. If set to 0 (the default), then the return value from
172             I only returns entries for those DNSBLs that actually
173             hit.
174              
175             =item lookup_keys
176              
177             This is a hashref of domain_name => key. Some domains require a secret
178             key to be inserted just before the domain name; rather than including
179             the key in the domain, you can separate it out with the lookup_keys hash,
180             making the returned results more readable.
181              
182             =back
183              
184             =item query_domain ( $domain, $dnsbls [, $options])
185              
186             Similar to query_ip, but considers $domain to be a domain name rather
187             than an IP address, and does not reverse the domain.
188              
189             =item get_answers ( )
190              
191             This method may only be called while a query is in flight. It waits
192             for DNS replies to come back and returns a reference to a list of I.
193             Once I returns, a query is no longer in flight.
194              
195             Note that the list of hits is I returned in the same
196             order as the original list of DNSBLs supplied to I.
197              
198             Each hit in the returned list is a hash reference containing the
199             following elements:
200              
201             =over 4
202              
203             =item domain
204              
205             The domain of the DNSBL.
206              
207             =item hit
208              
209             Set to 1 if the DNSBL was hit or 0 if it was not. (You will only get
210             entries with hit set to 0 if you used the I option to I.)
211              
212             =item type
213              
214             The type of the DNSBL (normal, match or mask).
215              
216             =item data
217              
218             The data supplied (for normal and mask types)
219              
220             =item userdata
221              
222             The userdata as supplied in the I call
223              
224             =item actual_hits
225              
226             Reference to array containing actual A or TXT records returned by the
227             lookup that caused a hit.
228              
229             =item replycode
230              
231             The reply code from the DNS server (as a string). Likely to be
232             one of NOERROR, NXDOMAIN, SERVFAIL or TIMEOUT. (TIMEOUT is not
233             a real DNS reply code; it is synthesized by this Perl module if
234             the lookup times out.)
235              
236             =back
237              
238             The hit may contain other elements not documented here; you should count
239             on only the elements documented above.
240              
241             If no DNSBLs were hit, then a reference to a zero-element list is returned.
242              
243             =back
244              
245             =cut
246              
247             sub get_resolver
248             {
249 0     0 1 0 my ($self) = @_;
250 0         0 return $self->{resolver};
251             }
252              
253             sub get_timeout
254             {
255 0     0 1 0 my ($self) = @_;
256 0         0 return $self->{timeout};
257             }
258              
259             sub set_timeout
260             {
261 2     2 1 2062 my ($self, $secs) = @_;
262 2 50 66     19 if (($secs !~ /^\d+$/) || $secs <= 0) {
263 2         27 croak("Timeout must be a positive integer");
264             }
265 0         0 $self->{timeout} = $secs;
266 0         0 return $secs;
267             }
268              
269             sub query_is_in_flight
270             {
271 0     0 1 0 my ($self) = @_;
272 0         0 return $self->{in_flight};
273             }
274              
275             sub query_ip
276             {
277 16     16 1 251709 my ($self, $ipaddr, $dnsbls, $options) = @_;
278 16 100       116 croak('Cannot issue new query while one is in flight') if $self->{in_flight};
279 15 100       93 croak('First argument (ip address) is required') unless $ipaddr;
280 14 100       57 croak('Second argument (dnsbl list) is required') unless $dnsbls;
281              
282             # Reverse the IP address in preparation for lookups
283 13         1762 my $revip = $self->_reverse_address($ipaddr);
284              
285 12         164 return $self->query_domain($revip, $dnsbls, $options);
286             }
287              
288             sub query_domain
289             {
290 12     12 1 32 my ($self, $ip_or_domain, $dnsbls, $options) = @_;
291              
292 12 50       56 croak('Cannot issue new query while one is in flight') if $self->{in_flight};
293 12 50       40 croak('First argument (domain) is required') unless $ip_or_domain;
294 12 50       34 croak('Second argument (dnsbl list) is required') unless $dnsbls;
295              
296 12         465 foreach my $opt (qw(early_exit return_all)) {
297 24 100 100     108 if ($options && exists($options->{$opt})) {
298 2         16 $self->{$opt} = $options->{$opt};
299             } else {
300 22         72 $self->{$opt} = 0;
301             }
302             }
303              
304             # Build a hash of domains to query. The key is the domain;
305             # value is an arrayref of type/data pairs
306 12         60 $self->{domains} = $self->_build_domains($dnsbls);
307 12         27 my $lookup_keys = {};
308 12 100 100     165 if ($options && exists($options->{lookup_keys}) && ref($options->{lookup_keys}) eq 'HASH') {
      66        
309 1         4 $lookup_keys = $options->{lookup_keys};
310             }
311              
312 12         55 $self->_send_queries($ip_or_domain, $lookup_keys);
313             }
314              
315             sub get_answers
316             {
317 13     13 1 1892 my ($self) = @_;
318 13 100       76 croak("Cannot call get_answers unless a query is in flight")
319             unless $self->{in_flight};
320              
321 12         53 $self->_collect_results();
322              
323 12         30 my $ans = [];
324 12         20 foreach my $d (keys %{$self->{domains}}) {
  12         238  
325 12         22 foreach my $r (@{$self->{domains}->{$d}}) {
  12         40  
326 22 100 100     143 push(@$ans, $r) if ( $r->{hit} || $self->{return_all} );
327             }
328             }
329              
330 12         36 $self->{in_flight} = 0;
331 12         43 delete $self->{sel};
332 12         37 delete $self->{sock_to_domain};
333 12         50 delete $self->{domains};
334              
335 12         51 return $ans;
336             }
337              
338             sub _build_domains
339             {
340 12     12   29 my($self, $dnsbls) = @_;
341 12         26 my $domains = {};
342              
343 12         37 foreach my $entry (@$dnsbls) {
344 22   100     31 push(@{$domains->{$entry->{domain}}}, {
  22         248  
345             domain => $entry->{domain},
346             type => ($entry->{type} || 'normal'),
347             data => $entry->{data},
348             userdata => $entry->{userdata},
349             hit => 0,
350             replycode => 'TIMEOUT',
351             });
352             }
353 12         49 return $domains;
354             }
355              
356             sub _send_queries
357             {
358 12     12   45 my ($self, $ip_or_domain, $lookup_keys) = @_;
359              
360 12         164 $self->{in_flight} = 1;
361 12         134 $self->{sel} = IO::Select->new();
362 12         159 $self->{sock_to_domain} = {};
363              
364 12         23 foreach my $domain (keys(%{$self->{domains}})) {
  12         51  
365 12         27 my $lookup_key;
366 12 100 66     61 if (exists($lookup_keys->{$domain}) && ($lookup_keys->{$domain} ne '')) {
367 1         3 $lookup_key = '.' . $lookup_keys->{$domain};
368             } else {
369 11         24 $lookup_key = '';
370             }
371 12         20 my($sock1, $sock2);
372 12         21 foreach my $e (@{$self->{domains}->{$domain}}) {
  12         41  
373 22 100       70 if ($e->{type} eq 'txt') {
374 1   33     14 $sock1 ||= $self->{resolver}->bgsend("$ip_or_domain$lookup_key.$domain", 'TXT');
375 1 50       1082 unless ($sock1) {
376 0         0 die $self->{resolver}->errorstring;
377             }
378             } else {
379 21   66     179 $sock2 ||= $self->{resolver}->bgsend("$ip_or_domain$lookup_key.$domain", 'A');
380 21 50       21577 unless ($sock2) {
381 0         0 die $self->{resolver}->errorstring;
382             }
383             }
384 22 50 66     111 last if ($sock1 && $sock2);
385             }
386              
387              
388 12 100       47 if ($sock1) {
389 1         6 $self->{sock_to_domain}->{$sock1} = $domain;
390 1         9 $self->{sel}->add($sock1);
391             }
392 12 100       86 if ($sock2) {
393 11         55 $self->{sock_to_domain}->{$sock2} = $domain;
394 11         689 $self->{sel}->add($sock2);
395             }
396             }
397             }
398              
399             sub _collect_results
400             {
401 12     12   28 my ($self) = @_;
402              
403 12         38 my $terminate = time() + $self->{timeout};
404 12         198 my $sel = $self->{sel};
405              
406 12         22 my $got_a_hit = 0;
407              
408 12         52 while(time() <= $terminate) {
409 24         197 my $expire = $terminate - time();
410 24 50       70 $expire = 1 if ($expire < 1);
411 24         113 my @ready = $sel->can_read($expire);
412              
413 24 100       265333 return $got_a_hit unless scalar(@ready);
414              
415 12         92 foreach my $sock (@ready) {
416 12         510 my $pack = $self->{resolver}->bgread($sock);
417 12         25013 my $domain = $self->{sock_to_domain}{$sock};
418 12         85 $sel->remove($sock);
419 12         597 undef($sock);
420 12 50       439 next unless $pack;
421 12 100       145 if ($self->_process_reply($domain, $pack)) {
422 10         158 $got_a_hit = 1;
423             }
424             }
425 12 50 66     191 return if $got_a_hit && $self->{early_exit};
426             }
427             }
428              
429             sub _process_reply
430             {
431 12     12   39 my ($self, $domain, $pack, $ans) = @_;
432              
433 12         35 my $entry = $self->{domains}->{$domain};
434              
435 12         93 my $rcode = $pack->header->rcode;
436 12 100 66     2164 if ($rcode eq 'SERVFAIL' || $rcode eq 'NXDOMAIN') {
437 2         7 foreach my $dnsbl (@$entry) {
438 4 50       15 next if $dnsbl->{hit};
439 4         12 $dnsbl->{replycode} = $rcode;
440             }
441 2         47 return 0;
442             }
443              
444 10         20 my $got_a_hit = 0;
445 10         66 foreach my $rr ($pack->answer) {
446 16 50 66     181 next unless ($rr->type eq 'A' || uc($rr->type) eq 'TXT');
447 16         528 foreach my $dnsbl (@$entry) {
448 36         114 my $this_rr_hit = 0;
449 36 100 100     135 next if $dnsbl->{hit} && ($dnsbl->{type} eq 'match');
450 30         61 $dnsbl->{replycode} = $rcode;
451 30 100       537 if ($dnsbl->{type} eq 'normal') {
    100          
    100          
    50          
452 3 50       14 next unless $rr->type eq 'A';
453 3         37 $this_rr_hit = 1;
454             } elsif ($dnsbl->{type} eq 'match') {
455 25 50       146 next unless $rr->type eq 'A';
456 25 100       816 next unless $rr->address eq $dnsbl->{data};
457 11         132 $this_rr_hit = 1;
458             } elsif ($dnsbl->{type} eq 'mask') {
459 1 50       3 next unless $rr->type eq 'A';
460 1         11 my @quads;
461             # For mask, we can be given an IP mask like
462             # a.b.c.d, or an integer n. The latter case
463             # is treated as 0.0.0.n.
464 1 50       8 if ($dnsbl->{data} =~ /^\d+$/) {
465 0         0 @quads = (0,0,0,$dnsbl->{data});
466             } else {
467 1         7 @quads = split(/\./,$dnsbl->{data});
468             }
469              
470 1         9 my $mask = unpack('N',pack('C4', @quads));
471 1         12 my $got = unpack('N',pack('C4', split(/\./,$rr->address)));
472 1 50       27 next unless ($got & $mask);
473              
474 1         3 $this_rr_hit = 1;
475             } elsif ($dnsbl->{type} eq 'txt') {
476 1 50       7 next unless uc($rr->type) eq 'TXT';
477 1         15 $this_rr_hit = 1;
478             }
479              
480 16 50       56 if( $this_rr_hit ) {
481 16         33 $dnsbl->{hit} = 1;
482 16 100       129 if( ! $dnsbl->{actual_hits} ) {
483 14         39 $dnsbl->{actual_hits} = [];
484             }
485 16 100       56 if ($rr->type eq 'A') {
486 15         166 push @{$dnsbl->{actual_hits}}, $rr->address;
  15         92  
487             } else {
488 1         11 push @{$dnsbl->{actual_hits}}, $rr->txtdata;
  1         5  
489             }
490 16         192 $got_a_hit = 1;
491             }
492             }
493             }
494 10         122 return $got_a_hit;
495             }
496              
497             sub _reverse_address
498             {
499 16     16   45 my ($self, $addr) = @_;
500              
501             # The following regex handles both regular IPv4 addresses
502             # and IPv6-mapped IPV4 addresses (::ffff:a.b.c.d)
503 16 100       266 if ($addr =~ /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) {
504 13         146 return "$4.$3.$2.$1";
505             }
506 3 100       18 if ($addr =~ /:/) {
507 2         13 $addr = $self->_expand_ipv6_address($addr);
508 2         24 $addr =~ s/://g;
509 2         28 return join('.', reverse(split(//, $addr)));
510             }
511              
512 1         14 croak("Unrecognized IP address '$addr'");
513             }
514              
515             sub _expand_ipv6_address
516             {
517 2     2   6 my ($self, $addr) = @_;
518              
519 2 50       10 return '0000:0000:0000:0000:0000:0000:0000:0000' if ($addr eq '::');
520 2 50       11 if ($addr =~ /::/) {
521             # Do nothing if more than one pair of colons
522 2 50       9 return $addr if ($addr =~ /::.*::/);
523              
524             # Make sure we don't begin or end with ::
525 2 100       13 $addr = "0000$addr" if $addr =~ /^::/;
526 2 50       10 $addr .= '0000' if $addr =~ /::$/;
527              
528             # Count number of colons
529 2         7 my $colons = ($addr =~ tr/:/:/);
530 2 50       9 if ($colons < 8) {
531 2         13 my $missing = ':' . ('0000:' x (8 - $colons));
532 2         13 $addr =~ s/::/$missing/;
533             }
534             }
535              
536             # Pad short fields
537 2 100       13 return join(':', map { (length($_) < 4 ? ('0' x (4-length($_)) . $_) : $_) } (split(/:/, $addr)));
  16         51  
538             }
539              
540             1;
541              
542             __END__