From 280ea90936f16644d7d5380d158627a8fa76bade Mon Sep 17 00:00:00 2001 From: Russell Bryant Date: Wed, 29 Jul 2026 16:44:52 -0400 Subject: [PATCH] fix(cli): bracket IPv6 bind literals in SSH forwards ForwardSpec accepts IPv6 bind addresses, but ssh_forward_arg() emitted them unbracketed (e.g. ::1:8080:127.0.0.1:8080), which OpenSSH rejects as a bad local forwarding specification. access_url() likewise produced invalid URLs like http://::1:8080/. Extract a shared bracket_ipv6_host() helper and use it in ssh_forward_arg(), access_url() (via format_gateway_url), and format_gateway_url() so IPv6 literals are bracketed consistently. Fixes #2279 Signed-off-by: Russell Bryant --- crates/openshell-core/src/forward.rs | 58 +++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index 70ab74edd0..1d97174d99 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -8,6 +8,7 @@ use crate::paths::{create_dir_restricted, xdg_config_dir}; use miette::{IntoDiagnostic, Result, WrapErr}; +use std::borrow::Cow; use std::net::TcpListener; use std::path::PathBuf; use std::process::Command; @@ -580,18 +581,28 @@ impl ForwardSpec { } /// The SSH `-L` local-forward argument: `bind_addr:port:127.0.0.1:port`. + /// + /// IPv6 bind literals are bracketed (`::1` → `[::1]`) because OpenSSH + /// rejects an unbracketed IPv6 address in a forward specification. pub fn ssh_forward_arg(&self) -> String { - format!("{}:{}:127.0.0.1:{}", self.bind_addr, self.port, self.port) + format!( + "{}:{}:127.0.0.1:{}", + bracket_ipv6_host(&self.bind_addr), + self.port, + self.port + ) } /// A human-readable URL for the forwarded port. pub fn access_url(&self) -> String { + // Wildcard binds are not connectable targets, so display a reachable + // loopback host instead. let host = if self.bind_addr == "0.0.0.0" || self.bind_addr == "::" { "localhost" } else { &self.bind_addr }; - format!("http://{host}:{}/", self.port) + format!("{}/", format_gateway_url("http", host, self.port)) } } @@ -747,18 +758,24 @@ pub fn resolve_ssh_gateway( (gateway_host.to_string(), gateway_port) } -/// Format a gateway URL, bracketing IPv6 literals when needed. -pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String { - let host = if host +/// Bracket a bare IPv6 literal (e.g. `::1` → `[::1]`) so it can be embedded in +/// `host:port` syntax. Non-IPv6 hosts (DNS names, IPv4) and already-bracketed +/// literals are returned unchanged. +fn bracket_ipv6_host(host: &str) -> Cow<'_, str> { + if host .parse::() .is_ok_and(|ip| ip.is_ipv6()) && !host.starts_with('[') { - format!("[{host}]") + Cow::Owned(format!("[{host}]")) } else { - host.to_string() - }; - format!("{scheme}://{host}:{port}") + Cow::Borrowed(host) + } +} + +/// Format a gateway URL, bracketing IPv6 literals when needed. +pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String { + format!("{scheme}://{}:{port}", bracket_ipv6_host(host)) } /// Shell-escape a value for use inside a `ProxyCommand` string. @@ -1413,6 +1430,17 @@ mod tests { assert_eq!(spec.ssh_forward_arg(), "127.0.0.1:8080:127.0.0.1:8080"); } + #[test] + fn forward_spec_ssh_forward_arg_brackets_ipv6_literal() { + // OpenSSH rejects an unbracketed IPv6 bind address in a `-L` + // specification; the literal must be wrapped in brackets. + let spec = ForwardSpec::parse("::1:8080").unwrap(); + assert_eq!(spec.ssh_forward_arg(), "[::1]:8080:127.0.0.1:8080"); + + let spec = ForwardSpec::parse(":::8080").unwrap(); + assert_eq!(spec.ssh_forward_arg(), "[::]:8080:127.0.0.1:8080"); + } + #[test] fn ssh_forward_command_matches_exact_l_argument() { let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox"; @@ -1663,6 +1691,18 @@ mod tests { assert_eq!(spec.access_url(), "http://localhost:8080/"); } + #[test] + fn forward_spec_access_url_ipv6() { + // A specific IPv6 loopback literal must be bracketed for a valid URL. + let spec = ForwardSpec::parse("::1:8080").unwrap(); + assert_eq!(spec.access_url(), "http://[::1]:8080/"); + + // The IPv6 wildcard bind is not a connectable target, so it maps to a + // reachable host for display. + let spec = ForwardSpec::parse(":::8080").unwrap(); + assert_eq!(spec.access_url(), "http://localhost:8080/"); + } + #[test] fn forward_spec_display() { let spec = ForwardSpec::parse("8080").unwrap();