#!/usr/bin/perl

=pod

=head1 NAME

git-server - Secure Git Server with more granular hooks capabilities than default git.

=head1 SYNOPSIS

  Standard Method:
  With SHELL=/bin/bash, use the following format in ~/.ssh/authorized_keys:
  command="/path/to/git-server REMOTE_USER=user1" ssh-ed25519 AAAA_OAX+blah_pub__ user1@workstation

   -- OR --

  Advanced Method:
  Set SHELL=/path/to/git-server (in /etc/passwd) and
  Add /path/to/git-server to /etc/shells and
  Set "PermitUserEnvironment yes" (in /etc/ssh/sshd_config)
  Then use the following format in ~/.ssh/authorized_keys:
  environment="REMOTE_USER=user1" ssh-ed25519 AAAA_OAX+blah_pub__ user1@workstation

=head1 ENV

You can set as many %ENV variables as you want
within the authorized_keys configuration.

=head2 REMOTE_USER

REMOTE_USER has a special meaning to define a word for the associated user.
You may use the same REMOTE_USER for those who have multiple PubKeys.
This REMOTE_USER will be used for ACL rules.

=head1 INSTALL

This can be used with any existing git repositories or as a drop-in replacement
for git-shell or you can create a fresh repo on the git host:

  git init --bare project

Then add hooks/run-git-hooks to override the default behavior:

  vi project/hooks/run-git-hooks
  chmod 755 project/hooks/run-git-hooks

If hooks/run-git-hooks exists from within the targetted repository's
hooksPath, then this will run with the correct GIT_DIR
and any other ENV settings defined in authorized_keys.
If it doesn't exist, then it will look for a way to use
these git-server hooks with this project.

=head1 SEE ALSO

Similar functionality to the following:

  gitlab-shell, gitolite, git-shell

=head1 AUTHOR

Rob Brown <bbb@cpan.org>

=head1 COPYRIGHT AND LICENSE

Copyright 2015-2026 by Rob Brown <bbb@cpan.org>

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.

=cut

use strict;
use warnings;
use Cwd qw(abs_path);
use FindBin qw($Bin);

our $VERSION = "0.042";

$SIG{PIPE} = sub { exit 1; };
my $cmd = $ENV{SSH_ORIGINAL_COMMAND} ||= # Standard Method from ~/.ssh/authorized_keys: command="git-server REMOTE_USER=user1" # i.e., "git-upload-pack 'project'"
    @ARGV == 2 && $ARGV[0] eq "-c" && shift && shift || ""; # Advanced Method from /etc/passwd SHELL

while (my $pair = shift) {
    $pair =~ /^(\w+)=(.*)$/ or die "Invalid ENV setting [$pair]\n";
    $ENV{$1} = $2;
}

$ENV{REMOTE_USER} ||= $ENV{KEY} || "";
$ENV{SSH_CONNECTION} ||= "";
($ENV{REMOTE_ADDR}, $ENV{REMOTE_PORT}, $ENV{SERVER_ADDR}, $ENV{SERVER_PORT}) = split / /, $ENV{SSH_CONNECTION};
if (!$ENV{REMOTE_PORT} || !$ENV{SERVER_ADDR} || !$ENV{SERVER_PORT}
    || $cmd =~ m{(^|/)git-verify\b}
    || !$ENV{REMOTE_ADDR} || $ENV{REMOTE_ADDR} !~ /^([\da-f\.:]+)$/) {
    (my $v = $0) =~ s{-[^/]+$}{-verify};
    -x $v or ($v = abs_path $0) =~ s{-[^/]+$}{-verify};
    -x $v && exec $v or die "git-verify: Verification failed\n";
}
$ENV{REMOTE_ADDR} = $1; # Taint cleaner
$ENV{REMOTE_USER} ||= "MISCONFIGURED_REMOTE_USER";
#delete $ENV{$_} foreach (qw[KEY SSH_CLIENT SSH_CONNECTION]); # No cheating
my $who = "$ENV{REMOTE_USER}\@$ENV{REMOTE_ADDR}";
if (!$cmd) {
    if ($ENV{SSH_USER_AUTH} and open my $auth, "<", $ENV{SSH_USER_AUTH}) {
        my $how = (<$auth>||"") =~ /^(?:publickey |)(.+)/ ? $1 : "";
        close $auth;
        if ($how) {
            my $key = eval { require Digest::SHA; $how =~ /^\S+\s+(\S+)/ && Digest::SHA::sha256_base64($1) };
            warn localtime().": [$who] git-server: Successfully Authenticated: [".($key ? "FingerPrint SHA256:$key" : "$how")."]\n";
        }
    }
    die localtime().": [$who] git-server: You don't have shell access!\n";
}

my $dir = undef;
if ($cmd =~ /^(git-[\w\-]+) (.+)$/) {
    my $op = $1;
    my $repo = $2;
    $repo = $1, $repo =~ s/'\\''/'/g if $repo =~ /^'(.+)'$/;
    $repo =~ s%(\.git|/)+$%%;
    my $home = $ENV{HOME} ||= (getpwuid $<)[7];
    foreach my $try ("$repo.git/.git", "$repo.git", "$repo/.git", $repo) {
        if (-d $try or $try =~ s{^/+}{} && -d $try or $try =~ s{^~/}{$home/} && -d $try) {
            $dir = $try;
            $ENV{GIT_DIR} = abs_path $dir;
            last;
        }
    }
    # Repository sanity check:
    die localtime().": [$who] git-server: You can't access '$repo' git repository\n" unless $dir && -f "$dir/config" && -d "$dir/refs" && -d "$dir/objects";

    # To avoid "fatal: bad argument" crash, git-shell mandates that repo dir be shell-escaped with single quotes even if it seems like it's not needed. i.e., "git-upload-pack 'project'"
    my $escape_dir = $ENV{GIT_DIR};
    $escape_dir =~ s/'/'\\''/g;
    $escape_dir = "'$escape_dir'";
    $cmd = "$op $escape_dir";
}
else {
    die localtime().": [$who] git-server: Unable to run the command: $cmd\n";
}

# Hand off request to the best handler
my @handler = ("$ENV{GIT_DIR}/hooks/run-git-hooks", "$Bin/hooks/run-git-hooks", "git-shell");
if (`git rev-parse --path-format=absolute --git-path hooks 2>/dev/null` =~ m{^(/.+)$}) {
    unshift @handler, "$1/run-git-hooks";
}
# Handle crusty old git versions that don't support "--git-path"
elsif (my $hooksdir = `git config core.hooksPath`) {
    chomp($hooksdir);
    $hooksdir = "$ENV{GIT_DIR}/$hooksdir" if $hooksdir !~ m{^/};
    $hooksdir = abs_path $hooksdir;
    unshift @handler, "$hooksdir/run-git-hooks";
}
-x || /^\w/ and exec $_, "-c", $cmd foreach @handler;
die localtime().": [$who] git-server: Failed to spawn handler $!\n";
