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 }