lib/DNS/Vi.pm
changeset 106 140d7537105e
parent 105 9069ce49fd83
equal deleted inserted replaced
105:9069ce49fd83 106:140d7537105e
     1 package DNS::Vi;
       
     2 use 5.0101;
       
     3 use strict;
       
     4 use warnings;
       
     5 use if $ENV{DEBUG} // '' eq 'dnsvi' => 'Smart::Comments';
       
     6 use Digest::SHA qw(sha512_hex);
       
     7 use File::Temp;
       
     8 use Net::DNS;
       
     9 use Term::ReadKey;
       
    10 use base 'Exporter';
       
    11 use if $] >= 5.020, experimental => 'smartmatch';
       
    12 
       
    13 our @EXPORT = qw(ttl2h h2ttl parse delta nice edit update show get_key
       
    14   get_auth_info);
       
    15 our @EXPORT_OK = ();
       
    16 
       
    17 # the sort order for the records of the same label
       
    18 my %ORDER = map { state $n = 0; $_ => ++$n } qw(SOA NS TXT MX A AAAA SSHFP);
       
    19 
       
    20 sub h2ttl(_);
       
    21 
       
    22 # input $arg - hash with options
       
    23 #       $data - a long string with the zone data
       
    24 sub parse {
       
    25     my %arg   = %{ pop @_ } if ref $_[-1] eq ref {};
       
    26     my $data  = shift;
       
    27     my @lines = split /\n/, $data;
       
    28 
       
    29     my @zone;
       
    30     my ($origin, $ttl, $last_label, $soa_seen);
       
    31 
       
    32     $_ = '';
       
    33     foreach (@lines) {
       
    34 	# simplificated comment remover
       
    35 	# after the comment character no '"' is allowed!
       
    36 #        s{^\s*;.*$}{};		  # strip comment lines
       
    37 #	s{\s*;[^"]*$}{};	  # strip trailing comments
       
    38 
       
    39 	# see https://regex101.com/r/cG6fK3/2
       
    40  	s{\s*(?:;)(?:(?:[^"]|"[^"]*")*$)}{};
       
    41         state $line;
       
    42         if (my $range = /(.*)\(\s*$/ .. /(.*)\)\s*/) {
       
    43             $line .= defined $1 ? $1 : $_;
       
    44             next unless $range =~ /E0$/;
       
    45         }
       
    46         if (defined $line) {
       
    47             $_    = $line;    # accumulated continuation line
       
    48             $line = undef;
       
    49         }
       
    50         s{\s*$}{};            # strip off trailing spaces
       
    51         given ($_) {
       
    52             when (m{^\s*$})                { next }
       
    53             when (m{^\s*\$ORIGIN\s+(\S+)}) { $origin = $1 }
       
    54             when (m{^\s*\$TTL\s+(\S+)})    { $ttl = $1 }
       
    55             when (
       
    56                 m{^(?:(?<label>\S+)(?=\s))?
       
    57 		    (?:\s+(?<ttl>\d[\dwdmhs]*(?=\s+)))?
       
    58 		    (?:\s+(?:(?:IN|ANY)\s+)?(?<rrtype>[a-z]\S*(?=\s+)))
       
    59 		    \s+(?<data>.*)
       
    60 		  }ix
       
    61               )
       
    62             {
       
    63                 my %rrset = (
       
    64                     label => $last_label =
       
    65                       defined $+{label}
       
    66                     ? $+{label} eq '@'
       
    67                           ? $origin
       
    68                           : $+{label}
       
    69                     : $last_label,
       
    70                       ttl => h2ttl($+{ttl} // $ttl),
       
    71                       rrtype => uc $+{rrtype},
       
    72                       data   => $+{data},
       
    73                 );
       
    74                 next if $rrset{rrtype} ~~ $arg{-skip};
       
    75 
       
    76                 if ($rrset{rrtype} eq 'SOA') {
       
    77                     next if $soa_seen++;
       
    78                 }
       
    79 
       
    80                 # label ergänzen, wenn nicht FQDN
       
    81                 $rrset{label} .= ".$origin"
       
    82                   unless substr($rrset{label}, -1) eq '.';
       
    83 
       
    84                 given ($rrset{rrtype}) {
       
    85 
       
    86                     # origin steht im SOA
       
    87                     when ('SOA') {
       
    88                         $origin = $rrset{label};
       
    89                         my ($primary, $hostmaster, $serial, $refresh, $retry,
       
    90                             $expire, $minttl)
       
    91                           = split ' ', $rrset{data};
       
    92 
       
    93                         $_ .= ".$origin" foreach grep !/\.$/ => $primary,
       
    94                           $hostmaster;
       
    95 
       
    96                         $rrset{data} = join ' ',
       
    97                           $primary, $hostmaster, $serial,
       
    98                           map { h2ttl } $refresh, $retry, $expire, $minttl;
       
    99                     }
       
   100 
       
   101                     # bei einigen RRs müssen wir die Daten korrigieren
       
   102                     when ([qw/CNAME MX NS PTR SOA/]) {
       
   103                         $rrset{data} =~ s/\@/$origin/g;
       
   104                         $rrset{data} .= ".$origin"
       
   105                           unless substr($rrset{data}, -1) eq '.';
       
   106                     }
       
   107                 }
       
   108                 my $id = sha512_hex(join "\0",
       
   109                     map { $_ => $rrset{$_} } sort keys %rrset);
       
   110                 push @zone, { id => $id, rrset => \%rrset };
       
   111                 ### x: $zone[-1]
       
   112             }
       
   113         }
       
   114     }
       
   115 
       
   116     return @zone;
       
   117 }
       
   118 
       
   119 sub ttl2h {
       
   120     my $seconds = shift;
       
   121     my @out;
       
   122     my @units = ([w => 604800], [d => 86400], [h => 3600], [m => 60], [s => 1]);
       
   123 
       
   124     foreach (@units) {
       
   125         my $x = int($seconds / $_->[1]);
       
   126         push @out, "$x$_->[0]" if $x;
       
   127         $seconds %= $_->[1] or last;
       
   128     }
       
   129 
       
   130     return join '', @out;
       
   131 }
       
   132 
       
   133 sub h2ttl(_) {
       
   134     my $ttl = shift;
       
   135     my $out;
       
   136     my %factor = (
       
   137         w => 604800,
       
   138         d => 86400,
       
   139         h => 3600,
       
   140         m => 60,
       
   141         s => 1,
       
   142     );
       
   143 
       
   144     while ($ttl =~ m{(\d+)([wdhms])}g) {
       
   145         $out += $1 * $factor{$2};
       
   146     }
       
   147 
       
   148     return $out // $ttl;
       
   149 }
       
   150 
       
   151 sub nice {
       
   152 
       
   153     # get a list of { id => $id, rrset => \%rrset }
       
   154     # we do a schwartz transformation here
       
   155     # [ reverse LABEL, RRSET ]
       
   156     my @zone = map { $_->[1] }
       
   157       sort {
       
   158              $a->[0] cmp $b->[0]
       
   159           or length $a->[1]{label} <=> length $b->[1]{label}
       
   160           or ($ORDER{ $a->[1]{rrtype} } // 99)
       
   161           <=> ($ORDER{ $b->[1]{rrtype} } // 99)
       
   162       }
       
   163       map { [scalar reverse($_->{label}), $_] } map { $_->{rrset} } @_;
       
   164 
       
   165     my @out;
       
   166     my $origin = (grep { $_->{rrtype} eq 'SOA' } @zone)[0]->{label};
       
   167     my $ttl    = (grep { $_->{rrtype} eq 'SOA' } @zone)[0]->{ttl};
       
   168     my $len1 =
       
   169       (sort { $a <=> $b } map { index $_->{label}, '.' } @zone)[-1];
       
   170     my $len2 = (sort { $a <=> $b } map { length $_->{rrtype} } @zone)[-1];
       
   171     push @out, "\$ORIGIN $origin", "\$TTL " . ttl2h($ttl);
       
   172 
       
   173     my $print = sub {
       
   174         my %r = %{ +shift };
       
   175         state $last_label;
       
   176 
       
   177         $r{label} = '@' if $r{label} eq $origin;
       
   178         $r{label} =~ s{\.\Q$origin\E$}{};
       
   179         $r{data} =~ s{\.\Q$origin\E$}{}
       
   180           if $r{rrtype} ~~ [qw(CNAME MX SOA PTR)];
       
   181         $r{data} =~ s{\Q$origin\E$}{\@} if $r{rrtype} ~~ [qw(CNAME MX)];
       
   182         $r{ttl} = $r{ttl} == $ttl ? '' : ttl2h($r{ttl});
       
   183         $r{label} = do {
       
   184             if (defined $last_label and $r{label} eq $last_label) { '' }
       
   185             else { $last_label = $r{label} }
       
   186         };
       
   187 
       
   188         return sprintf '%-*s %6s %-*s    %s',
       
   189           $len1 => $r{label},
       
   190           $r{ttl},
       
   191           $len2 => $r{rrtype},
       
   192           $r{data};
       
   193     };
       
   194     push @out, '; IF YOU EDIT the SOA record, INCREASE the serial number too!';
       
   195     push @out, $print->($_) foreach @zone;
       
   196     return join "\n", @out, '';
       
   197 }
       
   198 
       
   199 sub delta {
       
   200     my ($zone1, $zone2) = @_;
       
   201     my %zone1 = map { $_->{id}, $_->{rrset} } @$zone1;
       
   202     my %zone2 = map { $_->{id}, $_->{rrset} } @$zone2;
       
   203     my @keys1 = keys %zone1;
       
   204     my @keys2 = keys %zone2;
       
   205     delete @zone1{@keys2};
       
   206     delete @zone2{@keys1};
       
   207 
       
   208     my (@add, @del);
       
   209     push @add, "@{$_}{qw/label ttl rrtype data/}" foreach values %zone2;
       
   210     push @del, "@{$_}{qw/label ttl rrtype data/}" foreach values %zone1;
       
   211 
       
   212     return () if (!@add and !@del);
       
   213     return (add => \@add, del => \@del);
       
   214 }
       
   215 
       
   216 sub edit {
       
   217     my %arg = %{ pop @_ } if ref $_[-1] eq ref {};
       
   218     my @zone = @_;
       
   219 
       
   220     # first make a backup copy
       
   221     my $tmp = File::Temp->new();
       
   222     $tmp->print(nice @zone);
       
   223     $tmp->say('; vim:tw=0:');
       
   224     $tmp->flush();
       
   225 
       
   226     system $arg{-editor} => $tmp->filename;
       
   227     $tmp->seek(0, 0);
       
   228     ${ $arg{-backup} } = $tmp if $arg{-backup};
       
   229     return parse(do { local $/ = undef; <$tmp> }, { -skip => $arg{-skip} });
       
   230 }
       
   231 
       
   232 sub show {
       
   233     my ($add, $del) = @_;
       
   234     my @out = ((map { " - $_ " } @$del), (map { " + $_ " } @$add));
       
   235     return @out;
       
   236 }
       
   237 
       
   238 sub update {
       
   239     my %arg = %{ pop @_ } if ref $_[-1] eq ref {};
       
   240     my ($zone1, $add, $del) = @_;
       
   241 
       
   242     my $orig_soa =
       
   243       (grep { $_->{rrtype} eq 'SOA' } map { $_->{rrset} } @$zone1)[0];
       
   244 
       
   245     my @cmds = (
       
   246         $arg{-local} ? () : "server $arg{-server}",
       
   247         "prereq yxrrset @{$orig_soa}{qw{label rrtype data}}",
       
   248         (map { "update delete $_" } @$del),
       
   249         (map { "update add $_" } @$add),
       
   250         'send',
       
   251         'answer',
       
   252     );
       
   253     if ($arg{-dry}) {
       
   254         return say join "\n", '', @cmds, '' if $arg{-dry};
       
   255     }
       
   256     my @nsupdate = (
       
   257         'nsupdate',
       
   258         defined $arg{-debug} ? ('-d') : (),
       
   259         defined $arg{-key} ? (-k => $arg{-key}) : (),
       
   260         defined $arg{-local} ? ('-l') : (),
       
   261     );
       
   262 
       
   263     open(my $nsupdate, '|-') or do {
       
   264         exec @nsupdate;
       
   265         die "Can't exec @nsupdate: $!\n";
       
   266     };
       
   267     say $nsupdate join "\n", @cmds;
       
   268     close($nsupdate);
       
   269     say "nsupdate returned $?";
       
   270     return $? ? undef : 1;
       
   271 }
       
   272 
       
   273 sub save {
       
   274     my ($zone, $file) = @_;
       
   275     open(my $fh, '>', $file) or die "Can't open >$file: $!\n";
       
   276     print $fh nice @$zone;
       
   277     close($fh);
       
   278 
       
   279 }
       
   280 
       
   281 sub get_key {
       
   282     ReadMode 'cbreak';
       
   283     local $/ = \1;
       
   284     my $x = <STDIN>;
       
   285     ReadMode 'restore';
       
   286     print "\n";
       
   287     return $x;
       
   288 }
       
   289 
       
   290 sub get_auth_info {
       
   291     my $name = shift;
       
   292     my $server = shift;
       
   293     my %auth = (zone => undef, master => undef);
       
   294     state $resolver = Net::DNS::Resolver->new(
       
   295         defined $server ? (nameservers => [$server]) : ()
       
   296     );
       
   297     my $response = $resolver->send($name, 'SOA')
       
   298       or die $resolver->errorstring, "\n";
       
   299 
       
   300     #    use Data::Dumper;
       
   301     #    die Dumper $response;
       
   302 
       
   303     if (my @soa = grep { $_->type eq 'SOA' } $response->answer,
       
   304         $response->authority)
       
   305     {
       
   306         die "got multiple soa records\n" if @soa > 1;
       
   307         my $soa = $soa[0];
       
   308         return (
       
   309             name  => $soa->name,
       
   310             mname => $soa->mname,
       
   311         );
       
   312     }
       
   313 
       
   314     return $response->authority;
       
   315 }
       
   316 
       
   317 1;