gitzone

git-based zone management tool for static and dynamic domains
git clone https://git.parazyd.org/gitzone
Log | Files | Refs

gitzone (12898B)


      1 #!/usr/bin/env perl
      2 
      3 # gitzone - git-based zone file management tool for BIND
      4 #
      5 # Copyright (C) 2011 - 2013 Dyne.org Foundation
      6 #
      7 # This program is free software: you can redistribute it and/or modify it under
      8 # the terms of the GNU Affero General Public License as published by the Free
      9 # Software Foundation, either version 3 of the License, or (at your option) any
     10 # later version.
     11 #
     12 # This program is distributed in the hope that it will be useful, but WITHOUT
     13 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
     14 # FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
     15 # details.
     16 #
     17 # You should have received a copy of the GNU Affero General Public License
     18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
     19 
     20 
     21 # This program is called from a pre-receive & post-receive or pre-commit &
     22 # post-commit git hook. If a push is made to the master branch, changed files
     23 # are validated with named-checkzone>. The push or commit is rejected if there's
     24 # an error in one of the zone files specified in the config file. If everything
     25 # is OK, the zone files are copied to $zone_dir and the zone is reloaded with
     26 # the following command: rndc reload $zone $class $view
     27 
     28 use warnings;
     29 use strict;
     30 use POSIX qw/strftime/;
     31 use Cwd qw/cwd realpath/;
     32 use File::Basename qw/fileparse basename/;
     33 use File::Temp;
     34 use File::Path;
     35 use File::Spec;
     36 
     37 @ARGV >= 2 or die "Usage: gitzone /path/to/gitzone.conf <command>\n";
     38 chdir '.git' if -d '.git';
     39 basename(realpath) eq '.git' or die "gitzone has to be run from a .git directory\n";
     40 
     41 my $lock_file = realpath '.gitzone-lock';
     42 my $list_file = realpath '.gitzone-list';
     43 my $stash_file;
     44 my $read_only = 0;
     45 chdir '..';
     46 
     47 our $user = getpwuid $<;
     48 our $repo = basename realpath;
     49 our ($zone_dir, $git, $named_checkzone, $rndc, $class, $default_view, $update_record, $unrestricted_includes, $max_depth, $repos, $verbosity);
     50 
     51 my ($config_file, $cmd) = @ARGV;
     52 do $config_file or die "Can't load config: $!\n";
     53 
     54 my (%files, @zones, @changed_files, $date, $cleanup);
     55 delete $ENV{GIT_DIR};
     56 
     57 !-e $lock_file or die "Error: lock file exists\n";
     58 open FILE, '>', $lock_file or die $!; close FILE;
     59 
     60 sub cleanup { unlink $lock_file; &$cleanup() if ref $cleanup }
     61 sub clean_exit { cleanup; exit shift }
     62 $SIG{__DIE__} = \&cleanup;
     63 
     64 ($_ = $cmd) &&
     65     /^pre-receive$/ && pre_receive() ||
     66     /^post-receive$/ && post_receive() ||
     67     /^pre-commit$/ && pre_commit() ||
     68     /^post-commit$/ && post_commit() ||
     69     $update_record && /^update-record$/ && update_record($ARGV[2]);
     70 cleanup;
     71 
     72 sub git {
     73     my ($args, $print, $ret) = @_;
     74     $ret ||=0;
     75     print "% git $args\n" if $verbosity >= 2;
     76     $_ = `$git $args 2>&1`;
     77     $print = 1 if !defined $print && $verbosity >= 1;
     78     if ($print) {
     79 	#my $cwd = cwd; s/$cwd//g; # print relative paths
     80 	print;
     81     }
     82     if ($ret >= 0 && $? >> 8 != $ret) {
     83 	my ($package, $filename, $line) = caller;
     84 	print;
     85 	die "Died at line $line.\n";
     86     }
     87     return $_;
     88 }
     89 
     90 # Load BIND config files specified in the $repos config variable.
     91 # First load the -default key, then the $repo key.
     92 sub load_repo_config {
     93     my $key = shift || '-default';
     94 
     95     # move files not in a dir to a . dir for easier processing
     96     for my $file (keys %{$repos->{$key}}) {
     97 	next if ref $repos->{$key}->{$file} eq 'HASH';
     98 	$repos->{$key}->{'.'}->{$file} = $repos->{$key}->{$file};
     99 	delete $repos->{$key}->{$file};
    100     }
    101 
    102     for my $dir (keys %{$repos->{$key}}) {
    103 	my $d = $repos->{$key}->{$dir};
    104 	for my $file (keys %$d) {
    105 	    $d->{$file} = $default_view if $d->{$file} eq 1;
    106 	    $d->{$file} = [$d->{$file}] if ref $d->{$file} ne 'ARRAY';
    107 	    next unless $file =~ m,^/,;
    108 	    if (-f $file) {
    109 		open FILE, '<', $file or die $!;
    110 		while (<FILE>) {
    111 		    if (/^\s*zone\s+"([^"]+)"/) {
    112 			$repos->{$repo}->{$dir}->{$1} = $d->{$file};
    113 		    }
    114 		}
    115 		close FILE;
    116 	    }
    117 	    delete $d->{$file} if $key ne '-default';
    118 	}
    119     }
    120 
    121     load_repo_config($repo) if $key eq '-default';
    122 }
    123 
    124 sub check_what_changed {
    125     my ($old, $new) = @_;
    126 
    127     # diff with empty tree if there's no previous commit
    128     if (!$old || $old =~ /^0+$/) {
    129 	$_ = git "diff-tree --root $new";
    130     } else {
    131 	$_ = git "diff --raw --abbrev=40 ". ($new ? "$old..$new" : $old);
    132     }
    133 
    134     # parse diff output, add only valid zone names to %files for parsing
    135     $files{$1} = 0 while m,^:(?:[\w.]+\s+){5}(?:[A-Za-z0-9./-]+\s+)?([A-Za-z0-9./-]+)$,gm;
    136 }
    137 
    138 sub process_files {
    139     $files{$_} = 0 for @_;
    140     process_file($_) for keys %files;
    141     check_zones();
    142 
    143     if (@changed_files && !$read_only) {
    144 	print "adding changed files: @changed_files\n" if $verbosity >= 2;
    145 	git "add @changed_files";
    146     }
    147 }
    148 
    149 sub process_file {
    150     my ($file, $depth) = @_;
    151     my (@newfile, $changed, @inc_by);
    152     print ">> process_file($file)\n" if $verbosity >= 3;
    153 
    154     return 0 if $files{$file}; # already processed
    155     return -1 unless -f $file;
    156 
    157     print ">>> processing $file\n" if $verbosity >= 3;
    158     $files{$_}++;
    159 
    160     open FILE, '<', $file or die $!;
    161     my $n = 0;
    162     while (<FILE>) {
    163 	$n++;
    164 	my $line = $_;
    165 	if (/^(.*)(\b\d+\b)(.*?;AUTO_INCREMENT\b.*)$/) {
    166 	    # increment serial where marked with ;AUTO_INCREMENT
    167 	    # if length of serial is 10 and starts with 20 treat it as a date
    168 	    my ($a,$s,$z) = ($1,int $2,$3);
    169 	    $date ||= strftime '%Y%m%d', localtime;
    170 	    $s = ($s =~ /^$date/ || $s < 2000000000 || $s >= 2100000000) ? $s + 1 : $date.'00';
    171 	    $line = "$a$s$z\n";
    172 	    $changed = 1;
    173 	} elsif (/^(\s*\$INCLUDE\s+)(\S+)(.*)$/) {
    174 	    my ($a,$inc_file,$z) = ($1,$2,$3);
    175 	    unless ($unrestricted_includes) {
    176 		# check $INCLUDE lines for files outside the repo dir
    177 		unless ($inc_file =~ m,^$repo/, && $inc_file !~ /\.\./) {
    178 		    close FILE;
    179 		    die "Error in $file:$n: invalid included file name, it should start with: $repo/\n";
    180 		}
    181 	    }
    182 
    183 	    # Try and feed INCLUDE files with relative path names into the list.
    184 	    # This should allow having a common header with an AUTO_INCREMENTed serial number.
    185 	    if ($inc_file =~ m|^$repo/(.*)|) {
    186 		push (@inc_by, $1);
    187 	    }
    188 	} else {
    189 	    if ($n == 1 && /^;INCLUDED_BY\s+(.*)$/) {
    190 		push(@inc_by, split /\s+/, $1);
    191 	    }
    192 	}
    193 	push @newfile, $line;
    194     }
    195     close FILE;
    196 
    197     if ($changed && !$read_only) {
    198 	print ">>> $file changed, saving\n" if $verbosity >= 3;
    199 
    200 	open FILE, '>', $file or die $!;
    201 	print FILE for @newfile;
    202 	close FILE;
    203 
    204 	push @changed_files, $file;
    205     }
    206 
    207     if ($depth++ < $max_depth) {
    208 	process_file($_, $depth) for @inc_by;
    209     } else {
    210 	print "Warning: ;INCLUDED_BY is followed only up to $max_depth levels,\n".
    211 	    "  the following files are not reloaded: @inc_by\n";
    212     }
    213 
    214     return 1;
    215 }
    216 
    217 sub check_zones {
    218     print ">> check_zones: ,",%files,"\n" if $verbosity >= 3;
    219     for my $file (keys %files) {
    220 	my ($zone, $dir) = fileparse $file;
    221 	$zone =~ s/\.signed$//;
    222 	$dir = substr $dir, 0, -1;
    223 	# skip files with errors and those that are not in the config
    224 	next unless $files{$file} > 0 && exists $repos->{$repo}->{$dir}->{$zone};
    225 
    226 	print "Checking zone $zone\n";
    227 	print `$named_checkzone -w .. '$zone' '$repo/$file'`;
    228 	clean_exit 1 if $?; # error, reject push
    229 	push @zones, $file;
    230     }
    231 }
    232 
    233 sub save_list_file {
    234     if (@zones) {
    235 	print "Zone check passed: @zones\n";
    236 	# save changed zone list for post-receive hook
    237 	open FILE, '>>', $list_file or die $!;
    238 	print FILE join(' ', @zones), "\n";
    239 	close FILE;
    240     } else {
    241 	print "No zones to reload\n";
    242     }
    243 }
    244 
    245 sub load_list_file {
    246     return unless -f $list_file;
    247     my %zones;
    248     open FILE, '<', $list_file or die $!;
    249     while (<FILE>) {
    250 	$zones{$_} = 1 for split /[\s\n\r]+/;
    251     }
    252     close FILE;
    253     @zones = keys %zones;
    254 }
    255 
    256 sub install_zones {
    257     print "Reloading changed zones: @zones\n";
    258 
    259     my $cwd = cwd;
    260 
    261     chdir "$zone_dir/$repo" or die $!;
    262     git "clone $cwd ." unless -d '.git';
    263     git 'fetch';
    264     git 'reset --hard remotes/origin/master';
    265 
    266     for my $file (@zones) {
    267 	my ($zone, $dir) = fileparse $file;
    268 	$zone =~ s/\.signed$//;
    269 	$dir = substr $dir, 0, -1;
    270 	my $view = $repos->{$repo}->{$dir}->{$zone};
    271 	print "$_/$zone: ", `$rndc reload '$zone' $class $_` for @$view;
    272     }
    273 
    274     unlink $list_file;
    275 }
    276 
    277 # save working dir state
    278 # (git stash wouldn't work without conflicts if there's a
    279 # change in both the index & working tree in the same file)
    280 sub stash_save {
    281     $stash_file = File::Temp::tempnam('.git', '.gitzone-stash-');
    282     print "Saving working tree to $stash_file\n";
    283     git "update-index --refresh -q", 0, -1;
    284     git "diff >$stash_file";
    285     git 'checkout .';
    286 }
    287 
    288 # restore working dir
    289 sub stash_pop {
    290     print "Restoring working tree from $stash_file\n";
    291     git "apply --reject --whitespace=nowarn $stash_file", 1, -1;
    292     unlink $stash_file unless $?;
    293 }
    294 
    295 sub pre_receive {
    296     my ($old, $new, $ref);
    297 
    298     while (<STDIN>) { # <old-value> SP <new-value> SP <ref-name> LF
    299 	print if $verbosity >= 1;
    300 	next unless m,(\w+) (\w+) ([\w/]+),;
    301 	next if $3 ne 'refs/heads/master'; # only process master branch
    302 	die "Denied branch 'new', choose another name\n" if $3 eq 'refs/head/new';
    303 	($old, $new, $ref) = ($1, $2, $3);
    304     }
    305 
    306     # nothing for master branch, exit
    307     clean_exit 0 unless $ref;
    308 
    309     # Figure out the paths for the repo, and the temporary checkout location.
    310     my $base_cwd = cwd;
    311     my @dir = File::Spec->splitdir($base_cwd);
    312     my $repo_name = $dir[$#dir];
    313     $dir[$#dir] .= '_tmp';
    314     push(@dir, $repo_name);
    315     my $tmp_dir = join('/', @dir);
    316 
    317     # Do the diff and find out exactly what changed.
    318     # This must be done before the chdir below.
    319     check_what_changed($old, $new);
    320 
    321     # Make the temporary directory from scratch.
    322     File::Path->remove_tree($tmp_dir, verbose => 1);
    323     File::Path->make_path($tmp_dir, verbose => 1);
    324 
    325     # Extract the new commit.
    326     # We do this with git archive, and then extract the resulting tar in the temporary directory.
    327     # There really should be a better way to do this, but I can't find one.
    328     git "archive $new | tar -C $tmp_dir -xf -";
    329 
    330     # chdir into the temporary directory.
    331     chdir $tmp_dir or die $!;
    332 
    333     # Go read only, no actual changes in the pre-release hook.
    334     $read_only = 1;
    335 
    336     load_repo_config;
    337     process_files;
    338 
    339     # Go back to the repo.
    340     chdir $base_cwd;
    341 }
    342 
    343 sub pre_commit {
    344     stash_save;
    345 
    346     $cleanup = sub {
    347 	# reset any changes, e.g. auto inc.
    348 	git 'checkout .';
    349 	stash_pop;
    350     };
    351 
    352     git 'rev-parse --verify HEAD', 0, -1;
    353     check_what_changed($? ? undef : 'HEAD');
    354     load_repo_config;
    355     process_files;
    356 
    357     $cleanup = sub {
    358 	stash_pop;
    359     };
    360 
    361     save_list_file;
    362 }
    363 
    364 sub post_receive {
    365     my ($old, $new, $ref);
    366 
    367     while (<STDIN>) { # <old-value> SP <new-value> SP <ref-name> LF
    368 	print if $verbosity >= 1;
    369 	next unless m,(\w+) (\w+) ([\w/]+),;
    370 	next if $3 ne 'refs/heads/master'; # only process master branch
    371 	die "Denied branch 'new', choose another name\n" if $3 eq 'refs/head/new';
    372 	($old, $new, $ref) = ($1, $2, $3);
    373     }
    374 
    375     # nothing for master branch, exit
    376     clean_exit 0 unless $ref;
    377 
    378     # Repeat the check_what_changed from the pre_receive.
    379     check_what_changed($old, $new);
    380 
    381     print "\n";
    382 
    383     # Grab the current master.
    384     git 'checkout -f master';
    385 
    386     load_repo_config;
    387 
    388     # Go through and process the files again, this time allowing changes.
    389     # All of the AUTO_INCREMENT stuff happens here.
    390     # The zone files are checked a second time as well.
    391     process_files;
    392 
    393     # Commit any auto increment changes.
    394     if (@changed_files) {
    395 	git "commit -nm 'auto increment: @changed_files'", 1;
    396     }
    397 
    398     # Actually install the new zone files.
    399     install_zones;
    400 
    401     if (@changed_files) {
    402 	print "Done. Auto increment applied, don't forget to pull.\n";
    403     } else {
    404 	print "Done.\n";
    405     }
    406 }
    407 
    408 sub post_commit {
    409     print "\n";
    410 
    411     load_repo_config;
    412     load_list_file;
    413     install_zones;
    414     print "Done.\n";
    415 }
    416 
    417 sub update_record {
    418     my ($c, $file, @record) = split /\s+/, shift;
    419     my ($ip) = $ENV{SSH_CLIENT} =~ /^([\d.]+|[a-f\d:]+)\s/i or die "Invalid IP address\n";
    420     my $re = qr/^\s*/i;
    421     $re = qr/$re$_\s+/i for (@record);
    422     my $matched = 0;
    423     my $changed = 0;
    424     my @newfile;
    425 
    426     git 'checkout -f master';
    427 
    428     open FILE, '<', $file or die "$file: $!";
    429     while (<FILE>) {
    430 	my $line = $_;
    431 	if (!$matched && s/($re)([\d.]+|[a-f\d:]+)/$1$ip/i) {
    432 	    print "Matched record:\n$line";
    433 	    $matched = 1;
    434 	    if ($line ne "$1$ip\n") {
    435 		$changed = 1;
    436 		$line = "$1$ip\n";
    437 		print "Updating it with:\n$line";
    438 	    } else {
    439 		print "Not updating: already up-to-date\n";
    440 		close FILE;
    441 		clean_exit 0;
    442 	    }
    443 	}
    444 	push @newfile, $line;
    445     }
    446     close FILE;
    447     die "No matching record in $file: @record\n" unless $matched;
    448 
    449     open FILE, '>', $file or die $!;
    450     print FILE for @newfile;
    451     close FILE;
    452 
    453     git "commit -nm 'update-record: $file' '$file'", 1;
    454 
    455     load_repo_config;
    456     process_files $file;
    457     git "commit -nm 'auto increment: @changed_files'", 1 if @changed_files;
    458     install_zones if @zones;
    459 }