Context. The Rust
opensshcrate gives users astd::process::Command-like API for running commands on a remote host, while internally reusing a single authenticated SSH connection (OpenSSH's ControlMaster multiplexing). It ships two interchangeable backends behind one API:
process_impl— shells out to the systemsshbinary for every operation.native_mux_impl— talks the OpenSSH mux protocol directly over the master's Unix-domain control socket (no per-commandsshfork).This document is a language-agnostic specification for reimplementing that library — the ergonomic surface API and both backends — in another language. It pays special attention to the binary mux protocol, because that is the part with no public, friendly reference outside the OpenSSH C source and
PROTOCOL.mux.Authoritative sources cross-checked while writing this:
src/of theopensshcrate, theopenssh-mux-clientcrate, thessh_formatserde crate, OpenSSH'sPROTOCOL.muxandmux.c. Key facts below were verified against the actual code, not just the spec (the spec has at least one inaccuracy, noted inline).Deliverable: on approval, this document is written to
docs/PORTING_DESIGN.mdin theopensshrepo (newdocs/directory), including the Rust-mapping appendix in §11.
┌─────────────────────────────────────────────┐
your program │ Public API: Session / Command / Child / │
───────────────► │ Stdio / Error / SessionBuilder │
└───────────────┬─────────────────────────────┘
│ (compile-time or runtime dispatch)
┌──────────────────┴────────────────────┐
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ process backend │ │ native-mux backend │
│ spawn `ssh` │ │ speak mux protocol │
└────────┬─────────┘ └───────────┬────────────┘
│ argv + OS pipes │ AF_UNIX socket
│ `ssh -S <ctl> …` │ + SCM_RIGHTS fd passing
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ ONE long-lived `ssh` MASTER process (ControlMaster) │
│ listening on a Unix-domain control socket <ctl> │
└────────────────────────────────┬──────────────────────────┘
│ single encrypted TCP connection
▼
┌───────────┐
│ remote │
│ sshd │
└───────────┘
The two backends are symmetric in how the master is created and differ only in how
subsequent operations reach it. Both backends launch the master the exact same way:
by spawning ssh in master mode (-M -N -f -S <ctl> …). The master forks to the
background, opens the real SSH connection, and listens on the control socket <ctl>.
From then on:
- process backend: every command = a new short-lived
ssh -S <ctl> …child process. The OS gives you the stdio pipes; the localsshrelays bytes to the master, which multiplexes them as a new channel over the one TCP connection. - native-mux backend: every command = a new
AF_UNIXconnection to<ctl>over which you speak the binary mux protocol yourself, including passing the three stdio file descriptors to the master withSCM_RIGHTS.
Both achieve the same thing: N concurrent remote commands sharing one authenticated connection and one auth handshake.
Keep the public types backend-agnostic; no backend type should leak. Model the API on
the language's standard subprocess library so it feels native (Rust models it on
std::process::Command).
| Type | Role |
|---|---|
SessionBuilder |
Configures and launches a master connection. Fluent setters, then connect() / connect_mux(). |
Session |
A live (shared) master connection. Factory for Commands; can port-forward, health-check, and close. |
Command |
A builder for one remote command (program, args, stdio). Terminal ops: spawn(), output(), status(). |
Child (a.k.a. RemoteChild) |
A spawned remote process: stdin/stdout/stderr handles, wait(), wait_with_output(), disconnect(). |
Stdio |
How to wire a child's std stream: inherit(), null(), piped(), or from an existing fd/file. |
ChildStdin / ChildStdout / ChildStderr |
Async byte streams to/from the child; also exposable as raw fds (enables piping one remote command into another). |
Error |
One normalized error type across both backends (see §7). |
KnownHosts, ControlPersist |
Connection-policy enums. |
ForwardType, Socket |
Port-forwarding descriptors. |
OverSsh (trait) |
Lets a locally constructed std/tokio command be converted to run over a session. Rejects features SSH can't honor (env vars, cwd) with CommandHasEnv / CommandHasCwd. |
user, port, keyfile, connect_timeout, server_alive_interval,
known_hosts_check (Strict / Add / Accept), control_directory, control_persist
(Forever / ClosedAfterInitialConnection / IdleFor(secs)), clean_history_control_directory,
config_file, compression, jump_hosts, user_known_hosts_file, ssh_auth_sock.
Also support a resolve() step that parses ssh://[user@]host[:port] destinations and
overlays them onto the builder.
arg()shell-escapes its argument;raw_arg()passes bytes through untouched. (args/raw_argsare the plural forms.) This matters because the remote side runs the command through a shell — see §3.6.command(program)shell-escapes the program name too;raw_command(program)does not.shell(cmd)is sugar forraw_command("sh").arg("-c").arg(cmd).subsystem(name)requests an SSH subsystem instead of a shell command (e.g.sftp).- Default stdio differs per terminal op:
spawn()→ all inherit;output()→ stdin null, stdout/stderr piped;status()→ all inherit.
wait() returns the remote process's exit status, except:
- exit code 127 → normalize to a "command not found" error;
- exit code 255 / no exit value → "remote process terminated" (see §7 for the 255 ambiguity and how the two backends differ here).
This logic is backend-independent: it just spawns the ssh CLI in master mode. Pseudocode:
launch_master(builder, destination) -> handle:
socketdir = builder.control_directory
or $XDG_RUNTIME_DIR-style state dir
or ./ # last-resort fallback
if builder.clean_history_control_directory:
remove every "<socketdir>/.ssh-connection-*" directory # GC leaked masters
tmp = make_temp_dir(prefix=".ssh-connection", in=socketdir) # auto-deleted on drop
ctl = tmp/"master" # the control socket path (ssh creates the socket here)
log = tmp/"log" # master's stderr, captured for diagnostics
argv = ["ssh",
"-S", ctl, # ControlPath
"-M", # become ControlMaster
"-f", # fork to background after auth
"-N", # run no remote command (master only)
"-E", log, # write master stderr to log file
"-o", control_persist.as_o_value(), # ControlPersist=yes|no|<secs>s
"-o", "BatchMode=yes", # never prompt
"-o", known_hosts_check.as_o_value(), # StrictHostKeyChecking=...
...connect_timeout, server_alive_interval, port, user,
keyfile, config_file, compression, jump_hosts,
user_known_hosts_file, ssh_auth_sock...,
destination]
set stdin/stdout/stderr = null
status = run(argv).wait() # returns quickly thanks to -f
if status != 0:
return interpret_ssh_error(read(log)) # see §7.2
return handle{ tmp, ctl, log }
Notes:
-fmakessshfork into the background after authentication completes, so the parent's exit status tells you whether auth/connection succeeded. The control socket exists by the time the parent returns 0.- The temp directory owns the lifetime: when it is deleted, the socket file and log
vanish with it. Keep the temp-dir handle inside
Session.
| Variant | -o value |
Behavior |
|---|---|---|
Forever (default) |
ControlPersist=yes |
Master persists until explicitly killed. |
ClosedAfterInitialConnection |
ControlPersist=no |
Master exits when the last client disconnects. |
IdleFor(n) |
ControlPersist=<n>s |
Master exits after n seconds idle. |
server_alive_interval (→ ServerAliveInterval) keeps NAT/idle timeouts from killing
long-lived masters.
Sessionis cheap to share concurrently (it's just a path + temp-dir handle). All operations open their own transport to the master, so manyCommands can run at once.detach()→ returns(ctl_path, log_path)and leaks the temp dir (prevents the destructor from cleaning up).resume(ctl, log)→ rebuilds aSessionfrom those paths with no owned temp dir (so it won't clean up on drop). This lets a master outlive the process that created it and be re-adopted later (e.g. across CLI invocations).
- The master listens on a
AF_UNIX,SOCK_STREAMsocket at<ctl>. - One mux operation = one fresh
AF_UNIXconnection. The client connects, exchanges HELLO, performs exactly one logical operation (alive-check, open-session, open-forward, …), and the connection is then either closed or — for a session — kept open only to read that session's exit message. There is no request pipelining of unrelated operations over a single socket in this design. - Therefore concurrency = many AF_UNIX connections to the same master. The master
multiplexes them all into channels over the single TCP connection to the remote. (This
is the key difference from the process backend, which gets the same multiplexing by
spawning many
ssh -Sclients.) - A per-connection
request_idcounter exists (starts at 0, increments per request, wraps as u32) and is echoed by the server so replies can be matched to requests. Because each connection usually issues a single request, it's mostly a correctness check; still, always validateresponse_id == request_idand reject mismatches.
Every message — both directions — is:
uint32 packet_length // number of bytes that FOLLOW this field
uint32 packet_type // one of the MUX_* constants
... packet_body // type-specific, packet_length-4 bytes
- All integers are unsigned big-endian (network byte order).
packet_lengthcounts everything after itself, i.e.4 (type) + len(body).packet_typeis the first word of the framed body. (Implementation note: in the Rust serde encoding,packet_typeis literally the enum variant index, which is why the numeric constants double as both message IDs and serde discriminants — a reimplementation can ignore that detail and just write the constant.)
SSHMUX_VER = 4
# client → server
MUX_MSG_HELLO = 0x00000001 # also server → client (handshake)
MUX_C_NEW_SESSION = 0x10000002
MUX_C_ALIVE_CHECK = 0x10000004
MUX_C_TERMINATE = 0x10000005 # (defined; not used by this design — see note)
MUX_C_OPEN_FWD = 0x10000006
MUX_C_CLOSE_FWD = 0x10000007
MUX_C_NEW_STDIO_FWD = 0x10000008 # (not implemented here)
MUX_C_STOP_LISTENING = 0x10000009
MUX_C_PROXY = 0x1000000f # switches the socket to proxy mode — see §3.10
# server → client
MUX_S_OK = 0x80000001
MUX_S_PERMISSION_DENIED = 0x80000002 # body has reason string
MUX_S_FAILURE = 0x80000003 # body has reason string
MUX_S_EXIT_MESSAGE = 0x80000004
MUX_S_ALIVE = 0x80000005
MUX_S_SESSION_OPENED = 0x80000006
MUX_S_REMOTE_PORT = 0x80000007
MUX_S_TTY_ALLOC_FAIL = 0x80000008
MUX_S_PROXY = 0x8000000f # reply to MUX_C_PROXY — see §3.10
# forwarding type discriminants
MUX_FWD_LOCAL = 1
MUX_FWD_REMOTE = 2
MUX_FWD_DYNAMIC = 3
Note on master shutdown: OpenSSH defines
MUX_C_TERMINATEto kill a master, but this design closes a session by sendingMUX_C_STOP_LISTENINGinstead (master stops accepting new clients and removes its listener socket; existing sessions finish). That matches what the reference Rust client does.
Immediately after connecting, both peers send a HELLO; the client then reads the server's HELLO and checks the version.
client → server: server → client:
uint32 packet_length (= 8) uint32 packet_length (= 8, no extensions)
uint32 MUX_MSG_HELLO uint32 MUX_MSG_HELLO
uint32 4 (protocol version) uint32 4 (protocol version)
[string extension_name, string extension_value]* # 0+, currently none
If the server's version ≠ 4, abort with an "unsupported protocol" error. Ignore any trailing extension name/value pairs (forward-compat).
This is "passenger mode": the client hands its three stdio fds to the master and then just waits. Sequence on a freshly-HELLO'd connection:
(1) Client sends MUX_C_NEW_SESSION:
uint32 packet_length
uint32 MUX_C_NEW_SESSION
uint32 request_id
string reserved # always empty (uint32 len = 0); server ignores contents
uint32 want_tty # bool as u32 (0/1)
uint32 want_x11_forwarding # bool as u32
uint32 want_agent # bool as u32
uint32 subsystem # bool as u32 (1 ⇒ "command" is a subsystem name)
uint32 escape_char # 0xFFFFFFFF disables escapes (see caveat below)
string terminal_type # e.g. value of $TERM; empty string is fine for no-tty
string command # the remote command line (or subsystem name)
[string environment_string]* # OPTIONAL, zero or more "KEY=VALUE"; this design sends NONE
escape_charcaveat. The spec says use0xFFFFFFFFto disable the escape character. The reference Rust client is constrained by itschartype and actually sends0x0010FFFF(Unicode max). This is harmless for the non-interactive, no-TTY sessions this library creates (escape processing only applies with a TTY). In a reimplementation not bound by a 21-bit char type, prefer the spec value0xFFFFFFFF.
(2) Immediately after, client passes 3 fds — one sendmsg per fd, in order:
send stdin_fd, then stdout_fd, then stderr_fd
Each fd is transmitted via a Unix-domain ancillary control message: a sendmsg(2)
carrying a one-byte ordinary payload (0x00) plus a cmsghdr with
cmsg_level = SOL_SOCKET, cmsg_type = SCM_RIGHTS, and exactly one int fd in the data.
(Send them one fd per message, not three in one cmsg — that's what the master expects.)
The passed fds must be in blocking mode (clear O_NONBLOCK); see §5.
(3) Server replies once:
uint32 packet_length
uint32 MUX_S_SESSION_OPENED
uint32 client_request_id # must equal request_id
uint32 session_id # remember this; exit message is keyed on it
…or an error: MUX_S_PERMISSION_DENIED / MUX_S_FAILURE (each followed by
client_request_id then a string reason).
Spec vs. reality.
PROTOCOL.muxmentions an additionalMUX_S_OK"once the server has received the fds." The real master and the working Rust client do not exchange a separateMUX_S_OKforNEW_SESSION; the client treatsMUX_S_SESSION_OPENEDas the success signal and proceeds straight to waiting. Implement it that way.
(4) Client waits. The same connection now carries, eventually:
# optional, at most once, before exit:
uint32 packet_length
uint32 MUX_S_TTY_ALLOC_FAIL
uint32 session_id
# the terminal event:
uint32 packet_length
uint32 MUX_S_EXIT_MESSAGE
uint32 session_id # must equal the opened session_id
uint32 exit_value
Handle three terminal outcomes:
MUX_S_EXIT_MESSAGE→ remote exited withexit_value.MUX_S_TTY_ALLOC_FAIL→ record it (you could restore local TTY to cooked mode), then keep waiting for the exit message.- Connection EOF before any exit message → the remote process was killed by a signal (or the master died). Surface this as "terminated" with no exit value (see §7).
connect AF_UNIX
│
▼
┌──────────────────┐ send HELLO / recv HELLO (version==4?)──no──► ERROR(unsupported)
│ HANDSHAKE │
└────────┬─────────┘
│ yes
▼
send NEW_SESSION; send stdin,stdout,stderr fds (SCM_RIGHTS)
│
▼
┌───────────────────┐ recv …
│ AWAIT OPEN │── PERMISSION_DENIED / FAILURE ─► ERROR(reason)
└────────┬──────────┘── SESSION_OPENED(session_id) ─┐
│ │
▼ │
┌──────────────────┐ ◄──────────────────────────────┘
│ RUNNING (wait) │── TTY_ALLOC_FAIL ─► (note it, keep waiting)
└────────┬─────────┘── EXIT_MESSAGE(exit_value) ─► DONE(exit_value)
└──────────── EOF before exit ──────────► DONE(terminated/None)
The mux command field is a single command string that the remote shell parses, just
like ssh host "the command". So argument quoting must happen client-side before it
goes on the wire:
Command::arg(a)→ shell-escapea, then append" " + escapedto the command bytes.Command::raw_arg(a)→ append" " + averbatim.
The native-mux backend builds the command as a running byte buffer (space-separated). The
process backend instead lets the local ssh client assemble argv, but ssh also just
concatenates and ships a string to the remote shell — so the same escaping rules apply to
both backends. Reject commands containing NUL bytes (the wire string type can't carry NUL;
see §4).
client → server: server → client:
uint32 packet_length (=8) uint32 packet_length (=12)
uint32 MUX_C_ALIVE_CHECK uint32 MUX_S_ALIVE
uint32 request_id uint32 client_request_id
uint32 server_pid # master's PID; must be > 0
Use this for Session::check() in the mux backend.
MUX_C_OPEN_FWD / MUX_C_CLOSE_FWD share one body shape:
uint32 packet_length
uint32 MUX_C_OPEN_FWD | MUX_C_CLOSE_FWD
uint32 request_id
uint32 forwarding_type # MUX_FWD_LOCAL | MUX_FWD_REMOTE | MUX_FWD_DYNAMIC
string listen_host
uint32 listen_port
string connect_host
uint32 connect_port
- Unix-domain endpoints: set the port to
(uint32)-2(0xFFFFFFFE) and put the socket path in the corresponding host string. - TCP endpoints: host string = address, port = the TCP port.
- For
MUX_FWD_DYNAMIC(SOCKS), there is no connect endpoint; send an empty connect host and the path-style port.
Replies:
MUX_S_OK(+ client_request_id) — success, fixed port.MUX_S_REMOTE_PORT(+ client_request_id + allocated_port) — success with a dynamically allocated listen port (used by dynamic forwarding).MUX_S_PERMISSION_DENIED/MUX_S_FAILURE(+ client_request_id + reason).
client → server: server → client:
uint32 packet_length (=8) uint32 MUX_S_OK + client_request_id
uint32 MUX_C_STOP_LISTENING (or PERMISSION_DENIED / FAILURE + reason)
uint32 request_id
The master removes its listener socket and stops accepting new clients; existing sessions run to completion, then the master exits.
A session's stdin/stdout/stderr can reach the remote two different ways. Everything in
§3.5 is passenger mode; there is a second, heavier mode (proxy mode,
MUX_C_PROXY) that a client can opt into. The distinction matters specifically for flow
control, so read this before assuming §3.5 is the whole story.
In passenger mode the client passes three raw fds and then just waits (§3.5). The session's data bytes never travel on the mux control socket — they flow directly through the passed fds between the client's pipe endpoints and the master process. Consequently:
- The mux client never sees a window or a
WINDOW_ADJUST. Back-pressure is handled entirely by (a) ordinary OS pipe buffering between the client'sChildStdin/out/errand the master (a full pipe blocks the writer; an empty pipe blocks the reader), and (b) the SSH connection-protocol windowing that runs inside the master between the master and the remotesshd. Both are invisible to your code. - This is why §3.5 and §4 contain no window messages, and why the fds handed over must be blocking (§5.2): the master drives them with blocking reads/writes and relies on pipe back-pressure. A faithful passenger-mode reimplementation implements zero flow control — do not add any.
The openssh crate (and both backends it ships) uses passenger mode exclusively.
Proxy mode is the alternative. Here the client asks the master to stop speaking the mux
protocol and instead relay raw SSH connection-protocol messages between client and
remote. No fds are passed, so a proxy-mode connection can itself be forwarded/relayed to
another host — but in exchange the client must implement a meaningful subset of the SSH
connection protocol, including per-channel flow control. This is the only place
SSH_MSG_CHANNEL_WINDOW_ADJUST appears on the client side.
Status. Proxy mode is a distinct architecture, not merely a wire tweak. In this codebase it lives in a separate, partially-implemented
proxy-clientcrate and is not wired into theopensshcrate. It is documented here because (1) it is the answer to "how does flow control work," and (2) it is a legitimate alternative backend a reimplementation might choose (e.g. to avoidSCM_RIGHTS, which is awkward on some platforms and impossible across a relayed socket).
Entering proxy mode. On a HELLO'd connection:
client → server: server → client:
uint32 packet_length (=8) uint32 packet_length (=8)
uint32 MUX_C_PROXY (0x1000000f) uint32 MUX_S_PROXY (0x8000000f)
uint32 request_id uint32 client_request_id
After MUX_S_PROXY, the mux framing (§3.2) is replaced by SSH transport framing for
the rest of the connection's life:
uint32 packet_length // = 2 + len(payload): the padding byte + type byte + payload
byte padding_length // always 0 in proxy mode (messages are unencrypted, unpadded)
byte message_type // an SSH_MSG_* code (below)
byte[packet_length - 2] payload
Payload fields use the same ssh_format rules as §4 (big-endian ints, uint32-length
-prefixed strings). For channel messages, the first payload uint32 is the
recipient channel id.
Connection-protocol messages you must handle (u8 codes):
SSH_MSG_GLOBAL_REQUEST 80 SSH_MSG_CHANNEL_WINDOW_ADJUST 93
SSH_MSG_REQUEST_SUCCESS 81 SSH_MSG_CHANNEL_DATA 94
SSH_MSG_REQUEST_FAILURE 82 SSH_MSG_CHANNEL_EXTENDED_DATA 95
SSH_MSG_CHANNEL_OPEN 90 SSH_MSG_CHANNEL_EOF 96
SSH_MSG_CHANNEL_OPEN_CONFIRMATION 91 SSH_MSG_CHANNEL_CLOSE 97
SSH_MSG_CHANNEL_OPEN_FAILURE 92 SSH_MSG_CHANNEL_REQUEST 98
SSH_MSG_CHANNEL_SUCCESS 99
SSH_EXTENDED_DATA_STDERR (data_type) = 1 SSH_MSG_CHANNEL_FAILURE 100
Opening a channel. Send SSH_MSG_CHANNEL_OPEN with channel_type = "session",
your sender_channel id, your advertised initial_window_size, your max_packet_size.
The peer replies SSH_MSG_CHANNEL_OPEN_CONFIRMATION (its sender_channel, its
initial_window_size, its max_packet_size) or SSH_MSG_CHANNEL_OPEN_FAILURE. Then
start the program with a SSH_MSG_CHANNEL_REQUEST of "exec" / "shell" / "subsystem".
The dual-window model (RFC 4254 §5.2) — the crux of flow control. Each channel has two independent windows, one per direction:
- Send window — how many payload bytes you may still send to the peer. Initialized
from the peer's advertised
initial_window_size(in itsOPEN_CONFIRMATION). Decremented by the byte length of everyCHANNEL_DATAyou send. When it reaches 0 you must stop sending and wait. The peer replenishes it by sending youCHANNEL_WINDOW_ADJUST(bytes_to_add). - Receive window — how many bytes you have authorized the peer to send you.
Initialized to your advertised
initial_window_size. Decremented as you consume inboundCHANNEL_DATA/CHANNEL_EXTENDED_DATA. When it runs low (the reference resets at 0), you sendCHANNEL_WINDOW_ADJUST(extend_amount)to grant more and addextend_amountback to your counter. max_packet_sizecaps a singleCHANNEL_DATApayload; the sender chunks output tomin(peer_max_packet_size, remaining_send_window).
Message shapes (payloads, after the recipient-channel uint32):
CHANNEL_WINDOW_ADJUST (93): uint32 recipient_channel, uint32 bytes_to_add
CHANNEL_DATA (94): uint32 recipient_channel, string data
CHANNEL_EXTENDED_DATA (95): uint32 recipient_channel, uint32 data_type(=1 stderr), string data
CHANNEL_EOF (96): uint32 recipient_channel
CHANNEL_CLOSE (97): uint32 recipient_channel
CHANNEL_REQUEST (98): uint32 recipient_channel, string "exit-status", bool F, uint32 code
(or "exit-signal", …) — this is how the exit status arrives
Reference algorithm (what the proxy-client crate implements):
# ---- sender: local writes → remote stdin ----
send_window = 0
on OPEN_CONFIRMATION(peer_initial_window, peer_max_packet):
send_window += peer_initial_window; max_packet = peer_max_packet
on WINDOW_ADJUST(bytes_to_add):
send_window += bytes_to_add # may wake a blocked writer
write(bytes):
append bytes to pending
while pending not empty and send_window > 0:
n = min(len(pending), max_packet, send_window)
emit CHANNEL_DATA(channel, pending[..n]); send_window -= n; drop n from pending
if pending remains: block until send_window > 0 (next WINDOW_ADJUST)
# ---- receiver: remote stdout/stderr → local reads ----
recv_window = my_initial_window
on CHANNEL_DATA(d) | EXTENDED_DATA(stderr, d):
deliver d to the matching local pipe (stdout vs stderr)
recv_window -= len(d)
if recv_window == 0 and readers still alive:
emit WINDOW_ADJUST(channel, extend_amount); recv_window += extend_amount
The reference stores the send window in an await-able atomic counter (multiple writers
add(); the single async writer atomically swaps it to 0 to claim the whole allowance and
otherwise parks on it until non-zero). Any equivalent primitive — a counting semaphore or a
condition variable guarding an integer — works; the requirement is only that a writer
blocked at send_window == 0 is woken when a WINDOW_ADJUST arrives.
Defaults. OpenSSH advertises, for a session channel, a 2 MiB window
(CHAN_SES_WINDOW_DEFAULT = 64 × 32 KiB) and a 32 KiB max_packet_size
(CHAN_SES_PACKET_DEFAULT). A reimplementation may choose its own initial_window_size,
per-adjust extend_amount, and max_packet_size; larger windows reduce round-trips on
high-latency links.
The mux protocol body is encoded with the SSH wire conventions. These are the exact rules
(verified against ssh_format); implement a small encoder/decoder with them:
| Datum | Encoding |
|---|---|
u8 / i8 |
1 byte. |
u16/i16, u32/i32, u64/i64 |
2 / 4 / 8 bytes, big-endian. |
f32 / f64 |
IEEE-754, big-endian (not used by mux). |
bool |
encoded as a u32: 0x00000000 or 0x00000001 (4 bytes). |
char |
encoded as a u32 of the Unicode scalar value. |
string (str) |
uint32 length prefix (big-endian) + raw UTF-8 bytes. NUL bytes are stripped and excluded from the length (the encoder removes \0 from text strings). |
byte string ([u8]) |
uint32 length + raw bytes (NUL not stripped). |
Option::None |
zero bytes emitted. |
Option::Some(v) |
just the encoding of v. |
| unit / unit-struct | zero bytes. |
| tuple / struct / tuple-struct | fields concatenated, no length prefix, no field count. |
sequence (Vec) |
uint32 count + each element encoded in turn. |
| enum: unit variant | uint32 variant_index. |
| enum: newtype/tuple/struct variant | uint32 variant_index + the variant's field(s). |
| map | unsupported (never needed by mux). |
Top-level framing. A complete packet is produced by reserving 4 bytes, encoding the
body, then writing the body length into those first 4 bytes as a big-endian u32. The
length value = number of body bytes (everything after the length field). This is the
uint32 packet_length from §3.2.
Worked examples (from the encoder's own tests):
u32 0x12345678→00 00 00 04 | 12 34 56 78bool true→00 00 00 04 | 00 00 00 01"Hello, world!"→00 00 00 11 | 00 00 00 0D | 48 65 6C 6C 6F ...- A NEW_SESSION request is just the concatenation of:
MUX_C_NEW_SESSION (u32),request_id (u32),reservedempty string (00 00 00 00), four bool-u32 flags,escape_char (u32),terminal_typestring,commandstring — all wrapped in the outerpacket_lengthprefix.
Implementation shortcut used by the reference client (optional): for variable-length messages it encodes the fixed prefix, then appends the big
command/term/address byte-strings via scatter/gather (writev) to avoid copying, computing the outer length asfixed_len + 4 + var_len. The bytes on the wire are identical to encoding everything through the serializer; you can do the simple thing.
Reading responses. Read the 4-byte packet_length, then read exactly that many bytes,
then decode: first u32 is the type, dispatch on it, decode the rest per the table.
Ignore trailing bytes after a successfully decoded message (forward-compat).
Stdio has four shapes; each backend turns them into an fd plus (optionally) a local
handle the caller keeps:
Stdio |
fd given to the child/master | Local handle returned |
|---|---|---|
inherit() |
the parent process's own stdin/stdout/stderr fd |
none |
null() |
an fd for /dev/null (open RDWR once, cache it) |
none |
piped() |
one end of a freshly created OS pipe | the other end, as async ChildStdin/Stdout/Stderr |
| from fd/file | the supplied fd | none |
For a child's stdin the child gets the read end and you keep the write end; for stdout/stderr the child gets the write end and you keep the read end.
- The fds handed to the master via
SCM_RIGHTSmust be blocking (O_NONBLOCKcleared). When you create a pipe with an async runtime, convert the child-facing end to a blocking fd before passing it; for caller-supplied fds, force-clearO_NONBLOCK. - The local ends you keep (
ChildStdin/out/err) are the runtime's non-blocking async pipe handles, so the caller getsasync read/write. Also expose them as raw fds, which is what makes "pipe one remote command's stdout into another remote command's stdin" possible (you hand command B an fd taken from command A's handle).
- process backend: concurrency is just OS processes. Each
Command::spawn()launches anssh -S <ctl>child; the async runtime drives its pipes. Set kill-on-drop on the child so a droppedChildtears down its localssh(which closes the channel). Use a discard-port trick (-p 9) plusBatchMode=yesso the child only ever multiplexes through the master and never tries to open its own TCP connection if the master is gone. - native-mux backend: concurrency is many AF_UNIX connections (§3.1). Each
Command::spawn():- converts the three
Stdios to fds (+ keeps local handles), - opens a new connection to
<ctl>, does HELLO, - sends
NEW_SESSION+ the 3 fds, - returns a
Childwrapping the still-open connection (now in "await exit" state) plus the local stdio handles.Child::wait()reads on that connection until the exit message (or EOF). Dropping theChildcloses the connection, which signals the master to end that channel.
- converts the three
Concurrently drain stdout and stderr to EOF while waiting for exit (don't serialize them,
or a full pipe buffer can deadlock). Then assemble { status, stdout, stderr }.
For the backends described here (passenger mode), you implement no flow control.
Back-pressure is the OS pipe buffers between your ChildStdin/out/err and the master, plus
the SSH windowing that runs inside the master — see §3.10.1. Client-side channel flow
control (send/receive windows, WINDOW_ADJUST, max_packet_size chunking) only exists in
the alternative proxy mode; if you build that backend, implement §3.10.2 in full.
Define one internal interface per public type; each backend supplies an implementation with identical method shapes. The reference uses a tagged-union + macro rather than dynamic dispatch:
Session wraps SessionImpl = ProcessSession | MuxSession
Command wraps CommandImpl = ProcessCommand | MuxCommand
Child wraps ChildImpl = ProcessChild | MuxChild
A delegate!(self, inner => expr) macro expands to a match over whichever variants are
compiled in, so every public method is a thin forward to inner.method(...). Backend
selection:
- Compile-time: feature flags
process-mux(default) andnative-mux. Either or both may be enabled; with both, the enum carries whichever was constructed. - Runtime: the variant is chosen by which constructor you call —
builder.connect()→ process backend,builder.connect_mux()→ mux backend.
In a language without conditional compilation, use an interface/abstract base class with two implementing classes and pick at runtime; the surface API stays identical.
Each backend must implement, behind the boundary: check, raw_command/subsystem,
request_port_forward/close_port_forward, close, plus Command::spawn and
Child::wait/wait_with_output/disconnect/stdio accessors. The master-launch code
(§2.1) sits above the boundary and is shared.
Normalize everything into a single error type. Representative variants:
| Variant | Meaning |
|---|---|
Master(io) |
Failed to set up / talk to the master process. |
Connect(io) |
Initial connection to the remote failed (categorized from stderr; see §7.2). |
Ssh(io) (process backend) |
The local ssh command itself failed to execute. |
SshMux(e) (mux backend) |
mux-protocol-level failure (carries the mux client error). |
InvalidCommand (mux backend) |
Command contained a NUL byte (unrepresentable on the wire). |
Remote(io) |
Remote process failed; includes the normalized "command not found" (exit 127). |
RemoteProcessTerminated |
Remote process ended with no clean exit value (likely a signal). Best-effort in the process backend — may actually be a remote exit code of 255. |
Disconnected |
Connection severed (best-effort). |
Cleanup(io) |
Failed to remove the temp dir. |
ChildIo(io) |
Setting up/operating on a child's stdio failed. |
CommandHasEnv / CommandHasCwd |
OverSsh rejected a command using features SSH can't honor. |
When the master launch or a connect fails, parse the ssh stderr / master log and map to
an io-style error kind:
- "Could not resolve hostname …" / "Network is unreachable" → generic/other
- "Connection refused" → connection-refused
- "Connection timed out" / "Operation timed out" → timed-out
- "Permission denied (…)" → permission-denied
- "Connection to … closed by remote host" → connection-aborted
Strip noise first (a leading ssh: prefix; "Warning: Permanently added …" host-key
lines). This interpret_ssh_error step is what turns opaque exit code 255 from the master
into a meaningful error.
ssh uses 255 for its own failures (auth, connection dropped, …). But a remote
program is also free to exit 255. So:
- process backend cannot tell them apart from the child's exit code alone. It treats
child-exit-255 as
RemoteProcessTerminated, and exposesSession::check()so callers can disambiguate:check()runsssh -O check; if that fails, read the master log (discover_master_error) to learn the real connection error, else reportDisconnected. - mux backend has no ambiguity for the connection: a dead master surfaces as a
socket error (mapped to
Disconnectedfor connection-reset/refused/aborted/not-found kinds). The exit value still comes straight fromMUX_S_EXIT_MESSAGE; an EOF before the exit message is reported asRemoteProcessTerminated(no value). The mux backend builds a wait-style status from the value (e.g.exit_value << 8to mimic a Unix wait status), normalizing 127 → command-not-found.
- process:
ssh -S <ctl> -O check; on failure, read master log. - mux: open a connection and send
MUX_C_ALIVE_CHECK; a validMUX_S_ALIVEwith a nonzero pid means healthy.
- process: run
ssh -S <ctl> -O exit(tells the master to quit), check the master log, then delete the temp dir; surface deletion failure asCleanup. - mux: open a connection and send
MUX_C_STOP_LISTENING, then delete the temp dir.
In both, close() takes ownership, performs graceful shutdown, then removes the temp dir
explicitly (so the caller learns about cleanup errors, unlike the destructor path).
If a Session is dropped without close():
- process: synchronously spawn
ssh -S <ctl> -O exitwith stdio nulled, ignore errors (optionally log), then let the temp-dir handle delete the directory. - mux: call a synchronous "shutdown mux master" routine (open the socket with blocking
std I/O, send
STOP_LISTENING), ignore errors, then the temp dir is deleted.
The synchronous shutdown matters because destructors usually can't run async code. The temp dir's own destructor deletes the socket + log files regardless of whether the graceful shutdown succeeded.
- Per-command children (process backend): kill-on-drop ensures a dropped
Child's localsshis reaped; the master then closes that channel. - Per-command connections (mux backend): dropping a
Child/session closes the AF_UNIX socket; the master tears down the channel. ControlPersistinterplay: with=no, the master self-exits once the last client leaves even if your-O exitnever ran; with=yes, an orphaned master can linger — which is whyclean_history_control_directoryexists: on the nextconnect, it sweeps<socketdir>/.ssh-connection-*and removes stale directories (and thus stale sockets) left by crashed processes.detach()deliberately opts out of all of the above by leaking the temp dir; the master is then owned by whoever later callsresume()(or by a manual cleanup).
- Wire codec (§4): big-endian primitives,
bool-as-u32, length-prefixed strings, the outerpacket_lengthframing. Unit-test against the worked examples. - Master launch +
SessionBuilder(§2): get a working master, control socket, and temp-dir lifetime. This alone makes the process backend mostly functional. - Process backend (§3.6, §5.3, §7.3):
raw_command→ssh -S <ctl> -T -p 9 … -- cmd, stdio via OS pipes, kill-on-drop, 255/127 normalization,check,-O exit. - mux client (§3): HELLO,
ALIVE_CHECK,NEW_SESSION+SCM_RIGHTSfd passing, exit-message waiting,OPEN_FWD/CLOSE_FWD,STOP_LISTENING. Test against a real master created in step 2. - mux backend (§5, §6): blocking-fd conversion,
Stdioplumbing,Child/wait, wire it into the dispatch boundary. - Error normalization + teardown (§7, §8): unify both backends' errors; destructors;
clean_history_control_directory;detach/resume.
- Codec: golden-byte tests for each message (compare against the byte layouts in §3/§4).
- End-to-end against a real
sshd(e.g.localhostor a container): run a command and assert stdout/exit; run several concurrently and confirm a single master/auth is reused (check the master pid viaALIVE_CHECKand that no extra TCP connections open). - Interop: point the mux backend at a master launched by the stock
sshbinary, and point stockssh -S <ctl>at a master your library launched — both must work, proving protocol conformance. - fd passing:
cat-style round-trip through piped stdin→stdout (the reference client's own test does exactly this). - Teardown: assert the temp dir/socket are gone after
close()and after drop; assertclean_history_control_directoryremoves a deliberately-leaked dir. - 255 disambiguation: kill the master out from under a running command and confirm the
process backend reports a connection error via
check(), while the mux backend reportsDisconnected/RemoteProcessTerminated.
For readers cross-referencing the original crates. Paths are relative to each crate root.
| Concept | Rust location |
|---|---|
| Crate root, re-exports, feature gates | openssh/src/lib.rs; features process-mux (default) / native-mux in openssh/Cargo.toml |
Session(SessionImp) + delegate! macro |
openssh/src/session.rs (enum SessionImp { ProcessImpl(..), NativeMuxImpl(..) }) |
Command = OwningCommand<&Session>, CommandImp |
openssh/src/command.rs |
Child = RemoteChild<'_>, RemoteChildImp |
openssh/src/child.rs |
Stdio / StdioImpl (Null/Pipe/Fd/Inherit) |
openssh/src/stdio.rs |
OverSsh trait (+ CommandHasEnv/CommandHasCwd) |
openssh/src/command.rs |
ForwardType, Socket |
openssh/src/port_forwarding.rs |
Dispatch is a tagged union + delegate! macro, not dyn trait objects. Backend chosen by
constructor: connect() (process) vs connect_mux() (mux). Both backends expose the same
private method set; the macro forwards to whichever variants are compiled in.
| Concept | Rust location |
|---|---|
SessionBuilder, all options, resolve(), launch_master() |
openssh/src/builder.rs |
ControlPersist, KnownHosts enums |
openssh/src/builder.rs |
Temp dir (.ssh-connection-*) ownership |
tempfile::TempDir held in each backend Session |
new_process_mux/new_native_mux, resume/resume_mux, detach |
openssh/src/session.rs |
clean_history_control_directory sweep |
openssh/src/builder.rs |
| Concept | Rust location |
|---|---|
Connection (AF_UNIX, exchange_hello, read_response, write, get_request_id) |
openssh-mux-client/crates/mux-client/src/connection.rs |
Protocol constants (SSHMUX_VER=4, MUX_*) |
.../src/constants.rs |
Request enum + Serialize (variant index = packet type) |
.../src/request.rs |
Response enum + hand-written Deserialize |
.../src/response.rs |
Session/SessionZeroCopy (flags, escape_ch=char::MAX, term, cmd) |
.../src/request.rs |
EstablishedSession, wait/wait_impl, SessionStatus, EOF→Exited(None) |
.../src/session.rs |
Socket (Unix port = -2), Fwd (LOCAL/REMOTE/DYNAMIC) |
.../src/request.rs |
open_new_session_impl (scatter/gather write + per-fd send loop) |
.../src/connection.rs |
request_stop_listening / request_stop_listening_sync |
.../src/connection.rs |
Wire codec: Serializer/create_header, to_bytes, bool/char/str rules, NUL stripping |
ssh_format/src/ser.rs (decoder in ssh_format/src/de.rs) |
| Codec error type | ssh_format/ssh_format_error/src/lib.rs |
Notable quirks already called out in the body: escape_ch is char::MAX (0x0010FFFF),
not the spec's 0xFFFFFFFF, because of Rust's char type (§3.5); the reserved field is
serialized as the empty string in NewSession's tuple &(request_id, "", session)
(request.rs); no environment strings are sent.
| Concept | Rust location |
|---|---|
Session (check→send_alive_check, request_port_forward, close, Drop→shutdown_mux_master) |
openssh/src/native_mux_impl/session.rs |
Command (cmd: Vec<u8> byte buffer, raw_arg appends b' ' + bytes, spawn) |
openssh/src/native_mux_impl/command.rs |
RemoteChild (wraps EstablishedSession, wait maps SessionStatus, exit_value << 8) |
openssh/src/native_mux_impl/child.rs |
Stdio→Fd (Owned/Borrowed/Null), set_blocking via fcntl(F_SETFL, !O_NONBLOCK), /dev/null cache, into_blocking_fd() |
openssh/src/native_mux_impl/stdio.rs |
| fd passing | sendfd::SendWithFd over tokio::net::UnixStream (SCM_RIGHTS); local pipes via tokio::net::unix::pipe |
| Concept | Rust location |
|---|---|
Session (new_cmd/new_std_cmd, -S/-T/-p 9/BatchMode, check→-O check, discover_master_error, close→-O exit, Drop) |
openssh/src/process_impl/session.rs |
Command (wraps tokio::process::Command, kill_on_drop(true), spawn) |
openssh/src/process_impl/command.rs |
RemoteChild (255→RemoteProcessTerminated, 127→NotFound) |
openssh/src/process_impl/child.rs |
| stdio | tokio::process::{ChildStdin,ChildStdout,ChildStderr} directly |
| Concept | Rust location |
|---|---|
Error enum, From<openssh_mux_client::Error> (→Disconnected), interpret_ssh_error |
openssh/src/error.rs |
Session::close (calls backend close, then TempDir::close→Error::Cleanup) |
openssh/src/session.rs |
Synchronous master shutdown for Drop |
openssh-mux-client/.../shutdown_mux_master.rs (request_stop_listening_sync) |
tokio (async runtime, process, unix pipes, UnixStream) · tempfile (auto-cleaned temp
dir) · serde + ssh_format (wire codec) · sendfd (SCM_RIGHTS fd passing) ·
shell-escape (arg quoting) · libc (fcntl) · once_cell (cached /dev/null) ·
typed-builder (Session builder). A port should map each to its ecosystem's counterpart;
none are load-bearing on Rust specifically except where noted (the char/escape_ch quirk).
Lives in a separate, partial proxy-client crate in the openssh-mux-client
workspace, not consumed by the openssh crate. Paths relative to
openssh-mux-client/crates/proxy-client/src/.
| Concept | Location |
|---|---|
SSH transport framing (Request<T> = padding_len=0, packet_type, body; 4-byte length header) |
request/mod.rs |
SSH_MSG_* connection-protocol constants (80–100, SSH_EXTENDED_DATA_STDERR) |
constants.rs |
CHANNEL_OPEN (advertises initial_window_size, max_packet_size) |
request/channel/open_channel.rs |
CHANNEL_WINDOW_ADJUST + CHANNEL_DATA header builders |
request/channel/data_transfer.rs (ChannelAdjustWindow, DataTransfer) |
Send window (sender chunks to min(max_packet, window), blocks at 0) |
proxy_client/channel/channel_input.rs (curr_sender_win, try_flush, poll_ready) |
Await-able send-window counter (multi-writer add, single-reader swap-to-0) |
proxy_client/channel/awaitable_atomic_u64.rs |
Receive window (decrement on inbound data; emit WINDOW_ADJUST at 0) |
proxy_client/read_task.rs (handle_incoming_data, receiver_win_size, extend_window_size) |
Response parsing (BytesAdjust/Data/ExtendedData/Eof/Close/exit) |
response/mod.rs, response/channel/ |
ProxyClient entry point (spawns read/write tasks over the proxied socket) |
proxy_client/mod.rs |
Reference default window/packet come from OpenSSH itself: CHAN_SES_WINDOW_DEFAULT
(2 MiB) and CHAN_SES_PACKET_DEFAULT (32 KiB) in openssh-portable/channels.h.