# dns/trunk/DNSDB/ExportBIND.pm
# BIND data export/publication
# Call through DNSDB.pm's export() sub
##
# $Id: ExportBIND.pm 896 2025-06-27 18:13:20Z kdeugau $
# Copyright 2022,2023 Kris Deugau <kdeugau@deepnet.cx>
# 
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version. 
# 
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
# 
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
##

package DNSDB::ExportBIND;

use strict;
use warnings;

use DNSDB;

sub export {
  # expected to be a DNSDB object
  my $dnsdb = shift;

  # to be a hash of views/locations, containing lists of zones
  my %viewzones;

  # allow for future exports of subgroups of records
  my $viewlist = $dnsdb->getLocList(curgroup => 1, full => 1);


## export reverse zones

  my $soasth = $dnsdb->{dbh}->prepare("SELECT host,val,ttl,record_id,location FROM records WHERE rdns_id=? AND type=6");
  # record order matters for reverse zones because we need to override larger templates with smaller ones.
  my $recsth = $dnsdb->{dbh}->prepare(
        "SELECT host,type,val,distance,weight,port,ttl,record_id,location,extract(epoch from stamp),expires,stampactive ".
        "FROM records WHERE rdns_id=? AND NOT type=6 ".
        "ORDER BY masklen(inetlazy(val)) DESC, inetlazy(val), record_id");

  # Fetch active zone list
  my $revsth = $dnsdb->{dbh}->prepare("SELECT rdns_id,revnet,status,changed,default_location FROM revzones WHERE status=1 ".
        "ORDER BY masklen(revnet),revnet DESC, rdns_id");
  # Unflag changed zones, so we can maybe cache the export and not redo everything every time
  my $zonesth = $dnsdb->{dbh}->prepare("UPDATE revzones SET changed='n' WHERE rdns_id=?");

  my %recflags;  # need this to be independent for forward vs reverse zones, as they're not merged

  $revsth->execute();
  while (my ($revid,$revzone,$revstat,$changed,$defloc) = $revsth->fetchrow_array) {
    my $cidr = NetAddr::IP->new($revzone);

##fixme:  convert logical revzone into .arpa name?  maybe take a slice of showrev_arpa?
##fixme:  need to bodge logical non-octet-boundary revzones into octet-boundary revzones
##fixme:  do we do cache files?  views balloon the file count stupidly
## foreach $octetzone $cidr->split(octet-boundary)
##   loclist = SELECT DISTINCT location FROM records WHERE rdns_id = $zid AND inetlazy(val) <<= $octetzone

#printf "non-octet? %s, %i\n", $cidr->masklen, $cidr->masklen % 8;

    # fetch a list of views/locations present in the zone.  we need to publish a file for each one.
    # in the event that no locations are present (~~ $viewlist is empty), /%view collapses to nothing in the zone path
    my $tmplocs = $dnsdb->{dbh}->selectall_arrayref("SELECT DISTINCT location FROM records WHERE rdns_id = ?", undef, $revid);
    my @loclist;
    foreach my $tloc (@{$tmplocs}) {
      push @loclist, ($tloc->[0] eq '' ? 'common' : $tloc->[0]);
    }

    my %zonefiles;	# zone file handles

    eval {

##fixme:  use tmpfile module for more secure temp files?  want the zone name at least in it anyway, not sure that works...
      my $arpazone = DNSDB::_ZONE($cidr, 'ZONE', 'r', '.').($cidr->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
      my $zfile = $cidr->network->addr."-".$cidr->masklen;
#      my $cachefile = "$dnsdb->{exportcache}/$zfile";
#      my $tmpcache = "$dnsdb->{exportcache}/tmp.$zfile.$$";
      my $tmpcache = "tmp.$zfile.$$";      # safety net.  don't overwrite a previous known-good file

##fixme:  need to open separate zone files for aggregated metazones eg /22 or /14
      foreach my $loc (@loclist) {
        my $zfilepath = $dnsdb->{bind_export_reverse_zone_path};
        $zfilepath =~ s/\%view/$loc/;
        $zfilepath =~ s/\%zone/$zfile/;
        $zfilepath =~ s/\%arpazone/$arpazone/;

        # Just In Case(TM)
        $zfilepath =~ s,[^\w./-],_,g;

        # safety check, may need tweaking for race conditions
        my $zpathbase = $zfilepath;
        $zpathbase =~ s{/[^/]+$}{};
        if (!-e $zpathbase) {
          mkdir $zpathbase;
        } else {
          die "$zpathbase is not a directory\n" unless -d $zpathbase;
        }

        # write fresh records if:
        #  - the zone contains records which expire in less than 10 minutes or became valid less than 10 minutes ago
        # note, no need to multi-bump the serial
        if ( ($dnsdb->{dbh}->selectrow_array("SELECT COUNT(*) FROM records WHERE rdns_id = ? AND ".
                "stampactive='t' AND @(extract(epoch from stamp-now())) < 600", undef, $revid))[0] ) {
          $changed = 1;
          $dnsdb->_updateserial(rdns_id => $revid);
        }
#  - we are not using the cache
# if ($dnsdb->{usecache}
        #  - force_refresh is set
        #  - the zone has changed
        #  - the zone file does not exist
        #  - the zone file is empty
        elsif ($dnsdb->{force_refresh} || $changed || !-e $zfilepath || -z $zfilepath) {
#          if ($dnsdb->{usecache}) {
#            open ZONECACHE, ">$tmpcache" or die "Error creating temporary file $tmpcache: $!\n";
#            $zonefilehandle = *ZONECACHE;
#          }
          open $zonefiles{$loc}, ">", $zfilepath or die "Error creating temporary file $zfilepath: $!\n";

          # Header for human convenience
##fixme?  vary arpazone/cidr in header and error message per showrev_arpa, or possibly
# new dedicated setting, or possibly interact with with bind_export_fqdn?
          printf {$zonefiles{$loc}} "; %s in view %s exported %s\n", $arpazone, $loc, scalar(localtime)
            or die "Error writing header [$arpazone, '$loc']: $!\n";

          # Fetch the SOA separately as we publish it separately for each location with this loop,
          # mainly because we want it first in the zone file
          $soasth->execute($revid);
          my ($soa_host, $soa_val, $soa_ttl, $soa_id, $soa_loc) = $soasth->fetchrow_array;

##fixme: do we even need @loclist passed in?
          printrec_bind($dnsdb, \%zonefiles, \@loclist, $soa_id, 'y', \%recflags, $cidr,
            $soa_host, 6, $soa_val, 0, 0, 0, $soa_ttl, $loc, '');

        } # if force_refresh etc

        # tag the zonefile for publication in the view
        push @{$viewzones{$loc}}, $arpazone;

      } # foreach @loclist

      # now the meat of the records
      $recsth->execute($revid);
      while (my ($host, $type, $val, $dist, $weight, $port, $ttl, $recid, $loc, $stamp, $expires, $stampactive)
		= $recsth->fetchrow_array) {
        next if $recflags{$recid};

        # Spaces are evil.
        $val =~ s/^\s+//;
        $val =~ s/\s+$//;
        if ($typemap{$type} ne 'TXT') {
          # Leading or trailng spaces could be legit in TXT records.
          $host =~ s/^\s+//;
          $host =~ s/\s+$//;
        }

        # Check for out-of-zone data
        if ($val =~ /\.arpa$/) {
          # val is non-IP
          if ($val !~ /$arpazone$/) {
            warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $cidr)\n";
            next;
          }
        } else {
          my $ipval = new NetAddr::IP $val;
          if (!$cidr->contains($ipval)) {
            warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $cidr)\n";
            next;
          }
        } # is $val a raw .arpa name?

        printrec_bind($dnsdb, \%zonefiles, \@loclist, $recid, 'y', \%recflags, $revzone,
		$host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);

        $recflags{$recid} = 1;

      } # while ($recsth)

#      if ($dnsdb->{usecache}) {
#        close ZONECACHE; # force the file to be written
#        # catch obvious write errors that leave an empty temp file
#        if (-s $tmpcache) {
#          rename $tmpcache, $cachefile
#            or die "Error overwriting cache file $cachefile with temporary file: $!\n";
#        }
#      }

    };
    if ($@) {
      die "error writing ".($dnsdb->{usecache} ? 'new data for ' : '')."$revzone: $@\n";
      # error!  something borked, and we should be able to fall back on the old cache file
      # report the error, somehow.
    } else {
      # mark zone as unmodified.  Only do this if no errors, that way
      # export failures should recover a little more automatically.
      $zonesth->execute($revid);
    }

#    if ($dnsdb->{usecache}) {
#      # We've already made as sure as we can that a cached zone file is "good",
#      # although possibly stale/obsolete due to errors creating a new one.
#      eval {
#        open CACHE, "<$cachefile" or die $!;
#        print $datafile $_ or die "error copying cached $revzone to master file: $!" while <CACHE>;
#        close CACHE;
#      };
#      die $@ if $@;
#    }

  } # revsth->fetch



