File Coverage

blib/lib/Net/DNSBL/Statistics.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


'."\n";
line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             package Net::DNSBL::Statistics;
3              
4 5     5   40138 use strict;
  5         10  
  5         227  
5             #use diagnostics;
6 5     5   5309 use Net::DNS::Codes qw(:all);
  5         10717  
  5         10071  
7 5         603 use Net::DNS::ToolKit qw(
8             newhead
9             gethead
10             inet_ntoa
11 5     5   6005 );
  5         155288  
12 5     5   5247 use Net::DNS::ToolKit::RR;
  5         24662  
  5         201  
13 5         1684 use Net::DNS::ToolKit::Utilities qw(
14             id
15             revIP
16 5     5   5808 );
  5         140819  
17 0           use Net::DNSBL::Utilities qw(
18             DO
19             list2NetAddr
20             matchNetAddr
21 5     5   8683 );
  0            
22             #use Net::DNS::ToolKit::Debug qw(
23             # print_head
24             # print_buf
25             #);
26              
27             use vars qw(
28             $VERSION @ISA @EXPORT_OK
29             );
30             require Exporter;
31             @ISA = qw(Exporter);
32              
33             $VERSION = do { my @r = (q$Revision: 0.13 $ =~ /\d+/g); sprintf "%d."."%02d" x $#r, @r };
34              
35             @EXPORT_OK = qw(
36             run
37             plaintxt
38             htmltxt
39             );
40              
41             # undocumented $DEBUG values
42             #
43             # 1 => return @ip array
44             # 2 => return %dnsbls initialization hash
45             # 3 => return ($iptr,$regexptr) ignore, regexp ptrs
46             # 4 => return %ips union aging hash
47             # 5 => return %qc hash
48             # other => return un-converted %dnsbls hash
49              
50             =head1 NAME
51              
52             Net::DNSBL::Statistics - gather DNSBL Statistics
53              
54             =head1 SYNOPSIS
55              
56             use Net::DNSBL::Statistics qw(
57             run
58             plaintxt
59             htmltxt
60             );
61              
62             %dnsblcounts=run(\%config,$NonBlockSock,$sockaddr_in);
63             $text = plaintxt(\%config,\%dnsblscounts);
64             $html = htmltxt(\%config,\%dnsblcounts);
65              
66             =head1 DESCRIPTION
67              
68             B is the Perl module that collects statistics on the
69             interrogation success for a list of IP addresses against a list of DNSBL's.
70             The module is used to implement the reproting script B.
71              
72             =head1 CONFIGURATION FILE
73              
74             With the addition of a few elements, the configuration file for B
75             shares a common format with the Mail::SpamCannibal sc_BLcheck.pl script,
76             facilitating common maintenance of DNSBL's for your MTA installation.
77              
78             The sample configuration file
79             B is heavily commented with the details for each
80             configuration element.
81              
82             =head1 SYSTEM SIGNALS
83              
84             B responds to the following system signals:
85              
86             =over 2
87              
88             =item * TERM
89              
90             Script is terminated.
91              
92             =back
93              
94             =head1 PERL MODULE DESCRIPTION - Script Implementation
95              
96             B provides most of the functions that implement
97             B which is a script that collects statistics from a list of IP
98             address interrogations against a list of DNSBL's
99              
100             =head1 dnsblstat usage
101              
102             How to use B
103              
104             Syntax: dnsblstat path/to/config.file
105             or
106             dnsblstat -t path/to/config.file
107             dnsblstat -w path/to/config.file
108              
109             Normally dnsblstat prints a sorted list (by count)
110             of the DNSBL's interrogated with their reply count,
111             percentage of the total count, and any comments from
112             the DNSBL's 'comment' key field in the config file.
113             The 'comment' field may contain html markup text.
114              
115             i.e.
116             44 100.0% TOTAL IP's interrogated
117             41 93.2% UNION of all results
118             34 77.3% dnsbl.sorbs.net comment
119             ........
120              
121             The -t switch will print a start and stop time.
122              
123             i.e.
124             # start: Fri Jan 4 17:46:44 2008
125             # stop : Fri Jan 4 17:58:21 2008
126              
127             The -w switch will put the output into an HTML table
128             without the EtableE statement E/tableE>., a commment as above
129             and with an Ea href="..."Ednsbl nameE/aE statement replacing
130             the dnsbl name if the 'url' key is present in the config file.
131              
132             i.e.
133             A one line example corresponding to the text line above:
134              
135             34 77.3% dnsbl.sorbs.net
136              
137             with a 'comment' key of: 127.0.0.2,5,7,8,9,10,12
138             and a 'url' key of: http://www.au.sorbs.net/using.shtml
139              
140            
34
141             77.3%
142            
143             href="http://www.au.sorbs.net/using.shtml">dnsbl.sorbs.net
144             127.0.0.2,5,7,8,9,10,12
145            
146              
147             =head1 Net::DNSBL::Statistics FUNCTIONS
148              
149             =over 4
150              
151             =item * %dnsblscounts=run(\%config,$NonBlockSock,$sockaddr_in);
152              
153             Returns the total number of IP's interrogated (IP list less white listed items) and a hash of DNSBL
154             names and their respective SPAM reply counts or equivalent for 'in-addr.arpa' and GENERIC.
155              
156             input: config pointer,
157             non-blocking socket descriptor,
158             sockaddr_in for local DNS host
159              
160             returns: dnsbl count hash
161              
162             The dnsbl count hash will have two added keys:
163              
164             TOTAL the total number of interrogations less whitelist
165             UNION the total number of spam 'hits'
166              
167             HINTs: use Net::NBsocket qw( open_udbNB sockaddr_in );
168             use Net::DNS::ToolKit qw( get_ns );
169              
170             my $sock = open_udpNB();
171             my $sockaddr_in = sockaddr_in(53, scalar get_ns());
172              
173             =cut
174              
175             my $w = 0;
176             my @w = qw( \ | / - );
177             sub whirl {
178             return;
179             print STDERR "\r",$w[$w],"\r";
180             $w = 0 if ++$w > $#w;
181             }
182              
183             sub run {
184             my($conf,$Usock,$U_Sin,$DEBUG) = @_;
185             my %ips;
186             return () unless $conf->{FILES};
187             my @files = (ref $conf->{FILES}) ? @{$conf->{FILES}} : ($conf->{FILES});
188              
189             local *F;
190             foreach (@files) {
191             next unless -e $_ && open F, $_;
192             foreach () {
193             next unless $_ =~ /\S/;
194             next if $_ =~ /^\s*#/;
195             next unless $_ =~ /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
196             $ips{"$1"} = undef;
197             }
198             close F;
199             }
200             my @ips = keys %ips;
201             %ips = ();
202              
203             return @ips if $DEBUG && $DEBUG == 1;
204              
205             my @NAignor;
206             if ($conf->{IGNORE}) {
207             list2NetAddr($conf->{IGNORE},\@NAignor);
208             }
209              
210             ############## configure %dnsbl has for accumulating stats ###############
211             my @DNSBLs = grep( $_ =~ /^[0-9a-z]+\.[0-9a-z]/i && $_ !~ /in-addr/i, keys %{$conf});
212              
213             my %dnsbls;
214              
215             foreach(@DNSBLs) {
216             $dnsbls{"$_"} = {
217             C => 0, # count
218             TO => 0, # timeouts
219             };
220             }
221              
222             #### %dnsbls configuration complete, configure maximum union timeout
223             my $uto = 0;
224             foreach(keys %dnsbls) {
225             next unless exists $conf->{"$_"} &&
226             exists $conf->{"$_"}->{timeout};
227             next if $conf->{"$_"}->{timeout} < $uto;
228             $uto = $conf->{"$_"}->{timeout};
229             }
230              
231             my($iptr,$regexptr);
232             my $needPTR = 0;
233             if ($conf->{'in-addr.arpa'}) {
234             $dnsbls{'in-addr.arpa'} = { C => 0 };
235             $needPTR = $conf->{'in-addr.arpa'}->{timeout} || 30;
236             }
237             if ($conf->{GENERIC}) {
238             $dnsbls{GENERIC} = { C => 0 };
239             $needPTR = ($conf->{GENERIC}->{timeout} || 30)
240             unless $needPTR;
241             undef $regexptr unless ($regexptr = $conf->{GENERIC}->{regexp}) &&
242             ref $regexptr eq 'ARRAY' && @$regexptr > 0;
243             undef $iptr unless ($iptr = $conf->{GENERIC}->{ignore}) &&
244             ref $iptr eq 'ARRAY' && @$iptr > 0;
245             }
246             ### adjust $uto to account for generic retries and in-addr.arpa timeouts
247             $uto = $needPTR
248             if $uto < $needPTR;
249             $uto = 30 unless $uto;
250             #### maximum $uto = 2x max delay + a little
251             $uto *= 2;
252             $uto += 5;
253              
254             return %dnsbls if $DEBUG && $DEBUG == 2;
255              
256             return ($iptr,$regexptr) if $DEBUG && $DEBUG == 3;
257              
258             my %qc = (
259             'in-addr' => 0,
260             'regular' => 0,
261             # retries below
262             'generic' => 0,
263             'retry-r' => 0,
264             );
265              
266             my %queue;
267             my $fileno = fileno($Usock);
268             my $vin = '';
269             vec($vin,$fileno,1) = 1;
270             my $Run = 1;
271             local $SIG{TERM} = sub {$Run = 0};
272              
273             my $qsize = keys %dnsbls;
274             my $then = time;
275             my $uage = $then; # union aging every 5 seconds
276             my ($get,$put,$parse) = new Net::DNS::ToolKit::RR;
277             my($rin,$rout,$win,$wout,$ip,$packet,$name,$id,$msg,$bl,$now,$revIP,$nfound,$answer,$ttl,$rdl,@rdata,@sndQ);
278              
279             LOOP:
280             while ($Run) {
281             my $Q = keys %queue;
282             if ($qsize > $Q && ($ip = shift @ips)) { # run results for next IP if queue is not double full
283             next if matchNetAddr($ip,\@NAignor);
284             ++$dnsbls{TOTAL}->{C};
285             $now = time;
286             $revIP = revIP($ip);
287             if ($needPTR) {
288             $id = makid(\%queue);
289             $name = $revIP .'.in-addr.arpa';
290             $packet = makequery($put,$id,$name,T_PTR());
291             $queue{$id} = {
292             B => 'in-addr.arpa',
293             Q => $packet,
294             T => $now + $needPTR, # timeout
295             R => 0, # retry
296             X => $revIP,
297             };
298             push @sndQ, $packet;
299             ++$qc{'in-addr'};
300             }
301            
302             foreach $bl (@DNSBLs) {
303             next if $dnsbls{"$bl"}->{TO} > 5; # ignore this BL if it timed out to many times
304             $id = makid(\%queue);
305             $name = $revIP .'.'. $bl;
306             $packet = makequery($put,$id,$name,T_A());
307             $queue{$id} = {
308             B => "$bl",
309             Q => $packet,
310             T => $now + ($conf->{"$bl"}->{timeout} || 30),
311             R => 0,
312             X => $revIP,
313             };
314             push @sndQ, $packet;
315             ++$qc{regular};
316             }
317             }
318              
319             # wait for some responses
320             $rin = $vin;
321             if (@sndQ) {
322             $win = $vin;
323             } else {
324             $win = '';
325             }
326             $nfound = select($rout=$rin,$wout=$win,undef,0.5); # tick each second
327             if ($nfound > 0) {
328             while (vec($wout,$fileno,1) && @sndQ) {
329             $packet = shift @sndQ;
330             #print STDERR "WRITE\n";
331             #print_buf(\$packet);
332             #print STDERR "\n";
333             send($Usock,$packet,0,$U_Sin);
334             whirl() if $DEBUG;
335             }
336             if (vec($rout,$fileno,1)) {
337             undef $msg;
338             next unless recv($Usock,$msg,,PACKETSZ,0); # ignore receive errors
339             next unless length($msg) > HFIXEDSZ; # ignore short packets
340             #print STDERR "RECEIVE\n";
341             #print_buf(\$msg);
342             #print STDERR "\n";
343             my($off,$qr,$opcode,$aa,$tc,$rd,$ra,$mbz,$ad,$cd,$rcode,$qdcount,$ancount,$nscount,$arcount);
344             ($off,$id,$qr,$opcode,$aa,$tc,$rd,$ra,$mbz,$ad,$cd,$rcode,$qdcount,$ancount,$nscount,$arcount)= gethead(\$msg);
345             next unless
346             $tc == 0 &&
347             $qr == 1 &&
348             $opcode == QUERY &&
349             ($rcode == NOERROR || $rcode == NXDOMAIN || $rcode == SERVFAIL) &&
350             $qdcount == 1 &&
351             exists $queue{$id};
352              
353             ($off,my($name,$t,$class)) = $get->Question(\$msg,$off);
354             next unless $class == C_IN; # not my question
355              
356             $bl = $queue{$id}->{B};
357             $revIP = $queue{$id}->{X};
358             delete $queue{$id};
359             $dnsbls{"$bl"}->{TO} = 0 # reset timeout count
360             unless $bl eq 'in-addr.arpa';
361             if ($ancount && $rcode == &NOERROR) { # if good response
362             $name =~ /(?:\d+\.\d+\.\d+\.\d+\.)/i;
363             next unless lc $bl eq lc $' &&
364             ($t == T_A || $t == T_PTR);
365              
366             undef $answer;
367             my @generic;
368             ANSWER:
369             foreach(0..$ancount-1) {
370             ($off,$name,$t,$class,$ttl,$rdl,@rdata) = $get->next(\$msg,$off);
371             if ($t == T_A) {
372             while($answer = shift @rdata) {
373             $ip = inet_ntoa($answer);
374             if (grep($ip eq $_,keys %{$conf->{"$bl"}->{accept}})) {
375             ++$dnsbls{"$bl"}->{C}; # bump dnsbl count
376             union(\%dnsbls,\%ips,$revIP,$now + $uto);
377             #print STDERR "FAILED $name $ip\n";
378             next LOOP;
379             }
380             }
381             }
382             elsif ($needPTR && $t == T_PTR && exists $dnsbls{GENERIC}) {
383             # positive in-addr.arpa responses are ignored
384             push @generic, $rdata[0];
385             next ANSWER;
386             }
387             }
388             if (@generic) {
389             foreach my $g (@generic) {
390             next LOOP if $iptr && grep($g =~ /$_/i, @$iptr);
391             next LOOP if $g && ! grep($g =~ /$_/i, @$regexptr);
392             }
393             ++$dnsbls{GENERIC}->{C}; # bump GENERIC count
394             union(\%dnsbls,\%ips,$revIP,$now + $uto);
395             #print STDERR "FAILED $name GENERIC\n";
396             }
397             }
398             elsif ($t == T_A) {
399             next LOOP; # tis a lookup failure or no response... ignore
400             }
401             elsif ($needPTR && $t == T_PTR && exists $conf->{'in-addr.arpa'}) {
402             ++$dnsbls{'in-addr.arpa'}->{C};
403             union(\%dnsbls,\%ips,$revIP,$now + $uto);
404             #print STDERR "FAILED $name ERROR\n";
405             next LOOP;
406             }
407             }
408             }
409             ######################################################################
410             else { # timeout
411             $now = time;
412             next unless $now > $then;
413             $then = $now;
414             my @queue = sort {$queue{$a}->{T} <=> $queue{$b}->{T}} keys %queue;
415             foreach $id (@queue) { # check for DNSBL timeouts
416             last if $now < $queue{$id}->{T};
417             $bl = $queue{$id}->{B};
418             if ($bl eq 'in-addr.arpa') {
419             if (exists $conf->{'in-addr.arpa'}) {
420             $revIP = $queue{$id}->{X};
421             delete $queue{$id};
422             ++$dnsbls{'in-addr.arpa'}->{C};
423             union(\%dnsbls,\%ips,$revIP,$now + $uto);
424             #print STDERR "FAILED $revIP.in-addr.arpa timeout\n";
425             }
426             elsif (exists $dnsbls{GENERIC}) {
427             unless ($queue{$id}->{R}) {
428             $queue{$id}->{R} = 1; # retry generic queries
429             $queue{$id}->{T} = $now + $needPTR;
430             push @sndQ, $queue{$id}->{Q};
431             ++$qc{generic};
432             }
433             else {
434             delete $queue{$id};
435             }
436             }
437             }
438             else { # regular DNSBL
439             unless ($queue{$id}->{R}) {
440             $queue{$id}->{R} = 1;
441             $queue{$id}->{T} = $now + ($conf->{"$bl"}->{timeout} || 30);
442             push @sndQ, $queue{$id}->{Q};
443             ++$qc{'retry-r'};
444             }
445             else {
446             $revIP = $queue{$id}->{X};
447             delete $queue{$id};
448             ++$dnsbls{"$bl"}->{TO};
449             }
450             }
451             }
452             last LOOP unless @ips || keys %queue; # run through all IP's and remaining queue items
453             next unless $uage < $now;
454             $uage = $now + 5;
455             @_ = sort {$ips{"$a"} <=> $ips{"$b"}} keys %ips;
456             foreach (@_) {
457             last if $ips{"$_"} > $now;
458             delete $ips{"$_"};
459             }
460             } # else nfound
461             } # while ($Run)
462              
463             close $Usock unless $DEBUG;
464              
465             if ($DEBUG) {
466             return %ips if $DEBUG == 4;
467             return %qc if $DEBUG == 5;
468             return %dnsbls; # for any other debug value
469             }
470             else {
471             foreach(keys %dnsbls) {
472             $dnsbls{$_} = $dnsbls{$_}->{C};
473             }
474             }
475             return %dnsbls;
476             }
477              
478             sub makequery {
479             my($put,$id,$name,$type) = @_;
480             my $buf;
481             my $off = newhead(\$buf,
482             $id,
483             BITS_QUERY | RD,
484             1,0,0,0,
485             );
486             $off = $put->Question(\$buf,$off,$name,$type,C_IN);
487             return $buf;
488             }
489              
490             sub makid {
491             my $qp = shift;
492             my $id;
493             do {
494             $id = id()
495             } while exists $qp->{$id};
496             return $id;
497             }
498              
499             sub union {
500             my($dnsbls,$union,$rip,$expire) = @_;
501             $expire += 30; # union cache expiration is alway longer than timeouts
502             if (exists $union->{"$rip"}) {
503             $union->{"$rip"} = $expire
504             if $expire > $union->{"$rip"};
505             } else {
506             $union->{"$rip"} = $expire;
507             ++$dnsbls->{UNION}->{C};
508             }
509             }
510              
511             =item * $text = plaintxt($config,\%dnsbls);
512              
513             Generate a plain text report of the form:
514              
515             44 100.0% TOTAL IP's interrogated
516             41 93.2% UNION of all results
517             34 77.3% dnsbl.sorbs.net comment
518             22 50.0% GENERIC comment
519             13 29.5% in-addr.arpa comment
520             11 25.0% cbl.abuseat.org comment
521             9 20.5% list.dsbl.org comment
522             2 4.5% dnsbl.njabl.org comment
523             1 2.3% bl.spamcannibal.org comment
524             0 0.0% dynablock.njabl.org comment
525              
526             input: configuration pointer,
527             dnsbl count hash pointer
528             returns: text buffer
529              
530             The 'comment' comes from the config file 'comment' key field for each
531             specified DNSBL or is blank if there is no 'comment' key.
532              
533             =cut
534              
535             # return 'comment' and 'url' if present
536             # input: $conf, $bl, $nbsp
537             # output: if $nbsp ($comment,$url)
538             # if !$nbsp $comment
539             #
540             sub cmurl {
541             my($conf,$bl,$nbsp) = @_;
542             if ($bl eq 'TOTAL') {
543             if ($nbsp) {
544             return (q|IP's interrogated|,'');
545             } else {
546             return q|IP's interrogated|;
547             }
548             } elsif ($bl eq 'UNION') {
549             if ($nbsp) {
550             return (q|of all results|,'');
551             } else {
552             return q|of all results|;
553             }
554             }
555             my $comment = (exists $conf->{"$bl"}->{comment} && $conf->{"$bl"}->{comment})
556             ? $conf->{"$bl"}->{comment}
557             : ($nbsp) ? ' ' : '';
558             return $comment unless $nbsp;
559             my $url = (exists $conf->{"$bl"}->{url} && $conf->{"$bl"}->{url})
560             ? q|{url} .q|">|
561             : '';
562             return ($comment,$url);
563             }
564            
565             sub plaintxt {
566             my($conf,$dnsbls) = @_;
567             return "# ERROR list is empty\n"
568             unless keys %$dnsbls && $dnsbls->{TOTAL};
569             my $txt = '';
570             my $tot = $dnsbls->{TOTAL}/100;
571             my $len = length($dnsbls->{TOTAL});
572             foreach(sort{$dnsbls->{"$b"} <=> $dnsbls->{"$a"}} keys %$dnsbls) {
573             my $comment = cmurl($conf,$_);
574             $txt .= sprintf("% ${len}u% 6.1f%% %s %s\n",$dnsbls->{"$_"},$dnsbls->{"$_"}/$tot,$_,$comment);
575             }
576             return $txt;
577             }
578              
579             =item * $html = htmltxt($config,\%dnsbls);
580              
581             Generate a report as above but with EtrEEtdEE/tdEE/trE table markup. The
582             EtableEE/tableE tags are not generated. If there is a 'url' key field in the
583             respective DNSBL config entry, the DNSBL name is provide with Ea href="url"EDNSBLE/aE
584             tags with the specified 'url' as the 'href' value.
585              
586             input: configuration pointer,
587             dnsbl count hash pointer
588             returns: html text buffer
589              
590             A one line example corresponding to the text line above:
591              
592             34 77.3% dnsbl.sorbs.net
593              
594             with a 'comment' key of: 127.0.0.2,5,7,8,9,10,12
595             and a 'url' key of: http://www.au.sorbs.net/using.shtml
596              
597            
34
598             77.3%
599            
600             href="http://www.au.sorbs.net/using.shtml">dnsbl.sorbs.net
601             127.0.0.2,5,7,8,9,10,12
602            
603              
604             =back
605              
606             =cut
607              
608             sub htmltxt {
609             my($conf,$dnsbls) = @_;
610             return "\n"
611             unless keys %$dnsbls && $dnsbls->{TOTAL};
612             my $html = '';
613             my $tot = $dnsbls->{TOTAL}/100;
614             my $len = length($dnsbls->{TOTAL});
615             foreach(sort{$dnsbls->{"$b"} <=> $dnsbls->{"$a"}} keys %$dnsbls) {
616             my($comment,$url) = cmurl($conf,$_,1);
617             my $aa = $url ? '' : '';
618             my $count = $dnsbls->{"$_"};
619             $html .= '
'.
620             $count .''.
621             sprintf("%.1f",$count/$tot) .'%'.
622             $url . $_ . $aa .''. $comment .'
623             }
624             return $html;
625             }
626              
627             =head1 Statistics Web Page HOWTO
628              
629             Read the C document that describes the scripts used with
630             'cron' to auto generate web pages for the statistics reports
631              
632             =head1 EXPORT_OK
633              
634             run
635             plaintxt
636             htmltxt
637              
638             =head1 AUTHOR
639              
640             Michael Robinton, michael@bizsystems.com
641              
642             =head1 COPYRIGHT
643              
644             Copyright 2008-2014, Michael Robinton.
645             This program is free software; you can redistribute it and/or modify
646             it under the terms of the GNU General Public License as published by
647             the Free Software Foundation; either version 2 of the License, or
648             (at your option) any later version.
649              
650             This program is distributed in the hope that it will be useful,
651             but WITHOUT ANY WARRANTY; without even the implied warranty of
652             MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
653             GNU General Public License for more details.
654              
655             You should have received a copy of the GNU General Public License
656             along with this program; if not, write to the Free Software
657             Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
658              
659             =head1 SEE ALSO
660              
661             L,
662             L,
663             L,
664             L
665              
666             =cut
667              
668             1;