the-bastion/bin/plugin/open/sftp
2023-11-08 13:21:20 +01:00

379 lines
12 KiB
Perl
Executable file

#! /usr/bin/env perl
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
use common::sense;
use MIME::Base64;
use IO::Compress::Gzip qw{ gzip };
use Sys::Hostname ();
use File::Basename;
use lib dirname(__FILE__) . '/../../../lib/perl';
use OVH::Result;
use OVH::Bastion;
use OVH::Bastion::Plugin qw( :DEFAULT );
# stdout is used by scp, so ensure we output everything through stderr
local $ENV{'FORCE_STDERR'} = 1;
# don't output fancy stuff, this can get digested by scp and we get garbage output
local $ENV{'PLUGIN_QUIET'} = 1;
my $remainingOptions = OVH::Bastion::Plugin::begin(
argv => \@ARGV,
header => undef,
options => {},
help => \&help,
);
sub help {
delete $ENV{'FORCE_STDERR'};
delete $ENV{'PLUGIN_QUIET'};
osh_header("sftp");
my $bastionCommand = OVH::Bastion::config('bastionCommand')->value;
my $bastionName = OVH::Bastion::config('bastionName')->value;
$bastionCommand =~ s/USER|ACCOUNT/$self/g;
$bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g;
my $hostname = Sys::Hostname::hostname();
$bastionCommand =~ s/HOSTNAME/$hostname/g;
# for sftp, if the bastionCommand contains -t, we need to get rid of it
$bastionCommand =~ s/ -t( |$)/$1/;
# same thing for --
$bastionCommand =~ s/ --/ /;
my $script = <<'END_OF_SCRIPT';
#! /usr/bin/env bash
set -u
shopt -s nocasematch
# <custom-section>
SELF="%SELF%"
BASTION_CMD="%BASTION_CMD%"
VERSION="%VERSION%"
# </custom_section>
: "${BASTION_SFTP_DEBUG:=}"
[ "$BASTION_SFTP_DEBUG" = 1 ] && echo "sftpwrapper: args: $*" >&2
BASTION_SSH_EXTRA_ARGS=""
BASTION_SFTP_EXTRA_ARGS=""
usage() {
cat >&2 <<EOF
usage: $0 [-i identity_file] [-P port] [-o ssh_option] [-b batchfile] [-F ssh_config] [-l limit] [-afprv] destination
EOF
exit 1
}
while [ -n "${1:-}" ]; do
case "$1" in
'-a'|'-f'|'-p'|'-r')
BASTION_SFTP_EXTRA_ARGS="$BASTION_SFTP_EXTRA_ARGS $1"
shift;;
'-v')
BASTION_SSH_EXTRA_ARGS="$BASTION_SSH_EXTRA_ARGS $1"
BASTION_SFTP_EXTRA_ARGS="$BASTION_SFTP_EXTRA_ARGS $1"
shift;;
'-i'|'-o'|'-F')
if [ -z "${2:-}" ]; then
echo "sftpwrapper: missing argument after '$1'" >&2
exit 1
fi
BASTION_SSH_EXTRA_ARGS="$BASTION_SSH_EXTRA_ARGS $1 $2"
BASTION_SFTP_EXTRA_ARGS="$BASTION_SFTP_EXTRA_ARGS $1 $2"
shift 2;;
'-b'|'-l')
if [ -z "${2:-}" ]; then
echo "sftpwrapper: missing argument after '$1'" >&2
exit 1
fi
BASTION_SFTP_EXTRA_ARGS="$BASTION_SFTP_EXTRA_ARGS $1 $2"
shift 2;;
"-P")
if [ -z "${2:-}" ]; then
echo "sftpwrapper: missing argument after '$1'" >&2
exit 1
fi
REMOTE_PORT="$2"
shift 2;;
"-*")
echo "sftpwrapper: unsupported option '$1'" >&2
exit 1;;
*) break;;
esac
done
# here, we should have only destination in $1
[ $# -ne 1 ] && usage
dst="${1:-}"
if [[ $dst =~ ^(sftp://)?(([^@:/]+)@)?([^@:/]+)(:([0-9]+))?(/(.+))?$ ]]; then
REMOTE_USER="${BASH_REMATCH[3]:-$SELF}"
REMOTE_HOST="${BASH_REMATCH[4]}"
REMOTE_PORT="${BASH_REMATCH[6]:-22}"
REMOTE_PATH="${BASH_REMATCH[7]}"
[ "$BASTION_SFTP_DEBUG" = 1 ] && echo "sftpwrapper: parsed user=$REMOTE_USER host=$REMOTE_HOST port=$REMOTE_PORT path=$REMOTE_PATH" >&2
else
echo "sftpwrapper: couldn't parse destination '$dst'" >&2
exit 1
fi
if [ -z "${REMOTE_HOST:-}" ]; then
echo "sftpwrapper: no remote host found in your command '$dst'" >&2
exit 1
fi
t=$(mktemp)
# shellcheck disable=SC2064
trap "rm -f $t" EXIT
# shellcheck disable=SC2086
[ "$BASTION_SFTP_DEBUG" = 1 ] && set -x
$BASTION_CMD -t $BASTION_SSH_EXTRA_ARGS -- --osh sftp --host "$REMOTE_HOST" --port "$REMOTE_PORT" --user "$REMOTE_USER" --generate-mfa-token | tee "$t"
[ "$BASTION_SFTP_DEBUG" = 1 ] && set +x
token=$(grep -Eo '^MFA_TOKEN=[a-zA-Z0-9,]+' "$t" | tail -n 1 | cut -d= -f2)
if [ -z "$token" ]; then
echo "sftpwrapper: Couldn't get an MFA token, aborting." >&2
exit 1
fi
# now craft the wrapper to be used by sftp through -S
cat >"$t" <<'EOF'
#! /usr/bin/env bash
shopt -s nocasematch
REMOTE_USER=${USER:-}
REMOTE_PORT=22
sshcmdline=""
[ "$BASTION_SFTP_DEBUG" = 1 ] && echo "sftphelper: args: $*" >&2
while ! [ "${1:-}" = "--" ] ; do
# handle several ways of specifying remote user
if [ "${1:-}" = "-l" ] ; then
REMOTE_USER="${2:-}"
shift 2
elif [[ ${1:-} =~ ^-oUser[=\ ]([^\ ]+)$ ]] ; then
REMOTE_USER="${BASH_REMATCH[1]}"
shift
elif [ "${1:-}" = "-o" ] && [[ ${2:-} =~ ^user=([0-9]+)$ ]] ; then
REMOTE_USER="${BASH_REMATCH[1]}"
shift 2
# handle several ways of specifying remote port
elif [ "${1:-}" = "-p" ] ; then
REMOTE_PORT="${2:-22}"
shift 2
elif [[ ${1:-} =~ ^-oPort[=\ ]([0-9]+)$ ]] ; then
REMOTE_PORT="${BASH_REMATCH[1]}"
shift
elif [ "${1:-}" = "-o" ] && [[ $2 =~ ^port=([0-9]+)$ ]] ; then
REMOTE_PORT="${BASH_REMATCH[1]}"
shift 2
# other '-oFoo Bar'
elif [[ ${1:-} =~ ^-o([^\ ]+)\ (.+)$ ]] ; then
sshcmdline="$sshcmdline -o${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
shift
# don't forward -s
elif [ "$1" = "-s" ]; then
shift
# other stuff passed directly to ssh
else
sshcmdline="$sshcmdline $1"
shift
fi
done
[ "$BASTION_SFTP_DEBUG" = 1 ] && echo "sftphelper: remaining args: $*" >&2
# sane default
: "${REMOTE_USER:-$USER}"
[ -z "$REMOTE_USER" ] && REMOTE_USER="$(whoami)"
# after '--', remaining args are always host then 'sftp'
REMOTE_HOST="$2"
subsystem="$3"
if [ "$subsystem" != sftp ]; then
echo "Unknown subsystem requested '$subsystem', expected 'sftp'" >&2
exit 1
fi
# if host is in the form REMOTE_USER@REMOTE_HOST, split it
if [[ $REMOTE_HOST =~ @ ]]; then
REMOTE_USER="${REMOTE_HOST%@*}"
REMOTE_HOST="${REMOTE_HOST#*@}"
fi
# and go
[ "$BASTION_SFTP_DEBUG" = 1 ] && set -x
EOF
echo "exec $BASTION_CMD -T \$sshcmdline $BASTION_SSH_EXTRA_ARGS -- --user \"\$REMOTE_USER\" --port \"\$REMOTE_PORT\" --host \"\$REMOTE_HOST\" --osh sftp --mfa-token $token" >> "$t"
chmod +x "$t"
# don't use exec below, because we need the trap to be executed on exit
export BASTION_SFTP_DEBUG
[ "$BASTION_SFTP_DEBUG" = 1 ] && set -x
sftp $BASTION_SFTP_EXTRA_ARGS -S "$t" sftp://"$REMOTE_USER"@"$REMOTE_HOST":"$REMOTE_PORT""$REMOTE_PATH"
END_OF_SCRIPT
$script =~ s{%BASTION_CMD%}{$bastionCommand}g;
$script =~ s{%SELF%}{$self}g;
$script =~ s{%VERSION%}{$OVH::Bastion::VERSION}g;
my $compressed = '';
gzip \$script => \$compressed;
my $base64 = encode_base64($compressed);
chomp $base64;
osh_info <<"EOF";
Description:
Transfers files to/from a host through the bastion
Usage:
To use sftp through the bastion, you need a wrapper script to use instead of
calling your sftp client directly. It'll be specific to your account and this bastion,
don't share it with others! To download your customized script, copy/paste this command:
EOF
print "\necho \"$base64\"|base64 -d|gunzip -c > ~/sftp-via-$bastionName \\\n"
. "&& chmod +x ~/sftp-via-$bastionName\n\n";
osh_info <<"EOF";
To use sftp through this bastion, use this script instead of your regular sftp command.
For example:
\$ ~/sftp-via-$bastionName login\@server:port
The following environment variables modify the behavior of the script:
- `BASTION_SFTP_DEBUG`: if set to 1, debug info is printed on the console
- `BASTION_SFTP_EXTRA_ARGS`: if set, the contents of this variable is added
to the resulting sftp command called by the script
- `BASTION_SSH_EXTRA_ARGS`: if set, the contents of this variable is added
to the resulting ssh commands used in the script
For example:
\$ BASTION_SFTP_DEBUG=1 BASTION_SSH_EXTRA_ARGS="-v" ~/sftp-via-$bastionName login\@server
Please note that you need to be granted to be allowed to use sftp to the remote host,
in addition to having the right to SSH to it.
For a group, the right should be added with --sftp of the groupAddServer command.
For a personal access, the right should be added with --sftp of the selfAddPersonalAccess command.
EOF
osh_ok({script => $base64, "content-encoding" => 'base64-gzip'});
return 0;
}
#
# code
#
my $fnret;
if (not $host) {
help();
osh_exit;
}
if (not $ip) {
# note that the calling-side sftp will not passthrough this exit code, but most probably "1" instead.
osh_exit 'ERR_HOST_NOT_FOUND', "Sorry, couldn't resolve the host you specified ('$host'), aborting.";
}
my $machine = $ip;
$machine = "$user\@$ip" if $user;
$port ||= 22; # sftp uses 22 if not specified, so we need to test access to that port and not any port (aka undef)
$user ||= $self; # same for user
$machine .= ":$port";
my %keys;
osh_debug("Checking access 1/2 of $self to $machine...");
$fnret = OVH::Bastion::is_access_granted(
account => $self,
user => $user,
ipfrom => $ENV{'OSH_IP_FROM'},
ip => $ip,
port => $port,
details => 1
);
if (not $fnret) {
osh_exit 'ERR_ACCESS_DENIED', "Sorry, but you don't seem to have access to $machine";
}
# get the keys we would try
foreach my $access (@{$fnret->value || []}) {
foreach my $key (@{$access->{'sortedKeys'} || []}) {
my $keyfile = $access->{'keys'}{$key}{'fullpath'};
$keys{$keyfile}++ if -r $keyfile;
osh_debug("Checking access 1/2 keyfile: $keyfile");
}
}
my $userToCheck = '!sftp';
osh_debug("Checking access 2/2 of $self to $userToCheck of $machine...");
$fnret = OVH::Bastion::is_access_granted(
account => $self,
user => $userToCheck,
ipfrom => $ENV{'OSH_IP_FROM'},
ip => $ip,
port => $port,
exactUserMatch => 1,
details => 1
);
if (not $fnret) {
osh_exit 'ERR_ACCESS_DENIED',
"Sorry, but even if you have ssh access to $machine, you still need to be granted specifically for sftp";
}
# get the keys we would try too
foreach my $access (@{$fnret->value || []}) {
foreach my $key (@{$access->{'sortedKeys'} || []}) {
my $keyfile = $access->{'keys'}{$key}{'fullpath'};
$keys{$keyfile}++ if -r $keyfile;
osh_debug("Checking access 2/2 keyfile: $keyfile");
}
}
# now build the command
my @cmd = qw{ ssh -x -oForwardAgent=no -oPermitLocalCommand=no -oClearAllForwardings=yes };
push @cmd, ('-p', $port) if $port;
push @cmd, ('-l', $user) if $user;
push @cmd, '-s';
my $atleastonekey = 0;
foreach my $keyfile (keys %keys) {
# only use the key if it has been seen in both allow_deny() calls, this is to avoid
# a security bypass where a user would have group access to a server, but not to the
# !sftp special user, and we would add himself this access through selfAddPrivateAccess.
# in that case both allow_deny would return OK, but with different keys.
# we'll only use the keys that matched BOTH calls.
next unless $keys{$keyfile} == 2;
push @cmd, ('-i', $keyfile);
$atleastonekey = 1;
}
if (not $atleastonekey) {
osh_exit('KO_ACCESS_DENIED',
"Sorry, you seem to have access through ssh and through sftp but by different and distinct means (distinct keys)."
. " The intersection between your rights for ssh and for sftp needs to be at least one.");
}
push @cmd, "--", $ip, 'sftp';
print STDERR ">>> Hello $self, starting up sftp subsystem to $machine...\n";
$fnret = OVH::Bastion::execute(cmd => \@cmd, expects_stdin => 1, is_binary => 1);
if ($fnret->err ne 'OK') {
osh_exit 'ERR_TRANSFER_FAILED', "Error launching transfer: $fnret";
}
print STDERR ">>> Done, "
. $fnret->value->{'bytesnb'}{'stdin'}
. " bytes uploaded, "
. $fnret->value->{'bytesnb'}{'stdout'}
. " bytes downloaded.\n";
if ($fnret->value->{'sysret'} != 0) {
print STDERR ">>> On bastion side, sftp exited with return code " . $fnret->value->{'sysret'} . ".\n";
}
# don't use osh_exit() to avoid getting a footer
exit OVH::Bastion::EXIT_OK;