Skip to content
Open
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
38 changes: 37 additions & 1 deletion .github/workflows/branch-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,19 @@ jobs:
suite-matrix: >-
[{"suite":"ci","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"mise run --no-deps --skip-deps e2e:podman:ci"}]

podman-additional-ca-e2e:
needs: [pr_metadata, build-binaries, build-images]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-podman-test.yml
with:
image-tag: ${{ github.sha }}
suite-matrix: >-
[{"suite":"additional-ca","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"OPENSHELL_E2E_PODMAN_TEST=additional_ca mise run --no-deps --skip-deps e2e:podman"}]

vm-e2e:
needs: [pr_metadata, build-binaries, build-vm-driver]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
Expand All @@ -337,6 +350,18 @@ jobs:
packages: read
uses: ./.github/workflows/e2e-vm-test.yml

vm-additional-ca-e2e:
needs: [pr_metadata, build-binaries, build-vm-driver]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-vm-test.yml
with:
suite-name: additional-ca
test-name: additional_ca

docker-external-driver-e2e:
needs: [pr_metadata, build-binaries, build-gateway-plain, build-external-drivers, build-images]
if: needs.pr_metadata.outputs.should_run == 'true' && needs.pr_metadata.outputs.run_core_e2e == 'true'
Expand Down Expand Up @@ -401,17 +426,26 @@ jobs:
include:
- agent_sandbox_api: v1beta1
agent_sandbox_version: v1.0.3
job_name: Kubernetes E2E (Rust smoke, Agent Sandbox v1beta1)
test_name: ""
- agent_sandbox_api: v1alpha1
agent_sandbox_version: v0.4.6
job_name: Kubernetes E2E (Rust smoke, Agent Sandbox v1alpha1)
test_name: ""
- agent_sandbox_api: v1beta1
agent_sandbox_version: v0.5.0
job_name: Kubernetes E2E (additional destination CA, Agent Sandbox v1beta1)
test_name: additional_ca
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/e2e-kubernetes-test.yml
with:
image-tag: ${{ github.sha }}
job-name: Kubernetes E2E (Rust smoke, Agent Sandbox ${{ matrix.agent_sandbox_api }})
job-name: ${{ matrix.job_name }}
agent-sandbox-version: ${{ matrix.agent_sandbox_version }}
test-name: ${{ matrix.test_name }}

kubernetes-workspace-managed-e2e:
needs: [pr_metadata, build-binaries, build-images]
Expand Down Expand Up @@ -494,7 +528,9 @@ jobs:
- driver-specific-integration
- docker-e2e
- podman-e2e
- podman-additional-ca-e2e
- vm-e2e
- vm-additional-ca-e2e
- docker-external-driver-e2e
- podman-external-driver-e2e
- vm-external-driver-e2e
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-docker-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ on:
required: false
type: string
default: >-
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]
[{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker-push","cmd":"mise run --no-deps --skip-deps e2e:rust:push","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"oci-image","cmd":"mise run --no-deps --skip-deps e2e:oci-image","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"additional-ca","cmd":"OPENSHELL_E2E_DOCKER_TEST=additional_ca mise run --no-deps --skip-deps e2e:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}]

permissions:
actions: read
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/e2e-vm-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ on:
required: false
type: string
default: e2e:vm
test-name:
required: false
type: string
default: ""

permissions:
actions: read
Expand Down Expand Up @@ -55,4 +59,5 @@ jobs:
- name: Run tests
env:
E2E_TASK: ${{ inputs.e2e-task }}
OPENSHELL_E2E_VM_TEST: ${{ inputs.test-name }}
run: mise run --no-deps --skip-deps "$E2E_TASK"
6 changes: 6 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-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ openshell-otel = { path = "../openshell-otel", optional = true }
[target.'cfg(unix)'.dependencies]
nix = { workspace = true }
rustix = { workspace = true }
libc = "0.2"

[features]
default = ["telemetry"]
Expand All @@ -65,9 +66,10 @@ tonic-prost-build = { workspace = true }
protoc-bin-vendored = { workspace = true }

[dev-dependencies]
h2 = "0.4"
rcgen = { workspace = true }
tempfile = "3"
tracing-subscriber = { workspace = true }
rcgen = { workspace = true }
tokio-rustls = { workspace = true }

[lints]
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result<String, Bound
use std::os::unix::fs::OpenOptionsExt as _;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(nix::libc::O_NONBLOCK)
.custom_flags(libc::O_NONBLOCK)
.open(path)
};
#[cfg(not(unix))]
Expand Down
165 changes: 144 additions & 21 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,24 @@ impl tonic::service::Interceptor for AuthInterceptor {
}
}

/// Configure server-authenticated TLS for the gateway control-plane endpoint.
///
/// Trust only the configured gateway CA. Destination, system, and proxy
/// trust are intentionally not parameters to this control-plane boundary.
fn configure_gateway_tls(
ep: Endpoint,
ca_pem: Vec<u8>,
server_name: Option<&str>,
) -> Result<Endpoint> {
let mut tls_config = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem));
if let Some(server_name) = server_name.filter(|name| !name.is_empty()) {
tls_config = tls_config.domain_name(server_name);
}
ep.tls_config(tls_config)
.into_diagnostic()
.wrap_err("failed to configure TLS")
}

/// Build the plain (un-intercepted) gRPC channel.
///
/// When the endpoint uses `https://`, server-authenticated TLS is configured
Expand Down Expand Up @@ -242,27 +260,12 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
.into_diagnostic()
.wrap_err_with(|| format!("failed to read CA cert from {ca_path}"))?;

