Index: branches/stable/DNSDB.pm
===================================================================
--- branches/stable/DNSDB.pm	(revision 438)
+++ branches/stable/DNSDB.pm	(revision 544)
@@ -28,5 +28,5 @@
 use Crypt::PasswdMD5;
 use Net::SMTP;
-use NetAddr::IP;
+use NetAddr::IP qw(:lower);
 use POSIX;
 use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
@@ -39,10 +39,12 @@
 	&changeGroup
 	&loadConfig &connectDB &finish
-	&addDomain &delDomain &domainName &domainID
+	&addDomain &delDomain &domainName &revName &domainID &addRDNS
+	&getZoneCount &getZoneList
 	&addGroup &delGroup &getChildren &groupName
 	&addUser &updateUser &delUser &userFullName &userStatus &getUserData
 	&getSOA	&getRecLine &getDomRecs &getRecCount
 	&addRec &updateRec &delRec
-        &getParents
+	&getTypelist
+	&parentID
 	&isParent
 	&domStatus &importAXFR
@@ -59,10 +61,12 @@
 		&changeGroup
 		&loadConfig &connectDB &finish
-		&addDomain &delDomain &domainName &domainID
+		&addDomain &delDomain &domainName &revName &domainID &addRDNS
+		&getZoneCount &getZoneList
 		&addGroup &delGroup &getChildren &groupName
 		&addUser &updateUser &delUser &userFullName &userStatus &getUserData
 		&getSOA &getRecLine &getDomRecs &getRecCount
 		&addRec &updateRec &delRec
-	        &getParents
+		&getTypelist
+		&parentID
 		&isParent
 		&domStatus &importAXFR
@@ -139,4 +143,468 @@
 		perpage		=> 15,
 	);
+
+## (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
+
 
 
@@ -327,9 +795,17 @@
 
 # load record types from database
-  my $sth = $dbh->prepare("select val,name from rectypes");
+  my $sth = $dbh->prepare("SELECT val,name,stdflag FROM rectypes");
   $sth->execute;
-  while (my ($recval,$recname) = $sth->fetchrow_array()) {
+  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
@@ -534,32 +1010,41 @@
 # Log an action
 # Internal sub
-# Takes a database handle, domain_id, user_id, group_id, email, name and log entry
+# 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 ($domain_id,$user_id,$group_id,$username,$name,$entry) = @_;
+
+  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 (!$user_id) {
-    ($user_id, $fullname) = $dbh->selectrow_array("SELECT user_id, firstname || ' ' || lastname FROM users".
-	" WHERE username=?", undef, ($username));
-  } elsif (!$username) {
-    ($username, $fullname) = $dbh->selectrow_array("SELECT username, firstname || ' ' || lastname FROM users".
-	" WHERE user_id=?", undef, ($user_id));
-  } else {
+  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, ($user_id));
-  }
-
-  $name = $fullname if !$name;
+	" 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,user_id,group_id,email,name,entry) VALUES (?,?,?,?,?,?)", undef,
-	($domain_id,$user_id,$group_id,$username,$name,$entry));
-#            123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
-#                     1         2         3         4         5         6         7
+  $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
 
@@ -619,6 +1104,6 @@
     ($dom_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE domain=?", undef, ($domain));
 
-    _log($dbh, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
-	"Added ".($state ? 'active' : 'inactive')." domain $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.
@@ -634,13 +1119,15 @@
 	my @tmp1 = split /:/, $host;
 	my @tmp2 = split /:/, $val;
-	_log($dbh, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
+	_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");
+		"[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, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
-		$logentry." $val', TTL $ttl");
+	_log($dbh, (domain_id => $dom_id, user_id => $userinfo{id}, group_id => $group,
+		username => $userinfo{username}, entry =>
+		$logentry." $val', TTL $ttl"));
       }
     }
@@ -713,4 +1200,18 @@
 
 
+## 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
@@ -724,4 +1225,189 @@
   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()
 
 
@@ -762,7 +1448,5 @@
     $sth->execute($pargroup,$groupname);
 
-    $sth = $dbh->prepare("SELECT group_id FROM groups WHERE group_name=?");
-    $sth->execute($groupname);
-    my ($groupid) = $sth->fetchrow_array();
+    my ($groupid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($groupname));
 
 # Permissions
@@ -789,6 +1473,8 @@
 
 # Default records
