Skip to content
Merged
Show file tree
Hide file tree
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
9 changes: 9 additions & 0 deletions .github/workflows/macos-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }
51 changes: 33 additions & 18 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,27 +132,33 @@ 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<tempfile::TempDir>) {
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")
.arg("-o")
.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")
Expand All @@ -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")
Expand All @@ -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)]
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-server/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 34 additions & 28 deletions crates/openshell-tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,39 +781,45 @@ 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<tempfile::TempDir> {
if host_key_fingerprint.is_empty() {
cmd.arg("-o")
.arg("StrictHostKeyChecking=no")
.arg("-o")
.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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading