--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/update-serial Thu Jan 13 00:20:26 2011 +0100
@@ -0,0 +1,548 @@
+#!/usr/bin/perl
+
+use v5.10;
+use strict;
+use warnings;
+
+use FindBin;
+use File::Basename;
+use Pod::Usage;
+use Getopt::Long;
+use File::Temp;
+use IO::File;
+use POSIX qw(strftime);
+use if $ENV{DEBUG} => "Smart::Comments";
+use DNStools::Config qw(get_config);
+
+sub uniq(@);
+sub zones(@);
+sub changed_zones();
+sub update_index($);
+sub signature_expired($);
+sub need_rollover();
+sub done_rollover();
+sub begin_rollover(@);
+sub end_rollover(@);
+sub unlink_unused_keys($);
+sub include_keys($);
+sub sign($);
+sub update_serial($);
+
+sub mk_zone_conf;
+sub file_entry;
+sub server_reload;
+
+my %config;
+my %opt;
+
+MAIN: {
+
+ GetOptions(
+ "sign-alert-time=i" => \$opt{sign_alert_time},
+ "key-counter-end=i" => \$opt{key_counter_end},
+ "h|help" => sub { pod2usage(-exit 0, -verbose => 1) },
+ "m|man" => sub {
+ pod2usage(
+ -exit 0,
+ -verbose => 2,
+ -noperldoc => system("perldoc -v &>/dev/null")
+ );
+ },
+ ) or pod2usage;
+
+ # merge the config and the defined options from commandline
+ %config = get_config("$FindBin::Bin/dnstools.conf", "/etc/dnstools.conf", \%opt);
+
+ our $bind_dir = $config{bind_dir};
+ our $conf_dir = $config{zone_conf_dir};
+
+ my @candidates = @ARGV ? zones(@ARGV) : changed_zones;
+ push @candidates, update_index($config{indexzone});
+ push @candidates, signature_expired($config{sign_alert_time});
+
+ my @need_rollover = need_rollover;
+ my @done_rollover = done_rollover;
+
+ push @candidates, begin_rollover(@need_rollover);
+ push @candidates, end_rollover(@done_rollover);
+
+ foreach my $zone (uniq(@candidates)) {
+ update_serial($zone);
+ sign($zone);
+ }
+ say "Need to ... file_entry, mk_zone_conf, server_reload";
+ exit;
+
+ file_entry; # bearbeitet die file-eintraege der konfigurations-datei
+ mk_zone_conf; # konfiguration zusammenfuegen
+ server_reload; # server neu laden
+
+}
+
+sub uniq(@) {
+
+ # remove duplicate entries
+ my %all;
+ @all{@_} = ();
+ keys %all;
+}
+
+sub zones(@) {
+
+ # check whether the zones in argv are managed zones and
+ # insert them into the list new_serial
+
+ my @r;
+
+ foreach (@_) {
+ chomp(my $zone = `idn --quiet "$_"`);
+ die "$zone is not managed\n"
+ if not -e "$config{master_dir}/$zone/$zone";
+ push @r, $zone;
+ }
+
+ return @r;
+}
+
+sub changed_zones() {
+
+ # find candidates in our master dir
+ my @r;
+
+ while (glob "$config{master_dir}/*") {
+ my $zone = basename($_);
+
+ if (not -e "$_/.stamp") {
+ say " * $zone: no .stamp file found"; # NOCH IN NEW_SERIAL PUSHEN
+ push @r, $zone;
+ next;
+ }
+
+ my $stamp_age = -M _;
+ my $file_age = -M "$_/$zone";
+
+ next if $stamp_age <= $file_age; # should be only <
+
+ push @r, $zone;
+ say " * $zone: zone file modified";
+ }
+ return @r;
+}
+
+sub signature_expired($) {
+ my $sign_alert_time = shift; # the time between the end and the new signing
+ # (see external configuration)
+ my @r;
+
+# erzeugt $time (die zeit ab der neu signiert werden soll)
+# ... warum eigentlich nur bis zu den Stunden und nicht auch Minuten und Sekunden?
+ my $time = strftime("%Y%m%d%H" => localtime time + 3600 * $sign_alert_time);
+
+ ## vergleicht fuer alle zonen im ordner $config{master_dir} mit einer
+ ## <zone>.signed-datei den zeitpunkt in $time mit dem ablaufdatum der
+ ## signatur, welcher aus der datei <zone>.signed ausgelesen wird.
+ ZONE: while (my $dir = glob "$config{master_dir}/*") {
+ my $zone = basename $dir;
+
+ next if not -e "$dir/$zone.signed";
+
+ open(my $fh, "$dir/$zone.signed")
+ or die "Can't open $dir/$zone.signed: $!\n";
+ push @r, $zone
+ if /RRSIG\s+SOA[\d ]+(\d{10})\d{4}\s+\(/ ~~ [<$fh>]
+ and $1 < $time;
+ }
+
+ return @r;
+}
+
+sub sign($) {
+
+ my $zone = shift;
+ my $dir = "$config{master_dir}/$zone";
+
+ my $pid = fork // die "Can't fork: $!";
+
+ if ($pid == 0) {
+ chdir $dir or die "Can't chdir to $dir: $!\n";
+ exec "dnssec-signzone" => $zone;
+ die "Can't exec: $!\n";
+ }
+
+ wait == $pid or die "Child is lost: $!";
+ die "Can't sign zone!" if $?;
+
+ say " * $zone neu signiert";
+
+ open(my $fh, "+>>$dir/.keycounter")
+ or die "Can't open $dir/.keycounter for update: $!\n";
+ seek($fh, 0, 0);
+ my $kc = <$fh>;
+ truncate($fh, 0);
+ say $fh ++$kc;
+}
+
+sub update_serial($) {
+
+ my $zone = shift;
+
+ my $file = "$config{master_dir}/$zone/$zone";
+ my $in = IO::File->new($file) or die "Can't open $file: $!\n";
+ my $out = File::Temp->new(DIR => dirname $file)
+ or die "Can't open tmpfile: $!\n";
+ my $_ = join "" => <$in>;
+
+ my $serial;
+ s/^(\s+)(\d{10})(?=\s*;\s*serial)/$1 . ($serial = new_serial($2))/emi
+ or die "Serial number not found for replacement!";
+
+ print $out $_;
+
+ close($in);
+ close($out);
+
+ rename($out->filename => $file)
+ or die "Can't rename tmp to $file: $!\n";
+
+ $serial =~ s/\s*//g;
+ say " * $zone: serial incremented to $serial";
+
+ open(my $stamp, ">", dirname($file) . "/.stamp");
+ print $stamp time() . " " . localtime() . "\n";
+
+ say " * $zone: stamp aktualisiert";
+}
+
+sub new_serial($) {
+
+ my ($date, $cnt) = $_[0] =~ /(\d{8})(\d\d)/;
+
+ state $now = strftime("%4Y%02m%02d", localtime);
+
+ return $date eq $now
+ ? sprintf "%s%02d", $date, $cnt + 1
+ : "${now}00";
+
+}
+
+sub mk_zone_conf {
+
+ # erzeugt eine named.conf-datei aus den entsprechenden vorlagen.
+ our $bind_dir;
+ our $conf_dir;
+
+ open(TO, ">$bind_dir/named.conf.zones")
+ or die "$bind_dir/named.conf.zones: $!\n";
+ while (<$conf_dir/*>) {
+ open(FROM, "$_") or die "$_: $! \n";
+ print TO <FROM>;
+ close(FROM);
+ }
+ close(TO);
+ print "** zonekonfiguration erzeugt\n";
+}
+
+sub update_index($) {
+ my $indexzone = shift;
+
+ my @iz;
+
+ {
+ open(my $fh, "$config{master_dir}/$indexzone/$indexzone")
+ or die "$config{master_dir}/$indexzone/$indexzone: $!\n";
+ chomp(@iz = grep !/ZONE::/ => <$fh>);
+ }
+
+ for my $dir (glob "$config{master_dir}/*") {
+ my $zone = basename($dir);
+ my $info = -e ("$dir/.keycounter") ? "sec-on" : "sec-off";
+ push @iz, join "::", "\t\tIN TXT\t\t\"ZONE", $zone, $info;
+ }
+
+ {
+ my $fh = File::Temp->new(DIR => "$config{master_dir}/$indexzone")
+ or die "Can't create tmpdir: $!\n";
+ print $fh join "\n" => @iz, "";
+ rename($fh->filename => "$config{master_dir}/$indexzone/$indexzone")
+ or die "Can't rename "
+ . $fh->filename
+ . " to $config{master_dir}/$indexzone/$indexzone: $!\n";
+ }
+
+ say "** index-zone aktualisiert";
+ return $indexzone;
+}
+
+sub file_entry {
+
+ # prueft jede domain, die ein verzeichnis in $config{master_dir} hat, ob sie
+ # dnssec nutzt.
+ # passt die eintraege in $config_file falls noetig an.
+ our $conf_dir;
+
+ while (glob "$config{master_dir}/*") {
+ s#($config{master_dir}/)(.*)#$2#;
+ my $zone = $_;
+ my $zone_file = "$config{master_dir}/$zone/$zone";
+ my $conf_file = "$conf_dir/$zone";
+ my @c_content;
+
+ unless (-f "$conf_file") {
+ die "$conf_file: $! \n";
+ }
+
+ if (-e "$config{master_dir}/$zone/.keycounter") {
+ open(FILE, "<$conf_file") or die "$conf_file: $!\n";
+ @c_content = <FILE>;
+ close(FILE);
+ for (@c_content) {
+ if (m{(.*)($zone_file)(";)}) {
+ print
+ " * zonekonfiguration aktualisiert ($2 ==> $2.signed)\n";
+ $_ = "$1$2.signed$3\n";
+ }
+ }
+ open(FILE, ">$conf_file") or die "$conf_file: $!\n";
+ print FILE @c_content;
+ close(FILE);
+ }
+ else {
+ open(FILE, "<$conf_file") or die "$conf_file: $!\n";
+ @c_content = <FILE>;
+ close(FILE);
+ for (@c_content) {
+ if (m{(.*)($zone_file)\.signed(.*)}) {
+ print
+ " * zonekonfiguration aktualisiert ($2.signed ==> $2)\n";
+ $_ = "$1$2$3\n";
+ }
+ }
+ open(FILE, ">$conf_file") or die "$conf_file: $!\n";
+ print FILE @c_content;
+ close(FILE);
+ }
+ }
+}
+
+sub server_reload {
+ if (`rndc reload`) { print "** reload dns-server \n" }
+}
+
+sub need_rollover() {
+
+ # gibt alle zonen mit abgelaufenen keycounter
+ my @r;
+
+ while (my $kc = glob "$config{master_dir}/*/.keycounter") {
+ my $zone = basename dirname $kc;
+ my $key;
+
+ {
+ open(my $fh, $kc) or die "$kc: $!\n";
+ chomp($key = <$fh>);
+ }
+
+ push @r, $zone if $config{key_counter_end} <= $key;
+ }
+
+ return @r;
+}
+
+sub done_rollover() {
+
+ # funktion ueberprueft ob ein keyrollover fertig ist
+ # die bedingung dafuer ist das:
+ # - eine datei .index.zsk vorhanden ist
+ # - die datei .index.zsk älter ist, als die rollover-Zeit
+ # - die datei .index.zsk ueber mehr als eine zeile gross ist
+ # (also mehr als einen Schlüssel enthält)
+ my @r;
+ my $now = time;
+
+ while (my $dir = glob "$config{master_dir}/*") {
+ my $zone = basename $dir;
+
+ my @index = ();
+ my $index_wc;
+
+ # prueft nach der ".index.zsk"-datei und erstellt den zeitpunkt
+ # an dem das key-rollover endet.
+ # rollover is done when mtime of the .index.zsk + abl_zeit is
+ # in the past
+ next if not -e "$dir/.index.zsk";
+ next if (stat _)[9] + 3600 * $config{abl_zeit} >= $now;
+
+ # prueft die anzahl der schluessel in der .index.zsk
+ open(my $fh, "$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
+ (<$fh>);
+ push @r, $zone if $. > 1;
+ }
+
+ return @r;
+}
+
+sub begin_rollover(@) {
+ my @zones = @_;
+ my @r;
+
+ # anfang des key-rollovers
+
+ foreach my $zone (@zones) {
+
+ # erzeugt zsks
+ my $dir = "$config{master_dir}/$zone";
+ my ($keyname, @keys);
+
+ # create a new key
+ { # need to change the direcoty, thus some more effort
+ # alternativly: $keyname = `cd $dir && dnssec-keygen ...`;
+ # would do, but is more fragile on shell meta characters
+
+ open(my $keygen, "-|") or do {
+ chdir $dir or die "Can't chdir to $dir: $!\n";
+ exec "dnssec-keygen",
+ -a => "RSASHA1",
+ -b => 512,
+ -n => "ZONE",
+ $zone;
+ die "Can't exec: $!";
+ };
+ chomp($keyname = <$keygen>);
+ close($keygen) or die "dnssec-keygen failed: $@";
+ }
+
+ open(my $fh, "+>>$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
+ seek($fh, 0, 0);
+ chomp(@keys = <$fh>);
+
+ ### @keys
+
+ push @keys, $keyname;
+ shift @keys if @keys > 2;
+
+ truncate($fh, 0) or die "truncate";
+ print $fh join "\n" => @keys;
+
+ print " * $zone: neuer ZSK $keyname erstellt\n";
+
+ open($fh, ">$dir/.keycounter") or die "$dir/.keycounter: $!\n";
+ say $fh 0;
+ close($fh);
+
+ unlink_unused_keys($zone);
+ include_keys($zone);
+ push @r, $zone;
+ }
+
+ return @r;
+}
+
+sub include_keys($) {
+
+ # die funktion fugt alle schluessel in eine zonedatei
+ my $zone = shift;
+ my $dir = "$config{master_dir}/$zone";
+
+ my $in = IO::File->new("$dir/$zone") or die "Can't open $dir/$zone: $!\n";
+ my $out = File::Temp->new(DIR => $dir) or die "Can't open tmpfile: $!\n";
+
+ print $out grep { !/\$include\s+.*key/i } $in;
+ print $out map { "\$INCLUDE @{[basename $_]}\n" } glob "$dir/K*key";
+
+ close $in;
+ close $out;
+ rename($out->filename => "$dir/$zone")
+ or die "Can't rename tmp to $dir/$zone: $!\n";
+
+}
+
+sub unlink_unused_keys($) {
+
+ # die funktion loescht alle schluessel die nicht in der index.zsk
+ # der uebergebenen zone stehen
+ my $zone = shift;
+
+ my @keys;
+ my $dir = "$config{master_dir}/$zone";
+
+ {
+
+ # collect the keys and cut everything except the key id
+ # we cut the basenames (w/o the .private|.key suffix)
+ open(my $zsk, "<$dir/.index.zsk") or die "$dir/.index.zsk: $!\n";
+ open(my $ksk, "<$dir/.index.ksk") or die "$dir/.index.ksk: $!\n";
+ @keys = (<$zsk>, <$ksk>);
+ }
+
+ # prueft alle schluesseldateien (ksk, zsk) ob sie in der jeweiligen
+ # indexdatei beschrieben sind. wenn nicht werden sie geloescht.
+ for my $file (glob "$dir/K*.key $dir/K*.private") {
+ unlink $file if basename($file, ".key", ".private") ~~ @keys;
+ }
+}
+
+sub end_rollover(@) {
+
+ my @zones = @_;
+ my @r;
+
+ foreach my $zone (@zones) {
+
+ my $dir = "$config{master_dir}/$zone";
+
+ open(my $fh, "+>>$dir/.index.zsk")
+ or die "Can't open $dir/.index.zsk: $!\n";
+ seek($fh, 0, 0);
+ chomp(my @keys = <$fh>);
+
+ if (@keys > 1) {
+ truncate($fh, 0);
+ say $fh $keys[-1];
+ }
+ close($fh);
+
+ unlink_unused_keys($zone);
+ include_keys($zone);
+ push @r => $zone;
+ }
+
+ return @r;
+}
+
+__END__
+
+=head1 NAME
+
+ update-serial - updates the serial numbers and re-signs the zone files
+
+=head1 SYNOPSIS
+
+ update-serial [options] [zone...]
+
+=head1 DESCRIPTION
+
+B<update-serial> scans the configured directories for modified zone files. On any
+file found it increments the serial number and signs the zone, if approbiate.
+
+=head1 OPTIONS
+
+=over
+
+=item B<--sign-alert-time> I<days>
+
+=item B<--key-counter-end> I<integer>
+
+Maximum number if key usages.
+
+
+=back
+
+The common options B<-h>|B<--help>|B<-m>|B<--man> are supported.
+
+=head1 AUTHOR
+
+L<andre.suess@pipkin.cc>
+
+=cut
+
+# vim:sts=4 sw=4 aw ai sm: