diff --git a/.github/workflows/macos-e2e.yml b/.github/workflows/macos-e2e.yml index f5009b4b..6ffcd6f5 100644 --- a/.github/workflows/macos-e2e.yml +++ b/.github/workflows/macos-e2e.yml @@ -33,6 +33,15 @@ jobs: - name: Install mise uses: jdx/mise-action@v2 + - name: Install Apple Container + env: + CONTAINER_VERSION: "0.11.0" + run: | + curl -fsSL -o container-installer.pkg \ + "https://github.com/apple/container/releases/download/${CONTAINER_VERSION}/container-${CONTAINER_VERSION}-installer-signed.pkg" + sudo installer -pkg container-installer.pkg -target / + rm container-installer.pkg + - name: Verify Apple Container run: container system info diff --git a/Cargo.lock b/Cargo.lock index b7e5abdf..b68e7d40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2486,6 +2486,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "time", "tokio", "tracing", ] @@ -2706,6 +2707,7 @@ dependencies = [ "owo-colors", "ratatui", "serde", + "tempfile", "terminal-colorsaurus", "tokio", "tonic", diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index ef6d8779..0d615f0a 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -71,6 +71,9 @@ url = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +# Temporary files (SSH known_hosts for host key verification) +tempfile = "3" + [lints] workspace = true @@ -83,6 +86,5 @@ rcgen = { version = "0.13", features = ["crypto", "pem"] } reqwest = { workspace = true } serde_json = { workspace = true } temp-env = "0.3" -tempfile = "3" tokio-stream = { workspace = true } url = { workspace = true } diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 111a6a8b..41d4ef6b 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -132,12 +132,20 @@ async fn ssh_session_config( }) } -fn ssh_base_command(proxy_command: &str, host_key_fingerprint: &str) -> Command { +fn ssh_base_command( + proxy_command: &str, + host_key_fingerprint: &str, +) -> (Command, Option) { let mut command = Command::new("ssh"); command .arg("-o") .arg(format!("ProxyCommand={proxy_command}")); - if host_key_fingerprint.is_empty() { + + // When the gateway provides a host key fingerprint, write a temporary + // known_hosts file and enable strict checking to detect MITM attacks. + // SSH connects to hostname "sandbox" at default port 22, so the + // known_hosts entry uses bare "sandbox" (not [sandbox]:port). + let known_hosts_dir = if host_key_fingerprint.is_empty() { command .arg("-o") .arg("StrictHostKeyChecking=no") @@ -145,14 +153,12 @@ fn ssh_base_command(proxy_command: &str, host_key_fingerprint: &str) -> Command .arg("UserKnownHostsFile=/dev/null") .arg("-o") .arg("GlobalKnownHostsFile=/dev/null"); + None } else { - // Write a temporary known_hosts with the gateway-provided fingerprint - // and enable strict host key checking to detect MITM attacks. - let known_hosts = format!("[sandbox]:2222 {host_key_fingerprint}"); - let applied = (|| -> Option<()> { - let dir = tempfile::tempdir().ok()?; - let path = dir.into_path().join("known_hosts"); - std::fs::write(&path, &known_hosts).ok()?; + let dir = tempfile::tempdir().ok(); + let applied = dir.as_ref().and_then(|d| { + let path = d.path().join("known_hosts"); + fs::write(&path, format!("sandbox {host_key_fingerprint}")).ok()?; command .arg("-o") .arg("StrictHostKeyChecking=yes") @@ -161,7 +167,7 @@ fn ssh_base_command(proxy_command: &str, host_key_fingerprint: &str) -> Command .arg("-o") .arg("GlobalKnownHostsFile=/dev/null"); Some(()) - })(); + }); if applied.is_none() { command .arg("-o") @@ -171,9 +177,11 @@ fn ssh_base_command(proxy_command: &str, host_key_fingerprint: &str) -> Command .arg("-o") .arg("GlobalKnownHostsFile=/dev/null"); } - } + dir + }; + command.arg("-o").arg("LogLevel=ERROR"); - command + (command, known_hosts_dir) } #[cfg(unix)] @@ -266,7 +274,8 @@ async fn sandbox_connect_with_mode( ) -> Result<()> { let session = ssh_session_config(server, name, tls).await?; - let mut command = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let (mut command, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); command .arg("-tt") .arg("-o") @@ -345,7 +354,9 @@ pub async fn sandbox_forward( let session = ssh_session_config(server, name, tls).await?; - let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command, &session.host_key_fingerprint)); + let (base, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let mut command = TokioCommand::from(base); command .arg("-N") .arg("-o") @@ -425,7 +436,8 @@ async fn sandbox_exec_with_mode( } let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let (mut ssh, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); if tty { ssh.arg("-tt") @@ -495,7 +507,8 @@ pub async fn sandbox_sync_up_files( let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let (mut ssh, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") @@ -564,7 +577,8 @@ pub async fn sandbox_sync_up( ) -> Result<()> { let session = ssh_session_config(server, name, tls).await?; - let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let (mut ssh, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") @@ -657,7 +671,8 @@ pub async fn sandbox_sync_down( ), ); - let mut ssh = ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); + let (mut ssh, _known_hosts_guard) = + ssh_base_command(&session.proxy_command, &session.host_key_fingerprint); ssh.arg("-T") .arg("-o") .arg("RequestTTY=no") diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs index da64cede..3d2ebebb 100644 --- a/crates/openshell-server/src/grpc.rs +++ b/crates/openshell-server/src/grpc.rs @@ -906,6 +906,7 @@ impl OpenShell for OpenShellService { .metadata() .get("x-sandbox-id") .and_then(|v| v.to_str().ok()) + .map(str::to_owned) .ok_or_else(|| Status::permission_denied("missing x-sandbox-id header"))?; let sandbox_id = request.into_inner().sandbox_id; diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1c186d08..36c8e3b6 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -781,9 +781,14 @@ async fn fetch_sandbox_detail(app: &mut App) { /// Apply SSH host key verification arguments to an SSH command. /// /// When the gateway provides a host key fingerprint, a temporary `known_hosts` file -/// is written and strict checking is enabled. Otherwise, host key checking is -/// disabled (current behavior until the gateway populates the fingerprint field). -fn apply_host_key_args(cmd: &mut std::process::Command, host_key_fingerprint: &str) { +/// is written and strict checking is enabled. The returned `TempDir` guard must be +/// kept alive until the SSH process exits so the file is not cleaned up early. +/// SSH connects to hostname "sandbox" at default port 22, so the known_hosts +/// entry uses bare "sandbox" (not `[sandbox]:port`). +fn apply_host_key_args( + cmd: &mut std::process::Command, + host_key_fingerprint: &str, +) -> Option { if host_key_fingerprint.is_empty() { cmd.arg("-o") .arg("StrictHostKeyChecking=no") @@ -791,29 +796,30 @@ fn apply_host_key_args(cmd: &mut std::process::Command, host_key_fingerprint: &s .arg("UserKnownHostsFile=/dev/null") .arg("-o") .arg("GlobalKnownHostsFile=/dev/null"); - } else { - let known_hosts = format!("[sandbox]:2222 {host_key_fingerprint}"); - let applied = (|| -> Option<()> { - let dir = tempfile::tempdir().ok()?; - let path = dir.into_path().join("known_hosts"); - std::fs::write(&path, &known_hosts).ok()?; - cmd.arg("-o") - .arg("StrictHostKeyChecking=yes") - .arg("-o") - .arg(format!("UserKnownHostsFile={}", path.display())) - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null"); - Some(()) - })(); - if applied.is_none() { - cmd.arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null"); - } + return None; + } + + let dir = tempfile::tempdir().ok(); + let applied = dir.as_ref().and_then(|d| { + let path = d.path().join("known_hosts"); + std::fs::write(&path, format!("sandbox {host_key_fingerprint}")).ok()?; + cmd.arg("-o") + .arg("StrictHostKeyChecking=yes") + .arg("-o") + .arg(format!("UserKnownHostsFile={}", path.display())) + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); + Some(()) + }); + if applied.is_none() { + cmd.arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null"); } + dir } /// Suspend the TUI, launch an interactive SSH shell to the sandbox, resume on exit. @@ -902,7 +908,7 @@ async fn handle_shell_connect( command .arg("-o") .arg(format!("ProxyCommand={proxy_command}")); - apply_host_key_args(&mut command, &session.host_key_fingerprint); + let _known_hosts_guard = apply_host_key_args(&mut command, &session.host_key_fingerprint); command .arg("-o") .arg("LogLevel=ERROR") @@ -1047,7 +1053,7 @@ async fn handle_exec_command( let mut ssh = std::process::Command::new("ssh"); ssh.arg("-o") .arg(format!("ProxyCommand={proxy_command}")); - apply_host_key_args(&mut ssh, &session.host_key_fingerprint); + let _known_hosts_guard = apply_host_key_args(&mut ssh, &session.host_key_fingerprint); ssh.arg("-o") .arg("LogLevel=ERROR") .arg("-tt") @@ -1464,7 +1470,7 @@ async fn start_port_forwards( command .arg("-o") .arg(format!("ProxyCommand={proxy_command}")); - apply_host_key_args(&mut command, &session.host_key_fingerprint); + let _known_hosts_guard = apply_host_key_args(&mut command, &session.host_key_fingerprint); command .arg("-o") .arg("LogLevel=ERROR")