Index: branches/stable/DNSDB.pm
===================================================================
--- branches/stable/DNSDB.pm	(revision 544)
+++ branches/stable/DNSDB.pm	(revision 545)
@@ -3,5 +3,5 @@
 ##
 # $Id$
-# Copyright 2008-2011 Kris Deugau <kdeugau@deepnet.cx>
+# Copyright 2008-2012 Kris Deugau <kdeugau@deepnet.cx>
 # 
 #    This program is free software: you can redistribute it and/or modify
@@ -30,49 +30,61 @@
 use NetAddr::IP qw(:lower);
 use POSIX;
+use Fcntl qw(:flock);
+
 use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
 
-$VERSION	= "1.0.5";	##VERSION##
+$VERSION	= 1.1;	##VERSION##
 @ISA		= qw(Exporter);
 @EXPORT_OK	= qw(
-	&initGlobals
+	&initGlobals &login &initActionLog
 	&initPermissions &getPermissions &changePermissions &comparePermissions
 	&changeGroup
 	&loadConfig &connectDB &finish
-	&addDomain &delDomain &domainName &revName &domainID &addRDNS
-	&getZoneCount &getZoneList
+	&addDomain &delZone &domainName &revName &domainID &revID &addRDNS
+	&getZoneCount &getZoneList &getZoneLocation
 	&addGroup &delGroup &getChildren &groupName
+	&getGroupCount &getGroupList
 	&addUser &updateUser &delUser &userFullName &userStatus &getUserData
-	&getSOA	&getRecLine &getDomRecs &getRecCount
+	&getUserCount &getUserList &getUserDropdown
+	&addLoc &updateLoc &delLoc &getLoc
+	&getLocCount &getLocList &getLocDropdown
+	&getSOA	&updateSOA &getRecLine &getDomRecs &getRecCount
 	&addRec &updateRec &delRec
+	&getLogCount &getLogEntries
 	&getTypelist
 	&parentID
 	&isParent
-	&domStatus &importAXFR
+	&zoneStatus &importAXFR
 	&export
 	&mailNotify
 	%typemap %reverse_typemap %config
-	%permissions @permtypes $permlist
+	%permissions @permtypes $permlist %permchains
 	);
 
 @EXPORT		= (); # Export nothing by default.
 %EXPORT_TAGS	= ( ALL => [qw(
-		&initGlobals
+		&initGlobals &login &initActionLog
 		&initPermissions &getPermissions &changePermissions &comparePermissions
 		&changeGroup
 		&loadConfig &connectDB &finish
-		&addDomain &delDomain &domainName &revName &domainID &addRDNS
-		&getZoneCount &getZoneList
+		&addDomain &delZone &domainName &revName &domainID &revID &addRDNS
+		&getZoneCount &getZoneList &getZoneLocation
 		&addGroup &delGroup &getChildren &groupName
+		&getGroupCount &getGroupList
 		&addUser &updateUser &delUser &userFullName &userStatus &getUserData
-		&getSOA &getRecLine &getDomRecs &getRecCount
+		&getUserCount &getUserList &getUserDropdown
+		&addLoc &updateLoc &delLoc &getLoc
+		&getLocCount &getLocList &getLocDropdown
+		&getSOA &updateSOA &getRecLine &getDomRecs &getRecCount
 		&addRec &updateRec &delRec
+		&getLogCount &getLogEntries
 		&getTypelist
 		&parentID
 		&isParent
-		&domStatus &importAXFR
+		&zoneStatus &importAXFR
 		&export
 		&mailNotify
 		%typemap %reverse_typemap %config
-		%permissions @permtypes $permlist
+		%permissions @permtypes $permlist %permchains
 		)]
 	);
@@ -80,4 +92,5 @@
 our $group = 1;
 our $errstr = '';
+our $resultstr = '';
 
 # Halfway sane defaults for SOA, TTL, etc.
@@ -97,12 +110,23 @@
 
 # Arguably defined wholly in the db, but little reason to change without supporting code changes
+# group_view, user_view permissions? separate rDNS permission(s)?
 our @permtypes = qw (
 	group_edit	group_create	group_delete
 	user_edit	user_create	user_delete
 	domain_edit	domain_create	domain_delete
-	record_edit	record_create	record_delete
+	record_edit	record_create	record_delete	record_locchg
+	location_edit	location_create	location_delete	location_view
 	self_edit	admin
 );
 our $permlist = join(',',@permtypes);
+
+# Some permissions more or less require certain others.
+our %permchains = (
+	user_edit	=> 'self_edit',
+	location_edit	=> 'location_view',
+	location_create	=> 'location_view',
+	location_delete	=> 'location_view',
+	record_locchg	=> 'location_view',
+);
 
 # DNS record type map and reverse map.
@@ -135,4 +159,5 @@
 #		cssdir	=> 'templates/',
 		sessiondir	=> 'session/',
+		exportcache	=> 'cache/',
 
 		# Session params
@@ -145,2164 +170,13 @@
 
 ## (Semi)private variables
+
 # Hash of functions for validating record types.  Filled in initGlobals() since
 # it relies on visibility flags from the rectypes table in the DB
 my %validators;
 
-
-##
-## utility functions
-# _rectable()
-# Takes default+rdns flags, returns appropriate table name
-sub _rectable {
-  my $def = shift;
-  my $rev = shift;
-
-  return 'records' if $def ne 'y';
-  return 'default_records' if $rev ne 'y';
-  return 'default_rev_records';
-} # end _rectable()
-
-# _recparent()
-# Takes default+rdns flags, returns appropriate parent-id column name
-sub _recparent {
-  my $def = shift;
-  my $rev = shift;
-
-  return 'group_id' if $def eq 'y';
-  return 'rdns_id' if $rev eq 'y';
-  return 'domain_id';
-} # end _recparent()
-
-# Check an IP to be added in a reverse zone to see if it's really in the requested parent.
-# Takes a database handle, default and reverse flags, IP (fragment) to check, parent zone ID,
-# and a reference to a NetAddr::IP object (also used to pass back a fully-reconstructed IP for
-# database insertion)
-sub _ipparent {
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-  my $val = shift;
-  my $id = shift;
-  my $addr = shift;
-
-  return if $revrec ne 'y';	# this sub not useful in forward zones
-
-  $$addr = NetAddr::IP->new($$val);	 #necessary?
-
-  # subsub to split, reverse, and overlay an IP fragment on a netblock
-  sub __rev_overlay {
-    my $splitme = shift;	# ':' or '.', m'lud?
-    my $parnet = shift;
-    my $val = shift;
-    my $addr = shift;
-
-    my $joinme = $splitme;
-    $splitme = '\.' if $splitme eq '.';
-    my @working = reverse(split($splitme, $parnet->addr));
-    my @parts = reverse(split($splitme, $$val));
-    for (my $i = 0; $i <= $#parts; $i++) {
-      $working[$i] = $parts[$i];
-    }
-    my $checkme = NetAddr::IP->new(join($joinme, reverse(@working))) or return 0;
-    return 0 unless $checkme->within($parnet);
-    $$addr = $checkme;	# force "correct" IP to be recorded.
-    return 1;
-  }
-
-  my ($parstr) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id = ?", undef, ($id));
-  my $parnet = NetAddr::IP->new($parstr);
-
-  # Fail early on v6-in-v4 or v4-in-v6.  We're not accepting these ATM.
-  return 0 if $parnet->addr =~ /\./ && $$val =~ /:/;
-  return 0 if $parnet->addr =~ /:/ && $$val =~ /\./;
-
-  if ($$addr && $$val =~ /^[\da-fA-F][\da-fA-F:]+[\da-fA-F]$/) {
-    # the only case where NetAddr::IP's acceptance of legitimate IPs is "correct" is for a proper IPv6 address.
-    # the rest we have to restructure before fiddling.  *sigh*
-    return 1 if $$addr->within($parnet);
-  } else {
-    # We don't have a complete IP in $$val (yet)
-    if ($parnet->addr =~ /:/) {
-      $$val =~ s/^:+//;	 # gotta strip'em all...
-      return __rev_overlay(':', $parnet, $val, $addr);
-    }
-    if ($parnet->addr =~ /\./) {
-      $$val =~ s/^\.+//;
-      return __rev_overlay('.', $parnet, $val, $addr);
-    }
-    # should be impossible to get here...
-  }
-  # ... and here.
-  # can't do nuttin' in forward zones
-} # end _ipparent()
-
-# A little different than _ipparent above;  this tries to *find* the parent zone of a hostname
-sub _hostparent {
-  my $dbh = shift;
-  my $hname = shift;
-  
-  my @hostbits = split /\./, $hname;
-  my $sth = $dbh->prepare("SELECT count(*),domain_id FROM domains WHERE domain = ? GROUP BY domain_id");
-  foreach (@hostbits) {
-    $sth->execute($hname);
-    my ($found, $parid) = $sth->fetchrow_array;
-    if ($found) {
-      return $parid;
-    }
-    $hname =~ s/^$_\.//;
-  }
-} # end _hostparent()
-
-##
-## Record validation subs.
-##
-
-# A record
-sub _validate_1 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  return ('FAIL', 'Reverse zones cannot contain A records') if $args{revrec} eq 'y';
-
-  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
-  # or the intended parent domain for live records.
-  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
-
-  # Check IP is well-formed, and that it's a v4 address
-  # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
-  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
-	unless ${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/;
-  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
-	unless $args{addr} && !$args{addr}->{isv6};
-  # coerce IP/value to normalized form for storage
-  ${$args{val}} = $args{addr}->addr;
-
-  return ('OK','OK');
-} # done A record
-
-# NS record
-sub _validate_2 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  # Coerce the hostname to "DOMAIN" for forward default records, "ZONE" for reverse default records,
-  # or the intended parent zone for live records.
-##fixme:  allow for delegating <subdomain>.DOMAIN?
-  if ($args{revrec} eq 'y') {
-    my $pname = ($args{defrec} eq 'y' ? 'ZONE' : revName($dbh,$args{id}));
-    ${$args{host}} = $pname if ${$args{host}} ne $pname;
-  } else {
-    my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-    ${$args{host}} = $pname if ${$args{host}} ne $pname;
-  }
-
-# Let this lie for now.  Needs more magic.
-#  # Check IP is well-formed, and that it's a v4 address
-#  return ('FAIL',"A record must be a valid IPv4 address")
-#	unless $addr && !$addr->{isv6};
-#  # coerce IP/value to normalized form for storage
-#  $$val = $addr->addr;
-
-  return ('OK','OK');
-} # done NS record
-
-# CNAME record
-sub _validate_5 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-# Not really true, but these are only useful for delegating smaller-than-/24 IP blocks.
-# This is fundamentally a messy operation and should really just be taken care of by the
-# export process, not manual maintenance of the necessary records.
-  return ('FAIL', 'Reverse zones cannot contain CNAME records') if $args{revrec} eq 'y';
-
-  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
-  # or the intended parent domain for live records.
-  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
-
-  return ('OK','OK');
-} # done CNAME record
-
-# SOA record
-sub _validate_6 {
-  # Smart monkeys won't stick their fingers in here;  we have
-  # separate dedicated routines to deal with SOA records.
-  return ('OK','OK');
-} # done SOA record
-
-# PTR record
-sub _validate_12 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  if ($args{revrec} eq 'y') {
-    if ($args{defrec} eq 'n') {
-      return ('FAIL', "IP or IP fragment ${$args{val}} is not within ".revName($dbh, $args{id}))
-	unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
-      ${$args{val}} = $args{addr}->addr;
-    } else {
-      if (${$args{val}} =~ /\./) {
-	# looks like a v4 or fragment
-	if (${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/) {
-	  # woo!  a complete IP!  validate it and normalize, or fail.
-	  $args{addr} = NetAddr::IP->new(${$args{val}})
-		or return ('FAIL', "IP/value looks like IPv4 but isn't valid");
-	  ${$args{val}} = $args{addr}->addr;
-	} else {
-	  ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
-	}
-      } elsif (${$args{val}} =~ /[a-f:]/) {
-	# looks like a v6 or fragment
-	${$args{val}} =~ s/^:*/ZONE::/ if !$args{addr} && ${$args{val}} !~ /^ZONE/;
-	if ($args{addr}) {
-	  if ($args{addr}->addr =~ /^0/) {
-	    ${$args{val}} =~ s/^:*/ZONE::/ unless ${$args{val}} =~ /^ZONE/;
-	  } else {
-	    ${$args{val}} = $args{addr}->addr;
-	  }
-	}
-      } else {
-	# bare number (probably).  These could be v4 or v6, so we'll
-	# expand on these on creation of a reverse zone.
-	${$args{val}} = "ZONE,${$args{val}}" unless ${$args{val}} =~ /^ZONE/;
-      }
-      ${$args{host}} =~ s/\.*$/\.$config{domain}/ if ${$args{host}} !~ /(?:$config{domain}|ADMINDOMAIN)$/;
-    }
-
-# Multiple PTR records do NOT generally do what most people believe they do,
-# and tend to fail in the most awkward way possible.  Check and warn.
-# We use $val instead of $addr->addr since we may be in a defrec, and may have eg "ZONE::42" or "ZONE.12"
-
-    my @checkvals = (${$args{val}});
-    if (${$args{val}} =~ /,/) {
-      # push . and :: variants into checkvals if val has ,
-      my $tmp;
-      ($tmp = ${$args{val}}) =~ s/,/./;
-      push @checkvals, $tmp;
-      ($tmp = ${$args{val}}) =~ s/,/::/;
-      push @checkvals, $tmp;
-    }
-    my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE val = ?");
-    foreach my $checkme (@checkvals) {
-      my $ptrcount;
-      ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
-	" WHERE val = ?", undef, ($checkme));
-      return ('WARN', "PTR record for $checkme already exists;  adding another will probably not do what you want")
-	if $ptrcount;
-    }
-  } else {
-    # Not absolutely true but only useful if you hack things up for sub-/24 v4 reverse delegations
-    # Simpler to just create the reverse zone and grant access for the customer to edit it, and create direct
-    # PTR records on export
-    return ('FAIL',"Forward zones cannot contain PTR records");
-  }
-
-  return ('OK','OK');
-} # done PTR record
-
-# MX record
-sub _validate_15 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-# Not absolutely true but WTF use is an MX record for a reverse zone?
-  return ('FAIL', 'Reverse zones cannot contain MX records') if $args{revrec} eq 'y';
-
-  return ('FAIL', "Distance is required for MX records") unless defined(${$args{dist}});
-  ${$args{dist}} =~ s/\s*//g;
-  return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
-
-  ${$args{fields}} = "distance,";
-  push @{$args{vallist}}, ${$args{dist}};
-
-  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
-  # or the intended parent domain for live records.
-  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
-
-  return ('OK','OK');
-} # done MX record
-
-# TXT record
-sub _validate_16 {
-  # Could arguably put a WARN return here on very long (>512) records
-  return ('OK','OK');
-} # done TXT record
-
-# RP record
-sub _validate_17 {
-  # Probably have to validate these some day
-  return ('OK','OK');
-} # done RP record
-
-# AAAA record
-sub _validate_28 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  return ('FAIL', 'Reverse zones cannot contain AAAA records') if $args{revrec} eq 'y';
-
-  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
-  # or the intended parent domain for live records.
-  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
-
-  # Check IP is well-formed, and that it's a v6 address
-  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv6 address")
-	unless $args{addr} && $args{addr}->{isv6};
-  # coerce IP/value to normalized form for storage
-  ${$args{val}} = $args{addr}->addr;
-
-  return ('OK','OK');
-} # done AAAA record
-
-# SRV record
-sub _validate_33 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-# Not absolutely true but WTF use is an SRV record for a reverse zone?
-  return ('FAIL', 'Reverse zones cannot contain SRV records') if $args{revrec} eq 'y';
-
-  return ('FAIL', "Distance is required for SRV records") unless defined(${$args{dist}});
-  ${$args{dist}} =~ s/\s*//g;
-  return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
-
-  return ('FAIL',"SRV records must begin with _service._protocol [${$args{host}}]")
-	unless ${$args{host}} =~ /^_[A-Za-z]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
-  return ('FAIL',"Port and weight are required for SRV records")
-	unless defined(${$args{weight}}) && defined(${$args{port}});
-  ${$args{weight}} =~ s/\s*//g;
-  ${$args{port}} =~ s/\s*//g;
-
-  return ('FAIL',"Port and weight are required, and must be numeric")
-	unless ${$args{weight}} =~ /^\d+$/ && ${$args{port}} =~ /^\d+$/;
-
-  ${$args{fields}} = "distance,weight,port,";
-  push @{$args{vallist}}, (${$args{dist}}, ${$args{weight}}, ${$args{port}});
-
-  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
-  # or the intended parent domain for live records.
-  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
-  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
-
-  return ('OK','OK');
-} # done SRV record
-
-# Now the custom types
-
-# A+PTR record.  With a very little bit of magic we can also use this sub to validate AAAA+PTR.  Whee!
-sub _validate_65280 {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  my $code = 'OK';
-  my $msg = 'OK';
-
-  if ($args{defrec} eq 'n') {
-    # live record;  revrec determines whether we validate the PTR or A component first.
-
-    if ($args{revrec} eq 'y') {
-      ($code,$msg) = _validate_12($dbh, %args);
-      return ($code,$msg) if $code eq 'FAIL';
-
-      # Check if the reqested domain exists.  If not, coerce the type down to PTR and warn.
-      if (!(${$args{domid}} = _hostparent($dbh, ${$args{host}}))) {
-	my $addmsg = "Record added as PTR instead of $typemap{${$args{rectype}}};  domain not found for ${$args{host}}";
-	$msg .= "\n$addmsg" if $code eq 'WARN';
-	$msg = $addmsg if $code eq 'OK';
-        ${$args{rectype}} = $reverse_typemap{PTR};
-        return ('WARN', $msg);
-      }
-
-      # Add domain ID to field list and values
-      ${$args{fields}} .= "domain_id,";
-      push @{$args{vallist}}, ${$args{domid}};
-
-    } else {
-      ($code,$msg) = _validate_1($dbh, %args) if ${$args{rectype}} == 65280;
-      ($code,$msg) = _validate_28($dbh, %args) if ${$args{rectype}} == 65281;
-      return ($code,$msg) if $code eq 'FAIL';
-
-      # Check if the requested reverse zone exists - note, an IP fragment won't
-      # work here since we don't *know* which parent to put it in.
-      # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
-      my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
-	" ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
-      if (!$revid) {
-        $msg = "Record added as ".(${$args{rectype}} == 65280 ? 'A' : 'AAAA').
-		" instead of $typemap{${$args{rectype}}};  reverse zone not found for ${$args{val}}";
-	${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
-	return ('WARN', $msg);
-      }
-
-      # Check for duplicate PTRs.  Note we don't have to play games with $code and $msg, because
-      # by definition there can't be duplicate PTRs if the reverse zone isn't managed here.
-      my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
-	" WHERE val = ?", undef, ${$args{val}});
-      if ($ptrcount) {
-	$msg = "PTR record for ${$args{val}} already exists;  adding another will probably not do what you want";
-	$code = 'WARN';
-      }
-
-      ${$args{fields}} .= "rdns_id,";
-      push @{$args{vallist}}, $revid;
-    }
-
-  } else {	# defrec eq 'y'
-    if ($args{revrec} eq 'y') {
-      ($code,$msg) = _validate_12($dbh, %args);
-      return ($code,$msg) if $code eq 'FAIL';
-      if (${$args{rectype}} == 65280) {
-	return ('FAIL',"A+PTR record must be a valid IPv4 address or fragment")
-		if ${$args{val}} =~ /:/;
-	${$args{val}} =~ s/^ZONE,/ZONE./;       # Clean up after uncertain IP-fragment-type from _validate_12
-      } elsif (${$args{rectype}} == 65281) {
-	return ('FAIL',"AAAA+PTR record must be a valid IPv6 address or fragment")
-		if ${$args{val}} =~ /\./;
-	${$args{val}} =~ s/^ZONE,/ZONE::/;      # Clean up after uncertain IP-fragment-type from _validate_12
-      }
-    } else {
-      # This is easy.  I also can't see a real use-case for A/AAAA+PTR in *all* forward
-      # domains, since you wouldn't be able to substitute both domain and reverse zone
-      # sanely, and you'd end up with guaranteed over-replicated PTR records that would
-      # confuse the hell out of pretty much anything that uses them.
-##fixme: make this a config flag?
-      return ('FAIL', "$typemap{${$args{rectype}}} records not allowed in default domains");
-    }
-  }
-
-  return ($code, $msg);
-} # done A+PTR record
-
-# AAAA+PTR record
-# A+PTR above has been magicked to handle AAAA+PTR as well.
-sub _validate_65281 {
-  return _validate_65280(@_);
-} # done AAAA+PTR record
-
-# PTR template record
-sub _validate_65282 {
-  return ('OK','OK');
-} # done PTR template record
-
-# A+PTR template record
-sub _validate_65283 {
-  return ('OK','OK');
-} # done AAAA+PTR template record
-
-# AAAA+PTR template record
-sub _validate_65284 {
-  return ('OK','OK');
-} # done AAAA+PTR template record
-
-
-
-##
-## Initialization and cleanup subs
-##
-
-
-## DNSDB::loadConfig()
-# Load the minimum required initial state (DB connect info) from a config file
-# Load misc other bits while we're at it.
-# Takes an optional basename and config path to look for
-# Populates the %config and %def hashes
-sub loadConfig {
-  my $basename = shift || '';	# this will work OK
-##fixme  $basename isn't doing what I think I thought I was trying to do.
-
-  my $deferr = '';	# place to put error from default config file in case we can't find either one
-
-  my $configroot = "/etc/dnsdb";	##CFG_LEAF##
-  $configroot = '' if $basename =~ m|^/|;
-  $basename .= ".conf" if $basename !~ /\.conf$/;
-  my $defconfig = "$configroot/dnsdb.conf";
-  my $siteconfig = "$configroot/$basename";
-
-  # System defaults
-  __cfgload("$defconfig") or $deferr = $errstr;
-
-  # Per-site-ish settings.
-  if ($basename ne '.conf') {
-    unless (__cfgload("$siteconfig")) {
-      $errstr = ($deferr ? "Error opening default config file $defconfig: $deferr\n" : '').
-	"Error opening site config file $siteconfig";
-      return;
-    }
-  }
-
-  # Munge log_failures.
-  if ($config{log_failures} ne '1' && $config{log_failures} ne '0') {
-    # true/false, on/off, yes/no all valid.
-    if ($config{log_failures} =~ /^(?:true|false|on|off|yes|no)$/) {
-      if ($config{log_failures} =~ /(?:true|on|yes)/) {
-	$config{log_failures} = 1;
-      } else {
-	$config{log_failures} = 0;
-      }
-    } else {
-      $errstr = "Bad log_failures setting $config{log_failures}";
-      $config{log_failures} = 1;
-      # Bad setting shouldn't be fatal.
-      # return 2;
-    }
-  }
-
-  # All good, clear the error and go home.
-  $errstr = '';
-  return 1;
-} # end loadConfig()
-
-
-## DNSDB::__cfgload()
-# Private sub to parse a config file and load it into %config
-# Takes a file handle on an open config file
-sub __cfgload {
-  $errstr = '';
-  my $cfgfile = shift;
-
-  if (open CFG, "<$cfgfile") {
-    while (<CFG>) {
-      chomp;
-      s/^\s*//;
-      next if /^#/;
-      next if /^$/;
-# hmm.  more complex bits in this file might require [heading] headers, maybe?
-#    $mode = $1 if /^\[(a-z)+]/;
-    # DB connect info
-      $config{dbname}	= $1 if /^dbname\s*=\s*([a-z0-9_.-]+)/i;
-      $config{dbuser}	= $1 if /^dbuser\s*=\s*([a-z0-9_.-]+)/i;
-      $config{dbpass}	= $1 if /^dbpass\s*=\s*([a-z0-9_.-]+)/i;
-      $config{dbhost}	= $1 if /^dbhost\s*=\s*([a-z0-9_.-]+)/i;
-      # SOA defaults
-      $def{contact}	= $1 if /^contact\s*=\s*([a-z0-9_.-]+)/i;
-      $def{prins}	= $1 if /^prins\s*=\s*([a-z0-9_.-]+)/i;
-      $def{soattl}	= $1 if /^soattl\s*=\s*(\d+)/i;
-      $def{refresh}	= $1 if /^refresh\s*=\s*(\d+)/i;
-      $def{retry}	= $1 if /^retry\s*=\s*(\d+)/i;
-      $def{expire}	= $1 if /^expire\s*=\s*(\d+)/i;
-      $def{minttl}	= $1 if /^minttl\s*=\s*(\d+)/i;
-      $def{ttl}		= $1 if /^ttl\s*=\s*(\d+)/i;
-      # Mail settings
-      $config{mailhost}		= $1 if /^mailhost\s*=\s*([a-z0-9_.-]+)/i;
-      $config{mailnotify}	= $1 if /^mailnotify\s*=\s*([a-z0-9_.\@-]+)/i;
-      $config{mailsender}	= $1 if /^mailsender\s*=\s*([a-z0-9_.\@-]+)/i;
-      $config{mailname}		= $1 if /^mailname\s*=\s*([a-z0-9\s_.-]+)/i;
-      $config{orgname}		= $1 if /^orgname\s*=\s*([a-z0-9\s_.,'-]+)/i;
-      $config{domain}		= $1 if /^domain\s*=\s*([a-z0-9_.-]+)/i;
-      # session - note this is fed directly to CGI::Session
-      $config{timeout}		= $1 if /^[tT][iI][mM][eE][oO][uU][tT]\s*=\s*(\d+[smhdwMy]?)/;
-      $config{sessiondir}	= $1 if m{^sessiondir\s*=\s*([a-z0-9/_.-]+)}i;
-      # misc
-      $config{log_failures}	= $1 if /^log_failures\s*=\s*([a-z01]+)/i;
-      $config{perpage}		= $1 if /^perpage\s*=\s*(\d+)/i;
-    }
-    close CFG;
-  } else {
-    $errstr = $!;
-    return;
-  }
-  return 1;
-} # end __cfgload()
-
-
-## DNSDB::connectDB()
-# Creates connection to DNS database.
-# Requires the database name, username, and password.
-# Returns a handle to the db.
-# Set up for a PostgreSQL db;  could be any transactional DBMS with the
-# right changes.
-sub connectDB {
-  $errstr = '';
-  my $dbname = shift;
-  my $user = shift;
-  my $pass = shift;
-  my $dbh;
-  my $DSN = "DBI:Pg:dbname=$dbname";
-
-  my $host = shift;
-  $DSN .= ";host=$host" if $host;
-
-# Note that we want to autocommit by default, and we will turn it off locally as necessary.
-# We may not want to print gobbledygook errors;  YMMV.  Have to ponder that further.
-  $dbh = DBI->connect($DSN, $user, $pass, {
-	AutoCommit => 1,
-	PrintError => 0
-	})
-    or return (undef, $DBI::errstr) if(!$dbh);
-
-##fixme:  initialize the DB if we can't find the table (since, by definition, there's
-# nothing there if we can't select from it...)
-  my $tblsth = $dbh->prepare("SELECT count(*) FROM pg_catalog.pg_class WHERE relkind='r' AND relname=?");
-  my ($tblcount) = $dbh->selectrow_array($tblsth, undef, ('misc'));
-  return (undef,$DBI::errstr) if $dbh->err;
-
-#if ($tblcount == 0) {
-#  # create tables one at a time, checking for each.
-#  return (undef, "check table misc missing");
-#}
-
-
-# Return here if we can't select.
-# This should retrieve the dbversion key.
-  my $sth = $dbh->prepare("SELECT key,value FROM misc WHERE misc_id=1");
-  $sth->execute();
-  return (undef,$DBI::errstr) if ($sth->err);
-
-##fixme:  do stuff to the DB on version mismatch
-# x.y series should upgrade on $DNSDB::VERSION > misc(key=>version)
-# DB should be downward-compatible;  column defaults should give sane (if possibly
-# useless-and-needs-help) values in columns an older software stack doesn't know about.
-
-# See if the select returned anything (or null data).  This should
-# succeed if the select executed, but...
-  $sth->fetchrow();
-  return (undef,$DBI::errstr)  if ($sth->err);
-
-  $sth->finish;
-
-# If we get here, we should be OK.
-  return ($dbh,"DB connection OK");
-} # end connectDB
-
-
-## DNSDB::finish()
-# Cleans up after database handles and so on.
-# Requires a database handle
-sub finish {
-  my $dbh = $_[0];
-  $dbh->disconnect;
-} # end finish
-
-
-## DNSDB::initGlobals()
-# Initialize global variables
-# NB: this does NOT include web-specific session variables!
-# Requires a database handle
-sub initGlobals {
-  my $dbh = shift;
-
-# load record types from database
-  my $sth = $dbh->prepare("SELECT val,name,stdflag FROM rectypes");
-  $sth->execute;
-  while (my ($recval,$recname,$stdflag) = $sth->fetchrow_array()) {
-    $typemap{$recval} = $recname;
-    $reverse_typemap{$recname} = $recval;
-    # now we fill the record validation function hash
-    if ($stdflag < 5) {
-      my $fn = "_validate_$recval";
-      $validators{$recval} = \&$fn;
-    } else {
-      my $fn = "sub { return ('FAIL','Type $recval ($recname) not supported'); }";
-      $validators{$recval} = eval $fn;
-    }
-  }
-} # end initGlobals
-
-
-## DNSDB::initPermissions()
-# Set up permissions global
-# Takes database handle and UID
-sub initPermissions {
-  my $dbh = shift;
-  my $uid = shift;
-
-#  %permissions = $(getPermissions($dbh,'user',$uid));
-  getPermissions($dbh, 'user', $uid, \%permissions);
-
-} # end initPermissions()
-
-
-## DNSDB::getPermissions()
-# Get permissions from DB
-# Requires DB handle, group or user flag, ID, and hashref.
-sub getPermissions {
-  my $dbh = shift;
-  my $type = shift;
-  my $id = shift;
-  my $hash = shift;
-
-  my $sql = qq(
-	SELECT
-	p.admin,p.self_edit,
-	p.group_create,p.group_edit,p.group_delete,
-	p.user_create,p.user_edit,p.user_delete,
-	p.domain_create,p.domain_edit,p.domain_delete,
-	p.record_create,p.record_edit,p.record_delete
-	FROM permissions p
-	);
-  if ($type eq 'group') {
-    $sql .= qq(
-	JOIN groups g ON g.permission_id=p.permission_id
-	WHERE g.group_id=?
-	);
-  } else {
-    $sql .= qq(
-	JOIN users u ON u.permission_id=p.permission_id
-	WHERE u.user_id=?
-	);
-  }
-
-  my $sth = $dbh->prepare($sql);
-
-  $sth->execute($id) or die "argh: ".$sth->errstr;
-
-#  my $permref = $sth->fetchrow_hashref;
-#  return $permref;
-#  $hash = $permref;
-# Eww.  Need to learn how to forcibly drop a hashref onto an existing hash.
-  ($hash->{admin},$hash->{self_edit},
-	$hash->{group_create},$hash->{group_edit},$hash->{group_delete},
-	$hash->{user_create},$hash->{user_edit},$hash->{user_delete},
-	$hash->{domain_create},$hash->{domain_edit},$hash->{domain_delete},
-	$hash->{record_create},$hash->{record_edit},$hash->{record_delete})
-	= $sth->fetchrow_array;
-
-} # end getPermissions()
-
-
-## DNSDB::changePermissions()
-# Update an ACL entry
-# Takes a db handle, type, owner-id, and hashref for the changed permissions.
-sub changePermissions {
-  my $dbh = shift;
-  my $type = shift;
-  my $id = shift;
-  my $newperms = shift;
-  my $inherit = shift || 0;
-
-  my $failmsg = '';
-
-  # see if we're switching from inherited to custom.  for bonus points,
-  # snag the permid and parent permid anyway, since we'll need the permid
-  # to set/alter custom perms, and both if we're switching from custom to
-  # inherited.
-  my $sth = $dbh->prepare("SELECT (u.permission_id=g.permission_id) AS was_inherited,u.permission_id,g.permission_id".
-	" FROM ".($type eq 'user' ? 'users' : 'groups')." u ".
-	" JOIN groups g ON u.".($type eq 'user' ? '' : 'parent_')."group_id=g.group_id ".
-	" WHERE u.".($type eq 'user' ? 'user' : 'group')."_id=?");
-  $sth->execute($id);
-
-  my ($wasinherited,$permid,$parpermid) = $sth->fetchrow_array;
-
-# hack phtoui
-# group id 1 is "special" in that it's it's own parent (err...  possibly.)
-# may make its parent id 0 which doesn't exist, and as a bonus is Perl-false.
-  $wasinherited = 0 if ($type eq 'group' && $id == 1);
-
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  # Wrap all the SQL in a transaction
-  eval {
-    if ($inherit) {
-
-      $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='t',permission_id=? ".
-	"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($parpermid, $id) );
-      $dbh->do("DELETE FROM permissions WHERE permission_id=?", undef, ($permid) );
-
-    } else {
-
-      if ($wasinherited) {	# munge new permission entry in if we're switching from inherited perms
-##fixme: need to add semirecursive bit to properly munge inherited permission ID on subgroups and users
-# ... if'n'when we have groups with fully inherited permissions.
-        # SQL is coo
-	$dbh->do("INSERT INTO permissions ($permlist,".($type eq 'user' ? 'user' : 'group')."_id) ".
-		"SELECT $permlist,? FROM permissions WHERE permission_id=?", undef, ($id,$permid) );
-	($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions ".
-		"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($id) );
-	$dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='f',permission_id=? ".
-		"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($permid, $id) );
-      }
-
-      # and now set the permissions we were passed
-      foreach (@permtypes) {
-	if (defined ($newperms->{$_})) {
-	  $dbh->do("UPDATE permissions SET $_=? WHERE permission_id=?", undef, ($newperms->{$_},$permid) );
-	}
-      }
-
-    } # (inherited->)? custom
-
-    $dbh->commit;
-  }; # end eval
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',"$failmsg: $msg ($permid)");
-  } else {
-    return ('OK',$permid);
-  }
-
-} # end changePermissions()
-
-
-## DNSDB::comparePermissions()
-# Compare two permission hashes
-# Returns '>', '<', '=', '!'
-sub comparePermissions {
-  my $p1 = shift;
-  my $p2 = shift;
-
-  my $retval = '=';	# assume equality until proven otherwise
-
-  no warnings "uninitialized";
-
-  foreach (@permtypes) {
-    next if $p1->{$_} == $p2->{$_};	# equal is good
-    if ($p1->{$_} && !$p2->{$_}) {
-      if ($retval eq '<') {	# if we've already found an unequal pair where
-        $retval = '!';		# $p2 has more access, and we now find a pair
-        last;			# where $p1 has more access, the overall access
-      }				# is neither greater or lesser, it's unequal.
-      $retval = '>';
-    }
-    if (!$p1->{$_} && $p2->{$_}) {
-      if ($retval eq '>') {	# if we've already found an unequal pair where
-        $retval = '!';		# $p1 has more access, and we now find a pair
-        last;			# where $p2 has more access, the overall access
-      }				# is neither greater or lesser, it's unequal.
-      $retval = '<';
-    }
-  }
-  return $retval;
-} # end comparePermissions()
-
-
-## DNSDB::changeGroup()
-# Change group ID of an entity
-# Takes a database handle, entity type, entity ID, and new group ID
-sub changeGroup {
-  my $dbh = shift;
-  my $type = shift;
-  my $id = shift;
-  my $newgrp = shift;
-
-##fixme:  fail on not enough args
-  #return ('FAIL', "Missing 
-
-  if ($type eq 'domain') {
-    $dbh->do("UPDATE domains SET group_id=? WHERE domain_id=?", undef, ($newgrp, $id))
-	or return ('FAIL','Group change failed: '.$dbh->errstr);
-  } elsif ($type eq 'user') {
-    $dbh->do("UPDATE users SET group_id=? WHERE user_id=?", undef, ($newgrp, $id))
-	or return ('FAIL','Group change failed: '.$dbh->errstr);
-  } elsif ($type eq 'group') {
-    $dbh->do("UPDATE groups SET parent_group_id=? WHERE group_id=?", undef, ($newgrp, $id))
-	or return ('FAIL','Group change failed: '.$dbh->errstr);
-  }
-  return ('OK','OK');
-} # end changeGroup()
-
-
-## DNSDB::_log()
-# Log an action
-# Internal sub
-# Takes a database handle and log entry hash containing at least:
-# user_id, group_id, log entry
-# and optionally one or more of:
-# username/email, user full name, domain_id, rdns_id
-##fixme:  convert to trailing hash for user info
-# User info must contain a (user ID OR username)+fullname
-sub _log {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  $args{rdns_id} = 0 if !$args{rdns_id};
-  $args{domain_id} = 0 if !$args{domain_id};
-
-##fixme:  need better way(s?) to snag userinfo for log entries.  don't want to have
-# to pass around yet *another* constant (already passing $dbh, shouldn't need to)
-  my $fullname;
-  if (!$args{user_id}) {
-    ($args{user_id}, $fullname) = $dbh->selectrow_array("SELECT user_id, firstname || ' ' || lastname FROM users".
-	" WHERE username=?", undef, ($args{username}));
-  }
-  if (!$args{username}) {
-    ($args{username}, $fullname) = $dbh->selectrow_array("SELECT username, firstname || ' ' || lastname FROM users".
-	" WHERE user_id=?", undef, ($args{user_id}));
-  }
-  if (!$args{fullname}) {
-    ($fullname) = $dbh->selectrow_array("SELECT firstname || ' ' || lastname FROM users".
-	" WHERE user_id=?", undef, ($args{user_id}));
-  }
-
-  $args{name} = $fullname if !$args{name};
-
-##fixme:  farm out the actual logging to different subs for file, syslog, internal, etc based on config
-  $dbh->do("INSERT INTO log (domain_id,rdns_id,user_id,group_id,email,name,entry) VALUES (?,?,?,?,?,?,?)",
-	undef,
-	($args{domain_id},$args{rdns_id},$args{user_id},$args{group_id},$args{username},$args{name},$args{entry}));
-
-} # end _log
-
-
-##
-## Processing subs
-##
-
-## DNSDB::addDomain()
-# Add a domain
-# Takes a database handle, domain name, numeric group, boolean(ish) state (active/inactive),
-# and user info hash (for logging).
-# Returns a status code and message
-sub addDomain {
-  $errstr = '';
-  my $dbh = shift;
-  return ('FAIL',"Need database handle") if !$dbh;
-  my $domain = shift;
-  return ('FAIL',"Domain must not be blank") if !$domain;
-  my $group = shift;
-  return ('FAIL',"Need group") if !defined($group);
-  my $state = shift;
-  return ('FAIL',"Need domain status") if !defined($state);
-
-  my %userinfo = @_;	# remaining bits.
-# user ID, username, user full name
-
-  $state = 1 if $state =~ /^active$/;
-  $state = 1 if $state =~ /^on$/;
-  $state = 0 if $state =~ /^inactive$/;
-  $state = 0 if $state =~ /^off$/;
-
-  return ('FAIL',"Invalid domain status") if $state !~ /^\d+$/;
-
-  return ('FAIL', "Invalid characters in domain") if $domain !~ /^[a-zA-Z0-9_.-]+$/;
-
-  my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
-  my $dom_id;
-
-# quick check to start to see if we've already got one
-  $sth->execute($domain);
-  ($dom_id) = $sth->fetchrow_array;
-
-  return ('FAIL', "Domain already exists") if $dom_id;
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  # Wrap all the SQL in a transaction
-  eval {
-    # insert the domain...
-    $dbh->do("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)", undef, ($domain, $group, $state));
-
-    # get the ID...
-    ($dom_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE domain=?", undef, ($domain));
-
-    _log($dbh, (domain_id => $dom_id, user_id => $userinfo{id}, group_id => $group, username => $userinfo{username},
-	entry => "Added ".($state ? 'active' : 'inactive')." domain $domain"));
-
-    # ... and now we construct the standard records from the default set.  NB:  group should be variable.
-    my $sth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
-    my $sth_in = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,distance,weight,port,ttl)".
-	" VALUES ($dom_id,?,?,?,?,?,?,?)");
-    $sth->execute($group);
-    while (my ($host,$type,$val,$dist,$weight,$port,$ttl) = $sth->fetchrow_array()) {
-      $host =~ s/DOMAIN/$domain/g;
-      $val =~ s/DOMAIN/$domain/g;
-      $sth_in->execute($host,$type,$val,$dist,$weight,$port,$ttl);
-      if ($typemap{$type} eq 'SOA') {
-	my @tmp1 = split /:/, $host;
-	my @tmp2 = split /:/, $val;
-	_log($dbh, (domain_id => $dom_id, user_id => $userinfo{id}, group_id => $group,
-		username => $userinfo{username}, entry =>
-		"[new $domain] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
-		"[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl"));
-      } else {
-	my $logentry = "[new $domain] Added record '$host $typemap{$type}";
-	$logentry .= " [distance $dist]" if $typemap{$type} eq 'MX';
-	$logentry .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$type} eq 'SRV';
-	_log($dbh, (domain_id => $dom_id, user_id => $userinfo{id}, group_id => $group,
-		username => $userinfo{username}, entry =>
-		$logentry." $val', TTL $ttl"));
-      }
-    }
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',$msg);
-  } else {
-    return ('OK',$dom_id);
-  }
-} # end addDomain
-
-
-## DNSDB::delDomain()
-# Delete a domain.
-# for now, just delete the records, then the domain.
-# later we may want to archive it in some way instead (status code 2, for example?)
-sub delDomain {
-  my $dbh = shift;
-  my $domid = shift;
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  my $failmsg = '';
-
-  # Wrap all the SQL in a transaction
-  eval {
-    my $sth = $dbh->prepare("delete from records where domain_id=?");
-    $failmsg = "Failure removing domain records";
-    $sth->execute($domid);
-    $sth = $dbh->prepare("delete from domains where domain_id=?");
-    $failmsg = "Failure removing domain";
-    $sth->execute($domid);
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',"$failmsg: $msg");
-  } else {
-    return ('OK','OK');
-  }
-
-} # end delDomain()
-
-
-## DNSDB::domainName()
-# Return the domain name based on a domain ID
-# Takes a database handle and the domain ID
-# Returns the domain name or undef on failure
-sub domainName {
-  $errstr = '';
-  my $dbh = shift;
-  my $domid = shift;
-  my ($domname) = $dbh->selectrow_array("SELECT domain FROM domains WHERE domain_id=?", undef, ($domid) );
-  $errstr = $DBI::errstr if !$domname;
-  return $domname if $domname;
-} # end domainName()
-
-
-## DNSDB::revName()
-# Return the reverse zone name based on an rDNS ID
-# Takes a database handle and the rDNS ID
-# Returns the reverse zone name or undef on failure
-sub revName {
-  $errstr = '';
-  my $dbh = shift;
-  my $revid = shift;
-  my ($revname) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id=?", undef, ($revid) );
-  $errstr = $DBI::errstr if !$revname;
-  return $revname if $revname;
-} # end revName()
-
-
-## DNSDB::domainID()
-# Takes a database handle and domain name
-# Returns the domain ID number
-sub domainID {
-  $errstr = '';
-  my $dbh = shift;
-  my $domain = shift;
-  my ($domid) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE domain=?", undef, ($domain) );
-  $errstr = $DBI::errstr if !$domid;
-  return $domid if $domid;
-} # end domainID()
-
-
-## DNSDB::addRDNS
-# Adds a reverse DNS zone
-# Takes a database handle, CIDR block, numeric group, boolean(ish) state (active/inactive),
-# and user info hash (for logging).
-# Returns a status code and message
-sub addRDNS {
-  my $dbh = shift;
-  my $zone = NetAddr::IP->new(shift);
-  return ('FAIL',"Zone name must be a valid CIDR netblock") unless ($zone && $zone->addr !~ /^0/);
-  my $revpatt = shift;
-  my $group = shift;
-  my $state = shift;
-
-  my %userinfo = @_;	# remaining bits.
-# user ID, username, user full name
-
-  $state = 1 if $state =~ /^active$/;
-  $state = 1 if $state =~ /^on$/;
-  $state = 0 if $state =~ /^inactive$/;
-  $state = 0 if $state =~ /^off$/;
-
-  return ('FAIL',"Invalid zone status") if $state !~ /^\d+$/;
-
-# quick check to start to see if we've already got one
-  my ($rdns_id) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revzone=?", undef, ("$zone"));
-
-  return ('FAIL', "Zone already exists") if $rdns_id;
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-#$dbh->selectrow_array("SELECT currval('users_user_id_seq')");
-  # Wrap all the SQL in a transaction
-  eval {
-    # insert the domain...
-    $dbh->do("INSERT INTO revzones (revnet,group_id,status) VALUES (?,?,?)", undef, ($zone, $group, $state));
-
-    # get the ID...
-    ($rdns_id) = $dbh->selectrow_array("SELECT currval('revzones_rdns_id_seq')");
-
-    _log($dbh, (rdns_id => $rdns_id, user_id => $userinfo{id}, group_id => $group, username => $userinfo{name},
-	entry => "Added ".($state ? 'active' : 'inactive')." reverse zone $zone"));
-
-    # ... and now we construct the standard records from the default set.  NB:  group should be variable.
-    my $sth = $dbh->prepare("SELECT host,type,val,ttl FROM default_rev_records WHERE group_id=?");
-    my $sth_in = $dbh->prepare("INSERT INTO records (rdns_id,host,type,val,ttl)".
-	" VALUES ($rdns_id,?,?,?,?)");
-    $sth->execute($group);
-    while (my ($host,$type,$val,$ttl) = $sth->fetchrow_array()) {
-      $host =~ s/ADMINDOMAIN/$config{domain}/g;
-##work
-# - replace ZONE in $val
-# - skip records not appropriate for the zone (skip A+PTR on v6 zones, and AAAA+PTR on v4 zones)
-#      $val =~ s/DOMAIN/$domain/g;
-      $sth_in->execute($host,$type,$val,$ttl);
-      if ($typemap{$type} eq 'SOA') {
-	my @tmp1 = split /:/, $host;
-	my @tmp2 = split /:/, $val;
-	_log($dbh, (rdns_id => $rdns_id, user_id => $userinfo{id}, group_id => $group,
-		username => $userinfo{name}, entry =>
-		"[new $zone] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
-		"[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl"));
-      } else {
-	my $logentry = "[new $zone] Added record '$host $typemap{$type}";
-#	$logentry .= " [distance $dist]" if $typemap{$type} eq 'MX';
-#	$logentry .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$type} eq 'SRV';
-	_log($dbh, (rdns_id => $rdns_id, user_id => $userinfo{id}, group_id => $group,
-		username => $userinfo{name}, entry =>
-		$logentry." $val', TTL $ttl"));
-      }
-    }
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',$msg);
-  } else {
-    return ('OK',$rdns_id);
-  }
-
-} # end addRDNS()
-
-
-## DNSDB::getZoneCount
-# Get count of zones in group or groups
-# Takes a database handle and hash containing:
-#  - the "current" group
-#  - an array of "acceptable" groups
-#  - a flag for forward/reverse zones
-#  - Optionally accept a "starts with" and/or "contains" filter argument
-# Returns an integer count of the resulting zone list.
-sub getZoneCount {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  my @filterargs;
-  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
-  push @filterargs, "^$args{startwith}" if $args{startwith};
-  $args{filter} =~ s/\./\[\.\]/g if $args{filter};	# only match literal dots, usually in reverse zones
-  push @filterargs, $args{filter} if $args{filter};
-
-  my $sql;
-  # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
-  if ($args{revrec} eq 'n') {
-    $sql = "SELECT count(*) FROM domains".
-	" WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
-	($args{startwith} ? " AND domain ~* ?" : '').
-	($args{filter} ? " AND domain ~* ?" : '');
-  } else {
-    $sql = "SELECT count(*) FROM revzones".
-	" WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
-	($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
-	($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
-  }
-  my ($count) = $dbh->selectrow_array($sql, undef, @filterargs);
-  return $count;
-} # end getZoneCount()
-
-
-## DNSDB::getZoneList()
-# Get a list of zones in the specified group(s)
-# Takes the same arguments as getZoneCount() above
-# Returns a reference to an array of hashrefs suitable for feeding to HTML::Template
-sub getZoneList {
-  my $dbh = shift;
-
-  my %args = @_;
-
-  my @zonelist;
-
-  $args{sortorder} = 'ASC' if !grep $args{sortorder}, ('ASC','DESC');
-  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
-
-  my @filterargs;
-  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
-  push @filterargs, "^$args{startwith}" if $args{startwith};
-  $args{filter} =~ s/\./\[\.\]/g if $args{filter};	# only match literal dots, usually in reverse zones
-  push @filterargs, $args{filter} if $args{filter};
-
-  my $sql;
-  # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
-  if ($args{revrec} eq 'n') {
-    $args{sortby} = 'domain' if !grep $args{sortby}, ('revnet','group','status');
-    $sql = "SELECT domain_id,domain,status,groups.group_name AS group FROM domains".
-	" INNER JOIN groups ON domains.group_id=groups.group_id".
-	" WHERE domains.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
-	($args{startwith} ? " AND domain ~* ?" : '').
-	($args{filter} ? " AND domain ~* ?" : '');
-  } else {
-##fixme:  arguably startwith here is irrelevant.  depends on the UI though.
-    $args{sortby} = 'revnet' if !grep $args{sortby}, ('domain','group','status');
-    $sql = "SELECT rdns_id,revnet,status,groups.group_name AS group FROM revzones".
-	" INNER JOIN groups ON revzones.group_id=groups.group_id".
-	" WHERE revzones.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
-	($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
-	($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
-  }
-  # A common tail.
-  $sql .= " ORDER BY ".($args{sortby} eq 'group' ? 'groups.group_name' : $args{sortby})." $args{sortorder} ".
-	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage}".
-	" OFFSET ".$args{offset}*$config{perpage});
-  my $sth = $dbh->prepare($sql);
-  $sth->execute(@filterargs);
-  my $rownum = 0;
-
-  while (my @data = $sth->fetchrow_array) {
-    my %row;
-    $row{domainid} = $data[0];
-    $row{domain} = $data[1];
-    $row{status} = $data[2];
-    $row{group} = $data[3];
-    push @zonelist, \%row;
-  }
-
-  return \@zonelist;
-} # end getZoneList()
-
-
-## DNSDB::addGroup()
-# Add a group
-# Takes a database handle, group name, parent group, hashref for permissions,
-# and optional template-vs-cloneme flag
-# Returns a status code and message
-sub addGroup {
-  $errstr = '';
-  my $dbh = shift;
-  my $groupname = shift;
-  my $pargroup = shift;
-  my $permissions = shift;
-
-  # 0 indicates "custom", hardcoded.
-  # Any other value clones that group's default records, if it exists.
-  my $inherit = shift || 0;	
-##fixme:  need a flag to indicate clone records or <?> ?
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE group_name=?");
-  my $group_id;
-
-# quick check to start to see if we've already got one
-  $sth->execute($groupname);
-  ($group_id) = $sth->fetchrow_array;
-
-  return ('FAIL', "Group already exists") if $group_id;
-
-  # Wrap all the SQL in a transaction
-  eval {
-    $sth = $dbh->prepare("INSERT INTO groups (parent_group_id,group_name) VALUES (?,?)");
-    $sth->execute($pargroup,$groupname);
-
-    my ($groupid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($groupname));
-
-# Permissions
-    if ($inherit) {
-    } else {
-      my @permvals;
-      foreach (@permtypes) {
-	if (!defined ($permissions->{$_})) {
-	  push @permvals, 0;
-	} else {
-	  push @permvals, $permissions->{$_};
-	}
-      }
-
-      $sth = $dbh->prepare("INSERT INTO permissions (group_id,$permlist) values (?".',?'x($#permtypes+1).")");
-      $sth->execute($groupid,@permvals);
-
-      $sth = $dbh->prepare("SELECT permission_id FROM permissions WHERE group_id=?");
-      $sth->execute($groupid);
-      my ($permid) = $sth->fetchrow_array();
-
-      $dbh->do("UPDATE groups SET permission_id=$permid WHERE group_id=$groupid");
-    } # done permission fiddling
-
-# Default records
-    my $sthf = $dbh->prepare("INSERT INTO default_records (group_id,host,type,val,distance,weight,port,ttl) ".
-	"VALUES ($groupid,?,?,?,?,?,?,?)");
-    my $sthr = $dbh->prepare("INSERT INTO default_rev_records (group_id,host,type,val,ttl) ".
-	"VALUES ($groupid,?,?,?,?)");
-    if ($inherit) {
-      # Duplicate records from parent.  Actually relying on inherited records feels
-      # very fragile, and it would be problematic to roll over at a later time.
-      my $sth2 = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
-      $sth2->execute($pargroup);
-      while (my @clonedata = $sth2->fetchrow_array) {
-	$sthf->execute(@clonedata);
-      }
-      # And now the reverse records
-      $sth2 = $dbh->prepare("SELECT group_id,host,type,val,ttl FROM default_rev_records WHERE group_id=?");
-      $sth2->execute($pargroup);
-      while (my @clonedata = $sth2->fetchrow_array) {
-	$sthr->execute(@clonedata);
-      }
-    } else {
-##fixme: Hardcoding is Bad, mmmmkaaaay?
-      # reasonable basic defaults for SOA, MX, NS, and minimal hosting
-      # could load from a config file, but somewhere along the line we need hardcoded bits.
-      $sthf->execute('ns1.example.com:hostmaster.example.com', 6, '10800:3600:604800:10800', 0, 0, 0, 86400);
-      $sthf->execute('DOMAIN', 1, '192.168.4.2', 0, 0, 0, 7200);
-      $sthf->execute('DOMAIN', 15, 'mx.example.com', 10, 0, 0, 7200);
-      $sthf->execute('DOMAIN', 2, 'ns1.example.com', 0, 0, 0, 7200);
-      $sthf->execute('DOMAIN', 2, 'ns2.example.com', 0, 0, 0, 7200);
-      $sthf->execute('www.DOMAIN', 5, 'DOMAIN', 0, 0, 0, 7200);
-      # reasonable basic defaults for generic reverse zone.  Same as initial SQL tabledef.
-      $sthr->execute('hostmaster.ADMINDOMAIN:ns1.ADMINDOMAIN', 6, '10800:3600:604800:10800', 86400);
-      $sthr->execute('unused-%r.ADMINDOMAIN', 65283, 'ZONE', 3600);
-    }
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',$msg);
-  } else {
-    return ('OK','OK');
-  }
-
-} # end addGroup()
-
-
-## DNSDB::delGroup()
-# Delete a group.
-# Takes a group ID
-# Returns a status code and message
-sub delGroup {
-  my $dbh = shift;
-  my $groupid = shift;
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-##fixme:  locate "knowable" error conditions and deal with them before the eval
-# ... or inside, whatever.
-# -> domains still exist in group
-# -> ...
-  my $failmsg = '';
-
-  # Wrap all the SQL in a transaction
-  eval {
-    my $sth = $dbh->prepare("SELECT count(*) FROM domains WHERE group_id=?");
-    $sth->execute($groupid);
-    my ($domcnt) = $sth->fetchrow_array;
-    $failmsg = "Can't remove group ".groupName($dbh,$groupid);
-    die "$domcnt domains still in group\n" if $domcnt;
-
-    $sth = $dbh->prepare("delete from default_records where group_id=?");
-    $failmsg = "Failed to delete default records for ".groupName($dbh,$groupid);
-    $sth->execute($groupid);
-    $sth = $dbh->prepare("delete from groups where group_id=?");
-    $failmsg = "Failed to remove group ".groupName($dbh,$groupid);
-    $sth->execute($groupid);
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',"$failmsg: $msg");
-  } else {
-    return ('OK','OK');
-  }
-} # end delGroup()
-
-
-## DNSDB::getChildren()
-# Get a list of all groups whose parent^n is group <n>
-# Takes a database handle, group ID, reference to an array to put the group IDs in,
-# and an optional flag to return only immediate children or all children-of-children
-# default to returning all children
-# Calls itself
-sub getChildren {
-  $errstr = '';
-  my $dbh = shift;
-  my $rootgroup = shift;
-  my $groupdest = shift;
-  my $immed = shift || 'all';
-
-  # special break for default group;  otherwise we get stuck.
-  if ($rootgroup == 1) {
-    # by definition, group 1 is the Root Of All Groups
-    my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE NOT (group_id=1)".
-	($immed ne 'all' ? " AND parent_group_id=1" : ''));
-    $sth->execute;
-    while (my @this = $sth->fetchrow_array) {
-      push @$groupdest, @this;
-    }
-  } else {
-    my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE parent_group_id=?");
-    $sth->execute($rootgroup);
-    return if $sth->rows == 0;
-    my @grouplist;
-    while (my ($group) = $sth->fetchrow_array) {
-      push @$groupdest, $group;
-      getChildren($dbh,$group,$groupdest) if $immed eq 'all';
-    }
-  }
-} # end getChildren()
-
-
-## DNSDB::groupName()
-# Return the group name based on a group ID
-# Takes a database handle and the group ID
-# Returns the group name or undef on failure
-sub groupName {
-  $errstr = '';
-  my $dbh = shift;
-  my $groupid = shift;
-  my $sth = $dbh->prepare("SELECT group_name FROM groups WHERE group_id=?");
-  $sth->execute($groupid);
-  my ($groupname) = $sth->fetchrow_array();
-  $errstr = $DBI::errstr if !$groupname;
-  return $groupname if $groupname;
-} # end groupName
-
-
-## DNSDB::groupID()
-# Return the group ID based on the group name
-# Takes a database handle and the group name
-# Returns the group ID or undef on failure
-sub groupID {
-  $errstr = '';
-  my $dbh = shift;
-  my $group = shift;
-  my ($grpid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group=?", undef, ($group) );
-  $errstr = $DBI::errstr if !$grpid;
-  return $grpid if $grpid;
-} # end groupID()
-
-
-## DNSDB::addUser()
-# Add a user.
-# Takes a DB handle, username, group ID, password, state (active/inactive).
-# Optionally accepts:
-#   user type (user/admin)	- defaults to user
-#   permissions string		- defaults to inherit from group
-#      three valid forms:
-#	i		     - Inherit permissions
-#	c:<user_id>	     - Clone permissions from <user_id>
-#	C:<permission list>  - Set these specific permissions
-#   first name			- defaults to username
-#   last name			- defaults to blank
-#   phone			- defaults to blank (could put other data within column def)
-# Returns (OK,<uid>) on success, (FAIL,<message>) on failure
-sub addUser {
-  $errstr = '';
-  my $dbh = shift;
-  my $username = shift;
-  my $group = shift;
-  my $pass = shift;
-  my $state = shift;
-
-  return ('FAIL', "Missing one or more required entries") if !defined($state);
-  return ('FAIL', "Username must not be blank") if !$username;
-
-  my $type = shift || 'u';	# create limited users by default - fwiw, not sure yet how this will interact with ACLs
-  
-  my $permstring = shift || 'i';	# default is to inhert permissions from group
-
-  my $fname = shift || $username;
-  my $lname = shift || '';
-  my $phone = shift || '';	# not going format-check
-
-  my $sth = $dbh->prepare("SELECT user_id FROM users WHERE username=?");
-  my $user_id;
-
-# quick check to start to see if we've already got one
-  $sth->execute($username);
-  ($user_id) = $sth->fetchrow_array;
-
-  return ('FAIL', "User already exists") if $user_id;
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  my $failmsg = '';
-
-  # Wrap all the SQL in a transaction
-  eval {
-    # insert the user...  note we set inherited perms by default since
-    # it's simple and cleans up some other bits of state
-    my $sth = $dbh->prepare("INSERT INTO users ".
-	"(group_id,username,password,firstname,lastname,phone,type,status,permission_id,inherit_perm) ".
-	"VALUES (?,?,?,?,?,?,?,?,(SELECT permission_id FROM permissions WHERE group_id=?),'t')");
-    $sth->execute($group,$username,unix_md5_crypt($pass),$fname,$lname,$phone,$type,$state,$group);
-
-    # get the ID...
-    ($user_id) = $dbh->selectrow_array("SELECT currval('users_user_id_seq')");
-
-# Permissions!  Gotta set'em all!
-    die "Invalid permission string $permstring"
-	if $permstring !~ /^(?:
-		i	# inherit
-		|c:\d+	# clone
-			# custom.  no, the leading , is not a typo
-		|C:(?:,(?:group|user|domain|record|self)_(?:edit|create|delete))*
-		)$/x;
-# bleh.  I'd call another function to do my dirty work, but we're in the middle of a transaction already.
-    if ($permstring ne 'i') {
-      # for cloned or custom permissions, we have to create a new permissions entry.
-      my $clonesrc = $group;
-      if ($permstring =~ /^c:(\d+)/) { $clonesrc = $1; }
-      $dbh->do("INSERT INTO permissions ($permlist,user_id) ".
-	"SELECT $permlist,? FROM permissions WHERE permission_id=".
-	"(SELECT permission_id FROM permissions WHERE ".($permstring =~ /^c:/ ? 'user' : 'group')."_id=?)",
-	undef, ($user_id,$clonesrc) );
-      $dbh->do("UPDATE users SET permission_id=".
-	"(SELECT permission_id FROM permissions WHERE user_id=?) ".
-	"WHERE user_id=?", undef, ($user_id, $user_id) );
-    }
-    if ($permstring =~ /^C:/) {
-      # finally for custom permissions, we set the passed-in permissions (and unset
-      # any that might have been brought in by the clone operation above)
-      my ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions WHERE user_id=?",
-	undef, ($user_id) );
-      foreach (@permtypes) {
-	if ($permstring =~ /,$_/) {
-	  $dbh->do("UPDATE permissions SET $_='t' WHERE permission_id=?", undef, ($permid) );
-	} else {
-	  $dbh->do("UPDATE permissions SET $_='f' WHERE permission_id=?", undef, ($permid) );
-	}
-      }
-    }
-
-    $dbh->do("UPDATE users SET inherit_perm='n' WHERE user_id=?", undef, ($user_id) );
-
-##fixme: add another table to hold name/email for log table?
-
-    # once we get here, we should have suceeded.
-    $dbh->commit;
-  }; # end eval
-
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',$msg." $failmsg");
-  } else {
-    return ('OK',$user_id);
-  }
-} # end addUser
-
-
-## DNSDB::checkUser()
-# Check user/pass combo on login
-sub checkUser {
-  my $dbh = shift;
-  my $user = shift;
-  my $inpass = shift;
-
-  my $sth = $dbh->prepare("SELECT user_id,group_id,password,firstname,lastname FROM users WHERE username=?");
-  $sth->execute($user);
-  my ($uid,$gid,$pass,$fname,$lname) = $sth->fetchrow_array;
-  my $loginfailed = 1 if !defined($uid);
-
-  if ($pass =~ m|^\$1\$([A-Za-z0-9/.]+)\$|) {
-    $loginfailed = 1 if $pass ne unix_md5_crypt($inpass,$1);
-  } else {
-    $loginfailed = 1 if $pass ne $inpass;
-  }
-
-  # nnnngggg
-  return ($uid, $gid);
-} # end checkUser
-
-
-## DNSDB:: updateUser()
-# Update general data about user
-sub updateUser {
-  my $dbh = shift;
-
-##fixme:  tweak calling convention so that we can update any given bit of data
-  my $uid = shift;
-  my $username = shift;
-  my $group = shift;
-  my $pass = shift;
-  my $state = shift;
-  my $type = shift || 'u';
-  my $fname = shift || $username;
-  my $lname = shift || '';
-  my $phone = shift || '';	# not going format-check
-
-  my $failmsg = '';
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  my $sth;
-
-  # Password can be left blank;  if so we assume there's one on file.
-  # Actual blank passwords are bad, mm'kay?
-  if (!$pass) {
-    $sth = $dbh->prepare("SELECT password FROM users WHERE user_id=?");
-    $sth->execute($uid);
-    ($pass) = $sth->fetchrow_array;
-  } else {
-    $pass = unix_md5_crypt($pass);
-  }
-
-  eval {
-    my $sth = $dbh->prepare(q(
-	UPDATE users
-	SET username=?, password=?, firstname=?, lastname=?, phone=?, type=?, status=?
-	WHERE user_id=?
-	)
-      );
-    $sth->execute($username, $pass, $fname, $lname, $phone, $type, $state, $uid);
-    $dbh->commit;
-  };
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',"$failmsg: $msg");
-  } else {
-    return ('OK','OK');
-  }
-} # end updateUser()
-
-
-## DNSDB::delUser()
-#
-sub delUser {
-  my $dbh = shift;
-  return ('FAIL',"Need database handle") if !$dbh;
-  my $userid = shift;
-  return ('FAIL',"Missing userid") if !defined($userid);
-
-  my $sth = $dbh->prepare("delete from users where user_id=?");
-  $sth->execute($userid);
-
-  return ('FAIL',"Couldn't remove user: ".$sth->errstr) if $sth->err;
-
-  return ('OK','OK');
-
-} # end delUser
-
-
-## DNSDB::userFullName()
-# Return a pretty string!
-# Takes a user_id and optional printf-ish string to indicate which pieces where:
-# %u for the username
-# %f for the first name
-# %l for the last name
-# All other text in the passed string will be left as-is.
-##fixme:  need a "smart" option too, so that missing/null/blank first/last names don't give funky output
-sub userFullName {
-  $errstr = '';
-  my $dbh = shift;
-  my $userid = shift;
-  my $fullformat = shift || '%f %l (%u)';
-  my $sth = $dbh->prepare("select username,firstname,lastname from users where user_id=?");
-  $sth->execute($userid);
-  my ($uname,$fname,$lname) = $sth->fetchrow_array();
-  $errstr = $DBI::errstr if !$uname;
-
-  $fullformat =~ s/\%u/$uname/g;
-  $fullformat =~ s/\%f/$fname/g;
-  $fullformat =~ s/\%l/$lname/g;
-
-  return $fullformat;
-} # end userFullName
-
-
-## DNSDB::userStatus()
-# Sets and/or returns a user's status
-# Takes a database handle, user ID and optionally a status argument
-# Returns undef on errors.
-sub userStatus {
-  my $dbh = shift;
-  my $id = shift;
-  my $newstatus = shift;
-
-  return undef if $id !~ /^\d+$/;
-
-  my $sth;
-
-# ooo, fun!  let's see what we were passed for status
-  if ($newstatus) {
-    $sth = $dbh->prepare("update users set status=? where user_id=?");
-    # ass-u-me caller knows what's going on in full
-    if ($newstatus =~ /^[01]$/) {	# only two valid for now.
-      $sth->execute($newstatus,$id);
-    } elsif ($newstatus =~ /^usero(?:n|ff)$/) {
-      $sth->execute(($newstatus eq 'useron' ? 1 : 0),$id);
-    }
-  }
-
-  $sth = $dbh->prepare("select status from users where user_id=?");
-  $sth->execute($id);
-  my ($status) = $sth->fetchrow_array;
-  return $status;
-} # end userStatus()
-
-
-## DNSDB::getUserData()
-# Get misc user data for display
-sub getUserData {
-  my $dbh = shift;
-  my $uid = shift;
-
-  my $sth = $dbh->prepare("SELECT group_id,username,firstname,lastname,phone,type,status,inherit_perm ".
-	"FROM users WHERE user_id=?");
-  $sth->execute($uid);
-  return $sth->fetchrow_hashref();
-
-} # end getUserData()
-
-
-## DNSDB::getSOA()
-# Return all suitable fields from an SOA record in separate elements of a hash
-# Takes a database handle, default/live flag, domain/reverse flag, and parent ID
-sub getSOA {
-  $errstr = '';
-  my $dbh = shift;
-  my $def = shift;
-  my $rev = shift;
-  my $id = shift;
-  my %ret;
-
-  # (ab)use distance and weight columns to store SOA data?  can't for default_rev_records...
-  # - should really attach serial to the zone parent somewhere
-
-  my $sql = "SELECT record_id,host,val,ttl from "._rectable($def,$rev).
-	" WHERE "._recparent($def,$rev)." = ? AND type=$reverse_typemap{SOA}";
-
-  my $sth = $dbh->prepare($sql);
-  $sth->execute($id);
-##fixme:  stick a flag somewhere if the record doesn't exist.  by the API, this is an impossible case, but...
-
-  my ($recid,$host,$val,$ttl) = $sth->fetchrow_array() or return;
-  my ($contact,$prins) = split /:/, $host;
-  my ($refresh,$retry,$expire,$minttl) = split /:/, $val;
-
-  $ret{recid}	= $recid;
-  $ret{ttl}	= $ttl;
-#  $ret{serial}	= $serial;	# ca't use distance for serial with default_rev_records
-  $ret{prins}	= $prins;
-  $ret{contact}	= $contact;
-  $ret{refresh}	= $refresh;
-  $ret{retry}	= $retry;
-  $ret{expire}	= $expire;
-  $ret{minttl}	= $minttl;
-
-  return %ret;
-} # end getSOA()
-
-
-## DNSDB::updateSOA()
-# Update the specified SOA record
-# Takes a database handle, default/live flag, forward/reverse flag, and SOA data hash
-sub updateSOA {
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-
-  my %soa = @_;
-
-##fixme: data validation: make sure {recid} is really the SOA for {parent}
-  my $sql = "UPDATE "._rectable($defrec, $revrec)." SET host=?, val=?, ttl=? WHERE record_id=? AND type=6";
-  $dbh->do($sql, undef, ("$soa{contact}:$soa{prins}", "$soa{refresh}:$soa{retry}:$soa{expire}:$soa{minttl}",
-        $soa{ttl}, $soa{recid}));
-
-} # end updateSOA()
-
-
-## DNSDB::getRecLine()
-# Return all data fields for a zone record in separate elements of a hash
-# Takes a database handle, default/live flag, forward/reverse flag, and record ID
-sub getRecLine {
-  $errstr = '';
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-  my $id = shift;
-
-  my $sql = "SELECT record_id,host,type,val,ttl".($revrec eq 'n' ? ',distance,weight,port' : '').
-	(($defrec eq 'y') ? ',group_id FROM ' : ',domain_id,rdns_id FROM ').
-	_rectable($defrec,$revrec)." WHERE record_id=?";
-  my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
-
-  if ($dbh->err) {
-    $errstr = $DBI::errstr;
-    return undef;
-  }
-
-  if (!$ret) {
-    $errstr = "No such record";
-    return undef;
-  }
-
-  # explicitly set a parent id
-  if ($defrec eq 'y') {
-    $ret->{parid} = $ret->{group_id};
-  } else {
-    $ret->{parid} = (($revrec eq 'n') ? $ret->{domain_id} : $ret->{rdns_id});
-    # and a secondary if we have a custom type that lives in both a forward and reverse zone
-    $ret->{secid} = (($revrec eq 'y') ? $ret->{domain_id} : $ret->{rdns_id}) if $ret->{type} > 65279;
-  }
-
-  return $ret;
-}
-
-
-##fixme: should use above (getRecLine()) to get lines for below?
-## DNSDB::getDomRecs()
-# Return records for a domain
-# Takes a database handle, default/live flag, group/domain ID, start,
-# number of records, sort field, and sort order
-# Returns a reference to an array of hashes
-sub getDomRecs {
-  $errstr = '';
-  my $dbh = shift;
-  my $def = shift;
-  my $rev = shift;
-  my $id = shift;
-  my $nrecs = shift || 'all';
-  my $nstart = shift || 0;
-
-## for order, need to map input to column names
-  my $order = shift || 'host';
-  my $direction = shift || 'ASC';
-
-  my $filter = shift || '';
-
-  my $sql = "SELECT r.record_id,r.host,r.type,r.val,r.ttl";
-  $sql .= ",r.distance,r.weight,r.port" if $rev eq 'n';
-  $sql .= " FROM "._rectable($def,$rev)." r ";
-
-  # whee!  multisort means just passing comma-separated fields in sortby!
-  my $newsort = '';
-  foreach my $sf (split /,/, $order) {
-    $sf = "r.$sf";
-    $sf =~ s/r\.type/t.alphaorder/;
-    $newsort .= ",$sf";
-  }
-  $newsort =~ s/^,//;
-
-  $sql .= "INNER JOIN rectypes t ON r.type=t.val ";	# for sorting by type alphabetically
-  $sql .= "WHERE "._recparent($def,$rev)." = ?";
-  $sql .= " AND NOT r.type=$reverse_typemap{SOA}";
-  $sql .= " AND host ~* ?" if $filter;
-  # use alphaorder column for "correct" ordering of sort-by-type instead of DNS RR type number
-  $sql .= " ORDER BY $newsort $direction";
-
-  my @bindvars = ($id);
-  push @bindvars, $filter if $filter;
-
-  # just to be ultraparanoid about SQL injection vectors
-  if ($nstart ne 'all') {
-    $sql .= " LIMIT ? OFFSET ?";
-    push @bindvars, $nrecs;
-    push @bindvars, ($nstart*$nrecs);
-  }
-  my $sth = $dbh->prepare($sql) or warn $dbh->errstr;
-  $sth->execute(@bindvars) or warn "$sql: ".$sth->errstr;
-
-  my @retbase;
-  while (my $ref = $sth->fetchrow_hashref()) {
-    push @retbase, $ref;
-  }
-
-  my $ret = \@retbase;
-  return $ret;
-} # end getDomRecs()
-
-
-## DNSDB::getRecCount()
-# Return count of non-SOA records in zone (or default records in a group)
-# Takes a database handle, default/live flag, reverse/forward flag, group/domain ID,
-# and optional filtering modifier
-# Returns the count
-sub getRecCount {
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-  my $id = shift;
-  my $filter = shift || '';
-
-  # keep the nasties down, since we can't ?-sub this bit.  :/
-  # note this is chars allowed in DNS hostnames
-  $filter =~ s/[^a-zA-Z0-9_.:-]//g;
-
-  my @bindvars = ($id);
-  push @bindvars, $filter if $filter;
-  my $sql = "SELECT count(*) FROM ".
-	_rectable($defrec,$revrec).
-	" WHERE "._recparent($defrec,$revrec)."=? ".
-	"AND NOT type=$reverse_typemap{SOA}".
-	($filter ? " AND host ~* ?" : '');
-  my ($count) = $dbh->selectrow_array($sql, undef, (@bindvars) );
-
-  return $count;
-
-} # end getRecCount()
-
-
-## DNSDB::addRec()
-# Add a new record to a domain or a group's default records
-# Takes a database handle, default/live flag, group/domain ID,
-# host, type, value, and TTL
-# Some types require additional detail: "distance" for MX and SRV,
-# and weight/port for SRV
-# Returns a status code and detail message in case of error
-##fixme:  pass a hash with the record data, not a series of separate values
-sub addRec {
-  $errstr = '';
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-  my $id = shift;	# parent (group_id for defrecs, rdns_id for reverse records,
-			# domain_id for domain records)
-
-  my $host = shift;
-  my $rectype = shift;	# reference so we can coerce it if "+"-types can't find both zones
-  my $val = shift;
-  my $ttl = shift;
-
-  # Spaces are evil.
-  $host =~ s/^\s+//;
-  $host =~ s/\s+$//;
-  if ($typemap{$rectype} ne 'TXT') {
-    # Leading or trailng spaces could be legit in TXT records.
-    $val =~ s/^\s+//;
-    $val =~ s/\s+$//;
-  }
-
-  # Validation
-  my $addr = NetAddr::IP->new($val);
-  if ($rectype == $reverse_typemap{A}) {
-    return ('FAIL',$typemap{$rectype}." record must be a valid IPv4 address")
-	unless $addr && !$addr->{isv6};
-  }
-  if ($rectype == $reverse_typemap{AAAA}) {
-    return ('FAIL',$typemap{$rectype}." record must be a valid IPv6 address")
-	unless $addr && $addr->{isv6};
-  }
-
-  my $domid = 0;
-  my $revid = 0;
-
-  my $retcode = 'OK';	# assume everything will go OK
-  my $retmsg = '';
-
-  # do simple validation first
-  return ('FAIL', "TTL must be numeric") unless $ttl =~ /^\d+$/;
-
-  # Quick check on hostname parts.  Note the regex is more forgiving than the error message;
-  # domain names technically are case-insensitive, and we use printf-like % codes for a couple
-  # of types.  Other things may also be added to validate default records of several flavours.
-  return ('FAIL', "Hostnames may not contain anything other than (0-9 a-z . _)")
-	if $defrec eq 'n' && $$host !~ /^[0-9a-z_%.]+$/i;
-
-  # Collect these even if we're only doing a simple A record so we can call *any* validation sub
-  my $dist = shift;
-  my $port = shift;
-  my $weight = shift;
-
-  my $fields;
-  my @vallist;
-
-  # Call the validation sub for the type requested.
-  ($retcode,$retmsg) = $validators{$$rectype}($dbh, (defrec => $defrec, revrec => $revrec, id => $id,
-	host => $host, rectype => $rectype, val => $val, addr => $addr,
-	dist => \$dist, port => \$port, weight => \$weight,
-	fields => \$fields, vallist => \@vallist) );
-
-  return ($retcode,$retmsg) if $retcode eq 'FAIL';
-
-  # Set up database fields and bind parameters
-  $fields .= "host,type,val,ttl,"._recparent($defrec,$revrec);
-  push @vallist, ($$host,$$rectype,$$val,$ttl,$id);
-  my $vallen = '?'.(',?'x$#vallist);
-
-  # Allow transactions, and raise an exception on errors so we can catch it later.
-  # Use local to make sure these get "reset" properly on exiting this block
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  eval {
-    $dbh->do("INSERT INTO "._rectable($defrec, $revrec)." ($fields) VALUES ($vallen)",
-	undef, @vallist);
-    $dbh->commit;
-  };
-  if ($@) {
-    my $msg = $@;
-    eval { $dbh->rollback; };
-    return ('FAIL',$msg);
-  }
-
-  return ($retcode, $retmsg);
-
-} # end addRec()
-
-
-## DNSDB::updateRec()
-# Update a record
-sub updateRec {
-  $errstr = '';
-
-  my $dbh = shift;
-  my $defrec = shift;
-  my $id = shift;
-
-# all records have these
-  my $host = shift;
-  my $type = shift;
-  my $val = shift;
-  my $ttl = shift;
-
-  return('FAIL',"Missing standard argument(s)") if !defined($ttl);
-
-  # 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+$//;
-  }
-
-# only MX and SRV will use these
-  my $dist = 0;
-  my $weight = 0;
-  my $port = 0;
-
-  if ($type == $reverse_typemap{MX} || $type == $reverse_typemap{SRV}) {
-    $dist = shift;
-    $dist =~ s/\s+//g;
-    return ('FAIL',"MX or SRV requires distance") if !defined($dist);
-    return ('FAIL', "Distance must be numeric") unless $dist =~ /^\d+$/;
-    if ($type == $reverse_typemap{SRV}) {
-      $weight = shift;
-      $weight =~ s/\s+//g;
-      return ('FAIL',"SRV requires weight") if !defined($weight);
-      return ('FAIL',"Weight must be numeric") unless $weight =~ /^\d+$/;
-      $port = shift;
-      $port =~ s/\s+//g;
-      return ('FAIL',"SRV requires port") if !defined($port);
-      return ('FAIL',"Port must be numeric") unless $port =~ /^\d+$/;
-    }
-  }
-
-# Enforce IP addresses on A and AAAA types
-  my $addr = NetAddr::IP->new($val);
-  if ($type == $reverse_typemap{A}) {
-    return ('FAIL',$typemap{$type}." record must be a valid IPv4 address")
-        unless $addr && !$addr->{isv6};
-  }
-  if ($type == $reverse_typemap{AAAA}) {
-    return ('FAIL',$typemap{$type}." record must be a valid IPv6 address")
-        unless $addr && $addr->{isv6};
-  }
-
-# hmm..  this might work.  except possibly for something pointing to "deadbeef.ca".  <g>
-#  if ($type == $reverse_typemap{NS} || $type == $reverse_typemap{MX} || $type == $reverse_typemap{SRV}) {
-#    if ($val =~ /^\s*[\da-f:.]+\s*$/) {
-#      return ('FAIL',"$val is not a valid IP address") if !$addr;
-#    }
-#  }
-
-  local $dbh->{AutoCommit} = 0;
-  local $dbh->{RaiseError} = 1;
-
-  eval {
-    $dbh->do("UPDATE ".($defrec eq 'y' ? 'default_' : '')."records ".
-	"SET host=?,val=?,type=?,ttl=?,distance=?,weight=?,port=? ".
-	"WHERE record_id=?", undef, ($host, $val, $type, $ttl, $dist, $weight, $port, $id) );
-    $dbh->commit;
-  };
-  if ($@) {
-    my $msg = $@;
-    $dbh->rollback;
-    return ('FAIL', $msg);
-  }
-
-  return ('OK','OK');
-} # end updateRec()
-
-
-## DNSDB::delRec()
-# Delete a record.  
-sub delRec {
-  $errstr = '';
-  my $dbh = shift;
-  my $defrec = shift;
-  my $revrec = shift;
-  my $id = shift;
-
-  my $sth = $dbh->prepare("DELETE FROM "._rectable($defrec,$revrec)." WHERE record_id=?");
-  $sth->execute($id);
-
-  return ('FAIL',"Couldn't remove record: ".$sth->errstr) if $sth->err;
-
-  return ('OK','OK');
-} # end delRec()
-
-
-  # Reference hashes.
+# Username, full name, ID - mainly for logging
+my %userdata;
+
+# Entity-relationship reference hashes.
 my %par_tbl = (
 		group	=> 'groups',
@@ -2342,4 +216,3665 @@
 	);
 
+##
+## utility functions
+##
+
+## DNSDB::_rectable()
+# Takes default+rdns flags, returns appropriate table name
+sub _rectable {
+  my $def = shift;
+  my $rev = shift;
+
+  return 'records' if $def ne 'y';
+  return 'default_records' if $rev ne 'y';
+  return 'default_rev_records';
+} # end _rectable()
+
+## DNSDB::_recparent()
+# Takes default+rdns flags, returns appropriate parent-id column name
+sub _recparent {
+  my $def = shift;
+  my $rev = shift;
+
+  return 'group_id' if $def eq 'y';
+  return 'rdns_id' if $rev eq 'y';
+  return 'domain_id';
+} # end _recparent()
+
+## DNSDB::_ipparent()
+# Check an IP to be added in a reverse zone to see if it's really in the requested parent.
+# Takes a database handle, default and reverse flags, IP (fragment) to check, parent zone ID,
+# and a reference to a NetAddr::IP object (also used to pass back a fully-reconstructed IP for
+# database insertion)
+sub _ipparent {
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $val = shift;
+  my $id = shift;
+  my $addr = shift;
+
+  return if $revrec ne 'y';	# this sub not useful in forward zones
+
+  $$addr = NetAddr::IP->new($$val);	 #necessary?
+
+  # subsub to split, reverse, and overlay an IP fragment on a netblock
+  sub __rev_overlay {
+    my $splitme = shift;	# ':' or '.', m'lud?
+    my $parnet = shift;
+    my $val = shift;
+    my $addr = shift;
+
+    my $joinme = $splitme;
+    $splitme = '\.' if $splitme eq '.';
+    my @working = reverse(split($splitme, $parnet->addr));
+    my @parts = reverse(split($splitme, $$val));
+    for (my $i = 0; $i <= $#parts; $i++) {
+      $working[$i] = $parts[$i];
+    }
+    my $checkme = NetAddr::IP->new(join($joinme, reverse(@working))) or return 0;
+    return 0 unless $checkme->within($parnet);
+    $$addr = $checkme;	# force "correct" IP to be recorded.
+    return 1;
+  }
+
+  my ($parstr) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id = ?", undef, ($id));
+  my $parnet = NetAddr::IP->new($parstr);
+
+  # Fail early on v6-in-v4 or v4-in-v6.  We're not accepting these ATM.
+  return 0 if $parnet->addr =~ /\./ && $$val =~ /:/;
+  return 0 if $parnet->addr =~ /:/ && $$val =~ /\./;
+
+  if ($$addr && ($$val =~ /^[\da-fA-F][\da-fA-F:]+[\da-fA-F]$/ || $$val =~ m|/\d+$|)) {
+    # the only case where NetAddr::IP's acceptance of legitimate IPs is "correct" is for a proper IPv6 address,
+    # or a netblock (only expected on templates)
+    # the rest we have to restructure before fiddling.  *sigh*
+    return 1 if $$addr->within($parnet);
+  } else {
+    # We don't have a complete IP in $$val (yet)... unless we have a netblock
+    if ($parnet->addr =~ /:/) {
+      $$val =~ s/^:+//;	 # gotta strip'em all...
+      return __rev_overlay(':', $parnet, $val, $addr);
+    }
+    if ($parnet->addr =~ /\./) {
+      $$val =~ s/^\.+//;
+      return __rev_overlay('.', $parnet, $val, $addr);
+    }
+    # should be impossible to get here...
+  }
+  # ... and here.
+  # can't do nuttin' in forward zones
+} # end _ipparent()
+
+## DNSDB::_hostparent()
+# A little different than _ipparent above;  this tries to *find* the parent zone of a hostname
+# Takes a database handle and hostname.
+# Returns the domain ID of the parent domain if one was found.
+sub _hostparent {
+  my $dbh = shift;
+  my $hname = shift;
+
+  $hname =~ s/^\*\.//;	# this should be impossible to find in the domains table.
+  my @hostbits = split /\./, $hname;
+  my $sth = $dbh->prepare("SELECT count(*),domain_id FROM domains WHERE lower(domain) = lower(?) GROUP BY domain_id");
+  foreach (@hostbits) {
+    $sth->execute($hname);
+    my ($found, $parid) = $sth->fetchrow_array;
+    if ($found) {
+      return $parid;
+    }
+    $hname =~ s/^$_\.//;
+  }
+} # end _hostparent()
+
+## DNSDB::_log()
+# Log an action
+# Takes a database handle and log entry hash containing at least:
+#  group_id, log entry
+# and optionally one or more of:
+#  domain_id, rdns_id
+# The %userdata hash provides the user ID, username, and fullname
+sub _log {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  $args{rdns_id} = 0 if !$args{rdns_id};
+  $args{domain_id} = 0 if !$args{domain_id};
+
+##fixme:  farm out the actual logging to different subs for file, syslog, internal, etc based on config
+#  if ($config{log_channel} eq 'sql') {
+  $dbh->do("INSERT INTO log (domain_id,rdns_id,group_id,entry,user_id,email,name) VALUES (?,?,?,?,?,?,?)",
+	undef,
+	($args{domain_id}, $args{rdns_id}, $args{group_id}, $args{entry},
+		$userdata{userid}, $userdata{username}, $userdata{fullname}) );
+#  } elsif ($config{log_channel} eq 'file') {
+#  } elsif ($config{log_channel} eq 'syslog') {
+#  }
+} # end _log
+
+
+##
+## Record validation subs.
+##
+
+## All of these subs take substantially the same arguments:
+# a database handle
+# a hash containing at least the following keys:
+#  - defrec (default/live flag)
+#  - revrec (forward/reverse flag)
+#  - id (parent entity ID)
+#  - host (hostname)
+#  - rectype
+#  - val (IP, hostname [CNAME/MX/SRV] or text)
+#  - addr (NetAddr::IP object from val.  May be undef.)
+# MX and SRV record validation also expect distance, and SRV records expect weight and port as well.
+# host, rectype, and addr should be references as these may be modified in validation
+
+# A record
+sub _validate_1 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  return ('FAIL', 'Reverse zones cannot contain A records') if $args{revrec} eq 'y';
+
+  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
+  # or the intended parent domain for live records.
+  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
+  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+
+  # Check IP is well-formed, and that it's a v4 address
+  # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
+  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
+	unless ${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/;
+  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
+	unless $args{addr} && !$args{addr}->{isv6};
+  # coerce IP/value to normalized form for storage
+  ${$args{val}} = $args{addr}->addr;
+
+  return ('OK','OK');
+} # done A record
+
+# NS record
+sub _validate_2 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  # Check that the target of the record is within the parent.
+  # Yes, host<->val are mixed up here;  can't see a way to avoid it.  :(
+  if ($args{defrec} eq 'n') {
+    # Check if IP/address/zone/"subzone" is within the parent
+    if ($args{revrec} eq 'y') {
+      my $tmpip = NetAddr::IP->new(${$args{val}});
+      my $pname = revName($dbh,$args{id});
+      return ('FAIL',"${$args{val}} not within $pname")
+	 unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$tmpip);
+      # Sub the returned thing for ZONE?  This could get stupid if you have typos...
+      ${$args{val}} =~ s/ZONE/$tmpip->address/;
+    } else {
+      my $pname = domainName($dbh,$args{id});
+      ${$args{host}} = $pname if ${$args{host}} !~ /\.$pname$/;
+    }
+  } else {
+    # Default reverse NS records should always refer to the implied parent
+    ${$args{host}} = 'DOMAIN' if $args{revrec} eq 'n';
+    ${$args{val}} = 'ZONE' if $args{revrec} eq 'y';
+  }
+
+# Let this lie for now.  Needs more magic.
+#  # Check IP is well-formed, and that it's a v4 address
+#  return ('FAIL',"A record must be a valid IPv4 address")
+#	unless $addr && !$addr->{isv6};
+#  # coerce IP/value to normalized form for storage
+#  $$val = $addr->addr;
+
+  return ('OK','OK');
+} # done NS record
+
+# CNAME record
+sub _validate_5 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+# Not really true, but these are only useful for delegating smaller-than-/24 IP blocks.
+# This is fundamentally a messy operation and should really just be taken care of by the
+# export process, not manual maintenance of the necessary records.
+  return ('FAIL', 'Reverse zones cannot contain CNAME records') if $args{revrec} eq 'y';
+
+  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
+  # or the intended parent domain for live records.
+  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
+  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+
+  return ('OK','OK');
+} # done CNAME record
+
+# SOA record
+sub _validate_6 {
+  # Smart monkeys won't stick their fingers in here;  we have
+  # separate dedicated routines to deal with SOA records.
+  return ('OK','OK');
+} # done SOA record
+
+# PTR record
+sub _validate_12 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  if ($args{revrec} eq 'y') {
+    if ($args{defrec} eq 'n') {
+      return ('FAIL', "IP or IP fragment ${$args{val}} is not within ".revName($dbh, $args{id}))
+	unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
+      ${$args{val}} = $args{addr}->addr;
+    } else {
+      if (${$args{val}} =~ /\./) {
+	# looks like a v4 or fragment
+	if (${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/) {
+	  # woo!  a complete IP!  validate it and normalize, or fail.
+	  $args{addr} = NetAddr::IP->new(${$args{val}})
+		or return ('FAIL', "IP/value looks like IPv4 but isn't valid");
+	  ${$args{val}} = $args{addr}->addr;
+	} else {
+	  ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
+	}
+      } elsif (${$args{val}} =~ /[a-f:]/) {
+	# looks like a v6 or fragment
+	${$args{val}} =~ s/^:*/ZONE::/ if !$args{addr} && ${$args{val}} !~ /^ZONE/;
+	if ($args{addr}) {
+	  if ($args{addr}->addr =~ /^0/) {
+	    ${$args{val}} =~ s/^:*/ZONE::/ unless ${$args{val}} =~ /^ZONE/;
+	  } else {
+	    ${$args{val}} = $args{addr}->addr;
+	  }
+	}
+      } else {
+	# bare number (probably).  These could be v4 or v6, so we'll
+	# expand on these on creation of a reverse zone.
+	${$args{val}} = "ZONE,${$args{val}}" unless ${$args{val}} =~ /^ZONE/;
+      }
+      ${$args{host}} =~ s/\.*$/\.$config{domain}/ if ${$args{host}} !~ /(?:$config{domain}|ADMINDOMAIN)$/;
+    }
+
+# Multiple PTR records do NOT generally do what most people believe they do,
+# and tend to fail in the most awkward way possible.  Check and warn.
+# We use $val instead of $addr->addr since we may be in a defrec, and may have eg "ZONE::42" or "ZONE.12"
+
+    my @checkvals = (${$args{val}});
+    if (${$args{val}} =~ /,/) {
+      # push . and :: variants into checkvals if val has ,
+      my $tmp;
+      ($tmp = ${$args{val}}) =~ s/,/./;
+      push @checkvals, $tmp;
+      ($tmp = ${$args{val}}) =~ s/,/::/;
+      push @checkvals, $tmp;
+    }
+    my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE val = ?");
+    foreach my $checkme (@checkvals) {
+      if ($args{update}) {
+	# Record update.  There should usually be an existing PTR (the record being updated)
+	my @ptrs = @{ $dbh->selectcol_arrayref("SELECT record_id FROM "._rectable($args{defrec},$args{revrec}).
+		" WHERE val = ?", undef, ($checkme)) };
+	return ('WARN', "PTR record for $checkme already exists;  adding another will probably not do what you want")
+		if @ptrs && (!grep /^$args{update}$/, @ptrs);
+      } else {
+	# New record.  Always warn if a PTR exists
+	my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
+		" WHERE val = ?", undef, ($checkme));
+	return ('WARN', "PTR record for $checkme already exists;  adding another will probably not do what you want")
+		if $ptrcount;
+      }
+    }
+
+  } else {
+    # Not absolutely true but only useful if you hack things up for sub-/24 v4 reverse delegations
+    # Simpler to just create the reverse zone and grant access for the customer to edit it, and create direct
+    # PTR records on export
+    return ('FAIL',"Forward zones cannot contain PTR records");
+  }
+
+  return ('OK','OK');
+} # done PTR record
+
+# MX record
+sub _validate_15 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+# Not absolutely true but WTF use is an MX record for a reverse zone?
+  return ('FAIL', 'Reverse zones cannot contain MX records') if $args{revrec} eq 'y';
+
+  return ('FAIL', "Distance is required for MX records") unless defined(${$args{dist}});
+  ${$args{dist}} =~ s/\s*//g;
+  return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
+
+  ${$args{fields}} = "distance,";
+  push @{$args{vallist}}, ${$args{dist}};
+
+  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
+  # or the intended parent domain for live records.
+  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
+  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+
+# hmm..  this might work.  except possibly for something pointing to "deadbeef.ca".  <g>
+#  if ($type == $reverse_typemap{NS} || $type == $reverse_typemap{MX} || $type == $reverse_typemap{SRV}) {
+#    if ($val =~ /^\s*[\da-f:.]+\s*$/) {
+#      return ('FAIL',"$val is not a valid IP address") if !$addr;
+#    }
+#  }
+
+  return ('OK','OK');
+} # done MX record
+
+# TXT record
+sub _validate_16 {
+  # Could arguably put a WARN return here on very long (>512) records
+  return ('OK','OK');
+} # done TXT record
+
+# RP record
+sub _validate_17 {
+  # Probably have to validate these some day
+  return ('OK','OK');
+} # done RP record
+
+# AAAA record
+sub _validate_28 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  return ('FAIL', 'Reverse zones cannot contain AAAA records') if $args{revrec} eq 'y';
+
+  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
+  # or the intended parent domain for live records.
+  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
+  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+
+  # Check IP is well-formed, and that it's a v6 address
+  return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv6 address")
+	unless $args{addr} && $args{addr}->{isv6};
+  # coerce IP/value to normalized form for storage
+  ${$args{val}} = $args{addr}->addr;
+
+  return ('OK','OK');
+} # done AAAA record
+
+# SRV record
+sub _validate_33 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+# Not absolutely true but WTF use is an SRV record for a reverse zone?
+  return ('FAIL', 'Reverse zones cannot contain SRV records') if $args{revrec} eq 'y';
+
+  return ('FAIL', "Distance is required for SRV records") unless defined(${$args{dist}});
+  ${$args{dist}} =~ s/\s*//g;
+  return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
+
+  return ('FAIL',"SRV records must begin with _service._protocol [${$args{host}}]")
+	unless ${$args{host}} =~ /^_[A-Za-z]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
+  return ('FAIL',"Port and weight are required for SRV records")
+	unless defined(${$args{weight}}) && defined(${$args{port}});
+  ${$args{weight}} =~ s/\s*//g;
+  ${$args{port}} =~ s/\s*//g;
+
+  return ('FAIL',"Port and weight are required, and must be numeric")
+	unless ${$args{weight}} =~ /^\d+$/ && ${$args{port}} =~ /^\d+$/;
+
+  ${$args{fields}} = "distance,weight,port,";
+  push @{$args{vallist}}, (${$args{dist}}, ${$args{weight}}, ${$args{port}});
+
+  # Coerce all hostnames to end in ".DOMAIN" for group/default records,
+  # or the intended parent domain for live records.
+  my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
+  ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+
+  return ('OK','OK');
+} # done SRV record
+
+# Now the custom types
+
+# A+PTR record.  With a very little bit of magic we can also use this sub to validate AAAA+PTR.  Whee!
+sub _validate_65280 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my $code = 'OK';
+  my $msg = 'OK';
+
+  if ($args{defrec} eq 'n') {
+    # live record;  revrec determines whether we validate the PTR or A component first.
+
+    if ($args{revrec} eq 'y') {
+      ($code,$msg) = _validate_12($dbh, %args);
+      return ($code,$msg) if $code eq 'FAIL';
+
+      # Check if the reqested domain exists.  If not, coerce the type down to PTR and warn.
+      if (!(${$args{domid}} = _hostparent($dbh, ${$args{host}}))) {
+	my $addmsg = "Record ".($args{update} ? 'updated' : 'added').
+		" as PTR instead of $typemap{${$args{rectype}}};  domain not found for ${$args{host}}";
+	$msg .= "\n$addmsg" if $code eq 'WARN';
+	$msg = $addmsg if $code eq 'OK';
+        ${$args{rectype}} = $reverse_typemap{PTR};
+        return ('WARN', $msg);
+      }
+
+      # Add domain ID to field list and values
+      ${$args{fields}} .= "domain_id,";
+      push @{$args{vallist}}, ${$args{domid}};
+
+    } else {
+      ($code,$msg) = _validate_1($dbh, %args) if ${$args{rectype}} == 65280;
+      ($code,$msg) = _validate_28($dbh, %args) if ${$args{rectype}} == 65281;
+      return ($code,$msg) if $code eq 'FAIL';
+
+      # Check if the requested reverse zone exists - note, an IP fragment won't
+      # work here since we don't *know* which parent to put it in.
+      # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
+      my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
+	" ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
+      if (!$revid) {
+        $msg = "Record ".($args{update} ? 'updated' : 'added')." as ".(${$args{rectype}} == 65280 ? 'A' : 'AAAA').
+		" instead of $typemap{${$args{rectype}}};  reverse zone not found for ${$args{val}}";
+	${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
+	return ('WARN', $msg);
+      }
+
+      # Check for duplicate PTRs.  Note we don't have to play games with $code and $msg, because
+      # by definition there can't be duplicate PTRs if the reverse zone isn't managed here.
+      if ($args{update}) {
+	# Record update.  There should usually be an existing PTR (the record being updated)
+	my @ptrs = @{ $dbh->selectcol_arrayref("SELECT record_id FROM "._rectable($args{defrec},$args{revrec}).
+		" WHERE val = ?", undef, (${$args{val}})) };
+	if (@ptrs && (!grep /^$args{update}$/, @ptrs)) {
+	  $msg = "PTR record for ${$args{val}} already exists;  adding another will probably not do what you want";
+	  $code = 'WARN';
+	}
+      } else {
+	# New record.  Always warn if a PTR exists
+	my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
+		" WHERE val = ?", undef, (${$args{val}}));
+	$msg = "PTR record for ${$args{val}} already exists;  adding another will probably not do what you want"
+		if $ptrcount;
+	$code = 'WARN' if $ptrcount;
+      }
+
+#      my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
+#	" WHERE val = ?", undef, ${$args{val}});
+#      if ($ptrcount) {
+#        my $curid = $dbh->selectrow_array("SELECT record_id FROM "._rectable($args{defrec},$args{revrec}).
+#		" WHERE val = ?
+#	$msg = "PTR record for ${$args{val}} already exists;  adding another will probably not do what you want";
+#	$code = 'WARN';
+#      }
+
+      ${$args{fields}} .= "rdns_id,";
+      push @{$args{vallist}}, $revid;
+    }
+
+  } else {	# defrec eq 'y'
+    if ($args{revrec} eq 'y') {
+      ($code,$msg) = _validate_12($dbh, %args);
+      return ($code,$msg) if $code eq 'FAIL';
+      if (${$args{rectype}} == 65280) {
+	return ('FAIL',"A+PTR record must be a valid IPv4 address or fragment")
+		if ${$args{val}} =~ /:/;
+	${$args{val}} =~ s/^ZONE,/ZONE./;       # Clean up after uncertain IP-fragment-type from _validate_12
+      } elsif (${$args{rectype}} == 65281) {
+	return ('FAIL',"AAAA+PTR record must be a valid IPv6 address or fragment")
+		if ${$args{val}} =~ /\./;
+	${$args{val}} =~ s/^ZONE,/ZONE::/;      # Clean up after uncertain IP-fragment-type from _validate_12
+      }
+    } else {
+      # This is easy.  I also can't see a real use-case for A/AAAA+PTR in *all* forward
+      # domains, since you wouldn't be able to substitute both domain and reverse zone
+      # sanely, and you'd end up with guaranteed over-replicated PTR records that would
+      # confuse the hell out of pretty much anything that uses them.
+##fixme: make this a config flag?
+      return ('FAIL', "$typemap{${$args{rectype}}} records not allowed in default domains");
+    }
+  }
+
+  return ($code, $msg);
+} # done A+PTR record
+
+# AAAA+PTR record
+# A+PTR above has been magicked to handle AAAA+PTR as well.
+sub _validate_65281 {
+  return _validate_65280(@_);
+} # done AAAA+PTR record
+
+# PTR template record
+sub _validate_65282 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  # we're *this* >.< close to being able to just call _validate_12... unfortunately we can't, quite.
+  if ($args{revrec} eq 'y') {
+    if ($args{defrec} eq 'n') {
+      return ('FAIL', "Template block ${$args{val}} is not within ".revName($dbh, $args{id}))
+	unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
+##fixme:  warn if $args{val} is not /31 or larger block?
+      ${$args{val}} = "$args{addr}";
+    } else {
+      if (${$args{val}} =~ /\./) {
+	# looks like a v4 or fragment
+	if (${$args{val}} =~ m|^\d+\.\d+\.\d+\.\d+(?:/\d+)?$|) {
+	  # woo!  a complete IP!  validate it and normalize, or fail.
+	  $args{addr} = NetAddr::IP->new(${$args{val}})
+		or return ('FAIL', "IP/value looks like IPv4 but isn't valid");
+	  ${$args{val}} = "$args{addr}";
+	} else {
+	  ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
+	}
+      } elsif (${$args{val}} =~ /[a-f:]/) {
+	# looks like a v6 or fragment
+	${$args{val}} =~ s/^:*/ZONE::/ if !$args{addr} && ${$args{val}} !~ /^ZONE/;
+	if ($args{addr}) {
+	  if ($args{addr}->addr =~ /^0/) {
+	    ${$args{val}} =~ s/^:*/ZONE::/ unless ${$args{val}} =~ /^ZONE/;
+	  } else {
+	    ${$args{val}} = "$args{addr}";
+	  }
+	}
+      } else {
+	# bare number (probably).  These could be v4 or v6, so we'll
+	# expand on these on creation of a reverse zone.
+	${$args{val}} = "ZONE,${$args{val}}" unless ${$args{val}} =~ /^ZONE/;
+      }
+    }
+##fixme:  validate %-patterns?
+
+# Unlike single PTR records, there is absolutely no way to sanely support multiple
+# PTR templates for the same block, since they expect to expand to all the individual
+# IPs on export.  Nested templates should be supported though.
+
+    my @checkvals = (${$args{val}});
+    if (${$args{val}} =~ /,/) {
+      # push . and :: variants into checkvals if val has ,
+      my $tmp;
+      ($tmp = ${$args{val}}) =~ s/,/./;
+      push @checkvals, $tmp;
+      ($tmp = ${$args{val}}) =~ s/,/::/;
+      push @checkvals, $tmp;
+    }
+##fixme:  this feels wrong still - need to restrict template pseudorecords to One Of Each
+# Per Netblock such that they don't conflict on export
+    my $typeck;
+# type 65282 -> ptr template -> look for any of 65282, 65283, 65284
+    $typeck = 'type=65283 OR type=65284' if ${$args{rectype}} == 65282;
+# type 65283 -> a+ptr template -> v4 -> look for 65282 or 65283
+    $typeck = 'type=65283' if ${$args{rectype}} == 65282;
+# type 65284 -> aaaa+ptr template -> v6 -> look for 65282 or 65284
+    $typeck = 'type=65284' if ${$args{rectype}} == 65282;
+    my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE val = ? ".
+	"AND (type=65282 OR $typeck)");
+    foreach my $checkme (@checkvals) {
+      $pcsth->execute($checkme);
+      my ($rc) = $pcsth->fetchrow_array;
+      return ('FAIL', "Only one template pseudorecord may exist for a given IP block") if $rc;
+    }
+
+  } else {
+    return ('FAIL', "Forward zones cannot contain PTR records");
+  }
+
+  return ('OK','OK');
+} # done PTR template record
+
+# A+PTR template record
+sub _validate_65283 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my ($code,$msg) = ('OK','OK');
+
+##fixme:  need to fiddle things since A+PTR templates are acceptable in live
+# forward zones but not default records
+  if ($args{defrec} eq 'n') {
+    if ($args{revrec} eq 'n') {
+      ($code,$msg) = _validate_1($dbh, %args) if ${$args{rectype}} == 65280;
+      ($code,$msg) = _validate_28($dbh, %args) if ${$args{rectype}} == 65281;
+      return ($code,$msg) if $code eq 'FAIL';
+
+      # Check if the requested reverse zone exists - note, an IP fragment won't
+      # work here since we don't *know* which parent to put it in.
+      # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
+      my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
+	" ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
+      # Fail if no match;  we can't coerce a PTR-template type down to not include the PTR bit currently.
+      if (!$revid) {
+        $msg = "Can't ".($args{update} ? 'update' : 'add')." ${$args{host}}/${$args{val}} as ".
+		"$typemap{${$args{rectype}}}:  reverse zone not found for ${$args{val}}";
+##fixme:  add A template, AAAA template types?
+#	${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
+	return ('FAIL', $msg);
+      }
+
+      # Add reverse zone ID to field list and values
+      ${$args{fields}} .= "rdns_id,";
+      push @{$args{vallist}}, $revid;
+
+    } else {
+      return ('FAIL', "IP or IP fragment ${$args{val}} is not within ".revName($dbh, $args{id}))
+	unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
+      ${$args{val}} = "$args{addr}";
+
+      if (!(${$args{domid}} = _hostparent($dbh, ${$args{host}}))) {
+	my $addmsg = "Record ".($args{update} ? 'updated' : 'added').
+		" as PTR template instead of $typemap{${$args{rectype}}};  domain not found for ${$args{host}}";
+	$msg .= "\n$addmsg" if $code eq 'WARN';
+	$msg = $addmsg if $code eq 'OK';
+        ${$args{rectype}} = 65282;
+        return ('WARN', $msg);
+      }
+
+      # Add domain ID to field list and values
+      ${$args{fields}} .= "domain_id,";
+      push @{$args{vallist}}, ${$args{domid}};
+    }
+
+  } else {
+    my ($code,$msg) = _validate_65282($dbh, %args);
+    return ($code, $msg) if $code eq 'FAIL';
+    # get domain, check against ${$args{name}}
+  }
+
+  return ('OK','OK');
+} # done AAAA+PTR template record
+
+# AAAA+PTR template record
+sub _validate_65284 {
+  return ('OK','OK');
+} # done AAAA+PTR template record
+
+# Delegation record
+# This is essentially a specialized clone of the NS record, primarily useful
+# for delegating IPv4 sub-/24 reverse blocks
+sub _validate_65285 {
+  my $dbh = shift;
+
+  my %args = @_;
+
+# Almost, but not quite, identical to NS record validation.
+
+  # Check that the target of the record is within the parent.
+  # Yes, host<->val are mixed up here;  can't see a way to avoid it.  :(
+  if ($args{defrec} eq 'n') {
+    # Check if IP/address/zone/"subzone" is within the parent
+    if ($args{revrec} eq 'y') {
+      my $tmpip = NetAddr::IP->new(${$args{val}});
+      my $pname = revName($dbh,$args{id});
+      return ('FAIL',"${$args{val}} not within $pname")
+	 unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$tmpip);
+      # Normalize
+      ${$args{val}} = "$tmpip";
+    } else {
+      my $pname = domainName($dbh,$args{id});
+      ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
+    }
+  } else {
+    return ('FAIL',"Delegation records are not permitted in default record sets");
+  }
+  return ('OK','OK');
+}
+
+
+##
+## Record data substitution subs
+##
+
+# Replace ZONE in hostname, or create (most of) the actual proper zone name
+sub _ZONE {
+  my $zone = shift;
+  my $string = shift;
+  my $fr = shift || 'f';	# flag for forward/reverse order?  nb: ignored for IP
+  my $sep = shift || '-';	# Separator character - unlikely we'll ever need more than . or -
+
+  my $prefix;
+
+  $string =~ s/,/./ if !$zone->{isv6};
+  $string =~ s/,/::/ if $zone->{isv6};
+
+  # Subbing ZONE in the host.  We need to properly ID the netblock range
+  # The subbed text should have "network IP with trailing zeros stripped" for
+  # blocks lined up on octet (for v4) or hex-quad (for v6) boundaries
+  # For blocks that do NOT line up on these boundaries, we take the most
+  # significant octet or 16-bit chunk of the "broadcast" IP and append it
+  # after a double-dash
+  # ie:
+  # 8.0.0.0/6 -> 8.0.0.0 -> 11.255.255.255;  sub should be 8--11
+  # 10.0.0.0/12 -> 10.0.0.0 -> 10.0.0.0 -> 10.15.255.255;  sub should be 10-0--15
+  # 192.168.4.0/22 -> 192.168.4.0 -> 192.168.7.255;  sub should be 192-168-4--7
+  # 192.168.0.8/29 -> 192.168.0.8 -> 192.168.0.15;  sub should be 192-168-0-8--15
+  # Similar for v6
+
+  if (!$zone->{isv6}) { # IPv4
+
+    $prefix = $zone->network->addr;	# Just In Case someone managed to slip in
+					# a funky subnet that had host bits set.
+    my $bc = $zone->broadcast->addr;
+
+    if ($zone->masklen > 24) {
+      $bc =~ s/^\d+\.\d+\.\d+\.//;
+    } elsif ($zone->masklen > 16) {
+      $prefix =~ s/\.0$//;
+      $bc =~ s/^\d+\.\d+\.//;
+    } elsif ($zone->masklen > 8) {
+      $bc =~ s/^\d+\.//;
+      $prefix =~ s/\.0\.0$//;
+    } else {
+      $prefix =~ s/\.0\.0\.0$//;
+    }
+    if ($zone->masklen % 8) {
+      $bc =~ s/(\.255)+$//;
+      $prefix .= "--$bc";	#"--".zone->masklen;	# use range or mask length?
+    }
+    if ($fr eq 'f') {
+      $prefix =~ s/\.+/$sep/g;
+    } else {
+      $prefix = join($sep, reverse(split(/\./, $prefix)));
+    }
+
+  } else { # IPv6
+
+    if ($fr eq 'f') {
+
+      $prefix = $zone->network->addr;	# Just In Case someone managed to slip in
+					# a funky subnet that had host bits set.
+      my $bc = $zone->broadcast->addr;
+      if (($zone->masklen % 16) != 0) {
+        # Strip trailing :0 off $prefix, and :ffff off the broadcast IP
+        for (my $i=0; $i<(7-int($zone->masklen / 16)); $i++) {
+          $prefix =~ s/:0$//;
+          $bc =~ s/:ffff$//;
+        }
+        # Strip the leading 16-bit chunks off the front of the broadcast IP
+        $bc =~ s/^([a-f0-9]+:)+//;
+        # Append the remaining 16-bit chunk to the prefix after "--"
+        $prefix .= "--$bc";
+      } else {
+        # Strip off :0 from the end until we reach the netblock length.
+        for (my $i=0; $i<(8-$zone->masklen / 16); $i++) {
+	  $prefix =~ s/:0$//;
+        }
+      }
+      # Actually deal with the separator
+      $prefix =~ s/:/$sep/g;
+
+    } else {	# $fr eq 'f'
+
+      $prefix = $zone->network->full;	# Just In Case someone managed to slip in
+					# a funky subnet that had host bits set.
+      my $bc = $zone->broadcast->full;
+      $prefix =~ s/://g;	# clean these out since they're not spaced right for this case
+      $bc =~ s/://g;
+      # Strip trailing 0 off $prefix, and f off the broadcast IP, to match the mask length
+      for (my $i=0; $i<(31-int($zone->masklen / 4)); $i++) {
+        $prefix =~ s/0$//;
+        $bc =~ s/f$//;
+      }
+      # Split and reverse the order of the nibbles in the network/broadcast IPs
+      # trim another 0 for nibble-aligned blocks first, but only if we really have a block, not an IP
+      $prefix =~ s/0$// if $zone->masklen % 4 == 0 && $zone->masklen != 128;
+      my @nbits = reverse split //, $prefix;
+      my @bbits = reverse split //, $bc;
+      # Handle the sub-nibble case.  Eww.  I feel dirty supporting this...
+      $nbits[0] = "$nbits[0]-$bbits[0]" if ($zone->masklen % 4) != 0;
+      # Glue it back together
+      $prefix = join($sep, @nbits);
+
+    }	# $fr ne 'f'
+
+  } # $zone->{isv6}
+
+  # Do the substitution, finally
+  $string =~ s/ZONE/$prefix/;
+  $string =~ s/--/-/ if $sep ne '-';	# - as separator needs extra help for sub-octet v4 netblocks
+  return $string;
+} # done _ZONE()
+
+# Not quite a substitution sub, but placed here as it's basically the inverse of above;
+# given the .arpa zone name, return the CIDR netblock the zone is for.
+# Supports v4 non-octet/non-classful netblocks as per the method outlined in the Grasshopper Book (2nd Ed p217-218)
+# Does NOT support non-quad v6 netblocks via the same scheme;  it shouldn't ever be necessary.
+# Takes a nominal .arpa zone name, returns a success code and NetAddr::IP, or a fail code and message
+sub _zone2cidr {
+  my $zone = shift;
+
+  my $cidr;
+  my $tmpcidr;
+  my $warnmsg = '';
+
+  if ($zone =~ /\.in-addr\.arpa\.?$/) {
+    # v4 revzone, formal zone name type
+    my $tmpzone = $zone;
+    $tmpzone =~ s/\.in-addr\.arpa\.?//;
+    return ('FAIL', "Non-numerics in apparent IPv4 reverse zone name") if $tmpzone !~ /^(?:\d+-)?[\d\.]+$/;
+
+    # Snag the octet pieces
+    my @octs = split /\./, $tmpzone;
+
+    # Map result of a range manipulation to a mask length change.  Cheaper than finding the 2-root of $octets[0]+1.
+    # Note we will not support /31 blocks, mostly due to issues telling "24-31" -> .24/29 apart from
+    # "24-31" -> .24/31", with a litte bit of "/31 is icky".
+    my %maskmap = (  3 => 2,  7 => 3, 15 => 4, 31 => 5, 63 => 6, 127 => 7,
+		    30 => 2, 29 => 3, 28 => 4, 27 => 5, 26 => 6,  25 => 7
+	);
+
+    # Handle "range" blocks, eg, 80-83.168.192.in-addr.arpa (192.168.80.0/22)
+    # Need to take the size of the range to offset the basic octet-based mask length,
+    # and make sure the first number in the range gets used as the network address for the block
+    # Alternate form:  The second number is actually the real netmask, not the end of the range.
+    my $masklen = 0;
+    if ($octs[0] =~ /^((\d+)-(\d+))$/) {	# take the range...
+      if (24 < $3 && $3 < 31) {
+	# we have a real netmask
+	$masklen = -$maskmap{$3};
+      } else {
+	# we have a range.  NB:  only real CIDR ranges are supported
+	$masklen -= $maskmap{-(eval $1)};	# find the mask base...
+      }
+      $octs[0] = $2;	# set the base octet of the range...
+    }
+    @octs = reverse @octs;	# We can reverse the octet pieces now that we've extracted and munged any ranges
+
+# arguably we should only allow sub-octet range/mask in-addr.arpa
+# specifications in the least significant octet, but the code is
+# simpler if we deal with sub-octet delegations at any level.
+
+    # Now we find the "true" mask with the aid of the "base" calculated above
+    if ($#octs == 0) {
+      $masklen += 8;
+      $tmpcidr = "$octs[0].0.0.0/$masklen";	# really hope we don't see one of these very often.
+    } elsif ($#octs == 1) {
+      $masklen += 16;
+      $tmpcidr = "$octs[0].$octs[1].0.0/$masklen";
+    } elsif ($#octs == 2) {
+      $masklen += 24;
+      $tmpcidr = "$octs[0].$octs[1].$octs[2].0/$masklen";
+    } else {
+      $masklen += 32;
+      $tmpcidr = "$octs[0].$octs[1].$octs[2].$octs[3]/$masklen";
+    }
+
+  } elsif ($zone =~ /\.ip6\.arpa$/) {
+    # v6 revzone, formal zone name type
+    my $tmpzone = $zone;
+    $tmpzone =~ s/\.ip6\.arpa\.?//;
+##fixme:  if-n-when we decide we can support sub-nibble v6 zone names, we'll need to change this segment
+    return ('FAIL', "Non-hexadecimals in apparent IPv6 reverse zone name") if $tmpzone !~ /^[a-fA-F\d\.]+$/;
+    my @quads = reverse(split(/\./, $tmpzone));
+    $warnmsg .= "Apparent sub-/64 IPv6 reverse zone\n" if $#quads > 15;
+    my $nc;
+    foreach (@quads) {
+      $tmpcidr .= $_;
+      $tmpcidr .= ":" if ++$nc % 4 == 0;
+    }
+    my $nq = 1 if $nc % 4 != 0;
+    my $mask = $nc * 4;	# need to do this here because we probably increment it below
+    while ($nc++ % 4 != 0) {
+      $tmpcidr .= "0";
+    }
+    $tmpcidr .= ($nq ? '::' : ':')."/$mask";
+  }
+
+  # Just to be sure, use NetAddr::IP to validate.  Saves a lot of nasty regex watching for valid octet values.
+  return ('FAIL', "Invalid zone $zone (apparent netblock $tmpcidr)")
+	unless $cidr = NetAddr::IP->new($tmpcidr);
+
+  if ($warnmsg) {
+    $errstr = $warnmsg;
+    return ('WARN', $cidr);
+  }
+  return ('OK', $cidr);
+} # done _zone2cidr()
+
+# Record template %-parameter expansion, IPv4.  Note that IPv6 doesn't
+# really have a sane way to handle this type of expansion at the moment
+# due to the size of the address space.
+# Takes a reference to a template string to be expanded, and an IP to use in the replacement.
+sub _template4_expand {
+  my $tmpl = shift;
+  my $ip = shift;
+
+  my @ipparts = split /\./, $ip;
+  my @iphex;
+  my @ippad;
+  for (@ipparts) {
+    push @iphex, sprintf("%x", $_);
+    push @ippad, sprintf("%u.3", $_);
+  }
+
+  # IP substitutions in template records:
+  #major patterns:
+  #dashed IP, forward and reverse
+  #dotted IP, forward and reverse (even if forward is... dumb)
+  # -> %r for reverse, %i for forward, leading - or . to indicate separator, defaults to -
+  # %r or %-r	=> %4d-%3d-%2d-%1d
+  # %.r		=> %4d.%3d.%2d.%1d
+  # %i or %-i	=> %1d-%2d-%3d-%4d
+  # %.i		=> %1d.%2d.%3d.%4d
+  $$tmpl =~ s/\%r/\%4d-\%3d-\%2d-\%1d/g;
+  $$tmpl =~ s/\%([-.])r/\%4d$1\%3d$1\%2d$1\%1d/g;
+  $$tmpl =~ s/\%i/\%1d-\%2d-\%3d-\%4d/g;
+  $$tmpl =~ s/\%([-.])i/\%1d$1\%2d$1\%3d$1\%4d/g;
+
+  #hex-coded IP
+  # %h
+  $$tmpl =~ s/\%h/$iphex[0]$iphex[1]$iphex[2]$iphex[3]/g;
+
+  #IP as decimal-coded 32-bit value
+  # %d
+  my $iptmp = $ipparts[0]*256*256*256 + $ipparts[1]*256*256 + $ipparts[2]*256 + $ipparts[3];
+  $$tmpl =~ s/\%d/$iptmp/g;
+
+  #minor patterns (per-octet)
+  # %[1234][dh0]
+  #octet
+  #hex-coded octet
+  #0-padded octet
+  $$tmpl =~ s/\%([1234])d/$ipparts[$1-1]/g;
+  $$tmpl =~ s/\%([1234])h/$iphex[$1-1]/g;
+  $$tmpl =~ s/\%([1234])h/$ippad[$1-1]/g;
+} # _template4_expand()
+
+
+##
+## Initialization and cleanup subs
+##
+
+
+## DNSDB::loadConfig()
+# Load the minimum required initial state (DB connect info) from a config file
+# Load misc other bits while we're at it.
+# Takes an optional hash that may contain:
+#  - basename and config path to look for
+#  - RPC flag (saves parsing the more complex RPC bits if not needed)
+# Populates the %config and %def hashes
+sub loadConfig {
+  my %args = @_;
+  $args{basename} = '' if !$args{basename};
+  $args{rpcflag} = '' if !$args{rpcflag};
+##fixme  $args{basename} isn't doing what I think I thought I was trying to do.
+
+  my $deferr = '';	# place to put error from default config file in case we can't find either one
+
+  my $configroot = "/etc/dnsdb";	##CFG_LEAF##
+  $configroot = '' if $args{basename} =~ m|^/|;
+  $args{basename} .= ".conf" if $args{basename} !~ /\.conf$/;
+  my $defconfig = "$configroot/dnsdb.conf";
+  my $siteconfig = "$configroot/$args{basename}";
+
+  # System defaults
+  __cfgload("$defconfig", $args{rpcflag}) or $deferr = $errstr;
+
+  # Per-site-ish settings.
+  if ($args{basename} ne '.conf') {
+    unless (__cfgload("$siteconfig"), $args{rpcflag}) {
+      $errstr = ($deferr ? "Error opening default config file $defconfig: $deferr\n" : '').
+	"Error opening site config file $siteconfig";
+      return;
+    }
+  }
+
+  # Munge log_failures.
+  if ($config{log_failures} ne '1' && $config{log_failures} ne '0') {
+    # true/false, on/off, yes/no all valid.
+    if ($config{log_failures} =~ /^(?:true|false|on|off|yes|no)$/) {
+      if ($config{log_failures} =~ /(?:true|on|yes)/) {
+	$config{log_failures} = 1;
+      } else {
+	$config{log_failures} = 0;
+      }
+    } else {
+      $errstr = "Bad log_failures setting $config{log_failures}";
+      $config{log_failures} = 1;
+      # Bad setting shouldn't be fatal.
+      # return 2;
+    }
+  }
+
+  # All good, clear the error and go home.
+  $errstr = '';
+  return 1;
+} # end loadConfig()
+
+
+## DNSDB::__cfgload()
+# Private sub to parse a config file and load it into %config
+# Takes a file handle on an open config file
+sub __cfgload {
+  $errstr = '';
+  my $cfgfile = shift;
+  my $rpcflag = shift;
+
+  if (open CFG, "<$cfgfile") {
+    while (<CFG>) {
+      chomp;
+      s/^\s*//;
+      next if /^#/;
+      next if /^$/;
+# hmm.  more complex bits in this file might require [heading] headers, maybe?
+#    $mode = $1 if /^\[(a-z)+]/;
+    # DB connect info
+      $config{dbname}	= $1 if /^dbname\s*=\s*([a-z0-9_.-]+)/i;
+      $config{dbuser}	= $1 if /^dbuser\s*=\s*([a-z0-9_.-]+)/i;
+      $config{dbpass}	= $1 if /^dbpass\s*=\s*([a-z0-9_.-]+)/i;
+      $config{dbhost}	= $1 if /^dbhost\s*=\s*([a-z0-9_.-]+)/i;
+      # SOA defaults
+      $def{contact}	= $1 if /^contact\s*=\s*([a-z0-9_.-]+)/i;
+      $def{prins}	= $1 if /^prins\s*=\s*([a-z0-9_.-]+)/i;
+      $def{soattl}	= $1 if /^soattl\s*=\s*(\d+)/i;
+      $def{refresh}	= $1 if /^refresh\s*=\s*(\d+)/i;
+      $def{retry}	= $1 if /^retry\s*=\s*(\d+)/i;
+      $def{expire}	= $1 if /^expire\s*=\s*(\d+)/i;
+      $def{minttl}	= $1 if /^minttl\s*=\s*(\d+)/i;
+      $def{ttl}		= $1 if /^ttl\s*=\s*(\d+)/i;
+      # Mail settings
+      $config{mailhost}		= $1 if /^mailhost\s*=\s*([a-z0-9_.-]+)/i;
+      $config{mailnotify}	= $1 if /^mailnotify\s*=\s*([a-z0-9_.\@-]+)/i;
+      $config{mailsender}	= $1 if /^mailsender\s*=\s*([a-z0-9_.\@-]+)/i;
+      $config{mailname}		= $1 if /^mailname\s*=\s*([a-z0-9\s_.-]+)/i;
+      $config{orgname}		= $1 if /^orgname\s*=\s*([a-z0-9\s_.,'-]+)/i;
+      $config{domain}		= $1 if /^domain\s*=\s*([a-z0-9_.-]+)/i;
+      # session - note this is fed directly to CGI::Session
+      $config{timeout}		= $1 if /^[tT][iI][mM][eE][oO][uU][tT]\s*=\s*(\d+[smhdwMy]?)/;
+      $config{sessiondir}	= $1 if m{^sessiondir\s*=\s*([a-z0-9/_.-]+)}i;
+      # misc
+      $config{log_failures}	= $1 if /^log_failures\s*=\s*([a-z01]+)/i;
+      $config{perpage}		= $1 if /^perpage\s*=\s*(\d+)/i;
+      $config{exportcache}	= $1 if m{^exportcache\s*=\s*([a-z0-9/_.-]+)}i;
+      # RPC options
+      if ($rpcflag && /^rpc/) {
+        if (my ($tmp) = /^rpc_iplist\s*=\s*(.+)/i) {
+          my @ips = split /[,\s]+/, $tmp;
+          my $rpcsys = shift @ips;
+          push @{$config{rpcacl}{$rpcsys}}, @ips;
+        }
+      }
+    }
+    close CFG;
+  } else {
+    $errstr = $!;
+    return;
+  }
+  return 1;
+} # end __cfgload()
+
+
+## DNSDB::connectDB()
+# Creates connection to DNS database.
+# Requires the database name, username, and password.
+# Returns a handle to the db.
+# Set up for a PostgreSQL db;  could be any transactional DBMS with the
+# right changes.
+sub connectDB {
+  $errstr = '';
+  my $dbname = shift;
+  my $user = shift;
+  my $pass = shift;
+  my $dbh;
+  my $DSN = "DBI:Pg:dbname=$dbname";
+
+  my $host = shift;
+  $DSN .= ";host=$host" if $host;
+
+# Note that we want to autocommit by default, and we will turn it off locally as necessary.
+# We may not want to print gobbledygook errors;  YMMV.  Have to ponder that further.
+  $dbh = DBI->connect($DSN, $user, $pass, {
+	AutoCommit => 1,
+	PrintError => 0
+	})
+    or return (undef, $DBI::errstr) if(!$dbh);
+
+##fixme:  initialize the DB if we can't find the table (since, by definition, there's
+# nothing there if we can't select from it...)
+  my $tblsth = $dbh->prepare("SELECT count(*) FROM pg_catalog.pg_class WHERE relkind='r' AND relname=?");
+  my ($tblcount) = $dbh->selectrow_array($tblsth, undef, ('misc'));
+  return (undef,$DBI::errstr) if $dbh->err;
+
+#if ($tblcount == 0) {
+#  # create tables one at a time, checking for each.
+#  return (undef, "check table misc missing");
+#}
+
+
+# Return here if we can't select.
+# This should retrieve the dbversion key.
+  my $sth = $dbh->prepare("SELECT key,value FROM misc WHERE misc_id=1");
+  $sth->execute();
+  return (undef,$DBI::errstr) if ($sth->err);
+
+##fixme:  do stuff to the DB on version mismatch
+# x.y series should upgrade on $DNSDB::VERSION > misc(key=>version)
+# DB should be downward-compatible;  column defaults should give sane (if possibly
+# useless-and-needs-help) values in columns an older software stack doesn't know about.
+
+# See if the select returned anything (or null data).  This should
+# succeed if the select executed, but...
+  $sth->fetchrow();
+  return (undef,$DBI::errstr)  if ($sth->err);
+
+  $sth->finish;
+
+# If we get here, we should be OK.
+  return ($dbh,"DB connection OK");
+} # end connectDB
+
+
+## DNSDB::finish()
+# Cleans up after database handles and so on.
+# Requires a database handle
+sub finish {
+  my $dbh = $_[0];
+  $dbh->disconnect;
+} # end finish
+
+
+## DNSDB::initGlobals()
+# Initialize global variables
+# NB: this does NOT include web-specific session variables!
+# Requires a database handle
+sub initGlobals {
+  my $dbh = shift;
+
+# load record types from database
+  my $sth = $dbh->prepare("SELECT val,name,stdflag FROM rectypes");
+  $sth->execute;
+  while (my ($recval,$recname,$stdflag) = $sth->fetchrow_array()) {
+    $typemap{$recval} = $recname;
+    $reverse_typemap{$recname} = $recval;
+    # now we fill the record validation function hash
+    if ($stdflag < 5) {
+      my $fn = "_validate_$recval";
+      $validators{$recval} = \&$fn;
+    } else {
+      my $fn = "sub { return ('FAIL','Type $recval ($recname) not supported'); }";
+      $validators{$recval} = eval $fn;
+    }
+  }
+} # end initGlobals
+
+
+## DNSDB::initRPC()
+# Takes a database handle, remote username, and remote fullname.
+# Sets up the RPC logging-pseudouser if needed.
+# Sets the %userdata hash for logging.
+# Returns undef on failure
+sub initRPC {
+  my $dbh = shift;
+  my %args  = @_;
+
+  return if !$args{username};
+  return if !$args{fullname};
+
+  $args{username} = "$args{username}/$args{rpcsys}";
+
+  my $tmpuser = $dbh->selectrow_hashref("SELECT username,user_id AS userid,group_id,firstname,lastname,status".
+	" FROM users WHERE username=?", undef, ($args{username}) );
+  if (!$tmpuser) {
+    $dbh->do("INSERT INTO users (username,password,firstname,type) VALUES (?,'RPC',?,'R')", undef,
+	($args{username}, $args{fullname}) );
+    $tmpuser = $dbh->selectrow_hashref("SELECT username,user_id AS userid,group_id,firstname,lastname,status".
+	" FROM users WHERE username=?", undef, ($args{username}) );
+  }
+  %userdata = %{$tmpuser};
+  $userdata{lastname} = '' if !$userdata{lastname};
+  $userdata{fullname} = "$userdata{firstname} $userdata{lastname} ($args{rpcsys})";
+  return 1 if $tmpuser;
+} # end initRPC()
+
+
+## DNSDB::login()
+# Takes a database handle, username and password
+# Returns a userdata hash (UID, GID, username, fullname parts) if username exists,
+# password matches the one on file, and account is not disabled
+# Returns undef otherwise
+sub login {
+  my $dbh = shift;
+  my $user = shift;
+  my $pass = shift;
+
+  my $userinfo = $dbh->selectrow_hashref("SELECT user_id,group_id,password,firstname,lastname,status".
+	" FROM users WHERE username=?",
+	undef, ($user) );
+  return if !$userinfo;
+  return if !$userinfo->{status};
+
+  if ($userinfo->{password} =~ m|^\$1\$([A-Za-z0-9/.]+)\$|) {
+    # native passwords (crypt-md5)
+    return if $userinfo->{password} ne unix_md5_crypt($pass,$1);
+  } elsif ($userinfo->{password} =~ /^[0-9a-f]{32}$/) {
+    # VegaDNS import (hex-coded MD5)
+    return if $userinfo->{password} ne md5_hex($pass);
+  } else {
+    # plaintext (convenient now and then)
+    return if $userinfo->{password} ne $pass;
+  }
+
+  return $userinfo;
+} # end login()
+
+
+## DNSDB::initActionLog()
+# Set up action logging.  Takes a database handle and user ID
+# Sets some internal globals and Does The Right Thing to set up a logging channel.
+# This sets up _log() to spew out log entries to the defined channel without worrying
+# about having to open a file or a syslog channel
+##fixme Need to call _initActionLog_blah() for various logging channels, configured
+# via dnsdb.conf, in $config{log_channel} or something
+# See https://secure.deepnet.cx/trac/dnsadmin/ticket/21
+sub initActionLog {
+  my $dbh = shift;
+  my $uid = shift;
+
+  return if !$uid;
+
+  # snag user info for logging.  there's got to be a way to not have to pass this back
+  # and forth from a caller, but web usage means no persistence we can rely on from
+  # the server side.
+  my ($username,$fullname) = $dbh->selectrow_array("SELECT username, firstname || ' ' || lastname".
+	" FROM users WHERE user_id=?", undef, ($uid));
+##fixme: errors are unpossible!
+
+  $userdata{username} = $username;
+  $userdata{userid} = $uid;
+  $userdata{fullname} = $fullname;
+
+  # convert to real check once we have other logging channels
+  # if ($config{log_channel} eq 'sql') {
+  #   Open Log, Sez Me!
+  # }
+
+} # end initActionLog
+
+
+## DNSDB::initPermissions()
+# Set up permissions global
+# Takes database handle and UID
+sub initPermissions {
+  my $dbh = shift;
+  my $uid = shift;
+
+#  %permissions = $(getPermissions($dbh,'user',$uid));
+  getPermissions($dbh, 'user', $uid, \%permissions);
+
+} # end initPermissions()
+
+
+## DNSDB::getPermissions()
+# Get permissions from DB
+# Requires DB handle, group or user flag, ID, and hashref.
+sub getPermissions {
+  my $dbh = shift;
+  my $type = shift;
+  my $id = shift;
+  my $hash = shift;
+
+  my $sql = qq(
+	SELECT
+	p.admin,p.self_edit,
+	p.group_create,p.group_edit,p.group_delete,
+	p.user_create,p.user_edit,p.user_delete,
+	p.domain_create,p.domain_edit,p.domain_delete,
+	p.record_create,p.record_edit,p.record_delete,p.record_locchg,
+	p.location_create,p.location_edit,p.location_delete,p.location_view
+	FROM permissions p
+	);
+  if ($type eq 'group') {
+    $sql .= qq(
+	JOIN groups g ON g.permission_id=p.permission_id
+	WHERE g.group_id=?
+	);
+  } else {
+    $sql .= qq(
+	JOIN users u ON u.permission_id=p.permission_id
+	WHERE u.user_id=?
+	);
+  }
+
+  my $sth = $dbh->prepare($sql);
+
+  $sth->execute($id) or die "argh: ".$sth->errstr;
+
+#  my $permref = $sth->fetchrow_hashref;
+#  return $permref;
+#  $hash = $permref;
+# Eww.  Need to learn how to forcibly drop a hashref onto an existing hash.
+  ($hash->{admin},$hash->{self_edit},
+	$hash->{group_create},$hash->{group_edit},$hash->{group_delete},
+	$hash->{user_create},$hash->{user_edit},$hash->{user_delete},
+	$hash->{domain_create},$hash->{domain_edit},$hash->{domain_delete},
+	$hash->{record_create},$hash->{record_edit},$hash->{record_delete},$hash->{record_locchg},
+	$hash->{location_create},$hash->{location_edit},$hash->{location_delete},$hash->{location_view}
+	) = $sth->fetchrow_array;
+
+} # end getPermissions()
+
+
+## DNSDB::changePermissions()
+# Update an ACL entry
+# Takes a db handle, type, owner-id, and hashref for the changed permissions.
+sub changePermissions {
+  my $dbh = shift;
+  my $type = shift;
+  my $id = shift;
+  my $newperms = shift;
+  my $inherit = shift || 0;
+
+  my $resultmsg = '';
+
+  # see if we're switching from inherited to custom.  for bonus points,
+  # snag the permid and parent permid anyway, since we'll need the permid
+  # to set/alter custom perms, and both if we're switching from custom to
+  # inherited.
+  my $sth = $dbh->prepare("SELECT (u.permission_id=g.permission_id) AS was_inherited,u.permission_id,g.permission_id,".
+	($type eq 'user' ? 'u.group_id,u.username' : 'u.parent_group_id,u.group_name').
+	" FROM ".($type eq 'user' ? 'users' : 'groups')." u ".
+	" JOIN groups g ON u.".($type eq 'user' ? '' : 'parent_')."group_id=g.group_id ".
+	" WHERE u.".($type eq 'user' ? 'user' : 'group')."_id=?");
+  $sth->execute($id);
+
+  my ($wasinherited,$permid,$parpermid,$parid,$name) = $sth->fetchrow_array;
+
+# hack phtoui
+# group id 1 is "special" in that it's it's own parent (err...  possibly.)
+# may make its parent id 0 which doesn't exist, and as a bonus is Perl-false.
+  $wasinherited = 0 if ($type eq 'group' && $id == 1);
+
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  # Wrap all the SQL in a transaction
+  eval {
+    if ($inherit) {
+
+      $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='t',permission_id=? ".
+	"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($parpermid, $id) );
+      $dbh->do("DELETE FROM permissions WHERE permission_id=?", undef, ($permid) );
+
+    } else {
+
+      if ($wasinherited) {	# munge new permission entry in if we're switching from inherited perms
+##fixme: need to add semirecursive bit to properly munge inherited permission ID on subgroups and users
+# ... if'n'when we have groups with fully inherited permissions.
+        # SQL is coo
+	$dbh->do("INSERT INTO permissions ($permlist,".($type eq 'user' ? 'user' : 'group')."_id) ".
+		"SELECT $permlist,? FROM permissions WHERE permission_id=?", undef, ($id,$permid) );
+	($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions ".
+		"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($id) );
+	$dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='f',permission_id=? ".
+		"WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($permid, $id) );
+      }
+
+      # and now set the permissions we were passed
+      foreach (@permtypes) {
+	if (defined ($newperms->{$_})) {
+	  $dbh->do("UPDATE permissions SET $_=? WHERE permission_id=?", undef, ($newperms->{$_},$permid) );
+	}
+      }
+
+    } # (inherited->)? custom
+
+    if ($type eq 'user') {
+      $resultmsg = "Updated permissions for user $name";
+    } else {
+      $resultmsg = "Updated default permissions for group $name";
+    }
+    _log($dbh, (group_id => ($type eq 'user' ? $parid : $id), entry => $resultmsg));
+    $dbh->commit;
+  }; # end eval
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    return ('FAIL',"Error changing permissions: $msg");
+  }
+
+  return ('OK',$resultmsg);
+} # end changePermissions()
+
+
+## DNSDB::comparePermissions()
+# Compare two permission hashes
+# Returns '>', '<', '=', '!'
+sub comparePermissions {
+  my $p1 = shift;
+  my $p2 = shift;
+
+  my $retval = '=';	# assume equality until proven otherwise
+
+  no warnings "uninitialized";
+
+  foreach (@permtypes) {
+    next if $p1->{$_} == $p2->{$_};	# equal is good
+    if ($p1->{$_} && !$p2->{$_}) {
+      if ($retval eq '<') {	# if we've already found an unequal pair where
+        $retval = '!';		# $p2 has more access, and we now find a pair
+        last;			# where $p1 has more access, the overall access
+      }				# is neither greater or lesser, it's unequal.
+      $retval = '>';
+    }
+    if (!$p1->{$_} && $p2->{$_}) {
+      if ($retval eq '>') {	# if we've already found an unequal pair where
+        $retval = '!';		# $p1 has more access, and we now find a pair
+        last;			# where $p2 has more access, the overall access
+      }				# is neither greater or lesser, it's unequal.
+      $retval = '<';
+    }
+  }
+  return $retval;
+} # end comparePermissions()
+
+
+## DNSDB::changeGroup()
+# Change group ID of an entity
+# Takes a database handle, entity type, entity ID, and new group ID
+sub changeGroup {
+  my $dbh = shift;
+  my $type = shift;
+  my $id = shift;
+  my $newgrp = shift;
+
+##fixme:  fail on not enough args
+  #return ('FAIL', "Missing 
+
+  return ('FAIL', "Can't change the group of a $type")
+	unless grep /^$type$/, ('domain','revzone','user','group');	# could be extended for defrecs?
+
+  # Collect some names for logging and messages
+  my $entname;
+  if ($type eq 'domain') {
+    $entname = domainName($dbh, $id);
+  } elsif ($type eq 'revzone') {
+    $entname = revName($dbh, $id);
+  } elsif ($type eq 'user') {
+    $entname = userFullName($dbh, $id, '%u');
+  } elsif ($type eq 'group') {
+    $entname = groupName($dbh, $id);
+  }
+
+  my ($oldgid) = $dbh->selectrow_array("SELECT group_id FROM $par_tbl{$type} WHERE $id_col{$type}=?",
+	undef, ($id));
+  my $oldgname = groupName($dbh, $oldgid);
+  my $newgname = groupName($dbh, $newgrp);
+
+  return ('FAIL', "Can't move things into a group that doesn't exist") if !$newgname;
+
+  return ('WARN', "Nothing to do, new group is the same as the old group") if $oldgid == $newgrp;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  eval {
+    $dbh->do("UPDATE $par_tbl{$type} SET group_id=? WHERE $id_col{$type}=?", undef, ($newgrp, $id));
+    # Log the change in both the old and new groups
+    _log($dbh, (group_id => $oldgid, entry => "Moved $type $entname from $oldgname to $newgname"));
+    _log($dbh, (group_id => $newgrp, entry => "Moved $type $entname from $oldgname to $newgname"));
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $oldgid, entry => "Error moving $type $entname to $newgname: $msg"));
+      $dbh->commit;	# since we enabled transactions earlier
+    }
+    return ('FAIL',"Error moving $type $entname to $newgname: $msg");
+  }
+
+  return ('OK',"Moved $type $entname from $oldgname to $newgname");
+} # end changeGroup()
+
+
+##
+## Processing subs
+##
+
+## DNSDB::addDomain()
+# Add a domain
+# Takes a database handle, domain name, numeric group, boolean(ish) state (active/inactive),
+# and user info hash (for logging).
+# Returns a status code and message
+sub addDomain {
+  $errstr = '';
+  my $dbh = shift;
+  return ('FAIL',"Need database handle") if !$dbh;
+  my $domain = shift;
+  return ('FAIL',"Domain must not be blank") if !$domain;
+  my $group = shift;
+  return ('FAIL',"Need group") if !defined($group);
+  my $state = shift;
+  return ('FAIL',"Need domain status") if !defined($state);
+
+  $state = 1 if $state =~ /^active$/;
+  $state = 1 if $state =~ /^on$/;
+  $state = 0 if $state =~ /^inactive$/;
+  $state = 0 if $state =~ /^off$/;
+
+  return ('FAIL',"Invalid domain status") if $state !~ /^\d+$/;
+
+  return ('FAIL', "Invalid characters in domain") if $domain !~ /^[a-zA-Z0-9_.-]+$/;
+
+  my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)");
+  my $dom_id;
+
+# quick check to start to see if we've already got one
+  $sth->execute($domain);
+  ($dom_id) = $sth->fetchrow_array;
+
+  return ('FAIL', "Domain already exists") if $dom_id;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  # Wrap all the SQL in a transaction
+  eval {
+    # insert the domain...
+    $dbh->do("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)", undef, ($domain, $group, $state));
+
+    # get the ID...
+    ($dom_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)",
+	undef, ($domain));
+
+    _log($dbh, (domain_id => $dom_id, group_id => $group,
+	entry => "Added ".($state ? 'active' : 'inactive')." domain $domain"));
+
+    # ... and now we construct the standard records from the default set.  NB:  group should be variable.
+    my $sth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
+    my $sth_in = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,distance,weight,port,ttl)".
+	" VALUES ($dom_id,?,?,?,?,?,?,?)");
+    $sth->execute($group);
+    while (my ($host,$type,$val,$dist,$weight,$port,$ttl) = $sth->fetchrow_array()) {
+      $host =~ s/DOMAIN/$domain/g;
+      $val =~ s/DOMAIN/$domain/g;
+      $sth_in->execute($host,$type,$val,$dist,$weight,$port,$ttl);
+      if ($typemap{$type} eq 'SOA') {
+	my @tmp1 = split /:/, $host;
+	my @tmp2 = split /:/, $val;
+	_log($dbh, (domain_id => $dom_id, group_id => $group,
+		entry => "[new $domain] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
+		"[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl"));
+      } else {
+	my $logentry = "[new $domain] Added record '$host $typemap{$type}";
+	$logentry .= " [distance $dist]" if $typemap{$type} eq 'MX';
+	$logentry .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$type} eq 'SRV';
+	_log($dbh, (domain_id => $dom_id, group_id => $group,
+		entry => $logentry." $val', TTL $ttl"));
+      }
+    }
+
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    _log($dbh, (group_id => $group, entry => "Failed adding domain $domain ($msg)"))
+	if $config{log_failures};
+    $dbh->commit;	# since we enabled transactions earlier
+    return ('FAIL',$msg);
+  } else {
+    return ('OK',$dom_id);
+  }
+} # end addDomain
+
+
+## DNSDB::delZone()
+# Delete a forward or reverse zone.
+# Takes a database handle, zone ID, and forward/reverse flag.
+# for now, just delete the records, then the domain.
+# later we may want to archive it in some way instead (status code 2, for example?)
+sub delZone {
+  my $dbh = shift;
+  my $zoneid = shift;
+  my $revrec = shift;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  my $msg = '';
+  my $failmsg = '';
+  my $zone = ($revrec eq 'n' ? domainName($dbh, $zoneid) : revName($dbh, $zoneid));
+
+  return ('FAIL', ($revrec eq 'n' ? 'Domain' : 'Reverse zone')." ID $zoneid doesn't exist") if !$zone;
+
+  # Set this up here since we may use if if $config{log_failures} is enabled
+  my %loghash;
+  $loghash{domain_id} = $zoneid if $revrec eq 'n';
+  $loghash{rdns_id} = $zoneid if $revrec eq 'y';
+  $loghash{group_id} = parentID($dbh,
+	(id => $zoneid, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec) );
+
+  # Wrap all the SQL in a transaction
+  eval {
+    # Disentangle custom record types before removing the
+    # ones that are only in the zone to be deleted
+    if ($revrec eq 'n') {
+      my $sth = $dbh->prepare("UPDATE records SET type=?,domain_id=0 WHERE domain_id=? AND type=?");
+      $failmsg = "Failure converting multizone types to single-zone";
+      $sth->execute($reverse_typemap{PTR}, $zoneid, 65280);
+      $sth->execute($reverse_typemap{PTR}, $zoneid, 65281);
+      $sth->execute(65282, $zoneid, 65283);
+      $sth->execute(65282, $zoneid, 65284);
+      $failmsg = "Failure removing domain records";
+      $dbh->do("DELETE FROM records WHERE domain_id=?", undef, ($zoneid));
+      $failmsg = "Failure removing domain";
+      $dbh->do("DELETE FROM domains WHERE domain_id=?", undef, ($zoneid));
+    } else {
+      my $sth = $dbh->prepare("UPDATE records SET type=?,rdns_id=0 WHERE rdns_id=? AND type=?");
+      $failmsg = "Failure converting multizone types to single-zone";
+      $sth->execute($reverse_typemap{A}, $zoneid, 65280);
+      $sth->execute($reverse_typemap{AAAA}, $zoneid, 65281);
+# We don't have an "A template" or "AAAA template" type, although it might be useful for symmetry.
+#      $sth->execute(65286?, $zoneid, 65283);
+#      $sth->execute(65286?, $zoneid, 65284);
+      $failmsg = "Failure removing reverse records";
+      $dbh->do("DELETE FROM records WHERE rdns_id=?", undef, ($zoneid));
+      $failmsg = "Failure removing reverse zone";
+      $dbh->do("DELETE FROM revzones WHERE rdns_id=?", undef, ($zoneid));
+    }
+
+    $msg = "Deleted ".($revrec eq 'n' ? 'domain' : 'reverse zone')." $zone";
+    $loghash{entry} = $msg;
+    _log($dbh, %loghash);
+
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    $msg = $@;
+    eval { $dbh->rollback; };
+    $loghash{entry} = "Error deleting $zone: $msg ($failmsg)";
+    if ($config{log_failures}) {
+      _log($dbh, %loghash);
+      $dbh->commit;	# since we enabled transactions earlier
+    }
+    return ('FAIL', $loghash{entry});
+  } else {
+    return ('OK', $msg);
+  }
+
+} # end delZone()
+
+
+## DNSDB::domainName()
+# Return the domain name based on a domain ID
+# Takes a database handle and the domain ID
+# Returns the domain name or undef on failure
+sub domainName {
+  $errstr = '';
+  my $dbh = shift;
+  my $domid = shift;
+  my ($domname) = $dbh->selectrow_array("SELECT domain FROM domains WHERE domain_id=?", undef, ($domid) );
+  $errstr = $DBI::errstr if !$domname;
+  return $domname if $domname;
+} # end domainName()
+
+
+## DNSDB::revName()
+# Return the reverse zone name based on an rDNS ID
+# Takes a database handle and the rDNS ID
+# Returns the reverse zone name or undef on failure
+sub revName {
+  $errstr = '';
+  my $dbh = shift;
+  my $revid = shift;
+  my ($revname) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id=?", undef, ($revid) );
+  $errstr = $DBI::errstr if !$revname;
+  return $revname if $revname;
+} # end revName()
+
+
+## DNSDB::domainID()
+# Takes a database handle and domain name
+# Returns the domain ID number
+sub domainID {
+  $errstr = '';
+  my $dbh = shift;
+  my $domain = shift;
+  my ($domid) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)",
+	undef, ($domain) );
+  $errstr = $DBI::errstr if !$domid;
+  return $domid if $domid;
+} # end domainID()
+
+
+## DNSDB::revID()
+# Takes a database handle and reverse zone name
+# Returns the rDNS ID number
+sub revID {
+  $errstr = '';
+  my $dbh = shift;
+  my $revzone = shift;
+  my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet=?", undef, ($revzone) );
+  $errstr = $DBI::errstr if !$revid;
+  return $revid if $revid;
+} # end revID()
+
+
+## DNSDB::addRDNS
+# Adds a reverse DNS zone
+# Takes a database handle, CIDR block, reverse DNS pattern, numeric group,
+# and boolean(ish) state (active/inactive)
+# Returns a status code and message
+sub addRDNS {
+  my $dbh = shift;
+  my $zone = NetAddr::IP->new(shift);
+  return ('FAIL',"Zone name must be a valid CIDR netblock") unless ($zone && $zone->addr !~ /^0/);
+  my $revpatt = shift;	# construct a custom (A/AAAA+)? PTR template record
+  my $group = shift;
+  my $state = shift;
+
+  $state = 1 if $state =~ /^active$/;
+  $state = 1 if $state =~ /^on$/;
+  $state = 0 if $state =~ /^inactive$/;
+  $state = 0 if $state =~ /^off$/;
+
+  return ('FAIL',"Invalid zone status") if $state !~ /^\d+$/;
+
+# quick check to start to see if we've already got one
+  my ($rdns_id) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet=?", undef, ("$zone"));
+
+  return ('FAIL', "Zone already exists") if $rdns_id;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  my $warnstr = '';
+  my $defttl = 3600;	# 1 hour should be reasonable.  And unless things have gone horribly
+			# wrong, we should have a value to override this anyway.
+
+  # Wrap all the SQL in a transaction
+  eval {
+    # insert the domain...
+    $dbh->do("INSERT INTO revzones (revnet,group_id,status) VALUES (?,?,?)", undef, ($zone, $group, $state));
+
+    # get the ID...
+    ($rdns_id) = $dbh->selectrow_array("SELECT currval('revzones_rdns_id_seq')");
+
+    _log($dbh, (rdns_id => $rdns_id, group_id => $group,
+	entry => "Added ".($state ? 'active' : 'inactive')." reverse zone $zone"));
+
+    # ... and now we construct the standard records from the default set.  NB:  group should be variable.
+    my $sth = $dbh->prepare("SELECT host,type,val,ttl FROM default_rev_records WHERE group_id=?");
+    my $sth_in = $dbh->prepare("INSERT INTO records (rdns_id,domain_id,host,type,val,ttl)".
+	" VALUES ($rdns_id,?,?,?,?,?)");
+    $sth->execute($group);
+    while (my ($host,$type,$val,$ttl) = $sth->fetchrow_array()) {
+      # Silently skip v4/v6 mismatches.  This is not an error, this is expected.
+      if ($zone->{isv6}) {
+	next if ($type == 65280 || $type == 65283);
+      } else {
+	next if ($type == 65281 || $type == 65284);
+      }
+
+      $host =~ s/ADMINDOMAIN/$config{domain}/g;
+
+      # Check to make sure the IP stubs will fit in the zone.  Under most usage failures here should be rare.
+      # On failure, tack a note on to a warning string and continue without adding this record.
+      # While we're at it, we substitute $zone for ZONE in the value.
+      if ($val eq 'ZONE') {
+	next if $revpatt;	# If we've got a pattern, we skip the default record version.
+##fixme?  do we care if we have multiple whole-zone templates?
+	$val = $zone->network;
+      } elsif ($val =~ /ZONE/) {
+	my $tmpval = $val;
+	$tmpval =~ s/ZONE//;
+	# Bend the rules and allow single-trailing-number PTR or PTR template records to be inserted
+	# as either v4 or v6.  May make this an off-by-default config flag
+	# Note that the origin records that may trigger this **SHOULD** already have ZONE,\d
+	if ($type == 12 || $type == 65282) {
+	  $tmpval =~ s/[,.]/::/ if ($tmpval =~ /^[,.]\d+$/ && $zone->{isv6});
+	  $tmpval =~ s/[,:]+/./ if ($tmpval =~ /^(?:,|::)\d+$/ && !$zone->{isv6});
+	}
+	my $addr;
+	if (_ipparent($dbh, 'n', 'y', \$tmpval, $rdns_id, \$addr)) {
+	  $val = $addr->addr;
+	} else {
+	  $warnstr .= "\nDefault record '$val $typemap{$type} $host' doesn't fit in $zone, skipping";
+	  next;
+	}
+      }
+
+      # Substitute $zone for ZONE in the hostname, but only for non-NS records.
+      # NS records get this substitution on the value instead.
+      $host = _ZONE($zone, $host) if $type != 2;
+
+      # Fill in the forward domain ID if we can find it, otherwise:
+      # Coerce type down to PTR or PTR template if we can't
+      my $domid = 0;
+      if ($type >= 65280) {
+	if (!($domid = _hostparent($dbh, $host))) {
+	  $warnstr .= "\nRecord added as PTR instead of $typemap{$type};  domain not found for $host";
+	  $type = $reverse_typemap{PTR};
+	  $domid = 0;	# just to be explicit.
+	}
+      }
+
+      $sth_in->execute($domid,$host,$type,$val,$ttl);
+
+      if ($typemap{$type} eq 'SOA') {
+	my @tmp1 = split /:/, $host;
+	my @tmp2 = split /:/, $val;
+	_log($dbh, (rdns_id => $rdns_id, group_id => $group,
+		entry => "[new $zone] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
+		"[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl"));
+	$defttl = $tmp2[3];
+      } else {
+	my $logentry = "[new $zone] Added record '$host $typemap{$type}";
+	_log($dbh, (rdns_id => $rdns_id, domain_id => $domid, group_id => $group,
+		entry => $logentry." $val', TTL $ttl"));
+      }
+    }
+
+    # Generate record based on provided pattern.  
+    if ($revpatt) {
+      my $host;
+      my $type = ($zone->{isv6} ? 65284 : 65283);
+      my $val = $zone->network;
+
+      # Substitute $zone for ZONE in the hostname.
+      $host = _ZONE($zone, $revpatt);
+
+      my $domid = 0;
+      if (!($domid = _hostparent($dbh, $host))) {
+	$warnstr .= "\nDefault pattern added as PTR template instead of $typemap{$type};  domain not found for $host";
+	$type = 65282;
+	$domid = 0;	# just to be explicit.
+      }
+
+      $sth_in->execute($domid,$host,$type,$val,$defttl);
+      my $logentry = "[new $zone] Added record '$host $typemap{$type}";
+      _log($dbh, (rdns_id => $rdns_id, domain_id => $domid, group_id => $group,
+	entry => $logentry." $val', TTL $defttl from pattern"));
+    }
+
+    # If there are warnings (presumably about default records skipped for cause) log them
+    _log($dbh, (rdns_id => $rdns_id, group_id => $group, entry => "Warning(s) adding $zone:$warnstr"))
+	if $warnstr;
+
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    _log($dbh, (group_id => $group, entry => "Failed adding reverse zone $zone ($msg)"))
+	if $config{log_failures};
+    $dbh->commit;	# since we enabled transactions earlier
+    return ('FAIL',$msg);
+  } else {
+    my $retcode = 'OK';
+    if ($warnstr) {
+      $resultstr = $warnstr;
+      $retcode = 'WARN';
+    }
+    return ($retcode, $rdns_id);
+  }
+
+} # end addRDNS()
+
+
+## DNSDB::getZoneCount
+# Get count of zones in group or groups
+# Takes a database handle and hash containing:
+#  - the "current" group
+#  - an array of "acceptable" groups
+#  - a flag for forward/reverse zones
+#  - Optionally accept a "starts with" and/or "contains" filter argument
+# Returns an integer count of the resulting zone list.
+sub getZoneCount {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  $args{filter} =~ s/\./\[\.\]/g if $args{filter};	# only match literal dots, usually in reverse zones
+  push @filterargs, $args{filter} if $args{filter};
+
+  my $sql;
+  # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
+  if ($args{revrec} eq 'n') {
+    $sql = "SELECT count(*) FROM domains".
+	" WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND domain ~* ?" : '').
+	($args{filter} ? " AND domain ~* ?" : '');
+  } else {
+    $sql = "SELECT count(*) FROM revzones".
+	" WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
+	($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
+  }
+  my ($count) = $dbh->selectrow_array($sql, undef, @filterargs);
+  return $count;
+} # end getZoneCount()
+
+
+## DNSDB::getZoneList()
+# Get a list of zones in the specified group(s)
+# Takes the same arguments as getZoneCount() above
+# Returns a reference to an array of hashrefs suitable for feeding to HTML::Template
+sub getZoneList {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @zonelist;
+
+  $args{sortorder} = 'ASC' if !grep /^$args{sortorder}$/, ('ASC','DESC');
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
+
+  my @filterargs;
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  $args{filter} =~ s/\./\[\.\]/g if $args{filter};	# only match literal dots, usually in reverse zones
+  push @filterargs, $args{filter} if $args{filter};
+
+  my $sql;
+  # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
+  if ($args{revrec} eq 'n') {
+    $args{sortby} = 'domain' if !grep /^$args{sortby}$/, ('domain','group','status');
+    $sql = "SELECT domain_id,domain,status,groups.group_name AS group FROM domains".
+	" INNER JOIN groups ON domains.group_id=groups.group_id".
+	" WHERE domains.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND domain ~* ?" : '').
+	($args{filter} ? " AND domain ~* ?" : '');
+  } else {
+##fixme:  arguably startwith here is irrelevant.  depends on the UI though.
+    $args{sortby} = 'revnet' if !grep /^$args{sortby}$/, ('revnet','group','status');
+    $sql = "SELECT rdns_id,revnet,status,groups.group_name AS group FROM revzones".
+	" INNER JOIN groups ON revzones.group_id=groups.group_id".
+	" WHERE revzones.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
+	($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
+  }
+  # A common tail.
+  $sql .= " ORDER BY ".($args{sortby} eq 'group' ? 'groups.group_name' : $args{sortby})." $args{sortorder} ".
+	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage}".
+	" OFFSET ".$args{offset}*$config{perpage});
+  my $sth = $dbh->prepare($sql);
+  $sth->execute(@filterargs);
+  my $rownum = 0;
+
+  while (my @data = $sth->fetchrow_array) {
+    my %row;
+    $row{domain_id} = $data[0];
+    $row{domain} = $data[1];
+    $row{status} = $data[2];
+    $row{group} = $data[3];
+    push @zonelist, \%row;
+  }
+
+  return \@zonelist;
+} # end getZoneList()
+
+
+## DNSDB::getZoneLocation()
+# Retrieve the default location for a zone.
+# Takes a database handle, forward/reverse flag, and zone ID
+sub getZoneLocation {
+  my $dbh = shift;
+  my $revrec = shift;
+  my $zoneid = shift;
+
+  my ($loc) = $dbh->selectrow_array("SELECT default_location FROM ".
+	($revrec eq 'n' ? 'domains WHERE domain_id = ?' : 'revzones WHERE rdns_id = ?'),
+	undef, ($zoneid));
+  return $loc;
+} # end getZoneLocation()
+
+
+## DNSDB::addGroup()
+# Add a group
+# Takes a database handle, group name, parent group, hashref for permissions,
+# and optional template-vs-cloneme flag for the default records
+# Returns a status code and message
+sub addGroup {
+  $errstr = '';
+  my $dbh = shift;
+  my $groupname = shift;
+  my $pargroup = shift;
+  my $permissions = shift;
+
+  # 0 indicates "custom", hardcoded.
+  # Any other value clones that group's default records, if it exists.
+  my $inherit = shift || 0;	
+##fixme:  need a flag to indicate clone records or <?> ?
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  my ($group_id) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($groupname));
+
+  return ('FAIL', "Group already exists") if $group_id;
+
+  # Wrap all the SQL in a transaction
+  eval {
+    $dbh->do("INSERT INTO groups (parent_group_id,group_name) VALUES (?,?)", undef, ($pargroup, $groupname) );
+
+    my ($groupid) = $dbh->selectrow_array("SELECT currval('groups_group_id_seq')");
+
+    # We work through the whole set of permissions instead of specifying them so
+    # that when we add a new permission, we don't have to change the code anywhere
+    # that doesn't explicitly deal with that specific permission.
+    my @permvals;
+    foreach (@permtypes) {
+      if (!defined ($permissions->{$_})) {
+	push @permvals, 0;
+      } else {
+	push @permvals, $permissions->{$_};
+      }
+    }
+    $dbh->do("INSERT INTO permissions (group_id,$permlist) values (?".',?'x($#permtypes+1).")",
+	undef, ($groupid, @permvals) );
+    my ($permid) = $dbh->selectrow_array("SELECT currval('permissions_permission_id_seq')");
+    $dbh->do("UPDATE groups SET permission_id=$permid WHERE group_id=$groupid");
+
+    # Default records
+    my $sthf = $dbh->prepare("INSERT INTO default_records (group_id,host,type,val,distance,weight,port,ttl) ".
+	"VALUES ($groupid,?,?,?,?,?,?,?)");
+    my $sthr = $dbh->prepare("INSERT INTO default_rev_records (group_id,host,type,val,ttl) ".
+	"VALUES ($groupid,?,?,?,?)");
+    if ($inherit) {
+      # Duplicate records from parent.  Actually relying on inherited records feels
+      # very fragile, and it would be problematic to roll over at a later time.
+      my $sth2 = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
+      $sth2->execute($pargroup);
+      while (my @clonedata = $sth2->fetchrow_array) {
+	$sthf->execute(@clonedata);
+      }
+      # And now the reverse records
+      $sth2 = $dbh->prepare("SELECT host,type,val,ttl FROM default_rev_records WHERE group_id=?");
+      $sth2->execute($pargroup);
+      while (my @clonedata = $sth2->fetchrow_array) {
+	$sthr->execute(@clonedata);
+      }
+    } else {
+##fixme: Hardcoding is Bad, mmmmkaaaay?
+      # reasonable basic defaults for SOA, MX, NS, and minimal hosting
+      # could load from a config file, but somewhere along the line we need hardcoded bits.
+      $sthf->execute('ns1.example.com:hostmaster.example.com', 6, '10800:3600:604800:10800', 0, 0, 0, 86400);
+      $sthf->execute('DOMAIN', 1, '192.168.4.2', 0, 0, 0, 7200);
+      $sthf->execute('DOMAIN', 15, 'mx.example.com', 10, 0, 0, 7200);
+      $sthf->execute('DOMAIN', 2, 'ns1.example.com', 0, 0, 0, 7200);
+      $sthf->execute('DOMAIN', 2, 'ns2.example.com', 0, 0, 0, 7200);
+      $sthf->execute('www.DOMAIN', 5, 'DOMAIN', 0, 0, 0, 7200);
+      # reasonable basic defaults for generic reverse zone.  Same as initial SQL tabledef.
+      $sthr->execute('hostmaster.ADMINDOMAIN:ns1.ADMINDOMAIN', 6, '10800:3600:604800:10800', 86400);
+      $sthr->execute('unused-%r.ADMINDOMAIN', 65283, 'ZONE', 3600);
+    }
+
+    _log($dbh, (group_id => $pargroup, entry => "Added group $groupname") );
+
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $pargroup, entry => "Failed to add group $groupname: $msg") );
+      $dbh->commit;
+    }
+    return ('FAIL',$msg);
+  }
+
+  return ('OK','OK');
+} # end addGroup()
+
+
+## DNSDB::delGroup()
+# Delete a group.
+# Takes a group ID
+# Returns a status code and message
+sub delGroup {
+  my $dbh = shift;
+  my $groupid = shift;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+##fixme:  locate "knowable" error conditions and deal with them before the eval
+# ... or inside, whatever.
+# -> domains still exist in group
+# -> ...
+  my $failmsg = '';
+  my $resultmsg = '';
+
+  # collect some pieces for logging and error messages
+  my $groupname = groupName($dbh,$groupid);
+  my $parid = parentID($dbh, (id => $groupid, type => 'group'));
+
+  # Wrap all the SQL in a transaction
+  eval {
+    # Check for Things in the group
+    $failmsg = "Can't remove group $groupname";
+    my ($grpcnt) = $dbh->selectrow_array("SELECT count(*) FROM groups WHERE parent_group_id=?", undef, ($groupid));
+    die "$grpcnt groups still in group\n" if $grpcnt;
+    my ($domcnt) = $dbh->selectrow_array("SELECT count(*) FROM domains WHERE group_id=?", undef, ($groupid));
+    die "$domcnt domains still in group\n" if $domcnt;
+    my ($usercnt) = $dbh->selectrow_array("SELECT count(*) FROM users WHERE group_id=?", undef, ($groupid));
+    die "$usercnt users still in group\n" if $usercnt;
+
+    $failmsg = "Failed to delete default records for $groupname";
+    $dbh->do("DELETE from default_records WHERE group_id=?", undef, ($groupid));
+    $failmsg = "Failed to delete default reverse records for $groupname";
+    $dbh->do("DELETE from default_rev_records WHERE group_id=?", undef, ($groupid));
+    $failmsg = "Failed to remove group $groupname";
+    $dbh->do("DELETE from groups WHERE group_id=?", undef, ($groupid));
+
+    _log($dbh, (group_id => $parid, entry => "Deleted group $groupname"));
+    $resultmsg = "Deleted group $groupname";
+
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $parid, entry => "$failmsg: $msg"));
+      $dbh->commit;	# since we enabled transactions earlier
+    }
+    return ('FAIL',"$failmsg: $msg");
+  }
+
+  return ('OK',$resultmsg);
+} # end delGroup()
+
+
+## DNSDB::getChildren()
+# Get a list of all groups whose parent^n is group <n>
+# Takes a database handle, group ID, reference to an array to put the group IDs in,
+# and an optional flag to return only immediate children or all children-of-children
+# default to returning all children
+# Calls itself
+sub getChildren {
+  $errstr = '';
+  my $dbh = shift;
+  my $rootgroup = shift;
+  my $groupdest = shift;
+  my $immed = shift || 'all';
+
+  # special break for default group;  otherwise we get stuck.
+  if ($rootgroup == 1) {
+    # by definition, group 1 is the Root Of All Groups
+    my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE NOT (group_id=1)".
+	($immed ne 'all' ? " AND parent_group_id=1" : '')." ORDER BY group_name");
+    $sth->execute;
+    while (my @this = $sth->fetchrow_array) {
+      push @$groupdest, @this;
+    }
+  } else {
+    my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE parent_group_id=? ORDER BY group_name");
+    $sth->execute($rootgroup);
+    return if $sth->rows == 0;
+    my @grouplist;
+    while (my ($group) = $sth->fetchrow_array) {
+      push @$groupdest, $group;
+      getChildren($dbh,$group,$groupdest) if $immed eq 'all';
+    }
+  }
+} # end getChildren()
+
+
+## DNSDB::groupName()
+# Return the group name based on a group ID
+# Takes a database handle and the group ID
+# Returns the group name or undef on failure
+sub groupName {
+  $errstr = '';
+  my $dbh = shift;
+  my $groupid = shift;
+  my $sth = $dbh->prepare("SELECT group_name FROM groups WHERE group_id=?");
+  $sth->execute($groupid);
+  my ($groupname) = $sth->fetchrow_array();
+  $errstr = $DBI::errstr if !$groupname;
+  return $groupname if $groupname;
+} # end groupName
+
+
+## DNSDB::getGroupCount()
+# Get count of subgroups in group or groups
+# Takes a database handle and hash containing:
+#  - the "current" group
+#  - an array of "acceptable" groups
+#  - Optionally accept a "starts with" and/or "contains" filter argument
+# Returns an integer count of the resulting group list.
+sub getGroupCount {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+  my $sql = "SELECT count(*) FROM groups ".
+	"WHERE parent_group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND group_name ~* ?" : '').
+	($args{filter} ? " AND group_name ~* ?" : '');
+  my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
+  $errstr = $dbh->errstr if !$count;
+  return $count;
+} # end getGroupCount
+
+
+## DNSDB::getGroupList()
+# Get a list of sub^n-groups in the specified group(s)
+# Takes the same arguments as getGroupCount() above
+# Returns an arrayref containing hashrefs suitable for feeding straight to HTML::Template
+sub getGroupList {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+  # protection against bad or missing arguments
+  $args{sortorder} = 'ASC' if !$args{sortorder};
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
+
+  # munge sortby for columns in database
+  $args{sortby} = 'g.group_name' if $args{sortby} eq 'group';
+  $args{sortby} = 'g2.group_name' if $args{sortby} eq 'parent';
+
+  my $sql = q(SELECT g.group_id AS groupid, g.group_name AS groupname, g2.group_name AS pgroup
+	FROM groups g
+	INNER JOIN groups g2 ON g2.group_id=g.parent_group_id
+	).
+	" WHERE g.parent_group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND g.group_name ~* ?" : '').
+	($args{filter} ? " AND g.group_name ~* ?" : '').
+	" GROUP BY g.group_id, g.group_name, g2.group_name ".
+	" ORDER BY $args{sortby} $args{sortorder} ".
+	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage} OFFSET ".$args{offset}*$config{perpage});
+  my $glist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
+  $errstr = $dbh->errstr if !$glist;
+
+  # LEFT JOINs make the result set balloon beyond sanity just to include counts;
+  # this means there's lots of crunching needed to trim the result set back down.
+  # So instead we track the order of the groups, and push the counts into the
+  # arrayref result separately.
+##fixme:  put this whole sub in a transaction?  might be
+# needed for accurate results on very busy systems.
+##fixme:  large group lists need prepared statements?
+#my $ucsth = $dbh->prepare("SELECT count(*) FROM users WHERE group_id=?");
+#my $dcsth = $dbh->prepare("SELECT count(*) FROM domains WHERE group_id=?");
+#my $rcsth = $dbh->prepare("SELECT count(*) FROM revzones WHERE group_id=?");
+  foreach (@{$glist}) {
+    my ($ucnt) = $dbh->selectrow_array("SELECT count(*) FROM users WHERE group_id=?", undef, ($$_{groupid}));
+    $$_{nusers} = $ucnt;
+    my ($dcnt) = $dbh->selectrow_array("SELECT count(*) FROM domains WHERE group_id=?", undef, ($$_{groupid}));
+    $$_{ndomains} = $dcnt;
+    my ($rcnt) = $dbh->selectrow_array("SELECT count(*) FROM revzones WHERE group_id=?", undef, ($$_{groupid}));
+    $$_{nrevzones} = $rcnt;
+  }
+
+  return $glist;
+} # end getGroupList
+
+
+## DNSDB::groupID()
+# Return the group ID based on the group name
+# Takes a database handle and the group name
+# Returns the group ID or undef on failure
+sub groupID {
+  $errstr = '';
+  my $dbh = shift;
+  my $group = shift;
+  my ($grpid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($group) );
+  $errstr = $DBI::errstr if !$grpid;
+  return $grpid if $grpid;
+} # end groupID()
+
+
+## DNSDB::addUser()
+# Add a user.
+# Takes a DB handle, username, group ID, password, state (active/inactive).
+# Optionally accepts:
+#   user type (user/admin)	- defaults to user
+#   permissions string		- defaults to inherit from group
+#      three valid forms:
+#	i		     - Inherit permissions
+#	c:<user_id>	     - Clone permissions from <user_id>
+#	C:<permission list>  - Set these specific permissions
+#   first name			- defaults to username
+#   last name			- defaults to blank
+#   phone			- defaults to blank (could put other data within column def)
+# Returns (OK,<uid>) on success, (FAIL,<message>) on failure
+sub addUser {
+  $errstr = '';
+  my $dbh = shift;
+  my $username = shift;
+  my $group = shift;
+  my $pass = shift;
+  my $state = shift;
+
+  return ('FAIL', "Missing one or more required entries") if !defined($state);
+  return ('FAIL', "Username must not be blank") if !$username;
+
+  # Munge in some alternate state values
+  $state = 1 if $state =~ /^active$/;
+  $state = 1 if $state =~ /^on$/;
+  $state = 0 if $state =~ /^inactive$/;
+  $state = 0 if $state =~ /^off$/;
+
+  my $type = shift || 'u';	# create limited users by default - fwiw, not sure yet how this will interact with ACLs
+  
+  my $permstring = shift || 'i';	# default is to inhert permissions from group
+
+  my $fname = shift || $username;
+  my $lname = shift || '';
+  my $phone = shift || '';	# not going format-check
+
+  my $sth = $dbh->prepare("SELECT user_id FROM users WHERE username=?");
+  my $user_id;
+
+# quick check to start to see if we've already got one
+  $sth->execute($username);
+  ($user_id) = $sth->fetchrow_array;
+
+  return ('FAIL', "User already exists") if $user_id;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  # Wrap all the SQL in a transaction
+  eval {
+    # insert the user...  note we set inherited perms by default since
+    # it's simple and cleans up some other bits of state
+    my $sth = $dbh->prepare("INSERT INTO users ".
+	"(group_id,username,password,firstname,lastname,phone,type,status,permission_id,inherit_perm) ".
+	"VALUES (?,?,?,?,?,?,?,?,(SELECT permission_id FROM permissions WHERE group_id=?),'t')");
+    $sth->execute($group,$username,unix_md5_crypt($pass),$fname,$lname,$phone,$type,$state,$group);
+
+    # get the ID...
+    ($user_id) = $dbh->selectrow_array("SELECT currval('users_user_id_seq')");
+
+# Permissions!  Gotta set'em all!
+    die "Invalid permission string $permstring\n"
+	if $permstring !~ /^(?:
+		i	# inherit
+		|c:\d+	# clone
+			# custom.  no, the leading , is not a typo
+		|C:(?:,(?:group|user|domain|record|location|self)_(?:edit|create|delete|locchg|view))*
+		)$/x;
+# bleh.  I'd call another function to do my dirty work, but we're in the middle of a transaction already.
+    if ($permstring ne 'i') {
+      # for cloned or custom permissions, we have to create a new permissions entry.
+      my $clonesrc = $group;
+      if ($permstring =~ /^c:(\d+)/) { $clonesrc = $1; }
+      $dbh->do("INSERT INTO permissions ($permlist,user_id) ".
+	"SELECT $permlist,? FROM permissions WHERE permission_id=".
+	"(SELECT permission_id FROM permissions WHERE ".($permstring =~ /^c:/ ? 'user' : 'group')."_id=?)",
+	undef, ($user_id,$clonesrc) );
+      $dbh->do("UPDATE users SET permission_id=".
+	"(SELECT permission_id FROM permissions WHERE user_id=?) ".
+	"WHERE user_id=?", undef, ($user_id, $user_id) );
+    }
+    if ($permstring =~ /^C:/) {
+      # finally for custom permissions, we set the passed-in permissions (and unset
+      # any that might have been brought in by the clone operation above)
+      my ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions WHERE user_id=?",
+	undef, ($user_id) );
+      foreach (@permtypes) {
+	if ($permstring =~ /,$_/) {
+	  $dbh->do("UPDATE permissions SET $_='t' WHERE permission_id=?", undef, ($permid) );
+	} else {
+	  $dbh->do("UPDATE permissions SET $_='f' WHERE permission_id=?", undef, ($permid) );
+	}
+      }
+    }
+
+    $dbh->do("UPDATE users SET inherit_perm='n' WHERE user_id=?", undef, ($user_id) );
+
+##fixme: add another table to hold name/email for log table?
+
+    _log($dbh, (group_id => $group, entry => "Added user $username ($fname $lname)"));
+    # once we get here, we should have suceeded.
+    $dbh->commit;
+  }; # end eval
+
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $group, entry => "Error adding user $username: $msg"));
+      $dbh->commit;	# since we enabled transactions earlier
+    }
+    return ('FAIL',"Error adding user $username: $msg");
+  }
+
+  return ('OK',"User $username ($fname $lname) added");
+} # end addUser
+
+
+## DNSDB::getUserCount()
+# Get count of users in group
+# Takes a database handle and hash containing at least the current group, and optionally:
+# - a reference list of secondary groups
+# - a filter string
+# - a "Starts with" string
+sub getUserCount {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+
+  my $sql = "SELECT count(*) FROM users ".
+	"WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND username ~* ?" : '').
+	($args{filter} ? " AND username ~* ?" : '');
+  my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
+  $errstr = $dbh->errstr if !$count;
+  return $count;
+} # end getUserCount()
+
+
+## DNSDB::getUserList()
+# Get list of users
+# Takes the same arguments as getUserCount() above, plus optional:
+# - sort field
+# - sort order
+# - offset/return-all-everything flag (defaults to $perpage records)
+sub getUserList {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+  # better to request sorts on "simple" names, but it means we need to map it to real columns
+  my %sortmap = (user => 'u.username', type => 'u.type', group => 'g.group_name', status => 'u.status',
+	fname => 'fname');
+  $args{sortby} = $sortmap{$args{sortby}};
+
+  # protection against bad or missing arguments
+  $args{sortorder} = 'ASC' if !$args{sortorder};
+  $args{sortby} = 'u.username' if !$args{sortby};
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
+
+  my $sql = "SELECT u.user_id, u.username, u.firstname || ' ' || u.lastname AS fname, u.type, g.group_name, u.status ".
+	"FROM users u ".
+	"INNER JOIN groups g ON u.group_id=g.group_id ".
+	"WHERE u.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND u.username ~* ?" : '').
+	($args{filter} ? " AND u.username ~* ?" : '').
+	" AND NOT u.type = 'R' ".
+	" ORDER BY $args{sortby} $args{sortorder} ".
+	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage} OFFSET ".$args{offset}*$config{perpage});
+  my $ulist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
+  $errstr = $dbh->errstr if !$ulist;
+  return $ulist;
+} # end getUserList()
+
+
+## DNSDB::getUserDropdown()
+# Get a list of usernames for use in a dropdown menu.
+# Takes a database handle, current group, and optional "tag this as selected" flag.
+# Returns a reference to a list of hashrefs suitable to feeding to HTML::Template
+sub getUserDropdown {
+  my $dbh = shift;
+  my $grp = shift;
+  my $sel = shift || 0;
+
+  my $sth = $dbh->prepare("SELECT username,user_id FROM users WHERE group_id=?");
+  $sth->execute($grp);
+
+  my @userlist;
+  while (my ($username,$uid) = $sth->fetchrow_array) {
+    my %row = (
+	username => $username,
+	uid => $uid,
+	selected => ($sel == $uid ? 1 : 0)
+	);
+    push @userlist, \%row;
+  }
+  return \@userlist;
+} # end getUserDropdown()
+
+
+## DNSDB::checkUser()
+# Check user/pass combo on login
+sub checkUser {
+  my $dbh = shift;
+  my $user = shift;
+  my $inpass = shift;
+
+  my $sth = $dbh->prepare("SELECT user_id,group_id,password,firstname,lastname FROM users WHERE username=?");
+  $sth->execute($user);
+  my ($uid,$gid,$pass,$fname,$lname) = $sth->fetchrow_array;
+  my $loginfailed = 1 if !defined($uid);
+
+  if ($pass =~ m|^\$1\$([A-Za-z0-9/.]+)\$|) {
+    $loginfailed = 1 if $pass ne unix_md5_crypt($inpass,$1);
+  } else {
+    $loginfailed = 1 if $pass ne $inpass;
+  }
+
+  # nnnngggg
+  return ($uid, $gid);
+} # end checkUser
+
+
+## DNSDB:: updateUser()
+# Update general data about user
+sub updateUser {
+  my $dbh = shift;
+
+##fixme:  tweak calling convention so that we can update any given bit of data
+  my $uid = shift;
+  my $username = shift;
+  my $group = shift;
+  my $pass = shift;
+  my $state = shift;
+  my $type = shift || 'u';
+  my $fname = shift || $username;
+  my $lname = shift || '';
+  my $phone = shift || '';	# not going format-check
+
+  my $resultmsg = '';
+
+  # Munge in some alternate state values
+  $state = 1 if $state =~ /^active$/;
+  $state = 1 if $state =~ /^on$/;
+  $state = 0 if $state =~ /^inactive$/;
+  $state = 0 if $state =~ /^off$/;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  my $sth;
+
+  # Password can be left blank;  if so we assume there's one on file.
+  # Actual blank passwords are bad, mm'kay?
+  if (!$pass) {
+    ($pass) = $dbh->selectrow_array("SELECT password FROM users WHERE user_id=?", undef, ($uid));
+  } else {
+    $pass = unix_md5_crypt($pass);
+  }
+
+  eval {
+    $dbh->do("UPDATE users SET username=?, password=?, firstname=?, lastname=?, phone=?, type=?, status=?".
+	" WHERE user_id=?", undef, ($username, $pass, $fname, $lname, $phone, $type, $state, $uid));
+    $resultmsg = "Updated user info for $username ($fname $lname)";
+    _log($dbh, group_id => $group, entry => $resultmsg);
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $group, entry => "Error updating user $username: $msg"));
+      $dbh->commit;	# since we enabled transactions earlier
+    }
+    return ('FAIL',"Error updating user $username: $msg");
+  }
+
+  return ('OK',$resultmsg);
+} # end updateUser()
+
+
+## DNSDB::delUser()
+# Delete a user.
+# Takes a database handle and user ID
+# Returns a success/failure code and matching message
+sub delUser {
+  my $dbh = shift;
+  my $userid = shift;
+
+  return ('FAIL',"Bad userid") if !defined($userid);
+
+  my $userdata = getUserData($dbh, $userid);
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  eval {
+    $dbh->do("DELETE FROM users WHERE user_id=?", undef, ($userid));
+    _log($dbh, (group_id => $userdata->{group_id},
+	entry => "Deleted user ID $userid/".$userdata->{username}.
+		" (".$userdata->{firstname}." ".$userdata->{lastname}.")") );
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      _log($dbh, (group_id => $userdata->{group_id}, entry => "Error deleting user ID ".
+	"$userid/".$userdata->{username}.": $msg") );
+      $dbh->commit;
+    }
+    return ('FAIL',"Error deleting user $userid/".$userdata->{username}.": $msg");
+  }
+
+  return ('OK',"Deleted user ".$userdata->{username}." (".$userdata->{firstname}." ".$userdata->{lastname}.")");
+} # end delUser
+
+
+## DNSDB::userFullName()
+# Return a pretty string!
+# Takes a user_id and optional printf-ish string to indicate which pieces where:
+# %u for the username
+# %f for the first name
+# %l for the last name
+# All other text in the passed string will be left as-is.
+##fixme:  need a "smart" option too, so that missing/null/blank first/last names don't give funky output
+sub userFullName {
+  $errstr = '';
+  my $dbh = shift;
+  my $userid = shift;
+  my $fullformat = shift || '%f %l (%u)';
+  my $sth = $dbh->prepare("select username,firstname,lastname from users where user_id=?");
+  $sth->execute($userid);
+  my ($uname,$fname,$lname) = $sth->fetchrow_array();
+  $errstr = $DBI::errstr if !$uname;
+
+  $fullformat =~ s/\%u/$uname/g;
+  $fullformat =~ s/\%f/$fname/g;
+  $fullformat =~ s/\%l/$lname/g;
+
+  return $fullformat;
+} # end userFullName
+
+
+## DNSDB::userStatus()
+# Sets and/or returns a user's status
+# Takes a database handle, user ID and optionally a status argument
+# Returns undef on errors.
+sub userStatus {
+  my $dbh = shift;
+  my $id = shift;
+  my $newstatus = shift || 'mu';
+
+  return undef if $id !~ /^\d+$/;
+
+  my $userdata = getUserData($dbh, $id);
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  if ($newstatus ne 'mu') {
+    # ooo, fun!  let's see what we were passed for status
+    eval {
+      $newstatus = 0 if $newstatus eq 'useroff';
+      $newstatus = 1 if $newstatus eq 'useron';
+      $dbh->do("UPDATE users SET status=? WHERE user_id=?", undef, ($newstatus, $id));
+
+      $resultstr = ($newstatus ? 'Enabled' : 'Disabled')." user ".$userdata->{username}.
+	" (".$userdata->{firstname}." ".$userdata->{lastname}.")";
+
+      my %loghash;
+      $loghash{group_id} = parentID($dbh, (id => $id, type => 'user'));
+      $loghash{entry} = $resultstr;
+      _log($dbh, %loghash);
+
+      $dbh->commit;
+    };
+    if ($@) {
+      my $msg = $@;
+      eval { $dbh->rollback; };
+      $resultstr = '';
+      $errstr = $msg;
+##fixme: failure logging?
+      return;
+    }
+  }
+
+  my ($status) = $dbh->selectrow_array("SELECT status FROM users WHERE user_id=?", undef, ($id));
+  return $status;
+} # end userStatus()
+
+
+## DNSDB::getUserData()
+# Get misc user data for display
+sub getUserData {
+  my $dbh = shift;
+  my $uid = shift;
+
+  my $sth = $dbh->prepare("SELECT group_id,username,firstname,lastname,phone,type,status,inherit_perm ".
+	"FROM users WHERE user_id=?");
+  $sth->execute($uid);
+  return $sth->fetchrow_hashref();
+} # end getUserData()
+
+
+## DNSDB::addLoc()
+# Add a new location.
+# Takes a database handle, group ID, short and long description, and a comma-separated
+# list of IP addresses.
+# Returns ('OK',<location>) on success, ('FAIL',<failmsg>) on failure
+sub addLoc {
+  my $dbh = shift;
+  my $grp = shift;
+  my $shdesc = shift;
+  my $comments = shift;
+  my $iplist = shift;
+
+  # $shdesc gets set to the generated location ID if possible, but these can be de-undefined here.
+  $comments = '' if !$comments;
+  $iplist = '' if !$iplist;
+
+  my $loc;
+
+  # Generate a location ID.  This is, by spec, a two-character widget.  We'll use [a-z][a-z]
+  # for now;  676 locations should satisfy all but the largest of the huge networks.
+  # Not sure whether these are case-sensitive, or what other rules might apply - in any case
+  # the absolute maximum is 16K (256*256) since it's parsed by tinydns as a two-character field.
+
+# add just after "my $origloc = $loc;":
+#    # These expand the possible space from 26^2 to 52^2 [* note in testing only 2052 were achieved],
+#    # and wrap it around.
+#    # Yes, they skip a couple of possibles.  No, I don't care.
+#    $loc = 'aA' if $loc eq 'zz';
+#    $loc = 'Aa' if $loc eq 'zZ';
+#    $loc = 'ZA' if $loc eq 'Zz';
+#    $loc = 'aa' if $loc eq 'ZZ';
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+##fixme:  There is probably a far better way to do this.  Sequential increments
+# are marginally less stupid that pure random generation though, and the existence
+# check makes sure we don't stomp on an imported one.
+
+  eval {
+    # Get the "last" location.  Note this is the only use for loc_id, because selecting on location Does Funky Things
+    ($loc) = $dbh->selectrow_array("SELECT location FROM locations ORDER BY loc_id DESC LIMIT 1");
+    ($loc) = ($loc =~ /^(..)/);
+    my $origloc = $loc;
+    # Make a change...
+    $loc++;
+    # ... and keep changing if it exists
+    while ($dbh->selectrow_array("SELECT count(*) FROM locations WHERE location LIKE ?", undef, ($loc.'%'))) {
+      $loc++;
+      ($loc) = ($loc =~ /^(..)/);
+      die "too many locations in use, can't add another one\n" if $loc eq $origloc;
+##fixme: really need to handle this case faster somehow
+#if $loc eq $origloc die "<thwap> bad admin:  all locations used, your network is too fragmented";
+    }
+    # And now we should have a unique location.  tinydns fundamentally limits the
+    # number of these but there's no doc on what characters are valid.
+    $shdesc = $loc if !$shdesc;
+    $dbh->do("INSERT INTO locations (location, group_id, iplist, description, comments) VALUES (?,?,?,?,?)", 
+	undef, ($loc, $grp, $iplist, $shdesc, $comments) );
+    _log($dbh, entry => "Added location ($shdesc, '$iplist')");
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      $shdesc = $loc if !$shdesc;
+      _log($dbh, (entry => "Failed adding location ($shdesc, '$iplist'): $msg"));
+      $dbh->commit;
+    }
+    return ('FAIL',$msg);
+  }
+
+  return ('OK',$loc);
+} # end addLoc()
+
+
+## DNSDB::updateLoc()
+sub updateLoc {
+  my $dbh = shift;
+  my $loc = shift;
+  my $grp = shift;
+  my $shdesc = shift;
+  my $comments = shift;
+  my $iplist = shift;
+
+  $shdesc = '' if !$shdesc;
+  $comments = '' if !$comments;
+  $iplist = '' if !$iplist;
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  my $oldloc = getLoc($dbh, $loc);
+  my $okmsg = "Updated location (".$oldloc->{description}.", '".$oldloc->{iplist}."') to ($shdesc, '$iplist')";
+
+  eval {
+    $dbh->do("UPDATE locations SET group_id=?,iplist=?,description=?,comments=? WHERE location=?",
+	undef, ($grp, $iplist, $shdesc, $comments, $loc) );
+    _log($dbh, entry => $okmsg);
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      $shdesc = $loc if !$shdesc;
+      _log($dbh, (entry => "Failed updating location ($shdesc, '$iplist'): $msg"));
+      $dbh->commit;
+    }
+    return ('FAIL',$msg);
+  }
+
+  return ('OK',$okmsg);
+} # end updateLoc()
+
+
+## DNSDB::delLoc()
+sub delLoc {}
+
+
+## DNSDB::getLoc()
+sub getLoc {
+  my $dbh = shift;
+  my $loc = shift;
+
+  my $sth = $dbh->prepare("SELECT group_id,iplist,description,comments FROM locations WHERE location=?");
+  $sth->execute($loc);
+  return $sth->fetchrow_hashref(); 
+} # end getLoc()
+
+
+## DNSDB::getLocCount()
+# Get count of locations/views
+# Takes a database handle and hash containing at least the current group, and optionally:
+# - a reference list of secondary groups
+# - a filter string
+# - a "Starts with" string
+sub getLocCount {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+
+  my $sql = "SELECT count(*) FROM locations ".
+	"WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND description ~* ?" : '').
+	($args{filter} ? " AND description ~* ?" : '');
+  my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
+  $errstr = $dbh->errstr if !$count;
+  return $count;
+} # end getLocCount()
+
+
+## DNSDB::getLocList()
+sub getLocList {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
+  push @filterargs, "^$args{startwith}" if $args{startwith};
+  push @filterargs, $args{filter} if $args{filter};
+
+  # better to request sorts on "simple" names, but it means we need to map it to real columns
+#  my %sortmap = (user => 'u.username', type => 'u.type', group => 'g.group_name', status => 'u.status',
+#	fname => 'fname');
+#  $args{sortby} = $sortmap{$args{sortby}};
+
+  # protection against bad or missing arguments
+  $args{sortorder} = 'ASC' if !$args{sortorder};
+  $args{sortby} = 'l.description' if !$args{sortby};
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
+
+  my $sql = "SELECT l.location, l.description, l.iplist, g.group_name ".
+	"FROM locations l ".
+	"INNER JOIN groups g ON l.group_id=g.group_id ".
+	"WHERE l.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
+	($args{startwith} ? " AND l.description ~* ?" : '').
+	($args{filter} ? " AND l.description ~* ?" : '').
+	" ORDER BY $args{sortby} $args{sortorder} ".
+	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage} OFFSET ".$args{offset}*$config{perpage});
+  my $ulist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
+  $errstr = $dbh->errstr if !$ulist;
+  return $ulist;
+} # end getLocList()
+
+
+## DNSDB::getLocDropdown()
+# Get a list of location names for use in a dropdown menu.
+# Takes a database handle, current group, and optional "tag this as selected" flag.
+# Returns a reference to a list of hashrefs suitable to feeding to HTML::Template
+sub getLocDropdown {
+  my $dbh = shift;
+  my $grp = shift;
+  my $sel = shift || '';
+
+  my $sth = $dbh->prepare(qq(
+	SELECT description,location FROM locations
+	WHERE group_id=?
+	ORDER BY description
+	) );
+  $sth->execute($grp);
+
+  my @loclist;
+  push @loclist, { locname => "(None/public)", loc => '', selected => ($sel ? 0 : ($sel eq '' ? 1 : 0)) };
+  while (my ($locname, $loc) = $sth->fetchrow_array) {
+    my %row = (
+	locname => $locname,
+	loc => $loc,
+	selected => ($sel eq $loc ? 1 : 0)
+	);
+    push @loclist, \%row;
+  }
+  return \@loclist;
+} # end getLocDropdown()
+
+
+## DNSDB::getSOA()
+# Return all suitable fields from an SOA record in separate elements of a hash
+# Takes a database handle, default/live flag, domain/reverse flag, and parent ID
+sub getSOA {
+  $errstr = '';
+  my $dbh = shift;
+  my $def = shift;
+  my $rev = shift;
+  my $id = shift;
+
+  # (ab)use distance and weight columns to store SOA data?  can't for default_rev_records...
+  # - should really attach serial to the zone parent somewhere
+
+  my $sql = "SELECT record_id,host,val,ttl from "._rectable($def,$rev).
+	" WHERE "._recparent($def,$rev)." = ? AND type=$reverse_typemap{SOA}";
+  my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
+  return if !$ret;
+##fixme:  stick a flag somewhere if the record doesn't exist.  by the API, this is an impossible case, but...
+
+  ($ret->{contact},$ret->{prins}) = split /:/, $ret->{host};
+  delete $ret->{host};
+  ($ret->{refresh},$ret->{retry},$ret->{expire},$ret->{minttl}) = split /:/, $ret->{val};
+  delete $ret->{val};
+
+  return $ret;
+} # end getSOA()
+
+
+## DNSDB::updateSOA()
+# Update the specified SOA record
+# Takes a database handle, default/live flag, forward/reverse flag, and SOA data hash
+# Returns a two-element list with a result code and message
+sub updateSOA {
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+
+  my %soa = @_;
+
+  my $oldsoa = getSOA($dbh, $defrec, $revrec, $soa{id});
+
+  my $msg;
+  my %logdata;
+  if ($defrec eq 'n') {
+    $logdata{domain_id} = $soa{id} if $revrec eq 'n';
+    $logdata{rdns_id} = $soa{id} if $revrec eq 'y';
+    $logdata{group_id} = parentID($dbh, (id => $soa{id}, revrec => $revrec,
+	type => ($revrec eq 'n' ? 'domain' : 'revzone') ) );
+  } else {
+    $logdata{group_id} = $soa{id};
+  }
+  my $parname = ($defrec eq 'y' ? groupName($dbh, $soa{id}) :
+		($revrec eq 'n' ? domainName($dbh, $soa{id}) : revName($dbh, $soa{id})) );
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  eval {
+    my $sql = "UPDATE "._rectable($defrec, $revrec)." SET host=?, val=?, ttl=? WHERE record_id=? AND type=6";
+    $dbh->do($sql, undef, ("$soa{contact}:$soa{prins}", "$soa{refresh}:$soa{retry}:$soa{expire}:$soa{minttl}",
+        $soa{ttl}, $oldsoa->{record_id}) );
+    $msg = "Updated ".($defrec eq 'y' ? ($revrec eq 'y' ? 'default reverse ' : 'default ') : '').
+	"SOA for $parname: ".
+	"(ns $oldsoa->{prins}, contact $oldsoa->{contact}, refresh $oldsoa->{refresh},".
+	" retry $oldsoa->{retry}, expire $oldsoa->{expire}, minTTL $oldsoa->{minttl}, TTL $oldsoa->{ttl}) to ".
+	"(ns $soa{prins}, contact $soa{contact}, refresh $soa{refresh},".
+	" retry $soa{retry}, expire $soa{expire}, minTTL $soa{minttl}, TTL $soa{ttl})";
+
+    $logdata{entry} = $msg;
+    _log($dbh, %logdata);
+
+    $dbh->commit;
+  };
+  if ($@) {
+    $msg = $@;
+    eval { $dbh->rollback; };
+    $logdata{entry} = "Error updating ".($defrec eq 'y' ? ($revrec eq 'y' ? 'default reverse zone ' : 'default ') : '').
+	"SOA record for $parname: $msg";
+    if ($config{log_failures}) {
+      _log($dbh, %logdata);
+      $dbh->commit;
+    }
+    return ('FAIL', $logdata{entry});
+  } else {
+    return ('OK', $msg);
+  }
+} # end updateSOA()
+
+
+## DNSDB::getRecLine()
+# Return all data fields for a zone record in separate elements of a hash
+# Takes a database handle, default/live flag, forward/reverse flag, and record ID
+sub getRecLine {
+  $errstr = '';
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $id = shift;
+
+  my $sql = "SELECT record_id,host,type,val,ttl,location".($revrec eq 'n' ? ',distance,weight,port' : '').
+	(($defrec eq 'y') ? ',group_id FROM ' : ',domain_id,rdns_id FROM ').
+	_rectable($defrec,$revrec)." WHERE record_id=?";
+  my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
+
+  if ($dbh->err) {
+    $errstr = $DBI::errstr;
+    return undef;
+  }
+
+  if (!$ret) {
+    $errstr = "No such record";
+    return undef;
+  }
+
+  # explicitly set a parent id
+  if ($defrec eq 'y') {
+    $ret->{parid} = $ret->{group_id};
+  } else {
+    $ret->{parid} = (($revrec eq 'n') ? $ret->{domain_id} : $ret->{rdns_id});
+    # and a secondary if we have a custom type that lives in both a forward and reverse zone
+    $ret->{secid} = (($revrec eq 'y') ? $ret->{domain_id} : $ret->{rdns_id}) if $ret->{type} > 65279;
+  }
+
+  return $ret;
+}
+
+
+##fixme: should use above (getRecLine()) to get lines for below?
+## DNSDB::getDomRecs()
+# Return records for a domain
+# Takes a database handle, default/live flag, group/domain ID, start,
+# number of records, sort field, and sort order
+# Returns a reference to an array of hashes
+sub getDomRecs {
+  $errstr = '';
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  push @filterargs, $args{filter} if $args{filter};
+
+  # protection against bad or missing arguments
+  $args{sortorder} = 'ASC' if !$args{sortorder};
+  $args{sortby} = 'host' if !$args{sortby} && $args{revrec} eq 'n';	# default sort by host on domain record list
+  $args{sortby} = 'val' if !$args{sortby} && $args{revrec} eq 'y';	# default sort by IP on revzone record list
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;  
+
+  # sort reverse zones on IP, correctly
+  # do other fiddling with $args{sortby} while we're at it.
+  $args{sortby} = "r.$args{sortby}";
+  $args{sortby} = 'CAST (r.val AS inet)'
+	if $args{revrec} eq 'y' && $args{defrec} eq 'n' && $args{sortby} eq 'r.val';
+  $args{sortby} = 't.alphaorder' if $args{sortby} eq 'r.type';
+
+  my $sql = "SELECT r.record_id,r.host,r.type,r.val,r.ttl";
+  $sql .= ",l.description AS locname" if $args{defrec} eq 'n';
+  $sql .= ",r.distance,r.weight,r.port" if $args{revrec} eq 'n';
+  $sql .= " FROM "._rectable($args{defrec},$args{revrec})." r ";
+
+  # whee!  multisort means just passing comma-separated fields in sortby!
+  my $newsort = '';
+  foreach my $sf (split /,/, $order) {
+    $sf = "r.$sf";
+    $sf =~ s/r\.type/t.alphaorder/;
+    $newsort .= ",$sf";
+  }
+  $newsort =~ s/^,//;
+
+  $sql .= "INNER JOIN rectypes t ON r.type=t.val ";	# for sorting by type alphabetically
+  $sql .= "LEFT JOIN locations l ON r.location=l.location " if $args{defrec} eq 'n';
+  $sql .= "WHERE "._recparent($args{defrec},$args{revrec})." = ?";
+  $sql .= " AND NOT r.type=$reverse_typemap{SOA}";
+  $sql .= " AND host ~* ?" if $args{filter};
+  $sql .= " ORDER BY $args{sortby} $args{sortorder}";
+  # ensure consistent ordering by sorting on record_id too
+  $sql .= ", record_id $args{sortorder}";
+  $sql .= ($args{offset} eq 'all' ? '' : " LIMIT $config{perpage} OFFSET ".$args{offset}*$config{perpage});
+
+  my @bindvars = ($args{id});
+  push @bindvars, $args{filter} if $args{filter};
+
+  my $ret = $dbh->selectall_arrayref($sql, { Slice => {} }, (@bindvars) );
+  return $ret;
+} # end getDomRecs()
+
+
+## DNSDB::getRecCount()
+# Return count of non-SOA records in zone (or default records in a group)
+# Takes a database handle, default/live flag, reverse/forward flag, group/domain ID,
+# and optional filtering modifier
+# Returns the count
+sub getRecCount {
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $id = shift;
+  my $filter = shift || '';
+
+  # keep the nasties down, since we can't ?-sub this bit.  :/
+  # note this is chars allowed in DNS hostnames
+  $filter =~ s/[^a-zA-Z0-9_.:-]//g;
+
+  my @bindvars = ($id);
+  push @bindvars, $filter if $filter;
+  my $sql = "SELECT count(*) FROM ".
+	_rectable($defrec,$revrec).
+	" WHERE "._recparent($defrec,$revrec)."=? ".
+	"AND NOT type=$reverse_typemap{SOA}".
+	($filter ? " AND host ~* ?" : '');
+  my ($count) = $dbh->selectrow_array($sql, undef, (@bindvars) );
+
+  return $count;
+
+} # end getRecCount()
+
+
+## DNSDB::addRec()
+# Add a new record to a domain or a group's default records
+# Takes a database handle, default/live flag, group/domain ID,
+# host, type, value, and TTL
+# Some types require additional detail: "distance" for MX and SRV,
+# and weight/port for SRV
+# Returns a status code and detail message in case of error
+##fixme:  pass a hash with the record data, not a series of separate values
+sub addRec {
+  $errstr = '';
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $id = shift;	# parent (group_id for defrecs, rdns_id for reverse records,
+			# domain_id for domain records)
+
+  my $host = shift;
+  my $rectype = shift;	# reference so we can coerce it if "+"-types can't find both zones
+  my $val = shift;
+  my $ttl = shift;
+  my $location = shift;
+  $location  = '' if !$location;
+
+  # Spaces are evil.
+  $host =~ s/^\s+//;
+  $host =~ s/\s+$//;
+  if ($typemap{$rectype} ne 'TXT') {
+    # Leading or trailng spaces could be legit in TXT records.
+    $val =~ s/^\s+//;
+    $val =~ s/\s+$//;
+  }
+
+  # Validation
+  my $addr = NetAddr::IP->new($val);
+  if ($rectype == $reverse_typemap{A}) {
+    return ('FAIL',$typemap{$rectype}." record must be a valid IPv4 address")
+	unless $addr && !$addr->{isv6};
+  }
+  if ($rectype == $reverse_typemap{AAAA}) {
+    return ('FAIL',$typemap{$rectype}." record must be a valid IPv6 address")
+	unless $addr && $addr->{isv6};
+  }
+
+  my $domid = 0;
+  my $revid = 0;
+
+  my $retcode = 'OK';	# assume everything will go OK
+  my $retmsg = '';
+
+  # do simple validation first
+  return ('FAIL', "TTL must be numeric") unless $ttl =~ /^\d+$/;
+
+  # Quick check on hostname parts.  Note the regex is more forgiving than the error message;
+  # domain names technically are case-insensitive, and we use printf-like % codes for a couple
+  # of types.  Other things may also be added to validate default records of several flavours.
+  return ('FAIL', "Hostnames may not contain anything other than (0-9 a-z . _)")
+	if $defrec eq 'n' && ($revrec eq 'y' ? $$rectype != $reverse_typemap{TXT} : 1) &&
+		$$host !~ /^[0-9a-z_%.-]+$/i;
+
+  # Collect these even if we're only doing a simple A record so we can call *any* validation sub
+  my $dist = shift;
+  my $weight = shift;
+  my $port = shift;
+
+  my $fields;
+  my @vallist;
+
+  # Call the validation sub for the type requested.
+  ($retcode,$retmsg) = $validators{$$rectype}($dbh, (defrec => $defrec, revrec => $revrec, id => $id,
+	host => $host, rectype => $rectype, val => $val, addr => $addr,
+	dist => \$dist, port => \$port, weight => \$weight,
+	fields => \$fields, vallist => \@vallist) );
+
+  return ($retcode,$retmsg) if $retcode eq 'FAIL';
+
+  # Set up database fields and bind parameters
+  $fields .= "host,type,val,ttl,location,"._recparent($defrec,$revrec);
+  push @vallist, ($$host,$$rectype,$$val,$ttl,$location,$id);
+  my $vallen = '?'.(',?'x$#vallist);
+
+  # Put together the success log entry.  We have to use this horrible kludge
+  # because domain_id and rdns_id may or may not be present, and if they are,
+  # they're not at a guaranteed consistent index in the array.  wheee!
+  my %logdata;
+  my @ftmp = split /,/, $fields;
+  for (my $i=0; $i <= $#vallist; $i++) {
+    $logdata{domain_id} = $vallist[$i] if $ftmp[$i] eq 'domain_id';
+    $logdata{rdns_id} = $vallist[$i] if $ftmp[$i] eq 'rdns_id';
+  }
+  $logdata{group_id} = $id if $defrec eq 'y';
+  $logdata{group_id} = parentID($dbh,
+		(id => $id, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec) )
+	if $defrec eq 'n';
+  $logdata{entry} = "Added ".($defrec eq 'y' ? 'default record' : 'record');
+  # NS records for revzones get special treatment
+  if ($revrec eq 'y' && $$rectype == 2) {
+    $logdata{entry} .= " '$$val $typemap{$$rectype} $$host";
+  } else {
+    $logdata{entry} .= " '$$host $typemap{$$rectype} $$val";
+  }
+
+  $logdata{entry} .= " [distance $dist]" if $typemap{$$rectype} eq 'MX';
+  $logdata{entry} .= " [priority $dist] [weight $weight] [port $port]"
+	if $typemap{$$rectype} eq 'SRV';
+  $logdata{entry} .= "', TTL $ttl, location $location";
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  eval {
+    $dbh->do("INSERT INTO "._rectable($defrec, $revrec)." ($fields) VALUES ($vallen)",
+	undef, @vallist);
+    _log($dbh, %logdata);
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      $logdata{entry} = "Failed adding ".($defrec eq 'y' ? 'default ' : '').
+	"record '$$host $typemap{$$rectype} $$val', TTL $ttl ($msg)";
+      _log($dbh, %logdata);
+      $dbh->commit;
+    }
+    return ('FAIL',$msg);
+  }
+
+  $resultstr = $logdata{entry};
+  return ($retcode, $retmsg);
+
+} # end addRec()
+
+
+## DNSDB::updateRec()
+# Update a record
+# Takes a database handle, default and reverse flags, record ID, immediate parent ID, and new record data.
+# Returns a status code and message
+sub updateRec {
+  $errstr = '';
+
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $id = shift;
+  my $parid = shift;	# immediate parent entity that we're descending from to update the record
+
+  # all records have these
+  my $host = shift;
+  my $hostbk = $$host;	# Keep a backup copy of the original, so we can WARN if the update mangles the domain
+  my $rectype = shift;
+  my $val = shift;
+  my $ttl = shift;
+  my $location = shift;	# may be empty/null/undef depending on caller
+  $location  = '' if !$location;
+
+  # prep for validation
+  my $addr = NetAddr::IP->new($$val);
+  $$host =~ s/\.+$//;	# FQDNs ending in . are an internal detail, and really shouldn't be exposed in the UI.
+
+  # 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+$//;
+  }
+
+  my $domid = 0;
+  my $revid = 0;
+
+  my $retcode = 'OK';	# assume everything will go OK
+  my $retmsg = '';
+
+  # do simple validation first
+  return ('FAIL', "TTL must be numeric") unless $ttl =~ /^\d+$/;
+
+  # Quick check on hostname parts.  Note the regex is more forgiving than the error message;
+  # domain names technically are case-insensitive, and we use printf-like % codes for a couple
+  # of types.  Other things may also be added to validate default records of several flavours.
+  return ('FAIL', "Hostnames may not contain anything other than (0-9 a-z - . _)")
+	if $defrec eq 'n' && ($revrec eq 'y' ? $$rectype != $reverse_typemap{TXT} : 1) &&
+		$$host !~ /^[0-9a-z_%.-]+$/i;
+
+  # only MX and SRV will use these
+  my $dist = shift || 0;
+  my $weight = shift || 0;
+  my $port = shift || 0;
+
+  my $fields;
+  my @vallist;
+
+  # get old record data so we have the right parent ID
+  # and for logging (eventually)
+  my $oldrec = getRecLine($dbh, $defrec, $revrec, $id);
+
+  # Call the validation sub for the type requested.
+  # Note the ID to pass here is the *parent*, not the record
+  ($retcode,$retmsg) = $validators{$$rectype}($dbh, (defrec => $defrec, revrec => $revrec,
+	id => ($defrec eq 'y' ? $oldrec->{group_id} : ($revrec eq 'n' ? $oldrec->{domain_id} : $oldrec->{rdns_id})),
+	host => $host, rectype => $rectype, val => $val, addr => $addr,
+	dist => \$dist, port => \$port, weight => \$weight,
+	fields => \$fields, vallist => \@vallist,
+	update => $id) );
+
+  return ($retcode,$retmsg) if $retcode eq 'FAIL';
+
+  # Set up database fields and bind parameters.  Note only the optional fields
+  # (distance, weight, port, secondary parent ID) are added in the validation call above
+  $fields .= "host,type,val,ttl,location,"._recparent($defrec,$revrec);
+  push @vallist, ($$host,$$rectype,$$val,$ttl,$location,
+	($defrec eq 'y' ? $oldrec->{group_id} : ($revrec eq 'n' ? $oldrec->{domain_id} : $oldrec->{rdns_id})) );
+
+  # hack hack PTHUI
+  # need to forcibly make sure we disassociate a record with a parent it's no longer related to.
+  # eg, PTR records may not have a domain parent, or A/AAAA records may not have a revzone parent.
+  # mainly needed for crossover types that got coerced down to "standard" types
+  if ($defrec eq 'n') {
+    if ($$rectype == $reverse_typemap{PTR}) {
+      $fields .= ",domain_id";
+      push @vallist, 0;
+    }
+    if ($$rectype == $reverse_typemap{A} || $$rectype == $reverse_typemap{AAAA}) {
+      $fields .= ",rdns_id";
+      push @vallist, 0;
+    }
+  }
+  # fix fat-finger-originated record type changes
+  if ($$rectype == 65285) {
+    $fields .= ",rdns_id" if $revrec eq 'n';
+    $fields .= ",domain_id" if $revrec eq 'y';
+    push @vallist, 0;
+  }
+  if ($defrec eq 'n') {
+    $domid = $parid if $revrec eq 'n';
+    $revid = $parid if $revrec eq 'y';
+  }
+
+  # Put together the success log entry.  Horrible kludge from addRec() copied as-is since
+  # we don't know whether the passed arguments or retrieved values for domain_id and rdns_id
+  # will be maintained (due to "not-in-zone" validation changes)
+  my %logdata;
+  $logdata{domain_id} = $domid;
+  $logdata{rdns_id} = $revid;
+  my @ftmp = split /,/, $fields;
+  for (my $i=0; $i <= $#vallist; $i++) {
+    $logdata{domain_id} = $vallist[$i] if $ftmp[$i] eq 'domain_id';
+    $logdata{rdns_id} = $vallist[$i] if $ftmp[$i] eq 'rdns_id';
+  }
+  $logdata{group_id} = $parid if $defrec eq 'y';
+  $logdata{group_id} = parentID($dbh,
+		(id => $parid, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec) )
+	if $defrec eq 'n';
+  $logdata{entry} = "Updated ".($defrec eq 'y' ? 'default record' : 'record')." from\n";
+  # NS records for revzones get special treatment
+  if ($revrec eq 'y' && $$rectype == 2) {
+    $logdata{entry} .= " '$oldrec->{val} $typemap{$oldrec->{type}} $oldrec->{host}";
+  } else {
+    $logdata{entry} .= " '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}";
+  }
+  $logdata{entry} .= " [distance $oldrec->{distance}]" if $typemap{$oldrec->{type}} eq 'MX';
+  $logdata{entry} .= " [priority $oldrec->{distance}] [weight $oldrec->{weight}] [port $oldrec->{port}]"
+	if $typemap{$oldrec->{type}} eq 'SRV';
+  $logdata{entry} .= "', TTL $oldrec->{ttl}, location $oldrec->{location}\nto\n";
+  # More NS special
+  if ($revrec eq 'y' && $$rectype == 2) {
+    $logdata{entry} .= "'$$val $typemap{$$rectype} $$host";
+  } else {
+    $logdata{entry} .= "'$$host $typemap{$$rectype} $$val";
+  }
+  $logdata{entry} .= " [distance $dist]" if $typemap{$$rectype} eq 'MX';
+  $logdata{entry} .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$$rectype} eq 'SRV';
+  $logdata{entry} .= "', TTL $ttl, location $location";
+
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  # Fiddle the field list into something suitable for updates
+  $fields =~ s/,/=?,/g;
+  $fields .= "=?";
+
+  eval {
+    $dbh->do("UPDATE "._rectable($defrec,$revrec)." SET $fields WHERE record_id=?", undef, (@vallist, $id) );
+    _log($dbh, %logdata);
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      $logdata{entry} = "Failed updating ".($defrec eq 'y' ? 'default ' : '').
+	"record '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}', TTL $oldrec->{ttl} ($msg)";
+      _log($dbh, %logdata);
+      $dbh->commit;
+    }
+    return ('FAIL', $msg);
+  }
+
+  $resultstr = $logdata{entry};
+  return ($retcode, $retmsg);
+} # end updateRec()
+
+
+## DNSDB::delRec()
+# Delete a record.  
+sub delRec {
+  $errstr = '';
+  my $dbh = shift;
+  my $defrec = shift;
+  my $revrec = shift;
+  my $id = shift;
+
+  my $oldrec = getRecLine($dbh, $defrec, $revrec, $id);
+
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  # Put together the log entry
+  my %logdata;
+  $logdata{domain_id} = $oldrec->{domain_id};
+  $logdata{rdns_id} = $oldrec->{rdns_id};
+  $logdata{group_id} = $oldrec->{group_id} if $defrec eq 'y';
+  $logdata{group_id} = parentID($dbh,
+		(id => $oldrec->{domain_id}, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec) )
+	if $defrec eq 'n';
+  $logdata{entry} = "Deleted ".($defrec eq 'y' ? 'default record ' : 'record ').
+	"'$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}";
+  $logdata{entry} .= " [distance $oldrec->{distance}]" if $typemap{$oldrec->{type}} eq 'MX';
+  $logdata{entry} .= " [priority $oldrec->{distance}] [weight $oldrec->{weight}] [port $oldrec->{port}]"
+	if $typemap{$oldrec->{type}} eq 'SRV';
+  $logdata{entry} .= "', TTL $oldrec->{ttl}\n";
+
+  eval {
+    my $sth = $dbh->do("DELETE FROM "._rectable($defrec,$revrec)." WHERE record_id=?", undef, ($id));
+    _log($dbh, %logdata);
+    $dbh->commit;
+  };
+  if ($@) {
+    my $msg = $@;
+    eval { $dbh->rollback; };
+    if ($config{log_failures}) {
+      $logdata{entry} = "Error deleting ".($defrec eq 'y' ? 'default record' : 'record').
+	" '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}', TTL $oldrec->{ttl} ($msg)";
+      _log($dbh, %logdata);
+      $dbh->commit;
+    }
+    return ('FAIL', $msg);
+  }
+
+  return ('OK',$logdata{entry});
+} # end delRec()
+
+
+## DNSDB::getLogCount()
+# Get a count of log entries
+# Takes a database handle and a hash containing at least:
+# - Entity ID and entity type as the primary log "slice"
+sub getLogCount {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+##fixme:  which fields do we want to filter on?
+# push @filterargs, 
+
+  $errstr = 'Missing primary parent ID and/or type';
+  # fail early if we don't have a "prime" ID to look for log entries for
+  return if !$args{id};
+
+  # or if the prime id type is missing or invalid
+  return if !$args{logtype};
+  $args{logtype} = 'revzone' if $args{logtype} eq 'rdns';	# hack pthui
+  $args{logtype} = 'domain' if $args{logtype} eq 'dom';		# hack pthui
+  return if !grep /^$args{logtype}$/, ('group', 'domain', 'revzone', 'user');
+
+  $args{logtype} = 'revzone' if $args{logtype} eq 'rdns';	# hack pthui
+
+  my $sql = "SELECT count(*) FROM log ".
+	"WHERE $id_col{$args{logtype}}=?".
+	($args{filter} ? " AND entry ~* ?" : '');
+  my ($count) = $dbh->selectrow_array($sql, undef, ($args{id}, @filterargs) );
+  $errstr = $dbh->errstr if !$count;
+  return $count;
+} # end getLogCount()
+
+
+## DNSDB::getLogEntries()
+# Get a list of log entries
+# Takes arguments as with getLogCount() above, plus optional:
+# - sort field
+# - sort order
+# - offset for pagination
+sub getLogEntries {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  my @filterargs;
+
+  # fail early if we don't have a "prime" ID to look for log entries for
+  return if !$args{id};
+
+  # or if the prime id type is missing or invalid
+  return if !$args{logtype};
+  $args{logtype} = 'revzone' if $args{logtype} eq 'rdns';	# hack pthui
+  $args{logtype} = 'domain' if $args{logtype} eq 'dom';		# hack pthui
+  return if !grep /^$args{logtype}$/, ('group', 'domain', 'revzone', 'user');
+
+  # Sorting defaults
+  $args{sortby} = 'stamp' if !$args{sortby};
+  $args{sortorder} = 'DESC' if !$args{sortorder};
+  $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
+
+  my %sortmap = (fname => 'name', username => 'email', entry => 'entry', stamp => 'stamp');
+  $args{sortby} = $sortmap{$args{sortby}};
+
+  my $sql = "SELECT user_id AS userid, email AS useremail, name AS userfname, entry AS logentry, ".
+	"date_trunc('second',stamp) AS logtime ".
+	"FROM log ".
+	"WHERE $id_col{$args{logtype}}=?".
+	($args{filter} ? " AND entry ~* ?" : '').
+	" ORDER BY $args{sortby} $args{sortorder}, log_id $args{sortorder}".
+	($args{offset} eq 'all' ? '' : " LIMIT $config{perpage} OFFSET ".$args{offset}*$config{perpage});
+  my $loglist = $dbh->selectall_arrayref($sql, { Slice => {} }, ($args{id}, @filterargs) );
+  $errstr = $dbh->errstr if !$loglist;
+  return $loglist;
+} # end getLogEntries()
+
 
 ## DNSDB::getTypelist()
