mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-06 08:44:05 +08:00
* fix(cli): preserve external TLS for SSH forwarding Resolve the external scheme together with its authority when replacing an internal bind address with the registered gateway endpoint. Ported from #3714. Fixes: #3674 Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * docs(kubernetes): explain TLS SSH relay endpoint Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> --------- Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>
2044 lines
69 KiB
Rust
2044 lines
69 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Shared port-forward PID file management and SSH utility functions.
|
|
//!
|
|
//! Used by both the CLI (`openshell-cli`) and the TUI (`openshell-tui`) to
|
|
//! start, stop, list, and track background SSH port forwards.
|
|
|
|
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::{Component, PathBuf};
|
|
use std::process::Command;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Forward PID file management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Base directory for forward PID files.
|
|
pub fn forward_pid_dir() -> Result<PathBuf> {
|
|
Ok(xdg_config_dir()?.join("openshell").join("forwards"))
|
|
}
|
|
|
|
fn safe_path_component(value: &str, kind: &str) -> Result<()> {
|
|
if value.is_empty()
|
|
|| !matches!(
|
|
std::path::Path::new(value)
|
|
.components()
|
|
.collect::<Vec<_>>()
|
|
.as_slice(),
|
|
[Component::Normal(_)]
|
|
)
|
|
{
|
|
return Err(miette::miette!("invalid {kind} name for forward tracking"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// PID file path for a workspace-scoped sandbox + port forward.
|
|
pub fn forward_pid_path(workspace: &str, name: &str, port: u16) -> Result<PathBuf> {
|
|
safe_path_component(workspace, "workspace")?;
|
|
safe_path_component(name, "sandbox")?;
|
|
Ok(forward_pid_dir()?
|
|
.join(workspace)
|
|
.join(format!("{name}-{port}.pid")))
|
|
}
|
|
|
|
/// Write a PID file for a background forward.
|
|
///
|
|
/// File format: `<pid>\t<sandbox_id>\t<bind_addr>`
|
|
pub fn write_forward_pid(
|
|
workspace: &str,
|
|
name: &str,
|
|
port: u16,
|
|
pid: u32,
|
|
sandbox_id: &str,
|
|
bind_addr: &str,
|
|
) -> Result<()> {
|
|
let path = forward_pid_path(workspace, name, port)?;
|
|
let dir = path
|
|
.parent()
|
|
.ok_or_else(|| miette::miette!("forward PID path has no parent"))?;
|
|
create_dir_restricted(dir)?;
|
|
std::fs::write(&path, format!("{pid}\t{sandbox_id}\t{bind_addr}"))
|
|
.into_diagnostic()
|
|
.wrap_err("failed to write forward PID file")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Find the PID of a backgrounded SSH forward by searching for the matching
|
|
/// SSH process. Falls back to `pgrep` since SSH `-f` forks a new process
|
|
/// whose PID we cannot capture directly.
|
|
pub fn find_ssh_forward_pid(
|
|
workspace: &str,
|
|
sandbox_name: &str,
|
|
sandbox_id: &str,
|
|
port: u16,
|
|
) -> Option<u32> {
|
|
// Use pgrep only as a broad process source. The command line still needs a
|
|
// second exact check before the PID can be tracked or signaled, otherwise a
|
|
// requested port such as 80 can substring-match an existing 8080 forward.
|
|
let pattern = "ssh-proxy";
|
|
let output = Command::new("pgrep").arg("-f").arg(pattern).output().ok()?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
// pgrep may return multiple PIDs; scan from newest to oldest and return
|
|
// the first one that still passes the exact command-line validation.
|
|
stdout
|
|
.lines()
|
|
.rev()
|
|
.filter_map(|l| l.trim().parse::<u32>().ok())
|
|
.find(|pid| {
|
|
pid_matches_openshell_ssh_forward(
|
|
*pid,
|
|
port,
|
|
Some(workspace),
|
|
Some(sandbox_name),
|
|
Some(sandbox_id),
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Record read from a forward PID file.
|
|
pub struct ForwardPidRecord {
|
|
pub pid: u32,
|
|
pub sandbox_id: Option<String>,
|
|
/// Bind address from the PID file, or `None` for old-format files.
|
|
pub bind_addr: Option<String>,
|
|
}
|
|
|
|
/// Read the PID from a forward PID file. Returns `None` if the file does not
|
|
/// exist or cannot be parsed.
|
|
pub fn read_forward_pid(workspace: &str, name: &str, port: u16) -> Option<ForwardPidRecord> {
|
|
let path = forward_pid_path(workspace, name, port).ok()?;
|
|
let contents = std::fs::read_to_string(path).ok()?;
|
|
let mut parts = contents.split('\t');
|
|
let pid = parts.next()?.trim().parse().ok()?;
|
|
let sandbox_id = parts.next().map(str::to_string);
|
|
let bind_addr = parts.next().map(|s| s.trim().to_string());
|
|
Some(ForwardPidRecord {
|
|
pid,
|
|
sandbox_id,
|
|
bind_addr,
|
|
})
|
|
}
|
|
|
|
/// Check whether a process is alive.
|
|
pub fn pid_is_alive(pid: u32) -> bool {
|
|
// `kill -0 <pid>` checks if we can signal the process without actually
|
|
// sending a signal.
|
|
Command::new("kill")
|
|
.arg("-0")
|
|
.arg(pid.to_string())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
}
|
|
|
|
/// Validate that a PID belongs to the expected `OpenShell` SSH forward.
|
|
pub fn pid_matches_openshell_ssh_forward(
|
|
pid: u32,
|
|
port: u16,
|
|
workspace: Option<&str>,
|
|
sandbox_name: Option<&str>,
|
|
sandbox_id: Option<&str>,
|
|
) -> bool {
|
|
let Some(argv) = process_forward_match_tokens(pid) else {
|
|
return false;
|
|
};
|
|
let tokens: Vec<&str> = argv.iter().map(String::as_str).collect();
|
|
args_match_ssh_forward(&tokens, port, workspace, sandbox_name, sandbox_id)
|
|
}
|
|
|
|
/// Read a process command line as matcher tokens.
|
|
///
|
|
/// Linux exposes exact argv from `/proc/<pid>/cmdline`; embedded
|
|
/// `ProxyCommand=` values are split so the matcher sees a flat token sequence.
|
|
#[cfg(target_os = "linux")]
|
|
fn process_forward_match_tokens(pid: u32) -> Option<Vec<String>> {
|
|
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
let argv: Vec<String> = raw
|
|
.split(|byte| *byte == 0)
|
|
.filter(|segment| !segment.is_empty())
|
|
.flat_map(|segment| expand_proxy_command_arg(&String::from_utf8_lossy(segment)))
|
|
.collect();
|
|
(!argv.is_empty()).then_some(argv)
|
|
}
|
|
|
|
/// Non-Linux fallback. `ps` flattens argv, so paths with spaces fail closed.
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn process_forward_match_tokens(pid: u32) -> Option<Vec<String>> {
|
|
let output = Command::new("ps")
|
|
.arg("-ww")
|
|
.arg("-o")
|
|
.arg("command=")
|
|
.arg("-p")
|
|
.arg(pid.to_string())
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let cmd = String::from_utf8_lossy(&output.stdout);
|
|
let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
|
|
(!argv.is_empty()).then_some(argv)
|
|
}
|
|
|
|
/// Expand a `ProxyCommand=<value>` element into matcher tokens.
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn expand_proxy_command_arg(arg: &str) -> Vec<String> {
|
|
let Some(value) = arg.strip_prefix("ProxyCommand=") else {
|
|
return vec![arg.to_string()];
|
|
};
|
|
let mut words = split_proxy_command_words(value);
|
|
if words.is_empty() {
|
|
return vec![arg.to_string()];
|
|
}
|
|
words[0] = format!("ProxyCommand={}", words[0]);
|
|
words
|
|
}
|
|
|
|
/// Split shell words well enough to round-trip values emitted by [`shell_escape`].
|
|
#[cfg(any(target_os = "linux", test))]
|
|
fn split_proxy_command_words(input: &str) -> Vec<String> {
|
|
let mut words = Vec::new();
|
|
let mut current: Option<String> = None;
|
|
let mut chars = input.chars();
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
c if c.is_whitespace() => {
|
|
if let Some(word) = current.take() {
|
|
words.push(word);
|
|
}
|
|
}
|
|
'\'' => {
|
|
let buf = current.get_or_insert_with(String::new);
|
|
for q in chars.by_ref() {
|
|
if q == '\'' {
|
|
break;
|
|
}
|
|
buf.push(q);
|
|
}
|
|
}
|
|
'"' => {
|
|
let buf = current.get_or_insert_with(String::new);
|
|
for q in chars.by_ref() {
|
|
if q == '"' {
|
|
break;
|
|
}
|
|
buf.push(q);
|
|
}
|
|
}
|
|
other => current.get_or_insert_with(String::new).push(other),
|
|
}
|
|
}
|
|
if let Some(word) = current.take() {
|
|
words.push(word);
|
|
}
|
|
words
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct ProxyCommandMatch {
|
|
outer_ssh_args_start: usize,
|
|
owner_requirement_met: bool,
|
|
prefix_has_no_command: bool,
|
|
}
|
|
|
|
/// Match an `OpenShell` SSH forward by proxy ownership and outer SSH args.
|
|
fn args_match_ssh_forward(
|
|
args: &[&str],
|
|
port: u16,
|
|
workspace: Option<&str>,
|
|
sandbox_name: Option<&str>,
|
|
sandbox_id: Option<&str>,
|
|
) -> bool {
|
|
if args.first().and_then(|arg| arg.rsplit('/').next()) != Some("ssh") {
|
|
return false;
|
|
}
|
|
let Some(proxy) = find_proxy_command_match(args, workspace, sandbox_name) else {
|
|
return false;
|
|
};
|
|
if (workspace.is_some() || sandbox_name.is_some()) && !proxy.owner_requirement_met {
|
|
return false;
|
|
}
|
|
outer_ssh_forward_matches(
|
|
&args[proxy.outer_ssh_args_start..],
|
|
port,
|
|
proxy.prefix_has_no_command,
|
|
sandbox_id,
|
|
)
|
|
}
|
|
|
|
/// Test-only wrapper for flat command lines.
|
|
#[cfg(test)]
|
|
fn command_matches_ssh_forward(
|
|
command: &str,
|
|
port: u16,
|
|
workspace: Option<&str>,
|
|
sandbox_name: Option<&str>,
|
|
) -> bool {
|
|
let args = command.split_whitespace().collect::<Vec<_>>();
|
|
args_match_ssh_forward(&args, port, workspace, sandbox_name, None)
|
|
}
|
|
|
|
fn find_proxy_command_match(
|
|
args: &[&str],
|
|
workspace: Option<&str>,
|
|
sandbox_name: Option<&str>,
|
|
) -> Option<ProxyCommandMatch> {
|
|
for (index, arg) in args.iter().enumerate().skip(1) {
|
|
let Some(prefix_has_no_command) = parse_ssh_prefix_before_proxy(args, index) else {
|
|
continue;
|
|
};
|
|
if !is_ssh_proxy_arg(arg) || !proxy_command_option_present(args, index) {
|
|
continue;
|
|
}
|
|
|
|
let mut current = index + 1;
|
|
let mut sandbox_name_requirement_met = sandbox_name.is_none();
|
|
let mut workspace_requirement_met = workspace.is_none();
|
|
while current < args.len() {
|
|
let arg = args[current];
|
|
if is_outer_ssh_option_start(arg) || arg == "sandbox" {
|
|
return Some(ProxyCommandMatch {
|
|
outer_ssh_args_start: current,
|
|
owner_requirement_met: sandbox_name_requirement_met
|
|
&& workspace_requirement_met,
|
|
prefix_has_no_command,
|
|
});
|
|
}
|
|
|
|
if let Some(value) = arg.strip_prefix("--sandbox=") {
|
|
sandbox_name_requirement_met |= sandbox_name == Some(value);
|
|
current += 1;
|
|
continue;
|
|
}
|
|
if arg == "--sandbox" {
|
|
let value = args.get(current + 1)?;
|
|
sandbox_name_requirement_met |= sandbox_name == Some(*value);
|
|
current += 2;
|
|
continue;
|
|
}
|
|
if let Some(value) = arg.strip_prefix("--workspace=") {
|
|
workspace_requirement_met |= workspace == Some(value);
|
|
current += 1;
|
|
continue;
|
|
}
|
|
if arg == "--workspace" {
|
|
let value = args.get(current + 1)?;
|
|
workspace_requirement_met |= workspace == Some(*value);
|
|
current += 2;
|
|
continue;
|
|
}
|
|
if proxy_option_takes_value(arg) {
|
|
current += 2;
|
|
continue;
|
|
}
|
|
if proxy_option_has_inline_value(arg) {
|
|
current += 1;
|
|
continue;
|
|
}
|
|
|
|
return None;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_ssh_proxy_arg(arg: &str) -> bool {
|
|
arg == "ssh-proxy" || arg.rsplit('/').next() == Some("ssh-proxy")
|
|
}
|
|
|
|
fn proxy_command_option_present(args: &[&str], proxy_index: usize) -> bool {
|
|
args.iter()
|
|
.take(proxy_index + 1)
|
|
.any(|arg| arg.starts_with("ProxyCommand="))
|
|
}
|
|
|
|
fn proxy_option_takes_value(arg: &str) -> bool {
|
|
matches!(
|
|
arg,
|
|
"--gateway" | "--workspace" | "--token" | "--gateway-name"
|
|
)
|
|
}
|
|
|
|
fn proxy_option_has_inline_value(arg: &str) -> bool {
|
|
["--gateway=", "--workspace=", "--token=", "--gateway-name="]
|
|
.iter()
|
|
.any(|prefix| arg.starts_with(prefix))
|
|
}
|
|
|
|
fn is_outer_ssh_option_start(arg: &str) -> bool {
|
|
matches!(arg, "-N" | "-f" | "-o" | "-L" | "-T" | "-tt")
|
|
|| arg.starts_with("-L")
|
|
|| arg.starts_with("-o")
|
|
|| arg.starts_with("-v")
|
|
}
|
|
|
|
fn parse_ssh_prefix_before_proxy(args: &[&str], proxy_index: usize) -> Option<bool> {
|
|
let mut current = 1;
|
|
let mut saw_no_command = false;
|
|
while current < proxy_index {
|
|
let arg = args[current];
|
|
if arg == "-o" {
|
|
current += 2;
|
|
continue;
|
|
}
|
|
if arg == "-N" {
|
|
saw_no_command = true;
|
|
current += 1;
|
|
continue;
|
|
}
|
|
if arg.starts_with("-o") || matches!(arg, "-f" | "-T" | "-tt") {
|
|
current += 1;
|
|
continue;
|
|
}
|
|
if arg.starts_with("-v") {
|
|
current += 1;
|
|
continue;
|
|
}
|
|
return None;
|
|
}
|
|
Some(saw_no_command)
|
|
}
|
|
|
|
fn outer_ssh_forward_matches(
|
|
args: &[&str],
|
|
port: u16,
|
|
prefix_has_no_command: bool,
|
|
sandbox_id: Option<&str>,
|
|
) -> bool {
|
|
let Some(forward_args) = args.strip_suffix(&["sandbox"]) else {
|
|
return false;
|
|
};
|
|
let mut saw_no_command = prefix_has_no_command;
|
|
let mut saw_forward = false;
|
|
let mut saw_sandbox_id = sandbox_id.is_none();
|
|
let mut current = 0;
|
|
|
|
while current < forward_args.len() {
|
|
let arg = forward_args[current];
|
|
match arg {
|
|
"-N" => {
|
|
saw_no_command = true;
|
|
current += 1;
|
|
}
|
|
"-f" | "-T" | "-tt" => {
|
|
current += 1;
|
|
}
|
|
"-o" => {
|
|
if current + 1 >= forward_args.len() {
|
|
return false;
|
|
}
|
|
saw_sandbox_id |=
|
|
forward_owner_option_matches(forward_args[current + 1], sandbox_id);
|
|
current += 2;
|
|
}
|
|
"-L" => {
|
|
let Some(candidate) = forward_args.get(current + 1) else {
|
|
return false;
|
|
};
|
|
saw_forward |= ssh_forward_arg_matches_openshell_loopback_port(candidate, port);
|
|
current += 2;
|
|
}
|
|
_ if arg.starts_with("-L") => {
|
|
let Some(candidate) = arg.strip_prefix("-L").filter(|value| !value.is_empty())
|
|
else {
|
|
return false;
|
|
};
|
|
saw_forward |= ssh_forward_arg_matches_openshell_loopback_port(candidate, port);
|
|
current += 1;
|
|
}
|
|
_ if arg.starts_with("-o") => {
|
|
saw_sandbox_id |= forward_owner_option_matches(&arg[2..], sandbox_id);
|
|
current += 1;
|
|
}
|
|
_ if arg.starts_with("-v") => {
|
|
current += 1;
|
|
}
|
|
_ => return false,
|
|
}
|
|
}
|
|
|
|
saw_no_command && saw_forward && saw_sandbox_id
|
|
}
|
|
|
|
fn forward_owner_option_matches(option: &str, sandbox_id: Option<&str>) -> bool {
|
|
sandbox_id.is_some_and(|sandbox_id| {
|
|
option.strip_prefix("SetEnv=OPENSHELL_FORWARD_SANDBOX_ID=") == Some(sandbox_id)
|
|
})
|
|
}
|
|
|
|
fn expected_sandbox_id_from_record(record: &ForwardPidRecord) -> Option<&str> {
|
|
// Legacy one-field PID files are cleanup records, not signal authority.
|
|
record.sandbox_id.as_deref().filter(|id| !id.is_empty())
|
|
}
|
|
|
|
fn ssh_forward_arg_matches_openshell_loopback_port(arg: &str, port: u16) -> bool {
|
|
let unbound = format!("{port}:127.0.0.1:{port}");
|
|
let bind_prefixed_suffix = format!(":{unbound}");
|
|
arg == unbound || arg.ends_with(&bind_prefixed_suffix)
|
|
}
|
|
|
|
/// Find the live, validated forward owner for a local port.
|
|
pub fn find_forward_by_port(port: u16) -> Result<Option<(String, String)>> {
|
|
Ok(list_forwards()?
|
|
.into_iter()
|
|
.find(|forward| forward.port == port && forward.validated_alive)
|
|
.map(|forward| (forward.workspace, forward.sandbox_name)))
|
|
}
|
|
|
|
/// Stop a background port forward.
|
|
pub fn stop_forward(workspace: &str, name: &str, port: u16) -> Result<bool> {
|
|
let pid_path = forward_pid_path(workspace, name, port)?;
|
|
let Some(record) = read_forward_pid(workspace, name, port) else {
|
|
return Ok(false);
|
|
};
|
|
let pid = record.pid;
|
|
let Some(_) = expected_sandbox_id_from_record(&record) else {
|
|
// Legacy PID records do not prove process ownership.
|
|
let _ = std::fs::remove_file(&pid_path);
|
|
return Ok(false);
|
|
};
|
|
|
|
if pid_is_alive(pid) {
|
|
if !pid_matches_openshell_ssh_forward(
|
|
pid,
|
|
port,
|
|
Some(workspace),
|
|
Some(name),
|
|
expected_sandbox_id_from_record(&record),
|
|
) {
|
|
let _ = std::fs::remove_file(&pid_path);
|
|
return Ok(false);
|
|
}
|
|
let _ = Command::new("kill")
|
|
.arg(pid.to_string())
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
// Give the process a moment to exit.
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&pid_path);
|
|
Ok(true)
|
|
}
|
|
|
|
/// Stop all forwards for a workspace-scoped sandbox name.
|
|
pub fn stop_forwards_for_sandbox(workspace: &str, name: &str) -> Result<Vec<u16>> {
|
|
safe_path_component(workspace, "workspace")?;
|
|
safe_path_component(name, "sandbox")?;
|
|
let Ok(dir) = forward_pid_dir().map(|path| path.join(workspace)) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let prefix = format!("{name}-");
|
|
let mut stopped = Vec::new();
|
|
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let file_name = entry.file_name();
|
|
let file_name = file_name.to_string_lossy();
|
|
if let Some(rest) = file_name.strip_prefix(&prefix)
|
|
&& let Some(port_str) = rest.strip_suffix(".pid")
|
|
&& let Ok(port) = port_str.parse::<u16>()
|
|
&& stop_forward(workspace, name, port)?
|
|
{
|
|
stopped.push(port);
|
|
}
|
|
}
|
|
|
|
Ok(stopped)
|
|
}
|
|
|
|
/// Information about a tracked forward.
|
|
pub struct ForwardInfo {
|
|
/// Workspace that scopes the sandbox name.
|
|
pub workspace: String,
|
|
/// User-facing sandbox name from the PID file path.
|
|
pub sandbox_name: String,
|
|
/// Local port bound by the SSH forward.
|
|
pub port: u16,
|
|
/// PID recorded for the background SSH process.
|
|
pub pid: u32,
|
|
/// PID is alive and validates as this `OpenShell` forward.
|
|
pub validated_alive: bool,
|
|
/// Bind address (defaults to `127.0.0.1` for old PID files).
|
|
pub bind_addr: String,
|
|
}
|
|
|
|
/// List all tracked forwards.
|
|
pub fn list_forwards() -> Result<Vec<ForwardInfo>> {
|
|
let Ok(dir) = forward_pid_dir() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
let mut forwards = Vec::new();
|
|
for workspace_entry in entries.flatten().filter(|entry| entry.path().is_dir()) {
|
|
let workspace = workspace_entry.file_name().to_string_lossy().to_string();
|
|
let Ok(entries) = std::fs::read_dir(workspace_entry.path()) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let file_name = entry.file_name().to_string_lossy().to_string();
|
|
if let Some(stem) = file_name.strip_suffix(".pid")
|
|
// Parse "<sandbox>-<port>" — the port is the last segment after '-'.
|
|
&& let Some(dash_pos) = stem.rfind('-')
|
|
&& let Ok(port) = stem[dash_pos + 1..].parse::<u16>()
|
|
&& let Some(record) = read_forward_pid(&workspace, &stem[..dash_pos], port)
|
|
{
|
|
// Revalidate ownership so PID reuse does not look like a live forward.
|
|
let validated_alive = expected_sandbox_id_from_record(&record).is_some_and(|id| {
|
|
pid_is_alive(record.pid)
|
|
&& pid_matches_openshell_ssh_forward(
|
|
record.pid,
|
|
port,
|
|
Some(&workspace),
|
|
Some(&stem[..dash_pos]),
|
|
Some(id),
|
|
)
|
|
});
|
|
forwards.push(ForwardInfo {
|
|
workspace: workspace.clone(),
|
|
sandbox_name: stem[..dash_pos].to_string(),
|
|
port,
|
|
pid: record.pid,
|
|
validated_alive,
|
|
bind_addr: record
|
|
.bind_addr
|
|
.unwrap_or_else(|| ForwardSpec::DEFAULT_BIND_ADDR.to_string()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
forwards.sort_by(|a, b| {
|
|
a.workspace.cmp(&b.workspace).then(
|
|
a.sandbox_name
|
|
.cmp(&b.sandbox_name)
|
|
.then(a.port.cmp(&b.port)),
|
|
)
|
|
});
|
|
Ok(forwards)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Forward spec parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A parsed port-forward specification: optional bind address + port.
|
|
///
|
|
/// Supports the same `[bind_address:]port` syntax as SSH `-L`. When no bind
|
|
/// address is given, defaults to `127.0.0.1`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ForwardSpec {
|
|
pub bind_addr: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
impl ForwardSpec {
|
|
/// Default bind address when none is specified.
|
|
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1";
|
|
|
|
/// Create a new `ForwardSpec` with the default bind address.
|
|
pub fn new(port: u16) -> Self {
|
|
Self {
|
|
bind_addr: Self::DEFAULT_BIND_ADDR.to_string(),
|
|
port,
|
|
}
|
|
}
|
|
|
|
/// Parse a `[bind_address:]port` string.
|
|
///
|
|
/// Examples:
|
|
/// - `"8080"` → `ForwardSpec { bind_addr: "127.0.0.1", port: 8080 }`
|
|
/// - `"0.0.0.0:8080"` → `ForwardSpec { bind_addr: "0.0.0.0", port: 8080 }`
|
|
/// - `"::1:8080"` → `ForwardSpec { bind_addr: "::1", port: 8080 }`
|
|
pub fn parse(s: &str) -> Result<Self> {
|
|
// Split on the last ':' to handle IPv6 addresses like "::1:8080".
|
|
if let Some(pos) = s.rfind(':') {
|
|
let addr = &s[..pos];
|
|
let port_str = &s[pos + 1..];
|
|
if let Ok(port) = port_str.parse::<u16>() {
|
|
if port == 0 {
|
|
return Err(miette::miette!("port must be between 1 and 65535"));
|
|
}
|
|
return Ok(Self {
|
|
bind_addr: addr.to_string(),
|
|
port,
|
|
});
|
|
}
|
|
}
|
|
|
|
// No colon or the part after the last colon isn't a valid port —
|
|
// treat the entire string as a port number.
|
|
let port: u16 = s.parse().map_err(|_| {
|
|
miette::miette!("invalid forward spec '{s}': expected [bind_address:]port")
|
|
})?;
|
|
if port == 0 {
|
|
return Err(miette::miette!("port must be between 1 and 65535"));
|
|
}
|
|
Ok(Self::new(port))
|
|
}
|
|
|
|
/// 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:{}",
|
|
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!("{}/", format_gateway_url("http", host, self.port))
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ForwardSpec {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
if self.bind_addr == Self::DEFAULT_BIND_ADDR {
|
|
write!(f, "{}", self.port)
|
|
} else {
|
|
write!(f, "{}:{}", self.bind_addr, self.port)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Port availability check
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Check whether a local port is available for forwarding.
|
|
///
|
|
/// Uses a two-pronged check:
|
|
/// 1. Attempts to bind `<bind_addr>:<port>` — catches same-family conflicts.
|
|
/// 2. Runs `lsof -i :<port> -sTCP:LISTEN` — catches cross-family conflicts
|
|
/// (e.g. an IPv6 wildcard listener blocking a port the IPv4 bind test
|
|
/// would miss).
|
|
///
|
|
/// If the port is already in use the error message includes an actionable
|
|
/// hint:
|
|
///
|
|
/// - If an existing openshell forward owns the port, suggest the stop command.
|
|
/// - Otherwise, show the `lsof` output and suggest `kill` to terminate the
|
|
/// owning process.
|
|
pub fn check_port_available(spec: &ForwardSpec) -> Result<()> {
|
|
let port = spec.port;
|
|
|
|
// Fast path: try binding on the requested address. If this fails, the
|
|
// port is definitely taken on this address family.
|
|
let bind_ok = TcpListener::bind((spec.bind_addr.as_str(), port)).is_ok();
|
|
|
|
// Also ask the OS whether *any* process is listening on this port,
|
|
// regardless of address family. This catches situations where e.g. a
|
|
// server binds [::]:8080 but our IPv4 bind test succeeds.
|
|
let lsof_output = lsof_listeners(port);
|
|
let lsof_occupied = lsof_output.is_some();
|
|
|
|
if bind_ok && !lsof_occupied {
|
|
return Ok(());
|
|
}
|
|
|
|
// Port is occupied. Check if it belongs to a tracked openshell forward.
|
|
if let Ok(forwards) = list_forwards()
|
|
&& let Some(fwd) = forwards
|
|
.iter()
|
|
.find(|f| f.port == port && f.validated_alive)
|
|
{
|
|
return Err(miette::miette!(
|
|
"Port {port} is already forwarded to sandbox '{}'.\n\
|
|
Stop it with: openshell forward stop {port} {}",
|
|
fwd.sandbox_name,
|
|
fwd.sandbox_name,
|
|
));
|
|
}
|
|
|
|
// Build a helpful error with lsof details when available.
|
|
if let Some(output) = lsof_output {
|
|
return Err(miette::miette!(
|
|
"Port {port} is already in use by another process.\n\n\
|
|
{output}\n\n\
|
|
To free the port, find the PID above and run:\n \
|
|
kill <PID>\n\n\
|
|
Or find it yourself with:\n \
|
|
lsof -i :{port} -sTCP:LISTEN",
|
|
));
|
|
}
|
|
|
|
Err(miette::miette!(
|
|
"Port {port} is already in use by another process.\n\
|
|
Find it with: lsof -i :{port} -sTCP:LISTEN\n\
|
|
Then terminate it with: kill <PID>",
|
|
))
|
|
}
|
|
|
|
/// Run `lsof` to check for any process listening on `port`.
|
|
///
|
|
/// Returns the trimmed stdout if at least one listener is found, or `None` if
|
|
/// the port is free (or `lsof` is unavailable).
|
|
fn lsof_listeners(port: u16) -> Option<String> {
|
|
let output = Command::new("lsof")
|
|
.arg("-i")
|
|
.arg(format!(":{port}"))
|
|
.arg("-sTCP:LISTEN")
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if stdout.is_empty() {
|
|
None
|
|
} else {
|
|
Some(stdout)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSH utility functions (shared between CLI and TUI)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Resolve the SSH gateway scheme, host, and port for a sandbox connection.
|
|
///
|
|
/// If the server-provided gateway host is a loopback address, use the host
|
|
/// and port from the cluster endpoint instead so the client connects to the
|
|
/// right machine. The server returns its internal bind address (e.g.
|
|
/// `http://0.0.0.0:8080`) which may not be reachable from outside — the
|
|
/// cluster URL has the actual scheme and Docker-mapped or tunnel authority.
|
|
pub fn resolve_ssh_gateway(
|
|
gateway_scheme: &str,
|
|
gateway_host: &str,
|
|
gateway_port: u16,
|
|
cluster_url: &str,
|
|
) -> (String, String, u16) {
|
|
let is_loopback = gateway_host == "127.0.0.1"
|
|
|| gateway_host == "0.0.0.0"
|
|
|| gateway_host == "localhost"
|
|
|| gateway_host == "::"
|
|
|| gateway_host == "::1";
|
|
|
|
if !is_loopback {
|
|
return (
|
|
gateway_scheme.to_string(),
|
|
gateway_host.to_string(),
|
|
gateway_port,
|
|
);
|
|
}
|
|
|
|
// Extract the scheme, host, and port from the cluster URL. The cluster URL
|
|
// represents the externally reachable endpoint (e.g. a TLS-terminating
|
|
// proxy or Docker port-mapped address).
|
|
if let Ok(url) = url::Url::parse(cluster_url)
|
|
&& matches!(url.scheme(), "http" | "https")
|
|
&& let Some(host) = url.host_str()
|
|
{
|
|
let cluster_scheme = url.scheme().to_string();
|
|
let cluster_port = url.port_or_known_default().unwrap_or(gateway_port);
|
|
let cluster_is_loopback =
|
|
host == "127.0.0.1" || host == "0.0.0.0" || host == "localhost" || host == "::1";
|
|
if !cluster_is_loopback {
|
|
// Remote cluster: use the complete externally reachable endpoint.
|
|
return (cluster_scheme, host.to_string(), cluster_port);
|
|
}
|
|
// Unspecified addresses are bind-only, and tonic cannot use an IPv6
|
|
// literal as a TLS DNS name. In those cases, keep the cluster URL's
|
|
// already-reachable authority. Other loopback addresses retain the
|
|
// gateway-reported host, but use the cluster scheme and port because
|
|
// those describe the externally reachable listener.
|
|
if matches!(gateway_host, "0.0.0.0" | "::" | "::1") {
|
|
return (cluster_scheme, host.to_string(), cluster_port);
|
|
}
|
|
return (cluster_scheme, gateway_host.to_string(), cluster_port);
|
|
}
|
|
|
|
(
|
|
gateway_scheme.to_string(),
|
|
gateway_host.to_string(),
|
|
gateway_port,
|
|
)
|
|
}
|
|
|
|
/// 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('[')
|
|
{
|
|
Cow::Owned(format!("[{host}]"))
|
|
} else {
|
|
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.
|
|
pub fn shell_escape(value: &str) -> String {
|
|
if value.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
|
|
let safe = value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'));
|
|
if safe {
|
|
return value.to_string();
|
|
}
|
|
|
|
let escaped = value.replace('\'', "'\"'\"'");
|
|
format!("'{escaped}'")
|
|
}
|
|
|
|
/// Build the SSH `ProxyCommand` string used to tunnel to a sandbox.
|
|
///
|
|
/// Every interpolated argument is shell-escaped so that server-supplied values
|
|
/// (gateway URL, sandbox name, workspace, token, gateway name) cannot inject shell
|
|
/// metacharacters into the command that OpenSSH executes via `/bin/sh -c`.
|
|
pub fn build_proxy_command(
|
|
exe: &str,
|
|
gateway_url: &str,
|
|
sandbox_name: &str,
|
|
workspace: &str,
|
|
token: &str,
|
|
gateway_name: &str,
|
|
) -> String {
|
|
format!(
|
|
"{} ssh-proxy --gateway {} --sandbox {} --workspace {} --token {} --gateway-name {}",
|
|
shell_escape(exe),
|
|
shell_escape(gateway_url),
|
|
shell_escape(sandbox_name),
|
|
shell_escape(workspace),
|
|
shell_escape(token),
|
|
shell_escape(gateway_name),
|
|
)
|
|
}
|
|
|
|
/// Build the `ssh` options shared by every sandbox session.
|
|
///
|
|
/// Every sandbox is addressed as the host `sandbox`, so a user's `ControlMaster`
|
|
/// or `ControlPath` would reuse one sandbox's connection for another. Command
|
|
/// line options beat `ssh_config`, so connection sharing is turned off here.
|
|
pub fn sandbox_ssh_args(proxy_command: &str, log_level: &str) -> Vec<String> {
|
|
[
|
|
format!("ProxyCommand={proxy_command}"),
|
|
"StrictHostKeyChecking=no".to_string(),
|
|
"UserKnownHostsFile=/dev/null".to_string(),
|
|
"GlobalKnownHostsFile=/dev/null".to_string(),
|
|
format!("LogLevel={log_level}"),
|
|
"ControlMaster=no".to_string(),
|
|
"ControlPath=none".to_string(),
|
|
"ControlPersist=no".to_string(),
|
|
]
|
|
.into_iter()
|
|
.flat_map(|option| ["-o".to_string(), option])
|
|
.collect()
|
|
}
|
|
|
|
/// Error returned when a `CreateSshSessionResponse` fails validation.
|
|
///
|
|
/// The response fields flow into a `ProxyCommand` string executed by
|
|
/// `/bin/sh -c`; any deviation from the documented charset is rejected at the
|
|
/// gRPC trust boundary before escaping is attempted.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SshSessionResponseError {
|
|
#[error("{field} is empty")]
|
|
Empty { field: &'static str },
|
|
#[error("{field} exceeds maximum length of {max} bytes")]
|
|
TooLong { field: &'static str, max: usize },
|
|
#[error("{field} contains invalid characters")]
|
|
InvalidChars { field: &'static str },
|
|
#[error("gateway_scheme must be 'http' or 'https'")]
|
|
InvalidScheme,
|
|
#[error("gateway_port must be in range 1..=65535")]
|
|
InvalidPort,
|
|
}
|
|
|
|
const MAX_SANDBOX_ID_LEN: usize = 128;
|
|
const MAX_TOKEN_LEN: usize = 4096;
|
|
const MAX_GATEWAY_HOST_LEN: usize = 253;
|
|
const MAX_FINGERPRINT_LEN: usize = 256;
|
|
|
|
fn is_sandbox_id_byte(b: u8) -> bool {
|
|
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')
|
|
}
|
|
|
|
fn is_token_byte(b: u8) -> bool {
|
|
// URL-safe base64 + common token charset. No shell metacharacters, no
|
|
// whitespace, no control bytes.
|
|
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'~' | b'+' | b'/' | b'=')
|
|
}
|
|
|
|
fn is_gateway_host_byte(b: u8) -> bool {
|
|
// DNS hostname (alphanumeric + `.-`), IPv4, or bracketed IPv6 (`[::1]`).
|
|
// Rejects Unicode — callers must Punycode-encode IDN hosts before emitting.
|
|
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')
|
|
}
|
|
|
|
fn is_fingerprint_byte(b: u8) -> bool {
|
|
b.is_ascii_alphanumeric() || matches!(b, b':' | b'+' | b'/' | b'=' | b'-')
|
|
}
|
|
|
|
/// Validate a `CreateSshSessionResponse` before any of its fields are used to
|
|
/// build a shell command or config file.
|
|
///
|
|
/// This is a belt-and-suspenders pair to [`build_proxy_command`]: escaping
|
|
/// alone is sufficient to prevent injection, but rejecting malformed fields
|
|
/// at the trust boundary fails loudly before the string is assembled and
|
|
/// catches gateway bugs or tampering early.
|
|
pub fn validate_ssh_session_response(
|
|
resp: &crate::proto::CreateSshSessionResponse,
|
|
) -> std::result::Result<(), SshSessionResponseError> {
|
|
validate_field(
|
|
"sandbox_id",
|
|
&resp.sandbox_id,
|
|
MAX_SANDBOX_ID_LEN,
|
|
is_sandbox_id_byte,
|
|
)?;
|
|
validate_field("token", &resp.token, MAX_TOKEN_LEN, is_token_byte)?;
|
|
validate_field(
|
|
"gateway_host",
|
|
&resp.gateway_host,
|
|
MAX_GATEWAY_HOST_LEN,
|
|
is_gateway_host_byte,
|
|
)?;
|
|
match resp.gateway_scheme.as_str() {
|
|
"http" | "https" => {}
|
|
_ => return Err(SshSessionResponseError::InvalidScheme),
|
|
}
|
|
if resp.gateway_port == 0 || resp.gateway_port > u32::from(u16::MAX) {
|
|
return Err(SshSessionResponseError::InvalidPort);
|
|
}
|
|
if !resp.host_key_fingerprint.is_empty() {
|
|
if resp.host_key_fingerprint.len() > MAX_FINGERPRINT_LEN {
|
|
return Err(SshSessionResponseError::TooLong {
|
|
field: "host_key_fingerprint",
|
|
max: MAX_FINGERPRINT_LEN,
|
|
});
|
|
}
|
|
if !resp.host_key_fingerprint.bytes().all(is_fingerprint_byte) {
|
|
return Err(SshSessionResponseError::InvalidChars {
|
|
field: "host_key_fingerprint",
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_field(
|
|
name: &'static str,
|
|
value: &str,
|
|
max_len: usize,
|
|
byte_ok: fn(u8) -> bool,
|
|
) -> std::result::Result<(), SshSessionResponseError> {
|
|
if value.is_empty() {
|
|
return Err(SshSessionResponseError::Empty { field: name });
|
|
}
|
|
if value.len() > max_len {
|
|
return Err(SshSessionResponseError::TooLong {
|
|
field: name,
|
|
max: max_len,
|
|
});
|
|
}
|
|
if !value.bytes().all(byte_ok) {
|
|
return Err(SshSessionResponseError::InvalidChars { field: name });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Build notes string for a sandbox based on active forwards.
|
|
///
|
|
/// Returns a string like `fwd:8080,3000` or an empty string if no forwards
|
|
/// are active for the given sandbox.
|
|
pub fn build_sandbox_notes(
|
|
workspace: &str,
|
|
sandbox_name: &str,
|
|
forwards: &[ForwardInfo],
|
|
) -> String {
|
|
let ports: Vec<String> = forwards
|
|
.iter()
|
|
.filter(|f| f.workspace == workspace && f.sandbox_name == sandbox_name && f.validated_alive)
|
|
.map(|f| f.port.to_string())
|
|
.collect();
|
|
if ports.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("fwd:{}", ports.join(","))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::test_environment::Environment;
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_keeps_non_loopback() {
|
|
let resolved = resolve_ssh_gateway("http", "10.0.0.5", 8080, "https://spark.local");
|
|
assert_eq!(resolved, ("http".to_string(), "10.0.0.5".to_string(), 8080));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_overrides_loopback_with_cluster_host() {
|
|
let resolved = resolve_ssh_gateway("http", "127.0.0.1", 8080, "https://spark.local");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "spark.local".to_string(), 443)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_overrides_zeros_with_cluster_host() {
|
|
let resolved = resolve_ssh_gateway("http", "0.0.0.0", 8080, "https://10.0.0.5:443");
|
|
assert_eq!(resolved, ("https".to_string(), "10.0.0.5".to_string(), 443));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_uses_known_default_http_port() {
|
|
let resolved = resolve_ssh_gateway("https", "0.0.0.0", 8080, "http://gateway.example.test");
|
|
assert_eq!(
|
|
resolved,
|
|
("http".to_string(), "gateway.example.test".to_string(), 80)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_overrides_localhost() {
|
|
let resolved = resolve_ssh_gateway("http", "localhost", 8080, "https://remote-host:443");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "remote-host".to_string(), 443)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_no_override_when_cluster_is_also_loopback() {
|
|
let resolved = resolve_ssh_gateway("http", "127.0.0.1", 8080, "https://127.0.0.1:443");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "127.0.0.1".to_string(), 443)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_preserves_loopback_tls_authority() {
|
|
let resolved = resolve_ssh_gateway("http", "::1", 8080, "https://localhost:8443");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "localhost".to_string(), 8443)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_swaps_zeros_for_loopback_cluster_host() {
|
|
// The gateway binds 0.0.0.0 but advertises that bind address via the
|
|
// SSH session response. 0.0.0.0 is not a valid connect target and is
|
|
// not in any TLS cert SAN; fall through to the cluster URL's host.
|
|
let resolved = resolve_ssh_gateway("http", "0.0.0.0", 8080, "https://127.0.0.1:9000");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "127.0.0.1".to_string(), 9000)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_swaps_unspecified_ipv6_for_cluster_host() {
|
|
let resolved = resolve_ssh_gateway("http", "::", 8080, "https://localhost:18443");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "localhost".to_string(), 18443)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_handles_invalid_cluster_url() {
|
|
let resolved = resolve_ssh_gateway("https", "127.0.0.1", 8080, "not-a-url");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "127.0.0.1".to_string(), 8080)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_ssh_gateway_ignores_non_http_cluster_url() {
|
|
let resolved = resolve_ssh_gateway("https", "127.0.0.1", 8080, "ssh://remote-host:22");
|
|
assert_eq!(
|
|
resolved,
|
|
("https".to_string(), "127.0.0.1".to_string(), 8080)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_gateway_url_brackets_ipv6_literals() {
|
|
assert_eq!(
|
|
format_gateway_url("https", "::1", 8080),
|
|
"https://[::1]:8080"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_gateway_url_leaves_dns_and_bracketed_ipv6_unchanged() {
|
|
assert_eq!(
|
|
format_gateway_url("https", "gateway.example.com", 443),
|
|
"https://gateway.example.com:443"
|
|
);
|
|
assert_eq!(
|
|
format_gateway_url("https", "[::1]", 8080),
|
|
"https://[::1]:8080"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_escape_empty() {
|
|
assert_eq!(shell_escape(""), "''");
|
|
}
|
|
|
|
#[test]
|
|
fn shell_escape_safe_chars() {
|
|
assert_eq!(shell_escape("hello-world/foo.bar"), "hello-world/foo.bar");
|
|
}
|
|
|
|
#[test]
|
|
fn shell_escape_special_chars() {
|
|
assert_eq!(shell_escape("it's"), "'it'\"'\"'s'");
|
|
}
|
|
|
|
fn valid_session_response() -> crate::proto::CreateSshSessionResponse {
|
|
crate::proto::CreateSshSessionResponse {
|
|
sandbox_id: "sb-1234".to_string(),
|
|
token: "abcDEF-123_456.789".to_string(),
|
|
gateway_scheme: "https".to_string(),
|
|
gateway_host: "gateway.example.com".to_string(),
|
|
gateway_port: 443,
|
|
host_key_fingerprint: String::new(),
|
|
expiration_time: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_accepts_realistic_response() {
|
|
assert!(validate_ssh_session_response(&valid_session_response()).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_accepts_bracketed_ipv6_host() {
|
|
let mut r = valid_session_response();
|
|
r.gateway_host = "[::1]".to_string();
|
|
assert!(validate_ssh_session_response(&r).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_accepts_optional_fingerprint() {
|
|
let mut r = valid_session_response();
|
|
r.host_key_fingerprint = "SHA256:abcd+/=".to_string();
|
|
assert!(validate_ssh_session_response(&r).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_empty_sandbox_id() {
|
|
let mut r = valid_session_response();
|
|
r.sandbox_id.clear();
|
|
assert!(matches!(
|
|
validate_ssh_session_response(&r),
|
|
Err(SshSessionResponseError::Empty {
|
|
field: "sandbox_id"
|
|
})
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_shell_metachars_in_sandbox_id() {
|
|
for bad in ["a;b", "a b", "a$(id)", "a`id`", "a|b", "a&b", "a\nb"] {
|
|
let mut r = valid_session_response();
|
|
r.sandbox_id = bad.to_string();
|
|
assert!(
|
|
validate_ssh_session_response(&r).is_err(),
|
|
"expected reject for sandbox_id={bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_shell_metachars_in_token() {
|
|
for bad in ["$(id)", "`id`", "a;b", "a b", "a\tb", "a\0b"] {
|
|
let mut r = valid_session_response();
|
|
r.token = bad.to_string();
|
|
assert!(
|
|
validate_ssh_session_response(&r).is_err(),
|
|
"expected reject for token={bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_invalid_gateway_host() {
|
|
for bad in ["evil; cmd", "evil host", "ev$(id)il", "ev\nil", "evil/x"] {
|
|
let mut r = valid_session_response();
|
|
r.gateway_host = bad.to_string();
|
|
assert!(
|
|
validate_ssh_session_response(&r).is_err(),
|
|
"expected reject for gateway_host={bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_unknown_scheme() {
|
|
for bad in ["javascript", "file", "", "HTTPS", "ftp"] {
|
|
let mut r = valid_session_response();
|
|
r.gateway_scheme = bad.to_string();
|
|
assert!(
|
|
matches!(
|
|
validate_ssh_session_response(&r),
|
|
Err(SshSessionResponseError::InvalidScheme)
|
|
),
|
|
"expected InvalidScheme for scheme={bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_ssh_session_response_rejects_out_of_range_port() {
|
|
for bad in [0u32, 65_536, 100_000] {
|
|
let mut r = valid_session_response();
|
|
r.gateway_port = bad;
|
|
assert!(matches!(
|
|
validate_ssh_session_response(&r),
|
|
Err(SshSessionResponseError::InvalidPort)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_proxy_command_escapes_shell_metacharacters() {
|
|
// Attacker-controlled values in every escapable position.
|
|
let cmd = build_proxy_command(
|
|
"/usr/local/bin/openshell",
|
|
"https://gw:443/connect",
|
|
"x$(touch /tmp/pwn)x",
|
|
"ws; touch /tmp/pwn",
|
|
"tok`id`",
|
|
"gw-name",
|
|
);
|
|
|
|
// The `$` / backtick must only appear inside single-quoted regions.
|
|
// A simple grep-based check: split on single-quoted runs and assert
|
|
// no shell metacharacter remains in the unquoted remainder.
|
|
assert!(!outside_single_quotes(&cmd).contains('$'));
|
|
assert!(!outside_single_quotes(&cmd).contains('`'));
|
|
assert!(!outside_single_quotes(&cmd).contains('|'));
|
|
assert!(!outside_single_quotes(&cmd).contains(';'));
|
|
assert!(!outside_single_quotes(&cmd).contains('&'));
|
|
assert!(!outside_single_quotes(&cmd).contains('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn build_proxy_command_empty_values_quote_rather_than_vanish() {
|
|
// An empty value must become `''` rather than disappearing — otherwise
|
|
// downstream argv splitting would misalign.
|
|
let cmd = build_proxy_command("exe", "gw", "", "", "tok", "name");
|
|
assert!(cmd.contains("--sandbox ''"));
|
|
assert!(cmd.contains("--workspace ''"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_proxy_command_safe_values_pass_through_unquoted() {
|
|
let cmd = build_proxy_command(
|
|
"/usr/local/bin/openshell",
|
|
"gw",
|
|
"sb-123",
|
|
"workspace-1",
|
|
"tok.456",
|
|
"name_1",
|
|
);
|
|
assert_eq!(
|
|
cmd,
|
|
"/usr/local/bin/openshell ssh-proxy --gateway gw --sandbox sb-123 --workspace workspace-1 --token tok.456 --gateway-name name_1"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_ssh_args_disable_connection_sharing() {
|
|
let args = sandbox_ssh_args("openshell ssh-proxy", "DEBUG");
|
|
let options: Vec<&str> = args
|
|
.chunks(2)
|
|
.map(|pair| {
|
|
assert_eq!(pair[0], "-o");
|
|
pair[1].as_str()
|
|
})
|
|
.collect();
|
|
assert!(options.contains(&"ProxyCommand=openshell ssh-proxy"));
|
|
assert!(options.contains(&"LogLevel=DEBUG"));
|
|
for option in ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"] {
|
|
assert!(options.contains(&option), "missing {option}");
|
|
}
|
|
}
|
|
|
|
/// Helper: return the concatenation of characters that appear outside
|
|
/// POSIX single-quoted runs. Used by the metacharacter assertions above.
|
|
fn outside_single_quotes(s: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut inside = false;
|
|
for c in s.chars() {
|
|
if c == '\'' {
|
|
inside = !inside;
|
|
continue;
|
|
}
|
|
if !inside {
|
|
out.push(c);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn build_sandbox_notes_with_forwards() {
|
|
let forwards = vec![
|
|
ForwardInfo {
|
|
workspace: "default".to_string(),
|
|
sandbox_name: "mybox".to_string(),
|
|
port: 8080,
|
|
pid: 123,
|
|
validated_alive: true,
|
|
bind_addr: "127.0.0.1".to_string(),
|
|
},
|
|
ForwardInfo {
|
|
workspace: "default".to_string(),
|
|
sandbox_name: "mybox".to_string(),
|
|
port: 3000,
|
|
pid: 456,
|
|
validated_alive: true,
|
|
bind_addr: "127.0.0.1".to_string(),
|
|
},
|
|
ForwardInfo {
|
|
workspace: "default".to_string(),
|
|
sandbox_name: "other".to_string(),
|
|
port: 9090,
|
|
pid: 789,
|
|
validated_alive: true,
|
|
bind_addr: "0.0.0.0".to_string(),
|
|
},
|
|
];
|
|
assert_eq!(
|
|
build_sandbox_notes("default", "mybox", &forwards),
|
|
"fwd:8080,3000"
|
|
);
|
|
assert_eq!(
|
|
build_sandbox_notes("default", "other", &forwards),
|
|
"fwd:9090"
|
|
);
|
|
assert_eq!(build_sandbox_notes("default", "missing", &forwards), "");
|
|
}
|
|
|
|
#[test]
|
|
fn build_sandbox_notes_dead_forwards_excluded() {
|
|
let forwards = vec![ForwardInfo {
|
|
workspace: "default".to_string(),
|
|
sandbox_name: "mybox".to_string(),
|
|
port: 8080,
|
|
pid: 123,
|
|
validated_alive: false,
|
|
bind_addr: "127.0.0.1".to_string(),
|
|
}];
|
|
assert_eq!(build_sandbox_notes("default", "mybox", &forwards), "");
|
|
}
|
|
|
|
#[test]
|
|
fn port_parsing_comma_separated() {
|
|
let input = "8080,3000, 443";
|
|
let ports: Vec<u16> = input
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<u16>().ok())
|
|
.collect();
|
|
assert_eq!(ports, vec![8080, 3000, 443]);
|
|
}
|
|
|
|
#[test]
|
|
fn port_parsing_empty_string() {
|
|
let input = "";
|
|
let has_ports = input.split(',').any(|s| s.trim().parse::<u16>().is_ok());
|
|
assert!(!has_ports);
|
|
}
|
|
|
|
#[test]
|
|
fn port_parsing_invalid_mixed() {
|
|
let input = "8080,abc,3000,0,99999";
|
|
let ports: Vec<u16> = input
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<u16>().ok())
|
|
.collect();
|
|
// 0 is valid u16 but we may want to filter it; 99999 overflows u16.
|
|
assert_eq!(ports, vec![8080, 3000, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn check_port_available_free_port() {
|
|
// Bind to port 0 to get an OS-assigned free port, then drop the
|
|
// listener so the port is released before we test it. On busy CI
|
|
// hosts, another process can claim that single ephemeral port before
|
|
// we re-bind it, so retry with fresh OS-assigned ports.
|
|
let mut last_error = None;
|
|
for _ in 0..20 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
|
|
match check_port_available(&ForwardSpec::new(port)) {
|
|
Ok(()) => return,
|
|
Err(err) => {
|
|
last_error = Some(err.to_string());
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
}
|
|
}
|
|
|
|
panic!(
|
|
"expected an OS-assigned port to be available; last error: {}",
|
|
last_error.unwrap_or_else(|| "none".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn check_port_available_occupied_port() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
// Keep the listener alive so the port stays occupied.
|
|
|
|
let result = check_port_available(&ForwardSpec::new(port));
|
|
assert!(result.is_err());
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
msg.contains("already in use"),
|
|
"expected 'already in use' in error message, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[test]
|
|
fn check_port_available_occupied_ipv6_wildcard() {
|
|
// Bind on [::]:0 (IPv6 wildcard) — this simulates a server like
|
|
// `python3 -m http.server` which listens on [::] by default. The
|
|
// IPv4-only TcpListener::bind("127.0.0.1", port) might succeed, but
|
|
// lsof should detect the listener and the check should still fail.
|
|
let Ok(listener) = TcpListener::bind("[::]:0") else {
|
|
return; // IPv6 not available, skip
|
|
};
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
let result = check_port_available(&ForwardSpec::new(port));
|
|
assert!(
|
|
result.is_err(),
|
|
"expected error for IPv6-occupied port {port}"
|
|
);
|
|
let msg = result.unwrap_err().to_string();
|
|
assert!(
|
|
msg.contains("already in use"),
|
|
"expected 'already in use' in error message, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_port_only() {
|
|
let spec = ForwardSpec::parse("8080").unwrap();
|
|
assert_eq!(spec.bind_addr, "127.0.0.1");
|
|
assert_eq!(spec.port, 8080);
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_ipv4_and_port() {
|
|
let spec = ForwardSpec::parse("0.0.0.0:8080").unwrap();
|
|
assert_eq!(spec.bind_addr, "0.0.0.0");
|
|
assert_eq!(spec.port, 8080);
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_ipv6_and_port() {
|
|
let spec = ForwardSpec::parse("::1:8080").unwrap();
|
|
assert_eq!(spec.bind_addr, "::1");
|
|
assert_eq!(spec.port, 8080);
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_localhost_and_port() {
|
|
let spec = ForwardSpec::parse("localhost:3000").unwrap();
|
|
assert_eq!(spec.bind_addr, "localhost");
|
|
assert_eq!(spec.port, 3000);
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_rejects_zero_port() {
|
|
assert!(ForwardSpec::parse("0").is_err());
|
|
assert!(ForwardSpec::parse("0.0.0.0:0").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_parse_rejects_invalid() {
|
|
assert!(ForwardSpec::parse("abc").is_err());
|
|
assert!(ForwardSpec::parse("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_ssh_forward_arg() {
|
|
let spec = ForwardSpec::parse("0.0.0.0:8080").unwrap();
|
|
assert_eq!(spec.ssh_forward_arg(), "0.0.0.0:8080:127.0.0.1:8080");
|
|
|
|
let spec = ForwardSpec::parse("8080").unwrap();
|
|
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 sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
let compact =
|
|
"ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(command_matches_ssh_forward(
|
|
compact,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_matches_bind_prefixed_l_argument() {
|
|
let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L 127.0.0.1:80:127.0.0.1:80 sandbox";
|
|
let compact = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L[::1]:80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(command_matches_ssh_forward(
|
|
compact,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_substring_port_collision() {
|
|
let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:8080 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_requires_matching_sandbox_name() {
|
|
let command =
|
|
"ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-2 -N -L 80:127.0.0.1:80 sandbox";
|
|
let equals =
|
|
"ssh -o ProxyCommand=openshell ssh-proxy --sandbox=sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(command_matches_ssh_forward(equals, 80, None, Some("sbx-1")));
|
|
assert!(command_matches_ssh_forward(command, 80, None, None));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_sandbox_name_prefix_collision() {
|
|
let split = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-10 -N -L 80:127.0.0.1:80 sandbox";
|
|
let equals = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox=sbx-10 -N -L 80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(split, 80, None, Some("sbx-1")));
|
|
assert!(!command_matches_ssh_forward(
|
|
equals,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_host_port_ambiguity() {
|
|
let wrong_remote_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L 80:127.0.0.1:8080 sandbox";
|
|
let wrong_local_port = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L 127.0.0.1:8080:127.0.0.1:80 sandbox";
|
|
let wrong_remote_host =
|
|
"ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N -L 80:localhost:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
wrong_remote_port,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
wrong_local_port,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
wrong_remote_host,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_matches_path_basenames_and_bind_variants() {
|
|
let command = "/usr/bin/ssh -o ProxyCommand=/usr/local/bin/ssh-proxy --sandbox=sbx-1 -N -L localhost:80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_matches_generated_forward_shape() {
|
|
let command = "/usr/bin/ssh -N -o ProxyCommand=/path/openshell ssh-proxy --gateway https://127.0.0.1:9443 --sandbox sbx-1 --workspace team-a --token tok_123 --gateway-name local -o ExitOnForwardFailure=yes -o SetEnv=OPENSHELL_FORWARD_SANDBOX_ID=id-1 -L 127.0.0.1:80:127.0.0.1:80 -f sandbox";
|
|
|
|
assert!(command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
Some("team-a"),
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
Some("team-b"),
|
|
Some("sbx-1")
|
|
));
|
|
|
|
let args = command.split_whitespace().collect::<Vec<_>>();
|
|
assert!(args_match_ssh_forward(
|
|
&args,
|
|
80,
|
|
Some("team-a"),
|
|
Some("sbx-1"),
|
|
Some("id-1")
|
|
));
|
|
assert!(!args_match_ssh_forward(
|
|
&args,
|
|
80,
|
|
Some("team-a"),
|
|
Some("sbx-1"),
|
|
Some("id-2")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn split_proxy_command_words_round_trips_shell_escape() {
|
|
assert_eq!(split_proxy_command_words("a b c"), vec!["a", "b", "c"]);
|
|
// A quoted executable path with whitespace stays a single word.
|
|
assert_eq!(
|
|
split_proxy_command_words("'/Application Support/openshell' ssh-proxy"),
|
|
vec!["/Application Support/openshell", "ssh-proxy"]
|
|
);
|
|
// The `'\"'\"'` idiom shell_escape emits for an embedded single quote.
|
|
assert_eq!(split_proxy_command_words("'it'\"'\"'s'"), vec!["it's"]);
|
|
// An explicitly empty argument survives as an empty word.
|
|
assert_eq!(split_proxy_command_words("''"), vec![String::new()]);
|
|
}
|
|
|
|
#[test]
|
|
fn expand_proxy_command_arg_splits_value_and_keeps_prefix() {
|
|
let exe = "/Application Support/openshell";
|
|
let arg = format!(
|
|
"ProxyCommand={} ssh-proxy --sandbox sbx-1",
|
|
shell_escape(exe)
|
|
);
|
|
assert_eq!(
|
|
expand_proxy_command_arg(&arg),
|
|
vec![
|
|
format!("ProxyCommand={exe}"),
|
|
"ssh-proxy".to_string(),
|
|
"--sandbox".to_string(),
|
|
"sbx-1".to_string(),
|
|
]
|
|
);
|
|
// Non-proxy arguments pass through untouched.
|
|
assert_eq!(expand_proxy_command_arg("-N"), vec!["-N".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn args_match_ssh_forward_handles_expanded_proxy_command_with_whitespace_in_exe_path() {
|
|
// Exact argv as recovered from /proc/<pid>/cmdline: the ProxyCommand
|
|
// value is one element whose executable path contains spaces. The flat
|
|
// `ps` parse cannot represent this, but the exact-argv path expands the
|
|
// ProxyCommand element and matches correctly.
|
|
let exe = "/Application Support/openshell";
|
|
let proxy_arg = format!(
|
|
"ProxyCommand={} ssh-proxy --gateway https://127.0.0.1:9443 --sandbox sbx-1 --workspace=team-a --token tok_123 --gateway-name local",
|
|
shell_escape(exe)
|
|
);
|
|
// Mirror process_forward_match_tokens: the ProxyCommand element is expanded.
|
|
let mut argv = vec![
|
|
"/usr/bin/ssh".to_string(),
|
|
"-N".to_string(),
|
|
"-o".to_string(),
|
|
];
|
|
argv.extend(expand_proxy_command_arg(&proxy_arg));
|
|
argv.extend([
|
|
"-o".to_string(),
|
|
"ExitOnForwardFailure=yes".to_string(),
|
|
"-L".to_string(),
|
|
"127.0.0.1:80:127.0.0.1:80".to_string(),
|
|
"-f".to_string(),
|
|
"sandbox".to_string(),
|
|
]);
|
|
let tokens: Vec<&str> = argv.iter().map(String::as_str).collect();
|
|
|
|
assert!(args_match_ssh_forward(
|
|
&tokens,
|
|
80,
|
|
None,
|
|
Some("sbx-1"),
|
|
None
|
|
));
|
|
// Port and sandbox-name discrimination still holds on the exact path.
|
|
assert!(!args_match_ssh_forward(
|
|
&tokens,
|
|
8080,
|
|
None,
|
|
Some("sbx-1"),
|
|
None
|
|
));
|
|
assert!(!args_match_ssh_forward(
|
|
&tokens,
|
|
80,
|
|
None,
|
|
Some("sbx-2"),
|
|
None
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_proxy_name_collisions() {
|
|
let wrong_ssh = "notssh ssh-proxy --sandbox sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
let wrong_proxy = "ssh -o ProxyCommand=/usr/local/bin/not-ssh-proxy --sandbox=sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
wrong_ssh,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
wrong_proxy,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_non_ssh_process_with_matching_tokens() {
|
|
let command = "python3 /tmp/ssh ssh-proxy --sandbox sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_bare_ssh_proxy_destination() {
|
|
let command = "ssh ssh-proxy --sandbox sbx-1 -N -L80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_forward_command_rejects_remote_command_l_argument() {
|
|
let remote_arg =
|
|
"ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 -N sandbox -L 80:127.0.0.1:80";
|
|
let missing_no_command = "ssh ssh-proxy --sandbox sbx-1 -L 80:127.0.0.1:80 sandbox";
|
|
let remote_command_lookalike = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-1 real-host echo -N -L 80:127.0.0.1:80 sandbox";
|
|
let sandbox_name_in_remote_command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox sbx-2 real-host --sandbox sbx-1 -N -L 80:127.0.0.1:80 sandbox";
|
|
|
|
assert!(!command_matches_ssh_forward(
|
|
remote_arg,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
missing_no_command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
remote_command_lookalike,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
assert!(!command_matches_ssh_forward(
|
|
sandbox_name_in_remote_command,
|
|
80,
|
|
None,
|
|
Some("sbx-1")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn stop_forward_removes_legacy_pid_file_without_signaling() {
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
Environment::new()
|
|
.set("XDG_CONFIG_HOME", config_dir.path())
|
|
.run(|| {
|
|
let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap();
|
|
std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&pid_path, "12345").unwrap();
|
|
|
|
assert!(!stop_forward("default", "sbx-1", 80).unwrap());
|
|
assert!(!pid_path.exists());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn forward_pid_paths_are_scoped_by_workspace() {
|
|
let team_a = forward_pid_path("team-a", "shared-name", 8080).unwrap();
|
|
let team_b = forward_pid_path("team-b", "shared-name", 8080).unwrap();
|
|
|
|
assert_ne!(team_a, team_b);
|
|
assert!(team_a.ends_with("team-a/shared-name-8080.pid"));
|
|
assert!(team_b.ends_with("team-b/shared-name-8080.pid"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_forwards_marks_legacy_pid_records_not_alive() {
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
Environment::new()
|
|
.set("XDG_CONFIG_HOME", config_dir.path())
|
|
.run(|| {
|
|
let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap();
|
|
std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&pid_path, "12345").unwrap();
|
|
|
|
let forwards = list_forwards().unwrap();
|
|
assert_eq!(forwards.len(), 1);
|
|
assert_eq!(forwards[0].workspace, "default");
|
|
assert_eq!(forwards[0].sandbox_name, "sbx-1");
|
|
assert_eq!(forwards[0].port, 80);
|
|
assert!(!forwards[0].validated_alive);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn find_forward_by_port_ignores_legacy_pid_records() {
|
|
let config_dir = tempfile::tempdir().unwrap();
|
|
Environment::new()
|
|
.set("XDG_CONFIG_HOME", config_dir.path())
|
|
.run(|| {
|
|
let pid_path = forward_pid_path("default", "old", 80).unwrap();
|
|
std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&pid_path, std::process::id().to_string()).unwrap();
|
|
|
|
assert_eq!(find_forward_by_port(80).unwrap(), None);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn forward_spec_access_url() {
|
|
let spec = ForwardSpec::parse("8080").unwrap();
|
|
assert_eq!(spec.access_url(), "http://127.0.0.1:8080/");
|
|
|
|
let spec = ForwardSpec::parse("0.0.0.0:8080").unwrap();
|
|
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();
|
|
assert_eq!(spec.to_string(), "8080");
|
|
|
|
let spec = ForwardSpec::parse("0.0.0.0:8080").unwrap();
|
|
assert_eq!(spec.to_string(), "0.0.0.0:8080");
|
|
}
|
|
}
|