URI: 
       tgitzone - gitzone - git-based zone management tool for static and dynamic domains
  HTML git clone https://git.parazyd.org/gitzone
   DIR Log
   DIR Files
   DIR Refs
       ---
       tgitzone (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 }