| [847] | 1 | # dns/trunk/DNSDB/ExportBIND.pm
 | 
|---|
 | 2 | # BIND data export/publication
 | 
|---|
 | 3 | # Call through DNSDB.pm's export() sub
 | 
|---|
 | 4 | ##
 | 
|---|
 | 5 | # $Id: ExportBIND.pm 854 2022-09-15 20:03:14Z kdeugau $
 | 
|---|
 | 6 | # Copyright 2022 Kris Deugau <kdeugau@deepnet.cx>
 | 
|---|
 | 7 | # 
 | 
|---|
 | 8 | #    This program is free software: you can redistribute it and/or modify
 | 
|---|
 | 9 | #    it under the terms of the GNU General Public License as published by
 | 
|---|
 | 10 | #    the Free Software Foundation, either version 3 of the License, or
 | 
|---|
 | 11 | #    (at your option) any later version. 
 | 
|---|
 | 12 | # 
 | 
|---|
 | 13 | #    This program is distributed in the hope that it will be useful,
 | 
|---|
 | 14 | #    but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
|---|
 | 15 | #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
|---|
 | 16 | #    GNU General Public License for more details.
 | 
|---|
 | 17 | # 
 | 
|---|
 | 18 | #    You should have received a copy of the GNU General Public License
 | 
|---|
 | 19 | #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 | 
|---|
 | 20 | ##
 | 
|---|
 | 21 | 
 | 
|---|
 | 22 | package DNSDB::ExportBIND;
 | 
|---|
 | 23 | 
 | 
|---|
 | 24 | use strict;
 | 
|---|
 | 25 | use warnings;
 | 
|---|
 | 26 | 
 | 
|---|
 | 27 | sub export {
 | 
|---|
 | 28 |   # expected to be a DNSDB object
 | 
|---|
 | 29 |   my $self = shift;
 | 
|---|
 | 30 |   my $dbh = $self->{dbh};
 | 
|---|
 | 31 | 
 | 
|---|
| [849] | 32 |   # to be a hash of views/locations, containing lists of zones
 | 
|---|
 | 33 |   my %viewzones;
 | 
|---|
 | 34 | 
 | 
|---|
| [848] | 35 |   # allow for future exports of subgroups of records
 | 
|---|
| [849] | 36 |   my $viewlist = $self->getLocList(curgroup => 1);
 | 
|---|
| [847] | 37 | 
 | 
|---|
| [850] | 38 |   my $soasth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
 | 
|---|
 | 39 |         "FROM records WHERE rdns_id=? AND type=6");
 | 
|---|
 | 40 |   my $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location,extract(epoch from stamp),expires,stampactive ".
 | 
|---|
 | 41 |         "FROM records WHERE rdns_id=? AND NOT type=6 ".
 | 
|---|
 | 42 |         "ORDER BY masklen(inetlazy(val)) DESC, inetlazy(val)");
 | 
|---|
 | 43 | 
 | 
|---|
| [849] | 44 |   # Fetch active zone list
 | 
|---|
 | 45 |   my $revsth = $self->{dbh}->prepare("SELECT rdns_id,revnet,status,changed,default_location FROM revzones WHERE status=1 ".
 | 
|---|
 | 46 |         "ORDER BY masklen(revnet) DESC, rdns_id");
 | 
|---|
 | 47 |   # Unflag changed zones, so we can maybe cache the export and not redo everything every time
 | 
|---|
 | 48 |   my $zonesth = $self->{dbh}->prepare("UPDATE revzones SET changed='n' WHERE rdns_id=?");
 | 
|---|
 | 49 |   $revsth->execute();
 | 
