How to execute an arbitrary simple command over ssh without knowing the login shell of the remote user?

ssh has an annoying feature in that when you run:

ssh user@host cmd and "here's" "one arg"

Instead of running that cmd with its arguments on host, it concatenates that cmd and arguments with spaces and runs a shell on host to interpret the resulting string (I guess that’s why its called ssh and not sexec).

Worse, you don’t know what shell is going to be used to interpret that string as that’s the login shell of user which is not even guaranteed to be Bourne like as there are still people using tcsh as their login shell and fish is on the rise.

Is there a way around that?

Suppose I have a command as a list of arguments stored in a bash array, each of which may contain any sequence of non-null bytes,
is there any way to have it executed on host as user in a consistent way regardless of the login shell of that user on host (which we’ll assume is one of the major Unix shell families: Bourne, csh, rc/es, fish)?

Another reasonable assumption that I should be able to make is that there be a sh command on host available in $PATH that is Bourne-compatible.

Example:

cmd=(
  'printf'
  '<%s>n'
  'arg with $and spaces'
  '' # empty
  $'evenn* * *nnewlines'
  "and 'single quotes'"
  '!!'
)

I can run it locally with ksh/zsh/bash/yash as:

$ "${cmd[@]}"
<arg with $and spaces>
<>
<even
* * *
newlines>
<and 'single quotes'>
<!!>

or

env "${cmd[@]}"

or

xterm -hold -e "${cmd[@]}"
...

How would I run it on host as user over ssh?

ssh user@host "${cmd[@]}"

obviously won’t work.

ssh user@host "$(printf ' %q' exec "${cmd[@]}")"

would only work if the login shell of the remote user was the same as the local shell (or understands quoting in the same way as printf %q in the local shell produces it) and runs in the same locale.

I don’t think any implementation of ssh has a native way to pass a command from client to server without involving a shell.

Now, things can get easier if you can tell the remote shell to only run a specific interpreter (like sh, for which we know the expected syntax) and give the code to execute by another mean.

That other mean can be for instance standard input or an environment variable.

When neither can be used, I propose a hacky third solution below.

Using stdin

If you don’t need to feed any data to the remote command, that’s the easiest solution.

If you know the remote host has an xargs command that supports the -0 option and the command is not too large, you can do:

printf '%s' "${cmd[@]}" | ssh user@host 'xargs -0 env --'

That xargs -0 env -- command line is interpreted the same with all those shell families. xargs reads the null-delimited list of arguments on stdin and passes those as arguments to env. That assumes the first argument (the command name) does not contain = characters.

Or you can use sh on the remote host after having quoted each element using sh quoting syntax.

