Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions crates/openshell-core/src/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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::<std::net::IpAddr>()
.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.
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading