Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

sshx - Run a Local Script over SSH with Interactivity

Sometimes you want to run a bit more than just one command over ssh, and you also want to maintain interactivity to the processes that you launch. Usually you could just do ssh -t <host> "foo; bar; fish; paste" however that can quickly become unwieldly, especialy if it is more than just a sequence of commands. You also can't use cat my_script | ssh <host> /bin/bash because then you loose your stdin and thus your interactivity.

However, there is a way to achieve our goal using the first form above. Consider that the maximum command line length is usually more than 2MB (see getconf ARG_MAX). Thus, we convert our entire script into a base64 encoded string with cat my_script | base64 and then call ssh -t <host> /bin/bash "<(echo <base64 string> | base64 --decode)". In this way the entire script is sent to the host in the command buffer, and so long as our total command length is less than 2MB, it will run.

Note: the usage of the <(...) (Process Substitution), and the quotes to prevent it from running locally.

So, now expressing that in full form would be:

ssh -t <host> /bin/bash "<(echo "$(cat my_script | base64)" | base64 --decode)" <arg1> ...

Though to avoid using the echo, which feels a bit klunky, lets rather use a <<EOF (Here Document) with cat and also use $'...' (ANSI C Quoting) for precise control, which gives us:

ssh -t <host> /bin/bash $'<(cat<<_ | base64 --decode\n'$(cat my_script | base64 | tr -d "\n")$'\n_\n)' <arg1> ...

Note: the tr -d "\n" is helpful as some base64 decode implementations will fail on the whitespace. I used a _ as the EOF marker as it is not a character in the base64 set.

This idea is developing quite nicely, however it still falls short in some ways. What if say we want to run a Python script? We'd have to change to ssh -t <host> /usr/bin/env python .... And if we wanted to provide arguments to our script that are actually input files, how would we get those across the wire?

Considering the above and more, after some further tinkering we can arrive at sshx (see full source below) which will marshall up an entire command line and run it over ssh as follows.

Say we have a Python script called "foo":

#!/usr/bin/env python
import sys
arg1 = sys.argv[1]
print "The contents of arg1:" 
sys.stdout.write(open(arg1).read())

And a file called "hi":

Hello World!

To run $ foo hi on a remote host we could generate the following BASH fragment:

export SCRATCH=$(TMPDIR=$HOME mktemp -d -t .scratch.XXXXXXXX)
trap "{ rm -rf \"$SCRATCH\"; }" EXIT SIGINT SIGTERM SIGKILL
chmod 700 $SCRATCH
# cmd: foo hi
# file: "foo" -> "$SCRATCH/foo"
cat<<_ |base64 --decode >"$SCRATCH/foo"; chmod 755 "$SCRATCH/foo"; touch -d @1557867114 "$SCRATCH/foo"
IyEvdXNyL2Jpbi9lbnYgcHl0aG9uCmltcG9ydCBzeXMKYXJnMSA9IHN5cy5hcmd2WzFdCnByaW50ICJUaGUgY29udGVudHMgb2YgYXJnMToiIApzeXMuc3Rkb3V0LndyaXRlKG9wZW4oYXJnMSkucmVhZCgpKQo=
_
# file: "hi" -> "$SCRATCH/hi"
cat<<_ |base64 --decode >"$SCRATCH/hi"; chmod 644 "$SCRATCH/hi"; touch -d @1557867136 "$SCRATCH/hi"
SGVsbG8gV29ybGQhCg==
_
$SCRATCH/foo $SCRATCH/hi 

Then we further base64 encode this marshalled command and use the above technique to run the command over ssh.

sshx:

#!/bin/bash

_realpath() {
    [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"
}

_stat_mode() {
	if [[ "$OSTYPE" == darwin* ]]; then
	    stat -f %Lp "$1"
	else
	    stat --format %a "$1"
	fi
}

_stat_mtime() {
	if [[ "$OSTYPE" == darwin* ]]; then
		stat -f '%m' "$1"
	else
		stat -c '%Y' "$1"
	fi
}

cat_base64_file() {
    local FILE=${1:?[?FILE]}
    echo "cat<<_ |base64 --decode" "${@:2}"
    cat "$FILE" | base64
    echo "_"
}

is_marshalable_file() {
    local FILE=${1:?[?FILE]}
    if [ -e "$FILE" -a ! -d "$FILE" ] && [[ "$FILE" =~ ^/dev/fd/* || "$(_realpath "$FILE")" =~ ^/tmp/*|^/home/*|^/Users/* ]]; then
        return 0
    else
        return 1
    fi
}

match_count() {
    local HAYSTACK=${1:?[?HAYSTACK]}
    local NEEDLE=${2:?[?NEEDLE]}
    local X=${HAYSTACK//$NEEDLE}
    echo $(((${#HAYSTACK} - ${#X}) / ${#NEEDLE}))
}

cat_marshalled_command() {
	cat <<"EOF"
export SCRATCH=$(TMPDIR=$HOME mktemp -d -t .sshx-scratch.XXXXXXXX)
trap "{ rm -rf \"$SCRATCH\"; }" EXIT SIGINT SIGTERM SIGKILL
chmod 700 $SCRATCH
EOF

    echo "# cmd: ${@}"
    local ARGS=""
    local FILES=" "
    while [ -n "$1" ]; do
        if is_marshalable_file "$1"; then
            if [[ "$1" == /dev/fd/* ]]; then
                FILENAME="~dev~fd~$(basename "$1")"
                MODE="500"
            elif [ -p "$1" ]; then
                FILENAME="$(basename "$1")"
                MODE="500"
            else
                FILENAME="$(basename "$1")"
                MODE=$(_stat_mode "$1")
            fi
            FILE='$SCRATCH/'$FILENAME
            MTIME=$(_stat_mtime "$1")

            # Count the repeated file names and suffix index as necessary
            COUNT=$(match_count "$FILES" "$FILE")
            FILES=$FILES"$FILE "
            if [ "$COUNT" != "0" ]; then
                FILE=$FILE"~$COUNT"
            fi

            echo "# file: \"$1\" -> \"$FILE\""
            cat_base64_file "$1" ">\"$FILE\"; chmod ${MODE} \"$FILE\"; touch -d @$MTIME \"$FILE\"" 

            ARGS=$ARGS"$FILE "
        else
            ARGS=$ARGS$(printf '%q ' "$1")
        fi
        shift
    done

    cat <<EOF
$ARGS
EOF
}

sshx() {
    HOST=${1:?[?HOST]}
    if [ "$HOST" == "stdout" ]; then
    	cat_marshalled_command "${@:2}"
    	exit 0
    fi
    unset TTY; [ -t 1 ] && TTY="-t"
    ssh $TTY $HOST \
        /bin/bash $'<(cat<<_|base64 --decode\n'$(cat_marshalled_command "${@:2}"|base64|tr -d "\n")$'\n_\n)'
}

sshx "${@}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.