#!/usr/local/bin/perl
# Copyright (C) 2011, 2015 Sergey Poznyakoff <gray@gnu.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

use strict;
use Getopt::Long qw(:config gnu_getopt no_ignore_case);
use DBI;
use POSIX qw(strftime setuid setgid);
use File::Temp qw(tempfile);
use Socket;
use Net::DNS;
use Net::CIDR;
use Pod::Usage;
use Pod::Man;

# Options:
my $sys_config_file = "/etc/dnsdbck.conf"; # Configuration file name
my $debug;         # Debug mode indicator.
my $logfile;       # Name of the logfile.
my $dry_run;       # Dry-run mode.
my $help;          # Show help and exit.
my $man;           # Show man and exit.
my $my_cnf;        # Name of the mysql option file.
my $all_zones;     # Process all zones.
my $author;        # Author name for INSERT statements.

my $username;

my $sqlfilename;   # Name of the output SQL file.
my $sqlfile;       # Its handle.

# Global vars:
my $descr = "check and repair the DNS database";
my $script;        # This script name.

my %debug_level = ( 'GENERAL' => 0,
		    'SQL' => 0,
		    'DNS' => 0,
		    'MISSING' => 0 );

# FIXME: these three should be configurable too
my %ns_name = ( 'ns1.farlep.net' => 1, 'ns3.farlep.net' => 1 );
my $primary_ns = 'ns1.farlep.net';
my $responsible_person = 'hostmaster.ns1.farlep.net';

my @create_soa_allow_list; # list of CIDRs for which creating SOA records
                           # is allowed.

my $dbd;           # Database connection descriptor.

our $sys_dbname;
our $sys_dbhost;
our $sys_dbport;
our $sys_dbuser;
our $sys_dbpasswd;
our $sys_dbparams;

my %ignored_zone;
my %ignored_host;
my %nosoa_warning; # Used to avoid duplicate 'no SOA' warnings.
##

sub logit {
    print LOG "@_\n";
}

sub loginit {
    if ($logfile and (!-e $logfile or -w $logfile)) {
	print STDERR "$script: logging to $logfile\n";
	open(LOG, ">$logfile");
    } else {
	open(LOG, ">&STDERR");
    }
}

sub logdone {
}

sub debug {
    my $category = shift;
    my $level = shift;
#    print STDERR "$category: $debug_level{$category} >= $level\n";
    if ($debug_level{$category} >= $level) {
	print LOG "$script: DEBUG[$category]: @_\n";
    }
}

sub read_config_file($) {
    my $config_file = shift;
    print STDERR "reading $config_file\n" if ($debug);
    open(FILE, "<", $config_file) or die("cannot open $config_file: $!");
    while (<FILE>) {
	chomp;
	s/^\s+//;
	s/\s+$//;
	s/\s+=\s+/=/;
	s/#.*//;
        next if ($_ eq "");
	unshift(@ARGV, "--$_");
    }
}

## Returns a hash of lists of entries
# arg0 : table
# arg1 : criterion
# arg2 : fields to show (* for all)
sub GetDBHashArray {
    my $table = $_[0];
    my $criterion;
    my $fields = "*";
    my $hop;
    my @ret;

    $criterion = "WHERE ".$_[1] if $_[1];
    $fields = $_[2] if $_[2];

    my $query = "SELECT ".$fields." FROM ".$table." ".$criterion;
    debug('SQL', 2, "Query $query");
    $hop = $dbd->prepare($query);
    $hop->execute;
    while (my $ref = $hop->fetchrow_hashref) {
	push(@ret, $ref);
    }
    $hop->finish;
    return @ret;
}

## Returns a list of entries
# arg0 : table
# arg1 : criterion
# arg2 : fields to show (* for all)
sub GetDB {
    my $table = $_[0];
    my $criterion;
    my $fields = "*";
    my $hop;
    my @ret;

    $criterion = "WHERE ".$_[1] if $_[1];
    $fields = $_[2] if $_[2];

    my $query = "SELECT ".$fields." FROM ".$table." ".$criterion;
    debug('SQL', 2, "Query $query");
    $hop = $dbd->prepare($query);
    $hop->execute;
    while (my (@line) = $hop->fetchrow_array) {
	push(@ret, join(",", map {defined $_ ? $_ : "0"} @line));

    }
    $hop->finish;
    return @ret;
}

