Subject: [PATCH] client: add Handle::channel_open_forwarded_tcpip Adds the ability for an SSH *client* to initiate a `forwarded-tcpip` channel. The existing upstream API only lets clients open `session`, `x11`, `direct-tcpip`, and `direct-streamlocal` channels — sufficient for RFC 4254 conformance, where `forwarded-tcpip` is a server-initiated channel sent in response to a prior `tcpip-forward` request. Microsoft's Dev Tunnels protocol inverts that convention: the tunnel *client* opens `forwarded-tcpip` directly to reach a known remote port on the tunnel host. This patch makes that possible without touching any private state — it mirrors exactly the shape of the existing `channel_open_direct_tcpip` plumbing. Applies against microsoft/vscode-russh `main` (commit fd4f608), which is byte-identical to Eugeny/russh v0.37.1 in the files this patch touches. --- russh/src/client/mod.rs | 37 +++++++++++++++++++++++++++++++++++++ russh/src/client/session.rs | 15 +++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/russh/src/client/mod.rs b/russh/src/client/mod.rs --- a/russh/src/client/mod.rs +++ b/russh/src/client/mod.rs @@ -178,6 +178,13 @@ pub enum Msg { originator_port: u32, sender: UnboundedSender, }, + ChannelOpenForwardedTcpIp { + connected_address: String, + connected_port: u32, + originator_address: String, + originator_port: u32, + sender: UnboundedSender, + }, ChannelOpenDirectStreamLocal { socket_path: String, sender: UnboundedSender, @@ -511,6 +518,28 @@ impl Handle { self.wait_channel_confirmation(receiver).await } + /// Open a TCP/IP forwarding channel for a remotely forwarded port. + pub async fn channel_open_forwarded_tcpip, B: Into>( + &self, + connected_address: A, + connected_port: u32, + originator_address: B, + originator_port: u32, + ) -> Result, crate::Error> { + let (sender, receiver) = unbounded_channel(); + self.sender + .send(Msg::ChannelOpenForwardedTcpIp { + connected_address: connected_address.into(), + connected_port, + originator_address: originator_address.into(), + originator_port, + sender, + }) + .await + .map_err(|_| crate::Error::SendError)?; + self.wait_channel_confirmation(receiver).await + } + pub async fn channel_open_direct_streamlocal>( &self, socket_path: S, @@ -890,6 +919,21 @@ impl Session { )?; self.channels.insert(id, sender); } + Msg::ChannelOpenForwardedTcpIp { + connected_address, + connected_port, + originator_address, + originator_port, + sender, + } => { + let id = self.channel_open_forwarded_tcpip( + &connected_address, + connected_port, + &originator_address, + originator_port, + )?; + self.channels.insert(id, sender); + } Msg::ChannelOpenDirectStreamLocal { socket_path, sender, diff --git a/russh/src/client/session.rs b/russh/src/client/session.rs --- a/russh/src/client/session.rs +++ b/russh/src/client/session.rs @@ -79,6 +79,21 @@ impl Session { }) } + pub fn channel_open_forwarded_tcpip( + &mut self, + connected_address: &str, + connected_port: u32, + originator_address: &str, + originator_port: u32, + ) -> Result { + self.channel_open_generic(b"forwarded-tcpip", |write| { + write.extend_ssh_string(connected_address.as_bytes()); + write.push_u32_be(connected_port); // sender channel id. + write.extend_ssh_string(originator_address.as_bytes()); + write.push_u32_be(originator_port); // sender channel id. + }) + } + pub fn channel_open_direct_streamlocal( &mut self, socket_path: &str