lib/DNStools/UpdateSerial.pm
changeset 128 ce219be2c383
parent 127 dcb0e36376ab
child 129 642a27894c86
equal deleted inserted replaced
127:dcb0e36376ab 128:ce219be2c383
       
     1 #    Copyright (C) 2011 Matthias Förste
       
     2 #    Copyright (C) 2010, 2011 Heiko Schlittermann
       
     3 #    Copyright (C) 2010 Andre Süß
       
     4 #
       
     5 #    This program is free software: you can redistribute it and/or modify
       
     6 #    it under the terms of the GNU General Public License as published by
       
     7 #    the Free Software Foundation, either version 3 of the License, or
       
     8 #    (at your option) any later version.
       
     9 #
       
    10 #    This program is distributed in the hope that it will be useful,
       
    11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
       
    12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
       
    13 #    GNU General Public License for more details.
       
    14 #
       
    15 #    You should have received a copy of the GNU General Public License
       
    16 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
       
    17 #
       
    18 #    Matthias Förste <foerste@schlittermann.de>
       
    19 
       
    20 =encoding utf8
       
    21 =cut
       
    22 
       
    23 package DNStools::UpdateSerial;
       
    24 
       
    25 use v5.10;
       
    26 use strict;
       
    27 use warnings;
       
    28 
       
    29 use File::Basename;
       
    30 use File::Temp;
       
    31 use IO::File;
       
    32 use POSIX qw(strftime);
       
    33 use if $ENV{DEBUG} => "Smart::Comments";
       
    34 
       
    35 
       
    36 BEGIN {
       
    37 
       
    38     our ($VERSION, @ISA, @EXPORT);
       
    39     use Exporter;
       
    40 
       
    41     # set the version for version checking
       
    42     $VERSION     = 1.00;
       
    43     # if using RCS/CVS, this may be preferred
       
    44     $VERSION = sprintf "%d.%03d", q$Revision: 1.1 $ =~ /(\d+)/g;
       
    45 
       
    46     @ISA         = qw(Exporter);
       
    47     @EXPORT      = qw(&uniq &zones &changed_zones &update_index
       
    48     &signature_expired &need_rollover &done_rollover &begin_rollover
       
    49     &end_rollover &unlink_unused_keys &include_keys &sign &update_serial
       
    50     &mk_zone_conf &file_entry &server_reload &dnssec_enabled %config);
       
    51 }
       
    52 
       
    53 our %config;
       
    54 
       
    55 sub uniq(@);
       
    56 sub zones(@);
       
    57 sub changed_zones();
       
    58 sub update_index($);
       
    59 sub signature_expired($);
       
    60 sub need_rollover();
       
    61 sub done_rollover();
       
    62 sub begin_rollover(@);
       
    63 sub end_rollover(@);
       
    64 sub unlink_unused_keys($);
       
    65 sub include_keys($);
       
    66 sub sign($);
       
    67 sub update_serial($);
       
    68 
       
    69 sub mk_zone_conf($$);
       
    70 sub file_entry;
       
    71 sub server_reload;
       
    72 
       
    73 sub dnssec_enabled($$);
       
    74 
       
    75 sub uniq(@) {
       
    76 
       
    77     # remove duplicate entries
       
    78     my %all;
       
    79     @all{@_} = ();
       
    80     keys %all;
       
    81 }
       
    82 
       
    83 sub zones(@) {
       
    84 
       
    85     # check whether the zones in argv are managed zones and
       
    86     # insert them into the list new_serial
       
    87 
       
    88     my @r;
       
    89 
       
    90     foreach (@_) {
       
    91         chomp(my $zone = `idn --quiet "$_"`);
       
    92         die "$zone is not managed\n"
       
    93           if not -e "$config{master_dir}/$zone/$zone";
       
    94         push @r, $zone;
       
    95     }
       
    96 
       
    97     return @r;
       
    98 }
       
    99 
       
   100 sub changed_zones() {
       
   101 
       
   102     # find candidates in our master dir
       
   103     my @r;
       
   104 
       
   105     while (glob "$config{master_dir}/*") {
       
   106         my $zone = basename($_);
       
   107 
       
   108         if (not -e "$_/.stamp") {
       
   109             say " * $zone: no .stamp file found";    # NOCH IN NEW_SERIAL PUSHEN
       
   110             push @r, $zone;
       
   111             next;
       
   112         }
       
   113 
       
   114         my $stamp_mtime = (stat _)[9];
       
   115         my $zone_file_mtime  = (stat "$_/$zone")[9] or die "Can't stat '$_/$zone': $!";
       
   116         # TODO: do this here?
       
   117         my $kc_file_mtime = 0;
       
   118         $kc_file_mtime = (stat "$_/.keycounter")[9] or die "Can't stat '$_/.keycounter': $!" if -f "$_/.keycounter";
       
   119 
       
   120         next unless $stamp_mtime < $zone_file_mtime or $stamp_mtime < $kc_file_mtime;
       
   121 
       
   122         push @r, $zone;
       
   123         say " * $zone: zone file modified";
       
   124     }
       
   125     return @r;
       
   126 }
       
   127 
       
   128 sub signature_expired($) {
       
   129     my $sign_alert_time = shift;  # the time between the end and the new signing
       
   130                                   # (see external configuration)
       
   131     my @r;
       
   132 
       
   133 # erzeugt $time (die zeit ab der neu signiert werden soll)
       
   134 # ... warum eigentlich nur bis zu den Stunden und nicht auch Minuten und Sekunden?
       
   135     my $time = strftime("%Y%m%d%H" => localtime time + 3600 * $sign_alert_time);
       
   136 
       
   137     ## vergleicht fuer alle zonen im ordner $config{master_dir} mit einer
       
   138     ## <zone>.signed-datei den zeitpunkt in $time mit dem ablaufdatum der
       
   139     ## signatur, welcher aus der datei <zone>.signed ausgelesen wird.
       
   140   ZONE: while (my $dir = glob "$config{master_dir}/*") {
       
   141         my $zone = basename $dir;
       
   142 
       
   143         next if not -e "$dir/$zone.signed";
       
   144 
       
   145         open(my $fh, "$dir/$zone.signed")
       
   146           or die "Can't open $dir/$zone.signed: $!\n";
       
   147         push @r, $zone
       
   148           if /RRSIG\s+SOA[\d ]+(\d{10})\d{4}\s+\(/ ~~ [<$fh>]
       
   149               and $1 < $time;
       
   150     }
       
   151 
       
   152     return @r;
       
   153 }
       
   154 
       
   155 sub sign($) {
       
   156 
       
   157     my $zone = shift;
       
   158     my $dir  = "$config{master_dir}/$zone";
       
   159 
       
   160     my $pid = fork // die "Can't fork: $!";
       
   161 
       
   162     if ($pid == 0) {
       
   163         chdir $dir or die "Can't chdir to $dir: $!\n";
       
   164         exec "dnssec-signzone" => $zone;
       
   165         die "Can't exec: $!\n";
       
   166     }
       
   167 
       
   168     wait == $pid or die "Child is lost: $!";
       
   169     die "Can't sign zone!" if $?;
       
   170 
       
   171     say " * $zone neu signiert";
       
   172 
       
   173     open(my $fh, "+>>$dir/.keycounter")
       
   174       or die "Can't open $dir/.keycounter for update: $!\n";
       
   175     seek($fh, 0, 0);
       
   176     my $kc = <$fh>;
       
   177     truncate($fh, 0);
       
   178     say $fh ++$kc;
       
   179 }
       
   180 
       
   181 sub update_serial($) {
       
   182 
       
   183     my $zone = shift;
       
   184 #    say "XXX: $zone: updating serial number";
       
   185 
       
   186     my $file = "$config{master_dir}/$zone/$zone";
       
   187     my $in   = IO::File->new($file) or die "Can't open $file: $!\n";
       
   188     my $out  = File::Temp->new(DIR => dirname $file)
       
   189       or die "Can't open tmpfile: $!\n";
       
   190     my $_ = join "" => <$in>;
       
   191 
       
   192     my $serial;
       
   193     s/^(\s+)(\d{10})(?=\s*;\s*serial)/$1 . ($serial = new_serial($2))/emi
       
   194       or die "Serial number not found for replacement!";
       
   195 
       
   196     print $out $_;
       
   197 
       
   198     close($in);
       
   199     close($out);
       
   200 
       
   201     rename($out->filename => $file)
       
   202       or die "Can't rename tmp to $file: $!\n";
       
   203 
       
   204     my $perms = (stat $file)[2] & 07777 | 040
       
   205         or die "Can't stat '$file': $!";
       
   206     chmod $perms, $file
       
   207         or die "Can't 'chmod $perms, $file': $!";
       
   208 
       
   209     $serial =~ s/\s*//g;
       
   210     say " * $zone: serial incremented to $serial";
       
   211 
       
   212     open(my $stamp, ">", dirname($file) . "/.stamp");
       
   213 
       
   214     say " * $zone: stamp aktualisiert";
       
   215 #    say " XXX $zone: stamp '$s' aktualisiert";
       
   216 }
       
   217 
       
   218 sub new_serial($) {
       
   219 
       
   220     my ($date, $cnt) = $_[0] =~ /(\d{8})(\d\d)/;
       
   221 
       
   222     state $now = strftime("%4Y%02m%02d", localtime);
       
   223 
       
   224     return $date eq $now
       
   225       ? sprintf "%s%02d", $date, $cnt + 1
       
   226       : "${now}00";
       
   227 
       
   228 }
       
   229 
       
   230 sub mk_zone_conf($$) {
       
   231 
       
   232     # erzeugt eine named.conf-datei aus den entsprechenden vorlagen.
       
   233     my ($bind_dir, $conf_dir) = @_;
       
   234 
       
   235     open(TO, ">$bind_dir/named.conf.zones")
       
   236       or die "$bind_dir/named.conf.zones: $!\n";
       
   237     while (<$conf_dir/*>) {
       
   238         next if /(\.bak|~)$/;
       
   239         open(FROM, "$_") or die "$_: $! \n";
       
   240         print TO <FROM>;
       
   241         close(FROM);
       
   242     }
       
   243     close(TO);
       
   244     print "** zonekonfiguration erzeugt\n";
       
   245 }
       
   246 
       
   247 sub update_index($) {
       
   248 
       
   249     my $indexzone = shift;
       
   250 
       
   251     my $izf = "$config{master_dir}/$indexzone/$indexzone";
       
   252     my @iz;
       
   253 
       
   254     {
       
   255         open(my $fh, "$izf")
       
   256           or die "$izf: $!\n";
       
   257         chomp(@iz = grep !/ZONE::/ => <$fh>);
       
   258     }
       
   259 
       
   260     for my $dir (glob "$config{master_dir}/*") {
       
   261         my $zone = basename($dir);
       
   262         my $info = -e ("$dir/.keycounter") ? "sec-on" : "sec-off";
       
   263         push @iz, join "::", "\t\tIN TXT\t\t\"ZONE", $zone, $info . '"';
       
   264     }
       
   265 
       
   266     {
       
   267         my $fh = File::Temp->new(DIR => "$config{master_dir}/$indexzone")
       
   268           or die "Can't create tmpdir: $!\n";
       
   269         print $fh join "\n" => @iz, "";
       
   270         rename($fh->filename => "$izf")
       
   271           or die "Can't rename ", $fh->filename, " to $izf: $!\n";
       
   272         $fh->unlink_on_destroy(0);
       
   273     }
       
   274 
       
   275     my $perms = (stat _)[2] & 07777 | 040
       
   276         or die "Can't stat '$izf': $!";
       
   277     chmod $perms, $izf
       
   278         or die "Can't 'chmod $perms, $izf': $!";
       
   279 
       
   280     say "** index-zone aktualisiert";
       
   281     return $indexzone;
       
   282 }
       
   283 
       
   284 sub file_entry {
       
   285 
       
   286     # prueft jede domain, die ein verzeichnis in $config{master_dir} hat, ob sie
       
   287     # dnssec nutzt.
       
   288     # passt die eintraege in $config_file falls noetig an.
       
   289     my $cd = $config{zone_conf_dir};
       
   290     my $md = $config{master_dir};
       
   291 
       
   292     while (glob "$md/*") {
       
   293         m#($md/)(.*)#;
       
   294         my $z  = $2;
       
   295         my $cf = "$cd/$z";
       
   296         my $de = dnssec_enabled $z, "$md/$config{indexzone}/$config{indexzone}";
       
   297         my $suf = $de ? '.signed' : '';
       
   298         # TODO: assuming that paths in $md and in zone config snippets match somehow
       
   299         my $zr = qr{\Q$z/$z$suf\E$};
       
   300         my $zf = "$md/$z/$z$suf";
       
   301 
       
   302         my ($files, $changed) = (0, 0);
       
   303         my $czf;
       
   304         open C, "+<$cf" or die "Cant't open '$cf': $!";
       
   305         my @lines = <C>; # TODO: deal with race condition?
       
   306         my @oldlines;
       
   307         my ($mode, $uid, $gid, $atime, $mtime) = (stat C)[2, 4, 5, 8, 9] or die "Can't stat: $!";
       
   308         $mode &= 07777;
       
   309         for (@lines) {
       
   310             next unless /^\s*file\s+"([^"]*)"\s*;\s*$/;
       
   311             $czf = $1;
       
   312             $files++;
       
   313             unless ($czf =~ /$zr/) {
       
   314                 $changed++;
       
   315                 @oldlines or @oldlines = @lines;
       
   316                 $_ = qq(\tfile "$zf";\n);
       
   317             }
       
   318         }
       
   319 
       
   320         die "Multiple file statements found in '$cf' (maybe inside multiline comments)" if $files > 1;
       
   321         next unless $changed;
       
   322 
       
   323         # file statement in config snippet doesnt match, so we make a backup first and write a new config
       
   324         my $cb = "$cf.bak";
       
   325         open B, ">$cb" or die "Can't open '$cb': $!";
       
   326         print B @oldlines;
       
   327         close B;
       
   328         chown $uid, $gid, $cb or die "Can't 'chown $uid, $gid, $cb': $!";
       
   329         chmod $mode, $cb or die "Can't 'chmod $mode, $cb': $!";
       
   330         utime $atime, $mtime, $cb or die "Can't 'utime $atime, $mtime, $cb': $!";
       
   331 
       
   332         seek C, 0, 0 or die "Can't seek C, 0, 0: $!";
       
   333         # write back @lines we modified earlier
       
   334         print C @lines;
       
   335         close C;
       
   336 
       
   337         print " * zonekonfiguration aktualisiert ($czf ==> $zf)\n";
       
   338 
       
   339     }
       
   340 
       
   341 }
       
   342 
       
   343 sub server_reload {
       
   344     if (`rndc reload`) { print "** reload dns-server \n" }
       
   345 }
       
   346 
       
   347 sub need_rollover() {
       
   348 
       
   349     # gibt alle zonen mit abgelaufenen keycounter
       
   350     my @r;
       
   351 
       
   352     while (my $kc = glob "$config{master_dir}/*/.keycounter") {
       
   353         my $zone = basename dirname $kc;
       
   354         my $key;
       
   355 
       
   356         {
       
   357             open(my $fh, $kc) or die "$kc: $!\n";
       
   358             chomp($key = <$fh>);
       
   359         }
       
   360 
       
   361         push @r, $zone if $config{key_counter_end} <= $key;
       
   362     }
       
   363 
       
   364     return @r;
       
   365 }
       
   366 
       
   367 sub done_rollover() {
       
   368 
       
   369     # funktion ueberprueft ob ein keyrollover fertig ist
       
   370     # die bedingung dafuer ist das:
       
   371     # - eine datei .index.zsk vorhanden ist
       
   372     # - die datei .index.zsk älter ist, als die rollover-Zeit
       
   373     # - die datei .index.zsk ueber mehr als eine zeile gross ist
       
   374     #   (also mehr als einen Schlüssel enthält)
       
   375     my @r;
       
   376     my $now = time;
       
   377 
       
   378     while (my $dir = glob "$config{master_dir}/*") {
       
   379         my $zone = basename $dir;
       
   380 
       
   381         my @index = ();
       
   382         my $index_wc;
       
   383 
       
   384         # prueft nach der ".index.zsk"-datei und erstellt den zeitpunkt
       
   385         # an dem das key-rollover endet.
       
   386         # rollover is done when mtime of the .index.zsk + abl_zeit is
       
   387         # in the past
       
   388         next if not -e "$dir/.index.zsk";
       
   389         next if (stat _)[9] + 3600 * $config{abl_zeit} >= $now;
       
   390 
       
   391         # prueft die anzahl der schluessel in der .index.zsk
       
   392         open(my $fh, "$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
       
   393         (<$fh>);
       
   394         push @r, $zone if $. > 1;
       
   395     }
       
   396 
       
   397     return @r;
       
   398 }
       
   399 
       
   400 sub begin_rollover(@) {
       
   401     my @zones = @_;
       
   402     my @r;
       
   403 
       
   404     # anfang des key-rollovers
       
   405 
       
   406     foreach my $zone (@zones) {
       
   407 
       
   408         # erzeugt zsks
       
   409         my $dir = "$config{master_dir}/$zone";
       
   410         my ($keyname, @keys);
       
   411 
       
   412         # create a new key
       
   413         {    # need to change the direcoty, thus some more effort
       
   414                 # alternativly: $keyname = `cd $dir && dnssec-keygen ...`;
       
   415                 # would do, but is more fragile on shell meta characters
       
   416 
       
   417             open(my $keygen, "-|") or do {
       
   418                 chdir $dir or die "Can't chdir to $dir: $!\n";
       
   419                 exec "dnssec-keygen",
       
   420                   -a => "RSASHA1",
       
   421                   -b => 512,
       
   422                   -n => "ZONE",
       
   423                   $zone;
       
   424                 die "Can't exec: $!";
       
   425             };
       
   426             chomp($keyname = <$keygen>);
       
   427             close($keygen) or die "dnssec-keygen failed: $@";
       
   428         }
       
   429 
       
   430         open(my $fh, "+>>$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
       
   431         seek($fh, 0, 0);
       
   432         chomp(@keys = <$fh>);
       
   433 
       
   434         ### @keys
       
   435 
       
   436         push @keys, $keyname;
       
   437         shift @keys if @keys > 2;
       
   438 
       
   439         truncate($fh, 0) or die "truncate";
       
   440         print $fh join "\n" => @keys;
       
   441 
       
   442         print " * $zone: neuer ZSK $keyname erstellt\n";
       
   443 
       
   444         open($fh, ">$dir/.keycounter") or die "$dir/.keycounter: $!\n";
       
   445         say $fh 0;
       
   446         close($fh);
       
   447 
       
   448         unlink_unused_keys($zone);
       
   449         include_keys($zone);
       
   450         push @r, $zone;
       
   451     }
       
   452 
       
   453     return @r;
       
   454 }
       
   455 
       
   456 sub include_keys($) {
       
   457 
       
   458     # die funktion fugt alle schluessel in eine zonedatei
       
   459     my $zone = shift;
       
   460     my $dir  = "$config{master_dir}/$zone";
       
   461 
       
   462     my $in = IO::File->new("$dir/$zone") or die "Can't open $dir/$zone: $!\n";
       
   463     my $out = File::Temp->new(DIR => $dir) or die "Can't open tmpfile: $!\n";
       
   464 
       
   465     print $out grep { !/\$include\s+.*key/i } <$in>;
       
   466     print $out map  { "\$INCLUDE @{[basename $_]}\n" } glob "$dir/K*key";
       
   467 
       
   468     close $in;
       
   469     close $out;
       
   470     rename($out->filename => "$dir/$zone")
       
   471       or die "Can't rename tmp to $dir/$zone: $!\n";
       
   472 
       
   473 }
       
   474 
       
   475 sub unlink_unused_keys($) {
       
   476 
       
   477     # die funktion loescht alle schluessel die nicht in der index.zsk
       
   478     # der uebergebenen zone stehen
       
   479     my $zone = shift;
       
   480 
       
   481     my @keys;
       
   482     my $dir = "$config{master_dir}/$zone";
       
   483 
       
   484     {
       
   485 
       
   486         # collect the keys and cut everything except the key id
       
   487         # we cut the basenames (w/o the .private|.key suffix)
       
   488         open(my $zsk, "<$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
       
   489         open(my $ksk, "<$dir/.index.ksk") or die "$dir/.index.ksk: $!\n";
       
   490         chomp(@keys = (<$zsk>, <$ksk>));
       
   491     }
       
   492 
       
   493     # prueft alle schluesseldateien (ksk, zsk) ob sie in der jeweiligen
       
   494     # indexdatei beschrieben sind. wenn nicht werden sie geloescht.
       
   495     for my $file (glob "$dir/K*.key $dir/K*.private") {
       
   496         unlink $file unless basename($file, ".key", ".private") ~~ @keys;
       
   497     }
       
   498 }
       
   499 
       
   500 sub end_rollover(@) {
       
   501 
       
   502     my @zones = @_;
       
   503     my @r;
       
   504 
       
   505     foreach my $zone (@zones) {
       
   506 
       
   507         my $dir = "$config{master_dir}/$zone";
       
   508 
       
   509         open(my $fh, "+>>$dir/.index.zsk")
       
   510           or die "Can't open $dir/.index.zsk: $!\n";
       
   511         seek($fh, 0, 0);
       
   512         chomp(my @keys = <$fh>);
       
   513 
       
   514         if (@keys > 1) {
       
   515             truncate($fh, 0);
       
   516             say $fh $keys[-1];
       
   517         }
       
   518         close($fh);
       
   519 
       
   520         unlink_unused_keys($zone);
       
   521         include_keys($zone);
       
   522         push @r => $zone;
       
   523     }
       
   524 
       
   525     return @r;
       
   526 }
       
   527 
       
   528 # dnssec_enabled($zone, $path_to_indexzone_file)
       
   529 # return true if the index zone indicates that dnssec is enabled for a zone
       
   530 sub dnssec_enabled($$) {
       
   531 
       
   532     my ($z, $if) = @_;
       
   533     my $re = qr/^[^;]*IN\s+TXT\s+"ZONE::\Q$z\E::sec-(on|off)"/;
       
   534     my $r;
       
   535 
       
   536     open I, "<$if" or die "Can't open index zone file '<$if': $!";
       
   537     while (<I>) {
       
   538 #        say "XXX: match: $_" if /$re/;
       
   539         $r = $1 eq 'on' and last if /$re/;
       
   540     }
       
   541     close I;
       
   542 
       
   543     return $r;
       
   544 
       
   545 }
       
   546 
       
   547 1;
       
   548 
       
   549 # vim:sts=4 sw=4 aw ai sm: