diff --git a/crates/openshell-driver-mxc/README.md b/crates/openshell-driver-mxc/README.md index 97b0bd327a..7a4c834af0 100644 --- a/crates/openshell-driver-mxc/README.md +++ b/crates/openshell-driver-mxc/README.md @@ -127,6 +127,10 @@ etw_audit = false Only `process_container` supports this architecture. `isolation_session` is rejected during sandbox validation. +The host supervisor writes its proxy CA certificate and trust bundle to +`proxy-tls\` in the sandbox's host state directory, +`\\`, and deleting the sandbox removes them. + MXC 1.0.0 is required. Pre-1.0 executors and the retired `default_configuration_id` setting are not supported; remove that setting before upgrading. Both live launches and standalone mapper output use the diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 03bdb88d9a..b2ae367bfd 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -40,6 +40,8 @@ const DRIVER_NAME: &str = "mxc"; const DRIVER_VERSION: &str = env!("CARGO_PKG_VERSION"); const DEFAULT_IMAGE_SENTINEL: &str = "mxc:process-container"; const HOST_AUTH_BUNDLE_FILE: &str = "supervisor-auth.json"; +/// Directory in the sandbox host state directory for the proxy CA files. +const HOST_PROXY_TLS_DIR: &str = "proxy-tls"; const HOST_RUNTIME_DESCRIPTOR_FILE: &str = "runtime-descriptor.json"; const BOUNDARY_CONFIG_FILE: &str = "boundary.json"; const BOUNDARY_TLS_CERT_FILE: &str = "sandbox.crt"; @@ -858,6 +860,17 @@ fn spawn_supervisor( auth_bundle_path: &Path, main: &openshell_core::sandbox_env::MainProcessConfig, ) -> Result { + supervisor_command(context, descriptor_path, auth_bundle_path, main)? + .spawn() + .map_err(|error| format!("start host openshell-supervisor: {error}")) +} + +fn supervisor_command( + context: &LifecycleContext, + descriptor_path: &Path, + auth_bundle_path: &Path, + main: &openshell_core::sandbox_env::MainProcessConfig, +) -> Result { let main_process_spec = mxc_main_process_spec(main)?; let mut command = Command::new(&context.config.supervisor_binary_path); command @@ -887,6 +900,14 @@ fn spawn_supervisor( ) .env(openshell_core::sandbox_env::SANDBOX_ID, &context.sandbox.id) .env(openshell_core::sandbox_env::SANDBOX, &context.sandbox.name) + // The supervisor's default `/etc/openshell-tls` resolves to + // `\etc\openshell-tls` on the current drive, where every sandbox on + // the host would write and read the same CA files. Keep them with the + // sandbox's host state, which `delete_sandbox` removes. + .env( + openshell_core::sandbox_env::PROXY_TLS_DIR, + context.host_state_dir.join(HOST_PROXY_TLS_DIR), + ) .env( openshell_core::sandbox_env::LOG_LEVEL, openshell_core::driver_utils::sandbox_log_level(&context.sandbox, "info"), @@ -900,9 +921,7 @@ fn spawn_supervisor( server_name, ); } - command - .spawn() - .map_err(|error| format!("start host openshell-supervisor: {error}")) + Ok(command) } fn mxc_main_process_spec( @@ -1848,6 +1867,50 @@ mod tests { assert!(!backend.config.state_dir.exists()); } + #[test] + fn host_supervisor_proxy_tls_dir_is_in_sandbox_host_state() { + let (backend, sandbox, dir) = preflight_fixture(); + let host_state_dir = backend + .config + .state_dir + .join(&sandbox.id) + .join("generation"); + let context = LifecycleContext { + invoker: backend.invoker.clone(), + config: backend.config.clone(), + gateway: backend.gateway.clone(), + registry: backend.registry.clone(), + watch_tx: backend.watch_tx.clone(), + attribution: backend.attribution.clone(), + sandbox_config: sandbox_config(&sandbox).unwrap(), + sandbox, + generation: "generation".into(), + host_state_dir: host_state_dir.clone(), + boundary_state_dir: dir.path().join("boundary"), + policy_mapper: backend.policy_mapper.clone(), + }; + let main = mxc_main_process_config( + context.sandbox.spec.as_ref(), + &context.sandbox_config.command, + ); + let command = supervisor_command( + &context, + &host_state_dir.join(HOST_RUNTIME_DESCRIPTOR_FILE), + &host_state_dir.join(HOST_AUTH_BUNDLE_FILE), + &main, + ) + .unwrap(); + let tls_dir = command + .as_std() + .get_envs() + .find(|(key, _)| *key == openshell_core::sandbox_env::PROXY_TLS_DIR) + .and_then(|(_, value)| value); + assert_eq!( + tls_dir.map(Path::new), + Some(host_state_dir.join(HOST_PROXY_TLS_DIR).as_path()) + ); + } + fn with_ui_policy( mut sandbox: DriverSandbox, ui: openshell_core::proto::UiPolicy, diff --git a/crates/openshell-supervisor-network/src/l7/tls.rs b/crates/openshell-supervisor-network/src/l7/tls.rs index 2db7ba0d60..3e66b732f1 100644 --- a/crates/openshell-supervisor-network/src/l7/tls.rs +++ b/crates/openshell-supervisor-network/src/l7/tls.rs @@ -393,6 +393,15 @@ fn write_tls_output(path: &Path, contents: &[u8]) -> Result<()> { Err(error) => return Err(error).into_diagnostic(), } + // `NamedTempFile::persist` passes the temporary file's path to + // `SetFileAttributesW` and both paths to `MoveFileExW` unchanged, and those + // calls fail for paths longer than `MAX_PATH` unless they carry the `\\?\` + // prefix. A sandbox's host state directory can be that deep. + #[cfg(windows)] + let extended = extended_length_path(path); + #[cfg(windows)] + let path = extended.as_path(); + let parent = path .parent() .ok_or_else(|| miette!("TLS output has no parent: {}", path.display()))?; @@ -418,6 +427,19 @@ fn write_tls_output(path: &Path, contents: &[u8]) -> Result<()> { Ok(()) } +/// `path` with its directory in the extended-length (`\\?\`) form that +/// `canonicalize` returns on Windows, or `path` unchanged if `canonicalize` +/// fails. It can fail on volumes that the Mount Manager does not track, where +/// paths shorter than `MAX_PATH` still work as given. +#[cfg(windows)] +fn extended_length_path(path: &Path) -> PathBuf { + let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else { + return path.to_path_buf(); + }; + std::fs::canonicalize(parent) + .map_or_else(|_| path.to_path_buf(), |parent| parent.join(file_name)) +} + /// Load PEM-encoded certificates from a string into a root certificate store. /// /// Returns `(added, ignored)` counts. Invalid or unparseable certificates @@ -882,6 +904,47 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn write_ca_files_accepts_paths_longer_than_max_path() { + use std::os::windows::ffi::OsStrExt; + + let ca = SandboxCa::generate().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut output_dir = dir.path().to_path_buf(); + // `MAX_PATH` counts UTF-16 code units. + while output_dir + .join("openshell-ca.pem") + .as_os_str() + .encode_wide() + .count() + <= 260 + { + output_dir.push("0123456789abcdef0123456789abcdef"); + } + + let (ca_path, bundle_path) = write_ca_files(&ca, &output_dir, "").unwrap(); + + assert_eq!(std::fs::read_to_string(&ca_path).unwrap(), ca.cert_pem()); + assert!( + std::fs::read_to_string(&bundle_path) + .unwrap() + .contains(ca.cert_pem()) + ); + } + + #[cfg(windows)] + #[test] + fn extended_length_path_keeps_the_path_when_canonicalize_fails() { + let dir = tempfile::tempdir().unwrap(); + let resolved = extended_length_path(&dir.path().join("openshell-ca.pem")); + assert!(resolved.as_os_str().to_string_lossy().starts_with(r"\\?\")); + + // The directory does not exist, so `canonicalize` fails. + let unresolved = dir.path().join("missing").join("openshell-ca.pem"); + assert_eq!(extended_length_path(&unresolved), unresolved); + } + #[test] fn durable_ca_round_trip_preserves_certificate_bytes() { let generated = SandboxCa::generate().unwrap();