#!/usr/bin/perl

=pod

=head1 NAME

git-verify - Verify server configuration settings.

=head1 SYNOPSIS

  git-verify [ <REMOTE> ] [ /path/to/server/repo.git ... ]

=head1 DESCRIPTION

Command-line utility to verify git server configuration for capabilities that could be used by git-server.

<REMOTE> can be any valid SCP-style Git URL, such as:

  gituser@remote-git-server-host-name.com:my-repo.git

Or it can be a full ssh:// Git URL, such as:

  ssh://gituser@remote-git-server-host-name.com/my-repo.git

Or if you don't care about any specific repo, then <REMOTE> can use simple ssh-style format, such as:

  gituser@remote-git-server-host-name.com

If no user@ is provided, then ssh will use your current username.

If NO <REMOTE> is provided, then checks the current server where the command is run.

If no repo directories are specified, then the HOME directory is scanned
for any directory that smells like a git repo.

=head1 TODO

    * Validate scripts installation: git-server git-client git-deploy git-verify
    * Validate core.hooksPath
    * Validate ./hooks symlink for each repo if hooksPath not set
    * Secured ~/.ssh/authorized_keys format, including "REMOTE_USER" settings
    * Make sure ~/.ssh/authorized_keys has at least one ADMIN user
    * ADMIN and ACL Management (if "ADMIN")
    * Create / Remove repos (if "ADMIN")
    * Validate or Configure "any-user"@server feature (if "ADMIN")

=head1 SUPPORTED

    * Only allow safe read-only operations appropriate for REMOTE_USER powers.
    * Validate git-server installation.
    * Verify AllowAgentForwarding setting for two-way sync feature.
    * Verify AcceptEnv setting.
    * Check ExposeAuthInfo functionality to be able to determine key info, if desired.
    * List accessible repos specified or scanned.
    * Files and Directories and ~/.ssh/authorized_keys chmod permissions.

=head1 AUTHOR

Rob Brown <bbb@cpan.org>

=head1 COPYRIGHT AND LICENSE

Copyright 2025-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($RealBin);

our $VERSION = "0.041";