sub matches_direct($) {
    my $arg = $_[0];
    return 1 if ($#ARGV == -1);
    foreach my $zone (@ARGV) {
	return 1 if (substr($arg, - (length($zone) + 1)) eq "$zone.");
    }
    return 0;
}

my %soa_records;
sub has_soa($) {
    my $zone = $_[0];
    $zone =~ s/\.$//;
    unless (defined($soa_records{$zone})) {
	my @soa = GetDB("dns_soa",
			"type='SOA' AND zone='".$zone."'");
	$soa_records{$zone} = ($#soa >= 0);
    }
    return $soa_records{$zone};
}

sub build_insert_query($$$$$$) {
    my ($zone, $host, $type, $data, $view, $ttl) = @_;
    return sprintf("INSERT INTO dns_records (author,zone,host,type,data,view,ttl)"
		   . " VALUES('%s','%s','%s','%s','%s','%s','%s')",
		   $author,
		   $zone, $host, $type, $data, $view, $ttl);
}

sub read_from_file($%) {
    my ($file, $hashref) = @_;
    open(FILE, "<", $file)
	or die("Cannot open file $file for reading");
    while (<FILE>) {
	chomp;
	s/^\s+//;
	s/\s+$//;
	s/#.*//;
	next if ($_ eq "");
	$hashref->{$_} = 1;
    }
    close(FILE);
}

sub build_delete_query($) {
    my ($serial_id) = @_;
    return "DELETE FROM dns_records WHERE serial=$serial_id";
}

sub build_soa($$) {
    my ($key,$view) = @_;

    my $query = sprintf("INSERT INTO dns_soa (zone,type,data,serial,resp_person,view) VALUES('%s','SOA','%s.','%s','%s.','%s');\n",
			$key, $primary_ns,
			strftime('%Y%m%d00', localtime),
			$responsible_person,
			$view);
    foreach my $ns (keys %ns_name) {
	$query .= sprintf("INSERT INTO dns_soa (zone,type,data,view) VALUES('%s','NS','%s.','%s');\n", $key, $ns, $view);
    }
    return $query;
}
#######

my $resolver;

sub check_zone_ns($) {
    my ($zone) = @_;
    $resolver = Net::DNS::Resolver->new if (!$resolver);
    debug('DNS', 1, "querying NSs for $zone");
    my $query = $resolver->query($zone, "NS");
    if ($query) {
	foreach my $rr (grep { $_->type eq 'NS' } $query->answer) {
	    debug('DNS', 2, "$zone: ns=".$rr->nsdname);
	    return 1 if ($ns_name{$rr->nsdname});
	}
    } else {
	debug('DNS', 1, "query failed: ", $resolver->errorstring);
	return 2 if (($resolver->errorstring eq "NXDOMAIN") or
		     ($resolver->errorstring eq "NOERROR"));
	return -1;
    }
    debug('DNS', 1, "$zone: not ours");
    return 0;
}

sub check_host_name($$) {
    my ($addr, $name) = @_;
    $name =~ s/\.$//;
    $resolver = Net::DNS::Resolver->new if (!$resolver);
    debug('DNS', 1, "matching PTRs for $addr against $name");
    my $query = $resolver->query($addr, "PTR");
    if ($query) {
	foreach my $rr (grep { $_->type eq 'PTR' } $query->answer) {
	    my $s;
	    debug('DNS', 2, "$addr: ptr=".$rr->name."; string=".$rr->rdatastr);
	    ($s = $rr->rdatastr) =~ s/\.$//;
	    return 1 if ($s eq $name);
	    return 2 if ((length($s) > length($name)
			  and substr($s, - (length($name) + 1)) eq ".$name") or
			 (length($name) > length($s)
			  and substr($name, - (length($s) + 1)) eq ".$s"))
	}
    } else {
	debug('DNS', 1, "query failed: ", $resolver->errorstring);
	return -2 if ($resolver->errorstring eq "NXDOMAIN");
	return -1;
    }
    debug('DNS', 1, "$addr: no match");
    return 0;
}

my @private_network_cidr_list;

sub private_network($) {
    my $arg = shift;

    if ($#private_network_cidr_list == -1) {
	@private_network_cidr_list =
	    Net::CIDR::cidradd("10.0.0.0/8", @private_network_cidr_list);
	@private_network_cidr_list =
	    Net::CIDR::cidradd("172.16.0.0/12", @private_network_cidr_list);
	@private_network_cidr_list =
	    Net::CIDR::cidradd("192.168.0.0/16", @private_network_cidr_list);
    }
    if ($arg =~ /.*\.in-addr\.arpa$/) {
	my @octet = split(/\./, $arg);
	$arg = "$octet[2].$octet[1].$octet[0].0/24";
    }
    return 0 unless Net::CIDR::cidrvalidate($arg);
    return Net::CIDR::cidrlookup($arg, @private_network_cidr_list);
}

# Return true if creating SOA record for reverse $zone is allowed.
sub create_soa_allowed($) {
    my $arg = shift;

    if ($arg =~ /.*\.in-addr\.arpa$/) {
	my @octet = split(/\./, $arg);
	$arg = "$octet[2].$octet[1].$octet[0].0/24";
    }
    return 0 unless Net::CIDR::cidrvalidate($arg);
    return Net::CIDR::cidrlookup($arg, @create_soa_allow_list);
}

#############

($script = $0) =~ s/.*\///;

my $home;

eval {
	my @ar = getpwuid($<);
	$home = $ar[7];
};

if ($ENV{'DNSDBCK_CONF'}) {
    read_config_file($ENV{'DNSDBCK_CONF'});
} elsif (-e "$home/.dnsdbck.conf") {
    read_config_file("$home/.dnsdbck.conf");
} elsif (-e "$sys_config_file") {
    read_config_file("$sys_config_file");
}

GetOptions("help|man" => \$man,
	   "h" => \$help,
	   "debug|d:s" => sub {
	       if (!$_[1]) {
		   foreach my $key (keys %debug_level) {
		       $debug_level{$key} = 1;
		   }
	       } else {
		   foreach my $cat (split(/,/, $_[1])) {
		       my @s = split(/[:=]/, $cat, 2);
		       $s[0] =~ tr/[a-z]/[A-Z]/;
		       if (defined($debug_level{$s[0]})) {
			   $debug_level{$s[0]} =
			       ($#s == 1) ? $s[1] : 1;
		       } else {
			   print STDERR "$script: no such category: $s[0]\n";
			   exit(1);
		       }
		   }
	       }
	   },
	   "create-reverse-soa=s" => sub {
	       foreach my $cidr (split(/,/, $_[1])) {
#		       unless (Net::CIDR::cidrvalidate($cidr)) {
#			   print STDERR "$script: invalid CIDR: $cidr\n";
#			   exit(1);
#		       }
		   @create_soa_allow_list =
		       Net::CIDR::cidradd($cidr, @create_soa_allow_list);
	       }
	   },
	   "dry-run|n" => \$dry_run,
	   "my-cnf|c=s" => \$my_cnf,
	   "author|a=s" => \$author,
	   "outfile|o=s" => \$sqlfilename,
	   "all|A" => \$all_zones,
	   "ignore-zone=s" => sub {
	       foreach my $zone (split(/,/, $_[1])) {
		   $ignored_zone{$zone} = 1;
	       }
	   },
	   "ignore-zones-from=s" => sub {
	       read_from_file($_[1], \%ignored_zone);
	   },
	   "ignore-host=s" => sub {
	       foreach my $host (split(/,/, $_[1])) {
		   $ignored_host{$host} = 1;
	       }
	   },
	   "ignore-hosts-from=s" => sub {
	       read_from_file($_[1], \%ignored_host);
	   },
	   "log-file|l=s" => \$logfile,
	   "user-name|u=s" => \$username
    ) or exit(1);

pod2usage(-message => "$script: $descr", -exitstatus => 0) if $help;
pod2usage(-exitstatus => 0, -verbose => 2) if $man;

if ($username) {
    my ($login,$pw,$uid,$gid,$quota,$comment,$gecos,$dir) = getpwnam($username)
	or die("no such user: $username");
    setgid($gid);
    setuid($uid);
    die("cannot switch to $username privileges")
	unless ($< == $uid and $( == $gid);
    $home = $dir;
}

unless (defined($author)) {
    my ($login) = getpwuid($<);
    $author = $login;
}

if ($#ARGV == -1 and !$all_zones) {
    print STDERR "$script: no zones specified, try `$script --help' for more info\n";
    exit(1);
}

if (defined($sqlfilename)) {
    $dry_run = 1;
    open($sqlfile, "+>", $sqlfilename) or
	die("cannot open file $sqlfilename: $!");
    print $sqlfile strftime "-- SQL script generated by $script on %c.\n",
	  localtime;
} else {
    ($sqlfile, $sqlfilename) = tempfile(UNLINK => 1);
}

loginit();

debug('SQL', 1, "connecting to the database");

my $arg = "";
$arg .= ":database=$sys_dbname" if ($sys_dbname);
$arg .= ":host=$sys_dbhost" if ($sys_dbhost);
$arg .= ":port=$sys_dbport" if ($sys_dbport);
$arg .= ":$sys_dbparams" if ($sys_dbparams);

if (!$arg) {
    $my_cnf = "$home/.my.cnf" if (!$my_cnf);
    debug('SQL', 1, "using mysql option file $my_cnf");
    $arg = ":;mysql_read_default_file=$my_cnf";
}
$arg = 'DBI:mysql'.$arg;

$dbd = DBI->connect($arg,
		    $sys_dbuser, $sys_dbpasswd,
		    { RaiseError => 1, AutoCommit => 1}) or exit(1);

my $criterion = "type='A'";
if ($#ARGV >= 0) {
    $criterion .= " AND zone IN ("
	       . join(',', map { s/[\\\"\']/\\$&/g; "'".$_."'" } @ARGV)
	       . ")";
}

my %reverse_zone;
my $count;
my $insert_count = 0;
my $delete_count = 0;
debug('GENERAL', 1, "collecting reverse zones");
foreach my $ref (GetDBHashArray("dns_records",
				$criterion,
				"zone,host,data,view,ttl,serial")) {
    my %row = %{$ref};
    $row{'data'} =~ s/\s*$//;
    $row{'host'} =~ s/\s*$//;
    my @octets = split(/\./, $row{'data'});
    my $rev = "$octets[2].$octets[1].$octets[0].in-addr.arpa";
    debug('GENERAL', 10, "reverse zone $rev");
    if (!$reverse_zone{$rev}) {
	$reverse_zone{$rev} = {};
    }
    my $dirzone;
    if ($row{'host'} eq "@") {
	$dirzone = $row{'zone'}.".";
    } else {
	$dirzone = $row{'host'}.".".$row{'zone'}.".";
    }
    $reverse_zone{$rev}{$octets[3]} = [$dirzone, $row{'view'}, $row{'ttl'}, $row{'serial'}];
    ++$count;
}
debug('GENERAL', 1,
      "collected $count addresses in " . keys(%reverse_zone) . " reverse zones");


REVLOOP:
while (my ($key, $value) = each(%reverse_zone)) {
    my %revhash = %{$value};
    
    debug('GENERAL', 2,
	  "checking reverse zone $key (".keys(%revhash)." hosts)");

    next if ($ignored_zone{$key});
    unless (has_soa($key)) {
	if (private_network($key) and create_soa_allowed($key)) {
            debug('GENERAL', 1, "creating SOA/NS for $key");
            my $query = build_soa($key, 'internal');
	    print $sqlfile "$query";
        } else {
            while (my ($host, $val) = each(%revhash)) {
		my @ar = @{$val};
		my $hostname = $ar[0];

		my $canon_hostname;
		($canon_hostname = $hostname) =~ s/\.$//;
		next if ($ignored_host{$canon_hostname});
		while ($canon_hostname =~ s/[^\.]+\.//) {
		    next REVLOOP if $ignored_zone{$canon_hostname};
		}
		
		my $fullkey = "$host.$key";
		my $msg = "$ar[3]: $hostname => $fullkey: no corresponding reverse record and no SOA for $key in the database";
		my $rc = check_host_name($fullkey, $hostname);
		if ($rc == 1) {
		    debug('GENERAL', 2, "$msg, but resolve is OK");
		} elsif ($rc == 2) {
		    debug('GENERAL', 2, "$msg, but resolves to a subdomain, so OK");
		} elsif ($rc == 0) {
		    logit("$msg, but the reverse resolves");
		} else {
		    logit($msg);
		}
            }
            next;
        }
    }

    $criterion = "zone='$key' AND type='PTR'";
  DBLOOP:
    foreach my $ref (GetDBHashArray("dns_records",
				    $criterion,
				    "host,data,view,ttl,serial")) {
	my %row = %{$ref};
	next unless matches_direct($row{'data'});

	if (defined($revhash{$row{'host'}})) {
	    delete $revhash{$row{'host'}};
	} else {
	    my $fullkey = $row{'host'}.".".$key;
	    my $revhost;
	    ($revhost = $row{'data'}) =~ s/\.$//;
	    next if ($ignored_host{$revhost} or
		     $ignored_zone{$revhost} or
		     $ignored_host{$fullkey});
	    debug('MISSING', 1,
		  $row{'serial'}.": $fullkey => "
		  .$row{'data'}
		  ." direct record missing");

	    my $dir;
	    my $pref = "";
	    ($dir = $row{'data'}) =~ s/[^\.]+\.//;
	    $dir =~ s/\.$//;
	    next if ($ignored_zone{$dir});

	    unless (has_soa($revhost) or has_soa($dir)) {
		my $msg = $row{'serial'}
		          .": $fullkey => "
			  .$row{'data'}
		          .": no SOA for $dir in the database";

		my (undef,undef,undef,undef,@addrs)
		    = gethostbyname($row{'data'});
		foreach my $addr (@addrs) {
		    my @oct = split(/\./, inet_ntoa($addr));
		    my $revaddr = "$oct[3].$oct[2].$oct[1].$oct[0].in-addr.arpa";
		    debug('DNS', 2, "$row{'data'} resolves to $revaddr");
#			print STDERR "$revaddr eq $fullkey\n";
		    if ($revaddr eq $fullkey) {
			debug('GENERAL', 2, "$msg, but backresolve is OK");
			next DBLOOP;
		    }
		}
		    
		my $nsres = check_zone_ns($dir);
		if ($nsres == 1) {
		    $msg .= ", but the zone is handled by our NSs";
		} elsif ($nsres == 0) {
		    $msg .= " and the zone is NOT handled by our NSs: deleted";
		    my $query = build_delete_query($row{'serial'});
		    print $sqlfile "$query;\n";
		    $delete_count++;
		    logit($msg);
		    next;
		} elsif ($nsres == 2) {
		    $msg .= " and the zone does not exist: deleted";
		    my $query = build_delete_query($row{'serial'});
		    print $sqlfile "$query;\n";
		    $delete_count++;
		    logit($msg);
		    next;
		} else {
		    $msg .= " (resolve error)";
		}

		logit($msg);
		
		$pref = "-- ";
	    }
	    
	    my @octets = split(/\./, $key);
	    my $query = build_insert_query($dir,
					   substr($row{'data'}, 0,
						  length($row{'data'})
						    - length($dir)-2),
					   "A",
					   "$octets[2].$octets[1].$octets[0]."
					   .$row{'host'},
					   $row{'view'},
					   $row{'ttl'});
	    print $sqlfile "$pref$query;\n";
	    $insert_count++ unless ($pref);
	}
    }

    # Generate missing reverse entries
    while (my ($host, $val) = each(%revhash)) {
	my @ar = @{$val};
	debug('MISSING', 1,
	      "$ar[3]: $ar[0] => $host.$key: reverse record missing");
	my $query = build_insert_query($key,
				       $host,
				       "PTR",
				       $ar[0],
				       $ar[1],
				       $ar[2]);
	print $sqlfile "$query;\n";
	$insert_count++;
    }
}

if ($insert_count + $delete_count) {
    debug('GENERAL', 1, "updating database");
    unless ($dry_run) {
	seek($sqlfile, 0, 0);
	while (<$sqlfile>) {
	    next if (/^--/);
	    s/;$//;
	    debug('SQL', 2, "Query: $_\n");
	    $dbd->do($_);
	}
	debug('GENERAL', 1,
	      "inserted: $insert_count, deleted: $delete_count records");
    }
} else {
    debug('GENERAL', 1, "nothing to update in the database");
}

debug('SQL', 1, "closing database");
$dbd->disconnect();
logdone();
debug('GENERAL', 1, "finished");

__END__

=head1 DNSDBCK

dnsdbck - check and, if possible, repair the DNS database

=head1 SYNOPSIS

dnsdbck [I<options>] [B<zone>] [B<zone>...]

=head1 DESCRIPTION

B<Dnsdbck> checks the DNS database for consistency and repairs it, when
possible.  For each record in a specified set of zones, it checks whether
a corresponding reverse exists.  If it does not, B<dnsdbck> tries to
create it, if the corresponding C<in-addr.arpa> zone is present in the
database or if its IP address falls within CIDRs set using the
B<--create-reverse-soa> option (see below).  In the latter case, 
corresponding B<SOA> and B<NS> records will be created.    

=head1 OPTIONS

=over 4
    
=item B<--all>, B<-a>

Process all zones.  When this option is used, any B<zone> arguments are
ignored.

=item B<--create-reverse-soa>=I<cidr>,[I<cidr>...]

Create SOA and NS records for reverse zones matching given CIDRs.
    
=item B<--ignore-zone>=I<zone>[,I<zone>...]

Ignore changes to the listed zones.    

=item B<--ignore-zone-from>=I<FILE>

Read the list of zones to ignore from I<FILE>.  The file format is: one
zone per line, UNIX comments and empty lines are ignored.

=item B<--ignore-host>=I<host>[,I<host>...]

Ignore changes to the listed hostnames.

=item B<--ignore-host-from>=I<FILE>

Read the list of hostnames to ignore from I<FILE>.  The file format is: one
host per line, UNIX comments and empty lines are ignored.    
    
=item B<--my-cnf>=I<FILE>, B<-c> I<FILE>

Use I<FILE> as MySQL options file.  Default is "$HOME/.my.cnf".    

=item B<--author>=I<NAME>, B<-a> I<NAME>

Use I<NAME> for the B<author> column.  Default is your login name.

=item B<--user-name>=I<NAME>, B<-u> I<NAME>

Switch to the privileges of user I<NAME> after startup.    
    
=item B<--outfile>=I<FILE>, B<-o> I<FILE>

Write SQL instructions to repair the database to I<FILE>.  Implies
B<--dry-run>.

=item B<--log-file>=I<FILE>, B<-l> I<FILE>

Write diagnostic output to I<FILE>, instead of standard error.    
    
=item B<--dry-run>, B<-n>

Do not try to repair the database.    
    
=item B<--debug>[=I<spec>[,I<spec>...]], B<-d>[I<spec>[,I<spec>...]]

Set debugging level.  I<spec> is either B<category> or B<category>=B<level>,
B<category> is a debugging category name and B<level> is a decimal
verbosity level.  Valid categories are: C<GENERAL>, C<SQL>, C<DNS> and
C<MISSING> (all case-insensitive).  If B<level> is not supplied, 1 is used
instead.    

=item B<-h>

Show a terse help summary and exit.

=item B<--help>

Prints the manual page and exits.

=back

=head1 CONFIGURATION

The program reads its configuration from one of the following locations:

=over 4

=item B<a.> File name given by C<DNSDBCK_CONF> environment variable (if set)
    
=item B<b.> B<~>/.dnsdbck.conf
    
=item B<c.> /etc/dnsdbck.conf

=back

First of these files that exists is read.  It is an error, if the
B<$DNSDBCK_CONF> variable is set, but points to a file that does not exist.
It is not an error if B<$DNSDBCK_CONF> is not set and neither of the two
remaining files exist.  It is, however, an error if any of the file exists,
but is not readable.

The configuration file uses usual UNIX configuration format.  Empty
lines and UNIX comments are ignored.  Each non-empty line is either an
option name, or option assignment, i.e. B<opt>=B<val>, with any amount of
optional whitespace around the equals sign.  Valid option names are 
the same as long command line options, but without the leading B<-->.
For example: 

  all
  ignore-zones-from = /etc/dns/zone.ignore
  ignore-hosts-from = /etc/dns/host.ignore 
  my-cnf            = /etc/dns/my.cnf 
    
=head1 ENVIRONMENT

=over 4

=item DNSDBCK_CONF

The name of the configuration file to read, instead of the default
F</etc/dnsdbck.conf>.

=back
    
=head1 AUTHOR

Sergey Poznyakoff <gray@gnu.org>

=cut
