File Coverage

blib/lib/App/DNS/Adblock.pm
Criterion Covered Total %
statement 97 240 40.4
branch 13 82 15.8
condition 5 58 8.6
subroutine 21 39 53.8
pod 0 17 0.0
total 136 436 31.1


line stmt bran cond sub pod time code
1             package App::DNS::Adblock;
2             {
3             $App::DNS::Adblock::VERSION = '0.015';
4             }
5              
6 2     2   379412 use strict;
  2         6  
  2         62  
7 2     2   10 use warnings;
  2         4  
  2         64  
8              
9 2     2   2272 use Net::DNS 0.74;
  2         9032  
  2         256  
10 2     2   1882 use Net::DNS::Nameserver;
  2         14126  
  2         68  
11 2     2   1836 use Sys::HostIP;
  2         4038  
  2         120  
12 2     2   3556 use Capture::Tiny qw(capture);
  2         48540  
  2         176  
13 2     2   1654 use LWP::Simple qw($ua getstore);
  2         190634  
  2         20  
14             $ua->agent("");
15 2     2   6780 use Mozilla::CA;
  2         10348  
  2         98  
16              
17 2     2   5588 use POSIX qw( strftime );
  2         37968  
  2         46  
18 2     2   2386 use Carp;
  2         4  
  2         108  
19              
20 2     2   10 use Data::Dumper;
  2         6  
  2         94  
21              
22 2     2   10 use Storable qw(freeze thaw);
  2         4  
  2         6870  
23              
24             my $attributes;
25              
26             sub new {
27 2     2 0 214 my ( $class, %self ) = @_;
28 2         12 my $self = { %self };
29 2         8 bless $self, $class;
30              
31 2         18 $attributes = freeze($self);
32 2         358 $self->read_config();
33              
34 2         22 my $host = Sys::HostIP->new;
35 2         34090 my %devices = reverse %{ $host->interfaces };
  2         34  
36 2         106 my $hostip = $host->ip;
37              
38 2         66 $self->{interface} = $devices{ $hostip };
39 2 50       22 $self->{host} = $hostip unless $self->{host};
40 2 50       14 $self->{port} = 53 unless $self->{port};
41 2 50       32 $self->{debug} = 0 unless $self->{debug};
42              
43             my $ns = Net::DNS::Nameserver->new(
44             LocalAddr => $self->{host},
45             LocalPort => $self->{port},
46 1     1   34306 ReplyHandler => sub { $self->reply_handler(@_); },
47 2   50     108 Verbose => ($self->{debug} > 1 ? 1 : 0)
48             ) || die "couldn't create nameserver object: $!";
49              
50 2         3604 $self->{nameserver} = $ns;
51              
52 2         52 my $res = Net::DNS::Resolver->new(
53 2 50 50     92 nameservers => [ @{ $self->{forwarders} } ],
54             port => $self->{forwarders_port} || 53,
55             recurse => 1,
56             debug => ($self->{debug} > 2 ? 1 : 0),
57             );
58              
59 2         408 $self->{resolver} = $res;
60              
61 2         96 return $self;
62             }
63              
64             sub run {
65 1     1 0 6921 my ( $self ) = shift;
66              
67 1 50       51 $self->set_local_dns() if $self->{setdns};
68              
69 1     0   133 $SIG{KILL} = sub { $self->signal_handler(@_) };
  0         0  
70 1     1   19 $SIG{QUIT} = sub { $self->signal_handler(@_) };
  1         18042  
71 1     0   24 $SIG{TERM} = sub { $self->signal_handler(@_) };
  0         0  
72 1     0   21 $SIG{INT} = sub { $self->signal_handler(@_) };
  0         0  
73 1     0   16 $SIG{HUP} = sub { $self->read_config() };
  0         0  
74              
75 1         37 $self->log("nameserver accessible locally @ $self->{host}", 1);
76              
77 1         60 $self->{nameserver}->main_loop;
78             };
79              
80             sub set_local_dns {
81 0     0 0 0 my ( $self ) = shift;
82              
83 0         0 my $stdout;
84             my $stderr;
85 0         0 my @result;
86              
87 0 0       0 if ($^O =~ /darwin/i) { # is osx
88 0         0 eval {
89 0     0   0 ($self->{service}, $stderr, @result) = capture { system("networksetup -listallhardwareports | grep -B 1 $self->{interface} | cut -c 16-32") };
  0         0  
90 0 0 0     0 if ($stderr || ($result[0] < 0)) {
91 0   0     0 die $stderr || $result[0];
92             } else {
93 0         0 $self->{service} =~ s/\n//g;
94 0         0 system("networksetup -setdnsservers $self->{service} $self->{host}");
95 0         0 system("networksetup -setsearchdomains $self->{service} empty");
96             }
97             }
98             }
99              
100 0 0       0 if (!grep { $^O eq $_ } qw(VMS MSWin32 os2 dos MacOS darwin NetWare beos vos)) { # is unix
  0         0  
101 0         0 eval {
102 0     0   0 ($stdout, $stderr, @result) = capture { system("cp /etc/resolv.conf /etc/resolv.bk") };
  0         0  
103 0 0 0     0 if ($stderr || ($result[0] < 0)) {
104 0   0     0 die $stderr || $result[0];
105             } else {
106 0         0 open(CONF, ">", "/etc/resolv.conf");
107 0         0 print CONF "nameserver $self->{host}\n";
108 0         0 close CONF;
109             }
110             }
111             }
112              
113 0 0 0     0 if ($stderr||$result[0]) {
114 0         0 $self->log("switching of local dns settings failed: $@", 1);
115 0         0 undef $self->{setdns};
116             } else {
117 0         0 $self->log("local dns settings ($self->{interface}) switched", 1);
118             }
119             }
120              
121             sub restore_local_dns {
122 0     0 0 0 my ( $self ) = shift;
123              
124 0         0 my $stdout;
125             my $stderr;
126 0         0 my @result;
127              
128 0 0       0 if ($^O =~ /darwin/i) { # is osx
129 0         0 eval {
130 0     0   0 ($stdout, $stderr, @result) = capture { system("networksetup -setdnsservers $self->{service} empty") };
  0         0  
131 0 0 0     0 if ($stderr || ($result[0] < 0)) {
132 0   0     0 die $stderr || $result[0];
133             } else {
134 0         0 system("networksetup -setsearchdomains $self->{service} empty");
135             }
136             }
137             }
138              
139 0 0       0 if (!grep { $^O eq $_ } qw(VMS MSWin32 os2 dos MacOS darwin NetWare beos vos)) { # is unix
  0         0  
140 0         0 eval {
141 0     0   0 ($stdout, $stderr, @result) = capture { system("mv /etc/resolv.bk /etc/resolv.conf") };
  0         0  
142 0   0     0 die $stderr || $result[0];
143             }
144             }
145              
146 0 0 0     0 ($stderr||$result[0]) ? $self->log("local dns settings failed to restore: $@", 1)
147             : $self->log("local dns settings restored", 1);
148             }
149              
150             sub signal_handler {
151 1     1 0 5 my ( $self, $signal ) = @_;
152              
153 1         9 $self->log("shutting down: signal $signal");
154              
155 1 50       7 $self->restore_local_dns() if $self->{setdns};
156              
157 1         168 exit;
158             }
159              
160             sub reply_handler {
161 1     1 0 12 my ($self, $qname, $qclass, $qtype, $peerhost, $query,$conn) = @_;
162              
163 1         2 my ($rcode, @ans, @auth, @add);
164              
165 1 0 0     7 if ($self->{adfilter} && ($qtype eq 'AAAA' || $qtype eq 'A' || $qtype eq 'PTR')) {
      33        
166            
167 0 0       0 if (my $ip = $self->query_adfilter( $qname, $qtype )) {
168              
169 0         0 $self->log("received query from $peerhost: qtype '$qtype', qname '$qname'");
170 0         0 $self->log("[local] resolved $qname to $ip NOERROR");
171              
172 0         0 my ($ttl, $rdata) = ( 300, $ip );
173            
174 0         0 push @ans, Net::DNS::RR->new("$qname $ttl $qclass $qtype $rdata");
175              
176 0         0 $rcode = "NOERROR";
177            
178 0         0 return ($rcode, \@ans, \@auth, \@add, { aa => 1, ra => 1 });
179             }
180             }
181              
182 1         55 my $answer = $self->{resolver}->send($qname, $qtype, $qclass);
183              
184 1 50       31258 if ($answer) {
185              
186 1         6 $rcode = $answer->header->rcode;
187 1         43 @ans = $answer->answer;
188 1         14 @auth = $answer->authority;
189 1         8 @add = $answer->additional;
190            
191 1         13 $self->log("[proxy] response from remote resolver: $qname $rcode");
192              
193 1         33 return ($rcode, \@ans, \@auth, \@add);
194             } else {
195              
196 0         0 $self->log("[proxy] can not resolve $qtype $qname - no answer from remote resolver. Sending NXDOMAIN response.");
197              
198 0         0 $rcode = "NXDOMAIN";
199              
200 0         0 return ($rcode, \@ans, \@auth, \@add, { aa => 1, ra => 1 });
201             }
202             }
203              
204             sub log {
205 3     3 0 16 my ( $self, $msg, $force_flag ) = @_;
206 3 100 66     955 print "[" . strftime('%Y-%m-%d %H:%M:%S', localtime(time)) . "] " . $msg . "\n" if $self->{debug} || $force_flag;
207             }
208              
209             sub read_config {
210 2     2 0 6 my $self = shift;
211 2         14 my $attributes = thaw($attributes);
212 2         96 for ( keys %{$attributes} ) { $self->{$_} = $attributes->{$_} }; # HUP restore
  2         12  
  6         16  
213              
214 2         8 my $cache = ();
215              
216 2         16 $self->{forwarders} = ([ $self->parse_resolv_conf() ]); # /etc/resolv.conf
217              
218 2 50       10 if ($self->{adblock_stack}) {
219 0         0 for ( @{ $self->{adblock_stack} } ) {
  0         0  
220 0         0 $cache = { $self->load_adblock_filter($_) }; # adblock plus hosts
221 0         0 %{ $self->{adfilter} } = $self->{adfilter} ? ( %{ $self->{adfilter} }, %{ $cache } )
  0         0  
  0         0  
  0         0  
222 0 0       0 : %{ $cache };
223             }
224             }
225 2 50       8 if ($self->{blacklist}) {
226 0         0 $cache = { $self->parse_single_col_hosts($self->{blacklist}) }; # local, custom hosts
227 0         0 %{ $self->{adfilter} } = $self->{adfilter} ? ( %{ $self->{adfilter} }, %{ $cache } )
  0         0  
  0         0  
  0         0  
228 0 0       0 : %{ $cache };
229             }
230 2 50       10 if ($self->{whitelist}) {
231 0         0 $cache = { $self->parse_single_col_hosts($self->{whitelist}) }; # remove entries
232 0         0 for ( keys %{ $cache } ) { delete ( $self->{adfilter}->{$_} ) };
  0         0  
  0         0  
233             }
234              
235             # $self->dump_adfilter;
236              
237 2         24 return;
238             }
239              
240             sub query_adfilter {
241 0     0 0 0 my ( $self, $qname, $qtype ) = @_;
242              
243 0 0 0     0 return $self->search_ip_in_adfilter( $qname ) if ($qtype eq 'A' || $qtype eq 'AAAA');
244 0 0       0 return $self->search_hostname_by_ip( $qname ) if $qtype eq 'PTR';
245             }
246              
247             sub search_ip_in_adfilter {
248 0     0 0 0 my ( $self, $hostname ) = @_;
249              
250 0         0 my $trim = $hostname;
251 0         0 my $sld = $hostname;
252 0   0     0 my $loopback = $self->{loopback} || '127.0.0.1';
253              
254 0         0 $trim =~ s/^www\.//i;
255 0         0 $sld =~ s/^.*\.(.+\..+)$/$1/;
256              
257 0 0 0     0 return $loopback if ( exists $self->{adfilter}->{$hostname} ||
      0        
258             exists $self->{adfilter}->{$trim} ||
259             exists $self->{adfilter}->{$sld} );
260 0         0 return;
261             }
262              
263             sub search_hostname_by_ip {
264 0     0 0 0 my ( $self, $ip ) = @_;
265              
266 0   0     0 $ip = $self->get_in_addr_arpa( $ip ) || return;
267             }
268              
269             sub get_in_addr_arpa {
270 0     0 0 0 my ( $self, $ptr ) = @_;
271              
272 0         0 my ($reverse_ip) = ($ptr =~ m!^([\d\.]+)\.in-addr\.arpa$!);
273 0 0       0 return unless $reverse_ip;
274 0         0 my @octets = reverse split(/\./, $reverse_ip);
275 0         0 return join('.', @octets);
276             }
277              
278             sub parse_resolv_conf {
279 2     2 0 6 my ( $self ) = shift;
280              
281 2 50       12 return @{$self->{forwarders}} if $self->{forwarders};
  2         12  
282              
283 0           $self->log('reading /etc/resolv.conf file');
284              
285 0           my @dns_servers;
286              
287 0 0         open (RESOLV, "/etc/resolv.conf") || croak "cant open /etc/resolv.conf file: $!";
288              
289 0           while () {
290 0 0         if (/^nameserver\s+([\d\.]+)/) {
291 0           push @dns_servers, $1;
292             }
293             }
294              
295 0           close (RESOLV);
296 0 0         croak "no nameservers listed in /etc/resolv.conf!" unless @dns_servers;
297 0           return @dns_servers;
298             }
299              
300             sub load_adblock_filter {
301 0     0 0   my ( $self ) = shift;
302 0           my %cache;
303              
304 0 0         my $hostsfile = $_->{path} or die "adblock {path} is undefined";
305 0   0       my $refresh = $_->{refresh} || 7;
306 0   0       my $age = -M $hostsfile || $refresh;
307              
308 0 0         if ($age >= $refresh) {
309 0 0         my $url = $_->{url} or die "attempting to refresh $hostsfile failed as {url} is undefined";
310 0           $url =~ s/^\s*abp:subscribe\?location=//;
311 0           $url =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
  0            
312 0           $url =~ s/&.*$//;
313 0           $self->log("refreshing hosts: $hostsfile", 1);
314 0           getstore($url, $hostsfile);
315             }
316              
317 0           %cache = $self->parse_adblock_hosts($hostsfile);
318              
319 0           return %cache;
320             }
321              
322             sub parse_adblock_hosts {
323 0     0 0   my ( $self, $hostsfile ) = @_;
324 0           my %hosts;
325              
326 0 0         open(HOSTS, $hostsfile) or die "cant open $hostsfile file: $!";
327              
328 0           while () {
329 0           chomp;
330 0 0         next unless s/^\|\|(.*)\^(\$third-party)?$/$1/; #extract adblock host
331 0           $hosts{$_}++;
332             }
333              
334 0           close(HOSTS);
335              
336 0           return %hosts;
337             }
338              
339             sub parse_single_col_hosts {
340 0     0 0   my ( $self, $hostsfile ) = @_;
341 0           my %hosts;
342              
343 0 0         open(HOSTS, $hostsfile) or die "cant open $hostsfile file: $!";
344              
345 0           while () {
346 0           chomp;
347 0 0         next if /^\s*#/; # skip comments
348 0 0         next if /^$/; # skip empty lines
349 0           s/\s*#.*$//; # delete in-line comments and preceding whitespace
350 0           $hosts{$_}++;
351             }
352              
353 0           close(HOSTS);
354              
355 0           return %hosts;
356             }
357              
358             sub dump_adfilter {
359 0     0 0   my $self = shift;
360              
361 0           my $str = Dumper(\%{ $self->{adfilter} });
  0            
362 0 0         open(OUT, ">/var/named/adfilter_dumpfile") or die "cant open dump file: $!";
363 0           print OUT $str;
364 0           close OUT;
365             }
366              
367             1;
368              
369             =head1 NAME
370              
371             App::DNS::Adblock - A lightweight DNS ad filter
372              
373             =head1 VERSION
374              
375             version 0.015
376              
377             =head1 DESCRIPTION
378              
379             This is an ad filter for use in a local area network. Its function is to load
380             lists of ad domains and answer DNS queries for those domains with a loopback
381             address. Any other DNS queries are forwarded upstream, either to a specified
382             list of nameservers or to those listed in /etc/resolv.conf.
383              
384             The module loads externally maintained lists of ad hosts intended for use
385             by the I Firefox extension. Use of the lists focuses only on
386             third-party listings that define dedicated advertising and tracking hosts.
387              
388             A custom blacklist and/or whitelist can also be loaded. In this case, host
389             listings must conform to a one host per line format.
390              
391             Once running, local network dns queries can be addressed to the host's ip.
392              
393             =head1 SYNOPSIS
394              
395             my $adfilter = App::DNS::Adblock->new();
396              
397             $adfilter->run();
398              
399             Without any parameters, the module will function simply as a proxy, forwarding all
400             requests upstream to predefined nameservers.
401              
402             =head1 ATTRIBUTES
403              
404             =head2 adblock_stack
405              
406             my $adfilter = App::DNS::Adblock->new(
407              
408             adblock_stack => [
409             {
410             url => 'http://pgl.yoyo.org/adservers/serverlist.php?hostformat=adblockplus&showintro=0&startdate[day]=&startdate[month]=&startdate[year]=&mimetype=plaintext',
411             path => '/var/named/pgl-adblock.txt', #path to ad hosts
412             refresh => 7, #refresh value in days (default = 7)
413             },
414              
415             {
416             url => 'abp:subscribe?location=https%3A%2F%2Feasylist-downloads.adblockplus.org%2Feasyprivacy.txt&title=EasyPrivacy&requiresLocation=https%3A%2F%2Feasylist-downloads.adblockplus.org%2Feasylist.txt&requiresTitle=EasyList';
417             path => '/var/named/easyprivacy.txt',
418             refresh => 5,
419             },
420             ],
421             );
422              
423             The adblock_stack arrayref encloses one or more hashrefs composed of three
424             parameters: a url that returns a list of ad hosts in adblock plus format;
425             a path string that defines where the module will write a local copy of
426             the list; a refresh value that determines what age (in days) the local copy
427             may be before it is refreshed.
428              
429             A collection of lists is available at http://adblockplus.org/en/subscriptions.
430             The module will accept standard or abp:subscribe? urls. You can cut and paste
431             encoded links directly.
432              
433             =head2 blacklist
434              
435             my $adfilter = App::DNS::Adblock->new(
436             blacklist => '/var/named/blacklist', #path to secondary hosts
437             );
438              
439             A path string that defines where the module will access a local list of ad hosts.
440             A single column is the only acceptable format:
441              
442             # ad nauseam
443             googlesyndication.com
444             facebook.com
445             twitter.com
446             ...
447              
448             =head2 whitelist
449              
450             my $adfilter = App::DNS::Adblock->new(
451              
452             whitelist => '/var/named/whitelist', #path to exclusions
453             );
454              
455             A path string to a single column list of hosts. These hosts will be removed from the filter.
456              
457             =head2 host, port
458              
459             my $adfilter = App::DNS::Adblock->new( host => $host, port => $port );
460              
461             The IP address to bind to. If not defined, the server attempts binding to the local ip.
462             The default port is 53.
463              
464             =head2 forwarders, forwarders_port
465              
466             my $adfilter = App::DNS::Adblock->new( forwarders => [ nameserver, ], forwarders_port => $port );
467              
468             An arrayref of one or more nameservers to forward any DNS queries to. Defaults to nameservers
469             listed in /etc/resolv.conf. The default port is 53. Windows machines should define a forwarder to avoid
470             the default behavior.
471              
472             =head2 setdns
473              
474             my $adfilter = App::DNS::Adblock->new( setdns => '1' ); #defaults to '0'
475              
476             If set, the module attempts to set local dns settings to the host's ip. This may or may not work
477             if there are multiple active interfaces. You may need to manually adjust your local dns settings.
478              
479             =head2 loopback
480              
481             my $adfilter = App::DNS::Adblock->new( loopback => '127.255.255.254' ); #defaults to '127.0.0.1'
482              
483             If set, the nameserver will return this address rather than the standard loopback address.
484              
485             =head2 debug
486              
487             my $adfilter = App::DNS::Adblock->new( debug => '1' ); #defaults to '0'
488              
489             The debug option logs actions to stdout and can be set from 1-3 with increasing output: the module will
490             feedback (1) adfilter.pm logging, (2) nameserver logging, and (3) resolver logging.
491              
492             =head1 CAVEATS
493              
494             Tested under darwin only.
495              
496             =head1 AUTHOR
497              
498             David Watson
499              
500             =head1 SEE ALSO
501              
502             scripts/ in the distribution
503              
504             This module is essentially a lightweight, non-Moose version of Net::DNS::Dynamic::Adfilter
505              
506             =head1 COPYRIGHT AND LICENSE
507              
508             This library is free software, you can redistribute it and/or modify
509             it under the same terms as Perl itself.
510              
511             The full text of the license can be found in the LICENSE file included with this module.
512              
513             =cut