File Coverage

blib/lib/Parse/DNS/Zone.pm
Criterion Covered Total %
statement 222 230 96.5
branch 66 80 82.5
condition 30 46 65.2
subroutine 27 27 100.0
pod 13 13 100.0
total 358 396 90.4


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             # Parse::DNS::Zone - DNS Zone File Parser
3             #
4             # Copyright (c) 2009-2021 - Olof Johansson
5             #
6             # This program is free software; you can redistribute it and/or
7             # modify it under the same terms as Perl itself.
8              
9             =pod
10              
11             =head1 NAME
12              
13             Parse::DNS::Zone - DNS Zone File Parser
14              
15             =head1 SYNOPSIS
16              
17             use Parse::DNS::Zone;
18              
19             my $pdz = Parse::DNS::Zone->new(
20             zonefile=>'db.example',
21             origin=>'example.org.',
22             );
23              
24             my $a_rr = $pdz->get_rdata(name=>'foo', rr=>'A');
25             my $mx_rr = $pdz->get_rdata(name=>'@', rr=>'MX'); # Get the origin's MX
26              
27             # Getting SOA values
28             my $mname = $pdz->get_mname();
29             my $rname = $pdz->get_rname();
30             my $serial = $pdz->get_serial();
31             # ... etc ...
32              
33             =head1 DESCRIPTION
34              
35             B parses a zonefile, used to define a DNS Zone
36             and gives you the information therein, via an object oriented
37             interface. Parse::DNS::Zone doesn't validate rrdata, except for
38             SOA, and is used to 1) validate the basic structure of the file
39             and 2) extract rdata so you can parse it and validate it yourself.
40              
41             Parse::DNS::Zone supports the zone file format as described in
42             RFC 1034:
43              
44             =over 4
45              
46             =item * $INCLUDE
47              
48             =item * $ORIGIN
49              
50             =item * $TTL (as described in RFC 2308)
51              
52             =back
53              
54             Parse::DNS::Zone does not support $GENERATE in this version.
55              
56             Additionally, use of time unit suffixes (e.g. using 1d instead of
57             86400 or 1m30s instead of 90, etc), as supported by Bind9 and nsd et
58             al is supported in TTLs and the time fields of a SOA record (as
59             of version 0.70).
60              
61             =cut
62              
63 5     5   508876 use 5.010;
  5         17  
64             package Parse::DNS::Zone;
65             our $VERSION = '0.70';
66 5     5   42 use warnings;
  5         18  
  5         309  
67 5     5   30 use strict;
  5         7  
  5         132  
68 5     5   26 use File::Basename;
  5         7  
  5         470  
69 5     5   30 use File::Spec;
  5         12  
  5         97  
70 5     5   19 use Carp;
  5         8  
  5         19293  