// Trust only the configured CA — this is the chart's internal CA
// that signs the gateway's internal server certificate. The gateway uses SNI-based
// certificate selection to present this internal cert to supervisor
// connections, so no public root trust is needed here.
//
// Do NOT add `.with_native_roots()` or `.with_webpki_roots()` here:
// the supervisor runs inside the user-selected sandbox image
// (Docker/Podman drivers), and broadening the trust store would let
// an attacker who controls the image + DNS present a publicly valid
// certificate and intercept the supervisor→gateway TLS connection.
let mut tls_config = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem));
if let Ok(server_name) = std::env::var(sandbox_env::GATEWAY_TLS_SERVER_NAME)
&& !server_name.is_empty()
{
tls_config = tls_config.domain_name(server_name);
}

ep = ep
.tls_config(tls_config)
.into_diagnostic()
.wrap_err("failed to configure TLS")?;
// The gateway uses SNI-based certificate selection to present its
// internal cert to supervisor connections. Do not add native,
// webpki, destination, or child-process roots here: an image that can
// influence DNS must not broaden supervisor→gateway authentication.
let server_name = std::env::var(sandbox_env::GATEWAY_TLS_SERVER_NAME).ok();
ep = configure_gateway_tls(ep, ca_pem, server_name.as_deref())?;
}

ep.connect()
Expand Down Expand Up @@ -299,6 +302,126 @@ impl Drop for ClientSpanStatus {
}
}

#[cfg(test)]
mod gateway_tls_isolation_tests {
use super::*;
use rcgen::{CertificateParams, IsCa, KeyPair};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::io::Cursor;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;

#[allow(clippy::struct_field_names)]
struct TestPki {
ca_pem: Vec<u8>,
server_cert_pem: Vec<u8>,
server_key_pem: Vec<u8>,
}

fn test_pki(server_name: &str) -> TestPki {
let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
let ca_key = KeyPair::generate().unwrap();
let ca = ca_params.self_signed(&ca_key).unwrap();

let server_key = KeyPair::generate().unwrap();
let server = CertificateParams::new(vec![server_name.to_string()])
.unwrap()
.signed_by(&server_key, &ca, &ca_key)
.unwrap();
TestPki {
ca_pem: ca.pem().into_bytes(),
server_cert_pem: server.pem().into_bytes(),
server_key_pem: server_key.serialize_pem().into_bytes(),
}
}

async fn start_h2_tls_server(pki: &TestPki) -> std::net::SocketAddr {
let certificates = rustls_pemfile::certs(&mut Cursor::new(&pki.server_cert_pem))
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
.unwrap();
let key: PrivateKeyDer<'static> =
rustls_pemfile::private_key(&mut Cursor::new(&pki.server_key_pem))
.unwrap()
.unwrap();
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certificates, key)
.unwrap();
config.alpn_protocols = vec![b"h2".to_vec()];
let acceptor = TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let Ok(stream) = acceptor.accept(stream).await else {
return;
};
if let Ok(mut connection) = h2::server::handshake(stream).await {
while let Some(request) = connection.accept().await {
if request.is_err() {
break;
}
}
}
});
address
}

async fn connect_with_gateway_ca(
address: std::net::SocketAddr,
pki: &TestPki,
server_name: &str,
) -> Result<Channel> {
let endpoint = Endpoint::from_shared(format!("https://{address}")).into_diagnostic()?;
configure_gateway_tls(endpoint, pki.ca_pem.clone(), Some(server_name))?
.connect()
.await
.into_diagnostic()
}

#[tokio::test]
async fn destination_ca_cannot_authenticate_gateway() {
const GATEWAY_NAME: &str = "gateway.internal.test";
let gateway = test_pki(GATEWAY_NAME);
let destination = test_pki(GATEWAY_NAME);

let real_address = start_h2_tls_server(&gateway).await;
tokio::time::timeout(
Duration::from_secs(5),
connect_with_gateway_ca(real_address, &gateway, GATEWAY_NAME),
)
.await
.expect("real gateway connection timed out")
.expect("configured gateway CA should authenticate the gateway");

let fake_address = start_h2_tls_server(&destination).await;
let error = tokio::time::timeout(
Duration::from_secs(5),
connect_with_gateway_ca(fake_address, &gateway, GATEWAY_NAME),
)
.await
.expect("fake gateway rejection timed out")
.expect_err("destination-signed fake gateway must be rejected");
assert!(format!("{error:?}").contains("certificate"), "{error:?}");

let mismatch_address = start_h2_tls_server(&gateway).await;
tokio::time::timeout(
Duration::from_secs(5),
connect_with_gateway_ca(mismatch_address, &gateway, "wrong.internal.test"),
)
.await
.expect("hostname mismatch rejection timed out")
.expect_err("normal server-name verification must remain enabled");
}
}

/// Build a Bearer-authenticated channel to the gateway.
///
/// First call per process resolves the sandbox JWT via the three-step
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub mod mcp;
pub mod metadata;
pub mod middleware;
pub mod net;
pub mod network_trust;
#[cfg(feature = "oauth")]
pub mod oauth;
pub mod paths;
Expand Down Expand Up @@ -79,6 +80,7 @@ pub use error::{ComputeDriverError, Error, Result};
pub use metadata::{
GetResourceVersion, ObjectId, ObjectLabels, ObjectName, ObjectWorkspace, SetResourceVersion,
};
pub use network_trust::NetworkSupervisorTrustBundle;
pub use sandbox_session::{SandboxSessionId, SandboxSessionIdError};

/// Build version string derived from git metadata.
Expand Down
Loading
Loading