Files
OpenShell/e2e/rust/tests/ssh_host_identity.rs
Mike Nguyen 9fd41e6bfd fix(ssh): persist sandbox host identities (#4094)
* fix(ssh): persist sandbox host identities

Store each sandbox's Ed25519 host key in the gateway credential store and
deliver it only to the supervisor. Preserve identity across restarts,
delete owned credentials with the sandbox, and expose the public SHA256
fingerprint through sandbox and SSH-session APIs and client SDKs.

Cover credential ownership, cancellation, deletion retries, client
compatibility, and pinned SSH connections through lifecycle transitions.

Closes #3835

Signed-off-by: Mike Nguyen <miken@nvidia.com>

* fix(compute): clean up failed sandbox SSH identity creation

Signed-off-by: Mike Nguyen <miken@nvidia.com>

* test(ssh): wait for sandbox deletion before name reuse

Signed-off-by: Mike Nguyen <miken@nvidia.com>

---------

Signed-off-by: Mike Nguyen <miken@nvidia.com>
2026-10-06 20:09:20 +00:00

157 lines
5.7 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
#![cfg(feature = "e2e")]
use std::path::Path;
use std::time::Duration;
use openshell_e2e::harness::cli::{
run_cli, sandbox_names, wait_for_healthy, wait_for_sandbox_exec_contains,
wait_for_sandbox_phase,
};
use openshell_e2e::harness::gateway::ManagedGateway;
use openshell_e2e::harness::output::strip_ansi;
use openshell_e2e::harness::sandbox::SandboxGuard;
async fn fingerprint(name: &str) -> String {
let (output, code) = run_cli(&["sandbox", "get", name, "--output", "json"]).await;
assert_eq!(code, 0, "sandbox get failed: {output}");
let sandbox: serde_json::Value = serde_json::from_str(&strip_ansi(&output)).unwrap();
let fingerprint = sandbox["host_key_fingerprint"]
.as_str()
.unwrap()
.to_string();
assert!(fingerprint.starts_with("SHA256:"));
let (output, code) = run_cli(&["sandbox", "list", "--output", "json"]).await;
assert_eq!(code, 0, "sandbox list failed: {output}");
let list: serde_json::Value = serde_json::from_str(&strip_ansi(&output)).unwrap();
let listed = list["sandboxes"]
.as_array()
.unwrap()
.iter()
.find(|sandbox| sandbox["name"] == name)
.unwrap();
assert_eq!(listed["host_key_fingerprint"], fingerprint);
fingerprint
}
async fn pinned_ssh(config: &Path, known_hosts: &Path, name: &str, first: bool) {
let output = tokio::time::timeout(
Duration::from_secs(30),
tokio::process::Command::new("ssh")
.kill_on_drop(true)
.arg("-F")
.arg(config)
.arg("-o")
.arg(if first {
"StrictHostKeyChecking=accept-new"
} else {
"StrictHostKeyChecking=yes"
})
.arg("-o")
.arg(format!("UserKnownHostsFile={}", known_hosts.display()))
.arg("-o")
.arg("BatchMode=yes")
.arg(format!("openshell-{name}.default"))
.args(["printf", "ssh-identity-ok"])
.output(),
)
.await
.unwrap()
.unwrap();
assert!(
output.status.success(),
"pinned SSH failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(String::from_utf8_lossy(&output.stdout), "ssh-identity-ok");
}
#[tokio::test]
async fn ssh_host_identity_survives_restarts_and_changes_after_recreation() {
let mut sandbox = SandboxGuard::create(&[]).await.unwrap();
let name = sandbox.name.clone();
let original = fingerprint(&name).await;
let directory = tempfile::tempdir().unwrap();
let config = directory.path().join("ssh_config");
let known_hosts = directory.path().join("known_hosts");
let (output, code) = run_cli(&["sandbox", "ssh-config", &name]).await;
assert_eq!(code, 0, "SSH config failed: {output}");
std::fs::write(&config, output).unwrap();
pinned_ssh(&config, &known_hosts, &name, true).await;
let key = tokio::process::Command::new("ssh-keygen")
.args(["-l", "-E", "sha256", "-f"])
.arg(&known_hosts)
.output()
.await
.unwrap();
assert!(key.status.success());
assert!(
String::from_utf8_lossy(&key.stdout)
.split_whitespace()
.any(|word| word == original)
);
sandbox
.exec(&["sh", "-c", "test ! -r /.openshell/supervisor/auth.json"])
.await
.unwrap();
let (output, code) = run_cli(&["sandbox", "stop", &name]).await;
assert_eq!(code, 0, "stop failed: {output}");
wait_for_sandbox_phase(&name, "Stopped", Duration::from_secs(60))
.await
.unwrap();
assert_eq!(fingerprint(&name).await, original);
let (output, code) = run_cli(&["sandbox", "start", &name]).await;
assert_eq!(code, 0, "start failed: {output}");
wait_for_sandbox_phase(&name, "Ready", Duration::from_secs(120))
.await
.unwrap();
assert_eq!(fingerprint(&name).await, original);
pinned_ssh(&config, &known_hosts, &name, false).await;
if let Some(gateway) = ManagedGateway::from_env().unwrap() {
gateway.stop().unwrap();
gateway.start().unwrap();
wait_for_healthy(Duration::from_secs(120)).await.unwrap();
wait_for_sandbox_phase(&name, "Ready", Duration::from_secs(120))
.await
.unwrap();
wait_for_sandbox_exec_contains(
&name,
&["printf", "ssh-identity-ready"],
"ssh-identity-ready",
Duration::from_secs(240),
)
.await
.unwrap();
assert_eq!(fingerprint(&name).await, original);
pinned_ssh(&config, &known_hosts, &name, false).await;
} else {
eprintln!("Skipping gateway restart check: this run uses an existing gateway");
}
let (output, code) = run_cli(&["sandbox", "delete", &name]).await;
assert_eq!(code, 0, "delete failed: {output}");
// Delete may return before the driver finishes removing the sandbox.
// Its name remains reserved until the gateway confirms removal.
tokio::time::timeout(Duration::from_secs(120), async {
loop {
let names = sandbox_names()
.await
.expect("list sandboxes after deletion");
if !names.iter().any(|listed| listed == &name) {
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.expect("original sandbox was not fully deleted before name reuse");
sandbox.cleanup().await;
let mut replacement = SandboxGuard::create(&["--name", &name]).await.unwrap();
assert_ne!(fingerprint(&name).await, original);
replacement.cleanup().await;
}