ssh: Fix IPv6 address formatting in port forward -L arguments (#49032)

## 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 <noreply@anthropic.com>
This commit is contained in:
Wuji Chen 2026-02-26 16:42:56 +08:00 committed by GitHub
parent 845328662d
commit 7cca7bc6d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 168 additions and 20 deletions

View file

@ -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<u16> {
.with_context(|| format!("parsing port number: {port_str}"))
}
fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
let parts: Vec<&str> = spec.split(':').collect();
fn split_port_forward_tokens(spec: &str) -> Result<Vec<String>> {
let mut tokens = Vec::new();
let mut chars = spec.chars().peekable();
match *parts {
[a, b, c, d] => {
let local_port = parse_port_number(b)?;
let remote_port = parse_port_number(d)?;
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<SshPortForwardOption> {
let tokens = if spec.contains('[') {
split_port_forward_tokens(spec)?
} else {
spec.split(':').map(String::from).collect()
};
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(())
}
}

View file

@ -356,9 +356,18 @@ fn start_server(
let (mut stdin_msg_tx, mut stdin_msg_rx) = mpsc::unbounded::<Envelope>();
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();