tinitial commit - gitzone - git-based zone management tool for static and dynamic domains
 (HTM) git clone https://git.parazyd.org/gitzone
 (DIR) Log
 (DIR) Files
 (DIR) Refs
       ---
 (DIR) commit 0ee7507d69e87c816eefdb0fcaf3db75f7ecf91a
 (HTM) Author: tg(x) <*@tg-x.net>
       Date:   Thu,  3 Feb 2011 23:54:09 +0100
       
       initial commit
       
       Diffstat:
         A README.org                          |       3 +++
         A bin/gitzone                         |     277 +++++++++++++++++++++++++++++++
         A bin/gitzone-shell                   |      49 +++++++++++++++++++++++++++++++
         A etc/gitzone.conf                    |      24 ++++++++++++++++++++++++
         A hooks/post-receive                  |       5 +++++
         A hooks/pre-receive                   |       3 +++
       
       6 files changed, 361 insertions(+), 0 deletions(-)
       ---
 (DIR) diff --git a/README.org b/README.org
       t@@ -0,0 +1,3 @@
       +% git init zones
       +% cd zones
       +% git config receive.denyCurrentBranch ignore
 (DIR) diff --git a/bin/gitzone b/bin/gitzone
       t@@ -0,0 +1,277 @@
       +#!/usr/bin/env perl
       +#
       +# gitzone by tg
       +#
       +# this program should be called from a pre-receive hook, it receives
       +# <old-value> SP <new-value> SP <ref-name> LF
       +# on STDIN for each branch, the push is rejected if the exit code is non-zero
       +#
       +# changed files are validated with named-checkzone and the push is rejected
       +# if there's an error in the zones specified in the config file ($ARGV[0]),
       +# if everything is OK, the zone files are copied to $zone_dir and the zone is
       +# reloaded with rndc reload
       +
       +use warnings;
       +use strict;
       +use POSIX qw/strftime/;
       +use Cwd qw/cwd realpath/;
       +use File::Basename qw/basename dirname/;
       +
       +our ($zone_dir, $git, $named_checkzone, $rndc, $class, $default_view, $max_depth, $zones, $verbosity);
       +our $user = getpwuid $<;
       +
       +my $config_file = $ARGV[0] or die "Usage: gitzone /path/to/gitzone.conf\n";
       +do $config_file or die "Can't load config: $!\n";
       +
       +my $lock_file = realpath '.gitzone-lock';
       +my $list_file = realpath '.gitzone-list';
       +my (%files, %inc_files, @zones, $date);
       +delete $ENV{GIT_DIR};
       +
       +!-e $lock_file or die "Error: lock file exists\n";
       +open FILE, '>', $lock_file or die $!; close FILE;
       +
       +sub cleanup { unlink $lock_file }
       +sub clean_exit { cleanup; exit shift }
       +$SIG{__DIE__} = \&cleanup;
       +
       +$_ = $ARGV[1];
       +/^pre-receive$/ && pre_receive() || /^post-receive$/ && post_receive() || /^update-record$/ && update_record($ARGV[2]);
       +cleanup;
       +
       +sub git {
       +  my ($args, $print, $ret) = @_;
       +  $ret ||=0;
       +  print "% git $args\n" if $verbosity >= 2;
       +  $_ = `$git $args 2>&1`;
       +  $print = 1 if !defined $print && $verbosity >= 1;
       +  if ($print) {
       +    #my $cwd = cwd; s/$cwd//g; # print relative paths
       +    print;
       +  }
       +  die if $ret >= 0 && $? >> 8 != $ret;
       +  return $_;
       +}
       +
       +sub process_files {
       +  $files{$_} = 0 for (@_);
       +  $files{$_} += process_file($_) for keys %files;
       +  find_inc_by($_) for keys %inc_files;
       +  check_zones();
       +}
       +
       +sub process_file {
       +  my $f = shift; # filename
       +  my (@newfile, $changed, @inc_by);
       +  print ">> process_file($f)\n" if $verbosity >= 3;
       +
       +  return 0 if $files{$f}; # already processed
       +  return -1 unless -f $f; # deleted
       +
       +  open FILE, '<', $f or die $!;
       +  my $n = 0;
       +  while (<FILE>) {
       +    $n++;
       +    my $line = $_;
       +    if (/^(.*)(\b\d+\b)(.*?;AUTO_INCREMENT\b.*)$/) {
       +      # increment serial where marked with ;AUTO_INCREMENT
       +      # if length of serial is 8 and starts with 20 treat it as a date
       +      my ($a,$s,$z) = ($1,int $2,$3);
       +      $date ||= strftime '%Y%m%d', localtime;
       +      $s = ($s =~ /^$date/ || $s < 20000000 || $s >= 21000000) ? $s + 1 : $date.'00';
       +      $line = "$a$s$z\n";
       +      $changed = 1;
       +    } elsif (/^(\W*\$INCLUDE\W+)(\S+)(.*)$/) {
       +      # check $INCLUDE lines for files outside the user dir
       +      my ($a,$file,$z) = ($1,$2,$3);
       +      unless ($file =~ m,^$user/, && $file !~ /\.\./) {
       +        close FILE;
       +        die "Error in $f:$n: invalid included file name, it should start with: $user/\n";
       +      }
       +    } else {
       +      if ($n == 1 && /^;INCLUDED_BY\s+(.*)$/) {
       +        # add files listed after ;INCLUDED_BY to %inc_files
       +        @inc_by = split /\s+/, $1;
       +        for (@inc_by) {
       +          $inc_files{$_} = 0 unless exists $files{$_};
       +        }
       +      }
       +    }
       +    push @newfile, $line;
       +  }
       +  close FILE;
       +
       +  if ($changed) {
       +    open FILE, '>', $f or die $!;
       +    print FILE for @newfile;
       +    close FILE;
       +
       +    my $fesc = $f;
       +    $fesc =~ s/'/'\\''/g;
       +    git "commit -m 'auto increment: $fesc' '$fesc'", 1;
       +  }
       +
       +  return 1;
       +}
       +
       +sub find_inc_by {
       +  my $f = shift; # filename
       +  my $d = shift || 1; # recursion depth
       +  my @inc_by;
       +  print ">> find_inc_by($f)\n" if $verbosity >= 3;
       +
       +  return 0 if $files{$f}; # already processed
       +  return -1 unless -f $f; # deleted
       +  $files{$_}++;
       +
       +  open FILE, '<', $f or die $!;
       +  if (<FILE> =~ /^;INCLUDED_BY\s+(.*)$/) {
       +    # add files listed after ;INCLUDED_BY to %files
       +    @inc_by = split /\s+/, $1;
       +    for (@inc_by) {
       +      $files{$_} = 0 unless exists $files{$_};
       +    }
       +  }
       +  close FILE;
       +
       +  if ($d++ < $max_depth) {
       +    find_inc_by($_, $d) for @inc_by;
       +  } else {
       +    print "Warning: ;INCLUDED_BY is followed only up to $max_depth levels,\n".
       +          "  the following files are not reloaded: @inc_by\n";
       +  }
       +}
       +
       +sub check_zones {
       +  for my $f (keys %files) {
       +    # skip files with errors and those that are not in the config
       +    next unless $files{$f} > 0 && $zones->{$user}->{$f};
       +    next if $f =~ /'/;
       +    my $zone = basename $f;
       +    print `$named_checkzone -kn -w .. '$zone' '$user/$f'`;
       +    clean_exit 1 if $?; # error, reject push
       +    push @zones, $f;
       +  }
       +}
       +
       +sub install_zones {
       +  print "Reloading changed zones: @zones\n";
       +
       +  my $cwd = cwd;
       +  # move master to new
       +  git 'checkout -f master';
       +  git 'reset --hard new';
       +
       +  chdir "$zone_dir/$user" or die $!;
       +  git "clone $cwd ." unless -d '.git';
       +  git 'reset --hard';
       +  git 'pull';
       +
       +  for my $f (@zones) {
       +    my $zone = basename $f;
       +    my $view = $zones->{$user}->{$f};
       +    $view = $default_view if $view eq 1;
       +    `$rndc reload '$zone' $class $view`;
       +  }
       +
       +  unlink $list_file;
       +}
       +
       +sub pre_receive {
       +  my ($old, $new, $ref);
       +  chdir '..';
       +
       +  while (<STDIN>) { # <old-value> SP <new-value> SP <ref-name> LF
       +    print if $verbosity >= 1;
       +    next unless m,(\w+) (\w+) ([\w/]+),;
       +    next if $3 ne 'refs/heads/master'; # only process master branch
       +    die "Denied branch 'new', choose another name\n" if $3 eq 'refs/head/new';
       +    ($old, $new, $ref) = ($1, $2, $3);
       +  }
       +
       +  # nothing for master branch, exit
       +  clean_exit 0 unless $ref;
       +
       +  # check what changed
       +  git "checkout -qf $new";
       +  $_ = git "diff --raw $old..$new";
       +  $files{$1} = 0 while m,^:(?:[\w.]+\s+){5}([\w./-]+)$,gm;
       +
       +  process_files;
       +
       +  if (@zones) {
       +    print "Zone check passed: @zones\n";
       +    # save changed zone list for post-receive hook
       +    open FILE, '>>', $list_file or die $!;
       +    print FILE join(' ', @zones), "\n";
       +    close FILE;
       +  } else {
       +    print "No zones to reload\n";
       +  }
       +
       +  # save new commits in a new branch
       +  git 'branch -D new';
       +  git 'checkout -b new';
       +}
       +
       +sub post_receive {
       +  print "\n";
       +  chdir '..';
       +
       +  open FILE, '<', $list_file or die $!;
       +  push @zones, split /[\s\n\r]+/ while <FILE>;
       +  close FILE;
       +
       +  install_zones;
       +  print "Done. Don't forget to pull if you use auto increment.\n";
       +}
       +
       +sub update_record {
       +  my ($c, $f, @record) = split /\s+/, shift;
       +  my ($ip) = $ENV{SSH_CLIENT} =~ /^([\d.]+|[a-f\d:]+)\s/i or die "Invalid IP address\n";
       +  my $re = qr/^\s*/i;
       +  $re = qr/$re$_\s+/i for (@record);
       +  my $matched = 0;
       +  my $changed = 0;
       +  my @newfile;
       +
       +  chdir $user;
       +  git 'checkout -f master';
       +
       +  open FILE, '<', $f or die "$f: $!";
       +  while (<FILE>) {
       +    my $line = $_;
       +    if (!$matched && s/($re)([\d.]+|[a-f\d:]+)/$1$ip/i) {
       +      print "Matched record:\n$line";
       +      $matched = 1;
       +      if ($line ne "$1$ip\n") {
       +        $changed = 1;
       +        $line = "$1$ip\n";
       +        print "Updating it with:\n$line";
       +      } else {
       +        print "Not updating: already up-to-date\n";
       +        close FILE;
       +        clean_exit 0;
       +      }
       +    }
       +    push @newfile, $line;
       +  }
       +  close FILE;
       +  die "No matching record in $f: @record\n" unless $matched;
       +
       +  open FILE, '>', $f or die $!;
       +  print FILE for @newfile;
       +  close FILE;
       +
       +  my $fesc = $f;
       +  $fesc =~ s/'/'\\''/g;
       +  git "commit -m 'update-record: $fesc' '$fesc'", 1;
       +
       +  process_files $f;
       +
       +  # save new commits in a new branch
       +  git 'branch -D new';
       +  git 'checkout -b new';
       +
       +  install_zones if @zones;
       +}
 (DIR) diff --git a/bin/gitzone-shell b/bin/gitzone-shell
       t@@ -0,0 +1,49 @@
       +#!/bin/sh
       +
       +# only repo allowed for git  pull/push
       +repo='zones'
       +# allow ssh key add/del/list commands
       +allow_key_management=1
       +
       +# paths
       +git_shell=/usr/bin/git-shell
       +gitzone=/usr/bin/gitzone
       +config=/etc/gitzone.conf
       +grep=grep
       +
       +function error {
       +  echo "fatal: What do you think I am? A shell?"
       +  exit 128
       +}
       +
       +if [ "$1" != "-c" ]; then error; fi
       +cmd=$2
       +
       +if [[ "$cmd" == git-upload-pack* ]]; then
       +  $git_shell -c "git-upload-pack '$repo'"
       +elif [[ "$cmd" == git-receive-pack* ]]; then
       +  $git_shell -c "git-receive-pack '$repo'"
       +elif [[ "$cmd" == update-record* ]]; then
       +  $gitzone $config update-record "$cmd"
       +elif [ "$allow_key_management" == 1 ]; then
       +  if [ "$cmd" == list-keys ]; then
       +    cat .ssh/authorized_keys
       +  elif [[ "$cmd" == add-key* ]]; then
       +    key="${cmd:8}"
       +    if [[ "$key" =~ ^ssh-(rsa|dss)\ [a-zA-Z0-9/+]+=*\ [a-zA-Z0-9_.]+@[a-zA-Z0-9.-]+$ ]]; then
       +      echo "$key" >> .ssh/authorized_keys && \
       +        echo "key added"
       +    else
       +      echo "invalid key"
       +    fi
       +  elif [[ "$cmd" == del-key* ]]; then
       +    key="${cmd:8}"
       +    $grep -v "$key" .ssh/authorized_keys > .ssh/authorized_keys-new && \
       +      mv .ssh/authorized_keys-new .ssh/authorized_keys && \
       +      echo "key deleted"
       +  else
       +    error
       +  fi
       +else
       +  error
       +fi
 (DIR) diff --git a/etc/gitzone.conf b/etc/gitzone.conf
       t@@ -0,0 +1,24 @@
       +# directory where the zone files are copied to (no trailing slash)
       +# there should be one directory for each user here chowned to the users
       +$zone_dir = "/var/bind";
       +
       +# commands
       +$git = 'git';
       +$named_checkzone = '/usr/sbin/named-checkzone';
       +$rndc = '/usr/sbin/rndc';
       +
       +# parameters for rndc reload: class & view
       +$class = 'IN';
       +# default view of the zones
       +$default_view = '';
       +
       +# max depth to follow INCLUDED_BY files
       +$max_depth = 256;
       +# output verbosity (0..3)
       +$verbosity = 0;
       +
       +# defines which files in a user's repo can be loaded as zone files,
       +# optionally you can define which view a zone belongs to
       +$zones = {
       +# user1 => { 'example.com' => 1, 'local/example.net' => 'local', },
       +}
 (DIR) diff --git a/hooks/post-receive b/hooks/post-receive
       t@@ -0,0 +1,5 @@
       +#!/bin/sh
       +
       +if [ -f .gitzone-list ]; then
       +  gitzone /etc/gitzone.conf post-receive
       +fi
 (DIR) diff --git a/hooks/pre-receive b/hooks/pre-receive
       t@@ -0,0 +1,3 @@
       +#!/bin/sh
       +
       +gitzone /etc/gitzone.conf pre-receive