#!/usr/bin/perl
#
# $Cambridge: hermes/conf/bind/bin/nsdiff,v 1.33 2011/11/24 18:54:31 fanf2 Exp $

use warnings;
use strict;

use Getopt::Std;
use POSIX;

sub fail { die  "nsdiff: @_\n"; }
sub wail { warn "nsdiff: @_\n"; }

# for named-compilezone
$ENV{PATH} .= ":/sbin:/usr/sbin:/usr/local/sbin";
my $compilezone = 'named-compilezone -q -i local -k warn -n warn -o -';
my $cleanzone = q%awk 'BEGIN {B["NSEC"]=B["NSEC3"]=B["NSEC3PARAM"]=B["RRSIG"]=B["DNSKEY"]=B["CDS"]=B["CDNSKEY"]=B["TSIG"]=1} !($4 in B)'%;

sub usage {
    print STDERR <<EOF;
usage: nsdiff [options] [<zone> [old]] <new>
  Generate an `nsupdate` script that changes a zone from the
  "old" version into the "new" version, ignoring DNSSEC records.
  If the "old" file is omitted, `nsdiff` will AXFR the zone.
options:
  -0                  Allow a domain's updates to span packets
  -1                  Abort if update doesn't fit in one packet
  -S num|mode         SOA serial number or update mode
  -v [q][r]           verbose query and/or reply
  -@ address          dig nameserver address
  -b address          dig query source address
  -k keyfile          dig query TSIG key
  -y [hmac:]name:key  dig query TSIG key
EOF
    exit 1;
}
my %opt;
usage unless getopts '01qv:S:@:b:k:y:', \%opt;
usage unless @ARGV == 1 || @ARGV == 2 || @ARGV == 3;

my @digopts;
for my $o (qw{ @ b k y }) {
    push @digopts, map {s/^-@ /@/;$_} "-$o $opt{$o}" if exists $opt{$o};
}
wail "ignoring dig options when loading old zone from file"
    if @digopts && @ARGV == 3;

usage if $opt{v} && $opt{v} !~ m{^[qr]*$};
my $verbosity = $opt{v} || '';

my $soamode = $opt{S} || 'serial';
my $soafun = $soamode =~ m{^[0-9]+$} ?
             sub { return $soamode } : {
   serial => sub { return 0 },
     unix => sub { return time },
     date => sub { return strftime "%Y%m%d00", gmtime },
}->{$soamode} or usage;
my $soamin = $soafun->();

my $zone = shift;
my $file = $zone;
$zone = qx[awk '\$3 == "SOA" {print \$1}' $zone] if @ARGV == 0;
chomp $zone;
$zone =~ s{[.]?$}{.};
my $zonere = quotemeta $zone;
my $domain = qr{(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?[.])+};
my $soare = qr{^$zonere\s+(\d+)\s+(IN\s+SOA\s+$domain\s+$domain)
               \s+(\d+)\s+(\d+\s+\d+\s+\d+\s+\d+\n)$}x;

fail "not a domain name: $zone" unless $zone =~ m{^$domain$};

# Check there is a SOA and remove DNSSEC records.
# Store zone data in the keys of a hash.

sub cleanzone {
    my ($soa,%zone) = shift;
    fail "missing SOA record" unless defined $soa and $soa =~ $soare;
    $zone{$_} = 1 for @_;
    return ($soa,\%zone);
}

sub axfrzone {
    my $zone = shift;
    my $master = $opt{'@'};
    unless (defined($master)) {
        my @soa = split ' ', qx{dig +short soa $zone};
        $master = $soa[0];
        fail "could not get SOA record for $zone"
            unless defined $master and $master =~ m{^$domain$};
    }
    #wail "loading zone $zone via AXFR from $master";
    return cleanzone qx{ dig @digopts axfr $zone \@$master | $cleanzone | $compilezone $zone /dev/stdin };
}

sub loadzone {
    my ($zone,$file) = @_;
    #wail "loading zone $zone from file $file";
    return cleanzone qx{$compilezone -j $zone '$file'};
}

my ($soa,$old) = (@ARGV <= 1)
                  ? axfrzone $zone
                  : loadzone $zone, shift;
my ($newsoa,$new) = (@ARGV == 0)
                  ? loadzone $zone, $file
                  : loadzone $zone, shift;

# Remove unchanged RRs, and save each name's deletions and additions.

my (%del,%add);

for my $rr (keys %$old) {
    delete $old->{$rr};
    next if delete $new->{$rr};
    my ($owner,$ttl,$data) = split ' ', $rr, 3;
    push @{$del{$owner}}, $data;
}
for my $rr (keys %$new) {
    delete $new->{$rr};
    my ($owner,$data) = split ' ', $rr, 2;
    push @{$add{$owner}}, $data;
}

# For each owner name prepare deletion commands followed by addition
# commands. This ensures TTL adjustments and CNAME/other replacements
# are handled correctly. Ensure each owner's changes are not split below.

my (@batch,@script);

sub emit {
    if ($opt{0}) { push @script, splice @batch }
    else { push @script, join '', splice @batch }
}
sub update {
    my ($addel,$owner,$rrs) = @_;
    push @batch, map "update $addel $owner $_", sort @$rrs;
}
for my $owner (keys %del) {
    update 'delete', $owner, delete $del{$owner};
    update 'add', $owner, delete $add{$owner} if exists $add{$owner};
    emit;
}
for my $owner (keys %add) {
    update 'add', $owner, delete $add{$owner};
    emit;
}

# Emit commands in batches that fit within the 64 KiB DNS packet limit
# assuming textual representation is not smaller than binary encoding.
# Use a prerequisite based on the SOA record to catch races.

my $maxlen = 20000;
while (@script) {
    my ($length,$i) = (0,0);
    $length += length $script[$i++] while $length < $maxlen and $i < @script;
    my @batch = splice @script, 0, $length < $maxlen ? $i : $i - 1;
    fail "update does not fit in packet" if @batch == 0 or $opt{1} and @script != 0;
    $soa =~ $soare;
    # 2012-12-11 massar: disable prereq check, because resigning increases serial-no every few seconds
    #print "prereq yxrrset $zone $2 $3 $4";
    my $serial = $3 >= $soamin ? $3 + 1 : $soamin;
    $newsoa =~ $soare;
    print "update add ", $soa = "$zone $1 $2 $serial $4";
    print @batch;
    print "show\n" if $verbosity =~ m{q};
    print "send\n";
    print "answer\n" if $verbosity =~ m{r};
}

exit;

__END__

=head1 NAME

nsdiff - create "nsupdate" script from DNS zone file diffrences

=head1 SYNOPSIS

nsdiff [B<-b> I<address>] [B<-k> I<keyfile>] [B<-y> [I<hmac>:]I<name>:I<key>]
       [B<-0>|B<-1>] [B<-v> [q][r]] [B<-S> I<mode>|I<num>] <I<zone>> [I<old>] <I<new>>

=head1 DESCRIPTION

The C<nsdiff> program examines the F<old> and F<new> versions of a DNS
zone, and outputs the differences as a script for use by BIND's
C<nsupdate> program. It ignores DNSSEC-related differences, assuming
that the name server has sole control over zone keys and signatures.

The input files are typically in standard DNS master file format. They
are passed through BIND's C<named-compilezone> program to convert them
to canonical form, so they may also be in BIND's "raw" format and may
have F<.jnl> update journals.

If the F<old> file is not specified, C<nsdiff> will use C<dig> to
transfer the zone from its master server as identified in the zone's
SOA MNAME field.

=head1 OPTIONS

=over

=item B<-0>

Allow updates for one domain name to be split across multiple requests.

=item B<-1>

Abort if update does not fit in one request packet.

=item B<-S> B<date>|B<serial>|B<unix>|I<num>

Choose the SOA serial number update mode: the default I<serial> just
increments the serial number; I<date> uses a number of the form
YYYYMMDDnn and allows for up to 100 updates per day; I<unix> uses the
UNIX "seconds since the epoch" value. You can also specify an explicit
serial number value. In all cases, if the current serial number is
larger than the target value it is just incremented. Serial number
wrap-around is not supported.

=item B<-v> [q][r]

Control verbosity.
The B<q> flag causes queries to be printed.
The B<r> flag causes responses to be printed.
To make C<nsdiff> quiet, use B<-v ''>.

=back

The following options are passed to C<dig> to modify its SOA and AXFR
queries:

=over

=item B<-b> I<address>

AXFR query source address

=item B<-k> I<keyfile>

TSIG key file for AXFR query

=item B<-y> [I<hmac>:]I<name>:I<key>

Literal TSIG key for AXFR query

=back

=head1 DIAGNOSTICS

=over

=item C<usage: ...>

=item C<not a domain name: I<zone>>

Errors in the command line.

=item C<could not get SOA record for I<zone>>

Failed to retreive the zone's SOA using C<dig>.

=item C<update does not fit in packet>

The changes for one domain name did not fit in 64 KiB, or the B<-1>
option was specified and all the changes did not fit in 64 KiB.

=item C<missing SOA record>

The output of C<named-compilezone> is incomplete,
usually because the input file is erroneous.

=item C<ignoring dig options when loading old zone from file>

Warning emitted when the command line includes options for C<dig>
as well as an old zone source file.

=item C<loading zone I<zone> from AXFR>

=item C<loading zone I<zone> from file I<file>>

Normal progress messages emitted before C<nsdiff> invokes
C<named-compilezone>, to explain the latter's diagnostics.

=back

=head1 EXAMPLES

It is easiest to deploy DNSSEC if you allow C<named> to manage zone keys
and signatures automatically, and feed in changes to zones using DNS
update requests. However this is very different from the traditional way
of manually maintaining zones in standard master file format. The
C<nsdiff> program bridges the gap between the two operational styles.

To support this workflow you need BIND-9.7 or newer. You will continue
maintaining your zone master file C<$sourcefile> as before, but it is no
longer the same as the C<$workingfile> used by C<named>. After you make
a change, instead of using C<rndc reload $zone>, run C<nsdiff $zone
$sourcefile | nsupdate -l>.

Configure your zone as follows, to support DNSSEC and local dynamic updates:

  zone $zone {
    type master;
    file "$workingfile";
    auto-dnssec maintain;
    update-policy local;
  };

To create DNSSEC keys for your zone, change to named's working directory
and run these commands:

  dnssec-keygen -f KSK $zone
  dnssec-keygen $zone

=head1 CAVEATS

Note that C<nsdiff> does not maintain the transactional semantics of
native DNS update requests when the diff is large - it applies the
changes in multiple update requests. To minimise the problems this may
cause, C<nsdiff> ensures each domain name's changes are all in the same
update request. There is still a small risk of clients not seeing a
change applied atomically when that matters (e.g. altering an MX and
creating the new target in the same transaction). You may be able to
avoid this if you have good control over what gets changed when.

Also, if something else is making changes to the zone at the time you run
C<nsdiff> and C<nsupdate>, then some or all of the changes may be rejected
by the server. This may happen even in simple setups if C<named> happens
to be re-signing the zone at the time you make an update. The SOA serial
number prerequisite checks are supposed to catch this problem, but they
are a bit weak when the changes are split into multiple transactions. But
if this happens it should be safe to just re-run C<nsdiff | nsupdate>
(though the warning about delicate dependencies applies even more
strongly).

The current operational trade-offs in C<nsdiff> are not necessarily the
right ones. Feedback is welcome.

=head1 AUTHOR

=over

=item Written by Tony Finch <fanf2@cam.ac.uk> <dot@dotat.at>

=item at the University of Cambridge Computing Service.

=item You may do anything with this. It has no warranty.

=item L<http://creativecommons.org/publicdomain/zero/1.0/>

=back

=head1 ACKNOWLEDGMENTS

Thanks to Terry Burton at the University of Leicester and Piete Brooks
at the University of Cambridge Computer Laboratory for providing
useful feedback.

=head1 SEE ALSO

dig(1), nsupdate(1), named(8), named-compilezone(8)

=cut
