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 inyash
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 theprintf
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.