if (@ARGV and local $_ = $ARGV[0] || die "Bad arg\n" and !/^--/) {
    s{^ssh://(|[^/:]+\@)\[([a-z0-9\-\.\:]+)\]:(\d+)(?:$|/)}{}i ||
    s{^ssh://(|[^/:]+\@)\[?([a-z0-9\-\.]+)\]?:\[?(\d+)\]?(?:$|/)}{}i ||
    s{^ssh://(|[^/:]+\@)\[?([a-z0-9\-\.\:]+)\]?(?:$|/)}{}i ||
    s{^(|[^/:]+\@)\[([a-z0-9\-\.]+):(\d+)\](?:$|:)}{}i ||
    s{^(|[^/:]+\@)\[([a-z0-9\-\.\:]+)\]()(?:$|:)}{}i ||
    s{^(|[^/:]+\@)([a-z0-9\-\.]+)()(?:$|:)}{}i and do {
        my $userprefix = $1;
        my $remotehost = $2;
        my $remoteport = $3 || 22;
        my $resolvable = undef;
        $resolvable = gethostbyname($remotehost) or eval {
            require Socket;
            my ($err, @res) = Socket::getaddrinfo($remotehost,22,{socktype=>Socket::SOCK_STREAM(),family=>Socket::AF_UNSPEC(),flags=>((eval{Socket::AI_ALL()}||0)|(eval{Socket::AI_V4MAPPED()}||0))});
            $resolvable = @res;
            1;
        } or eval {
            require File::Which; # Try "dig" as last-ditch effort:
            my $dig = File::Which::which("dig") or die "no dig";
            $resolvable = `$dig +short $remotehost A $remotehost AAAA 2>/dev/null`;
            1;
        } or $resolvable = 1; # If IPv6 resolvers are unavailable, then assume it's valid, and just blindly run ssh
        if ($resolvable) {
            length()?$ARGV[0]=$_:shift;
            $ENV{XMODIFIERS}="client=".abs_path($0)."\@v$VERSION";
            my $search = q{$_=shift and 1+s/'/'"'"'/g and`(which '$_' git-verify|grep . || find . ~ /usr/local/bin /usr/bin /bin -name git-verify) 2>/dev/null`=~m{^(.+)}&&exec $1,@ARGV;warn"Cannot find git-verify on ".`hostname`."[$1][$!]['$_' @ARGV]\n"};
            pipe my $FAKE_STDIN, my $prog;
            print $prog $search;
            close $prog;
            open STDIN, "<&", $FAKE_STDIN;
            exec qw[ssh
                -o PubKeyAuthentication=yes
                -o KbdInteractiveAuthentication=no
                -o PasswordAuthentication=no
                -o SendEnv=XMODIFIERS
                -p], $remoteport,
                "$userprefix$remotehost","perl","-",abs_path($0),(
                !(grep {m{[^\w/\-.]}} @ARGV) ? @ARGV :     # Unable to find any potentionally stank chars so it's safe to pass @ARGV as is.
                ("--escaperepos",map{unpack"H*",$_}@ARGV)  # Otherwise, encode every character for all repos just to be extra safe.
            ) or die "$0: remote verification failed.\n";
        }
    };
}

my @repo_dirs = @ARGV;
if (!@repo_dirs) {
    local $_ = $ENV{SSH_ORIGINAL_COMMAND} || "";
    @repo_dirs = split;
    splice @repo_dirs, 0, 2 if $repo_dirs[0] && $repo_dirs[1] && $repo_dirs[0] eq "perl" && $repo_dirs[1] eq "-";
    shift @repo_dirs if $repo_dirs[0] && $repo_dirs[0] =~ m{(^|/)git-verify$};
}
if (@repo_dirs and $repo_dirs[0] eq "--escaperepos") {
    shift @repo_dirs;
    foreach (@repo_dirs) {
        $_ = pack "H*",$_;
    }
}
if (!@repo_dirs and !$ENV{GIT_SERVER_VERSION} and my $gitdir = $ENV{GIT_DIR}) {
    # Not running within git-server properly, so use a wrapper to avoid this problem in the future.
    eval {
        require File::Spec;
        my $fixed = 0;
        foreach my $path (File::Spec->path) {
            if (-d $path and -w _) { # First writeable directory in $PATH
                foreach (qw[git-upload-pack git-receive-pack]) {
                    symlink "$RealBin/git-packer","$path/$_" and ++$fixed and warn "Installed symlink: $path/$_ -> $RealBin/git-packer\n" if !-e "$path/$_";
                }
                last if $fixed;
            }
        }
    };
    @repo_dirs = (abs_path $gitdir);
}

sub run_how_cmd {
    my $stderr = shift;
    my @cmd = @_;
    if (my $pid = open my $fh_out, "-|") {
        # Parent process waiting for kid to say something
        my $output = join "", <$fh_out>;
        waitpid $pid, 0;
        my $exit_status = $?;
        close $fh_out;
        $? = $exit_status;
        return $output;
    }
    # Child process
    open STDERR, $stderr;
    exec @cmd or die "$cmd[0]: Failed to spawn? $!\n";
}

sub run_output_ignore_err {
    return run_how_cmd ">/dev/null", @_;
}

sub run_output_include_err {
    return run_how_cmd ">&STDOUT", @_;
}

#1. Check git-server install
my $bin;
if (eval { require File::Which;1 }) {
    my $prog = "git-server";
    $bin = File::Which::which($prog);
    if ($bin) {
        print "$prog found in PATH [$bin]: OK\n";
        $bin = $prog;
    }
    elsif (($bin = $0) =~ s/-verify.*/-server/ and -x $bin) {
        print "git-server outside of PATH [$bin]: FAIR\n";
    }
    else {
        print "git-server program missing: BAD\n";
        $bin = $prog;
    }
}

#2. Verify SSH Method
my $keyfile = "$ENV{HOME}/.ssh/authorized_keys";
if (local $_ = $ENV{HOME} and my @s = stat) {
    if (-d _ and $s[2] & 0700 and not $s[2] & 0022) {
        printf "REMOTE_USER: \$HOME has safe %04o permissions: GOOD\n", ($s[2] & 07777);
    }
    else {
        printf "REMOTE_USER: \$HOME broken %04o permissions: BAD!\n", ($s[2] & 07777);
        system qw[chmod -v u+rwx], $_ if not $s[2] & 0700;
        system qw[chmod -v go-w], $_ if $s[2] & 0022;
    }
    $_ .= "/.ssh";
    if (@s = stat) {
        if (-d _ and $s[2] & 0700 and not $s[2] & 0022) {
            printf "REMOTE_USER: ~/.ssh has safe %04o permissions: GOOD\n", ($s[2] & 07777);
        }
        else {
            printf "REMOTE_USER: ~/.ssh broken %04o permissions: BAD!\n", ($s[2] & 07777);
            system qw[chmod -v u+rwx], $_ if not $s[2] & 0700;
            system qw[chmod -v go-w], $_ if $s[2] & 0022;
        }
        $_ .= "/config";
        if (@s = stat) {
            if (-f _ and $s[2] & 0600 and not $s[2] & 0022) {
                printf "REMOTE_USER: ~/.ssh/config has safe %04o permissions: GOOD\n", ($s[2] & 07777);
            }
            else {
                printf "REMOTE_USER: ~/.ssh/config has dangerous %04o permissions: BAD!\n", ($s[2] & 07777);
                system qw[chmod -v 0600], $_ if -f _;
            }
        }
        else {
            print "REMOTE_USER: ~/.ssh/config does not exist: FINE\n";
        }
        $_ = $keyfile;
        if (@s = stat) {
            if (-f _ and $s[2] & 0600 and not $s[2] & 0022) {
                printf "REMOTE_USER: ~/.ssh/authorized_keys has safe %04o permissions: GOOD\n", ($s[2] & 07777);
            }
            else {
                printf "REMOTE_USER: ~/.ssh/authorized_keys broken %04o permissions: BAD!\n", ($s[2] & 07777);
                system qw[chmod -v 0600], $_ if -f _;
            }
        }
        else {
            print "REMOTE_USER: ~/.ssh/authorized_keys does not exist: BAD!\n";
        }
    }
    else {
        print "REMOTE_USER: ~/.ssh does not exist: BAD!\n";
    }
}
else {
    print "REMOTE_USER: \$HOME $ENV{HOME} does not exist? BAD!\n";
}
my $how = "";
if ($ENV{SSH_USER_AUTH} and open my $auth, "<", $ENV{SSH_USER_AUTH}) {
    $how = $1 if (<$auth>||"") =~ /^publickey (.+)/;
    close $auth;
}
if (my $user = $ENV{REMOTE_USER}) {
    if ($ENV{SERVER_ADDR}) {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: ForceCommand [git-server] configured properly: GOOD\n";
    }
    else {
        if (local $_ = $ENV{SSH_CONNECTION}) {
            ($ENV{REMOTE_ADDR}, $ENV{REMOTE_PORT}, $ENV{SERVER_ADDR}, $ENV{SERVER_PORT}) = split;
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: Broken [ForceCommand] configuration not going through git-server properly: BAD\n";
        }
        chomp($ENV{SERVER_ADDR} = `hostname`) if !$ENV{SERVER_ADDR};
    }
    if ($how) {
        my $key = eval { require Digest::SHA; $how =~ /^\S+\s+(\S+)/ && " [FingerPrint SHA256:".Digest::SHA::sha256_base64($1)."]" } || "";
        my $winner = "";
        my $line = 0;
        if (open my $keys, "<", $keyfile) {
            while (<$keys>) {
                $line++;
                $winner = $1 and last if /^((?:|[^\#].*?\s)\Q$how\E.*)/;
            }
            close $keys;
        }
        if ($winner) {
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: ExposeAuthInfo is enabled: GREAT$key MATCHED!\n";
            print "authorized_keys:$line:[$winner]\n";
        }
        else {
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: ExposeAuthInfo seems half-corrupted: POOR!$key\n";
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: No matching ~/.ssh/authorized_keys line? [$how]\n";
        }
    }
    elsif ($ENV{SSH_USER_AUTH}) {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: ExposeAuthInfo is half-borked: BAD! Set [yes] but no info? Errno=[$!]\n";
    }
    else {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: ExposeAuthInfo is disabled but authentication working: FINE\n";
    }
    if ($ENV{SSH_AUTH_SOCK}) {
        if (my @pubkeys = `ssh-add -l 2>/dev/null`) {
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: AllowAgentForwarding is enabled: OK\n";
        }
        else {
            print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: AllowAgentForwarding is mostly enabled, but no pubkeys forwarded: FINE\n";
        }
    }
    else {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: AllowAgentForwarding is not enabled: FINE\n";
    }
    if ($ENV{XMODIFIERS} && $ENV{XMODIFIERS} =~ /(v|client)=/) {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: AcceptEnv is sufficient: OK\n";
    }
    else {
        # "AcceptEnv XMODIFIERS" is required to support several git-deploy features
        # and to support "-o" (or "-O") GIT_OPTION_* settings in "pre-" hooks
        # and to allow enabling DEBUG if needed.
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: Missing [AcceptEnv XMODIFIERS] on sshd configuration: CRIPPLED SERVER!\n";
    }
    if (my $count = @repo_dirs) {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: Filtering $count specified repos for access: OK\n";
    }
    else {
        print "REMOTE_USER: [$ENV{SERVER_ADDR}] $user: Scanning remote repos with access: OK\n";
    }
}
elsif (local $_ = $ENV{SSH_CONNECTION}) {
    if ($ENV{SSH_ORIGINAL_COMMAND} and my $serv = $ENV{SERVER_ADDR}) {
        print "REMOTE_USER: Half-burnt configuration in [ForceCommand] or [SHELL] or [command=] setting in $serv:$ENV{HOME}/.ssh/authorized_keys: POOR\n";
    }
    else {
        ($ENV{REMOTE_ADDR}, $ENV{REMOTE_PORT}, $ENV{SERVER_ADDR}, $ENV{SERVER_PORT}) = split;
    }

    if (!$how) {
        print "REMOTE_USER: ExposeAuthInfo is disabled: POOR! Enable it for hints on how to better configure $keyfile\n";
    }
    elsif (open my $keys, "<", $keyfile) {
        print "REMOTE_USER: ExposeAuthInfo is enabled: OK\n";
        my $label = "";
        my $line = 0;
        my @matches = ();
        while (<$keys>) {
            $line++;
            my $pos = index($_, $how);
            if ($pos >= 0) {
                $label = $1 if substr($_,$pos+length($how)) =~ /^\s*(\S[^\r\n]*)/;
                next if /^\s*\#/; # Ignore comments
                s/\s*$//;
                push @matches, [$line, $pos, $label, $_];
            }
        }
        close $keys;
        $label ||= "$ENV{USER}\@$ENV{REMOTE_ADDR}";
        (my $hint_user = lc($label =~ /^$ENV{USER}\@(.+)/ || $label =~ /^([^@]+)@/ ? $1 : $label)) =~ s/[^a-z0-9]+/_/g;
        if (@matches) {
            foreach my $k (@matches) {
                my $o = substr($k->[3],0,$k->[1]);
                $o =~ s/\s+$//;
                if ($o =~ /(?:^|,)command="[^\"]*git-server\b[^\"]*"/i and
                    $o =~ /(?:^|,)command="[^\"]*git-server[^\"]* (KEY|REMOTE_USER)=(\w+)\b[^\"]*"/i ||
                    $o =~ /(?:^|,)environment="(KEY|REMOTE_USER)=(\w+)\b[^\"]*"/i) {
                    my $var = $1;
                    my $user = $2;
                    if ($var eq "REMOTE_USER") {
                        print qq{REMOTE_USER: $user: Entry appears fine yet still malfunctioned somehow! [$ENV{SERVER_ADDR}:$keyfile] Line $k->[0]:$k->[3]\n};
                    }
                    else {
                        print "REMOTE_USER: $user: Line $k->[0] should use REMOTE_USER=$user instead of $var=$user: BAD\n";
                    }
                }
                elsif (length $o) {
                    print qq{REMOTE_USER: Preliminary directives exist [$o] but fail to define any REMOTE_USER: BAD! Try something like this in $keyfile Line $k->[0]:\n};
                    print qq{command="$bin REMOTE_USER=$hint_user" $k->[3]\n};
                }
                else {
                    print "REMOTE_USER: No options found! Try something like this in $keyfile Line $k->[0]:\n";
                    print qq{command="$bin REMOTE_USER=$hint_user" $k->[3]\n};
                }
            }
        }
        else {
            print "REMOTE_USER: [$ENV{SERVER_ADDR}:$keyfile] Unrecognized format allowed key but still not found. Try something like this:\n";
            print qq{command="$bin REMOTE_USER=$hint_user" $how $label\n\n};
        }
    }
    else {
        print "REMOTE_USER: BROKEN! Unable to read [$ENV{SERVER_ADDR}:$keyfile] [$!]\n";
    }
}
else {
    if (my $count = @repo_dirs) {
        print "Commandline filtering $count specified repos: OK\n";
    }
    else {
        print "Commandline scanning $ENV{HOME} for repos: OK\n";
    }
}

#3. Scan repos
-d ($ENV{HOME} ||= [getpwuid $<]->[7]) or die "HOME broken";
if (!@repo_dirs and opendir my $fh, $ENV{HOME}) {
    my @nodes = readdir $fh;
    closedir $fh;
    foreach my $node (@nodes) {
        push @repo_dirs, "$ENV{HOME}/$node" if -f "$ENV{HOME}/$node/config" && -d "$ENV{HOME}/$node/refs" && -d "$ENV{HOME}/$node/objects" or -f "$ENV{HOME}/$node/.git/config";
    }
}
my @repo_info;
if (@repo_dirs) {
    my %seen;
    foreach my $repo (@repo_dirs) {
        $repo =~ s{/+$}{}; # Remove trailing slashes.
        next if $repo =~ /\.workingdir$/;
        my $gitdir = undef;
        if (-d $repo) {
            $gitdir = $repo;
        }
        else {
            $repo =~ s/\.git$//;
            my @check = ("$repo.git/.git", "$repo.git", "$repo/.git", $repo);
            if (0 != index $repo, $ENV{HOME}) { # Not starting with $HOME
                push @check, map { "$ENV{HOME}/$_" } @check; # Try with prepended $HOME too
            }
            foreach (@check) {
                $gitdir = $_ and last if -d;
            }
        }
        next if !$gitdir;
        $gitdir = abs_path $gitdir; # Collapse duplicate slashes. Get rid of any "/./" silly sandwiches. Also removes trailing slashes (although should already be gone by now).
        (my $nice = $gitdir) =~ s{(?:/|\.git)+$}{};
        $nice =~ s{^\Q$ENV{HOME}\E/}{} if $ENV{REMOTE_USER}; # Hide $HOME if REMOTE
        next if $seen{$nice}++;
        my $conf = run_output_include_err "git","-C",$gitdir,"config","--list","--local";
        my $info = { nice => $nice, dir => $gitdir, config => $conf };
        my $include = $ENV{REMOTE_USER} ? 0 : 1;
        foreach my $setting (qw[acl.writers acl.readers acl.deploy core.bare proxy.url]) {
            my $name = $setting =~ /^(\w+)\.(\w+)$/ ? $2 : next;
            my $section = $1;
            if ($conf =~ /^\Q$setting\E=(.*)/m) {
                $info->{$name} = $1;
                $info->{permission} ||= $include = $name if !$include and $section eq "acl" and $1 =~ /(^|,)\Q$ENV{REMOTE_USER}\E($|,)/;
            }
            else {
                $info->{$name} = "";
            }
        }
        push @repo_info, $info if $include;
    }
}
print "Found ".scalar(@repo_info)." repos\n";
if (@repo_info) {
    foreach my $info (@repo_info) {
        my $access = $info->{permission} ? " [you have $info->{permission} permission]" : "";
        if ($info->{bare} eq "true") {
            if (my $proxy = $info->{url}) {
                if (-d "$info->{dir}.workingdir") {
                    print "$info->{nice}: Found Bare Repo proxy linked [$proxy]: OK$access\n";
                }
                else {
                    print "$info->{nice}: Found Bare Repo with broken proxy [$proxy]: POOR$access\n";
                }
            }
            else {
                print "$info->{nice}: Found Bare Repo: OK$access\n";
            }
        }
        elsif ($info->{bare} eq "false") {
            if ($info->{config} =~ m{^remote.*url=(.+)}m) {
                print "$info->{nice}: Found Working Dir [$1]: FAIR\n";
            }
            else {
                print "$info->{nice}: Found Non-Bare Hokey Checkout Missing Remote: POOR\n";
            }
        }
        else {
            print "$info->{nice}: Unrecognized Git Repo: BORKED!\n";
        }
    }
}
