# DNS::ZoneParse # Parse and Manipulate DNS Zonefiles # Version 0.95 # CVS: $Id: ZoneParse.pm,v 1.5 2004/10/24 16:55:01 simonflack Exp $ package DNS::ZoneParse; use 5.005; use Storable 'dclone'; use POSIX 'strftime'; use vars qw($VERSION); use strict; use Carp; $VERSION = '0.95'; my (%dns_id, %dns_soa, %dns_ns, %dns_a, %dns_cname, %dns_mx, %dns_txt, %dns_ptr, %dns_a4, %dns_last_name); sub new { my $class = shift; my $self = bless [], $class; $self->_initialize(); $self->_load_file(@_) if @_; return $self; } sub DESTROY { my $self = shift; delete $dns_soa {$self}; delete $dns_ns {$self}; delete $dns_a {$self}; delete $dns_cname {$self}; delete $dns_mx {$self}; delete $dns_txt {$self}; delete $dns_ptr {$self}; delete $dns_a4 {$self}; delete $dns_id {$self}; delete $dns_last_name {$self}; } sub AUTOLOAD { my $self = shift; (my $method = $DNS::ZoneParse::AUTOLOAD) =~ s/.*:://; my $rv = $method eq 'soa' ? $dns_soa {$self} : $method eq 'ns' ? $dns_ns {$self} : $method eq 'a' ? $dns_a {$self} : $method eq 'cname' ? $dns_cname {$self} : $method eq 'mx' ? $dns_mx {$self} : $method eq 'txt' ? $dns_txt {$self} : $method eq 'ptr' ? $dns_ptr {$self} : $method eq 'aaaa' ? $dns_a4 {$self} : $method eq 'zonefile' ? $dns_id {$self}->{ZoneFile} : $method eq 'origin' ? $dns_id {$self}->{Origin} : undef; croak "Invalid method called: $method" unless defined $rv; return $rv; } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Public OO Methods # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # sub dump { # returns a HOH for use with XML modules, etc my $self = shift; return dclone({ SOA => $dns_soa {$self}, AAAA => $dns_a4 {$self}, A => $dns_a {$self}, NS => $dns_ns {$self}, CNAME => $dns_cname {$self}, MX => $dns_mx {$self}, PTR => $dns_ptr {$self}, TXT => $dns_txt {$self}, }); } sub new_serial { my $self = shift; my $incriment = shift || 0; my $soa = $dns_soa{$self}; if ($incriment > 0) { $soa->{serial} += $incriment; } else { my $newserial = strftime("%Y%m%d%H", localtime(time)); $soa->{serial} = ($newserial > $soa->{serial}) ? $newserial : $soa->{serial} + 1; } return $soa->{serial}; } sub output { my $self = shift; my @quick_classes = qw(A AAAA CNAME PTR); my $zone_ttl = $dns_soa{$self}{ttl} ? "\$TTL $dns_soa{$self}{ttl}" : ''; my $output = ""; $output .= <{ZoneFile} for $dns_id{$self}->{Origin} zone. ; Zone version: $dns_soa{$self}->{serial} ; $zone_ttl $dns_soa{$self}->{origin} $dns_soa{$self}->{ttl} IN SOA $dns_soa{$self}->{primary} $dns_soa{$self}->{email} ( $dns_soa{$self}->{serial} ; serial number $dns_soa{$self}->{refresh} ; refresh $dns_soa{$self}->{retry} ; retry $dns_soa{$self}->{expire} ; expire $dns_soa{$self}->{minimumTTL} ; minimum TTL ) ; ; Zone NS Records ; ZONEHEADER foreach (@{$dns_ns{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} NS $_->{host}\n"; } $output .= "\n\;\n\; Zone MX Records\n\;\n\n"; foreach (@{$dns_mx{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} MX $_->{priority} " ." $_->{host}\n"; } $output .= "\n\;\n\; Zone Records\n\;\n\n"; foreach (@{$dns_a{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} A $_->{host}\n"; } foreach (@{$dns_cname{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} CNAME $_->{host}\n"; } foreach (@{$dns_a4{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} AAAA $_->{host}\n"; } foreach (@{$dns_txt{$self}}) { next unless defined; $output .= qq[$_->{name} $_->{ttl} $_->{class} TXT "$_->{text}"\n] } foreach (@{$dns_ptr{$self}}) { next unless defined; $output .= "$_->{name} $_->{ttl} $_->{class} PTR $_->{host}\n"; } return $output; } # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Private Methods # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # sub _initialize { my $self = shift; $dns_id {$self} = {}; $dns_soa {$self} = {}; $dns_ns {$self} = []; $dns_a {$self} = []; $dns_cname {$self} = []; $dns_mx {$self} = []; $dns_txt {$self} = []; $dns_ptr {$self} = []; $dns_a4 {$self} = []; $dns_last_name{$self} = '@'; return 1; } sub _load_file { my ($self, $zonefile, $origin) = @_; my $zone_contents; if(ref($zonefile) eq "SCALAR") { $zone_contents = $$zonefile; } else { local *inZONE; if (open(inZONE, "$zonefile")) { $zone_contents = do {local $/; }; close(inZONE); } else { croak qq[DNS::ZoneParse Could not open input file: "$zonefile":$!] } } if ($self->_parse( $zonefile, $zone_contents, $origin )) { return 1; } } sub _parse { my ($self, $zonefile, $contents, $origin) = @_; $self->_initialize(); my $chars = qr/[a-z\-\.0-9]+/i; $contents =~ /Database file ($chars)( dns)? for ($chars) zone/si; $dns_id{$self} = $self -> _massage({ ZoneFile => $1 || $zonefile, Origin => $3 || $origin, }); my $records = $self->_clean_records($contents); my $valid_name = qr/[\@a-z_\-\.0-9\*]+/i; my $valid_ip6 = qr/[\@a-z_\-\.0-9\*:]+/i; my $rr_class = qr/\b(?:IN|HS|CH)\b/i; my $rr_type = qr/\b(?:NS|A|CNAME)\b/i; my $rr_ttl = qr/(?:\d+[wdhms]?)+/i; my $ttl_cls = qr/(?:($rr_ttl)\s)?(?:($rr_class)\s)?/; my $last_name = $dns_id {$self} -> {Origin} || '@'; foreach (@$records) { TRACE ("parsing line <$_>"); if (/^($valid_name)? \s* # host $ttl_cls # ttl & class ($rr_type) \s # record type ($valid_name) # record data /ix) { my ($name, $ttl, $class, $type, $host) = ($1, $2, $3, $4, $5); my $dns_thing = uc $type eq 'NS' ? $dns_ns{$self} : uc $type eq 'A' ? $dns_a{$self} : $dns_cname{$self}; push @$dns_thing, $self -> _massage({name => $name, class=> $class, host => $host, ttl => $ttl}); } elsif (/^($valid_name)? \s* $ttl_cls AAAA \s ($valid_ip6) /x) { my ($name, $ttl, $class, $host) = ($1, $2, $3, $4); push @{$dns_a4{$self}}, $self -> _massage({name => $name, class=> $class, host => $host, ttl => $ttl}) } elsif (/^($valid_name)? \s* $ttl_cls MX \s (\d+) \s ($valid_name) /ix) { # host ttl class mx pri dest my ($name, $ttl, $class, $pri, $host) = ($1, $2, $3, $4, $5); push @{$dns_mx{$self}}, $self -> _massage({ name => $name, priority => $pri, host => $host, ttl => $ttl, class => $class}) } elsif (/^($valid_name) \s+ $ttl_cls SOA \s+ ($valid_name) \s+ ($valid_name) \s* \(?\s* ($rr_ttl) \s+ ($rr_ttl) \s+ ($rr_ttl) \s+ ($rr_ttl) \s+ ($rr_ttl) \s* \)? /ix) { # SOA record my $ttl = $dns_soa{$self}->{ttl} || $2 || ''; $dns_soa{$self} = $self -> _massage({ origin => $1, ttl => $ttl, primary => $4, email => $5, serial => $6, refresh => $7, retry => $8, expire => $9, minimumTTL => $10 }); } elsif (/^($valid_name)? \s* $ttl_cls PTR \s+ ($valid_name) /ix) { # PTR push @{$dns_ptr{$self}}, $self -> _massage({ name => $1, class => $3, ttl => $2, host => $4 }); } elsif (/($valid_name)? \s $ttl_cls TXT \s \"([^\"]*)\"/ix) { push @{$dns_txt{$self}}, $self -> _massage({ name => $1, ttl => $2, class => $3, text=> $4}); } elsif (/\$TTL\s+($rr_ttl)/i) { $dns_soa{$self}->{ttl} = $1; } else { carp "Unparseable line\n $_\n"; } } return 1; } sub _clean_records { my $self = shift; my ($zone) = shift; $zone =~ s<\;.*$> <>mg; # Remove comments $zone =~ s<^\s*$> <>mg; # Remove empty lines $zone =~ s<$/+> <$/>g; # Remove multiple carriage returns $zone =~ s<[ \t]+>< >g; # Collapse whitespace, turn TABs to spaces # Concatenate everything split over multiple lines i.e. elements surrounded # by parentheses can be split over multiple lines. See RFC 1035 section 5.1 $zone =~ s{(\([^\)]*?\))}{_concatenate($1)}egs; # Split into multiple records, and kick out empty lines my @records = grep !/^$/, split (m|$/|, $zone); return \@records; } sub _concatenate { my $text_in_parenth= shift; $text_in_parenth=~ s{\s*$/\s*}{ }g; return $text_in_parenth; } sub _massage { my $self = shift; my $record = shift; my $last_name = \$dns_last_name {$self}; foreach (keys %$record) { $record->{$_} = "" unless defined $record->{$_}; $record->{$_} = uc $record->{$_} if $_ eq 'class'; } return $record unless exists $record->{name}; if (length $record->{name}) { $$last_name = $record->{name}; } else { TRACE("Record has no name, using last name"); $record->{name} = $$last_name; } DUMP("Record parsed", $record); return $record; } sub TRACE {0 && print @_, $/} sub DUMP {0 && require Data::Dumper && TRACE(shift, Data::Dumper::Dumper(@_))} 1; __END__ =head1 NAME DNS::ZoneParse - Parse and manipulate DNS Zone Files. =head1 SYNOPSIS use DNS::ZoneParse; my $zonefile = DNS::ZoneParse->new("/path/to/dns/zonefile.db", $origin); # Get a reference to the MX records my $mx = $zonefile->mx; # Change the first mailserver on the list $mx->[0] = { host => 'mail.localhost.com', priority => 10, name => '@' }; # update the serial number $zonefile->new_serial(); # write the new zone file to disk open NEWZONE, ">/path/to/dns/zonefile.db" or die "error"; print NEWZONE $zonefile->output(); close NEWZONE; =head1 INSTALLATION perl Makefile.PL make make test make install Win32 users substitute "make" with "nmake" or equivalent. nmake is available at http://download.microsoft.com/download/vc15/Patch/1.52/W95/EN-US/Nmake15.exe =head1 DESCRIPTION This module will parse a Zone File and put all the Resource Records (RRs) into an anonymous hash structure. At the moment, the following types of RRs are supported: SOA, NS, MX, A, CNAME, TXT, PTR. It could be useful for maintaining DNS zones, or for transferring DNS zones to other servers. If you want to generate an XML-friendly version of your zone files, it is easy to use XML::Simple with this module once you have parsed the zonefile. DNS::ZoneParse scans the DNS zonefile - removes comments and seperates the file into its constituent records. It then parses each record and stores the records internally. See below for information on the accessor methods. =head2 METHODS =over 4 =item new This creates the DNS::ZoneParse Object and loads the zonefile Example: my $zonefile = DNS::ZoneParse->new("/path/to/zonefile.db"); You can also initialise the object with the contents of a file: my $zonefile = DNS::ZoneParse->new( \$zone_contents ); You can pass a second, optional parameter to the constructor to supply an C<$origin> if none can be found in the zone file. my $zonefile = DNS::ZoneParse->new( \$zone_contents, $origin ); =item a(), cname(), mx(), ns(), ptr() These methods return references to the resource records. For example: my $mx = $zonefile->mx; Returns the mx records in an array reference. A, CNAME, NS, MX and PTR records have the following properties: 'ttl', 'class', 'host', 'name' MX records also have a 'priority' property. =item soa() Returns a hash reference with the following properties: 'serial', 'origin', 'primary', 'refresh', 'retry', 'ttl', 'minimumTTL', 'email', 'expire' =item dump Returns a copy of the datastructute that stores all the resource records. This might be useful if you want to quickly transform the data into another format, such as XML. =item new_serial C incriments the Zone serial number. It will generate a date-based serial number. Or you can pass a positive number to add to the current serial number. Examples: $zonefile->new_serial(); # generates a new serial number based on date: # YYYYmmddHH format, incriments current serial # by 1 if the new serial is still smaller $zonefile->new_serial(50); # adds 50 to the original serial number =item output C returns the new zonefile output as a string. If you wish your output formatted differently, you can pass the output of C to your favourite templating module. =back =head2 EXAMPLES This script will print the A records in a zone file, add a new A record for the name "new" and then return the zone file. use strict; use DNS::ZoneParse; my $zonefile = DNS::ZoneParse->new("/path/to/zonefile.db"); print "Current A Records\n"; my $a_records = $zonefile->a(); foreach my $record (@$a_records) { print "$record->{name} resolves at $record->{host}\n"; } push (@$a_records, { name => 'new', class => 'IN', host => '127.0.0.1', ttl => '' }); $zonefile->new_serial(); my $newfile = $zonefile->output(); This script will convert a DNS Zonefile to an XML file using XML::Simple. use strict; use DNS::ZoneParse; use XML::Simple; my $zonefile = DNS::ZoneParse->new("/path/to/zonefile.db"); my $new_xml = XMLout($zonefile->dump, noattr => 1, suppressempty => 1, rootname => $zonefile->origin); =head1 CHANGES see F =head1 API The DNS::ZoneParse API may change in future versions. At present, the parsing is not as strict as it should be and support for C<$ORIGIN> and C<$TTL> is quite basic. It would also be nice to support the C statement. Furthermore, parsing large zonefiles with thousands of records can use lots of memory - some people have requested a callback interface. =head1 BUGS I can squash more bugs with your help. Please let me know if you spot something that doesn't work as expected. You can report bugs via the CPAN RT: L If possible, please provide a diff against F and F that demonstrates the bug(s). =head1 SEE ALSO Other modules with similar functionality: Net::DNS::ZoneParser, Net::DNS::ZoneFile, DNS::ZoneFile =head1 AUTHOR Simon Flack =head1 LICENSE DNS::ZoneParse is free software which you can redistribute and/or modify under the same terms as Perl itself. =cut