@@ -2517,33 +4052,57 @@
 
 
-## DNSDB::domStatus()
-# Sets and/or returns a domain's status
-# Takes a database handle, domain ID and optionally a status argument
-# Returns undef on errors.
-sub domStatus {
+## DNSDB::zoneStatus()
+# Returns and optionally sets a zone's status
+# Takes a database handle, domain/revzone ID, forward/reverse flag, and optionally a status argument
+# Returns status, or undef on errors.
+sub zoneStatus {
   my $dbh = shift;
   my $id = shift;
-  my $newstatus = shift;
+  my $revrec = shift;
+  my $newstatus = shift || 'mu';
 
   return undef if $id !~ /^\d+$/;
 
-  my $sth;
-
-# ooo, fun!  let's see what we were passed for status
-  if ($newstatus) {
-    $sth = $dbh->prepare("update domains set status=? where domain_id=?");
-    # ass-u-me caller knows what's going on in full
-    if ($newstatus =~ /^[01]$/) {	# only two valid for now.
-      $sth->execute($newstatus,$id);
-    } elsif ($newstatus =~ /^domo(?:n|ff)$/) {
-      $sth->execute(($newstatus eq 'domon' ? 1 : 0),$id);
-    }
-  }
-
-  $sth = $dbh->prepare("select status from domains where domain_id=?");
-  $sth->execute($id);
-  my ($status) = $sth->fetchrow_array;
+  # Allow transactions, and raise an exception on errors so we can catch it later.
+  # Use local to make sure these get "reset" properly on exiting this block
+  local $dbh->{AutoCommit} = 0;
+  local $dbh->{RaiseError} = 1;
+
+  if ($newstatus ne 'mu') {
+    # ooo, fun!  let's see what we were passed for status
+    eval {
+      $newstatus = 0 if $newstatus eq 'domoff';
+      $newstatus = 1 if $newstatus eq 'domon';
+      $dbh->do("UPDATE ".($revrec eq 'n' ? 'domains' : 'revzones')." SET status=? WHERE ".
+	($revrec eq 'n' ? 'domain_id' : 'rdns_id')."=?", undef, ($newstatus,$id) );
+
+##fixme  switch to more consise "Enabled <domain"/"Disabled <domain>" as with users?
+      $resultstr = "Changed ".($revrec eq 'n' ? domainName($dbh, $id) : revName($dbh, $id)).
+	" state to ".($newstatus ? 'active' : 'inactive');
+
+      my %loghash;
+      $loghash{domain_id} = $id if $revrec eq 'n';
+      $loghash{rdns_id} = $id if $revrec eq 'y';
+      $loghash{group_id} = parentID($dbh,
+	(id => $id, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec) );
+      $loghash{entry} = $resultstr;
+      _log($dbh, %loghash);
+
+      $dbh->commit;
+    };
+    if ($@) {
+      my $msg = $@;
+      eval { $dbh->rollback; };
+      $resultstr = '';
+      $errstr = $msg;
+      return;
+    }
+  }
+
+  my ($status) = $dbh->selectrow_array("SELECT status FROM ".
+	($revrec eq 'n' ? "domains WHERE domain_id=?" : "revzones WHERE rdns_id=?"),
+	undef, ($id) );
   return $status;
-} # end domStatus()
+} # end zoneStatus()
 
 
@@ -2561,5 +4120,5 @@
   my $dbh = shift;
   my $ifrom_in = shift;