71              
72             =head1 CONSTRUCTOR
73              
74             =head2 Parse::DNS::Zone->new( ARGS )
75              
76             =over 4
77              
78             =item B
79              
80             =over 4
81              
82             =item * B
83              
84             Origin
85              
86             =back
87              
88             And additionally, exactly one of the following:
89              
90             =over 4
91              
92             =item * B
93              
94             Path to the zonefile being parsed
95              
96             =item * B
97              
98             The zone, as a string.
99              
100             =back
101              
102             =item B
103              
104             =over 4
105              
106             =item * B
107              
108             If set to a true value, the parser will whine and die if
109             the zonefile doesn't contain a SOA record. (Default: yes)
110              
111             =item * B
112              
113             Specify a basepath, from which included relative zonefiles
114             should be available. If used with the B parameter,
115             this defaults to the directory in which the zonefile is in.
116             For $INCLUDEs to work when passing the zone in as a string,
117             this needs to be specified.
118              
119             =item * B
120              
121             If set to a true value, the parser will append the origin
122             to all unqualified domain names (in certain record types,
123             currently: CNAME, MX, NS, AFSDB, PTR). If some record
124             types are missing from this list, please report that as a
125             bug. (Default: no)
126              
127             This feature do run the risk of becoming stale if new
128             record types are introduced. But if you run into problems,
129             don't hesitate to report it!
130              
131             =back
132              
133             =back
134              
135             =cut
136              
137             sub new {
138 7     7 1 329707 my $class = shift;
139 7         51 my $self = {
140             require_soa => 1,
141             append_origin => 0,
142             @_
143             };
144              
145 7 100 66     60 if (not defined $self->{zonestr} and defined $self->{zonefile}) {
146 6         24 $self->{zonestr} = _load_zonefile($self->{zonefile});
147             }
148 7 50       36 if (not defined $self->{zonestr}) {
149 0         0 croak("You need to specify either zonestr or zonefile");
150             }
151              
152             # default basepath is dirname($zonefile)
153 7 100       27 if (not exists $self->{basepath}) {
154             $self->{basepath} = dirname($self->{zonefile}) if
155 6 50       386 defined $self->{zonefile};
156             }
157              
158             # append trailing .
159 7 50       47 $self->{origin} .= '.' if($self->{origin}=~/[^[^\.]$/);
160 7         30 bless($self, $class);
161              
162 7         44 _parse($self);
163              
164 7 50 33     60 if($self->{require_soa} &&
165             (!exists $self->{zone}{$self->{origin}}{soa})) {
166 0         0 croak("No SOA in zonefile");
167             }
168              
169 7         66 _parse_soa($self);
170              
171 7         177 return $self;
172             }
173              
174             =head1 METHODS
175              
176             =head2 General
177              
178             =head3 $pdz->get_rdata(name=>$name, rr=>$rr, n=>$n, field=>$field)
179              
180             Is used to get the data associated with a specific name and rr
181             type. The $name can be as the name appears in the zonefile, or a
182             fqdn (with trailing .) as long as it is tracked by the zonefile.
183             If the n argument is specified, the n:th RR in the RRset is
184             returned. Otherwise, you'll get a complete list of the RRset if
185             you're in list context, or the first RR if you're in scalar
186             context.
187              
188             The $field is the particular component of the resource record to
189             return. It defaults to 'rdata', which is the actual value of the
190             record. Other possibilities are 'class' (e.g. "IN") and 'ttl'.
191              
192             =cut
193              
194             sub get_rdata {
195 51     51 1 865 my $self = shift;
196 51         221 my $h = {
197             field=>'rdata',
198             @_,
199             };
200              
201 51         115 my ($name, $rr, $field, $n) = @{$h}{qw(name rr field n)};
  51         166  
202              
203 51         206 $name=~s/^\@$/$self->{origin}/g;
204 51         122 $name=~s/\.\@\./\.$self->{origin}/g;
205 51         111 $name=~s/\.\@$/\.$self->{origin}/g;
206 51         73 $name=~s/\@\.$/\.$self->{origin}/g;
207 51 100 100     290 $name .= ".$self->{origin}" if(($name ne $self->{origin}) &&
208             (!($name=~/\.$/)));
209              
210 51 100       143 return $self->{zone}{lc $name}{lc $rr}{lc $field}[$n] if defined $n;
211 49 100 100     102 return @{$self->{zone}{lc $name}{lc $rr}{lc $field} // []} if wantarray;
  3         44  
212 46         426 return $self->{zone}{lc $name}{lc $rr}{lc $field}[0];
213             }
214              
215             =head3 $pdz->exists($name)
216              
217             Returns a true value if the name exists, and false otherwise.
218              
219             =cut
220              
221             sub exists {
222 7     7 1 21 my $self = shift;
223 7         13 my $name = shift;
224              
225 7         19 $name=~s/^\@$/$self->{origin}/g;
226 7         13 $name=~s/\.\@\./\.$self->{origin}/g;
227 7         14 $name=~s/\.\@$/\.$self->{origin}/g;
228 7         11 $name=~s/\@\.$/\.$self->{origin}/g;
229 7 100 66     69 $name .= ".$self->{origin}" if(($name ne $self->{origin}) &&
230             (!($name=~/\.$/)));
231              
232 7         55 return exists $self->{zone}{lc $name};
233             }
234              
235             =head3 $pdz->get_rrs($name)
236              
237             Returns a list with all RR types for a specific name
238              
239             =cut
240              
241             sub get_rrs {
242 1     1 1 4 my $self = shift;
243 1         3 my $name = shift;
244 1         3 my @rrs;
245              
246 1         3 $name=~s/^\@$/$self->{origin}/g;
247 1         3 $name=~s/\.\@\./\.$self->{origin}/g;
248 1         3 $name=~s/\.\@$/\.$self->{origin}/g;
249 1         3 $name=~s/\@\.$/\.$self->{origin}/g;
250 1 50 33     11 $name .= ".$self->{origin}" if(($name ne $self->{origin}) &&
251             (!($name=~/\.$/)));
252              
253 1         3 foreach my $k (keys %{$self->{zone}{lc $name}}) {
  1         6  
254 2         6 push @rrs, $k;
255             }
256              
257 1         4 return @rrs;
258             }
259              
260             =head3 $pdz->get_dupes(name=>$name, rr=>$rr)
261              
262             Returns how many RRs of a given type is defined for $name. For a simple
263             setup with a single RR for $name, this will return 1. If you have some
264             kind of load balancing or other scheme using multiple RRs of the same
265             type this sub will return the number of "dupes".
266              
267             =cut
268              
269             sub get_dupes {
270 4     4 1 12 my $self = shift;
271 4         17 my $h = {
272             @_,
273             };
274              
275 4         39 my $name = $h->{name};
276 4         10 my $rr = $h->{rr};
277              
278 4         26 $name=~s/^\@$/$self->{origin}/g;
279 4         12 $name=~s/\.\@\./\.$self->{origin}/g;
280 4         10 $name=~s/\.\@$/\.$self->{origin}/g;
281 4         9 $name=~s/\@\.$/\.$self->{origin}/g;
282 4 100 66     33 $name .= ".$self->{origin}" if(($name ne $self->{origin}) &&
283             (!($name=~/\.$/)));
284              
285 4         8 return int(@{$self->{zone}{lc $name}{lc $rr}{rdata}});
  4         43  
286             }
287              
288             =head3 $pdz->get_names( )
289              
290             Returns a list with all names specified in the zone
291              
292             =cut
293              
294             sub get_names {
295 3     3 1 12 my $self = shift;
296 3         8 my @names;
297              
298 3         6 foreach my $n (keys %{$self->{zone}}) {
  3         52  
299 72         178 push @names, $n;
300             }
301              
302 3         34 return @names;
303             }
304              
305             =head2 SOA
306              
307             =head3 $pdz->get_mname( )
308              
309             Returns the MNAME part of the SOA.
310              
311             =cut
312              
313             sub get_mname {
314 1     1 1 934 my $self = shift;
315 1         8 return $self->{soa}{mname};
316             }
317              
318             =head3 $pdz->get_rname( parse=>{0,1} )
319              
320             Return the RNAME part of the SOA. If parse is set to a value
321             other than 0, the value will be interpreted to show an
322             emailaddress. (default: 0)
323              
324             =cut
325              
326             sub get_rname {
327 2     2 1 5 my $self = shift;
328 2         10 my %p = (
329             parse=>0,
330             @_
331             );
332              
333 2         6 my $ret = $self->{soa}{rname};
334 2 100       9 if($p{parse}) {
335 1         9 my ($user,$host)=$self->{soa}{rname}=~/^([^\.]+)\.(.*)$/;
336 1         4 $ret = "$user\@$host";
337             }
338              
339 2         12 return $ret;
340             }
341              
342             =head3 $pdz->get_serial( )
343              
344             Return the SERIAL value of a SOA.
345              
346             =cut
347              
348             sub get_serial {
349 2     2 1 870 my $self = shift;
350 2         10 return $self->{soa}{serial};
351             }
352              
353             =head3 $pdz->get_refresh( )
354              
355             Return the REFRESH value of a SOA
356              
357             =cut
358              
359             sub get_refresh {
360 2     2 1 7 my $self = shift;
361 2         12 return $self->{soa}{refresh};
362             }
363              
364             =head3 $pdz->get_retry( )
365              
366             Return the RETRY value of a SOA
367              
368             =cut
369              
370             sub get_retry {
371 2     2 1 5 my $self = shift;
372 2         11 return $self->{soa}{retry};
373             }
374              
375             =head3 $pdz->get_expire( )
376              
377             Return the EXPIRE value of a SOA
378              
379             =cut
380              
381             sub get_expire {
382 2     2 1 4 my $self = shift;
383 2         8 return $self->{soa}{expire};
384             }
385              
386             =head3 $pdz->get_minimum( )
387              
388             Return the MINIMUM value of a SOA
389              
390             =cut
391              
392             sub get_minimum {
393 2     2 1 4 my $self = shift;
394 2         9 return $self->{soa}{minimum};
395             }
396              
397             # Is used to populate the zone hash used internally.
398             sub _parse {
399 7     7   13 my $self = shift;
400              
401             my %zone = $self->_parse_zone(
402             zonestr => $self->{zonestr},
403             origin => $self->{origin},
404 7         48 );
405              
406 7         59 undef $self->{zone};
407 7         77 $self->{zone}={%zone};
408             }
409              
410             sub _load_zonefile {
411 11     11   27 my $file = shift;
412 11 50       695 open(my $zonefh, $file) or croak("Could not open $file: $!");
413 11         41 return do { local $/; <$zonefh> }; # slurp
  11         55  
  11         582  
414             }
415              
416             sub _parse_time_unit {
417 60     60   110 my $string = shift;
418              
419 60         159 my $time_units_regex = qr/^
420             (?: (?=\d+(?: w|d|h|m|s))
421             (\d+w)? (\d+d)? (\d+h)? (\d+m)? (\d+s)? ) | (\d+)
422             $/xi;
423 60         439 my @parts = ($string =~ $time_units_regex);
424              
425 60         228 my %multi_for_unit = (
426             s => 1,
427             m => 60,
428             h => 3600,
429             d => 86400,
430             w => 604800,
431             );
432              
433 60         167 my $seconds = 0;
434              
435 60         113 for my $part (@parts) {
436 360 100       667 next unless $part;
437 62         297 my ($value, $unit) = $part =~ /^(\d+)(\D+)?$/;
438 62 100       163 $unit = 's' unless $unit;
439 62         105 $unit = lc($unit);
440             # this should never happen as the %multi_for_unit hash
441             # includes all units the regex parses, just extra safety.
442             die "BUG: known unit '$unit' is unhandled"
443 62 50       138 unless exists $multi_for_unit{$unit};
444 62         192 $seconds += $value * $multi_for_unit{$unit};
445             }
446              
447 60         250 return $seconds;
448             }
449              
450             # Is used internally to parse a zone from a filename. will do some
451             # recursion for the $include, so a procedural implementation is needed
452             sub _parse_zone {
453 12     12   24 my $self = shift;
454             # $def_class and $def_ttl are only given when called for included zones
455 12         62 my %opts = @_;
456              
457 12   33     54 my $origin = $opts{origin} // $self->{origin};
458              
459 12         23 my $zonestr = $opts{zonestr};
460 12 50 66     62 if (not defined $zonestr and exists $opts{zonefile}) {
461 5         34 $zonestr = _load_zonefile($opts{zonefile});
462             }
463              
464 12         27 my ($def_class, $def_ttl);
465 12 100       53 if ($opts{included}) {
466 5         12 ($def_class, $def_ttl) = @{\%opts}{qw(default_class
  5         42  
467             default_ttl)};
468              
469             }
470 12         30 my $zonepath = $self->{basepath};
471              
472 12         34 my $mrow;
473             my $prev;
474 12         0 my %zone;
475              
476 12         67 my $time_units_regex = qr/
477             (?:
478             (?=\d+(?: w|d|h|m|s))
479             (?: \d+w)? (?: \d+d)? (?: \d+h)? (?: \d+m)? (?: \d+s)?
480             ) | \d+
481             /ix;
482              
483 12         706 my $zentry = qr/^
484             (\S+)\s+ # name
485             (?:
486             (
487             (?: (?: IN | CH | HS ) \s+ $time_units_regex ) |
488             (?: $time_units_regex \s+ (?: IN | CH | HS ) ) |
489             (?: (?: IN | CH | HS ) ) |
490             (?: $time_units_regex )
491             )
492             \s+
493             )? # or
494             (\S+)\s+ # type
495             (.*) # rdata
496             $/ix;
497              
498 12         182 for (split /\n/, $zonestr) {
499 315         616 chomp;
500              
501             # Strip away any quoted strings; they should not be parsed in
502             # the same way as the rest of the zonefile. Replace the strings
503             # with placeholders. The real strings are kept in @strs, and
504             # we'll replace them with the real strings again soon.
505 315         664 ($_, my @strs) = _strip_quotes($_);
506              
507             # Strip comments.
508 315         866 s/(?
509 315 100       1143 next if /^\s*$/;
510              
511             # Normalize in-line whitespace
512 235         1220 s/\s+/ /g;
513              
514 235         501 s/^\@ /$origin /g;
515 235         414 s/ \@ / $origin /g;
516 235         355 s/ \@$/ $origin/g;
517              
518             # Re-add the real strings again.
519 235         481 $_ = _unstrip_quotes($_, @strs);
520              
521             # handles mutlirow entries, with ()
522 235 100       738 if($mrow) {
    100          
523 45         99 $mrow.=$_;
524              
525 45 100       154 next if(! /\)/);
526              
527             # End of multirow
528 11         59 $mrow=~s/[\(\)]//g;
529 11         22 $mrow=~s/\n//mg;
530 11         94 $mrow=~s/\s+/ /g;
531 11         21 $mrow .= "\n";
532              
533 11         23 $_ = $mrow;
534 11         43 undef $mrow;
535             } elsif(/^.*\([^\)]*$/) {
536             # Start of multirow
537 11         33 $mrow.=$_;
538 11         26 next;
539             }
540              
541 190 100       407 if(/^ /) {
542 5         36 s/^/$prev/;
543             }
544              
545 190 100       436 $origin = $1, next if(/^\$ORIGIN ([\w\-\.]+)\s*$/i);
546 189 100       441 if(/^\$TTL (\S+)\s*$/i) {
547 6         19 $def_ttl = _parse_time_unit($1);
548 6         18 next;
549             }
550 183 100       375 if(/^\$INCLUDE (\S+)(?: (\S+))?\s*(?:;.*)?$/i) {
551 5 50       26 my $subo=defined $2?$2:$origin;
552              
553 5         14 my $zfile = $1;
554 5 50       35 if($1 !~ m/^\//) {
555 5         138 $zfile = File::Spec->catfile($zonepath, $zfile);
556             }
557              
558 5         59 my %subz = $self->_parse_zone(
559             zonefile => $zfile,
560             included => 1,
561             origin => $subo,
562             default_class => $def_class,
563             default_ttl => $def_ttl,
564             );
565              
566 5         19 foreach my $k (keys %subz) {
567 5         14 $zone{$k}=$subz{$k};
568             }
569 5         17 next;
570             }
571              
572 178         1744 my($name,$ttlclass,$type,$rdata) = /$zentry/;
573              
574 178         502 $rdata =~ s/\s+$//g;
575              
576 178         271 my($ttl, $class);
577 178 100 66     470 if(defined $ttlclass && $ttlclass ne '') {
578 36         491 ($ttl) = $ttlclass=~/($time_units_regex)/;
579 36         183 ($class) = $ttlclass=~/(CH|IN|HS)/i;
580              
581 36         215 $ttlclass=~s/$time_units_regex//;
582 36         119 $ttlclass=~s/(?:CH|IN|HS)//;
583 36         79 $ttlclass=~s/\s//g;
584 36 50       89 if($ttlclass) {
585 0         0 carp "bad rr: $_ (ttlclass: $ttlclass)";
586 0         0 next;
587             }
588             }
589              
590 178 100       378 $ttl = defined $ttl ? _parse_time_unit($ttl) : $def_ttl;
591 178 100       344 $class = defined $class ? $class : $def_class;
592 178         272 $def_class = $class;
593              
594 178 50 33     880 next if (!$name || !$type || !$rdata);
      33        
595              
596 178 50       419 if(not defined $def_class) {
597 0         0 carp("no class is set");
598 0         0 next;
599             }
600              
601 178 50       322 if(not defined $ttl) {
602 0         0 carp("no ttl is set");
603 0         0 next;
604             }
605              
606 178         289 $prev=$name;
607 178         400 $name = _fqdnize($name, $origin);
608              
609 178 100 100     641 if($self->{append_origin} and
      66        
      100        
610             $type =~ /^(?:cname|afsdb|mx|ns)$/i and
611             $rdata ne $origin and $rdata !~ /\.$/) {
612 3         9 $rdata.=".$origin";
613             }
614              
615 178         279 push(@{$zone{lc $name}{lc $type}{rdata}}, $rdata);
  178         1007  
616 178         303 push(@{$zone{lc $name}{lc $type}{ttl}}, $ttl);
  178         556  
617 178         302 push(@{$zone{lc $name}{lc $type}{class}}, $class);
  178         709  
618             }
619              
620 12         177 return %zone;
621             }
622              
623             sub _strip_quotes {
624 321     321   164185 local $_ = shift;
625 321         876 my $qstr = qr/(".*?(?
626 321         1423 my @strs = /$qstr/g;
627              
628 321         787 for my $str (keys @strs) {
629 31         710 s/\Q$strs[$str]\E/"\$str[$str]"/;
630             }
631              
632 321         12509 return $_, @strs;
633             }
634              
635             sub _unstrip_quotes {
636 241     241   5973 return shift =~ s/"\$str\[([0-9]+)\]"/$_[$1]/gr;
637             }
638              
639             sub _fqdnize {
640 184     184   167540 my ($name, $origin) = @_;
641              
642 184   100     368 $origin //= '.';
643 184 100       594 $origin .= '.' unless $origin =~ /\.$/;
644              
645 184 100       473 return $name if $name =~ /\.$/;
646 146 100       334 return "$name." if $origin eq '.';
647 145         419 return "$name.$origin";
648             }
649              
650             # Is used to parse the SOA and build the soa hash as used
651             # internally..
652             sub _parse_soa {
653 7     7   12 my $self = shift;
654 7         33 my $soa_rd = get_rdata($self, (name=>"$self->{origin}", rr=>'SOA'));
655 7         73 my($mname,$rname,$serial,$refresh,$retry,$expire,$minimum)=
656             $soa_rd=~/^(\S+) (\S+) (\d+) (\S+) (\S+) (\S+) (\S+)\s*$/;
657              
658 7         25 $self->{soa}{mname}=$mname;
659 7         18 $self->{soa}{rname}=$rname;
660 7         27 $self->{soa}{serial}=$serial;
661 7         16 $self->{soa}{refresh}=_parse_time_unit($refresh);
662 7         16 $self->{soa}{retry}=_parse_time_unit($retry);
663 7         16 $self->{soa}{expire}=_parse_time_unit($expire);
664 7         16 $self->{soa}{minimum}=_parse_time_unit($minimum);
665             }
666              
667             1;
668              
669             =head1 SEE ALSO
670              
671             RFC 1034, RFC 1035, Bind Administrator's Guide
672              
673             =head1 AVAILABILITY
674              
675             Latest stable version is available on CPAN. Current development
676             version is available on https://github.com/olof/Parse-DNS-Zone, and
677             this is the I place to report issues.
678              
679             =head1 COPYRIGHT
680              
681             Copyright (c) 2009-2021 - Olof Johansson
682              
683             This program is free software; you can redistribute it and/or
684             modify it under the same terms as Perl itself.
685              
686             =cut