#!/usr/bin/perl
# SPDX-License-Identifier: 0BSD OR MIT-0

use warnings;
use strict;

use File::Temp qw(tempfile);
use Getopt::Std;
use POSIX;

sub wail { warn "nsvi: @_\n"; }
sub fail { die "nsvi: @_\n"; }
sub fale { die "nsvi: @_: $!\n"; }

sub version {
    while (<DATA>) {
	print if m{^=head1 VERSION} ... m{^=head1 }
	  and not m{^=head1 };
    }
    exit;
}

sub usage {
    print STDERR <<EOF;
usage: nsvi [options] <zone>
  Transfer a zone from its server, edit it, then
  upload the edits using `nsdiff` and `nsupdate`.
nsvi options:
  -h                  display full documentation
  -V                  display version information
  -n                  interactive confirmation
  -v                  turn on verbose output
  -01cCdD             nsdiff options
  -S num|mode         SOA serial number or mode
  -s server[#port]    where to AXFR and UPDATE the zone
  -g                  use GSS-TSIG for UPDATE
  -k keyfile          AXFR and UPDATE TSIG key
  -y [hmac:]name:key  AXFR and UPDATE TSIG key
EOF
    exit 1;
}
my %opt;
usage unless getopts '-hV01cCdDgk:ns:S:vy:', \%opt;
version if $opt{V};
exec "perldoc -oterm -F $0" if $opt{h};
usage if @ARGV != 1;
my $zone = shift;

$opt{v} = 'qr' if $opt{v};

my @dig = qw{dig +multiline +onesoa +nocmd +nostats +noadditional};
push @dig, map "-$_$opt{$_}",
    grep $opt{$_}, qw{k y};
if ($opt{s} and $opt{s} =~ m{^(.*)#(\d+)$}) {
    push @dig, "-p$2", "\@$1";
} elsif ($opt{s}) {
    push @dig, "\@$opt{s}";
} else {
    push @dig, "\@localhost";
}

my @nsdiff = qw{nsdiff};
push @nsdiff, map "-$_",
    grep $opt{$_}, qw{0 1 c d D};
push @nsdiff, map "-$_$opt{$_}",
    grep $opt{$_}, qw{k s S v y};
push @nsdiff, "-slocalhost" unless $opt{s};
push @nsdiff, "-u" if $opt{s};

my @nsupdate = qw{nsupdate};
push @nsupdate, map "-$_",
    grep $opt{$_}, qw{g};
push @nsupdate, map "-$_$opt{$_}",
    grep $opt{$_}, qw{k y};
push @nsupdate, "-l" unless $opt{s};

my $secRRtypes = qr{NSEC|NSEC3|NSEC3PARAM|RRSIG};
$secRRtypes = qr{$secRRtypes|CDS|CDNSKEY} unless $opt{C};
$secRRtypes = qr{$secRRtypes|DNSKEY} unless $opt{D};
$secRRtypes = qr{$secRRtypes|DS} if $opt{d};

my $nl = qr{(?:;[^\n]*)?\n};
my $rdata = qr{(?:[^()\n]+
                 |(?:[(]
                      (?:[^()\n]+|$nl)+
                     [)])+
                 )+$nl}x;
my $dnssec = qr{(?m)^\S+\s+\d+\s+IN\s+($secRRtypes)\s+$rdata};

print "@dig axfr $zone" if $opt{v};
my $axfr = qx{@dig axfr $zone};
fail "failed to @dig axfr $zone" unless $axfr and $? == 0;

$axfr =~ s{$dnssec}{}g;

my ($fh,$fn) = tempfile("$zone.XXXXXXXXXX",
			TMPDIR => 1, UNLINK => 1);
print $fh $axfr;
close $fh;

my $vi = $ENV{VISUAL} || $ENV{EDITOR} || "vi";

sub prompt {
    print shift;
    system "stty -icanon";
    sysread STDIN, my $key, 1;
    system "stty icanon";
    print "\n";
    return $key;
}

sub retry {
    wail shift;
    my $key = prompt "re-edit and try again? (y/N) ";
    next RETRY if $key =~ m{[Yy]};
    exit 1;
}

RETRY: for (;;) {
    system "$vi $fn";
    fail "failed to $vi $fn" unless $? == 0;

    print "@nsdiff $zone $fn" if $opt{v};
    my $diff = qx{@nsdiff $zone $fn};
    if ($? == 0) {
	wail "no change";
	exit 0;
    }
    retry "failed to @nsdiff $zone $fn"
	unless $diff and $? == 256;
    if ($opt{n}) {
	print "$diff\n";
	my $key = prompt "make update, edit again, or quit? (u/e/Q) ";
	next RETRY if $key =~ m{[EeRr]};
	exit 1 unless $key =~ m{[UuYy]};
    }
    open my $ph, '|-', @nsupdate
	or retry "pipe to @nsupdate: $!";
    print $ph $diff;
    last if close $ph;
    retry "pipe to @nsupdate: $!" if $!;
    retry "failed to @nsupdate";
}

print "done\n" if $opt{v};
exit 0;

__END__

=encoding utf8

=head1 NAME

nsvi - transfer a zone, edit it, then upload the edits

=head1 SYNOPSIS

nsvi [B<-01cCdDghvV>] [B<-k> I<keyfile>] [B<-y> [I<hmac>:]I<name>:I<key>]
     [B<-S> I<mode>|I<num>] [B<-s> I<server>] <I<zone>>

=head1 DESCRIPTION

The B<nsvi> program makes an AXFR request for the zone, runs your
editor so you can make whatever changes you require, then it runs
B<nsdiff> | B<nsupdate> to push those changes to the server.

Automatically-maintained DNSSEC records are stripped from the zone
before it is passed to your editor, and you do not need to manually
adjust the SOA serial number.

=head1 OPTIONS

Most B<nsvi> options are passed to B<nsdiff> and some to B<nsupdate>.

=over

=item B<-h>

Display this documentation.

=item B<-V>

Display version information.

=item B<-v>

Verbose mode.

=item B<-n>

Interactive confirmation.

When you quit the editor, you will be shown the changes, then asked
whether to make the update (press B<U> or B<Y>), edit again (press
B<E> or B<R>), or quit (press another key).

=item B<-01cCdD>

=item B<-S> B<mode>|I<num>

These options are passed to B<nsdiff>.
For details see the nsdiff manual.

=item B<-s> I<server>[#I<port>]

Transfer the zone from the server given in this option, and send the
update request to the same place. You can specify the server host name
or IP address, optionally followed by a "#" and the port number.

If you do not use the B<-s> option, the zone will be transferred
from I<localhost>, and B<nsvi> will use B<nsupdate> B<-l> to update
the zone.

=item B<-g>

Passed to B<nsupdate> to use GSS-TSIG for UPDATE.

=item B<-k> I<keyfile>

TSIG key file, passed to B<dig>, B<nsdiff>, and B<nsupdate>.

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

Literal TSIG key, passed to B<dig>, B<nsdiff>, and B<nsupdate>.

=back

=head1 ENVIRONMENT

=over

=item B<TMPDIR>

Location for temporary files.

=item B<VISUAL>

=item B<EDITOR>

Which editor to use. C<$VISUAL> is used if it is set,
otherwise C<$EDITOR>, otherwise B<vi>.

=back

=head1 VERSION

  This is nsvi-1.85 <https://dotat.at/prog/nsdiff/>

  Written by Tony Finch <fanf2@cam.ac.uk> <dot@dotat.at>
  at Cambridge University Information Services.
  You may do anything with this. It has no warranty.

=head1 ACKNOWLEDGMENTS

Thanks to Tristan Le Guern for the B<-n> option and Mantas MikulÄ—nas
for the B<-g> option. Thanks to David McBride and Petr Menšík for
providing useful feedback.

=head1 SEE ALSO

nsdiff(1), nsupdate(1), dig(1).

=cut