shquote() {
  LC_ALL=C awk -v q=' '
    BEGIN{
      for (i=1; i<ARGC; i++) {
        gsub(q, q "\" q q, ARGV[i])
        printf "%s ", q ARGV[i] q
      }
      print ""
    }' "$@"
}
shquote "${cmd[@]}" | ssh user@host sh

Or just:

print -r -- ${(qq)cmd} | ssh user@host sh

If using zsh as the local shell.

Using environment variables

Now, if you do need to feed some data from the client to the remote command’s stdin, the above solution won’t work.

Some ssh server deployments however allow passing of arbitrary environment variables from the client to the server. For instance, many openssh deployments on Debian based systems allow passing variables whose name starts with LC_.

In those cases you could have a LC_CODE variable for instance containing the shquoted sh code as above and run sh -c 'eval "$LC_CODE"' on the remote host after having told your client to pass that variable (again, that’s a command-line that’s interpreted the same in every shell):

LC_CODE=$(shquote "${cmd[@]}") ssh -o SendEnv=LC_CODE user@host '
  sh -c '''eval "$LC_CODE"''

Or:

LC_CODE=${(qqj[ ])cmd} ssh -o SendEnv=LC_CODE user@host '
  sh -c '''eval "$LC_CODE"''

in zsh.

Building a command line compatible to all shell families

If none of the options above are acceptable (because you need stdin and sshd doesn’t accept any variable, or because you need a generic solution), then you’ll have to prepare a command line for the remote host that is compatible with all supported shells.

That is particularly tricky because all those shells (Bourne, csh, rc, es, fish) have their own different syntax, and in particular different quoting mechanisms and some of them have limitations that are hard to work around.

Here is a solution I came up with, I describe it further down:

#! /usr/bin/perl
my $arg, @ssh, $preamble =
q{printf '%.0s' "'";set x=! b=\\;setenv n "
";set q=';printf %.0s """'"';q='''';n=``()echo;x=!;b=''
printf '%.0s' ''';set b \\;set x !;set -x n n;set q '
printf '%.0s' ''' #'""'";export n;x=!;b=\\;IFS=.;set `echo;echo .`;n=$1 IFS= q='
};

@ssh = ('ssh');
while ($arg = shift @ARGV and $arg ne '--') {
  push @ssh, $arg;
}

if (@ARGV) {
  for (@ARGV) {
    s/'/'$q$b$q$q'/g;
    s/n/'$q'$n'$q'/g;
    s/!/'$x'/g;
    s/\/'$b'/g;
    $_ = "$q'$_'$q";
  }
  push @ssh, "${preamble}exec sh -c 'IFS=;exec '" . join "' '", @ARGV;
}

exec @ssh;

That’s a perl wrapper script around ssh. I call it sexec. You call it like:

sexec [ssh-options] user@host -- cmd and its args

so in your example:

sexec user@host -- "${cmd[@]}"

And the wrapper turns cmd and its args into a command line that all shells end up interpreting as calling cmd with its args (regarless of their content).

Limitations:

  • The preamble and the way the command is quoted means the remote command line ends up being significantly larger which means the limit on the maximum size of a command line will be reached sooner.
  • I’ve only tested it with: Bourne shell (from heirloom toolchest), dash, bash, zsh, mksh, lksh, yash, ksh93, rc, es, akanga, csh, tcsh, fish as found on a recent Debian system and /bin/sh, /usr/bin/ksh, /bin/csh and /usr/xpg4/bin/sh on Solaris 10.
  • If yash is the remote login shell, you can’t pass a command whose arguments contain invalid characters, but that’s a limitation in yash that you can’t work around anyway.
  • Some shells like csh or bash read some startup files when invoked over ssh. We assume those don’t change the behaviour dramatically so that the preamble still works.
  • beside sh, it also assumes the remote system has the printf command.

To understand how it works, you need to know how quoting works in the different shells:

  • Bourne: '...' are strong quotes with no special character in it. "..." are weak quotes where " can be escaped with backslash.
  • csh. Same as Bourne except that " cannot be escaped inside "...". Also a newline character has to be entered prefixed with a backslash. And ! causes problems even inside single quotes.
  • rc. The only quotes are '...' (strong). A single quote within single quotes is entered as '' (like '...''...'). Double quotes or backslashes are not special.
  • es. Same as rc except that outside quotes, backslash can escape a single quote.
  • fish: same as Bourne except that backslash escapes ' inside '...'.

With all those contraints, it’s easy to see that one cannot reliably quote command line arguments so that it works with all shells.

Using single quotes as in:

'foo' 'bar'

works in all but:

'echo' 'It'''s'

would not work in rc.

'echo' 'foo
bar'

would not work in csh.

'echo' 'foo'

would not work in fish.

However we should be able to work around most of those problems if we manage to store those problematic characters in variables, like backslash in $b, single quote in $q, newline in $n (and ! in $x for csh history expansion) in a shell independant way.

'echo' 'It'$q's'
'echo' 'foo'$b

would work in all shells. That would still not work for newline for csh though. If $n contains newline, in csh, you have to write it as $n:q for it to expand to a newline and that won’t work for other shells. So, what we end-up doing instead here is calling sh and have sh expand those $n. That also means having to do two levels of quoting, one for the remote login shell, and one for sh.

The $preamble in that code is the trickiest part. It makes use of the various different quoting rules in all shells to have some sections of the code interpreted by only one of the shells (while it’s commented out for the others) each of which just defining those $b, $q, $n, $x variables for their respective shell.

Here’s the shell code that would be interpreted by the login shell of the remote user on host for your example:

printf '%.0s' "'";set x=! b=\;setenv n "
";set q=';printf %.0s """'"';q='''';n=``()echo;x=!;b=''
printf '%.0s' ''';set b \;set x !;set -x n n;set q '
printf '%.0s' ''' #'""'";export n;x=!;b=\;IFS=.;set `echo;echo .`;n=$1 IFS= q='
exec sh -c 'IFS=;exec '$q'printf'$q' '$q'<%s>'$b'n'$q' '$q'arg with $and spaces'$q' '$q''$q' '$q'even'$q'$n'$q'* * *'$q'$n'$q'newlines'$q' '$q'and '$q$b$q$q'single quotes'$q$b$q$q''$q' '$q''$x''$x''$q

That code ends up running the same command when interpreted by any of the supported shells.

Answered By: Stéphane Chazelas

tl;dr

ssh USER@HOST -p PORT $(printf "%q" "cmd") $(printf "%q" "arg1") 
    $(printf "%q" "arg2")

For a more elaborate solution, read the comments and inspect the other answer.

description

Well, my solution won’t work with non-bash shells. But assuming it’s bash on the other end, things get simpler. My idea is to reuse printf "%q" for escaping. Also generally, it’s more readable to have a script on the other end, that accepts arguments. But if the command is short, it’s probably okay to inline it. Here are some example functions to use in scripts:

local.sh:

#!/usr/bin/env bash
set -eu

ssh_run() {
    local user_host_port=($(echo "$1" | tr '@:' ' '))
    local user=${user_host_port[0]}
    local host=${user_host_port[1]}
    local port=${user_host_port[2]-22}
    shift 1
    local cmd=("$@")
    local a qcmd=()
    for a in ${cmd[@]+"${cmd[@]}"}; do
        qcmd+=("$(printf "%q" "$a")")
    done
    ssh "$user"@"$host" -p "$port" ${qcmd[@]+"${qcmd[@]}"}
}

ssh_cmd() {
    local user_host_port=$1
    local cmd=$2
    shift 2
    local args=("$@")
    ssh_run "$user_host_port" bash -lc "$cmd" - ${args[@]+"${args[@]}"}
}

ssh_run USER@HOST ./remote.sh "1  '  "  2" '3  '''  "  4'
ssh_cmd USER@HOST:22 "for a; do echo "'$a'"; done" "1  '  "  2" '3  '''  "  4'
ssh_cmd USER@HOST:22 'for a; do echo "$a"; done' '1  "2' "3'  4"

remote.sh:

#!/usr/bin/env bash
set -eu
for a; do
    echo "'$a'"
done

The output:

'1  '  "  2'
'3  '  "  4'
'1  '  "  2'
'3  '  "  4'
1  "2
3'  4

Alternatively, you can do printf‘s job yourself, if you know what you’re doing:

ssh USER@HOST ./1.sh '"1  '''  "  2"' '"3  '''  "  4"'
Answered By: x-yuri
Categories: Answers Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.