Files
Drew Newberry b3e4ad4579 fix(ci): repair RFC 0012 post-merge checks (#3360)
* fix(ci): lock nextest for Windows ARM64

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(docker): preserve GPU access in sandbox runtime

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(windows): restore host proxy identity binding

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(windows): satisfy cross-platform network lint

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(windows): satisfy MXC lint

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(windows): exclude Unix supervisor runtime

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(mxc): satisfy Windows test lint

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(windows): escape proxy test policy paths

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(sandbox): accept admitted GPU runtime groups

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* test(docker): serialize proxy pipeline scenarios

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
2026-09-16 04:08:00 +00:00

377 lines
14 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Host-side proxy startup for compute drivers that do not run the Linux
//! in-sandbox supervisor.
//!
//! MXC uses this on Windows: MXC's `network.proxy` redirects sandbox egress to a
//! per-sandbox loopback listener in the gateway process, and this module starts
//! the existing `OpenShell` CONNECT proxy against the trimmed network-only
//! `SandboxPolicy`.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
use miette::Result;
use openshell_core::activity::ActivitySender;
use openshell_core::denial::DenialEvent;
use openshell_core::policy::ProxyPolicy;
use openshell_core::proposals::AgentProposals;
use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy;
use openshell_core::provider_credentials::ProviderCredentialState;
use openshell_isolation_interface::contract::{BinaryIdentity, Sha256Digest};
use openshell_ocsf::{
ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ctx::ctx as ocsf_ctx, ocsf_emit,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::identity::BinaryIdentityCache;
use crate::l7::tls::{
CertCache, ProxyTlsState, SandboxCa, build_upstream_client_config, read_system_ca_bundle,
write_ca_files,
};
use crate::opa::OpaEngine;
use crate::policy_local::PolicyLocalContext;
use crate::proxy::ProxyHandle;
/// Configuration for a host-side `OpenShell` CONNECT proxy.
pub struct HostProxyConfig {
/// Exact socket the compute driver will redirect sandbox egress to.
pub bind_addr: SocketAddr,
/// Network-only policy produced by the compute driver's policy split.
pub policy: ProtoSandboxPolicy,
/// Static process identity used when the platform cannot recover the
/// socket-owning sandbox process. Policy binaries must match this path for
/// L4/L7 allow rules to pass.
pub binary_path: PathBuf,
pub sandbox_id: Option<String>,
pub sandbox_name: Option<String>,
pub openshell_endpoint: Option<String>,
pub provider_credentials: Option<ProviderCredentialState>,
/// Shared feature state for the policy.local agent proposal surface.
pub agent_proposals: AgentProposals,
pub denial_tx: Option<UnboundedSender<DenialEvent>>,
pub activity_tx: Option<ActivitySender>,
}
/// RAII handle for a host-side proxy. Dropping it aborts the proxy accept loop.
pub struct HostProxyHandle {
proxy: ProxyHandle,
ca_file_paths: Option<(PathBuf, PathBuf)>,
#[allow(dead_code)]
tls_dir: Option<tempfile::TempDir>,
pub policy_local_ctx: Arc<PolicyLocalContext>,
}
impl HostProxyHandle {
#[must_use]
pub const fn http_addr(&self) -> Option<SocketAddr> {
self.proxy.http_addr()
}
#[must_use]
pub fn ca_file_paths(&self) -> Option<(PathBuf, PathBuf)> {
self.ca_file_paths.clone()
}
}
/// Start a host-side proxy for one sandbox.
///
/// Linux supervisor mode should continue to use `run::run_networking`; this API
/// is for host-side compute-driver integrations such as Windows MXC.
pub async fn start_host_proxy(config: HostProxyConfig) -> Result<HostProxyHandle> {
if !config.bind_addr.ip().is_loopback() {
return Err(miette::miette!(
"host proxy bind address must be loopback-only: {}",
config.bind_addr
));
}
if !config.policy.network_middlewares.is_empty() {
return Err(miette::miette!(
"host proxy cannot enforce network_middlewares without a middleware service registry; refusing to start with {} configured middleware(s)",
config.policy.network_middlewares.len()
));
}
let engine = Arc::new(OpaEngine::from_proto(&config.policy)?);
let (_workspace_tx, workspace_rx) = tokio::sync::watch::channel(String::new());
let policy_local_ctx = Arc::new(PolicyLocalContext::new(
Some(config.policy.clone()),
config.openshell_endpoint.clone(),
config
.sandbox_name
.clone()
.or_else(|| config.sandbox_id.clone()),
config.agent_proposals,
workspace_rx,
));
let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true);
let proxy_policy = ProxyPolicy {
http_addr: Some(config.bind_addr),
};
let upstream_proxy_args = crate::upstream_proxy::UpstreamProxyArgs::default();
let (tls_state, ca_file_paths, tls_dir) = match tempfile::Builder::new()
.prefix("openshell-mxc-tls-")
.tempdir()
{
Ok(tls_dir) => match SandboxCa::generate() {
Ok(ca) => {
let system_ca_bundle = read_system_ca_bundle();
match write_ca_files(&ca, tls_dir.path(), &system_ca_bundle) {
Ok(paths) => match build_upstream_client_config(&system_ca_bundle) {
Ok(upstream_config) => {
let cert_cache = CertCache::new(ca);
let state = Arc::new(ProxyTlsState::new(cert_cache, upstream_config));
ocsf_emit!(
ConfigStateChangeBuilder::new(ocsf_ctx())
.severity(SeverityId::Informational)
.status(StatusId::Success)
.state(StateId::Enabled, "enabled")
.message(
"Host proxy TLS termination enabled: ephemeral CA generated"
)
.build()
);
(Some(state), Some(paths), Some(tls_dir))
}
Err(e) => {
ocsf_emit!(
ConfigStateChangeBuilder::new(ocsf_ctx())
.severity(SeverityId::High)
.status(StatusId::Failure)
.state(StateId::Disabled, "disabled")
.message(format!(
"Failed to build host proxy upstream TLS config, TLS termination disabled: {e}"
))
.build()
);
(None, None, Some(tls_dir))
}
},
Err(e) => {
ocsf_emit!(
ConfigStateChangeBuilder::new(ocsf_ctx())
.severity(SeverityId::High)
.status(StatusId::Failure)
.state(StateId::Disabled, "disabled")
.message(format!(
"Failed to write host proxy CA files, TLS termination disabled: {e}"
))
.build()
);
(None, None, Some(tls_dir))
}
}
}
Err(e) => {
ocsf_emit!(
ConfigStateChangeBuilder::new(ocsf_ctx())
.severity(SeverityId::High)
.status(StatusId::Failure)
.state(StateId::Disabled, "disabled")
.message(format!(
"Failed to generate host proxy ephemeral CA, TLS termination disabled: {e}"
))
.build()
);
(None, None, Some(tls_dir))
}
},
Err(e) => {
ocsf_emit!(
ConfigStateChangeBuilder::new(ocsf_ctx())
.severity(SeverityId::High)
.status(StatusId::Failure)
.state(StateId::Disabled, "disabled")
.message(format!(
"Failed to create host proxy TLS trust directory, TLS termination disabled: {e}"
))
.build()
);
(None, None, None)
}
};
let binary_digest: Sha256Digest = crate::procfs::file_sha256(&config.binary_path)?
.parse()
.map_err(|error| miette::miette!("hash host proxy binary identity: {error}"))?;
let direct_listener_identity = BinaryIdentity {
binary_path: config.binary_path,
binary_digest: Some(binary_digest),
ancestors: Vec::new(),
cmdline_paths: Vec::new(),
};
let proxy = ProxyHandle::start_with_bind_addr(
&proxy_policy,
Some(config.bind_addr),
engine,
Arc::new(BinaryIdentityCache::new()),
Arc::new(AtomicU32::new(0)),
tls_state,
config.provider_credentials,
Some(policy_local_ctx.clone()),
config.denial_tx,
config.activity_tx,
None,
ready_rx,
&upstream_proxy_args,
None,
None,
None,
Some(direct_listener_identity),
)
.await?;
Ok(HostProxyHandle {
proxy,
ca_file_paths,
tls_dir,
policy_local_ctx,
})
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use openshell_core::proposals::AgentProposals;
use openshell_core::proto::{
MiddlewareEndpointSelector, NetworkMiddlewareConfig, SandboxPolicy as ProtoSandboxPolicy,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use super::*;
fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig {
HostProxyConfig {
bind_addr,
policy: ProtoSandboxPolicy {
version: 1,
..Default::default()
},
binary_path,
sandbox_id: Some("sandbox-123".to_string()),
sandbox_name: Some("agent-box".to_string()),
openshell_endpoint: None,
provider_credentials: None,
agent_proposals: AgentProposals::new(true),
denial_tx: None,
activity_tx: None,
}
}
#[tokio::test]
async fn rejects_non_loopback_bind_addr() {
let result = start_host_proxy(test_config(
([192, 0, 2, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
))
.await;
let Err(err) = result else {
panic!("host proxy should reject non-loopback bind addresses");
};
assert!(
err.to_string().contains("loopback-only"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn rejects_middleware_policy_without_registry() {
let mut config = test_config(
([127, 0, 0, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
);
config.policy.network_middlewares.insert(
"redactor".into(),
NetworkMiddlewareConfig {
name: "redactor".into(),
middleware: "openshell/regex".into(),
on_error: "fail_closed".into(),
endpoints: Some(MiddlewareEndpointSelector {
include: vec!["api.example.com".into()],
exclude: Vec::new(),
}),
..Default::default()
},
);
let Err(error) = start_host_proxy(config).await else {
panic!("host proxy must reject middleware without a registry");
};
assert!(
error
.to_string()
.contains("cannot enforce network_middlewares"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn starts_loopback_proxy_and_serves_policy_local() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let binary = tempfile::NamedTempFile::new().unwrap();
std::fs::write(binary.path(), b"agent").unwrap();
let handle = start_host_proxy(test_config(
([127, 0, 0, 1], 0).into(),
binary.path().to_path_buf(),
))
.await
.unwrap();
let addr = handle.http_addr().expect("proxy should report bound addr");
assert!(addr.ip().is_loopback());
assert_ne!(addr.port(), 0);
let (ca_cert, bundle) = handle.ca_file_paths().expect("CA paths");
assert!(ca_cert.exists(), "standalone CA cert should exist");
assert!(bundle.exists(), "combined CA bundle should exist");
assert!(
std::fs::read_to_string(ca_cert)
.unwrap()
.contains("BEGIN CERTIFICATE")
);
assert!(
std::fs::read_to_string(bundle)
.unwrap()
.contains("BEGIN CERTIFICATE")
);
let mut client = TcpStream::connect(addr).await.unwrap();
client
.write_all(
b"GET http://policy.local/v1/policy/current HTTP/1.1\r\n\
Host: policy.local\r\n\
Connection: close\r\n\
\r\n",
)
.await
.unwrap();
let mut response = Vec::new();
tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response))
.await
.unwrap()
.unwrap();
let response = String::from_utf8(response).unwrap();
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"unexpected response: {response}"
);
let (_, body) = response.split_once("\r\n\r\n").expect("response body");
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["format"], "yaml");
assert!(
body["policy_yaml"]
.as_str()
.unwrap_or_default()
.contains("version: 1"),
"unexpected policy payload: {body}"
);
}
}