|
8 | 8 |
|
9 | 9 | use crate::paths::{create_dir_restricted, xdg_config_dir}; |
10 | 10 | use miette::{IntoDiagnostic, Result, WrapErr}; |
| 11 | +use std::borrow::Cow; |
11 | 12 | use std::net::TcpListener; |
12 | 13 | use std::path::PathBuf; |
13 | 14 | use std::process::Command; |
@@ -580,18 +581,28 @@ impl ForwardSpec { |
580 | 581 | } |
581 | 582 |
|
582 | 583 | /// The SSH `-L` local-forward argument: `bind_addr:port:127.0.0.1:port`. |
| 584 | + /// |
| 585 | + /// IPv6 bind literals are bracketed (`::1` → `[::1]`) because OpenSSH |
| 586 | + /// rejects an unbracketed IPv6 address in a forward specification. |
583 | 587 | pub fn ssh_forward_arg(&self) -> String { |
584 | | - format!("{}:{}:127.0.0.1:{}", self.bind_addr, self.port, self.port) |
| 588 | + format!( |
| 589 | + "{}:{}:127.0.0.1:{}", |
| 590 | + bracket_ipv6_host(&self.bind_addr), |
| 591 | + self.port, |
| 592 | + self.port |
| 593 | + ) |
585 | 594 | } |
586 | 595 |
|
587 | 596 | /// A human-readable URL for the forwarded port. |
588 | 597 | pub fn access_url(&self) -> String { |
| 598 | + // Wildcard binds are not connectable targets, so display a reachable |
| 599 | + // loopback host instead. |
589 | 600 | let host = if self.bind_addr == "0.0.0.0" || self.bind_addr == "::" { |
590 | 601 | "localhost" |
591 | 602 | } else { |
592 | 603 | &self.bind_addr |
593 | 604 | }; |
594 | | - format!("http://{host}:{}/", self.port) |
| 605 | + format!("{}/", format_gateway_url("http", host, self.port)) |
595 | 606 | } |
596 | 607 | } |
597 | 608 |
|
@@ -747,18 +758,24 @@ pub fn resolve_ssh_gateway( |
747 | 758 | (gateway_host.to_string(), gateway_port) |
748 | 759 | } |
749 | 760 |
|
750 | | -/// Format a gateway URL, bracketing IPv6 literals when needed. |
751 | | -pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String { |
752 | | - let host = if host |
| 761 | +/// Bracket a bare IPv6 literal (e.g. `::1` → `[::1]`) so it can be embedded in |
| 762 | +/// `host:port` syntax. Non-IPv6 hosts (DNS names, IPv4) and already-bracketed |
| 763 | +/// literals are returned unchanged. |
| 764 | +fn bracket_ipv6_host(host: &str) -> Cow<'_, str> { |
| 765 | + if host |
753 | 766 | .parse::<std::net::IpAddr>() |
754 | 767 | .is_ok_and(|ip| ip.is_ipv6()) |
755 | 768 | && !host.starts_with('[') |
756 | 769 | { |
757 | | - format!("[{host}]") |
| 770 | + Cow::Owned(format!("[{host}]")) |
758 | 771 | } else { |
759 | | - host.to_string() |
760 | | - }; |
761 | | - format!("{scheme}://{host}:{port}") |
| 772 | + Cow::Borrowed(host) |
| 773 | + } |
| 774 | +} |
| 775 | + |
| 776 | +/// Format a gateway URL, bracketing IPv6 literals when needed. |
| 777 | +pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String { |
| 778 | + format!("{scheme}://{}:{port}", bracket_ipv6_host(host)) |
762 | 779 | } |
763 | 780 |
|
764 | 781 | /// Shell-escape a value for use inside a `ProxyCommand` string. |
@@ -1413,6 +1430,17 @@ mod tests { |
1413 | 1430 | assert_eq!(spec.ssh_forward_arg(), "127.0.0.1:8080:127.0.0.1:8080"); |
1414 | 1431 | } |
1415 | 1432 |
|
| 1433 | + #[test] |
| 1434 | + fn forward_spec_ssh_forward_arg_brackets_ipv6_literal() { |
| 1435 | + // OpenSSH rejects an unbracketed IPv6 bind address in a `-L` |
| 1436 | + // specification; the literal must be wrapped in brackets. |
| 1437 | + let spec = ForwardSpec::parse("::1:8080").unwrap(); |
| 1438 | + assert_eq!(spec.ssh_forward_arg(), "[::1]:8080:127.0.0.1:8080"); |
| 1439 | + |
| 1440 | + let spec = ForwardSpec::parse(":::8080").unwrap(); |
| 1441 | + assert_eq!(spec.ssh_forward_arg(), "[::]:8080:127.0.0.1:8080"); |
| 1442 | + } |
| 1443 | + |
1416 | 1444 | #[test] |
1417 | 1445 | fn ssh_forward_command_matches_exact_l_argument() { |
1418 | 1446 | 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 { |
1663 | 1691 | assert_eq!(spec.access_url(), "http://localhost:8080/"); |
1664 | 1692 | } |
1665 | 1693 |
|
| 1694 | + #[test] |
| 1695 | + fn forward_spec_access_url_ipv6() { |
| 1696 | + // A specific IPv6 loopback literal must be bracketed for a valid URL. |
| 1697 | + let spec = ForwardSpec::parse("::1:8080").unwrap(); |
| 1698 | + assert_eq!(spec.access_url(), "http://[::1]:8080/"); |
| 1699 | + |
| 1700 | + // The IPv6 wildcard bind is not a connectable target, so it maps to a |
| 1701 | + // reachable host for display. |
| 1702 | + let spec = ForwardSpec::parse(":::8080").unwrap(); |
| 1703 | + assert_eq!(spec.access_url(), "http://localhost:8080/"); |
| 1704 | + } |
| 1705 | + |
1666 | 1706 | #[test] |
1667 | 1707 | fn forward_spec_display() { |
1668 | 1708 | let spec = ForwardSpec::parse("8080").unwrap(); |
|
0 commit comments