Analysis
I then write a
RemoteCommand ssh -tt -q b
to the ssh config for hosta
on my laptop, so I can just runssh a
.
(For consistency I will use capital A and B even in code.)
This creates a chain consisting of the local ssh
(that connects the local machine to A) and ssh -tt
running on A (and connecting A to B).
Locally invoked ssh A
, depending on what options and operands you (or anything, e.g. rsync
) add, either allocates a tty on A or not; but the next ssh
in the chain is always ssh -tt
, so it always allocates a tty on B. ssh
that allocates a tty cannot be used as (a part of) a transport pipe because it will break any protocol that sends binary data. See this question (and the links therein) for some insight: ssh
with separate stdin, stdout, stderr AND tty.
In general in a link of ssh
s almost always we should either make each link allocate a tty, or make each link not allocate a tty.
A tool like rsync
invokes ssh
in a way that does not allocate a tty on the remote side; it needs an 8-bit clean channel. Adding ssh -tt
to the chain breaks this. Or would break this, because…
In the first place a tool like rsync
invokes ssh
with argument(s) that constitute a remote command the tool needs. In your case this command should get to a shell on B, so it would need to be provided as additional argument(s) to the ssh
inside your remote command. In fact the flow never gets to the remote command. In my tests ssh
from OpenSSH 9.6 does not allow me to use the RemoteCommand
option together with argument(s) building a remote command:
$ ssh -o RemoteCommand=foo stranger@nonexistent bar bazCannot execute a command-line and remote command.
Solution
In OpenSSH there is a way to supply a RemoteCommand
-like command together with a command build from arguments.
This solution requires you to be able to log in to A via SSH by using a key, and to be able to edit your ~/.ssh/authorized_keys
on A (in general: one of whatever files sshd
on A is configured to use; but this answer assumes the defaults).
Proceed as follows:
Remove the
RemoteCommand ssh -tt -q b
from your local SSH config.Generate a new key pair on the local computer:
# on localssh-keygen -f ~/.ssh/special_AB -C 'Special key to connect to A and then to B.'
Make A recognize the key:
# on localssh-copy-id -i ~/.ssh/special_AB A
(If the default key allows you to log in to A and you get
All keys were skipped
, retry with-f
exactly one time.)Force A to run specific code when the key is used. The full code we want A to run is somewhat complicated, therefore we will put it in a separate helper script (in a moment). Now let's just associate the key with the future script.
To do this, log in to A (to an interactive shell), open
~/.ssh/authorized_keys
in a text editor, locate the line that ends withSpecial key to connect to A and then to B.
(most likely this will be the last line). To the beginning of this line add:command="exec ~/.ssh/helper_AB"
Note there is a whitespace after the last
"
. The resulting line shall look similarly to the following example:command="exec ~/.ssh/helper_AB" ssh-ed25519 AAAAC3…
Save the file.
Still on A, create our helper script:
# on Acat >~/.ssh/helper_AB <<'EOF'#!/bin/shset -- ssh[ -t 0 ] && set -- "$@" -texec "$@" -q -- B "$SSH_ORIGINAL_COMMAND"EOF
And make it executable:
# on Achmod +x ~/.ssh/helper_AB
Tell your local
ssh
how to reach B; put this at the beginning* of your local SSH config (~/.ssh/config
):Host B Hostname A IdentitiesOnly yes IdentityFile ~/.ssh/special_ABHost *
* Since the first obtained value for each parameter is used, more host-specific declarations should be given near the beginning of the file, and general defaults at the end (elaborated in this answer).
Host *
in the snippet is in case your original file did not start withHost
/Match
and there were some lines applied unconditionally. If so, we want them to still apply unconditionally, not underHost B
.
Now local usage of ssh B
will reach B via A. Requests for a tty (or for no tty) will propagate properly. Command(s) given in the local command line will be passed properly.
Drawbacks
The solution is not perfect, there are drawbacks:
- Upon disconnecting, you may see
Connection to A closed
(notConnection to B …
). - Forwarding (tunnels) will not involve B automatically; so local tools that use
ssh B
to create tunnel(s) (and that obviously expect the remote side to be B) may still fail. In particularssh -A B
will expose your local agent only to A.
Possibly more. In general any implication of the fact your local ssh
connects to A, not to B, may manifest itself. To solve these problems you need "nested tubes" instead of the chain (read this answer to see what I mean); but this is exactly the "ProxyJump" solution you have tried, I understand why it doesn't fit your needs.
Explanation, divagation
The core of the solution comes from this fragment of man 8 sshd
:
command="command"
Specifies that the command is executed whenever this key is used for authentication. The command supplied by the user (if any) is ignored. The command is run on a pty if the client requests a pty; otherwise it is run without a tty. If an 8-bit clean channel is required, one must not request a pty or should specify
no-pty
. A quote may be included in the command by quoting it with a backslash.This option might be useful to restrict certain public keys to perform just a specific operation. […]
The command originally supplied by the client is available in the
SSH_ORIGINAL_COMMAND
environment variable. […]
It seems to me that conceptually this command=
is similar to RemoteCommand
, still only the former supports using and accessing whatever command the user supplied in the command line. The mentioned Cannot execute a command-line and remote command
seems to be an arbitrary obstacle, our solution works by using commands from two sources. It would be nice if also in case of RemoteCommand
it worked and SSH_ORIGINAL_COMMAND
was in the environment; we could then build a solution similar to the above, but without needing to involve a key (i.e. the solution could work with any authentication method).