## and now the domains

  $soasth = $dnsdb->{dbh}->prepare("SELECT host,val,ttl,record_id,location FROM records WHERE domain_id=? AND type=6");
  # record order needs to match reverse zone ordering for IP values, or A+PTR
  # template records don't cascade/expand correctly to match the reverse zones.
  # order by record_id at least makes the zone consistent from export to export,
  # otherwise the records could (theoretically) be returned in any old order by
  # the DB engine
  # ordering by nominal parent-child label hierarchy (as actually found live
  # in some AXFRed zone files) would take a lot of chewing on data
  $recsth = $dnsdb->{dbh}->prepare(
        "SELECT host,type,val,distance,weight,port,ttl,record_id,location,extract(epoch from stamp),expires,stampactive ".
        "FROM records WHERE domain_id=? AND NOT type=6 ".
        "ORDER BY masklen(inetlazy(val)) DESC, inetlazy(val), record_id");
#      "FROM records WHERE domain_id=? AND type < 65280");     # Just exclude all types relating to rDNS

  # Fetch active zone list
  my $domsth = $dnsdb->{dbh}->prepare("SELECT domain_id,domain,status,changed,default_location FROM domains WHERE status=1 ".
        "ORDER BY domain_id");
  # Unflag changed zones, so we can maybe cache the export and not redo everything every time
  $zonesth = $dnsdb->{dbh}->prepare("UPDATE domains SET changed='n' WHERE domain_id=?");

  # Clear %reclfags, since we explicitly want to NOT carry "I've published this
  # record" over from rDNS, since we have to regenerate any templates for forward
  # zones.  downside: small mismatches due to overridden entries.  not sure how
  # best to manage that.  :/