-  my $domain = shift;
+  my $zone = shift;
   my $group = shift;
   my $status = shift;
@@ -2569,4 +4128,6 @@
   my $newttl = shift;
 
+  my $merge = shift || 0;	# do we attempt to merge A/AAAA and PTR records whenever possible?
+				# do we overload this with the fixme below?
 ##fixme:  add mode to delete&replace, merge+overwrite, merge new?
 
@@ -2577,4 +4138,8 @@
   my $ifrom;
 
+  my $rev = 'n';
+  my $code = 'OK';
+  my $msg = 'foobar?';
+
   # choke on possible bad setting in ifrom
   # IPv4 and v6, and valid hostnames!
@@ -2583,4 +4148,82 @@
 	unless ($ifrom) = ($ifrom_in =~ /^([0-9a-f\:.]+|[0-9a-z_.-]+)$/i);
 
+  my $errmsg;
+
+  my $zone_id;
+  my $domain_id = 0;
+  my $rdns_id = 0;
+  my $cidr;
+
+# magic happens!  detect if we're importing a domain or a reverse zone
+# while we're at it, figure out what the CIDR netblock is (if we got a .arpa)
+# or what the formal .arpa zone is (if we got a CIDR netblock)
+# Handles sub-octet v4 zones in the format specified in the Cricket Book, 2nd Ed, p217-218
+
+  if ($zone =~ m{(?:\.arpa\.?|/\d+)$}) {
+    # we seem to have a reverse zone
+    $rev = 'y';
+
+    if ($zone =~ /\.arpa\.?$/) {
+      # we have a formal reverse zone.  call _zone2cidr and get the CIDR block.
+      ($code,$msg) = _zone2cidr($zone);
+      return ($code, $msg) if $code eq 'FAIL';
+      $cidr = $msg;
+    } elsif ($zone =~ m|^[\d.]+/\d+$|) {
+      # v4 revzone, CIDR netblock
+      $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
+      $zone = _ZONE($cidr, 'ZONE.in-addr.arpa', 'r', '.');
+    } elsif ($zone =~ m|^[a-fA-F\d:]+/\d+$|) {
+      # v6 revzone, CIDR netblock
+      $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
+      return ('FAIL', "$zone is not a nibble-aligned block") if $cidr->masklen % 4 != 0;
+      $zone = _ZONE($cidr, 'ZONE.ip6.arpa', 'r', '.');
+    } else {
+      # there is. no. else!
+      return ('FAIL', "Unknown zone name format");
+    }
+
+    # quick check to start to see if we've already got one
+
+    ($zone_id) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet=?",
+	undef, ("$cidr"));
+    $rdns_id = $zone_id;
+  } else {
+    # default to domain
+    ($zone_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)",
+	undef, ($zone));
+    $domain_id = $zone_id;
+  }
+
+  return ('FAIL', ($rev eq 'n' ? 'Domain' : 'Reverse zone')." already exists") if $zone_id;
+
+  # little local utility sub to swap $val and $host for revzone records.
+  sub _revswap {
+    my $rechost = shift;
+    my $recdata = shift;
+
+    if ($rechost =~ /\.in-addr\.arpa\.?$/) {
+      $rechost =~ s/\.in-addr\.arpa\.?$//;
+      $rechost = join '.', reverse split /\./, $rechost;
+    } else {
+      $rechost =~ s/\.ip6\.arpa\.?$//;
+      my @nibs = reverse split /\./, $rechost;
+      $rechost = '';
+      my $nc;
+      foreach (@nibs) {
+        $rechost.= $_;
+        $rechost .= ":" if ++$nc % 4 == 0 && $nc < 32;
+      }
+      $rechost .= ":" if $nc < 32 && $rechost !~ /\*$/;	# close netblock records?
+##fixme:  there's a case that ends up with a partial entry here:
+# ip:add:re:ss::
+# can't reproduce after letting it sit overnight after discovery.  :(
+#print "$rechost\n";
+      # canonicalize with NetAddr::IP
+      $rechost = NetAddr::IP->new($rechost)->addr unless $rechost =~ /\*$/;
+    }
+    return ($recdata,$rechost)
+  }
+
+
   # Allow transactions, and raise an exception on errors so we can catch it later.
   # Use local to make sure these get "reset" properly on exiting this block
