From 7cca7bc6d62ce3821ee95c831c937410d4bf8225 Mon Sep 17 00:00:00 2001 From: Wuji Chen Date: Thu, 26 Feb 2026 16:42:56 +0800 Subject: [PATCH] ssh: Fix IPv6 address formatting in port forward `-L` arguments (#49032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix SSH `-L` port-forward arguments to wrap IPv6 addresses in brackets (e.g. `-L[::1]:8080:[::1]:80`), so SSH can correctly parse them - Rewrite `parse_port_forward_spec` to support bracket-wrapped IPv6 tokens like `[::1]:8080:[::1]:80` - Add diagnostic logging for stdin read failures in the remote server to aid debugging connection issues Closes #49009 ## Test plan - [x] New unit tests: `test_parse_port_forward_spec_ipv6`, `test_port_forward_ipv6_formatting`, `test_build_command_with_ipv6_port_forward` - [x] Existing tests pass: `cargo test -p remote --lib transport::ssh::tests` (6/6) - [ ] Manual verification: connect via SSH to an IPv6 host with port forwarding configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude --- crates/remote/src/transport/ssh.rs | 171 ++++++++++++++++++++++++++--- crates/remote_server/src/server.rs | 15 ++- 2 files changed, 167 insertions(+), 19 deletions(-) diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 83733306e7a1c96209f12158263f719c22abf54c..d27662dde3656de1e2434273bee554a168198371 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -94,6 +94,14 @@ impl Default for SshConnectionHost { } } +fn bracket_ipv6(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{}]", host) + } else { + host.to_string() + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub struct SshConnectionOptions { pub host: SshConnectionHost, @@ -344,7 +352,12 @@ impl RemoteConnection for SshRemoteConnection { args.push("-N".into()); for (local_port, host, remote_port) in forwards { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } args.push(socket.connection_options.ssh_destination()); Ok(CommandTemplate { @@ -1342,33 +1355,71 @@ fn parse_port_number(port_str: &str) -> Result { .with_context(|| format!("parsing port number: {port_str}")) } +fn split_port_forward_tokens(spec: &str) -> Result> { + let mut tokens = Vec::new(); + let mut chars = spec.chars().peekable(); + + while chars.peek().is_some() { + if chars.peek() == Some(&'[') { + chars.next(); + let mut bracket_content = String::new(); + loop { + match chars.next() { + Some(']') => break, + Some(ch) => bracket_content.push(ch), + None => anyhow::bail!("Unmatched '[' in port forward spec: {spec}"), + } + } + tokens.push(bracket_content); + if chars.peek() == Some(&':') { + chars.next(); + } + } else { + let mut token = String::new(); + for ch in chars.by_ref() { + if ch == ':' { + break; + } + token.push(ch); + } + tokens.push(token); + } + } + + Ok(tokens) +} + fn parse_port_forward_spec(spec: &str) -> Result { - let parts: Vec<&str> = spec.split(':').collect(); + let tokens = if spec.contains('[') { + split_port_forward_tokens(spec)? + } else { + spec.split(':').map(String::from).collect() + }; - match *parts { - [a, b, c, d] => { - let local_port = parse_port_number(b)?; - let remote_port = parse_port_number(d)?; + match tokens.len() { + 4 => { + let local_port = parse_port_number(&tokens[1])?; + let remote_port = parse_port_number(&tokens[3])?; Ok(SshPortForwardOption { - local_host: Some(a.to_string()), + local_host: Some(tokens[0].clone()), local_port, - remote_host: Some(c.to_string()), + remote_host: Some(tokens[2].clone()), remote_port, }) } - [a, b, c] => { - let local_port = parse_port_number(a)?; - let remote_port = parse_port_number(c)?; + 3 => { + let local_port = parse_port_number(&tokens[0])?; + let remote_port = parse_port_number(&tokens[2])?; Ok(SshPortForwardOption { local_host: None, local_port, - remote_host: Some(b.to_string()), + remote_host: Some(tokens[1].clone()), remote_port, }) } - _ => anyhow::bail!("Invalid port forward format"), + _ => anyhow::bail!("Invalid port forward format: {spec}"), } } @@ -1534,7 +1585,10 @@ impl SshConnectionOptions { format!( "-L{}:{}:{}:{}", - local_host, pf.local_port, remote_host, pf.remote_port + bracket_ipv6(local_host), + pf.local_port, + bracket_ipv6(remote_host), + pf.remote_port ) })); } @@ -1641,7 +1695,12 @@ fn build_command_posix( if let Some((local_port, host, remote_port)) = port_forward { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } // -q suppresses the "Connection to ... closed." message that SSH prints when @@ -1731,7 +1790,12 @@ fn build_command_windows( if let Some((local_port, host, remote_port)) = port_forward { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } // -q suppresses the "Connection to ... closed." message that SSH prints when @@ -1938,4 +2002,79 @@ mod tests { Ok(()) } + + #[test] + fn test_parse_port_forward_spec_ipv6() -> Result<()> { + let pf = parse_port_forward_spec("[::1]:8080:[::1]:80")?; + assert_eq!(pf.local_host, Some("::1".to_string())); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("::1".to_string())); + assert_eq!(pf.remote_port, 80); + + let pf = parse_port_forward_spec("8080:[::1]:80")?; + assert_eq!(pf.local_host, None); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("::1".to_string())); + assert_eq!(pf.remote_port, 80); + + let pf = parse_port_forward_spec("[2001:db8::1]:3000:[fe80::1]:4000")?; + assert_eq!(pf.local_host, Some("2001:db8::1".to_string())); + assert_eq!(pf.local_port, 3000); + assert_eq!(pf.remote_host, Some("fe80::1".to_string())); + assert_eq!(pf.remote_port, 4000); + + let pf = parse_port_forward_spec("127.0.0.1:8080:localhost:80")?; + assert_eq!(pf.local_host, Some("127.0.0.1".to_string())); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("localhost".to_string())); + assert_eq!(pf.remote_port, 80); + + Ok(()) + } + + #[test] + fn test_port_forward_ipv6_formatting() { + let options = SshConnectionOptions { + host: "example.com".into(), + port_forwards: Some(vec![SshPortForwardOption { + local_host: Some("::1".to_string()), + local_port: 8080, + remote_host: Some("::1".to_string()), + remote_port: 80, + }]), + ..Default::default() + }; + + let args = options.additional_args(); + assert!( + args.iter().any(|arg| arg == "-L[::1]:8080:[::1]:80"), + "expected bracketed IPv6 in -L flag: {args:?}" + ); + } + + #[test] + fn test_build_command_with_ipv6_port_forward() -> Result<()> { + let command = build_command_posix( + None, + &[], + &HashMap::default(), + None, + Some((8080, "::1".to_owned(), 80)), + HashMap::default(), + PathStyle::Posix, + "/bin/bash", + ShellKind::Posix, + vec![], + "user@host", + Interactive::No, + )?; + + assert!( + command.args.iter().any(|arg| arg == "8080:[::1]:80"), + "expected bracketed IPv6 in port forward arg: {:?}", + command.args + ); + + Ok(()) + } } diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index 6784f5fc1d221989aeaf1ecbd34da65f8f923a87..bc39e4635e96110f5e9179ba744afc6f93f8e341 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -356,9 +356,18 @@ fn start_server( let (mut stdin_msg_tx, mut stdin_msg_rx) = mpsc::unbounded::(); cx.background_spawn(async move { - while let Ok(msg) = read_message(&mut stdin_stream, &mut input_buffer).await { - if (stdin_msg_tx.send(msg).await).is_err() { - break; + loop { + match read_message(&mut stdin_stream, &mut input_buffer).await { + Ok(msg) => { + if (stdin_msg_tx.send(msg).await).is_err() { + log::info!("stdin message channel closed, stopping stdin reader"); + break; + } + } + Err(error) => { + log::warn!("stdin read failed: {error:?}"); + break; + } } } }).detach();