You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
the-bastion/bin/plugin/open/scp

367 lines
12 KiB

#! /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 );
use OVH::Bastion::Plugin::otherProtocol;
# 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 ($scpCmd);
my $remainingOptions = OVH::Bastion::Plugin::begin(
argv => \@ARGV,
header => undef,
options => {'scp-cmd=s' => \$scpCmd},
help => \&help,
);
sub help {
delete $ENV{'FORCE_STDERR'};
delete $ENV{'PLUGIN_QUIET'};
osh_header("scp");
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 scp, 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
# <custom-section>
SELF="%SELF%"
BASTION_CMD="%BASTION_CMD%"
VERSION="%VERSION%"
# </custom_section>
: "${BASTION_SCP_DEBUG:=}"
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: args: $*" >&2
BASTION_SSH_EXTRA_ARGS=""
BASTION_SCP_EXTRA_ARGS=""
LOCAL_PATH=""
REMOTE_HOST=""
REMOTE_PORT=22
REMOTE_USER=""
REMOTE_PATH=""
usage() {
cat >&2 <<EOF
Usage: $0 [-p] [-q] [-r] [-T] [-v] [-l limit] [-i identity_file] [-P port] [-o ssh_option] source target
Please refer to the scp manpage for details about each command-line option above.
EOF
exit 1
}
while [ -n "${1:-}" ]; do
case "$1" in
"-i"|"-o")
if [ -z "${2:-}" ]; then
echo "scpwrapper: missing argument after '$1'" >&2
exit 1
fi
if [ "$1" = "-o" ] && [[ $2 =~ ^[Uu][Ss][Ee][Rr]=(.+) ]]; then
REMOTE_USER="${BASH_REMATCH[1]}"
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: using '$REMOTE_USER' as remote user found through -o" >&2
else
BASTION_SSH_EXTRA_ARGS="$BASTION_SSH_EXTRA_ARGS $1 $2"
BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS $1 $2"
fi
shift 2;;
"-P"|"-l")
if [ -z "${2:-}" ]; then
echo "scpwrapper: missing argument after '$1'" >&2
exit 1
elif ! echo "$2" | grep -qE '^[0-9]+$'; then
echo "scpwrapper: argument after '$1' should be a number (got '$2')" >&2
exit 1
elif [ "$1" = "-P" ]; then
REMOTE_PORT="$2"
elif [ "$1" = "-l" ]; then
BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -l $2"
else
echo "scpwrapper: unmanaged case for '$1'" >&2
exit 1
fi
shift 2;;
"-r"|"-p"|"-q"|"-T"|"-v")
BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS $1"
shift;;
"-*")
echo "scpwrapper: unsupported option '$1'" >&2
exit 1;;
*) break;;
esac
done
# here, we should have SRC in $1, and DST in $2
[ $# -ne 2 ] && usage
src="${1:-}"
dst="${2:-}"
way=""
if [[ $src =~ : ]]; then
# it's a download
if [[ $src =~ ^(([^@:]*)@)?([^@:]+):(.*)?$ ]]; then
[ -z "$REMOTE_USER" ] && REMOTE_USER="${BASH_REMATCH[2]:-$SELF}"
REMOTE_HOST="${BASH_REMATCH[3]}"
REMOTE_PATH="${BASH_REMATCH[4]}"
else
echo "scpwrapper: couldn't parse source '$src'" >&2
exit 1
fi
LOCAL_PATH="$dst"
way=download
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: will download '$REMOTE_PATH' to local path '$LOCAL_PATH' from '$REMOTE_HOST' port '$REMOTE_PORT' as account '$REMOTE_USER'" >&2
elif [[ $dst =~ : ]]; then
# it's an upload
if [[ $dst =~ ^(([^@:]*)@)?([^@:]+):(.*)?$ ]]; then
[ -z "$REMOTE_USER" ] && REMOTE_USER="${BASH_REMATCH[2]:-$SELF}"
REMOTE_HOST="${BASH_REMATCH[3]}"
REMOTE_PATH="${BASH_REMATCH[4]}"
else
echo "scpwrapper: couldn't parse destination '$dst'" >&2
exit 1
fi
LOCAL_PATH="$src"
way=upload
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: will upload '$LOCAL_PATH' to remote path '$REMOTE_PATH' on '$REMOTE_HOST' port '$REMOTE_PORT' as account '$REMOTE_USER'" >&2
else
echo "scpwrapper: no remote host found in your command '$src $dst'" >&2
exit 1
fi
t=$(mktemp)
# we don't expand $t here because it might change during the course of the script
trap 'rm -f $t' EXIT
# verify that the tmpfile was created in a spot that's NOT mounted in noexec
chmod +x "$t"
if ! "$t" 2>/dev/null; then
# oops, noexec => default to user's HOME directory instead
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scpwrapper: current TMPDIR is in noexec ($t), changing to user's home instead" >&2
rm -f "$t"
t=$(TMPDIR="$HOME" mktemp)
else
chmod -x "$t"
fi
# shellcheck disable=SC2086
[ "$BASTION_SCP_DEBUG" = 1 ] && set -x
$BASTION_CMD -t $BASTION_SSH_EXTRA_ARGS -- --osh scp --host "$REMOTE_HOST" --port "$REMOTE_PORT" --user "$REMOTE_USER" --generate-mfa-token | tee "$t"
[ "$BASTION_SCP_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 "scpwrapper: Couldn't get an MFA token, aborting." >&2
exit 1
fi
# detect whether we need '-O' for this scp or not
t2=$(mktemp)
if scp -O "$t2" "$t" >/dev/null 2>&1; then
BASTION_SCP_EXTRA_ARGS="$BASTION_SCP_EXTRA_ARGS -O"
fi
rm -f "$t2"
# now craft the wrapper to be used by scp through -S
cat >"$t" <<'EOF'
#! /usr/bin/env bash
REMOTE_USER=${USER:-}
REMOTE_PORT=22
sshcmdline=""
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scphelper: args: $*" >&2
while ! [ "${1:-}" = "--" ] ; do
# handle several ways of specifying remote user
if [ "${1:-}" = "-l" ] ; then
REMOTE_USER="${2:-}"
shift 2
elif [ "${1:-}" = "-o" ] && [[ "${2:-}" =~ ^[Uu][Ss][Ee][Rr]=(.+) ]]; then
REMOTE_USER="${BASH_REMATCH[1]}"
shift 2
elif [ "${1:-}" = "-p" ] ; then
REMOTE_PORT="${2:-}"
shift 2
elif [ "${1:-}" = "-s" ]; then
# caller is a newer scp that tries to use the sftp subsystem
# instead of plain old scp, warn because it won't work
echo "scphelper: WARNING: your scp version is recent, you need to add '-O' to your scp command-line, exiting." >&2
exit 1
else
sshcmdline="$sshcmdline $1"
shift
fi
done
[ "$BASTION_SCP_DEBUG" = 1 ] && echo "scphelper: remaining args: $*" >&2
# sane default
: "${REMOTE_USER:-$USER}"
[ -z "$REMOTE_USER" ] && REMOTE_USER="$(whoami)"
REMOTE_HOST="$2"
scpcmd=$(echo "$3" | sed -e 's/#/##/g;s/ /#/g')
# and go
[ "$BASTION_SCP_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 scp --scp-cmd \"\$scpcmd\" --mfa-token $token" >> "$t"
chmod +x "$t"
# don't use exec below, because we need the trap to be executed on exit
export BASTION_SCP_DEBUG
[ "$BASTION_SCP_DEBUG" = 1 ] && set -x
case "$way" in
upload) scp $BASTION_SCP_EXTRA_ARGS -S "$t" -P "$REMOTE_PORT" -o User="$REMOTE_USER" "$LOCAL_PATH" "$REMOTE_HOST":"$REMOTE_PATH";;
download) scp $BASTION_SCP_EXTRA_ARGS -S "$t" -P "$REMOTE_PORT" -o User="$REMOTE_USER" "$REMOTE_HOST":"$REMOTE_PATH" "$LOCAL_PATH";;
esac
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 scp through the bastion, you need a wrapper script to use instead of
calling your scp 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 > ~/scp-via-$bastionName && chmod +x ~/scp-via-$bastionName\n\n";
osh_info <<"EOF";
To use scp through this bastion, use this script instead of your regular scp command.
For example, to upload a file to a remote server with ssh listening on port 222 there:
\$ ~/scp-via-$bastionName -i ~/.ssh/mysshkey -P 222 localfile login\@server:/dest/folder/
Or to recursively download a folder contents:
\$ ~/scp-via-$bastionName -i ~/.ssh/mysshkey -r login\@server:/src/folder/ /tmp/
The following environment variables modify the behavior of the script:
- `BASTION_SCP_DEBUG`: if set to 1, debug info is printed on the console
- `BASTION_SCP_EXTRA_ARGS`: if set, the contents of this variable is added
to the resulting scp 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_SCP_DEBUG=1 BASTION_SSH_EXTRA_ARGS="-v" ~/scp-via-$bastionName file1 login\@srv:/tmp
Please note that you need to be granted for uploading or downloading files
with scp to/from the remote host, in addition to having the right to SSH to it.
For a group, the right should be added with --protocol scpupload/--protocol scpdownload of the groupAddServer command.
For a personal access, the right should be added with --protocol scpupload/--protocol scpdownload 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 scp 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.";
}
# decode the passed scp command
my $decoded = $scpCmd;
$decoded =~ s/(?<!#)#(?!#)/ /g;
$decoded =~ s/##/#/g;
if ($decoded !~ /^(?:scp )(?:.*)-([tf]) (?:.+)$/) {
# security
osh_exit 'ERR_SECURITY_VIOLATION', "scp command format unrecognized";
}
my $protocol = $1 eq 't' ? 'scpupload' : 'scpdownload'; ## no critic (CaptureWithoutTest) ## false positive
# basic mitigation for CVE-2020-15778
if ($decoded =~ m{[\`\$\;><\|\&]}) {
osh_exit('ERR_SECURITY_VIOLATION', "Invalid characters detected, bailing out");
}
$port ||= 22; # scp 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
$fnret = OVH::Bastion::Plugin::otherProtocol::has_protocol_access(
account => $self,
user => $user,
ip => $ip,
port => $port,
protocol => $protocol,
);
$fnret or osh_exit($fnret);
my $machine = $fnret->value->{'machine'};
my @keys = @{$fnret->value->{'keys'} || []};
# 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;
foreach my $key (@keys) {
push @cmd, ('-i', $key);
}
push @cmd, "--", $ip, $decoded;
print STDERR ">>> Hello $self, transferring your file through the bastion "
. ($protocol eq 'scpupload' ? 'to' : 'from')
. " $machine...\n";
# Use system here and in plugin's json config to avoid buffering deadlock issues (#486)
$fnret = OVH::Bastion::execute(cmd => \@cmd, system => 1);
if ($fnret->err ne 'OK') {
osh_exit 'ERR_TRANSFER_FAILED', "Error launching transfer: $fnret";
}
if ($fnret->value->{'sysret'} != 0) {
print STDERR ">>> On bastion side, scp exited with return code " . $fnret->value->{'sysret'} . ".\n";
}
else {
print STDERR ">>> Done, scp exited successfully.\n";
}
# don't use osh_exit() to avoid getting a footer
exit OVH::Bastion::EXIT_OK;