@@ -2588,20 +4231,24 @@
   local $dbh->{RaiseError} = 1;
 
-  my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
-  my $dom_id;
-
-# quick check to start to see if we've already got one
-  $sth->execute($domain);
-  ($dom_id) = $sth->fetchrow_array;
-
-  return ('FAIL', "Domain already exists") if $dom_id;
-
+  my $sth;
   eval {
-    # can't do this, can't nest transactions.  sigh.
-    #my ($dcode, $dmsg) = addDomain(dbh, domain, group, status);
-
+
+    if ($rev eq 'n') {
 ##fixme:  serial
-    my $sth = $dbh->prepare("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)");
-    $sth->execute($domain,$group,$status);
+      $dbh->do("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)", undef, ($zone,$group,$status) );
+      # get domain id so we can do the records
+      ($zone_id) = $dbh->selectrow_array("SELECT currval('domains_domain_id_seq')");
+      $domain_id = $zone_id;
+      _log($dbh, (group_id => $group, domain_id => $domain_id,
+		entry => "[Added ".($status ? 'active' : 'inactive')." domain $zone via AXFR]") );
+    } else {
+##fixme:  serial
+      $dbh->do("INSERT INTO revzones (revnet,group_id,status) VALUES (?,?,?)", undef, ($cidr,$group,$status) );
+      # get revzone id so we can do the records
+      ($zone_id) = $dbh->selectrow_array("SELECT currval('revzones_rdns_id_seq')");
+      $rdns_id = $zone_id;
+      _log($dbh, (group_id => $group, rdns_id => $rdns_id,
+		entry => "[Added ".($status ? 'active' : 'inactive')." reverse zone $cidr via AXFR]") );
+    }
 
 ## bizarre DBI<->Net::DNS interaction bug:
@@ -2610,25 +4257,34 @@
 ## caused a commit instead of barfing
 
-    # get domain id so we can do the records
-    $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
-    $sth->execute($domain);
-    ($dom_id) = $sth->fetchrow_array();
-
     my $res = Net::DNS::Resolver->new;
     $res->nameservers($ifrom);
-    $res->axfr_start($domain)
+    $res->axfr_start($zone)
 	or die "Couldn't begin AXFR\n";
 
+    $sth = $dbh->prepare("INSERT INTO records (domain_id,rdns_id,host,type,val,distance,weight,port,ttl)".
+	" VALUES (?,?,?,?,?,?,?,?,?)");
+
+    # Stash info about sub-octet v4 revzones here so we don't have
+    # to store the CNAMEs used to delegate a suboctet zone
+    # $suboct{zone}{ns}[] -> array of nameservers
+    # $suboct{zone}{cname}[] -> array of extant CNAMEs (Just In Case someone did something bizarre)
+## commented pending actual use of this data.  for now, we'll just
+## auto-(re)create the CNAMEs in revzones on export
+#    my %suboct;
+
     while (my $rr = $res->axfr_next()) {
+
+      my $val;
+      my $distance = 0;
+      my $weight = 0;
+      my $port = 0;
+      my $logfrag = '';
+
       my $type = $rr->type;
       my $ttl = ($newttl ? $newttl : $rr->ttl);	# allow force-override TTLs
-
-      my $sql = "INSERT INTO records (domain_id,host,type,ttl,val";
-      my $vallen = "?,?,?,?,?";
+      my $host = $rr->name;
 
       $soaflag = 1 if $type eq 'SOA';
       $nsflag = 1 if $type eq 'NS';
-
-      my @vallist = ($dom_id, $rr->name, $reverse_typemap{$type}, $ttl);
 
 # "Primary" types:
@@ -2636,4 +4292,16 @@
 # maybe KEY
 
+# BIND supports:
+# [standard]
+# A AAAA CNAME MX NS PTR SOA TXT
+# [variously experimental, obsolete, or obscure]
+# HINFO MB(ex) MD(ob) MF(ob) MG(ex) MINFO(ex) MR(ex) NULL WKS AFSDB(ex) ISDN(ex) RP(ex) RT(ex) X25(ex) PX
+# ... if one can ever find the right magic to format them correctly
+
+# Net::DNS supports:
+# RRSIG SIG NSAP NS NIMLOC NAPTR MX MR MINFO MG MB LOC ISDN IPSECKEY HINFO
+# EID DNAME CNAME CERT APL AFSDB AAAA A DS NXT NSEC3PARAM NSEC3 NSEC KEY
+# DNSKEY DLV X25 TXT TSIG TKEY SSHFP SRV SPF SOA RT RP PX PTR NULL APL::AplItem
+
 # nasty big ugly case-like thing here, since we have to do *some* different
 # processing depending on the record.  le sigh.
@@ -2642,49 +4310,84 @@
 
       if ($type eq 'A') {
-	push @vallist, $rr->address;
+	$val = $rr->address;
       } elsif ($type eq 'NS') {
 # hmm.  should we warn here if subdomain NS'es are left alone?
-	next if ($rwns && ($rr->name eq $domain));
-	push @vallist, $rr->nsdname;
+	next if ($rwns && ($rr->name eq $zone));
+	if ($rev eq 'y') {
+	  # revzones have records more or less reversed from forward zones.
+	  my ($tmpcode,$tmpmsg) = _zone2cidr($host);
+	  die "Error converting NS record: $tmpmsg\n" if $tmpcode eq 'FAIL';	# hmm.  may not make sense...
+	  $val = "$tmpmsg";
+	  $host = $rr->nsdname;
+	  $logfrag = "Added record '$val $type $host', TTL $ttl";
+# Tag and preserve.  For now this is commented for a no-op, but we have Ideas for
+# another custom storage type ("DELEGATE") that will use these subzone-delegation records
+#if ($val ne "$cidr") {
+#  push @{$suboct{$val}{ns}}, $host;
+#}
+	} else {
+	  $val = $rr->nsdname;
+	}
 	$nsflag = 1;
       } elsif ($type eq 'CNAME') {
-	push @vallist, $rr->cname;
+	if ($rev eq 'y') {
+	  # hmm.  do we even want to bother with storing these at this level?  Sub-octet delegation
+	  # by CNAME is essentially a record-publication hack, and we want to just represent the
+	  # "true" logical intentions as far down the stack as we can from the UI.
+	  ($host,$val) = _revswap($host,$rr->cname);
+	  $logfrag = "Added record '$val $type $host', TTL $ttl";
+# Tag and preserve in case we want to commit them as-is later, but mostly we don't care.
+# Commented pending actually doing something with possibly new type DELEGATE
+#my $tmprev = $host;
+#$tmprev =~ s/^\d+\.//;
+#($code,$tmprev) = _zone2cidr($tmprev);
+#push @{$suboct{"$tmprev"}{cname}}, $val;
+	  # Silently skip CNAMEs in revzones.
+	  next;
+	} else {
+	  $val = $rr->cname;
+	}
       } elsif ($type eq 'SOA') {
 	next if $rwsoa;
-	$vallist[1] = $rr->mname.":".$rr->rname;
-	push @vallist, ($rr->refresh.":".$rr->retry.":".$rr->expire.":".$rr->minimum);
+	$host = $rr->rname.":".$rr->mname;
+	$val = $rr->refresh.":".$rr->retry.":".$rr->expire.":".$rr->minimum;
 	$soaflag = 1;
       } elsif ($type eq 'PTR') {
-	push @vallist, $rr->ptrdname;
+	($host,$val) = _revswap($host,$rr->ptrdname);
+	$logfrag = "Added record '$val $type $host', TTL $ttl";
 	# hmm.  PTR records should not be in forward zones.
       } elsif ($type eq 'MX') {
-	$sql .= ",distance";
-	$vallen .= ",?";
-	push @vallist, $rr->exchange;
-	push @vallist, $rr->preference;
+	$val = $rr->exchange;
+	$distance = $rr->preference;
       } elsif ($type eq 'TXT') {
 ##fixme:  Net::DNS docs say this should be deprecated for rdatastr() or char_str_list(),
 ## but don't really seem enthusiastic about it.
-	my $rrdata = $rr->txtdata;
-	push @vallist, $rrdata;
+#print "should use rdatastr:\n\t".$rr->rdatastr."\n  or char_str_list:\n\t".join(' ',$rr->char_str_list())."\n";
+# rdatastr returns a BIND-targetted logical string, including opening and closing quotes
+# char_str_list returns a list of the individual string fragments in the record
+# txtdata returns the more useful all-in-one form (since we want to push such protocol
+# details as far down the stack as we can)
+# NB:  this may turn out to be more troublesome if we ever have need of >512-byte TXT records.
+	if ($rev eq 'y') {
+	  ($host,$val) = _revswap($host,$rr->txtdata);
+          $logfrag = "Added record '$val $type $host', TTL $ttl";
+	} else {
+	  $val = $rr->txtdata;
+	}
       } elsif ($type eq 'SPF') {
 ##fixme: and the same caveat here, since it is apparently a clone of ::TXT
-	my $rrdata = $rr->txtdata;
-	push @vallist, $rrdata;
+	$val = $rr->txtdata;
       } elsif ($type eq 'AAAA') {
-	push @vallist, $rr->address;
+	$val = $rr->address;
       } elsif ($type eq 'SRV') {
-	$sql .= ",distance,weight,port" if $type eq 'SRV';
-	$vallen .= ",?,?,?" if $type eq 'SRV';
-	push @vallist, $rr->target;
-	push @vallist, $rr->priority;
-	push @vallist, $rr->weight;
-	push @vallist, $rr->port;
+	$val = $rr->target;
+	$distance = $rr->priority;
+	$weight = $rr->weight;
+	$port = $rr->port;
       } elsif ($type eq 'KEY') {
 	# we don't actually know what to do with these...
-	push @vallist, ($rr->flags.":".$rr->protocol.":".$rr->algorithm.":".$rr->key.":".$rr->keytag.":".$rr->privatekeyname);
+	$val = $rr->flags.":".$rr->protocol.":".$rr->algorithm.":".$rr->key.":".$rr->keytag.":".$rr->privatekeyname;
       } else {
-	my $rrdata = $rr->rdatastr;
-	push @vallist, $rrdata;
+	$val = $rr->rdatastr;
 	# Finding a different record type is not fatal.... just problematic.
 	# We may not be able to export it correctly.
@@ -2692,20 +4395,85 @@
       }
 
-# BIND supports:
-# A CNAME HINFO MB(ex) MD(ob) MF(ob) MG(ex) MINFO(ex) MR(ex) MX NS NULL
-# PTR SOA TXT WKS AFSDB(ex) ISDN(ex) RP(ex) RT(ex) X25(ex) PX
-# ... if one can ever find the right magic to format them correctly
-
-# Net::DNS supports:
-# RRSIG SIG NSAP NS NIMLOC NAPTR MX MR MINFO MG MB LOC ISDN IPSECKEY HINFO
-# EID DNAME CNAME CERT APL AFSDB AAAA A DS NXT NSEC3PARAM NSEC3 NSEC KEY
-# DNSKEY DLV X25 TXT TSIG TKEY SSHFP SRV SPF SOA RT RP PX PTR NULL APL::AplItem
-
-      $sth = $dbh->prepare($sql.") VALUES (".$vallen.")") or die "problem preparing record insert SQL\n";
-      $sth->execute(@vallist) or die "failed to insert ".$rr->string.": ".$sth->errstr."\n";
+      my $logentry = "[AXFR ".($rev eq 'n' ? $zone : $cidr)."] ";
+
+      if ($merge) {
+	if ($rev eq 'n') {
+	  # importing a domain;  we have A and AAAA records that could be merged with matching PTR records
+	  my $etype;
+	  my ($erdns,$erid,$ettl) = $dbh->selectrow_array("SELECT rdns_id,record_id,ttl FROM records ".
+		"WHERE host=? AND val=? AND type=12",
+		undef, ($host, $val) );
+	  if ($erid) {
+	    if ($type eq 'A') {	# PTR -> A+PTR
+	      $etype = 65280;
+	      $logentry .= "Merged A record with existing PTR record '$host A+PTR $val', TTL $ettl";
+	    }
+	    if ($type eq 'AAAA') {	# PTR -> AAAA+PTR
+	      $etype = 65281;
+	      $logentry .= "Merged AAAA record with existing PTR record '$host AAAA+PTR $val', TTL $ettl";
+	    }
+	    $ettl = ($ettl < $ttl ? $ettl : $ttl);    # use lower TTL
+	    $dbh->do("UPDATE records SET domain_id=?,ttl=?,type=? WHERE record_id=?", undef,
+		($domain_id, $ettl, $etype, $erid));
+	    $nrecs++;
+	    _log($dbh, (group_id => $group, domain_id => $domain_id, rdns_id => $erdns, entry => $logentry) );
+	    next;	# while axfr_next
+	  }
+	} # $rev eq 'n'
+	else {
+	  # importing a revzone, we have PTR records that could be merged with matching A/AAAA records
+	  my ($domid,$erid,$ettl,$etype) = $dbh->selectrow_array("SELECT domain_id,record_id,ttl,type FROM records ".
+		"WHERE host=? AND val=? AND (type=1 OR type=28)",
+		undef, ($host, $val) );
+	  if ($erid) {
+	    if ($etype == 1) {	# A -> A+PTR
+	      $etype = 65280;
+	      $logentry .= "Merged PTR record with existing matching A record '$host A+PTR $val', TTL $ettl";
+	    }
+	    if ($etype == 28) {	# AAAA -> AAAA+PTR
+	      $etype = 65281;
+	      $logentry .= "Merged PTR record with existing matching AAAA record '$host AAAA+PTR $val', TTL $ettl";
+	    }
+	    $ettl = ($ettl < $ttl ? $ettl : $ttl);    # use lower TTL
+	    $dbh->do("UPDATE records SET rdns_id=?,ttl=?,type=? WHERE record_id=?", undef,
+		($rdns_id, $ettl, $etype, $erid));
+	    $nrecs++;
+	    _log($dbh, (group_id => $group, domain_id => $domid, rdns_id => $rdns_id, entry => $logentry) );
+	    next;	# while axfr_next
+	  }
+	} # $rev eq 'y'
+      } # if $merge
+
+      # Insert the new record
+      $sth->execute($domain_id, $rdns_id, $host, $reverse_typemap{$type}, $val,
+	$distance, $weight, $port, $ttl);
 
       $nrecs++;
 
+      if ($type eq 'SOA') {
+	# also !$rwsoa, but if that's set, it should be impossible to get here.
+	my @tmp1 = split /:/, $host;
+	my @tmp2 = split /:/, $val;
+	$logentry .= "Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
+		"[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl";
+      } elsif ($logfrag) {
+	# special case for log entries we need to meddle with a little.
+	$logentry .= $logfrag;
+      } else {
+	$logentry .= "Added record '$host $type";
+	$logentry .= " [distance $distance]" if $type eq 'MX';
+	$logentry .= " [priority $distance] [weight $weight] [port $port]" if $type eq 'SRV';
+	$logentry .= " $val', TTL $ttl";
+      }
+      _log($dbh, (group_id => $group, domain_id => $domain_id, rdns_id => $rdns_id, entry => $logentry) );
+
     } # while axfr_next
+
+# Detect and handle delegated subzones
+# Placeholder for when we decide what to actually do with this, see previous comments in NS and CNAME handling.
+#foreach (keys %suboct) {
+#  print "found ".($suboct{$_}{ns} ? @{$suboct{$_}{ns}} : '0')." NS records and ".
+#	($suboct{$_}{cname} ? @{$suboct{$_}{cname}} : '0')." CNAMEs for $_\n";
+#}
 
     # Overwrite SOA record
@@ -2716,7 +4484,7 @@
       $sthgetsoa->execute($group,$reverse_typemap{SOA});
       while (my ($host,$val,$ttl) = $sthgetsoa->fetchrow_array()) {
-	$host =~ s/DOMAIN/$domain/g;
-	$val =~ s/DOMAIN/$domain/g;
-	$sthputsoa->execute($dom_id,$host,$reverse_typemap{SOA},$val,$ttl);
+	$host =~ s/DOMAIN/$zone/g;
+	$val =~ s/DOMAIN/$zone/g;
+	$sthputsoa->execute($zone_id,$host,$reverse_typemap{SOA},$val,$ttl);
       }
     }