-    $sth = $dbh->prepare("INSERT INTO default_records (group_id,host,type,val,distance,weight,port,ttl) ".
+    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
@@ -797,5 +1483,11 @@
       $sth2->execute($pargroup);
       while (my @clonedata = $sth2->fetchrow_array) {
-	$sth->execute(@clonedata);
+	$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 {
@@ -803,10 +1495,13 @@
       # 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.
-      $sth->execute('ns1.example.com:hostmaster.example.com', 6, '10800:3600:604800:10800', 0, 0, 0, 86400);
-      $sth->execute('DOMAIN', 1, '192.168.4.2', 0, 0, 0, 7200);
-      $sth->execute('DOMAIN', 15, 'mx.example.com', 10, 0, 0, 7200);
-      $sth->execute('DOMAIN', 2, 'ns1.example.com', 0, 0, 0, 7200);
-      $sth->execute('DOMAIN', 2, 'ns2.example.com', 0, 0, 0, 7200);
-      $sth->execute('www.DOMAIN', 5, 'DOMAIN', 0, 0, 0, 7200);
+      $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);
     }
 
@@ -1222,25 +1917,24 @@
 ## DNSDB::getSOA()
 # Return all suitable fields from an SOA record in separate elements of a hash
-# Takes a database handle, default/live flag, and group (default) or domain (live) ID
+# 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
-
-  my $sql = "SELECT record_id,host,val,ttl,distance from";
-  if ($def eq 'def' or $def eq 'y') {
-    $sql .= " default_records WHERE group_id=? AND type=$reverse_typemap{SOA}";
-  } else {
-    # we're editing a live SOA record;  find based on domain
-    $sql .= " records WHERE domain_id=? AND type=$reverse_typemap{SOA}";
-  }
+  # (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);
-
-  my ($recid,$host,$val,$ttl,$serial) = $sth->fetchrow_array() or return;
+##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;
@@ -1248,5 +1942,5 @@
   $ret{recid}	= $recid;
   $ret{ttl}	= $ttl;
-  $ret{serial}	= $serial;
+#  $ret{serial}	= $serial;	# ca't use distance for serial with default_rev_records
   $ret{prins}	= $prins;
   $ret{contact}	= $contact;
@@ -1260,16 +1954,35 @@
 
 
+## 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, and record ID
+# Takes a database handle, default/live flag, forward/reverse flag, and record ID
 sub getRecLine {
   $errstr = '';
   my $dbh = shift;
-  my $def = shift;
+  my $defrec = shift;
+  my $revrec = shift;
   my $id = shift;
 
-  my $sql = "SELECT record_id,host,type,val,distance,weight,port,ttl".
-	(($def eq 'def' or $def eq 'y') ? ',group_id FROM default_' : ',domain_id FROM ').
-	"records WHERE record_id=?";
+  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) );
 
@@ -1284,5 +1997,12 @@
   }
 
-  $ret->{parid} = (($def eq 'def' or $def eq 'y') ? $ret->{group_id} : $ret->{domain_id});
+  # 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;
@@ -1299,5 +2019,6 @@
   $errstr = '';
   my $dbh = shift;
-  my $type = shift;
+  my $def = shift;
+  my $rev = shift;
   my $id = shift;
   my $nrecs = shift || 'all';
@@ -1310,9 +2031,7 @@
   my $filter = shift || '';
 
-  $type = 'y' if $type eq 'def';
-
-  my $sql = "SELECT r.record_id,r.host,r.type,r.val,r.distance,r.weight,r.port,r.ttl FROM ";
-  $sql .= "default_" if $type eq 'y';
-  $sql .= "records r ";
+  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!
@@ -1326,17 +2045,19 @@
 
   $sql .= "INNER JOIN rectypes t ON r.type=t.val ";	# for sorting by type alphabetically
-  if ($type eq 'y') {
-    $sql .= "WHERE r.group_id=?";
-  } else {
-    $sql .= "WHERE r.domain_id=?";
-  }
+  $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";
-  $sql .= " LIMIT $nrecs OFFSET ".($nstart*$nrecs) if $nstart ne 'all';
 
   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;
@@ -1353,10 +2074,12 @@
 
 ## DNSDB::getRecCount()
-# Return count of non-SOA records in domain (or default records in a group)
-# Takes a database handle, default/live flag, group/domain ID, and optional filtering modifier
+# 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 || '';
@@ -1368,10 +2091,10 @@
   my @bindvars = ($id);
   push @bindvars, $filter if $filter;
-  my ($count) = $dbh->selectrow_array("SELECT count(*) FROM ".
-        ($defrec eq 'y' ? 'default_' : '')."records ".
-        "WHERE ".($defrec eq 'y' ? 'group' : 'domain')."_id=? ".
-        "AND NOT type=$reverse_typemap{SOA}".
-	($filter ? " AND host ~* ?" : ''),
-	undef, (@bindvars) );
+  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;
@@ -1387,12 +2110,15 @@
 # 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 $id = 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;
+  my $rectype = shift;	# reference so we can coerce it if "+"-types can't find both zones
   my $val = shift;
   my $ttl = shift;
@@ -1418,38 +2144,39 @@
   }
 
+  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+$/;
 
-  my $fields = ($defrec eq 'y' ? 'group_id' : 'domain_id').",host,type,val,ttl";
-  my $vallen = "?,?,?,?,?";
-  my @vallist = ($id,$host,$rectype,$val,$ttl);
-
-  my $dist;
-  if ($rectype == $reverse_typemap{MX} or $rectype == $reverse_typemap{SRV}) {
-    $dist = shift;
-    return ('FAIL',"Distance is required for $typemap{$rectype} records") unless defined($dist);
-    $dist =~ s/\s*//g;
-    return ('FAIL',"Distance is required, and must be numeric") unless $dist =~ /^\d+$/;
-    $fields .= ",distance";
-    $vallen .= ",?";
-    push @vallist, $dist;
-  }
-  my $weight;
-  my $port;
-  if ($rectype == $reverse_typemap{SRV}) {
-    # check for _service._protocol.  NB:  RFC2782 does not say "MUST"...  nor "SHOULD"...
-    # it just says (paraphrased) "... is prepended with _ to prevent DNS collisions"
-    return ('FAIL',"SRV records must begin with _service._protocol [$host]")
-	unless $host =~ /^_[A-Za-z]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
-    $weight = shift;
-    $port = shift;
-    return ('FAIL',"Port and weight are required for SRV records") unless defined($weight) && defined($port);
-    $weight =~ s/\s*//g;
-    $port =~ s/\s*//g;
-    return ('FAIL',"Port and weight are required, and must be numeric")
-	unless $weight =~ /^\d+$/ && $port =~ /^\d+$/;
-    $fields .= ",weight,port";
-    $vallen .= ",?,?";
-    push @vallist, ($weight,$port);
-  }
+  # 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.
@@ -1459,5 +2186,5 @@
 
   eval {
-    $dbh->do("INSERT INTO ".($defrec eq 'y' ? 'default_' : '')."records ($fields) VALUES ($vallen)",
+    $dbh->do("INSERT INTO "._rectable($defrec, $revrec)." ($fields) VALUES ($vallen)",
 	undef, @vallist);
     $dbh->commit;
@@ -1469,5 +2196,5 @@
   }
 
-  return ('OK','OK');
+  return ($retcode, $retmsg);
 
 } # end addRec()
@@ -1565,7 +2292,8 @@
   my $dbh = shift;
   my $defrec = shift;
+  my $revrec = shift;
   my $id = shift;
 
-  my $sth = $dbh->prepare("DELETE FROM ".($defrec eq 'y' ? 'default_' : '')."records WHERE record_id=?");
+  my $sth = $dbh->prepare("DELETE FROM "._rectable($defrec,$revrec)." WHERE record_id=?");
   $sth->execute($id);
 
@@ -1577,57 +2305,131 @@
 
   # Reference hashes.
