#!/usr/bin/perl use strict; use warnings; use Data::Dumper; $Data::Dumper::Indent = 1; $Data::Dumper::Sortkeys = 1; # === add-auth-keys === # part of the gitolite (GL) suite # (1) - "compiles" ~/.ssh/authorized_keys from the list of pub-keys # (2) - also "compiles" the user-friendly GL conf file into something easier # to parse. We're doing this because both the gl-auth-command and the # (gl-)update hook need this, and it seems easier to do this than # replicate the parsing code in both those places. As a bonus, it's # probably more efficient. # (3) - finally does what I have resisted doing all along -- handle gitweb and # git-daemon access. It won't *setup* gitweb/daemon for you -- you have # to that yourself. What this does is make sure that "repo.git" # contains the file "git-daemon-export-ok" (for daemon case) and the # line "repo.git" exists in the "projects.list" file (for gitweb case). # how run: manual, by GL admin # when: # - anytime a pubkey is added/deleted # - anytime gitolite.conf is changed # input: # - GL_CONF (default: ~/.gitolite/conf/gitolite.conf) # - GL_KEYDIR (default: ~/.gitolite/keydir) # output: # - ~/.ssh/authorized_keys (dictated by sshd) # - GL_CONF_COMPILED (default: ~/.gitolite/conf/gitolite.conf-compiled.pm) # security: # - touches a very critical system file that manages the restrictions on # incoming users. Be sure to audit AUTH_COMMAND and AUTH_OPTIONS (see # below) on any change to this script # - no security checks within program. The GL admin runs this manually # warnings: # - if the "start" line exists, but the "end" line does not, you lose the # rest of the existing authkey file. In general, "don't do that (TM)", # but we do have a "vim -d" popping up so you can see the changes being # made, just in case... # ---------------------------------------------------------------------------- # common definitions # ---------------------------------------------------------------------------- # setup quiet mode if asked; please do not use this when running manually open STDOUT, ">", "/dev/null" if (@ARGV and shift eq '-q'); # these are set by the "rc" file our ($GL_ADMINDIR, $GL_CONF, $GL_KEYDIR, $GL_CONF_COMPILED, $REPO_BASE, $REPO_UMASK, $PROJECTS_LIST, $GIT_PATH, $GL_WILDREPOS, $GL_GITCONFIG_KEYS, $GL_PACKAGE_HOOKS); # and these are set by gitolite.pm our ($REPONAME_PATT, $REPOPATT_PATT, $USERNAME_PATT, $AUTH_COMMAND, $AUTH_OPTIONS, $ABRT, $WARN); # the common setup module is in the same directory as this running program is my $bindir = $0; $bindir =~ s/\/[^\/]+$//; $bindir = "$ENV{PWD}/$bindir" unless $bindir =~ /^\//; require "$bindir/gitolite.pm"; # ask where the rc file is, get it, and "do" it &where_is_rc(); die "$ABRT parse $ENV{GL_RC} failed: " . ($! or $@) unless do $ENV{GL_RC}; # add a custom path for git binaries, if specified $ENV{PATH} .= ":$GIT_PATH" if $GIT_PATH; # ---------------------------------------------------------------------------- # definitions specific to this program # ---------------------------------------------------------------------------- # command and options for authorized_keys $AUTH_COMMAND="$bindir/gl-auth-command"; $AUTH_OPTIONS="no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty"; # groups can now represent user groups or repo groups. # $groups{group}{member} = "master" (or name of fragment file in which the # group is defined). our %groups = (); # %repos has two functions. # $repos{repo}{R|W}{user} = 1 if user has R (or W) permissions for at least # one branch in repo. This is used by the "level 1 check" (see faq). There's # also the new "C" (create a repo) permission now # $repos{repo}{user} is a list of {ref, perms} pairs. This is used by the # level 2 check. In order to allow "exclude" rules, the order of rules now # matters, so what used to be entirely "hash of hash of hash" now has a list # in between :) my %repos = (); # ... having been forced to use a list as described above, we lose some # efficiency due to the possibility of the same {ref, perms} pair showing up # multiple times for the same repo+user. So... my %rurp_seen = (); # catch usernames<->pubkeys mismatches; search for "lint" below my %user_list = (); # repo configurations my %repo_config = (); # gitweb descriptions and owners; plain text, keyed by "$repo.git" my %desc = (); my %owner = (); # set the umask before creating any files umask($REPO_UMASK); # ---------------------------------------------------------------------------- # subroutines # ---------------------------------------------------------------------------- sub expand_list { my @list = @_; my @new_list = (); for my $item (@list) { if ($item =~ /^@/) # nested group { die "$ABRT undefined group $item\n" unless $groups{$item}; # add those names to the list push @new_list, sort keys %{ $groups{$item} }; } else { push @new_list, $item; } } return @new_list; } # ---------------------------------------------------------------------------- # "compile" GL conf # ---------------------------------------------------------------------------- sub parse_conf_file { my ($conffile, $fragment) = @_; # the second arg, $fragment, is passed in as "master" when parsing the # main config, and the fragment name when parsing a fragment. In the # latter case, the parser uses that information to ignore (and warn about) # any repos in the fragment that are not members of the "repo group" of # the same name. my %ignored = (); my $conf_fh = wrap_open( "<", $conffile ); # the syntax is fairly simple, so we parse it inline my @repos; while (<$conf_fh>) { # kill comments, but take care of "#" inside *simple* strings s/^((".*?"|[^#"])*)#.*/$1/; # normalise whitespace; keeps later regexes very simple s/=/ = /; s/\s+/ /g; s/^ //; s/ $//; # and blank lines next unless /\S/; # user or repo groups if (/^(@\S+) = (.*)/) { # store the members of each group as hash key. Keep track of when # the group was *first* created by using $fragment as the *value* do { $groups{$1}{$_} ||= $fragment } for ( expand_list( split(' ', $2) ) ); die "$ABRT bad group $1\n" unless $1 =~ $REPONAME_PATT; } # repo(s) elsif (/^repo (.*)/) { # grab the list and expand any @stuff in it @repos = split ' ', $1; unless (@repos == 1 and $repos[0] eq '@all') { @repos = expand_list ( @repos ); for (@repos) { die "$ABRT bad reponame $_\n" if ($GL_WILDREPOS and $_ !~ $REPOPATT_PATT); die "$ABRT bad reponame $_ or you forgot to set \$GL_WILDREPOS\n" if (not $GL_WILDREPOS and $_ !~ $REPONAME_PATT); } } s/\bCREAT[EO]R\b/\$creator/g for @repos; } # actual permission line elsif (/^(-|C|R|RW\+?D?) (.* )?= (.+)/) { my $perms = $1; my @refs; @refs = split(' ', $2) if $2; @refs = expand_list ( @refs ); my @users = split ' ', $3; die "$ABRT \$GL_WILDREPOS is not set, you cant use 'C' in config\n" if $perms eq 'C' and not $GL_WILDREPOS; # if no ref is given, this PERM applies to all refs @refs = qw(refs/.*) unless @refs; # deprecation warning map { warn "WARNING: old syntax 'PATH/' found; please use new syntax 'NAME/'\n" if s(^PATH/)(NAME/) } @refs; # fully qualify refs that dont start with "refs/" or "NAME/"; # prefix them with "refs/heads/" @refs = map { m(^(refs|NAME)/) or s(^)(refs/heads/); $_ } @refs; @refs = map { s(/USER/)(/\$gl_user/); $_ } @refs; # expand the user list, unless it is just "@all" @users = expand_list ( @users ) unless (@users == 1 and $users[0] eq '@all'); do { die "$ABRT bad username $_\n" unless $_ =~ $USERNAME_PATT } for @users; s/\bCREAT[EO]R\b/~\$creator/g for @users; s/\bREADERS\b/\$readers/g for @users; s/\bWRITERS\b/\$writers/g for @users; # ok, we can finally populate the %repos hash for my $repo (@repos) # each repo in the current stanza { # if we're processing a delegated config file (not the master # config), we need to prevent attempts by that admin to obtain # rights on stuff outside his domain # trying to set access for $repo (='foo')... if ( # processing the master config, not a fragment ( $fragment eq 'master' ) or # fragment is also called 'foo' (you're allowed to have a # fragment that is only concerned with one repo) ( $fragment eq $repo ) or # fragment is called "bar" and "@bar = foo" has been # defined in the master config ( ($groups{"\@$fragment"}{$repo} || '') eq 'master' ) ) { # all these are fine } else { # this is a little more complex # fragment is called "bar", one or more "@bar = regex" # have been specified in master, and "foo" matches some # such "regex" my @matched = grep { $repo =~ /^$_$/ } grep { $groups{"\@$fragment"}{$_} eq 'master' } sort keys %{ $groups{"\@$fragment"} }; if (@matched < 1) { $ignored{$fragment}{$repo} = 1; next; } } for my $user (@users) { $user_list{$user}++; # only to catch lint, see later # for 1st level check (see faq/tips doc) $repos{$repo}{C}{$user} = 1, next if $perms eq 'C'; $repos{$repo}{R}{$user} = 1 if $perms =~ /R/; $repos{$repo}{W}{$user} = 1 if $perms =~ /W|D/; # if the user specified even a single 'D' anywhere, make # that fact easy to find; this changes the meaning of RW+ # to no longer permit deletes (see update hook) $repos{$repo}{DELETE_IS_D} = 1 if $perms =~ /D/; # for 2nd level check, store each "ref, perms" pair in order for my $ref (@refs) { # checking NAME based restrictions is expensive for # the update hook (see the changes to src/hooks/update # in this commit for why) so we would *very* much like # to avoid doing it for the large majority of repos # that do *not* use NAME limits. Setting a flag that # can be checked right away will help us do that $repos{$repo}{NAME_LIMITS} = 1 if $ref =~ /^NAME\//; push @{ $repos{$repo}{$user} }, { $ref => $perms } unless $rurp_seen{$repo}{$user}{$ref}{$perms}++; } } } } # configuration elsif (/^config (.+) = ?(.*)/) { my ($key, $value) = ($1, $2); my @validkeys = split (' ', ($GL_GITCONFIG_KEYS || '')); my @matched = grep { $key =~ /^$_$/ } @validkeys; die "$ABRT git config $key not allowed\n" if (@matched < 1); for my $repo (@repos) # each repo in the current stanza { $repo_config{$repo}{$key} = $value; } } # include elsif (/^include "(.+)"/) { my $file = $1; $file = "$GL_ADMINDIR/conf/$file" unless $file =~ /^\//; die "$WARN $fragment attempting to include configuration\n" if $fragment ne 'master'; die "$ABRT included file not found: '$file'\n" unless -f $file; parse_conf_file($file, $fragment); } # very simple syntax for the gitweb description of repo; one of: # reponame = "some description string" # reponame "owner name" = "some description string" elsif (/^(\S+)(?: "(.*?)")? = "(.*)"$/) { my ($repo, $owner, $desc) = ($1, $2, $3); die "$ABRT bad repo name $repo\n" unless $repo =~ $REPONAME_PATT; die "$WARN $fragment attempting to set description for $repo\n" if $fragment ne 'master' and $fragment ne $repo and ($groups{"\@$fragment"}{$repo} || '') ne 'master'; $desc{"$repo.git"} = $desc; $owner{"$repo.git"} = $owner || ''; } else { die "$ABRT can't make head or tail of '$_'\n"; } } for my $ig (sort keys %ignored) { warn "\n\t\t***** WARNING *****\n" . "\t$ig.conf attempting to set access for " . join (", ", sort keys %{ $ignored{$ig} }) . "\n"; } } # parse the main config file parse_conf_file($GL_CONF, 'master'); # parse any delegated fragments wrap_chdir($GL_ADMINDIR); for my $fragment_file (glob("conf/fragments/*.conf")) { # we already check (elsewhere) that a fragment called "foo" will not try # to specify access control for a repo whose name is not "foo" or is not # part of a group called "foo" created by master # meanwhile, I found a possible attack where the admin for group B creates # a "convenience" group of (a subset of) his users, and then the admin for # repo group A (alphabetically before B) adds himself to that same group # in his own fragment. # as a result, admin_A now has access to group B repos :( # so now we lock the groups hash to the value it had after parsing # "master", and localise any changes to it by this fragment so that they # don't propagate to the next fragment. Thus, each fragment now has only # those groups that are defined in "master" and itself local %groups = %groups; my $fragment = $fragment_file; $fragment =~ s/^conf\/fragments\/(.*).conf$/$1/; parse_conf_file($fragment_file, $fragment); } my $compiled_fh = wrap_open( ">", $GL_CONF_COMPILED ); my $dumped_data = Data::Dumper->Dump([\%repos], [qw(*repos)]); # the dump uses single quotes, but we convert any strings containing $creator, # $readers, $writers, to double quoted strings. A wee bit sneaky, but not too # much... $dumped_data =~ s/'(?=[^']*\$(?:creator|readers|writers|gl_user))~?(.*?)'/"$1"/g; print $compiled_fh $dumped_data; close $compiled_fh or die "$ABRT close compiled-conf failed: $!\n"; # ---------------------------------------------------------------------------- # any new repos to be created? # ---------------------------------------------------------------------------- # modern gits allow cloning from an empty repo, so we just create it. Gitosis # did not have that luxury, so it was forced to detect the first push and # create it then # but it turns out not everyone has "modern" gits :) my $git_version = `git --version`; die " *** ERROR *** did not get a proper version number. Please see if git is in the PATH on the server. If it is not, please edit ~/.gitolite.rc on the server and set the \$GIT_PATH variable to the correct value\n " unless $git_version; my ($gv_maj, $gv_min, $gv_patchrel) = ($git_version =~ m/git version (\d+)\.(\d+)\.(\d+)/); die "$ABRT I can't understand $git_version\n" unless ($gv_maj >= 1); $git_version = $gv_maj*10000 + $gv_min*100 + $gv_patchrel; # now it's "normalised" # repo-base needs to be an absolute path for this loop to work right # so if it was not already absolute, prefix $HOME. my $repo_base_abs = ( $REPO_BASE =~ m(^/) ? $REPO_BASE : "$ENV{HOME}/$REPO_BASE" ); wrap_chdir("$repo_base_abs"); for my $repo (sort keys %repos) { next unless $repo =~ $REPONAME_PATT; next if $repo =~ m(^EXTCMD/); # these are not real repos next if $repo eq '@all'; unless (-d "$repo.git") { print STDERR "creating $repo...\n"; new_repo($repo, "$GL_ADMINDIR/hooks/common"); # new_repo would have chdir'd us away; come back wrap_chdir("$repo_base_abs"); } # when repos are copied over from elsewhere, one had to run easy install # once again to make the new (OS-copied) repo contain the proper update # hook. Perhaps we can make this easier now, and eliminate the easy # install, with a quick check (and a new, empty, "hook" as a sentinel) unless (-l "$repo.git/hooks/gitolite-hooked") { ln_sf("$GL_ADMINDIR/hooks/common", "*", "$repo.git/hooks"); # in case of package install, GL_ADMINDIR is no longer the top cop; # override with the package hooks ln_sf("$GL_PACKAGE_HOOKS/common", "*", "$repo.git/hooks") if $GL_PACKAGE_HOOKS; } } die "\n\t\t***** AAARGH! *****\n" . "\tyour git version is older than 1.6.2\n" . "\tsince that is now more than one year old, and gitolite needs some of\n" . "\tthe newer features, please upgrade.\n" if $git_version < 10602; # that's 1.6.2 to you # ---------------------------------------------------------------------------- # update repo configurations # ---------------------------------------------------------------------------- for my $repo (keys %repo_config) { wrap_chdir("$repo_base_abs/$repo.git"); while ( my ($key, $value) = each(%{ $repo_config{$repo} }) ) { if ($value) { $value =~ s/^"(.*)"$/$1/; system("git", "config", $key, $value); } else { system("git", "config", "--unset-all", $key); } } } # ---------------------------------------------------------------------------- # handle gitweb and daemon # ---------------------------------------------------------------------------- # How you specify gitweb and daemon access is quite different from gitosis. I # just assume you'll never have any *real* users called "gitweb" or "daemon" # :-) These are now "pseduo users" -- giving them "R" access to a repo is all # you have to do wrap_chdir("$repo_base_abs"); # daemons first... for my $repo (sort keys %repos) { next unless $repo =~ $REPONAME_PATT; my $export_ok = "$repo.git/git-daemon-export-ok"; if ($repos{$repo}{'R'}{'daemon'}) { system("touch $export_ok"); } else { unlink($export_ok); } } my %projlist = (); # ...then gitwebs for my $repo (sort keys %repos) { next unless $repo =~ $REPONAME_PATT; my $desc_file = "$repo.git/description"; # note: having a description also counts as enabling gitweb if ($repos{$repo}{'R'}{'gitweb'} or $desc{"$repo.git"}) { $projlist{"$repo.git"} = 1; # add the description file; no messages to user or error checking :) $desc{"$repo.git"} and open(DESC, ">", $desc_file) and print DESC $desc{"$repo.git"} . "\n" and close DESC; if ($owner{"$repo.git"}) { # set the repository owner system("git", "--git-dir=$repo.git", "config", "gitweb.owner", $owner{"$repo.git"}); } else { # remove the repository owner setting system("git --git-dir=$repo.git config --unset-all gitweb.owner 2>/dev/null"); } } else { # delete the description file; no messages to user or error checking :) unlink $desc_file; # remove the repository owner setting system("git --git-dir=$repo.git config --unset-all gitweb.owner 2>/dev/null"); } # unless there are other gitweb.* keys set, remove the section to keep the # config file clean my $keys = `git --git-dir=$repo.git config --get-regexp '^gitweb\\.' 2>/dev/null`; if (length($keys) == 0) { system("git --git-dir=$repo.git config --remove-section gitweb 2>/dev/null"); } } # update the project list my $projlist_fh = wrap_open( ">", $PROJECTS_LIST); for my $proj (sort keys %projlist) { print $projlist_fh "$proj\n"; } close $projlist_fh; # ---------------------------------------------------------------------------- # "compile" ssh authorized_keys # ---------------------------------------------------------------------------- my $authkeys_fh = wrap_open( "<", $ENV{HOME} . "/.ssh/authorized_keys", "\tFor security reasons, gitolite will not *create* this file if it does\n" . "\tnot already exist. Please see the \"admin\" document for details\n"); my $newkeys_fh = wrap_open( ">", $ENV{HOME} . "/.ssh/new_authkeys" ); # save existing authkeys minus the GL-added stuff while (<$authkeys_fh>) { print $newkeys_fh $_ unless (/^# gito(sis-)?lite start/../^# gito(sis-)?lite end/); } # add our "start" line, each key on its own line (prefixed by command and # options, in the standard ssh authorized_keys format), then the "end" line. print $newkeys_fh "# gitolite start\n"; wrap_chdir($GL_KEYDIR); for my $pubkey (`find . -type f`) { chomp($pubkey); $pubkey =~ s(^\./)(); # security check (thanks to divVerent for catching this) unless ($pubkey =~ $REPONAME_PATT) { print STDERR "$pubkey contains some unsavoury characters; ignored...\n"; next; } # lint check 1 unless ($pubkey =~ /\.pub$/) { print STDERR "WARNING: pubkey files should end with \".pub\", ignoring $pubkey\n"; next; } my $user = $pubkey; $user =~ s(.*/)(); # foo/bar/baz.pub -> baz.pub $user =~ s/(\@[^.]+)?\.pub$//; # baz.pub, baz@home.pub -> baz # lint check 2 print STDERR "WARNING: pubkey $pubkey exists but user $user not in config\n" unless $user_list{$user}; $user_list{$user} = 'has pubkey'; # apparently some pubkeys don't end in a newline... my $pubkey_content = `cat $pubkey`; $pubkey_content =~ s/\s*$/\n/; # don't trust files with multiple lines (i.e., something after a newline) if ($pubkey_content =~ /\n./) { print STDERR "WARNING: a pubkey file can only have one line (key); ignoring $pubkey\n"; next; } print $newkeys_fh "command=\"$AUTH_COMMAND $user\",$AUTH_OPTIONS "; print $newkeys_fh $pubkey_content; } # lint check 3; a little more severe than the first two I guess... for my $user (sort keys %user_list) { next if $user =~ /^(gitweb|daemon|\@all|~\$creator|\$readers|\$writers)$/ or $user_list{$user} eq 'has pubkey'; print STDERR "$WARN user $user in config, but has no pubkey!\n"; } print $newkeys_fh "# gitolite end\n"; close $newkeys_fh or die "$ABRT close newkeys failed: $!\n"; # all done; overwrite the file (use cat to avoid perm changes) system("cat $ENV{HOME}/.ssh/authorized_keys > $ENV{HOME}/.ssh/old_authkeys"); system("cat $ENV{HOME}/.ssh/new_authkeys > $ENV{HOME}/.ssh/authorized_keys") and die "couldn't write authkeys file\n"; system("rm $ENV{HOME}/.ssh/new_authkeys");