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 }