-  my %par_tbl = (
+my %par_tbl = (
 		group	=> 'groups',
 		user	=> 'users',
 		defrec	=> 'default_records',
+		defrevrec	=> 'default_rev_records',
 		domain	=> 'domains',
+		revzone	=> 'revzones',
 		record	=> 'records'
 	);
-  my %id_col = (
+my %id_col = (
 		group	=> 'group_id',
 		user	=> 'user_id',
 		defrec	=> 'record_id',
+		defrevrec	=> 'record_id',
 		domain	=> 'domain_id',
+		revzone	=> 'rdns_id',
 		record	=> 'record_id'
 	);
-  my %par_col = (
+my %par_col = (
 		group	=> 'parent_group_id',
 		user	=> 'group_id',
 		defrec	=> 'group_id',
+		defrevrec	=> 'group_id',
 		domain	=> 'group_id',
+		revzone	=> 'group_id',
 		record	=> 'domain_id'
 	);
-  my %par_type = (
+my %par_type = (
 		group	=> 'group',
 		user	=> 'group',
 		defrec	=> 'group',
+		defrevrec	=> 'group',
 		domain	=> 'group',
+		revzone	=> 'group',
 		record	=> 'domain'
 	);
 
-## DNSDB::getParents()
-# Find out which entities are parent to the requested id
-# Returns arrayref containing hash pairs of id/type
-sub getParents {
-  my $dbh = shift;
-  my $id = shift;
-  my $type = shift;
-  my $depth = shift || 'all';	# valid values:  'all', 'immed', <int> (stop at this group ID)
-
-  my @parlist;
-
-  while (1) {
-    my $result = $dbh->selectrow_hashref("SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?",
-	undef, ($id) );
-    my %tmp = ($result->{$par_col{$type}} => $par_type{$type});
-    unshift @parlist, \%tmp;
-    last if $result->{$par_col{$type}} == 1;	# group 1 is its own parent
-    $id = $result->{$par_col{$type}};
-    $type = $par_type{$type};
-  }
-
-  return \@parlist;
-
-} # end getParents()
+
+## DNSDB::getTypelist()
+# Get a list of record types for various UI dropdowns
+# Takes database handle, forward/reverse/lookup flag, and optional "tag as selected" indicator (defaults to A)
+# Returns an arrayref to list of hashrefs perfect for HTML::Template
+sub getTypelist {
+  my $dbh = shift;
+  my $recgroup = shift;
+  my $type = shift || $reverse_typemap{A};
+
+  # also accepting $webvar{revrec}!
+  $recgroup = 'f' if $recgroup eq 'n';
+  $recgroup = 'r' if $recgroup eq 'y';
+
+  my $sql = "SELECT val,name FROM rectypes WHERE ";
+  if ($recgroup eq 'r') {
+    # reverse zone types
+    $sql .= "stdflag=2 OR stdflag=3";
+  } elsif ($recgroup eq 'l') {
+    # DNS lookup types.  Note we avoid our custom types >= 65280, since those are entirely internal.
+    $sql .= "(stdflag=1 OR stdflag=2 OR stdflag=3) AND val < 65280";
+  } else {
+    # default;  forward zone types.  technically $type eq 'f' but not worth the error message.
+    $sql .= "stdflag=1 OR stdflag=2";
+  }
+  $sql .= " ORDER BY listorder";
+
+  my $sth = $dbh->prepare($sql);
+  $sth->execute;
+  my @typelist;
+  while (my ($rval,$rname) = $sth->fetchrow_array()) {
+    my %row = ( recval => $rval, recname => $rname );
+    $row{tselect} = 1 if $rval == $type;
+    push @typelist, \%row;
+  }
+
+  # Add SOA on lookups since it's not listed in other dropdowns.
+  if ($recgroup eq 'l') {
+    my %row = ( recval => $reverse_typemap{SOA}, recname => 'SOA' );
+    $row{tselect} = 1 if $reverse_typemap{SOA} == $type;
+    push @typelist, \%row;
+  }
+
+  return \@typelist;
+} # end getTypelist()
+
+
+## DNSDB::parentID()
+# Get ID of entity that is nearest parent to requested id
+# Takes a database handle and a hash of entity ID, entity type, optional parent type flag
+# (domain/reverse zone or group), and optional default/live and forward/reverse flags
+# Returns the ID or undef on failure
+sub parentID {
+  my $dbh = shift;
+
+  my %args = @_;
+
+  # clean up the parent-type.  Set it to group if not set;  coerce revzone to domain for simpler logic
+  $args{partype} = 'group' if !$args{partype};
+  $args{partype} = 'domain' if $args{partype} eq 'revzone';
+
+  # clean up defrec and revrec.  default to live record, forward zone
+  $args{defrec} = 'n' if !$args{defrec};
+  $args{revrec} = 'n' if !$args{revrec};
+
+  if ($par_type{$args{partype}} eq 'domain') {
+    # only live records can have a domain/zone parent
+    return unless ($args{type} eq 'record' && $args{defrec} eq 'n');
+    my $result = $dbh->selectrow_hashref("SELECT ".($args{revrec} eq 'n' ? 'domain_id' : 'rdns_id').
+	" FROM records WHERE record_id = ?",
+	undef, ($args{id}) ) or return;
+    return $result;
+  } else {
+    # snag some arguments that will either fall through or be overwritten to save some code duplication
+    my $tmpid = $args{id};
+    my $type = $args{type};
+    if ($type eq 'record' && $args{defrec} eq 'n') {
+      # Live records go through the records table first.
+      ($tmpid) = $dbh->selectrow_array("SELECT ".($args{revrec} eq 'n' ? 'domain_id' : 'rdns_id').
+	" FROM records WHERE record_id = ?",
+	undef, ($args{id}) ) or return;
+      $type = ($args{revrec} eq 'n' ? 'domain' : 'revzone');
+    }
+    my ($result) = $dbh->selectrow_array("SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?",
+	undef, ($tmpid) );
+    return $result;
+  }
+# should be impossible to get here with even remotely sane arguments
+  return;
+} # end parentID()
 
 
@@ -1643,17 +2445,18 @@
 
   # Return false on invalid types
-  return 0 if !grep /^$type1$/, ('record','defrec','user','domain','group');
-  return 0 if !grep /^$type2$/, ('record','defrec','user','domain','group');
+  return 0 if !grep /^$type1$/, ('record','defrec','defrevrec','user','domain','revzone','group');
+  return 0 if !grep /^$type2$/, ('record','defrec','defrevrec','user','domain','revzone','group');
 
   # Return false on impossible relations
   return 0 if $type1 eq 'record';	# nothing may be a child of a record
   return 0 if $type1 eq 'defrec';	# nothing may be a child of a record
+  return 0 if $type1 eq 'defrevrec';	# nothing may be a child of a record
   return 0 if $type1 eq 'user';		# nothing may be child of a user
   return 0 if $type1 eq 'domain' && $type2 ne 'record';	# domain may not be a parent of anything other than a record
+  return 0 if $type1 eq 'revzone' && $type2 ne 'record';# reverse zone may not be a parent of anything other than a record
 
   # ennnhhhh....  if we're passed an id of 0, it will never be found.  usual
   # case would be the UI creating a new <thing>, and so we don't have an ID for
   # <thing> to look up yet.  in that case the UI should check the parent as well.
-  # argument for returning 1 is
   return 0 if $id1 == 0;	# nothing can have a parent id of 0
   return 1 if $id2 == 0;	# anything could have a child id of 0 (or "unknown")
@@ -1665,9 +2468,24 @@
   return 1 if $type1 eq 'group' && $type2 eq 'group' && $id1 == $id2;
 
-# almost the same loop as getParents() above
   my $id = $id2;
   my $type = $type2;
   my $foundparent = 0;
 
+  # Records are the only entity with two possible parents.  We need to split the parent checks on
+  # domain/rdns.
+  if ($type eq 'record') {
+    my ($dom,$rdns) = $dbh->selectrow_array("SELECT domain_id,rdns_id FROM records WHERE record_id=?",
+	undef, ($id));
+    # check immediate parent against request
+    return 1 if $type1 eq 'domain' && $id1 == $dom;
+    return 1 if $type1 eq 'revzone' && $id1 == $rdns;
+    # if request is group, check *both* parents.  Only check if the parent is nonzero though.
+    return 1 if $dom && isParent($dbh, $id1, $type1, $dom, 'domain');
+    return 1 if $rdns && isParent($dbh, $id1, $type1, $rdns, 'revzone');
+    # exit here since we've executed the loop below by proxy in the above recursive calls.
+    return 0;
+  }
+
+# almost the same loop as getParents() above
   my $limiter = 0;
   while (1) {
@@ -1677,5 +2495,5 @@
     if (!$result) {
       $limiter++;
-##fixme:  how often will this happen on a live site?
+##fixme:  how often will this happen on a live site?  fail at max limiter <n>?
       warn "no results looking for $sql with id $id (depth $limiter)\n";
       last;
@@ -1686,4 +2504,5 @@
     } else {
 ##fixme: do we care about trying to return a "no such record/domain/user/group" error?
+# should be impossible to create an inconsistent DB just with API calls.
       warn $dbh->errstr." $sql, $id" if $dbh->errstr;
     }