@@ -2729,7 +4497,7 @@
       $sthgetns->execute($group,$reverse_typemap{NS});
       while (my ($host,$val,$ttl) = $sthgetns->fetchrow_array()) {
-	$host =~ s/DOMAIN/$domain/g;
-	$val =~ s/DOMAIN/$domain/g;
-	$sthputns->execute($dom_id,$host,$reverse_typemap{NS},$val,$ttl);
+	$host =~ s/DOMAIN/$zone/g;
+	$val =~ s/DOMAIN/$zone/g;
+	$sthputns->execute($zone_id,$host,$reverse_typemap{NS},$val,$ttl);
       }
     }
@@ -2757,4 +4525,14 @@
 
 
+## DNSDB::importBIND()
+sub importBIND {
+} # end importBIND()
+
+
+## DNSDB::import_tinydns()
+sub import_tinydns {
+} # end import_tinydns()
+
+
 ## DNSDB::export()
 # Export the DNS database, or a part of it
@@ -2785,4 +4563,164 @@
 
 ##fixme: slurp up further options to specify particular zone(s) to export
+
+##fixme: fail if $datafile isn't an open, writable file
+
+  # easy case - export all evarything
+  # not-so-easy case - export item(s) specified
+  # todo:  figure out what kind of list we use to export items
+
+# raw packet in unknown format:  first byte indicates length
+# of remaining data, allows up to 255 raw bytes
+
+  # Locations/views - worth including in the caching setup?
+  my $lochash = $dbh->selectall_hashref("SELECT location,iplist FROM locations", 'location');
+  foreach my $location (keys %$lochash) {
+    foreach my $ipprefix (split /[,\s]+/, $lochash->{$location}{iplist}) {
+      $ipprefix =~ s/\s+//g;
+      print $datafile "%$location:$ipprefix\n";
+    }
+    print $datafile "%$location\n" if !$lochash->{$location}{iplist};
+  }
+
+  # tracking hash so we don't double-export A+PTR or AAAA+PTR records.
+  my %recflags;
+
+  my $domsth = $dbh->prepare("SELECT domain_id,domain,status,changed FROM domains WHERE status=1");
+  my $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
+	"FROM records WHERE domain_id=? AND type < 65280");	# Just exclude all types relating to rDNS
+  my $zonesth = $dbh->prepare("UPDATE domains SET changed='n' WHERE domain_id=?");
+  $domsth->execute();
+  while (my ($domid,$dom,$domstat,$changed) = $domsth->fetchrow_array) {
+##fixme: need to find a way to block opening symlinked files without introducing a race.
+#       O_NOFOLLOW
+#              If  pathname  is a symbolic link, then the open fails.  This is a FreeBSD extension, which was
+#              added to Linux in version 2.1.126.  Symbolic links in earlier components of the pathname  will
+#              still be followed.
+# but that doesn't help other platforms.  :/
+    sysopen(ZONECACHE, "$config{exportcache}/$dom", O_RDWR|O_CREAT);
+    flock(ZONECACHE, LOCK_EX);
+    if ($changed || -s "$config{exportcache}/$dom" == 0) {
+      $recsth->execute($domid);
+      while (my ($host,$type,$val,$dist,$weight,$port,$ttl,$recid,$loc) = $recsth->fetchrow_array) {
+	next if $recflags{$recid};
+
+	$loc = '' if !$loc;	# de-nullify - just in case
+##fixme:  handle case of record-with-location-that-doesn't-exist better.
+# note this currently fails safe (tested) - records with a location that
+# doesn't exist will not be sent to any client
+#	$loc = '' if !$lochash->{$loc};
+
+##fixme:  record validity timestamp. tinydns supports fiddling with timestamps.
+# note $ttl must be set to 0 if we want to use tinydns's auto-expiring timestamps.
+# timestamps are TAI64
+# ~~ 2^62 + time()
+	my $stamp = '';
+
+	# support tinydns' auto-TTL
+	$ttl = '' if $ttl == '0';
+
+	_printrec_tiny($datafile, 'n', \%recflags,
+		$dom, $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp);
+
+	_printrec_tiny(*ZONECACHE, 'n', \%recflags,
+		$dom, $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp)
+		if *ZONECACHE;
+	# in case the zone shrunk, get rid of garbage at the end of the file.
+	truncate(ZONECACHE, tell(ZONECACHE));
+
+	$recflags{$recid} = 1;
+      } # while ($recsth)
+    } else {
+      # domain not changed, stream from cache
+      print $datafile $_ while <ZONECACHE>;
+    }
+    close ZONECACHE;
+    # mark domain as unmodified
+    $zonesth->execute($domid);
+  } # while ($domsth)
+
+  my $revsth = $dbh->prepare("SELECT rdns_id,revnet,status,changed FROM revzones WHERE status=1 ".
+	"ORDER BY masklen(revnet) DESC");
+
+# For reasons unknown, we can't sanely UNION these statements.  Feh.
+# Supposedly it should work though (note last 3 lines):
+## PG manual
+#UNION Clause
+#
+#The UNION clause has this general form:
+#
+#    select_statement UNION [ ALL ] select_statement
+#
+#select_statement is any SELECT statement without an ORDER BY, LIMIT, FOR UPDATE, or FOR SHARE clause. (ORDER BY
+#and LIMIT can be attached to a subexpression if it is enclosed in parentheses. Without parentheses, these
+#clauses will be taken to apply to the result of the UNION, not to its right-hand input expression.)
+  my $soasth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
+	"FROM records WHERE rdns_id=? AND type=6");
+  $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
+	"FROM records WHERE rdns_id=? AND not type=6 ".
+	"ORDER BY masklen(CAST(val AS inet)) DESC, CAST(val AS inet)");
+  $zonesth = $dbh->prepare("UPDATE revzones SET changed='n' WHERE rdns_id=?");
+  $revsth->execute();
+  while (my ($revid,$revzone,$revstat,$changed) = $revsth->fetchrow_array) {
+##fixme: need to find a way to block opening symlinked files without introducing a race.
+#       O_NOFOLLOW
+#              If  pathname  is a symbolic link, then the open fails.  This is a FreeBSD extension, which was
+#              added to Linux in version 2.1.126.  Symbolic links in earlier components of the pathname  will
+#              still be followed.
+# but that doesn't help other platforms.  :/
+    my $tmpzone = NetAddr::IP->new($revzone);
+    sysopen(ZONECACHE, "$config{exportcache}/".$tmpzone->network->addr, O_RDWR|O_CREAT);
+    flock(ZONECACHE, LOCK_EX);
+    if ($changed || -s "$config{exportcache}/".$tmpzone->network->addr == 0) {
+      # need to fetch this separately since the rest of the records all (should) have real IPs in val
+      $soasth->execute($revid);
+      my (@zsoa) = $soasth->fetchrow_array();
+      _printrec_tiny($datafile,'y',\%recflags,$revzone,
+	$zsoa[0],$zsoa[1],$zsoa[2],$zsoa[3],$zsoa[4],$zsoa[5],$zsoa[6],$zsoa[8],'');
+
+      $recsth->execute($revid);
+      while (my ($host,$type,$val,$dist,$weight,$port,$ttl,$recid,$loc) = $recsth->fetchrow_array) {
+	next if $recflags{$recid};
+
+	$loc = '' if !$loc;	# de-nullify - just in case
+##fixme:  handle case of record-with-location-that-doesn't-exist better.
+# note this currently fails safe (tested) - records with a location that
+# doesn't exist will not be sent to any client
+#	$loc = '' if !$lochash->{$loc};
+
+##fixme:  record validity timestamp. tinydns supports fiddling with timestamps.
+# note $ttl must be set to 0 if we want to use tinydns's auto-expiring timestamps.
+# timestamps are TAI64
+# ~~ 2^62 + time()
+	my $stamp = '';
+
+	# support tinydns' auto-TTL
+	$ttl = '' if $ttl == '0';
+
+	_printrec_tiny($datafile, 'y', \%recflags, $revzone,
+		$host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp);
+	_printrec_tiny(*ZONECACHE, 'y', \%recflags, $revzone,
+		$host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp)
+		if *ZONECACHE;
+	# in case the zone shrunk, get rid of garbage at the end of the file.
+	truncate(ZONECACHE, tell(ZONECACHE));
+
+	$recflags{$recid} = 1;
+      } # while ($recsth)
+    } else {
+      # zone not changed, stream from cache
+      print $datafile $_ while <ZONECACHE>;
+    }
+    close ZONECACHE;
+    # mark domain as unmodified
+    $zonesth->execute($revid);
+  } # while ($domsth)
+
+} # end __export_tiny()
+
+
+# Utility sub for __export_tiny above
+sub _printrec_tiny {
+  my ($datafile,$revrec,$recflags,$zone,$host,$type,$val,$dist,$weight,$port,$ttl,$loc,$stamp) = @_;
 
   ## Convert a bare number into an octal-coded pair of octets.
@@ -2797,38 +4735,34 @@
   }
 