##fixme:  selectively delete entries to allow template_always_publish_a to flag
# whether extra A records get published or not.  should default to not (nb, of
# *course* that's the complex case) to match original tinydns template masking behaviour
#  %recflags = ();

  $domsth->execute();
  while (my ($domid,$domain,$domstat,$changed) = $domsth->fetchrow_array) {

    # fetch a list of views/locations present in the zone.  we need to publish a file for each one.
    # in the event that no locations are present (~~ $viewlist is empty), /%view collapses to nothing in the zone path
    my $tmplocs = $dnsdb->{dbh}->selectall_arrayref("SELECT DISTINCT location FROM records WHERE domain_id = ?", undef, $domid);
    my @loclist;
    foreach my $tloc (@{$tmplocs}) {
      push @loclist, ($tloc->[0] eq '' ? 'common' : $tloc->[0]);
    }

    my %zonefiles;  # zone file handles

    eval {

##fixme:  use tmpfile module for more secure temp files?  want the zone name at least in it anyway, not sure that works...
      my $zfile = $domain;  # can probably drop this intermediate
      my $tmpcache = "tmp.$zfile.$$";	# safety net.  don't overwrite a previous known-good file

      foreach my $loc (@loclist) {
        my $zfilepath = $dnsdb->{bind_export_zone_path};
        $zfilepath =~ s/\%view/$loc/;
        $zfilepath =~ s/\%zone/$zfile/;

        # Just In Case(TM)
        $zfilepath =~ s,[^\w./-],_,g;

        # safety check, may need tweaking for race conditions
        my $zpathbase = $zfilepath;
        $zpathbase =~ s{/[^/]+$}{};
        if (!-e $zpathbase) {
          mkdir $zpathbase;
        } else {
          die "$zpathbase is not a directory\n" unless -d $zpathbase;
        }

        # write fresh records if:
        #  - the zone contains ALIAS pseudorecords, which need to cascade changes from the upstream CNAME farm at every opportunity
        if ( ($dnsdb->{dbh}->selectrow_array("SELECT count(*) FROM records WHERE domain_id = ? AND type=65300", undef, $domid))[0] ) {
          $changed = 1;  # abuse this flag for zones with ALIAS records
          # also update the serial number, because while it shouldn't matter purely for serving
          # records, it WILL matter if AXFR becomes part of the publishing infrastructure
          $dnsdb->_updateserial(domain_id => $domid);
        }
        #  - the zone contains records which expire in less than 10 minutes or became valid less than 10 minutes ago
        # note, no need to multi-bump the serial
        elsif ( ($dnsdb->{dbh}->selectrow_array("SELECT COUNT(*) FROM records WHERE domain_id = ? AND ".
                "stampactive='t' AND @(extract(epoch from stamp-now())) < 600", undef, $domid))[0] ) {
          $changed = 1;
          $dnsdb->_updateserial(domain_id => $domid);
        }
#  - we are not using the cache
# if ($dnsdb->{usecache}
        #  - force_refresh is set
        #  - the zone has changed
        #  - the zone file does not exist
        #  - the zone file is empty
#        if (!$self->{usecache} || $self->{force_refresh} || $changed || !-e $cachefile || -z $cachefile) {
        if ($dnsdb->{force_refresh} || $changed || !-e $zfilepath || -z $zfilepath) {
#          if ($self->{usecache}) {
#            open ZONECACHE, ">$tmpcache" or die "Error creating temporary file $tmpcache: $!\n";
#            $zonefilehandle = *ZONECACHE;
#          }
          open $zonefiles{$loc}, ">", $zfilepath or die "Error creating temporary file $zfilepath: $!\n";

          # Header for human convenience
          printf {$zonefiles{$loc}} "; %s in view %s exported %s\n", $domain, $loc, scalar(localtime)
		or die "Error writing header [$domain, '$loc']: $!\n";

          # Fetch the SOA separately as we publish it separately for each location with this loop,
          # mainly because we want it first in the zone file
          $soasth->execute($domid);
          my ($soa_host, $soa_val, $soa_ttl, $soa_id, $soa_loc) = $soasth->fetchrow_array;

##fixme: do we even need @loclist passed in?
          printrec_bind($dnsdb, \%zonefiles, \@loclist, $soa_id, 'n', \%recflags, $domain,
            $soa_host, 6, $soa_val, 0, 0, 0, $soa_ttl, $loc, '');

        } # if force_refresh etc

        # tag the zonefile for publication in the view
        push @{$viewzones{$loc}}, $domain;

      } # foreach @loclist

      # now the meat of the records
      $recsth->execute($domid);
      while (my ($host,$type,$val,$dist,$weight,$port,$ttl,$recid,$loc,$stamp,$expires,$stampactive) = $recsth->fetchrow_array) {
##work  need more subtle check - $recflags{$val} eq 'ptr' maybe?
        next if $recflags{$recid};
#next if $recflags{$val} && $type == 65280;# && !$dnsdb->{template_always_publish_a};

        # Spaces are evil.
        $host =~ s/^\s+//;
        $host =~ s/\s+$//;
        if ($typemap{$type} ne 'TXT') {
          # Leading or trailng spaces could be legit in TXT records.
          $val =~ s/^\s+//;
          $val =~ s/\s+$//;
        }

        # Check for out-of-zone data
        $host = $domain if $host eq '@';
        if ($domain !~ /\.rpz$/ && $host !~ /$domain$/i) {
          warn "Not exporting out-of-zone record $host $type $val, $ttl (zone $domain)\n";
          next;
        }

        printrec_bind($dnsdb, \%zonefiles, \@loclist, $recid, 'n', \%recflags, $domain,
          $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);

        $recflags{$recid} = 1;

      } # while ($recsth)

      # retrieve NS records for subdomains.  not strictly required in current production
      # context but may matter sometime down the road
      my $subnssth = $dnsdb->{dbh}->prepare("SELECT r.host,r.val,r.ttl,r.record_id,r.loc,r.stamp,r.expires,r.stampactive ".
        "FROM records r ".
        "JOIN domains d ON r.domain_id=d.domain_id ".
        "WHERE r.type=2 AND d.domain LIKE ?");
      $subnssth->execute('%.'.$domain);
      while (my ($host,$val,$ttl,$recid,$loc,$stamp,$expires,$stampactive) = $subnssth->fetchrow_array) {
        printrec_bind($dnsdb, \%zonefiles, \@loclist, $recid, 'n', \%recflags, $domain,
          $host, 2, $val, '', '', '', $ttl, $loc, $stamp, $expires, $stampactive);
      } # subdomain-ns-recsth


#        if ($self->{usecache}) {
#          close ZONECACHE; # force the file to be written
#          # catch obvious write errors that leave an empty temp file
#          if (-s $tmpcache) {
#            rename $tmpcache, $cachefile
#              or die "Error overwriting cache file $cachefile with temporary file: $!\n";
#          }
#        }

#      } # if $changed or cache filesize is 0

    };
    if ($@) {
      die "error writing ".($dnsdb->{usecache} ? 'new data for ' : '')."$domain: $@\n";
      # error!  something borked, and we should be able to fall back on the old cache file
      # report the error, somehow.
    } else {
      # mark zone as unmodified.  Only do this if no errors, that way
      # export failures should recover a little more automatically.
      $zonesth->execute($domid);
    }

#    if ($dnsdb->{usecache}) {
#      # We've already made as sure as we can that a cached zone file is "good",
#      # although possibly stale/obsolete due to errors creating a new one.
#      eval {
#        open CACHE, "<$cachefile" or die $!;
#        print $datafile $_ or die "error copying cached $revzone to master file: $!" while <CACHE>;
#        close CACHE;
#      };
#      die $@ if $@;
#    }

  } # domsth->fetch



  # Write the view configuration last, because otherwise we have to be horribly inefficient
  # at figuring out which zones are visible/present in which views
  if ($viewlist) {
    my $tmpconf = "$dnsdb->{bind_zone_conf}.$$"; ##fixme:  split filename for prefixing
    open BINDCONF, ">", $tmpconf;

    foreach my $view (@{$viewlist}, { location => 'common', iplist => '' }) {
#print Dumper($view);
      print BINDCONF "view $view->{location} {\n";
#      print "view $view->{location} {\n";
      # could also use an acl { ... }; statement, then match-clients { aclname; };, but that gets hairy
      # note that some semantics of data visibility need to be handled by the record export, since it's
      # not 100% clear if the semantics of a tinydns view with an empty IP list (matches anyone) are the
      # same as a BIND view with match-clients { any; };
      if ($view->{iplist}) {
         print BINDCONF "  match-clients { ".join("; ", $view->{iplist})."; };\n";
#         print "  match-clients { ".join("; ", split(/[\s,]+/, $view->{iplist}))."; };\n";
      } else {
         print BINDCONF "  match-clients { any; };\n";
#         print "  match-clients { any; };\n";
      }
      foreach my $zone (@{$viewzones{$view->{location}}}) {
##fixme:  notify settings, maybe per-zone?
        print BINDCONF qq(  zone "$zone" IN {\n\ttype master;\n\tnotify no;\n\tfile "db.$zone";\n  };\n);
#        print qq(  zone "$zone" IN {\n\ttype master;\n\tnotify no;\n\tfile "db.$zone";\n  };\n);
      }
      print BINDCONF "};\n\n";
#      print "};\n\n";
    } # foreach @$viewlist
    rename $tmpconf, $dnsdb->{bind_zone_conf};
  } # if $viewlist

} # export()