|---|
 | 50 |   while (my ($revid,$revzone,$revstat,$changed,$defloc) = $revsth->fetchrow_array) {
 | 
|---|
| [850] | 51 |     my $tmpzone = NetAddr::IP->new($revzone);
 | 
|---|
 | 52 |     my $zfile = $tmpzone->network->addr."-".$tmpzone->masklen;
 | 
|---|
 | 53 | #    my $cachefile = "$self->{exportcache}/$zfile";
 | 
|---|
 | 54 | #    my $tmpcache = "$self->{exportcache}/tmp.$zfile.$$";
 | 
|---|
 | 55 |     my $tmpcache = "tmp.$zfile.$$";      # safety net.  don't overwrite a previous known-good file
 | 
|---|
 | 56 | 
 | 
|---|
 | 57 | ##fixme:  convert logical revzone into .arpa name?  maybe take a slice of showrev_arpa?
 | 
|---|
 | 58 | ##fixme:  need to bodge logical non-octet-boundary revzones into octet-boundary revzones
 | 
|---|
 | 59 | ##fixme:  do we do cache files?  views balloon the file count stupidly
 | 
|---|
 | 60 | 
 | 
|---|
 | 61 | 
 | 
|---|
| [851] | 62 |     eval {
 | 
|---|
 | 63 | 
 | 
|---|
 | 64 |       # write fresh records if:
 | 
|---|
 | 65 |       #  - we are not using the cache
 | 
|---|
 | 66 |       #  - force_refresh is set
 | 
|---|
 | 67 |       #  - the zone has changed
 | 
|---|
 | 68 |       #  - the cache file does not exist
 | 
|---|
 | 69 |       #  - the cache file is empty
 | 
|---|
 | 70 |       if (!$self->{usecache} || $self->{force_refresh} || $changed || !-e $cachefile || -z $cachefile) {
 | 
|---|
 | 71 |         if ($self->{usecache}) {
 | 
|---|
 | 72 |           open ZONECACHE, ">$tmpcache" or die "Error creating temporary file $tmpcache: $!\n";
 | 
|---|
 | 73 |           $zonefilehandle = *ZONECACHE;
 | 
|---|
 | 74 |         }
 | 
|---|
 | 75 | 
 | 
|---|
| [853] | 76 |         # fetch a list of views/locations present in the zone.  we need to publish a file for each one.
 | 
|---|
 | 77 |         # in the event that no locations are present (~~ $viewlist is empty), /%view collapses to nothing in the zone path
 | 
|---|
 | 78 |         my (@loclist) = $self->{dbh}->selectrow_array("SELECT DISTINCT location FROM records WHERE rdns_id = ?", undef, $revid);
 | 
|---|
 | 79 |         push @loclist, $defloc unless grep /$defloc/, @loclist;
 | 
|---|
 | 80 |         my $zonepath = $self->{bind_export_zone_path};
 | 
|---|
 | 81 |         my %zonefiles;  # to be a list of file handles.
 | 
|---|
 | 82 | ##fixme:  convert logical revzone into .arpa name
 | 
|---|
 | 83 |         foreach my $loc (@loclist) {
 | 
|---|
 | 84 |           my $zfilepath = $zonepath;
 | 
|---|
 | 85 |           $zfilepath =~ s/\%view/$loc/;
 | 
|---|
 | 86 |           $zfilepath =~ s/\%zone/$revzone/;
 | 
|---|
 | 87 |           # Just In Case(TM)
 | 
|---|
 | 88 |           $zfilepath =~ s,[^\w./-],_,g;
 | 
|---|
 | 89 |           #open $zonefiles{$loc}, ">", $zfilepath;
 | 
|---|
 | 90 | print "open zonefile for '$loc', '$zfilepath'\n";
 | 
|---|
 | 91 |         }
 | 
|---|
 | 92 | 
 | 
|---|
| [851] | 93 |         # need to fetch this separately since the rest of the records all (should) have real IPs in val
 | 
|---|
 | 94 |         $soasth->execute($revid);
 | 
|---|
 | 95 |         my (@zsoa) = $soasth->fetchrow_array();
 | 
|---|
| [854] | 96 |         printrec_bind($zonefilehandle, $zsoa[7], 'y',\%recflags,$revzone,
 | 
|---|
| [851] | 97 |           $zsoa[0],$zsoa[1],$zsoa[2],$zsoa[3],$zsoa[4],$zsoa[5],$zsoa[6],$zsoa[8],'');
 | 
|---|
 | 98 | 
 | 
|---|
 | 99 |         $recsth->execute($revid);
 | 
|---|
 | 100 |         my $fullzone = _ZONE($tmpzone, 'ZONE', 'r', '.').($tmpzone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
 | 
|---|
 | 101 | 
 | 
|---|
 | 102 |         while (my ($host, $type, $val, $dist, $weight, $port, $ttl, $recid, $loc, $stamp, $expires, $stampactive)
 | 
|---|
 | 103 |                 = $recsth->fetchrow_array) {
 | 
|---|
 | 104 |           next if $recflags{$recid};
 | 
|---|
 | 105 | 
 | 
|---|
 | 106 |           # Check for out-of-zone data
 | 
|---|
 | 107 |           if ($val =~ /\.arpa$/) {
 | 
|---|
 | 108 |             # val is non-IP
 | 
|---|
 | 109 |             if ($val !~ /$fullzone$/) {
 | 
|---|
 | 110 |               warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $tmpzone)\n";
 | 
|---|
 | 111 |               next;
 | 
|---|
 | 112 |             }
 | 
|---|
 | 113 |           } else {
 | 
|---|
 | 114 |             my $ipval = new NetAddr::IP $val;
 | 
|---|
 | 115 |             if (!$tmpzone->contains($ipval)) {
 | 
|---|
 | 116 |               warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $tmpzone)\n";
 | 
|---|
 | 117 |               next;
 | 
|---|
 | 118 |             }
 | 
|---|
 | 119 |           } # is $val a raw .arpa name?
 | 
|---|
 | 120 | 
 | 
|---|
 | 121 |           # Spaces are evil.
 | 
|---|
 | 122 |           $val =~ s/^\s+//;
 | 
|---|
 | 123 |           $val =~ s/\s+$//;
 | 
|---|
 | 124 |           if ($typemap{$type} ne 'TXT') {
 | 
|---|
 | 125 |             # Leading or trailng spaces could be legit in TXT records.
 | 
|---|
 | 126 |             $host =~ s/^\s+//;
 | 
|---|
 | 127 |             $host =~ s/\s+$//;
 | 
|---|
 | 128 |           }
 | 
|---|
 | 129 | 
 | 
|---|
| [854] | 130 |           printrec_bind($zonefilehandle, $recid, 'y', \%recflags, $revzone,
 | 
|---|
| [851] | 131 |             $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
 | 
|---|
 | 132 | 
 | 
|---|
 | 133 |           $recflags{$recid} = 1;
 | 
|---|
 | 134 | 
 | 
|---|
 | 135 |         } # while ($recsth)
 | 
|---|
 | 136 | 
 | 
|---|
 | 137 |         if ($self->{usecache}) {
 | 
|---|
 | 138 |           close ZONECACHE; # force the file to be written
 | 
|---|
 | 139 |           # catch obvious write errors that leave an empty temp file
 | 
|---|
 | 140 |           if (-s $tmpcache) {
 | 
|---|
 | 141 |             rename $tmpcache, $cachefile
 | 
|---|
 | 142 |               or die "Error overwriting cache file $cachefile with temporary file: $!\n";
 | 
|---|
 | 143 |           }
 | 
|---|
 | 144 |         }
 | 
|---|
 | 145 | 
 | 
|---|
 | 146 |       } # if $changed or cache filesize is 0
 | 
|---|
 | 147 | 
 | 
|---|
 | 148 |     };
 | 
|---|
 | 149 |     if ($@) {
 | 
|---|
 | 150 |       die "error writing ".($self->{usecache} ? 'new data for ' : '')."$revzone: $@\n";
 | 
|---|
 | 151 |       # error!  something borked, and we should be able to fall back on the old cache file
 | 
|---|
 | 152 |       # report the error, somehow.
 | 
|---|
 | 153 |     } else {
 | 
|---|
 | 154 |       # mark zone as unmodified.  Only do this if no errors, that way
 | 
|---|
 | 155 |       # export failures should recover a little more automatically.
 | 
|---|
 | 156 |       $zonesth->execute($revid);
 | 
|---|
 | 157 |     }
 | 
|---|
 | 158 | 
 | 
|---|
 | 159 | #    if ($self->{usecache}) {
 | 
|---|
 | 160 | #      # We've already made as sure as we can that a cached zone file is "good",
 | 
|---|
 | 161 | #      # although possibly stale/obsolete due to errors creating a new one.
 | 
|---|
 | 162 | #      eval {
 | 
|---|
 | 163 | #        open CACHE, "<$cachefile" or die $!;
 | 
|---|
 | 164 | #        print $datafile $_ or die "error copying cached $revzone to master file: $!" while <CACHE>;
 | 
|---|
 | 165 | #        close CACHE;
 | 
|---|
 | 166 | #      };
 | 
|---|
 | 167 | #      die $@ if $@;
 | 
|---|
 | 168 | #    }
 | 
|---|
 | 169 | 
 | 
|---|
 | 170 | 
 | 
|---|
 | 171 | 
 | 
|---|
| [849] | 172 |   } # revsth->fetch
 | 
