SSH clients over rmote

rmote sshmux exposes a local OpenSSH control socket. Each ordinary ssh client gets a separate Vty session, and all sessions share one rmote connection to the target.

Standard sftp clients can use the same control socket. SFTP requires only Python 3.11+ and its standard library on the target POSIX host. No remote installation of rmote or sftp-server is needed.

Start the server in one terminal:

python -m rmote sshmux --socket=/tmp/my-rmote.sock ssh user@server

The console script is equivalent:

rmote sshmux --socket=/tmp/my-rmote.sock ssh user@server

-S is the short form of --socket, matching the SSH client’s socket flag. Use -v / --debug for protocol logs and -h / --help for all options.

In other terminals, connect to that socket:

ssh -S /tmp/my-rmote.sock user@server
ssh -S /tmp/my-rmote.sock user@server 'uname -a'
printf 'hello\n' | ssh -S /tmp/my-rmote.sock user@server cat

The server runs in the foreground. Exiting one shell closes only its Vty. There are no detached sessions to reconnect to.

Message of the day

Use --motd to show the remote host’s message of the day before each shell session with a PTY:

rmote sshmux --motd --socket /tmp/my-rmote.sock -- ssh user@server

The remote terminal launcher reads /run/motd.dynamic, then /etc/motd, before starting the login shell. It skips missing, unreadable, and non-regular files. If both paths refer to the same file, it displays that file once. ~/.hushlogin on the remote host suppresses the message.

MOTD is off by default. Commands, including commands with ssh -t, SFTP, and shells without a PTY do not receive it. The option only displays existing files: it does not call PAM or run scripts to regenerate the message. The host must update dynamic MOTD separately. If the mux server is already running, start a new server or use another socket to apply --motd.

Automatic startup from SSH config

-d / --daemon starts the server in the background and returns after its transport and control socket are ready. If a server already answers on that socket, it returns successfully without starting another transport. Concurrent invocations share a startup lock.

rmote sshmux --daemon --idle-timeout 300 --socket ~/.ssh/container.sock -- docker exec -i my-container

To start it automatically with ssh container, put this near the beginning of ~/.ssh/config, before general Host * defaults:

Host container
    ControlPath ~/.ssh/container.sock
    ControlMaster no
    ProxyCommand false
    ForwardAgent no
    ForwardX11 no

Match originalhost container exec "rmote sshmux --daemon --idle-timeout 300 --socket ~/.ssh/container.sock -- docker exec -i my-container"
Match all

Replace my-container with the running container’s name. If rmote is not on the PATH used by SSH, use its absolute path, such as /home/me/project/.venv/bin/rmote, inside the exec command.

ssh container
ssh container 'uname -a'
printf 'hello\n' | ssh container cat

The first connection starts rmote; subsequent connections reuse it. Each SSH client still gets an independent session. -i 300 / --idle-timeout 300 closes the server and its transport after five minutes without connected clients. A quiet but open shell keeps the server alive. The default is 0, which disables this timeout. The option also works in foreground mode.

Match exec runs while SSH reads its configuration, before it connects to ControlPath. A regular ProxyCommand expects an SSH server protocol stream, so it cannot connect directly to rmote’s mux protocol. Here ProxyCommand false prevents a failed mux connection from falling back to a network connection. See OpenSSH’s Match and ProxyCommand documentation.

For an SSH transport, replace docker exec -i my-container with ssh -T -o BatchMode=yes user@real-server. Use a different host alias for that transport so it does not invoke the same startup rule recursively. A detached server has no controlling terminal: transport authentication must work without interactive prompts, for example through an SSH agent.

The socket selects the existing connection. Changing the transport, --python or timeout options does not reconfigure an already running server; stop it first. Background output goes to ~/.ssh/container.sock.log (mode 0600). Startup waits up to 30 seconds and reports failures to stderr. The adjacent .lock file remains after shutdown; leave it in place so launchers use the same lock. --daemon can recover an owned socket left by a crashed server when it refuses connections; it does not replace regular files, symlinks or unresponsive listeners.

The startup rule also runs for ssh -G container, ssh -O check container and ssh -O exit container. To inspect or stop a server without triggering automatic startup, bypass the configuration:

ssh -F /dev/null -S ~/.ssh/container.sock -O check container
ssh -F /dev/null -S ~/.ssh/container.sock -O exit container

One rule for a family of hosts

ControlPath and Match exec both expand SSH’s tokens: %%, %C, %d, %h, %i, %j, %k, %L, %l, %n, %p, %r and %u. The exec command runs under your shell, so a shell expansion can change a token after SSH substitutes it. One rule therefore serves a whole family of aliases, and every alias gets a server of its own:

Host *.docker
    ControlPath %d/.ssh/rmote-%C.sock
    ControlMaster no
    ProxyCommand false
    ForwardAgent no
    ForwardX11 no

Match originalhost *.docker exec "n=%n; rmote sshmux --daemon --idle-timeout 300 --socket %d/.ssh/rmote-%C.sock -- docker exec -i ${n%%.docker}"
Match all

ssh my-container.docker reaches the container my-container: SSH replaces %n with the alias, and the shell removes the .docker suffix. Another alias starts another server through the same rule.

ssh my-container.docker uname -s
ssh other-container.docker 'cat /etc/hostname'

Write %% for the percent sign that the shell needs. SSH reads % as the start of a token and stops with unknown key %. for a single one. It gives the shell ${n%.docker} after it replaces %% with %.

Build the socket path from %C, a hash of %l%h%p%r%j. It is short, it differs for every host, and SSH gives ControlPath and the exec command the same value. A directory and a host name together pass the 104-byte limit of a UNIX socket path, and both rmote and SSH then report the length and refuse.

Name the home directory with %d, not with ~. SSH expands ~ from the account’s passwd entry, and the shell of the exec command expands it from $HOME. Where those differ, the server listens on one socket and the client looks for another.

%n is the alias from the command line, and it reaches the shell of the exec command without quoting. An alias that contains shell characters runs them, so use this form for aliases that you write yourself.

Transport and destination

The connection to the target uses rmote’s common Transports. SSH is the client interface to the mux socket; the underlying transport can reach Python through SSH, Docker/Kubernetes exec, a local process or a prepared byte stream.

Everything after the server options is the transport command. rmote appends python3 -qui; use -p / --python to choose another interpreter. With no transport, the interpreter runs locally:

rmote sshmux --socket=/tmp/container.sock docker exec -i my-container
rmote sshmux --socket=/tmp/local.sock
rmote sshmux --socket=/tmp/server.sock --python /usr/bin/python3 ssh user@server

For Docker, use exec -i, without -t, and omit python3 -qui from the transport command. Docker must pass the binary protocol through pipes; Vty creates the interactive terminals inside the container afterwards. exec -it fails because the transport’s stdin is not a terminal.

The socket selects an already connected destination and user. The host and user on subsequent ssh commands do not change that connection. No sshd is needed in a container reached through docker exec.

OpenSSH normally falls back to a network connection when a control socket is unavailable or refuses a request. To require this socket, use -o ProxyCommand=false. For example, a host alias can keep that setting together with the socket path:

Host rmote-container
    ControlPath /tmp/container.sock
    ControlMaster no
    ProxyCommand false
    ForwardAgent no
    ForwardX11 no

Then ssh rmote-container opens a Vty in the container. Only the user who started rmote can access the socket (mode 0600). Its parent directory must already exist, and the socket path must be unused. The server never replaces an existing file or socket in foreground mode; --daemon reuses a running server as described above.

TLS for the rmote transport

sshmux automatically uses the certificates created by rmote certs:

rmote certs
rmote sshmux --socket=/tmp/container.sock -- docker exec -i my-container

It searches $XDG_DATA_HOME/rmote/tls, falling back to ~/.local/share/rmote/tls. Existing files appear as defaults in rmote sshmux --help:

Option

Default file

--tls-cert

controller.crt

--tls-key

controller.key

--tls-ca

controller-ca.crt if present; otherwise controller.crt

Use --no-tls to disable TLS even when stored certificates exist. Do not combine it with explicit certificate, key, or CA arguments. The same options are available in shell, repl, and rsync; see TLS over a transport.

Without both default certificate and key files, sshmux starts without TLS. It does not create missing files. Explicit paths override the corresponding defaults; an explicitly selected file that does not exist causes startup to fail. Explicit TLS options require a complete certificate and key pair. Invalid credentials or a failed TLS handshake also stop startup, with no plaintext retry.

All paths refer to local controller files. Set them explicitly when using a different store or a separate CA:

rmote sshmux --socket=/tmp/container.sock \
  --tls-ca ./controller-ca.crt \
  --tls-cert ./controller.crt --tls-key ./controller.key \
  --tls-hostname controller.example.org -- docker exec -i my-container

--tls-hostname defaults to socket.gethostname(), matching rmote certs. It is the controller name checked by the remote process, not the destination label given to ssh -S. Without a separate CA file, the selected certificate also supplies the trust certificate.

These options apply to foreground and daemon startup. Reusing an existing daemon keeps its current connection; restart it to change TLS credentials. Missing explicitly selected files remain errors even when reusing a daemon.

TLS protects the rmote connection to the target. OpenSSH clients still use the local UNIX control socket. The plaintext bootstrap requires a trusted channel; see TLS over a transport for the protection and its limits.

Terminal behavior

OpenSSH chooses whether to request a terminal: use -t for an interactive command or -T for pipes. Each terminal has its own window size; resize, Ctrl-C, job control and TERM reach the remote session. Pipe sessions preserve binary data, deliver EOF to stdin, and keep stdout and stderr separate. Exit status is returned to ssh.

Commands run through the remote user’s shell with -c. An empty command opens a login shell. Environment entries explicitly sent by the client with SendEnv or SetEnv are passed to the child.

The supported escape sequences at the start of a line are ~. to close this session and ~~ to send a literal tilde. ssh -e none disables escapes. Other OpenSSH escape commands are not implemented. Remote terminals currently use their default terminal modes rather than copying local stty settings.

Stopping the server

ssh -S /tmp/my-rmote.sock -O check user@server
ssh -S /tmp/my-rmote.sock -O exit user@server

Ctrl-C, SIGTERM and SIGHUP also stop the server. Shutdown closes active Vty sessions, the rmote transport and the socket. A client disconnect closes its session and signals its child process group. Processes that deliberately detach from the session can survive. An abrupt loss of the remote interpreter cannot guarantee cleanup of processes on that host.

Supported OpenSSH requests

rmote sshmux supports the OpenSSH mux v4 passenger protocol: shell and exec sessions, health checks and master termination. It requires POSIX on both sides and an OpenSSH client locally; the remote Python needs no third-party packages.

Agent forwarding

To forward an agent, start the mux server with SSH_AUTH_SOCK pointing to your local agent, then request forwarding for a shell or command:

ssh -A container ssh-add -L

To select a known local agent socket explicitly, use --agent-socket when starting the mux server. It overrides SSH_AUTH_SOCK:

rmote sshmux --socket /tmp/container.sock --agent-socket /path/to/agent.sock -- docker exec -i my-container
ssh -A -S /tmp/container.sock -o ProxyCommand=false container ssh-add -L

The option also works with --daemon. Changing it requires restarting an existing mux server. Clients must still request forwarding with -A or ForwardAgent yes.

The forwarded agent belongs to the mux server process. The mux request does not carry the connecting client’s agent path. If you change SSH_AUTH_SOCK, restart the mux server to select the new agent. Each forwarded session has a private remote Unix socket and up to 32 agent connections. Closing the session closes those connections and removes its socket and temporary directory. Forcibly terminating the remote interpreter can leave the temporary directory behind, though its sockets can no longer forward requests. An unavailable local agent produces a warning; the shell still opens. Without -A or ForwardAgent yes, the shell receives no agent socket, including one inherited by the remote Python process.

This is a byte bridge to the agent. It does not create an OpenSSH session-bind@openssh.com binding for the rmote hop, which has no SSH host key or key-exchange signature. Do not rely on destination constraints to validate this forwarding hop, or on agent restrictions that require identifying a connection as forwarded. See OpenSSH’s agent protocol extensions.

X11 forwarding is not implemented. A request produces a warning and the shell opens without X11 forwarding. Use ssh -x to disable the request.

The public Agent Tool also supports reverse forwarding and forwarding between two remote endpoints. Select the listener and agent independently:

from rmote.tools import Agent

# Local agent available through a remote socket.
async with Agent.forward(listener=remote) as remote_socket:
    ...

# Remote agent available through a local socket.
async with Agent.forward(agent=remote) as local_socket:
    ...

# Use a known agent socket on the remote endpoint.
async with Agent.forward(agent=remote, path="/run/user/1000/ssh-agent.sock") as local_socket:
    ...

# Agent on host B available through a socket on host A.
async with Agent.forward(listener=host_a, agent=host_b) as socket_on_a:
    ...

Each endpoint is an open Protocol; an omitted endpoint means the current Python process. path= selects the agent socket on the agent endpoint. Without it, the Tool reads that endpoint’s SSH_AUTH_SOCK. Set the consuming process’s SSH_AUTH_SOCK to the yielded listener path. Both endpoint sessions and their connections are released when the context exits. The coordinator relays bytes through the selected Protocol connections. The same session-binding limitation described above applies in every direction. See the Agent API for runnable examples and the operations used to manage sessions and connections.

SFTP without a remote SFTP server

The sftp subsystem supports SFTP v3. The target POSIX host needs only Python 3.11+ and its standard library: no sftp-server, installed rmote package, or additional Python packages. rmote transfers the required Python code automatically and performs file operations through it.

SFTP uses the existing rmote transport. With Docker or Kubernetes exec, the target does not need OpenSSH or sshd. If you choose SSH as the transport, sshd is still required for that connection, but its SFTP subsystem is not used. The OpenSSH sftp client runs locally.

With the SSH configuration above, connect with:

sftp container

Without an SSH config entry, specify the control socket explicitly:

sftp -o ControlPath=~/.ssh/container.sock -o ProxyCommand=false container

The backend supports regular file reads and writes, resume, directory listing, rename, symbolic links, and file attributes. It also supports the OpenSSH posix-rename and fsync extensions. File changes are applied directly; uploads do not use FileSync’s atomic replacement or block comparison. Paths use the remote Python process’s working directory and permissions.

Each SFTP session owns up to 128 file and directory handles. Requests run in order, one at a time per session, with reads and writes limited to 256 KiB. Closing the session releases its handles, including when the mux client disconnects. Sequential requests limit transfer throughput on connections with high latency. Special files, extended attributes, and unadvertised SFTP extensions are not supported.

For direct Python file access, see the Files API. It documents session ownership, descriptor cleanup, and OpenFlags.

Other requests

Other subsystems, port forwarding, mux proxy mode and ssh -O stop are not implemented and receive a failure response. The adapter does not implement an SSH network server or SSH authentication.