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

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\0' "${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

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"'\'

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.