|---|
 | 173 | 
 | 
|---|
 | 174 | 
 | 
|---|
 | 175 |   # Write the view configuration last, because otherwise we have to be horribly inefficient
 | 
|---|
 | 176 |   # at figuring out which zones are visible/present in which views
 | 
|---|
| [848] | 177 |   if ($viewlist) {
 | 
|---|
 | 178 |     foreach my $view (@{$viewlist}) {
 | 
|---|
 | 179 | #print Dumper($view);
 | 
|---|
 | 180 | #      print BINDCONF "view $view->{location} {\n";
 | 
|---|
 | 181 |       print "view $view->{location} {\n";
 | 
|---|
 | 182 |       # could also use an acl { ... }; statement, then match-clients { aclname; };, but that gets hairy
 | 
|---|
 | 183 |       # note that some semantics of data visibility need to be handled by the record export, since it's
 | 
|---|
 | 184 |       # not 100% clear if the semantics of a tinydns view with an empty IP list (matches anyone) are the
 | 
|---|
 | 185 |       # same as a BIND view with match-clients { any; };
 | 
|---|
 | 186 |       if ($view->{iplist}) {
 | 
|---|
 | 187 | #         print BINDCONF "  match-clients { ".join("; ", $view->iplist)."; };\n";
 | 
|---|
 | 188 |          print "  match-clients { ".join("; ", split(/[\s,]+/, $view->{iplist}))."; };\n";
 | 
|---|
 | 189 |       } else {
 | 
|---|
| [849] | 190 | #         print BINDCONF "  match-clients { any; };\n";
 | 
|---|
 | 191 |          print "  match-clients { any; };\n";
 | 
|---|
| [848] | 192 |       }
 | 
|---|
| [849] | 193 |       foreach my $zone (@{$viewzones{$view->{location}}}) {
 | 
|---|
 | 194 | ##fixme:  notify settings, maybe per-zone?
 | 
|---|
 | 195 |         print qq(  zone "$zone" IN {\n\ttype master;\n\tnotify no;\n\tfile "db.$zone";\n  };\n);
 | 
|---|
 | 196 |       }
 | 
|---|
| [848] | 197 |       print "};\n\n";
 | 
|---|
 | 198 |     } # foreach @$viewlist
 | 
|---|
 | 199 |   } # if $viewlist
 | 