-##fixme: fail if $datafile isn't an open, writable file
-
-  # easy case - export all evarything
-  # not-so-easy case - export item(s) specified
-  # todo:  figure out what kind of list we use to export items
-
-  my $domsth = $dbh->prepare("SELECT domain_id,domain,status FROM domains WHERE status=1");
-  my $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl ".
-	"FROM records WHERE domain_id=?");
-  $domsth->execute();
-  while (my ($domid,$dom,$domstat) = $domsth->fetchrow_array) {
-    $recsth->execute($domid);
-    while (my ($host,$type,$val,$dist,$weight,$port,$ttl) = $recsth->fetchrow_array) {
-##fixme:  need to store location in the db, and retrieve it here.
-# temporarily hardcoded to empty so we can include it further down.
-my $loc = '';
-
-##fixme:  record validity timestamp. tinydns supports fiddling with timestamps.
-# note $ttl must be set to 0 if we want to use tinydns's auto-expiring timestamps.
-# timestamps are TAI64
-# ~~ 2^62 + time()
-my $stamp = '';
-
-# raw packet in unknown format:  first byte indicates length
-# of remaining data, allows up to 255 raw bytes
-
-	# 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+$//;
-	}
+## WARNING:  This works to export even the whole Internet's worth of IP space...
+##  if you have the disk/RAM to handle the dataset, and you call this sub based on /16-sized chunks
+##  A /16 took ~3 seconds with a handful of separate records;  adding a /8 pushed export time out to ~13m:40s 
+##  0/0 is estimated to take ~54 hours and ~256G of disk
+##  RAM usage depends on how many non-template entries you have in the set.
+##  This should probably be done on record addition rather than export;  large blocks may need to be done in a
+##  forked process
+  sub __publish_subnet {
+    my $sub = shift;
+    my $recflags = shift;
+    my $hpat = shift;
+    my $fh = shift;
+    my $ttl = shift;
+    my $stamp = shift;
+    my $loc = shift;
+    my $ptronly = shift || 0;
+
+    my $iplist = $sub->splitref(32);
+    foreach (@$iplist) {
+      my $ip = $_->addr;
+      # make as if we split the non-octet-aligned block into octet-aligned blocks as with SOA
+      next if $ip =~ /\.(0|255)$/;
+      next if $$recflags{$ip};
+      $$recflags{$ip}++;
+      my $rec = $hpat;	# start fresh with the template for each IP
+      _template4_expand(\$rec, $ip);
+      print $fh ($ptronly ? "^"._ZONE($_, 'ZONE.in-addr.arpa', 'r', '.').":$rec" : "=$rec:$ip").
+	":$ttl:$stamp:$loc\n";
+    }
+  }
 
 ##fixme?  append . to all host/val hostnames
@@ -2841,5 +4775,20 @@
 	my ($email, $primary) = (split /:/, $host)[0,1];
 	my ($refresh, $retry, $expire, $min_ttl) = (split /:/, $val)[0,1,2,3];
-	print $datafile "Z$dom:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n";
+	if ($revrec eq 'y') {
+##fixme:  have to publish SOA records for each v4 /24 in sub-/16, and each /16 in sub-/8
+# what about v6?
+# -> only need SOA for local chunks offset from reverse delegation boundaries, so v6 is fine
+	  $zone = NetAddr::IP->new($zone);
+	  # handle split-n-multiply SOA for off-octet (8 < mask < 16) or (16 < mask < 24) v4 zones
+	  if (!$zone->{isv6} && ($zone->masklen < 24) && ($zone->masklen % 8 != 0)) {
+	    foreach my $szone ($zone->split($zone->masklen + (8 - $zone->masklen % 8))) {
+	      $szone = _ZONE($szone, 'ZONE.in-addr.arpa', 'r', '.');
+	      print $datafile "Z$szone:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n";
+	    }
+	    return; # skips "default" bits just below
+	  }
+	  $zone = _ZONE($zone, 'ZONE', 'r', '.').($zone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
+	}
+	print $datafile "Z$zone:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n";
 
       } elsif ($typemap{$type} eq 'A') {
@@ -2849,5 +4798,29 @@
       } elsif ($typemap{$type} eq 'NS') {
 
-	print $datafile "\&$host"."::$val:$ttl:$stamp:$loc\n";
+	if ($revrec eq 'y') {
+	  $val = NetAddr::IP->new($val);
+	  # handle split-n-multiply SOA for off-octet (8 < mask < 16) or (16 < mask < 24) v4 zones
+	  if (!$val->{isv6} && ($val->masklen < 24) && ($val->masklen % 8 != 0)) {
+	    foreach my $szone ($val->split($val->masklen + (8 - $val->masklen % 8))) {
+	      my $szone2 = _ZONE($szone, 'ZONE.in-addr.arpa', 'r', '.');
+	      next if $$recflags{$szone2} && $$recflags{$szone2} > $val->masklen;
+	      print $datafile "\&$szone2"."::$host:$ttl:$stamp:$loc\n";
+	      $$recflags{$szone2} = $val->masklen;
+	    }
+	  } elsif ($val->{isv6} && ($val->masklen < 64) && ($val->masklen % 4 !=0)) {
+	    foreach my $szone ($val->split($val->masklen + (4 - $val->masklen % 4))) {
+	      my $szone2 = _ZONE($szone, 'ZONE.ip6.arpa', 'r', '.');
+	      next if $$recflags{$szone2} && $$recflags{$szone2} > $val->masklen;
+	      print $datafile "\&$szone2"."::$host:$ttl:$stamp:$loc\n";
+	      $$recflags{$szone2} = $val->masklen;
+	    }
+	  } else {
+	    my $val2 = _ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
+	    print $datafile "\&$val2"."::$host:$ttl:$stamp:$loc\n";
+	    $$recflags{$val2} = $val->masklen;
+	  }
+	} else {
+	  print $datafile "\&$host"."::$val:$ttl:$stamp:$loc\n";
+	}
 
       } elsif ($typemap{$type} eq 'AAAA') {
@@ -2881,6 +4854,13 @@
 
 ##fixme:  split v-e-r-y long TXT strings?  will need to do so for BIND export, at least
-	$val =~ s/:/\\072/g;	# may need to replace other symbols
-	print $datafile "'$host:$val:$ttl:$stamp:$loc\n";
+        if ($revrec eq 'n') {
+	  $val =~ s/:/\\072/g;	# may need to replace other symbols
+	  print $datafile "'$host:$val:$ttl:$stamp:$loc\n";
+	} else {
+	  $host =~ s/:/\\072/g;	# may need to replace other symbols
+	  my $val2 = NetAddr::IP->new($val);
+	  print $datafile "'"._ZONE($val2, 'ZONE', 'r', '.').($val2->{isv6} ? '.ip6.arpa' : '.in-addr.arpa').
+		":$host:$ttl:$stamp:$loc\n";
+	}
 
 # by-hand TXT
@@ -2903,5 +4883,11 @@
       } elsif ($typemap{$type} eq 'CNAME') {
 
-	print $datafile "C$host:$val:$ttl:$stamp:$loc\n";
+	if ($revrec eq 'n') {
+	  print $datafile "C$host:$val:$ttl:$stamp:$loc\n";
+	} else {
+	  my $val2 = NetAddr::IP->new($val);
+	  print $datafile "C"._ZONE($val2, 'ZONE', 'r', '.').($val2->{isv6} ? '.ip6.arpa' : '.in-addr.arpa').
+		":$host:$ttl:$stamp:$loc\n";
+	}
 
       } elsif ($typemap{$type} eq 'SRV') {
@@ -2936,8 +4922,107 @@
       } elsif ($typemap{$type} eq 'PTR') {
 
-	# must handle both IPv4 and IPv6
-##work
-	# data should already be in suitable reverse order.
-	print $datafile "^$host:$val:$ttl:$stamp:$loc\n";
+	$zone = NetAddr::IP->new($zone);
+	$$recflags{$val}++;
+	if (!$zone->{isv6} && $zone->masklen > 24) {
+	  ($val) = ($val =~ /\.(\d+)$/);
+	  print $datafile "^$val."._ZONE($zone, 'ZONE', 'r', '.').'.in-addr.arpa'.
+		":$host:ttl:$stamp:$loc\n";
+	} else {
+	  $val = NetAddr::IP->new($val);
+	  print $datafile "^".
+		_ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa').
+		":$host:$ttl:$stamp:$loc\n";
+	}
+
+      } elsif ($type == 65280) { # A+PTR
+
+	$$recflags{$val}++;
+	print $datafile "=$host:$val:$ttl:$stamp:$loc\n";
+
+      } elsif ($type == 65281) { # AAAA+PTR
+
+#$$recflags{$val}++;
+	# treat these as two separate records.  since tinydns doesn't have
+	# a native combined type, we have to create them separately anyway.
+	if ($revrec eq 'n') {
+	  $type = 28;
+	} else {
+	  $type = 12;
+	}
+	_printrec_tiny($datafile,$revrec,$recflags,$zone,$host,$type,$val,$dist,$weight,$port,$ttl,$loc,$stamp);
+##fixme: add a config flag to indicate use of the patch from http://www.fefe.de/dns/
+# type 6 is for AAAA+PTR, type 3 is for AAAA
+
+      } 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_subnet($sub, $recflags, $host, $datafile, $ttl, $stamp, $loc, 1);
+	  }
+	} else {
+	  __publish_subnet($val, $recflags, $host, $datafile, $ttl, $stamp, $loc, 1);
+	}
+
+      } 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_subnet($sub, $recflags, $host, $datafile, $ttl, $stamp, $loc, 0);
+	  }
+	} else {
+	  __publish_subnet($val, $recflags, $host, $datafile, $ttl, $stamp, $loc, 0);
+	}
+
+      } 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/ )
+
+      } elsif ($type == 65285) { # Delegation
+	# This is intended for reverse zones, but may prove useful in forward zones.
+
+	# All delegations need to create one or more NS records.  The NS record handler knows what to do.
+	_printrec_tiny($datafile,$revrec,$recflags,$zone,$host,$reverse_typemap{'NS'},
+		$val,$dist,$weight,$port,$ttl,$loc,$stamp);
+	if ($revrec eq 'y') {
+	  # In the case of a sub-/24 v4 reverse delegation, we need to generate CNAMEs
+	  # to redirect all of the individual IP lookups as well.
+	  # Not sure how this would actually resolve if a /24 or larger was delegated
+	  # one way, and a sub-/24 in that >=/24 was delegated elsewhere...
+	  my $dblock = NetAddr::IP->new($val);
+	  if (!$dblock->{isv6} && $dblock->masklen > 24) {
+	    my @subs = $dblock->split;
+	    foreach (@subs) {
+	      next if $$recflags{"$_"};
+	      my ($oct) = ($_->addr =~ /(\d+)$/);
+	      print $datafile "C"._ZONE($_, 'ZONE.in-addr.arpa', 'r', '.').":$oct.".
+		_ZONE($dblock, 'ZONE.in-addr.arpa', 'r', '.').":$ttl:$stamp:$loc\n";
+	      $$recflags{"$_"}++;
+	    }
+	  }
+	}
+
+##
+## Uncommon types.  These will need better UI support Any Day Sometime Maybe(TM).
+##
+
+      } elsif ($type == 44) { # SSHFP
+	my ($algo,$fpt,$fp) = split /\s+/, $val;
+
+	my $rec = sprintf ":$host:44:\\%0.3o\\%0.3o", $algo, $fpt;
+	while (my ($byte) = ($fp =~ /^(..)/) ) {
+	  $rec .= sprintf "\\%0.3o", hex($byte);
+	  $fp =~ s/^..//;
+	}
+	print $datafile "$rec:$ttl:$stamp:$loc\n";
 
       } else {
@@ -2954,11 +5039,9 @@
       } # record type if-else
 
-    } # while ($recsth)
-  } # while ($domsth)
-} # end __export_tiny()
+} # end _printrec_tiny()
 
 
 ## DNSDB::mailNotify()
-# Sends notification mail to recipients regarding an IPDB operation
+# Sends notification mail to recipients regarding a DNSDB operation
 sub mailNotify {
   my $dbh = shift;
