diff --git a/README.mkd b/README.mkd index 1df3bf8..7f3956a 100644 --- a/README.mkd +++ b/README.mkd @@ -99,6 +99,11 @@ or in Unix, perl, shell, etc.)... well I can't afford 1000 USD rewards like djb, so you'll have to settle for 1000 INR (Indian Rupees) as a "token" prize :-) +Update 2010-01-31: this security promise does not apply if you enable any of +the external command helpers (like rsync). It's probably quite secure, but I +just haven't thought about it enough to be able to make such promises, like I +can for the rest of "master". + ---- ### contact and license diff --git a/conf/example.conf b/conf/example.conf index 17e9998..a17161e 100644 --- a/conf/example.conf +++ b/conf/example.conf @@ -147,10 +147,18 @@ repo git # DENY/EXCLUDE RULES -# ***IMPORTANT NOTE: if you use deny rules, the order of the rules also makes -# a difference, where earlier it did not. Please review your ruleset -# carefully or test it. In particular, do not use `@all` in a deny rule -- it -# won't work as you might expect***. +# ***IMPORTANT NOTES ABOUT "DENY" RULES***: + +# - deny rules do NOT affect read access. They only apply to `W` and `+`. +# +# - when using deny rules, the order of your rules starts to matter, where +# earlier it did not. The first matching rule applies, where "matching" is +# defined as either permitting the operation you're attempting (`W` or `+`), +# which results in success, or a "deny" (`-`), which results in failure. +# (As before, a fallthrough also results in failure). +# +# - do not use `@all` when your config has any deny rules; it won't work as +# you probably expect it to! # in the example above, you cannot easily say "anyone can write any tag, # except version tags can only be written by junio". The following might look @@ -166,10 +174,6 @@ repo git - refs/tags/v[0-9] = linus pasky @others RW refs/tags/ = junio linus pasky @others -# Briefly, the rule is: the first matching refex that has the operation you're -# looking for (`W` or `+`), or a minus (`-`), results in success, or failure, -# respectively. A fallthrough also results in failure - # FILE/DIR NAME BASED RESTRICTIONS # -------------------------------- @@ -264,3 +268,21 @@ repo gitolite # security reasons. # - you can also use an absolute path if you like, although in the interests # of cloning the admin-repo sanely you should avoid doing this! + +# EXTERNAL COMMAND HELPERS -- RSYNC +# --------------------------------- + +# If $RSYNC_BASE is non-empty, the following config entries come into play +# (otherwise they are ignored): + +# a "fake" git repository to collect rsync rules. Gitolite does not +# auto-create any repo whose name starts with EXTCMD/ +repo EXTCMD/rsync +# grant permissions to files/dirs within the $RSYNC_BASE tree. A leading +# NAME/ is required as a prefix; the actual path starts after that. Matching +# follows the same rules as elsewhere in gitolite. + RW NAME/ = sitaram + RW NAME/foo/ = user1 + R NAME/bar/ = user2 +# just to remind you that these are perl regexes, not shell globs + RW NAME/baz/.*/*.c = user3 diff --git a/conf/example.gitolite.rc b/conf/example.gitolite.rc index d53b65f..bcb281f 100644 --- a/conf/example.gitolite.rc +++ b/conf/example.gitolite.rc @@ -106,6 +106,26 @@ $GIT_PATH=""; # syntax: space separated list of gitolite usernames in *one* string variable. # $SHELL_USERS = "alice bob"; +# -------------------------------------- + +# if you want to enable the "htpasswd" command, give this the absolute path to +# whatever file apache (etc) expect to find the passwords in. + +$HTPASSWD_FILE = ""; + +# Look in doc/3 ("easier to link gitweb authorisation with gitolite" section) +# for more details on using this feature. + +# -------------------------------------- +# EXTERNAL COMMAND HELPER -- RSYNC +# +# base path of all the files that are accessible via rsync. Must be an +# absolute path. Leave it undefined or set to the empty string to disable the +# rsync helper. +$RSYNC_BASE = ""; +# $RSYNC_BASE = "/home/git/up-down"; +# $RSYNC_BASE = "/tmp/up-down"; + # -------------------------------------- # per perl rules, this should be the last line in such a file: 1; diff --git a/doc/3-faq-tips-etc.mkd b/doc/3-faq-tips-etc.mkd index 5519eb6..b2a1ba7 100644 --- a/doc/3-faq-tips-etc.mkd +++ b/doc/3-faq-tips-etc.mkd @@ -27,6 +27,7 @@ In this document: * "personal" branches * custom hooks and custom git config * repos named with wildcards + * access control for external commands * design choices * keeping the parser and the access control separate @@ -331,9 +332,25 @@ This requires that: * the HTTP auth should use the same username (like "sitaram") as used in the gitolite config (for the corresponding user) -Once that is done, it's easy. Gitweb allows you to specify a subroutine to -decide on access. We use that feature and tie it to gitolite. Sample code -(untested, munged from something I saw [here][leho]) is given below. +Normally a superuser sets up passwords for users using the "htpasswd" command, +but this is an administrative chore. + +Robin Smidsrød had the *great* idea that, since each user already has pubkey +access to `git@server`, this gives us a very neat way of using gitolite to let +the users *manage their own HTTP passwords*. Here's how: + + * setup apache so that the htaccess file it looks for is owned by the "git" + user + * in the `~/.gitolite.rc` file, look for the variable `$HTPASSWD_FILE` and + point it to this file + * tell your users to type in `ssh git@server htpasswd` to set or change + their HTTP passwords + +Here's the rest of how it hangs together. + +Gitweb allows you to specify a subroutine to decide on access. We use that +feature and tie it to gitolite. Sample code (untested by me, but others do +use it, munged from something I saw [here][leho]) is given below. Note the **utter simplicity** of the actual check (just 1 line!). This is an unexpected piece of luck coming from the decision to keep the config parse @@ -349,7 +366,7 @@ already done and we just use it! $projectroot = '/home/git/repositories/'; my $gl_conf_compiled = '/home/git/.gitolite/conf/gitolite.conf-compiled.pm'; - # I assume this gives us the HTTP auth username + # I am told this gives us the HTTP auth username my $username = $cgi->remote_user; # ---------- @@ -359,10 +376,11 @@ already done and we just use it! die "parse $gl_conf_compiled failed: " . ($! or $@) unless do $gl_conf_compiled; # this is gitweb's mechanism; it calls whatever sub is pointed at by this - # variable to decide access yes/no + # variable to decide access yes/no. Gitweb calls it with one argument + # containing the full path of the repo being accessed $export_auth_hook = sub { my $reponame = shift; - # gitweb passes us the full repo path; so we strip the beginning... + # take the full path provided, strip the beginning... $reponame =~ s/\Q$projectroot\E\/?//; # ...and the end, to get the repo name as it is specified in gitolite conf $reponame =~ s/\.git$//; @@ -499,12 +517,9 @@ that code path to better use :-) #### "exclude" (or "deny") rules -***IMPORTANT CAVEAT: if you use deny rules, the order of the rules also makes -a difference, where earlier it did not. Please review your ruleset carefully -or test it. In particular, do not use `@all` in a deny rule -- it won't work -as you might expect***. Also, deny rules are only processed in the second -level checks (see "two levels of access rights checking" above), which means -they only apply to write operations. +Here is an illustrative explanation of "deny" rules. However, please be sure +to read the "DENY/EXCLUDE RULES" section in `conf/example.conf` for important +notes/caveats before using "deny" rules. Take a look at the following snippet, which *seems* to say that "bruce" can write versioned tags (anything containing `refs/tags/v[0-9]`), but the other @@ -614,6 +629,20 @@ per-repo "gitconfig" settings. Please see `doc/2-admin.mkd` and **This feature only exists in the "wildrepos" branch!** Please see `doc/4-wildcard-repositories.mkd` for all the details. +#### access control for external commands + +Gitolite now has a mechanism for allowing access control for arbitrary +external commands, as long as they are invoked via ssh and present a +server-side command that contains enough information to make an access control +decision. The first (and only, so far) such command implemented is rsync. + +Note that this is incompatible with giving people shell access as described in +`doc/6-ssh-troubleshooting.mkd` -- people who have shell access are not +subject to this mechanism (it wouldn't make sense to try and control someone +who has shell access anyway). + +Please see the config files (both of them) for examples and usage. + ### design choices #### keeping the parser and the access control separate diff --git a/src/gitolite.pm b/src/gitolite.pm index 045e339..14b7cf3 100644 --- a/src/gitolite.pm +++ b/src/gitolite.pm @@ -292,4 +292,118 @@ sub expand_wild } } +# ---------------------------------------------------------------------------- +# S P E C I A L C O M M A N D S +# ---------------------------------------------------------------------------- + +sub special_cmd +{ + my ($GL_ADMINDIR, $GL_CONF_COMPILED, $RSYNC_BASE, $HTPASSWD_FILE) = @_; + + my $cmd = $ENV{SSH_ORIGINAL_COMMAND}; + my $user = $ENV{GL_USER}; + + # check each special command we know about and call it if enabled + if ($cmd eq 'info') { + &report_basic($GL_ADMINDIR, $GL_CONF_COMPILED, $user); + print "you also have shell access\n\r" if $shell_allowed; + } elsif ($HTPASSWD_FILE and $cmd eq 'htpasswd') { + &ext_cmd_htpasswd($HTPASSWD_FILE); + } elsif ($RSYNC_BASE and $cmd =~ /^rsync /) { + &ext_cmd_rsync($GL_CONF_COMPILED, $RSYNC_BASE, $cmd); + } else { + # if the user is allowed a shell, just run the command + exec $ENV{SHELL}, "-c", $cmd if $shell_allowed; + + die "bad command: $cmd\n"; + } +} + +# ---------------------------------------------------------------------------- +# generic check access routine +# ---------------------------------------------------------------------------- + +sub check_access +{ + my ($GL_CONF_COMPILED, $repo, $path, $perm) = @_; + my $ref = "NAME/$path"; + + &parse_acl($GL_CONF_COMPILED); + + # until I do some major refactoring (which will bloat the update hook a + # bit, sadly), this code duplicates stuff in the current update hook. + + my @allowed_refs; + # we want specific perms to override @all, so they come first + push @allowed_refs, @ { $repos{$repo}{$ENV{GL_USER}} || [] }; + push @allowed_refs, @ { $repos{$repo}{'@all'} || [] }; + + for my $ar (@allowed_refs) { + my $refex = (keys %$ar)[0]; + next unless $ref =~ /^$refex/; + die "$perm $ref $ENV{GL_USER} DENIED by $refex\n" if $ar->{$refex} eq '-'; + return if ($ar->{$refex} =~ /\Q$perm/); + } + die "$perm $ref $ENV{GL_REPO} $ENV{GL_USER} DENIED by fallthru\n"; +} + +# ---------------------------------------------------------------------------- +# external command helper: rsync +# ---------------------------------------------------------------------------- + +sub ext_cmd_rsync +{ + my ($GL_CONF_COMPILED, $RSYNC_BASE, $cmd) = @_; + + # test the command patterns; reject if they don't fit. Rsync sends + # commands that looks like one of these to the server (the first one is + # for a read, the second for a write) + # rsync --server --sender -some.flags . some/path + # rsync --server -some.flags . some/path + + die "bad rsync command: $cmd" + unless $cmd =~ /^rsync --server( --sender)? -[\w.]+(?: --(?:delete|partial))* \. (\S+)$/; + my $perm = "W"; + $perm = "R" if $1; + my $path = $2; + die "I dont like absolute paths in $cmd\n" if $path =~ /^\//; + die "I dont like '..' paths in $cmd\n" if $path =~ /\.\./; + + # ok now check if we're permitted to execute a $perm action on $path + # (taken as a refex) using rsync. + + &check_access($GL_CONF_COMPILED, 'EXTCMD/rsync', $path, $perm); + # that should "die" if there's a problem + + wrap_chdir($RSYNC_BASE); + &log_it("$ENV{GL_TS}\t$ENV{SSH_ORIGINAL_COMMAND}\t$ENV{USER}\n"); + exec $ENV{SHELL}, "-c", $ENV{SSH_ORIGINAL_COMMAND}; +} + +# ---------------------------------------------------------------------------- +# external command helper: htpasswd +# ---------------------------------------------------------------------------- + +sub ext_cmd_htpasswd +{ + my $HTPASSWD_FILE = shift; + + die "$HTPASSWD_FILE doesn't exist or is not writable\n" unless -w $HTPASSWD_FILE; + $|++; + print <; + $password =~ s/[\n\r]*$//; + my $rc = system("htpasswd", "-b", $HTPASSWD_FILE, $ENV{GL_USER}, $password); + die "htpasswd command seems to have failed with $rc return code...\n" if $rc; +} + 1; diff --git a/src/gl-auth-command b/src/gl-auth-command index 26fbf71..dab635a 100755 --- a/src/gl-auth-command +++ b/src/gl-auth-command @@ -24,7 +24,7 @@ use warnings; # ---------------------------------------------------------------------------- # these are set by the "rc" file -our ($GL_LOGT, $GL_CONF_COMPILED, $REPO_BASE, $GIT_PATH, $REPO_UMASK, $GL_ADMINDIR); +our ($GL_LOGT, $GL_CONF_COMPILED, $REPO_BASE, $GIT_PATH, $REPO_UMASK, $GL_ADMINDIR, $RSYNC_BASE, $HTPASSWD_FILE); # and these are set by gitolite.pm our ($R_COMMANDS, $W_COMMANDS, $REPONAME_PATT, $REPOPATT_PATT); our %repos; @@ -48,6 +48,8 @@ $ENV{PATH} .= ":$GIT_PATH" if $GIT_PATH; # set the umask before creating any files umask($REPO_UMASK); +my $repo_base_abs = ( $REPO_BASE =~ m(^/) ? $REPO_BASE : "$ENV{HOME}/$REPO_BASE" ); + # ---------------------------------------------------------------------------- # start... # ---------------------------------------------------------------------------- @@ -63,25 +65,40 @@ if ($ARGV[0] eq '-s') { # first, fix the biggest gripe I have with gitosis, a 1-line change my $user=$ENV{GL_USER}=shift; # there; now that's available everywhere! +# ---------------------------------------------------------------------------- +# logging, timestamp env vars +# ---------------------------------------------------------------------------- + +# timestamp +my ($s, $min, $h, $d, $m, $y) = (localtime)[0..5]; +$y += 1900; $m++; # usual adjustments +for ($s, $min, $h, $d, $m) { + $_ = "0$_" if $_ < 10; +} +$ENV{GL_TS} = "$y-$m-$d.$h:$min:$s"; + +# substitute template parameters and set the logfile name +$GL_LOGT =~ s/%y/$y/g; +$GL_LOGT =~ s/%m/$m/g; +$GL_LOGT =~ s/%d/$d/g; +$ENV{GL_LOG} = $GL_LOGT; + # ---------------------------------------------------------------------------- # sanity checks on SSH_ORIGINAL_COMMAND # ---------------------------------------------------------------------------- -# print basic access info if SSH_ORIGINAL_COMMAND does not exist +# no SSH_ORIGINAL_COMMAND given... unless ($ENV{SSH_ORIGINAL_COMMAND}) { - # unless the user is allowed to use a shell + # if the user is allowed to use a shell, give him one if ($shell_allowed) { my $shell = $ENV{SHELL}; $shell =~ s/.*\//-/; # change "/bin/bash" to "-bash" exec { $ENV{SHELL} } $shell; } - &report_basic($GL_ADMINDIR, $GL_CONF_COMPILED, $user); - exit 1; + # otherwise, pretend he typed in "info" and carry on... + $ENV{SSH_ORIGINAL_COMMAND} = 'info'; } -my $cmd = $ENV{SSH_ORIGINAL_COMMAND}; -my $repo_base_abs = ( $REPO_BASE =~ m(^/) ? $REPO_BASE : "$ENV{HOME}/$REPO_BASE" ); - # ---------------------------------------------------------------------------- # get and set perms for actual repo created by wildcard-autoviv # ---------------------------------------------------------------------------- @@ -92,7 +109,8 @@ my $CUSTOM_COMMANDS=qr/^\s*(expand|getperms|setperms)\s/; # back; they all blithely take advantage of the fact that processing custom # commands is sort of a dead end for normal (git) processing -if ($cmd =~ $CUSTOM_COMMANDS) { +if ($ENV{SSH_ORIGINAL_COMMAND} =~ $CUSTOM_COMMANDS) { + my $cmd = $ENV{SSH_ORIGINAL_COMMAND}; my ($verb, $repo) = ($cmd =~ /^\s*(\S+)\s+\/?(.*?)(?:.git)?$/); if ($repo =~ $REPONAME_PATT and $verb =~ /getperms|setperms/) { # with an actual reponame, you can "getperms" or "setperms" @@ -108,35 +126,36 @@ if ($cmd =~ $CUSTOM_COMMANDS) { exit 0; } -# people allowed to get a shell can get basic access info by asking nicely -if ($cmd eq 'info') { - &report_basic($GL_ADMINDIR, $GL_CONF_COMPILED, $user); - print "you also have shell access\n\r" if $shell_allowed; - exit 0; -} - # ---------------------------------------------------------------------------- -# normal (git) processing +# non-git commands # ---------------------------------------------------------------------------- -# split into command and arguments; the pattern allows old style as well as -# new style: "git-subcommand arg" or "git subcommand arg", just like gitosis -# does, although I'm not sure how necessary that is -# -# keep in mind this is how git sends across the command: -# git-receive-pack 'reponame.git' -# including the single quotes +# if the command does NOT fit the pattern of a normal git command, send it off +# somewhere else... -my ($verb, $repo) = ($cmd =~ /^\s*(git\s+\S+|\S+)\s+'\/?(.*?)(?:\.git)?'/); +# side notes on detecting a normal git command: the pattern we check allows +# old style as well as new style ("git-subcommand arg" or "git subcommand +# arg"), just like gitosis does, although I'm not sure how necessary that is. +# Currently, this is how git sends across the command (including the single +# quotes): +# git-receive-pack 'reponame.git' + +my ($verb, $repo) = ($ENV{SSH_ORIGINAL_COMMAND} =~ /^\s*(git\s+\S+|\S+)\s+'\/?(.*?)(?:\.git)?'/); unless ( $verb and ( $verb =~ $R_COMMANDS or $verb =~ $W_COMMANDS ) and $repo and $repo =~ $REPONAME_PATT ) { - # if the user is allowed a shell, just run the command - exec $ENV{SHELL}, "-c", $ENV{SSH_ORIGINAL_COMMAND} if $shell_allowed; - # otherwise, whine - die "bad command: $cmd\n"; + # ok, it's not a normal git command; call the special command helper + &special_cmd ($GL_ADMINDIR, $GL_CONF_COMPILED, $RSYNC_BASE, $HTPASSWD_FILE); + exit; } die "$repo ends with a slash; I don't like that\n" if $repo =~ /\/$/; die "$repo has two consecutive periods; I don't like that\n" if $repo =~ /\.\./; +# reponame +$ENV{GL_REPO}=$repo; + +# ---------------------------------------------------------------------------- +# the real git commands (git-receive-pack, etc...) +# ---------------------------------------------------------------------------- + # ---------------------------------------------------------------------------- # first level permissions check # ---------------------------------------------------------------------------- @@ -163,32 +182,11 @@ die "$perm access for $repo DENIED to $user\n" unless $repos{$repo}{$perm}{$user} or $repos{$repo}{$perm}{'@all'}; -# ---------------------------------------------------------------------------- -# logging, timestamp. also setup env vars for later -# ---------------------------------------------------------------------------- - -# reponame -$ENV{GL_REPO}=$repo; - -# timestamp -my ($s, $min, $h, $d, $m, $y) = (localtime)[0..5]; -$y += 1900; $m++; # usual adjustments -for ($s, $min, $h, $d, $m) { - $_ = "0$_" if $_ < 10; -} -$ENV{GL_TS} = "$y-$m-$d.$h:$min:$s"; - -# substitute template parameters and set the logfile name -$GL_LOGT =~ s/%y/$y/g; -$GL_LOGT =~ s/%m/$m/g; -$GL_LOGT =~ s/%d/$d/g; -$ENV{GL_LOG} = $GL_LOGT; - -&log_it("$ENV{GL_TS}\t$ENV{SSH_ORIGINAL_COMMAND}\t$user\n"); - # ---------------------------------------------------------------------------- # over to git now # ---------------------------------------------------------------------------- +&log_it("$ENV{GL_TS}\t$ENV{SSH_ORIGINAL_COMMAND}\t$user\n"); + $repo = "'$REPO_BASE/$repo.git'"; exec("git", "shell", "-c", "$verb $repo"); diff --git a/src/gl-compile-conf b/src/gl-compile-conf index 9c05156..00fa298 100755 --- a/src/gl-compile-conf +++ b/src/gl-compile-conf @@ -312,7 +312,6 @@ sub parse_conf_file 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 =~ s/ /+/g if $owner; # gitweb/INSTALL wants more, but meh...! $owner{"$repo.git"} = $owner || ''; } else @@ -389,8 +388,9 @@ wrap_chdir("$repo_base_abs"); for my $repo (sort keys %repos) { next unless $repo =~ $REPONAME_PATT; - print STDERR "creating $repo...\n"; + next if $repo =~ m(^EXTCMD/); # these are not real repos unless (-d "$repo.git") { + print STDERR "creating $repo...\n"; new_repo($repo, "$GL_ADMINDIR/src/hooks"); # new_repo would have chdir'd us away; come back wrap_chdir("$repo_base_abs"); @@ -451,16 +451,32 @@ for my $repo (sort keys %repos) { $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" . ( $owner{$proj} ? " $owner{$proj}" : "" ) . "\n"; + print $projlist_fh "$proj\n"; } close $projlist_fh;