|---|
 | 200 | 
 | 
|---|
 | 201 | } # export()
 | 
|---|
 | 202 | 
 | 
|---|
| [854] | 203 | 
 | 
|---|
 | 204 | # Print individual records in BIND format
 | 
|---|
 | 205 | sub printrec_bind {
 | 
|---|
 | 206 |   my ($zonefiles, $recid, $revrec, $loclist, $zone, $host, $type, $val, $distance, $weight, $port, $ttl,
 | 
|---|
 | 207 |         $recloc, $stamp, $expires, $stampactive) = @_;
 | 
|---|
 | 208 | 
 | 
|---|
 | 209 |   # Just In Case something is lingering in the DB
 | 
|---|
 | 210 |   $recloc = '' if !$loc;
 | 
|---|
 | 211 | 
 | 
|---|
 | 212 |   ## And now to the records!
 | 
|---|
 | 213 | 
 | 
|---|
 | 214 |   if ($typemap{$type} eq 'SOA') {
 | 
|---|
 | 215 |     # host contains pri-ns:responsible
 | 
|---|
 | 216 |     # val is abused to contain refresh:retry:expire:minttl
 | 
|---|
 | 217 |     # let's be explicit about abusing $host and $val
 | 
|---|
 | 218 |     my ($email, $primary) = (split /:/, $host)[0,1];
 | 
|---|
 | 219 |     my ($refresh, $retry, $expire, $min_ttl) = (split /:/, $val)[0,1,2,3];
 | 
|---|
 | 220 |     my $serial = 0;  # fail less horribly than leaving it empty?
 | 
|---|
 | 221 |     if ($revrec eq 'y') {
 | 
|---|
 | 222 | ##fixme:  have to publish SOA records for each v4 /24 in sub-/16, and each /16 in sub-/8
 | 
|---|
 | 223 | # what about v6?
 | 
|---|
 | 224 | # -> only need SOA for local chunks offset from reverse delegation boundaries, so v6 is fine
 | 
|---|
 | 225 | # anyone who says they need sub-nibble v6 delegations, at this time, needs their head examined.
 | 
|---|
 | 226 | ##fixme?:  alternate SOA serial schemes?
 | 
|---|
 | 227 |       ($serial) = $self->{dbh}->selectrow_array("SELECT zserial FROM revzones WHERE revnet=?", undef, $zone);
 | 
|---|
 | 228 |       $zone = NetAddr::IP->new($zone);
 | 
|---|
 | 229 |       # handle split-n-multiply SOA for off-octet (8 < mask < 16) or (16 < mask < 24) v4 zones
 | 
|---|
 | 230 |       if (!$zone->{isv6} && ($zone->masklen < 24) && ($zone->masklen % 8 != 0)) {
 | 
|---|
 | 231 |         foreach my $szone ($zone->split($zone->masklen + (8 - $zone->masklen % 8))) {
 | 
|---|
 | 232 |           $szone = _ZONE($szone, 'ZONE.in-addr.arpa', 'r', '.');
 | 
|---|
 | 233 |           print $datafile "Z$szone:$primary:$email:$serial:$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n"
 | 
|---|
 | 234 |             or die $!;
 | 
|---|
 | 235 |         }
 | 
|---|
 | 236 |         return; # skips "default" bits just below
 | 
|---|
 | 237 |       }
 | 
|---|
 | 238 |       $zone = _ZONE($zone, 'ZONE', 'r', '.').($zone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
 | 
|---|
 | 239 |     } else {
 | 
|---|
 | 240 |       # just snarfing the right SOA serial for the zone type
 | 
|---|
 | 241 | ##fixme?:  alternate SOA serial schemes?
 | 
|---|
 | 242 |       ($serial) = $self->{dbh}->selectrow_array("SELECT zserial FROM domains WHERE domain=?", undef, $zone);
 | 
|---|
 | 243 |     } # revrec <> 'y'
 | 
|---|
 | 244 |     # suppress a "uninitialized value" warning.  should be impossible but...
 | 
|---|
 | 245 |     # abuse hours as the last digit pair of the serial for simplicity
 | 
|---|
 | 246 |     $serial = strftime("%Y%m%d%H", localtime()) if !$serial;
 | 
|---|
 | 247 |     print $zonefiles{$recloc} "Z$zone:$primary:$email:$serial:$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n"
 | 
|---|
 | 248 |       or die $!;
 | 
|---|
 | 249 |   } # SOA
 | 
|---|
 | 250 | 
 | 
|---|
 | 251 | } # printrec_bind()
 | 
|---|
 | 252 | 
 | 
|---|
 | 253 | 
 | 
|---|
| [847] | 254 | 1;
 | 
|---|