# Print individual records in BIND format
sub printrec_bind {
  my $dnsdb = shift;

#  my ($zonefiles, $recid, $revrec, $loclist, $zone, $host, $type, $val, $distance, $weight, $port, $ttl,
  my ($zonefiles, $loclist, $recid, $revrec, $recflags, $zone, $host, $type, $val, $distance, $weight, $port, $ttl,
	$loc, $stamp, $expires, $stampactive) = @_;

  # Just In Case something is lingering in the DB
  $loc = '' if !$loc;

  ## Records that are valid only before or after a set time
  # Note that BIND-style zone files fundamentally don't support this directly
  # unlike tinydns, as it's not a native feature/function.  Dropping TTLs to
  # 15s or so is the best we can do for expiry.  "Valid-after" is only as good
  # as the export cron job timing.
  if ($stampactive) {
    my $now = time();
    if ($expires) {
      # record expires at $stamp;  decide if we need to keep the TTL on file
      # or set it to 15 so the record falls out of caches quickly sometime
      # around the nominal expiry time.

      # For weirdos who set huge TTLs, cap the TTL at one day.  30+ years ago
      # long TTLs made sense when even DNS had a measurable cost in small
      # networks;  today DNS is below the noise floor in all but the largest
      # networks and systems.
      my $ahead = (86400 < $ttl*2 ? 86400 : $ttl*2);
      if (($now + $ahead) < $stamp) {
        # more than 2x TTL OR more than one day (whichever is less) from expiry time;  publish normal record
      } elsif ($now > $stamp) {
        # record has expired;  return early as we don't need to publish anything
        return;
      } else {
        # less than 2x TTL from expiry time, set a short TTL
        $ttl = $dnsdb->{bind_export_autoexpire_ttl};
      }
    } else {
      # record is "active after";  return unless it's now after the nominal validity timestamp.
      return unless $now >= $stamp;
    }
  } # if $stampactive

  ## And now to the records!

  if ($typemap{$type} eq 'SOA') {
    # host contains pri-ns:responsible
    # val is abused to contain refresh:retry:expire:minttl
    # let's be explicit about abusing $host and $val
    my ($email, $primary) = (split /:/, $host)[0,1];
    my ($refresh, $retry, $expire, $min_ttl) = (split /:/, $val)[0,1,2,3];
    my $serial = 0;  # fail less horribly than leaving it empty?
    # just snarfing the right SOA serial for the zone type
    if ($revrec eq 'y') {
      ($serial) = $dnsdb->{dbh}->selectrow_array("SELECT zserial FROM revzones WHERE revnet=?", undef, $zone);
    } else {
      ($serial) = $dnsdb->{dbh}->selectrow_array("SELECT zserial FROM domains WHERE domain=?", undef, $zone);
    } # revrec <> 'y'
    # suppress a "uninitialized value" warning.  should be impossible but...
    # abuse hours as the last digit pair of the serial for simplicity
##fixme?:  alternate SOA serial schemes?
    $serial = strftime("%Y%m%d%H", localtime()) if !$serial;
    $primary .= "." if $primary !~ /\.$/;
    $email .= "." if $email !~ /\.$/;
#    print *{$zonefiles->{$loc}} "Z$zone:$primary:$email:$serial:$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n"
#      or die $!;
#    print *{$zonefiles->{$loc}} "$zone	$ttl	IN	SOA	$primary	$email	( $serial $refresh $retry $expire $min_ttl )\n"
#       or die "couldn't write $zone SOA: $!";

    # Prepare the body of the record
    my $recdata = "$ttl	IN	SOA	$primary	$email	( $serial $refresh $retry $expire $min_ttl )\n";

    # ... and prepend the zone name FQDN
    if ($revrec eq 'y') {
      my $zone2 = DNSDB::_ZONE($zone, 'ZONE', 'r', '.').($zone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
      $recdata = "$zone2.	$recdata";
    } else {
      $recdata = "$zone.	$recdata";
    }

    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # SOA

  elsif ($typemap{$type} eq 'A') {
    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print $datafile "+$host:$val:$ttl:$stamp:$loc\n" or die $!;
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      A       $val\n" or die $!;
    my $recdata = "$host.	$ttl	IN	A	$val\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # A

  elsif ($typemap{$type} eq 'NS') {
    if ($revrec eq 'y') {
      $val = NetAddr::IP->new($val);

##fixme:  conversion for sub-/24 delegations in reverse zones?
#      if (!$val->{isv6} && ($val->masklen > 24)) {
#      }

      my $val2 = DNSDB::_ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
      $host .= "." if $host !~ /\.$/;
      my $recdata = "$val2.	$ttl	IN	NS	$host\n";
      __recprint($zonefiles, $loclist, $loc, $recdata);

    } else {
      my $recdata = "$host.	$ttl	IN	NS	$val.\n";
      __recprint($zonefiles, $loclist, $loc, $recdata);
    }
  } # NS

  elsif ($typemap{$type} eq 'AAAA') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      AAAA    $val\n" or die $!;
    my $recdata = "$host.	$ttl	IN	AAAA	$val\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # AAAA

  elsif ($typemap{$type} eq 'MX') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host	$ttl	IN	MX	$distance $val\n" or die $!;
# should arguably swap host and val first, but MX records really don't make any sense in reverse zones, so any silliness that results from finding one doesn't much matter.
    my $recdata = "$host.	$ttl	IN	MX	$distance $val.\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # MX

  elsif ($typemap{$type} eq 'TXT') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      TXT     \"$val\"\n" or die $!;
    # Clean up some lingering tinydns/VegaDNSisms
    DNSDB::_deoctal(\$val);
    my $recdata = "$host.	$ttl	IN	TXT	\"$val\"\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # TXT

  elsif ($typemap{$type} eq 'CNAME') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      CNAME   $val\n" or die $!;
    my $recdata;
    if ($zone =~ /\.rpz$/) {
      # RPZ data stored and published as-is
      $recdata = "$host	$ttl	IN	CNAME	$val\n";
    } else {
      $recdata = "$host.	$ttl	IN	CNAME	$val.\n";
    }
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # CNAME

  elsif ($typemap{$type} eq 'SRV') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      SRV     $distance   $weight $port   $val\n" or die $!;
    my $recdata = "$host	$ttl	IN	SRV	$distance	$weight $port	$val.\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # SRV

  elsif ($typemap{$type} eq 'RP') {
#    ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
#    print {$zonefiles->{$loc}} "$host  $ttl    IN      RP      $val\n" or die $!;
    my $recdata = "$host.	$ttl	IN	RP	$val\n";
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # RP

  elsif ($typemap{$type} eq 'PTR') {
#    $$recflags{$val}++;
       # maybe track exclusions like this?  so we can publish "all
       # A and/or PTR records" irrespective of template records
    $$recflags{$val} = 'ptr';
    return if $host eq '%blank%';

    if ($revrec eq 'y') {

      if ($val =~ /\.arpa$/) {
        # someone put in the formal .arpa name.  humor them.
        my $recdata = "$val.	$ttl	IN	PTR	$host.\n";
        __recprint($zonefiles, $loclist, $loc, $recdata);
      } else {
        $zone = NetAddr::IP->new($zone);
        if (!$zone->{isv6} && $zone->masklen > 24) {
          # sub-octet v4 zone
          ($val) = ($val =~ /\.(\d+)$/);
          my $recdata = "$val.".DNSDB::_ZONE($zone, 'ZONE', 'r', '.').".in-addr.arpa.	$ttl	IN	PTR	$host.\n";
          __recprint($zonefiles, $loclist, $loc, $recdata);
        } else {
          # not going to care about strange results if $val is not an IP value and is resolveable in DNS
          $val = NetAddr::IP->new($val);
          my $recdata = DNSDB::_ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa').
		".	$ttl	IN	PTR	$host.\n";
          __recprint($zonefiles, $loclist, $loc, $recdata);
        }
      } # non-".arpa" $val

    } else {
      # PTRs in forward zones are less bizarre and insane than some other record types
      # in reverse zones...  OTOH we can't validate them any which way, so we cross our
      # fingers and close our eyes and make it Someone Else's Problem.
#      print {$zonefiles->{$loc}} "$host	$ttl	IN	PTR	$val\n" or die $!;
      my $recdata = "$host.	$ttl	IN	PTR	$val.\n";
      __recprint($zonefiles, $loclist, $loc, $recdata);
    }
  } # PTR

  elsif ($type == 65280) { # A+PTR
    # Recurse to PTR or A as appropriate because BIND et al don't share
    # the tinydns concept of merged forward/reverse records
# %recflags gets updated in the PTR branch just above
#    $$recflags{$val}++;
    if ($revrec eq 'y') {
      printrec_bind($dnsdb, $zonefiles, $loclist, $recid, $revrec, $recflags, $zone, $host, 12, $val,
        $distance, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
# ... but we need to tweak it for this case?  so the A record gets published...
#$$recflags{$val} = 'a+ptr';
#print {$zonefiles->{$loc}} "=$host:$val:$ttl:$stamp:$loc\n" or die $!;
#          printrec_bind($dnsdb, \%zonefiles, $recid, 'y', \@loclist, $revzone,
#            $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
#  my ($zonefiles, $recid, $revrec, $loclist, $zone, $host, $type, $val, $distance, $weight, $port, $ttl,
#        $loc, $stamp, $expires, $stampactive) = @_;
    } else {
      printrec_bind($dnsdb, $zonefiles, $loclist, $recid, $revrec, $recflags, $zone, $host, 1, $val,
        $distance, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
      # set a unique flag to skip template expansion for this IP in forward zones
      $$recflags{$val} = 'a';
    }
  } # A+PTR

  elsif ($type == 65282) { # PTR template
    # only useful for v4 with standard DNS software, since this expands all
    # IPs in $zone (or possibly $val?) with autogenerated records
    $val = NetAddr::IP->new($val);
    return if $val->{isv6};

    if ($val->masklen <= 16) {
      foreach my $sub ($val->split(16)) {
        __publish_template_bind($dnsdb, $sub, $recflags, $host, $zonefiles, $loclist, $ttl, $stamp, $loc, $zone, $revrec);
      }
    } else {
      __publish_template_bind($dnsdb, $val, $recflags, $host, $zonefiles, $loclist, $ttl, $stamp, $loc, $zone, $revrec);
    }
  } # PTR template

  elsif ($type == 65283) { # A+PTR template
    $val = NetAddr::IP->new($val);
    # Just In Case.  An A+PTR should be impossible to add to a v6 revzone via API.
    return if $val->{isv6};

    if ($val->masklen < 16) {
      foreach my $sub ($val->split(16)) {
        __publish_template_bind($dnsdb, $sub, $recflags, $host, $zonefiles, $loclist, $ttl, $stamp, $loc, $zone, $revrec);
      }
    } else {
      __publish_template_bind($dnsdb, $val, $recflags, $host, $zonefiles, $loclist, $ttl, $stamp, $loc, $zone, $revrec);
    }
  } # A+PTR template
 
  elsif ($type == 65284) { # AAAA+PTR template
    # Stub for completeness.  Could be exported to DNS software that supports
    # some degree of internal automagic in generic-record-creation
    # (eg http://search.cpan.org/dist/AllKnowingDNS/ )
  } # AAAA+PTR template

} # printrec_bind()


sub __publish_template_bind {
  my $dnsdb = shift;
  my $sub = shift;
  my $recflags = shift;
  my $hpat = shift;
  my $zonefiles = shift;
  my $loclist = shift;
  my $ttl = shift;
  my $stamp = shift;
  my $loc = shift;
  my $zpass = shift;
  my $zone = new NetAddr::IP $zpass;
#  my $zone = new NetAddr::IP shift;
  my $revrec = shift || 'y';
#  my $ptrflag = shift || 0;    ##fixme:  default to PTR instead of A record for the BIND variant of this sub?

  # do this conversion once, not (number-of-ips-in-subnet) times
  my $arpabase = DNSDB::_ZONE($zone, 'ZONE.in-addr.arpa.', 'r', '.');

  my $iplist = $sub->splitref(32);
  my $ipindex = -1;
  foreach (@$iplist) {
    my $ip = $_->addr;
    $ipindex++;
    # make as if we split the non-octet-aligned block into octet-aligned blocks as with SOA
    my $lastoct = (split /\./, $ip)[3];

    # Allow smaller entries to override longer ones, eg, a specific PTR will
    # always publish, overriding any template record containing that IP.
    # %blank% also needs to be per-IP here to properly cascade overrides with
    # multiple nested templates
#    next if $$recflags{$ip}; # && $self->{skip_bcast_255}

#    next if $$recflags{$ip} && ($$recflags{$ip} eq 'ptr' || $$recflags{$ip} eq 'a+ptr');

    if ($revrec eq 'y') {
      next if $$recflags{$ip};  # blanket exclusion;  we do reverse records first
    } else {
##fixme:  A record side templates not cascading correctly
      # excluding ptr does NOT work, as it excludes ALL previously covered A+PTR template entries.
      # we only want to exclude the singleton (A+)PTR ones
      #if ($$recflags{$ip} && ($$recflags{$ip} eq 'a' || $$recflags{$ip} eq 'ptr')) {
      if ($$recflags{$ip} && ($$recflags{$ip} eq 'a' || $$recflags{$ip} eq 'atemplate' || $$recflags{$ip} eq 'ptr')) {
        # default skip case
        next;
      }
    } # revrec branch for skipping template member expansion

    # set a forward/reverse-unique flag in %recflags
    $$recflags{$ip} = ($revrec eq 'y' ? 'ptrtemplate' : 'atemplate');
    next if $hpat eq '%blank%';

    my $rec = $hpat;  # start fresh with the template for each IP
##fixme:  there really isn't a good way to handle sub-/24 zones here.  This way at least
# seems less bad than some alternatives.
    $dnsdb->_template4_expand(\$rec, $ip, \$sub, $ipindex);
    # _template4_expand may blank $rec;  if so, don't publish a record
    next if !$rec;
##fixme:  trim merged record type voodoo.  "if ($ptrflag) {} else {}" ?
#    if ($ptrflag || $zone->masklen > 24) {
    my $recdata;
    if ($revrec eq 'y') {
# || $zone->masklen > 24) {
#      print $fh "^$lastoct.$arpabase:$rec:$ttl:$stamp:$loc\n" or die $!;
##fixme: use $ORIGIN instead?  make the FQDN output switchable-optional?
#      print $fh "$lastoct.$arpabase    $ttl    IN      PTR     $rec\n" or die $!;
#      if ($revrec ne 'y') {
        # print a separate A record.  Arguably we could use an = record here instead.
#        print $fh "+$rec:$ip:$ttl:$stamp:$loc\n" or die $!;
#        print $fh "$rec	$ttl	IN	A	$ip\n" or die $!;
#      }
      if ($dnsdb->{bind_export_fqdn}) {
        $recdata = "$lastoct.$arpabase	$ttl	IN	PTR	$rec.\n";
      } else {
        $recdata = "$lastoct	$ttl	IN	PTR	$rec.\n";
      }

    } else {
      # A record, not merged
#      print $fh "=$rec:$ip:$ttl:$stamp:$loc\n" or die $!;
#      print $fh "$rec	$ttl	IN	A	$ip\n" or die $!;
      $rec =~ s/\.$zone$// unless $dnsdb->{bind_export_fqdn};
      $recdata = "$rec.	$ttl	IN	A	$ip\n";
    }
    # and finally 
    __recprint($zonefiles, $loclist, $loc, $recdata);
  } # foreach (@iplist)
} # __publish_template_bind()


# actual record printing sub
# loop on the locations here so we don't end up with a huge pot of copypasta
sub __recprint {
  my ($zonefiles, $loclist, $loc, $recdata) = @_;
  if ($loc eq '') {
    # "common" record visible in all locations
    foreach my $rloc (@{$loclist}) {
      print {$zonefiles->{$rloc}} $recdata or die $!;
    }
  } else {
    # record with specific location tagged
    print {$zonefiles->{$loc}} $recdata or die $!;
  }
}

1;
