mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-22 06:19:47 +08:00
* 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>
377 lines
14 KiB
Rust
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}"
|
|
);
|
|
}
|
|
}
|