File Coverage

blib/lib/Net/DNSBL/Client.pm
Criterion Covered Total %
statement 168 191 87.9
branch 73 108 67.5
condition 34 44 77.2
subroutine 17 20 85.0
pod 8 8 100.0
total 300 371 80.8


line stmt bran cond sub pod time code
1             package Net::DNSBL::Client;
2 8     8   154659 use strict;
  8         11  
  8         244  
3 8     8   29 use warnings;
  8         10  
  8         162  
4 8     8   160 use 5.008;
  8         24  
  8         184  
5              
6 8     8   24 use Carp;
  8         9  
  8         485  
7 8     8   3769 use Net::DNS::Resolver;
  8         482988  
  8         373  
8 8     8   59 use IO::Select;
  8         11  
  8         13283  
9              
10             our $VERSION = '0.206';
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 191604 my ($class, $args) = @_;
64 10         42 my $self = {
65             resolver => undef,
66             timeout => 10,
67             };
68 10         38 foreach my $possible_arg (keys(%$self)) {
69 20 100       85 if( exists $args->{$possible_arg} ) {
70 2         6 $self->{$possible_arg} = delete $args->{$possible_arg};
71             }
72             }
73 10 100       33 if (scalar(%$args)) {
74 2         16 croak("Unknown arguments to new: " .
75 1         7 join(', ', (sort { $a cmp $b } keys(%$args))));
76             }
77              
78             # Timeout must be a positive integer
79 9 100 100     121 if (($self->{timeout} !~ /^\d+$/) || $self->{timeout} <= 0) {
80 2         19 croak("Timeout must be a positive integer");
81             }
82              
83 7 50       168 $self->{resolver} = Net::DNS::Resolver->new() unless $self->{resolver};
84              
85 7         1071 $self->{in_flight} = 0;
86 7         16 $self->{early_exit} = 0;
87              
88 7         12 bless $self, $class;
89 7         19 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 1168 my ($self, $secs) = @_;
262 2 50 66     13 if (($secs !~ /^\d+$/) || $secs <= 0) {
263 2         19 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 78156 my ($self, $ipaddr, $dnsbls, $options) = @_;
278 16 100       81 croak('Cannot issue new query while one is in flight') if $self->{in_flight};
279 15 100       64 croak('First argument (ip address) is required') unless $ipaddr;
280 14 100       47 croak('Second argument (dnsbl list) is required') unless $dnsbls;
281              
282             # Reverse the IP address in preparation for lookups
283 13         48 my $revip = $self->_reverse_address($ipaddr);
284              
285 12         44 return $self->query_domain($revip, $dnsbls, $options);
286             }
287              
288             sub query_domain
289             {
290 12     12 1 24 my ($self, $ip_or_domain, $dnsbls, $options) = @_;
291              
292 12 50       35 croak('Cannot issue new query while one is in flight') if $self->{in_flight};
293 12 50       26 croak('First argument (domain) is required') unless $ip_or_domain;
294 12 50       27 croak('Second argument (dnsbl list) is required') unless $dnsbls;
295              
296 12         25 foreach my $opt (qw(early_exit return_all)) {
297 24 100 100     66 if ($options && exists($options->{$opt})) {
298 2         4 $self->{$opt} = $options->{$opt};
299             } else {
300 22         47 $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         43 $self->{domains} = $self->_build_domains($dnsbls);
307 12         18 my $lookup_keys = {};
308 12 100 100     49 if ($options && exists($options->{lookup_keys}) && ref($options->{lookup_keys}) eq 'HASH') {
      66        
309 1         2 $lookup_keys = $options->{lookup_keys};
310             }
311              
312 12         68 $self->_send_queries($ip_or_domain, $lookup_keys);
313             }
314              
315             sub get_answers
316             {
317 13     13 1 1169 my ($self) = @_;
318 13 100       59 croak("Cannot call get_answers unless a query is in flight")
319             unless $self->{in_flight};
320              
321 12         34 $self->_collect_results();
322              
323 12         25 my $ans = [];
324 12         21 foreach my $d (keys %{$self->{domains}}) {
  12         44  
325 12         16 foreach my $r (@{$self->{domains}->{$d}}) {
  12         30  
326 22 100 100     98 push(@$ans, $r) if ( $r->{hit} || $self->{return_all} );
327             }
328             }
329              
330 12         25 $self->{in_flight} = 0;
331 12         35 delete $self->{sel};
332 12         28 delete $self->{sock_to_domain};
333 12         40 delete $self->{domains};
334              
335 12         47 return $ans;
336             }
337              
338             sub _build_domains
339             {
340 12     12   46 my($self, $dnsbls) = @_;
341 12         21 my $domains = {};
342              
343 12         26 foreach my $entry (@$dnsbls) {
344 22   100     22 push(@{$domains->{$entry->{domain}}}, {
  22         140  
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         30 return $domains;
354             }
355              
356             sub _send_queries
357             {
358 12     12   18 my ($self, $ip_or_domain, $lookup_keys) = @_;
359              
360 12         108 $self->{in_flight} = 1;
361 12         95 $self->{sel} = IO::Select->new();
362 12         119 $self->{sock_to_domain} = {};
363              
364 12         17 foreach my $domain (keys(%{$self->{domains}})) {
  12         37  
365 12         12 my $lookup_key;
366 12 100 66     47 if (exists($lookup_keys->{$domain}) && ($lookup_keys->{$domain} ne '')) {
367 1         2 $lookup_key = '.' . $lookup_keys->{$domain};
368             } else {
369 11         23 $lookup_key = '';
370             }
371 12         14 my($sock1, $sock2);
372 12         17 foreach my $e (@{$self->{domains}->{$domain}}) {
  12         28  
373 22 100       48 if ($e->{type} eq 'txt') {
374 1   33     11 $sock1 ||= $self->{resolver}->bgsend("$ip_or_domain$lookup_key.$domain", 'TXT');
375 1 50       814 unless ($sock1) {
376 0         0 die $self->{resolver}->errorstring;
377             }
378             } else {
379 21   66     148 $sock2 ||= $self->{resolver}->bgsend("$ip_or_domain$lookup_key.$domain", 'A');
380 21 50       9065 unless ($sock2) {
381 0         0 die $self->{resolver}->errorstring;
382             }
383             }
384 22 50 66     76 last if ($sock1 && $sock2);
385             }
386              
387              
388 12 100       32 if ($sock1) {
389 1         3 $self->{sock_to_domain}->{$sock1} = $domain;
390 1         5 $self->{sel}->add($sock1);
391             }
392 12 100       61 if ($sock2) {
393 11         42 $self->{sock_to_domain}->{$sock2} = $domain;
394 11         49 $self->{sel}->add($sock2);
395             }
396             }
397             }
398              
399             sub _collect_results
400             {
401 12     12   21 my ($self) = @_;
402              
403 12         29 my $terminate = time() + $self->{timeout};
404 12         20 my $sel = $self->{sel};
405              
406 12         16 my $got_a_hit = 0;
407              
408 12         31 while(time() <= $terminate) {
409 24         50 my $expire = $terminate - time();
410 24 50       51 $expire = 1 if ($expire < 1);
411 24         72 my @ready = $sel->can_read($expire);
412              
413 24 100       73176 return $got_a_hit unless scalar(@ready);
414              
415 12         32 foreach my $sock (@ready) {
416 12         124 my $pack = $self->{resolver}->bgread($sock);
417 12         7140 my $domain = $self->{sock_to_domain}{$sock};
418 12         51 $sel->remove($sock);
419 12         384 undef($sock);
420 12 50       253 next unless $pack;
421 12 100       51 if ($self->_process_reply($domain, $pack)) {
422 8         86 $got_a_hit = 1;
423             }
424             }
425 12 50 66     93 return if $got_a_hit && $self->{early_exit};
426             }
427             }
428              
429             sub _process_reply
430             {
431 12     12   31 my ($self, $domain, $pack, $ans) = @_;
432              
433 12         33 my $entry = $self->{domains}->{$domain};
434              
435 12         61 my $rcode = $pack->header->rcode;
436 12 100 100     1357 if ($rcode eq 'SERVFAIL' || $rcode eq 'NXDOMAIN') {
437 4         17 foreach my $dnsbl (@$entry) {
438 6 50       16 next if $dnsbl->{hit};
439 6         15 $dnsbl->{replycode} = $rcode;
440             }
441 4         53 return 0;
442             }
443              
444 8         16 my $got_a_hit = 0;
445 8         34 foreach my $rr ($pack->answer) {
446 14 50 33     126 next unless ($rr->type eq 'A' || uc($rr->type) eq 'TXT');
447 14         179 foreach my $dnsbl (@$entry) {
448 34         71 my $this_rr_hit = 0;
449 34 100 100     84 next if $dnsbl->{hit} && ($dnsbl->{type} eq 'match');
450 28         33 $dnsbl->{replycode} = $rcode;
451 28 100       68 if ($dnsbl->{type} eq 'normal') {
    50          
    0          
    0          
452 3 50       5 next unless $rr->type eq 'A';
453 3         16 $this_rr_hit = 1;
454             } elsif ($dnsbl->{type} eq 'match') {
455 25 50       47 next unless $rr->type eq 'A';
456 25 100       208 next unless $rr->address eq $dnsbl->{data};
457 11         140 $this_rr_hit = 1;
458             } elsif ($dnsbl->{type} eq 'mask') {
459 0 0       0 next unless $rr->type eq 'A';
460 0         0 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 0 0       0 if ($dnsbl->{data} =~ /^\d+$/) {
465 0         0 @quads = (0,0,0,$dnsbl->{data});
466             } else {
467 0         0 @quads = split(/\./,$dnsbl->{data});
468             }
469              
470 0         0 my $mask = unpack('N',pack('C4', @quads));
471 0         0 my $got = unpack('N',pack('C4', split(/\./,$rr->address)));
472 0 0       0 next unless ($got & $mask);
473              
474 0         0 $this_rr_hit = 1;
475             } elsif ($dnsbl->{type} eq 'txt') {
476 0 0       0 next unless uc($rr->type) eq 'TXT';
477 0         0 $this_rr_hit = 1;
478             }
479              
480 14 50       32 if( $this_rr_hit ) {
481 14         22 $dnsbl->{hit} = 1;
482 14 100       30 if( ! $dnsbl->{actual_hits} ) {
483 12         24 $dnsbl->{actual_hits} = [];
484             }
485 14 50       25 if ($rr->type eq 'A') {
486 14         91 push @{$dnsbl->{actual_hits}}, $rr->address;
  14         34  
487             } else {
488 0         0 push @{$dnsbl->{actual_hits}}, $rr->txtdata;
  0         0  
489             }
490 14         95 $got_a_hit = 1;
491             }
492             }
493             }
494 8         55 return $got_a_hit;
495             }
496              
497             sub _reverse_address
498             {
499 16     16   30 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       97 if ($addr =~ /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) {
504 13         100 return "$4.$3.$2.$1";
505             }
506 3 100       12 if ($addr =~ /:/) {
507 2         7 $addr = $self->_expand_ipv6_address($addr);
508 2         9 $addr =~ s/://g;
509 2         17 return join('.', reverse(split(//, $addr)));
510             }
511              
512 1         13 croak("Unrecognized IP address '$addr'");
513             }
514              
515             sub _expand_ipv6_address
516             {
517 2     2   4 my ($self, $addr) = @_;
518              
519 2 50       7 return '0000:0000:0000:0000:0000:0000:0000:0000' if ($addr eq '::');
520 2 50       9 if ($addr =~ /::/) {
521             # Do nothing if more than one pair of colons
522 2 50       7 return $addr if ($addr =~ /::.*::/);
523              
524             # Make sure we don't begin or end with ::
525 2 100       9 $addr = "0000$addr" if $addr =~ /^::/;
526 2 50       7 $addr .= '0000' if $addr =~ /::$/;
527              
528             # Count number of colons
529 2         6 my $colons = ($addr =~ tr/:/:/);
530 2 50       8 if ($colons < 8) {
531 2         10 my $missing = ':' . ('0000:' x (8 - $colons));
532 2         7 $addr =~ s/::/$missing/;
533             }
534             }
535              
536             # Pad short fields
537 2 100       12 return join(':', map { (length($_) < 4 ? ('0' x (4-length($_)) . $_) : $_) } (split(/:/, $addr)));
  16         36  
538             }
539              
540             1;
541              
542             __END__