diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index cb3023f476..932d0d905d 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -83,7 +83,10 @@ capability-free workload Pod and a directly managed capability-free supervisor Pod. One namespace-wide NetworkPolicy denies direct egress from every OpenShell workload Pod. The `pkiInitJob` hook (a pre-install Job that runs `openshell-gateway generate-certs`) -generates mTLS secrets on first install. The default Skaffold values export +generates gateway and CLI TLS secrets on first install. Supervisor Pods project +only `ca.crt` and authenticate gateway RPCs with sandbox bearer tokens. User +client certificates and private keys remain outside supervisor and workload Pods. +The default Skaffold values export gateway and Kubernetes-driver traces to the collector service installed by `helm:k3s:create`. Envoy Gateway is opt-in; see the Optional Add-ons section. diff --git a/crates/openshell-bootstrap/src/pki.rs b/crates/openshell-bootstrap/src/pki.rs index 5e5839a11a..e615e73fa3 100644 --- a/crates/openshell-bootstrap/src/pki.rs +++ b/crates/openshell-bootstrap/src/pki.rs @@ -93,7 +93,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result { .into_diagnostic() .wrap_err("failed to sign server certificate")?; - // --- Client cert (shared by CLI and sandbox pods) --- + // --- User client cert (CLI only; sandboxes use bearer identity) --- let client_key = KeyPair::generate() .into_diagnostic() .wrap_err("failed to generate client key")?; diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index c85fc4308a..820b4a9476 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -378,13 +378,13 @@ pub struct OidcConfig { pub scopes_claim: String, } -/// mTLS user authentication for local, single-user gateways. +/// mTLS user authentication for gateway users. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MtlsAuthConfig { /// When true, the gateway maps a verified TLS client certificate into a - /// user principal. Keep disabled for Kubernetes deployments because - /// Kubernetes sandbox pods and external users must not share user auth. + /// user principal. Sandbox and supervisor clients use bearer identity, so + /// this setting is independent of the selected compute driver. #[serde(default)] pub enabled: bool, } diff --git a/crates/openshell-core/src/container_paths.rs b/crates/openshell-core/src/container_paths.rs index 63511c13ff..e44f9fa8cb 100644 --- a/crates/openshell-core/src/container_paths.rs +++ b/crates/openshell-core/src/container_paths.rs @@ -44,8 +44,6 @@ pub const SUPERVISOR_CONTAINER_DIR: &str = "/opt/openshell/bin"; pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sandbox"; pub const TLS_CLIENT_DIR: &str = "/etc/openshell/tls/client"; pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt"; -pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt"; -pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; pub const CONTAINER_POLICY_PATH: &str = "/etc/openshell/policy.yaml"; @@ -60,8 +58,6 @@ pub const SUPERVISOR_CA_CERT_PATH: &str = "/etc/openshell-tls/openshell-ca.pem"; pub const SUPERVISOR_CA_BUNDLE_PATH: &str = "/etc/openshell-tls/ca-bundle.pem"; pub const VM_GUEST_TLS_CA_PATH: &str = "/opt/openshell/tls/ca.crt"; -pub const VM_GUEST_TLS_CERT_PATH: &str = "/opt/openshell/tls/tls.crt"; -pub const VM_GUEST_TLS_KEY_PATH: &str = "/opt/openshell/tls/tls.key"; pub const VM_GUEST_SANDBOX_TOKEN_PATH: &str = "/opt/openshell/auth/sandbox.jwt"; pub const VM_GUEST_INIT_DROPIN_DIR: &str = "/opt/openshell/init.d"; pub const VM_GUEST_INIT_DROPIN_MANIFEST: &str = "/opt/openshell/init.d.manifest"; @@ -109,8 +105,6 @@ mod tests { SUPERVISOR_CONTAINER_BINARY, TLS_CLIENT_DIR, TLS_CA_MOUNT_PATH, - TLS_CERT_MOUNT_PATH, - TLS_KEY_MOUNT_PATH, SANDBOX_TOKEN_MOUNT_PATH, UPSTREAM_PROXY_AUTH_MOUNT_PATH, CONTAINER_POLICY_PATH, @@ -123,8 +117,6 @@ mod tests { SUPERVISOR_CA_CERT_PATH, SUPERVISOR_CA_BUNDLE_PATH, VM_GUEST_TLS_CA_PATH, - VM_GUEST_TLS_CERT_PATH, - VM_GUEST_TLS_KEY_PATH, VM_GUEST_SANDBOX_TOKEN_PATH, VM_GUEST_UPSTREAM_PROXY_AUTH_PATH, VM_GUEST_PROXY_CA_PATH, diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index a5a86fe908..71fb790b11 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -104,22 +104,15 @@ pub const SUPERVISOR_CONTAINER_BINARY: &str = "/opt/openshell/bin/openshell-sand // --------------------------------------------------------------------------- // In-container mount paths for guest TLS materials and the sandbox token. // -// All container-based drivers (Docker, Podman, Kubernetes) mount the gateway's -// mTLS client credentials at these fixed paths inside every sandbox container. -// The supervisor reads these paths on startup to establish its gRPC-over-mTLS -// connection back to the gateway. The paths must remain stable across driver -// versions since the supervisor binary is built and packaged separately. +// Container-based drivers mount the gateway CA at this fixed path inside every +// supervisor container. The supervisor reads it on startup to authenticate the +// gateway TLS endpoint. Sandbox identity is provided separately by a bearer +// token. // --------------------------------------------------------------------------- -/// Container-side mount path for the guest mTLS CA certificate. +/// Container-side mount path for the gateway CA certificate. pub const TLS_CA_MOUNT_PATH: &str = "/etc/openshell/tls/client/ca.crt"; -/// Container-side mount path for the guest mTLS client certificate. -pub const TLS_CERT_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.crt"; - -/// Container-side mount path for the guest mTLS client private key. -pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; - /// Container-side mount path for the per-sandbox JWT token. pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 9808bd16e0..f95314fece 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -40,7 +40,7 @@ use openshell_extension_core::{BearerTokenSlot, ExtensionCredentialStore}; use tonic::Status; use tonic::metadata::AsciiMetadataValue; use tonic::service::interceptor::InterceptedService; -use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint}; use tracing::{debug, info, warn}; /// Preserve the gRPC status as a source so callers can classify retryable errors. @@ -201,10 +201,9 @@ impl tonic::service::Interceptor for AuthInterceptor { /// Build the plain (un-intercepted) gRPC channel. /// -/// When the endpoint uses `https://`, mTLS is configured using these env vars: +/// When the endpoint uses `https://`, server-authenticated TLS is configured +/// using this env var: /// - `OPENSHELL_TLS_CA` -- path to the CA certificate -/// - `OPENSHELL_TLS_CERT` -- path to the client certificate -/// - `OPENSHELL_TLS_KEY` -- path to the client private key /// /// When the endpoint uses `http://`, a plaintext connection is used (for /// deployments where TLS is disabled, e.g. behind a Cloudflare Tunnel). @@ -223,7 +222,7 @@ async fn build_plain_channel(endpoint: &str) -> Result { let tls_enabled = endpoint.starts_with("https://"); - // TODO: TLS certs are loaded once here and never re-read. The gateway + // TODO: The TLS CA is loaded once here and never re-read. The gateway // server side supports hot-reload (ArcSwap + notify in tls.rs). The // supervisor should do the same so that cert-manager rotations take // effect without restarting the sandbox. @@ -231,26 +230,12 @@ async fn build_plain_channel(endpoint: &str) -> Result { let ca_path = std::env::var(sandbox_env::TLS_CA) .into_diagnostic() .wrap_err("OPENSHELL_TLS_CA is required")?; - let cert_path = std::env::var(sandbox_env::TLS_CERT) - .into_diagnostic() - .wrap_err("OPENSHELL_TLS_CERT is required")?; - let key_path = std::env::var(sandbox_env::TLS_KEY) - .into_diagnostic() - .wrap_err("OPENSHELL_TLS_KEY is required")?; - let ca_pem = std::fs::read(&ca_path) .into_diagnostic() .wrap_err_with(|| format!("failed to read CA cert from {ca_path}"))?; - let cert_pem = std::fs::read(&cert_path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read client cert from {cert_path}"))?; - let key_pem = std::fs::read(&key_path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read client key from {key_path}"))?; // Trust only the configured CA — this is the chart's internal CA - // that signs both the gateway's internal server certificate and - // this client's identity certificate. The gateway uses SNI-based + // 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. // @@ -259,9 +244,7 @@ async fn build_plain_channel(endpoint: &str) -> Result { // (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)) - .identity(Identity::from_pem(cert_pem, key_pem)); + 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() { diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index 24640c7908..9ac843cecd 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -185,7 +185,7 @@ pub const PROXY_CA_KEY: &str = "OPENSHELL_PROXY_CA_KEY"; /// Whether the control-owned SSH Unix socket is shared across trusted UIDs. pub const SSH_SOCKET_SHARED: &str = "OPENSHELL_SSH_SOCKET_SHARED"; -/// Path to the CA certificate for mTLS communication with the gateway. +/// Path to the CA certificate used to authenticate the gateway TLS endpoint. pub const TLS_CA: &str = "OPENSHELL_TLS_CA"; /// Path to the client certificate for mTLS communication with the gateway. diff --git a/crates/openshell-driver-docker/README.md b/crates/openshell-driver-docker/README.md index ee329f8dea..c4a551caa0 100644 --- a/crates/openshell-driver-docker/README.md +++ b/crates/openshell-driver-docker/README.md @@ -177,6 +177,10 @@ The driver publishes host loopback as the backend address for mediated path, so policies can reach host services without a Docker bridge, container DNS alias, or another gateway listener. +For HTTPS endpoints, the supervisor receives only the gateway CA and +uses its sandbox-scoped bearer token to authenticate RPCs. User client +certificates and private keys are not delivered to either container. + Docker Engine on Linux supports host networking directly. Docker Desktop requires host networking to be enabled in Settings and does not support it when Enhanced Container Isolation is enabled. @@ -189,7 +193,7 @@ The supervisor owns these security-critical variables: - `OPENSHELL_SANDBOX_TOKEN_FILE` - `OPENSHELL_SSH_SOCKET_PATH` - `OPENSHELL_MAIN_PROCESS_SPEC` -- TLS path variables when HTTPS is enabled +- `OPENSHELL_TLS_CA` when HTTPS is enabled Template and sandbox environment is encoded in the protected bootstrap and exposed only to workload children. Workload input cannot override diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index d4a1f2f71d..4c9d2ac2bc 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -189,13 +189,15 @@ pub struct DockerComputeConfig { /// Image containing the trusted `openshell-supervisor` binary. pub supervisor_image: Option, - /// Host-side CA certificate for Docker sandbox mTLS. + /// Host-side CA certificate for sandbox-to-gateway TLS. pub guest_tls_ca: Option, - /// Host-side client certificate for Docker sandbox mTLS. + /// Deprecated. Sandboxes authenticate with bearer tokens and must not + /// receive a user client certificate. pub guest_tls_cert: Option, - /// Host-side private key for Docker sandbox mTLS. + /// Deprecated. Sandboxes authenticate with bearer tokens and must not + /// receive a user client private key. pub guest_tls_key: Option, /// Unix socket path used for interactive sandbox access. @@ -293,8 +295,6 @@ impl Default for DockerComputeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DockerGuestTlsPaths { pub(crate) ca: PathBuf, - pub(crate) cert: PathBuf, - pub(crate) key: PathBuf, } #[derive(Debug, Clone)] @@ -4676,11 +4676,7 @@ async fn docker_supervisor_bundle_archive( SUPERVISOR_UID, SUPERVISOR_GID, )?; - for (name, path) in [ - ("ca.pem", &tls.ca), - ("cert.pem", &tls.cert), - ("key.pem", &tls.key), - ] { + for (name, path) in [("ca.pem", &tls.ca)] { let contents = tokio::fs::read(path).await.map_err(|error| { Status::internal(format!( "read Docker supervisor TLS file {}: {error}", @@ -5097,20 +5093,10 @@ async fn spawn_docker_control_process( ), ]; if config.guest_tls.is_some() { - environment.extend([ - format!( - "{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem", - openshell_core::sandbox_env::TLS_CA - ), - format!( - "{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/cert.pem", - openshell_core::sandbox_env::TLS_CERT - ), - format!( - "{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/key.pem", - openshell_core::sandbox_env::TLS_KEY - ), - ]); + environment.push(format!( + "{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem", + openshell_core::sandbox_env::TLS_CA + )); } if let Some(socket) = config.provider_spiffe_workload_api_socket.as_ref() { let projected = openshell_core::driver_utils::projected_provider_spiffe_socket_path(socket) @@ -6505,8 +6491,6 @@ fn canonicalize_existing_file(path: &Path, description: &str) -> CoreResult bool { docker_config.guest_tls_ca.is_some() - && docker_config.guest_tls_cert.is_some() - && docker_config.guest_tls_key.is_some() } fn default_docker_supervisor_grpc_endpoint(gateway_port: u16, tls: bool) -> String { @@ -6521,24 +6505,25 @@ pub(crate) fn docker_guest_tls_paths( || docker_config.guest_tls_cert.is_some() || docker_config.guest_tls_key.is_some(); + if docker_config.guest_tls_cert.is_some() || docker_config.guest_tls_key.is_some() { + return Err(Error::config( + "guest_tls_cert and guest_tls_key are no longer supported; sandboxes authenticate to the gateway with bearer tokens", + )); + } + if !docker_config.grpc_endpoint.starts_with("https://") { if tls_flags_provided { return Err(Error::config(format!( - "guest_tls_ca/guest_tls_cert/guest_tls_key were provided but grpc_endpoint is '{}'; TLS materials require an https:// endpoint", + "guest_tls_ca was provided but grpc_endpoint is '{}'; TLS materials require an https:// endpoint", docker_config.grpc_endpoint, ))); } return Ok(None); } - let provided = [ - docker_config.guest_tls_ca.as_ref(), - docker_config.guest_tls_cert.as_ref(), - docker_config.guest_tls_key.as_ref(), - ]; - if provided.iter().all(Option::is_none) { + if docker_config.guest_tls_ca.is_none() { return Err(Error::config( - "docker compute driver requires guest_tls_ca, guest_tls_cert, and guest_tls_key when grpc_endpoint uses https://", + "docker compute driver requires guest_tls_ca when grpc_endpoint uses https://", )); } @@ -6547,21 +6532,8 @@ pub(crate) fn docker_guest_tls_paths( "guest_tls_ca is required when Docker sandbox TLS materials are configured", )); }; - let Some(cert) = docker_config.guest_tls_cert.clone() else { - return Err(Error::config( - "guest_tls_cert is required when Docker sandbox TLS materials are configured", - )); - }; - let Some(key) = docker_config.guest_tls_key.clone() else { - return Err(Error::config( - "guest_tls_key is required when Docker sandbox TLS materials are configured", - )); - }; - Ok(Some(DockerGuestTlsPaths { ca: canonicalize_existing_file(&ca, "docker TLS CA certificate")?, - cert: canonicalize_existing_file(&cert, "docker TLS client certificate")?, - key: canonicalize_existing_file(&key, "docker TLS client private key")?, })) } diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index bc747312c3..8aa05302e0 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -188,8 +188,6 @@ fn runtime_config() -> DockerDriverRuntimeConfig { ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), guest_tls: Some(DockerGuestTlsPaths { ca: PathBuf::from("/tmp/ca.crt"), - cert: PathBuf::from("/tmp/tls.crt"), - key: PathBuf::from("/tmp/tls.key"), }), gpu: DockerGpuRuntimeCapabilities { cdi_supported: false, @@ -3237,18 +3235,19 @@ fn workload_mounts_only_the_shared_channel_volume() { } #[test] -fn docker_guest_tls_paths_require_all_files_for_https() { +fn docker_guest_tls_paths_accept_ca_only_for_https() { let tempdir = TempDir::new().unwrap(); let ca = tempdir.path().join("ca.crt"); fs::write(&ca, b"ca").unwrap(); - let err = docker_guest_tls_paths(&DockerComputeConfig { + let paths = docker_guest_tls_paths(&DockerComputeConfig { grpc_endpoint: "https://localhost:8443".to_string(), - guest_tls_ca: Some(ca), + guest_tls_ca: Some(ca.clone()), ..Default::default() }) - .unwrap_err(); - assert!(err.to_string().contains("guest_tls_cert")); + .unwrap() + .expect("CA-only TLS paths"); + assert_eq!(paths.ca, ca.canonicalize().unwrap()); } #[test] diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 215a7b21e1..6cb277fbbb 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -157,7 +157,10 @@ UID. Restart requires exactly one matching Sandbox resource and preserves its namespace and UID while rotating the supervisor Pod UID. The gateway requires the authenticated identity to match the durable binding before returning the generation-bound session JWT used by the supervisor. The sandbox Pod receives -neither token. +neither token. For HTTPS gateway connections, the supervisor reads only the +CA from the configured TLS Secret. Shared mode projects `ca.crt` directly; +managed and operator modes stage only the CA into the supervisor bootstrap +Secret. User client certificates and private keys are not mounted into either Pod. The gateway uses the supervisor relay for connect, exec, logs, and file sync. Sandbox Pods do not need direct external ingress for SSH. diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index d454014708..992c9741ab 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -624,7 +624,7 @@ impl KubernetesComputeConfig { !matches!(self.workspace_mode, WorkspaceMode::Shared) } - /// Where supervisor Pods read the gateway client TLS material. Outside + /// Where supervisor Pods read the gateway CA. Outside /// shared mode it is staged into each generation's bootstrap Secret. #[must_use] pub fn supervisor_client_tls(&self) -> crate::sandbox_runtime::SupervisorClientTls<'_> { diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 425f6daaca..64ffb0ca97 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -1251,8 +1251,8 @@ impl KubernetesComputeDriver { Ok(()) } - /// Read the gateway client TLS material staged into supervisor bootstrap - /// Secrets outside the sandbox namespace. + /// Read only the gateway CA staged into supervisor bootstrap Secrets + /// outside the sandbox namespace. async fn read_client_tls_material( &self, ) -> Result, KubernetesDriverError> { @@ -1272,8 +1272,6 @@ impl KubernetesComputeDriver { }; Ok(Some(ClientTlsMaterial { ca_certificate: take("ca.crt")?, - certificate: take("tls.crt")?, - private_key: take("tls.key")?, })) } @@ -11244,7 +11242,7 @@ mod tests { "kind": "Secret", "metadata": {"name": "openshell-client-tls", "namespace": "openshell"}, "type": "kubernetes.io/tls", - "data": {"ca.crt": "Y2E=", "tls.crt": "Y2VydA==", "tls.key": "a2V5"} + "data": {"ca.crt": "Y2E="} }), ), )], @@ -11255,8 +11253,6 @@ mod tests { .expect("read client TLS") .expect("client TLS is staged in managed mode"); assert_eq!(material.ca_certificate, b"ca"); - assert_eq!(material.certificate, b"cert"); - assert_eq!(material.private_key, b"key"); assert!(steps.lock().unwrap().is_empty()); let (shared, _, _) = scripted_driver( diff --git a/crates/openshell-driver-kubernetes/src/sandbox_runtime.rs b/crates/openshell-driver-kubernetes/src/sandbox_runtime.rs index abee5eaf03..642809aff5 100644 --- a/crates/openshell-driver-kubernetes/src/sandbox_runtime.rs +++ b/crates/openshell-driver-kubernetes/src/sandbox_runtime.rs @@ -39,8 +39,6 @@ pub const PROXY_CA_PRIVATE_KEY: &str = "proxy-ca.key"; /// which is the sandbox's own generated TLS-interception CA. pub const UPSTREAM_PROXY_CA_BUNDLE_KEY: &str = "upstream-proxy-ca.pem"; pub const CLIENT_TLS_CA_KEY: &str = "client-ca.crt"; -pub const CLIENT_TLS_CERTIFICATE_KEY: &str = "client-tls.crt"; -pub const CLIENT_TLS_PRIVATE_KEY: &str = "client-tls.key"; pub const SANDBOX_BOOTSTRAP_INPUT_PATH: &str = "/.openshell/bootstrap-input"; pub const BOUNDARY_CONFIG_PATH: &str = "/.openshell/state/bootstrap/boundary.json"; pub const BOUNDARY_CERTIFICATE_PATH: &str = "/.openshell/state/bootstrap/tls.crt"; @@ -56,8 +54,6 @@ pub const PROXY_CA_PRIVATE_KEY_PATH: &str = "/.openshell/supervisor/proxy-ca.key /// without a dedicated volume or mount. pub const UPSTREAM_PROXY_CA_BUNDLE_PATH: &str = "/.openshell/supervisor/upstream-proxy-ca.pem"; pub const CLIENT_TLS_CA_PATH: &str = "/.openshell/supervisor/client-ca.crt"; -pub const CLIENT_TLS_CERTIFICATE_PATH: &str = "/.openshell/supervisor/client-tls.crt"; -pub const CLIENT_TLS_PRIVATE_KEY_PATH: &str = "/.openshell/supervisor/client-tls.key"; pub const CONTROL_HEALTH_SOCKET_PATH: &str = "/run/openshell/health.sock"; /// Kubelet `tcpSocket` readiness port. An exec probe would start a supervisor /// process in every sandbox on every period. @@ -66,15 +62,13 @@ pub const NAMESPACE_WORKLOAD_POLICY_NAME: &str = "openshell-sandbox-workloads"; pub const NAMESPACE_SUPERVISOR_EGRESS_POLICY_NAME: &str = "openshell-sandbox-supervisors"; pub const SUPERVISOR_TERMINATION_GRACE_PERIOD_SECONDS: i64 = 30; -/// Gateway client TLS material staged into the supervisor bootstrap Secret. +/// Gateway CA material staged into the supervisor bootstrap Secret. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ClientTlsMaterial { pub ca_certificate: Vec, - pub certificate: Vec, - pub private_key: Vec, } -/// Where the supervisor reads its gateway client TLS material. +/// Where the supervisor reads the gateway CA. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SupervisorClientTls<'a> { Disabled, @@ -304,29 +298,28 @@ pub fn supervisor_pod( match client_tls { SupervisorClientTls::Disabled => {} SupervisorClientTls::Secret(secret_name) => { - environment.extend([ - env_var("OPENSHELL_TLS_CA", "/var/run/secrets/openshell-tls/ca.crt"), - env_var( - "OPENSHELL_TLS_CERT", - "/var/run/secrets/openshell-tls/tls.crt", - ), - env_var( - "OPENSHELL_TLS_KEY", - "/var/run/secrets/openshell-tls/tls.key", - ), - ]); + environment.push(env_var( + "OPENSHELL_TLS_CA", + "/var/run/secrets/openshell-tls/ca.crt", + )); volume_mounts.push(volume_mount( "client-tls", "/var/run/secrets/openshell-tls", true, )); - volumes.push(secret_volume("client-tls", secret_name, None)); + volumes.push(secret_volume( + "client-tls", + secret_name, + Some(KeyToPath { + key: "ca.crt".to_string(), + path: "ca.crt".to_string(), + ..Default::default() + }), + )); + } + SupervisorClientTls::Bootstrap => { + environment.push(env_var("OPENSHELL_TLS_CA", CLIENT_TLS_CA_PATH)); } - SupervisorClientTls::Bootstrap => environment.extend([ - env_var("OPENSHELL_TLS_CA", CLIENT_TLS_CA_PATH), - env_var("OPENSHELL_TLS_CERT", CLIENT_TLS_CERTIFICATE_PATH), - env_var("OPENSHELL_TLS_KEY", CLIENT_TLS_PRIVATE_KEY_PATH), - ]), } let mut command = vec![ "/openshell-supervisor".to_string(), @@ -560,20 +553,10 @@ pub fn supervisor_bootstrap_secret( data.insert(UPSTREAM_PROXY_CA_BUNDLE_KEY.to_string(), ByteString(bundle)); } if let Some(tls) = client_tls { - data.extend([ - ( - CLIENT_TLS_CA_KEY.to_string(), - ByteString(tls.ca_certificate), - ), - ( - CLIENT_TLS_CERTIFICATE_KEY.to_string(), - ByteString(tls.certificate), - ), - ( - CLIENT_TLS_PRIVATE_KEY.to_string(), - ByteString(tls.private_key), - ), - ]); + data.insert( + CLIENT_TLS_CA_KEY.to_string(), + ByteString(tls.ca_certificate), + ); } Secret { metadata: ObjectMeta { @@ -793,15 +776,13 @@ mod tests { None, Some(ClientTlsMaterial { ca_certificate: b"ca".to_vec(), - certificate: b"cert".to_vec(), - private_key: b"key".to_vec(), }), owner(), ); let data = secret.data.expect("Secret data"); assert_eq!(data[CLIENT_TLS_CA_KEY].0, b"ca"); - assert_eq!(data[CLIENT_TLS_CERTIFICATE_KEY].0, b"cert"); - assert_eq!(data[CLIENT_TLS_PRIVATE_KEY].0, b"key"); + assert!(!data.contains_key("client-tls.crt")); + assert!(!data.contains_key("client-tls.key")); } fn supervisor_pod_with_client_tls(client_tls: SupervisorClientTls<'_>) -> Pod { @@ -865,8 +846,8 @@ mod tests { SupervisorClientTls::Bootstrap, )); assert_eq!(env["OPENSHELL_TLS_CA"], CLIENT_TLS_CA_PATH); - assert_eq!(env["OPENSHELL_TLS_CERT"], CLIENT_TLS_CERTIFICATE_PATH); - assert_eq!(env["OPENSHELL_TLS_KEY"], CLIENT_TLS_PRIVATE_KEY_PATH); + assert!(!env.contains_key("OPENSHELL_TLS_CERT")); + assert!(!env.contains_key("OPENSHELL_TLS_KEY")); assert!(CLIENT_TLS_CA_PATH.starts_with("/.openshell/supervisor/")); assert!(!volumes.contains(&"client-tls".to_string())); } @@ -879,6 +860,21 @@ mod tests { env["OPENSHELL_TLS_CA"], "/var/run/secrets/openshell-tls/ca.crt" ); + assert!(!env.contains_key("OPENSHELL_TLS_CERT")); + assert!(!env.contains_key("OPENSHELL_TLS_KEY")); + let tls_volume = pod + .spec + .as_ref() + .unwrap() + .volumes + .as_ref() + .unwrap() + .iter() + .find(|volume| volume.name == "client-tls") + .unwrap(); + let items = tls_volume.secret.as_ref().unwrap().items.as_ref().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].key, "ca.crt"); assert!(volumes.contains(&"client-tls".to_string())); } @@ -936,6 +932,31 @@ mod tests { None ); let container = &pod_spec.containers[0]; + let environment = container.env.as_ref().expect("supervisor environment"); + assert!( + environment + .iter() + .any(|entry| entry.name == "OPENSHELL_TLS_CA") + ); + assert!(!environment.iter().any(|entry| matches!( + entry.name.as_str(), + "OPENSHELL_TLS_CERT" | "OPENSHELL_TLS_KEY" + ))); + let gateway_tls = pod_spec + .volumes + .as_ref() + .expect("supervisor volumes") + .iter() + .find(|volume| volume.name == "client-tls") + .and_then(|volume| volume.secret.as_ref()) + .expect("gateway CA secret"); + let items = gateway_tls + .items + .as_ref() + .expect("CA-only secret projection"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].key, "ca.crt"); + assert_eq!(items[0].path, "ca.crt"); assert_eq!(container.image_pull_policy.as_deref(), Some("IfNotPresent")); assert_eq!(pod_spec.automount_service_account_token, Some(false)); assert_eq!(pod_spec.restart_policy.as_deref(), Some("Never")); diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index 133326a0ec..abf2147513 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -69,8 +69,9 @@ Landlock denies agent access to the top-level `/.openshell` control hierarchy. The driver verifies Podman's reported `network=none` fence before launch and restart. Host networking applies to the supervisor, not the agent. -Gateway sessions use the existing sandbox JWT and optional configured mTLS -bundle. The sandbox/supervisor channel always uses its separate, per-sandbox +Gateway sessions use the sandbox JWT and optional server-authenticated TLS. +Only the gateway CA is delivered to the supervisor; user client certificates +and private keys are not mounted into either container. The sandbox/supervisor channel always uses its separate, per-sandbox mutual TLS material. These are distinct authentication relationships. ## Identity and trusted binaries diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index e5a7802d2e..4559cdd65e 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -69,16 +69,14 @@ pub struct PodmanComputeConfig { pub sandbox_runtime_image: String, /// OCI image containing the dynamically linked `openshell-supervisor` binary. pub supervisor_image: String, - /// Host path to the CA certificate for sandbox mTLS. + /// Host path to the CA certificate for sandbox-to-gateway TLS. /// - /// When all three TLS paths (`guest_tls_ca`, `guest_tls_cert`, - /// `guest_tls_key`) are set, the driver bind-mounts them into sandbox - /// containers and switches the auto-detected endpoint from `http://` - /// to `https://`. + /// When set, the driver mounts the CA into supervisor containers and + /// switches the auto-detected endpoint from `http://` to `https://`. pub guest_tls_ca: Option, - /// Host path to the client certificate for sandbox mTLS. + /// Deprecated. Sandboxes authenticate with bearer tokens. pub guest_tls_cert: Option, - /// Host path to the client private key for sandbox mTLS. + /// Deprecated. Sandboxes authenticate with bearer tokens. pub guest_tls_key: Option, /// Container cgroup PID limit for Podman-managed sandboxes. /// @@ -253,43 +251,33 @@ impl PodmanComputeConfig { self.validate_userns_mappings() } - /// Returns `true` when all three TLS paths are configured. + /// Returns `true` when the gateway CA is configured. #[must_use] pub fn tls_enabled(&self) -> bool { - self.guest_tls_ca.is_some() && self.guest_tls_cert.is_some() && self.guest_tls_key.is_some() + self.guest_tls_ca.is_some() } /// Validate TLS configuration consistency. /// - /// Returns `Ok(())` when either all three TLS paths are set (full mTLS) - /// or none are set (plaintext). Returns an error naming the missing - /// fields when only a subset is provided — this prevents silent - /// fallback to plaintext when an operator partially configures mTLS. + /// Client certificates are rejected because sandbox identity is carried + /// by bearer tokens rather than the gateway user's mTLS identity. pub fn validate_tls_config(&self) -> Result<(), crate::client::PodmanApiError> { - let has_ca = self.guest_tls_ca.is_some(); let has_cert = self.guest_tls_cert.is_some(); let has_key = self.guest_tls_key.is_some(); - // All set or none set — both are valid. - if (has_ca && has_cert && has_key) || (!has_ca && !has_cert && !has_key) { + if !has_cert && !has_key { return Ok(()); } - - let mut missing = Vec::new(); - if !has_ca { - missing.push("--podman-tls-ca / OPENSHELL_PODMAN_TLS_CA"); - } - if !has_cert { - missing.push("--podman-tls-cert / OPENSHELL_PODMAN_TLS_CERT"); - } - if !has_key { - missing.push("--podman-tls-key / OPENSHELL_PODMAN_TLS_KEY"); - } - Err(crate::client::PodmanApiError::InvalidInput(format!( - "Partial TLS configuration: all three TLS paths must be provided together. \ - Missing: {}", - missing.join(", ") + "Sandbox client certificates are no longer supported; remove {}", + [ + has_cert.then_some("--podman-tls-cert / OPENSHELL_PODMAN_TLS_CERT"), + has_key.then_some("--podman-tls-key / OPENSHELL_PODMAN_TLS_KEY"), + ] + .into_iter() + .flatten() + .collect::>() + .join(" and ") ))) } @@ -1030,107 +1018,39 @@ mod tests { } #[test] - fn validate_tls_config_all_set_is_ok() { + fn validate_tls_config_ca_only_is_ok() { let cfg = PodmanComputeConfig { guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), - guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")), - guest_tls_key: Some(PathBuf::from("/tls/tls.key")), ..PodmanComputeConfig::default() }; assert!(cfg.validate_tls_config().is_ok()); + assert!(cfg.tls_enabled()); } #[test] - fn validate_tls_config_only_ca_is_error() { - let cfg = PodmanComputeConfig { - guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), - ..PodmanComputeConfig::default() - }; - let err = cfg - .validate_tls_config() - .expect_err("only CA should be rejected"); - let msg = err.to_string(); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - } - - #[test] - fn validate_tls_config_only_cert_is_error() { - let cfg = PodmanComputeConfig { - guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")), - ..PodmanComputeConfig::default() - }; - let err = cfg - .validate_tls_config() - .expect_err("only cert should be rejected"); - let msg = err.to_string(); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - } - - #[test] - fn validate_tls_config_only_key_is_error() { - let cfg = PodmanComputeConfig { - guest_tls_key: Some(PathBuf::from("/tls/tls.key")), - ..PodmanComputeConfig::default() - }; - let err = cfg - .validate_tls_config() - .expect_err("only key should be rejected"); - let msg = err.to_string(); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); - } - - #[test] - fn validate_tls_config_ca_and_cert_missing_key_is_error() { + fn validate_tls_config_rejects_client_certificate() { let cfg = PodmanComputeConfig { - guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")), ..PodmanComputeConfig::default() }; let err = cfg .validate_tls_config() - .expect_err("missing key should be rejected"); - let msg = err.to_string(); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - } - - #[test] - fn validate_tls_config_ca_and_key_missing_cert_is_error() { - let cfg = PodmanComputeConfig { - guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), - guest_tls_key: Some(PathBuf::from("/tls/tls.key")), - ..PodmanComputeConfig::default() - }; - let err = cfg - .validate_tls_config() - .expect_err("missing cert should be rejected"); + .expect_err("sandbox client certificate should be rejected"); let msg = err.to_string(); assert!(msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); } #[test] - fn validate_tls_config_cert_and_key_missing_ca_is_error() { + fn validate_tls_config_rejects_client_private_key() { let cfg = PodmanComputeConfig { - guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")), guest_tls_key: Some(PathBuf::from("/tls/tls.key")), ..PodmanComputeConfig::default() }; let err = cfg .validate_tls_config() - .expect_err("missing CA should be rejected"); + .expect_err("sandbox client private key should be rejected"); let msg = err.to_string(); - assert!(msg.contains("OPENSHELL_PODMAN_TLS_CA"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_CERT"), "{msg}"); - assert!(!msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); + assert!(msg.contains("OPENSHELL_PODMAN_TLS_KEY"), "{msg}"); } #[test] diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 3092eb73b3..51245a244a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -55,13 +55,9 @@ const TOKEN_SECRET_PREFIX: &str = "openshell-token-"; const PROXY_AUTH_SECRET_PREFIX: &str = "openshell-proxy-auth-"; const RESOLVER_SECRET_PREFIX: &str = "openshell-resolver-"; const TLS_CA_SECRET_PREFIX: &str = "openshell-tls-ca-"; -const TLS_CERT_SECRET_PREFIX: &str = "openshell-tls-cert-"; -const TLS_KEY_SECRET_PREFIX: &str = "openshell-tls-key-"; /// Container-side mount paths for client TLS materials and the sandbox token. const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; -const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH; -const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PATH; const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; @@ -201,14 +197,10 @@ pub fn resolver_secret_name(sandbox_id: &str) -> String { format!("{RESOLVER_SECRET_PREFIX}{sandbox_id}") } -/// Build per-sandbox Podman secret names for TLS CA, cert, and key. +/// Build the per-sandbox Podman secret name for the gateway CA. #[must_use] -pub fn tls_secret_names(sandbox_id: &str) -> [String; 3] { - [ - format!("{TLS_CA_SECRET_PREFIX}{sandbox_id}"), - format!("{TLS_CERT_SECRET_PREFIX}{sandbox_id}"), - format!("{TLS_KEY_SECRET_PREFIX}{sandbox_id}"), - ] +pub fn tls_secret_names(sandbox_id: &str) -> [String; 1] { + [format!("{TLS_CA_SECRET_PREFIX}{sandbox_id}")] } /// Truncate a container ID to 12 characters (standard short form). @@ -594,22 +586,12 @@ fn build_env( openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.into(), ); - // 3. TLS client cert paths (when mTLS is enabled). These point to - // the container-side mount paths where the cert files are - // bind-mounted from the host. + // 3. Gateway CA path (when TLS is enabled). if config.tls_enabled() { env.insert( openshell_core::sandbox_env::TLS_CA.into(), TLS_CA_MOUNT_PATH.into(), ); - env.insert( - openshell_core::sandbox_env::TLS_CERT.into(), - TLS_CERT_MOUNT_PATH.into(), - ); - env.insert( - openshell_core::sandbox_env::TLS_KEY.into(), - TLS_KEY_MOUNT_PATH.into(), - ); } if let Some(socket_path) = provider_spiffe_workload_api_socket_env_value(config) { @@ -1091,7 +1073,7 @@ pub fn build_container_spec_for_image( image_id: &str, oci_user: &str, supervisor_bin_path: Option<&Path>, - tls_secret_names: Option<&[String; 3]>, + tls_secret_names: Option<&[String; 1]>, ) -> Result { serde_json::to_value(build_base_spec( sandbox, @@ -1117,7 +1099,7 @@ fn build_base_spec( image_id: &str, oci_user: &str, supervisor_bin_path: Option<&Path>, - tls_secret_names: Option<&[String; 3]>, + tls_secret_names: Option<&[String; 1]>, ) -> Result { let name = container_name(&sandbox.workspace, &sandbox.name, &sandbox.id); let vol = volume_name(&sandbox.id); @@ -1259,7 +1241,7 @@ fn build_base_spec( mode: 0o400, }); } - if let Some([ca, cert, key]) = tls_secret_names { + if let Some([ca]) = tls_secret_names { secrets.push(SecretMount { source: ca.clone(), target: TLS_CA_MOUNT_PATH.into(), @@ -1267,20 +1249,6 @@ fn build_base_spec( gid: 0, mode: 0o400, }); - secrets.push(SecretMount { - source: cert.clone(), - target: TLS_CERT_MOUNT_PATH.into(), - uid: 0, - gid: 0, - mode: 0o400, - }); - secrets.push(SecretMount { - source: key.clone(), - target: TLS_KEY_MOUNT_PATH.into(), - uid: 0, - gid: 0, - mode: 0o400, - }); } secrets }, @@ -1317,18 +1285,14 @@ fn build_base_spec( destination: openshell_core::container_paths::NETNS_MOUNT_ROOT.into(), options: vec!["rw".into(), "nosuid".into(), "nodev".into()], }]; - // Deliver client TLS materials into the container when mTLS is + // Deliver the gateway CA into the container when TLS is // enabled. When userns remaps UIDs (auto, no-map), bind-mounted // host files are unreadable because the container root maps to a // different host UID. In that case TLS materials are delivered as // Podman secrets (handled in the `secrets` block above); otherwise // use bind mounts. if tls_secret_names.is_none() - && let (Some(ca), Some(cert), Some(key)) = ( - &config.guest_tls_ca, - &config.guest_tls_cert, - &config.guest_tls_key, - ) + && let Some(ca) = &config.guest_tls_ca { let mut ro = vec!["ro".into(), "rbind".into()]; if is_selinux_enabled() { @@ -1340,18 +1304,6 @@ fn build_base_spec( destination: TLS_CA_MOUNT_PATH.into(), options: ro.clone(), }); - m.push(Mount { - kind: "bind".into(), - source: cert.display().to_string(), - destination: TLS_CERT_MOUNT_PATH.into(), - options: ro.clone(), - }); - m.push(Mount { - kind: "bind".into(), - source: key.display().to_string(), - destination: TLS_KEY_MOUNT_PATH.into(), - options: ro, - }); } // Bind-mount the corporate proxy CA bundle read-only when // configured. A CA certificate is not secret, so unlike the proxy @@ -1452,7 +1404,7 @@ pub struct IsolationSpecInput<'a> { pub image_user: &'a str, pub image_env: &'a [String], pub supervisor_bin: Option<&'a Path>, - pub tls_secrets: Option<&'a [String; 3]>, + pub tls_secrets: Option<&'a [String; 1]>, pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity, /// Whether this workload is created by a rootless Podman service. pub rootless: bool, @@ -1693,11 +1645,7 @@ pub fn build_isolation_specs( fn trusted_mount(destination: &str) -> bool { matches!( destination, - TLS_CA_MOUNT_PATH - | TLS_CERT_MOUNT_PATH - | TLS_KEY_MOUNT_PATH - | PROXY_CA_MOUNT_PATH - | PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR + TLS_CA_MOUNT_PATH | PROXY_CA_MOUNT_PATH | PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR ) || destination == openshell_core::container_paths::NETNS_MOUNT_ROOT } @@ -3382,12 +3330,10 @@ mod tests { } #[test] - fn container_spec_includes_tls_mounts_when_configured() { + fn container_spec_includes_only_tls_ca_when_configured() { let sandbox = test_sandbox("tls-id", "tls-name"); let mut config = test_config(); config.guest_tls_ca = Some(std::path::PathBuf::from("/host/ca.crt")); - config.guest_tls_cert = Some(std::path::PathBuf::from("/host/tls.crt")); - config.guest_tls_key = Some(std::path::PathBuf::from("/host/tls.key")); let spec = build_container_spec(&sandbox, &config); @@ -3397,16 +3343,10 @@ mod tests { env_map.get("OPENSHELL_TLS_CA").and_then(|v| v.as_str()), Some("/etc/openshell/tls/client/ca.crt"), ); - assert_eq!( - env_map.get("OPENSHELL_TLS_CERT").and_then(|v| v.as_str()), - Some("/etc/openshell/tls/client/tls.crt"), - ); - assert_eq!( - env_map.get("OPENSHELL_TLS_KEY").and_then(|v| v.as_str()), - Some("/etc/openshell/tls/client/tls.key"), - ); + assert!(env_map.get("OPENSHELL_TLS_CERT").is_none()); + assert!(env_map.get("OPENSHELL_TLS_KEY").is_none()); - // Verify bind mounts exist for all three cert files. + // Verify only the CA bind mount exists. let mounts = spec["mounts"] .as_array() .expect("mounts should be an array"); @@ -3419,14 +3359,7 @@ mod tests { bind_dests.contains(&"/etc/openshell/tls/client/ca.crt"), "should bind-mount CA cert" ); - assert!( - bind_dests.contains(&"/etc/openshell/tls/client/tls.crt"), - "should bind-mount client cert" - ); - assert!( - bind_dests.contains(&"/etc/openshell/tls/client/tls.key"), - "should bind-mount client key" - ); + assert_eq!(bind_dests.len(), 1); // Verify SELinux relabel option is present iff SELinux is enabled. let tls_binds: Vec<&Value> = mounts diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 3788c756f4..d3023cddd8 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -297,13 +297,9 @@ async fn cleanup_sandbox_proxy_auth_secret(client: &PodmanClient, secret_name: & async fn create_tls_secrets( client: &PodmanClient, config: &PodmanComputeConfig, - names: &[String; 3], + names: &[String; 1], ) -> Result<(), ComputeDriverError> { - let paths = [ - config.guest_tls_ca.as_deref(), - config.guest_tls_cert.as_deref(), - config.guest_tls_key.as_deref(), - ]; + let paths = [config.guest_tls_ca.as_deref()]; let mut created = 0usize; for (name, path) in names.iter().zip(paths.iter()) { let Some(p) = path else { continue }; @@ -328,7 +324,7 @@ async fn create_tls_secrets( Ok(()) } -async fn cleanup_tls_secrets(client: &PodmanClient, names: &[String; 3]) { +async fn cleanup_tls_secrets(client: &PodmanClient, names: &[String]) { for name in names { if let Err(err) = client.remove_secret(name).await { warn!( @@ -2687,8 +2683,6 @@ mod tests { let cfg = PodmanComputeConfig { gateway_port: 8080, guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), - guest_tls_cert: Some(PathBuf::from("/tls/tls.crt")), - guest_tls_key: Some(PathBuf::from("/tls/tls.key")), ..PodmanComputeConfig::default() }; assert_eq!( @@ -2698,26 +2692,14 @@ mod tests { } #[test] - fn partial_tls_config_returns_error() { + fn ca_only_tls_config_is_enabled() { let cfg = PodmanComputeConfig { gateway_port: 8080, guest_tls_ca: Some(PathBuf::from("/tls/ca.crt")), - // guest_tls_cert and guest_tls_key not set — incomplete TLS config. ..PodmanComputeConfig::default() }; - assert!(!cfg.tls_enabled()); - let err = cfg - .validate_tls_config() - .expect_err("partial TLS config should be rejected"); - let msg = err.to_string(); - assert!( - msg.contains("OPENSHELL_PODMAN_TLS_CERT"), - "error should name the missing cert: {msg}" - ); - assert!( - msg.contains("OPENSHELL_PODMAN_TLS_KEY"), - "error should name the missing key: {msg}" - ); + assert!(cfg.tls_enabled()); + cfg.validate_tls_config().expect("CA-only TLS is valid"); } #[test] diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index d04b20cea7..8287ad019e 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -117,15 +117,15 @@ struct Args { #[arg(long, env = "OPENSHELL_SUPERVISOR_IMAGE")] supervisor_image: Option, - /// Host path to the CA certificate for sandbox mTLS. + /// Host path to the CA certificate for supervisor-to-gateway TLS. #[arg(long, env = "OPENSHELL_PODMAN_TLS_CA")] podman_tls_ca: Option, - /// Host path to the client certificate for sandbox mTLS. + /// Deprecated; client certificates are rejected. #[arg(long, env = "OPENSHELL_PODMAN_TLS_CERT")] podman_tls_cert: Option, - /// Host path to the client private key for sandbox mTLS. + /// Deprecated; client private keys are rejected. #[arg(long, env = "OPENSHELL_PODMAN_TLS_KEY")] podman_tls_key: Option, diff --git a/crates/openshell-driver-vm/README.md b/crates/openshell-driver-vm/README.md index b2103a6957..a0d83e1e4d 100644 --- a/crates/openshell-driver-vm/README.md +++ b/crates/openshell-driver-vm/README.md @@ -167,8 +167,6 @@ Select the VM driver with `--compute-driver vm`, `OPENSHELL_COMPUTE_DRIVER=vm`, | `overlay_disk_mib` | `4096` | Sparse writable overlay disk size per sandbox, in MiB. | | `krun_log_level` | `1` | libkrun verbosity (0-5). | | `guest_tls_ca` | unset | Historical key name for the host supervisor's gateway CA certificate. Required when `grpc_endpoint` uses `https://`; never copied into the guest. | -| `guest_tls_cert` | unset | Historical key name for the host supervisor's client certificate; never copied into the guest. | -| `guest_tls_key` | unset | Historical key name for the host supervisor's client private key; never copied into the guest. | | `https_proxy` | unset | Corporate forward proxy (`http://host:port` or `https://host:port`) that host control chains policy-approved TLS CONNECT egress through. Host-loopback proxy URLs work because control runs on the gateway host. | | `no_proxy` | unset | Comma-separated bypass list for the corporate proxy only. OpenShell policy evaluation still applies. | | `proxy_auth_file` | unset | Gateway-host path to a validated `user:pass` credential file. Staged root-only into the per-sandbox overlay and removed with the sandbox; credentials never enter logs or process arguments. | diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 58ad21892e..3fceaf0e80 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -157,10 +157,6 @@ const GUEST_SSH_SOCKET_PATH: &str = openshell_core::container_paths::SSH_SOCKET_ #[allow(dead_code)] const GUEST_TLS_CA_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_CA_PATH; #[allow(dead_code)] -const GUEST_TLS_CERT_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_CERT_PATH; -#[allow(dead_code)] -const GUEST_TLS_KEY_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_KEY_PATH; -#[allow(dead_code)] const GUEST_SANDBOX_TOKEN_PATH: &str = openshell_core::container_paths::VM_GUEST_SANDBOX_TOKEN_PATH; const GUEST_INIT_DROPIN_DIR: &str = openshell_core::container_paths::VM_GUEST_INIT_DROPIN_DIR; const GUEST_BOUNDARY_CONFIG_DIR: &str = "/.openshell/state"; @@ -215,8 +211,6 @@ static OWNER_STATE_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); #[derive(Debug, Clone)] struct VmDriverTlsPaths { ca: PathBuf, - cert: PathBuf, - key: PathBuf, } #[derive(Debug, Clone)] @@ -497,15 +491,16 @@ impl VmDriverConfig { } fn tls_paths(&self) -> Result, String> { - let provided = [ - self.guest_tls_ca.as_ref(), - self.guest_tls_cert.as_ref(), - self.guest_tls_key.as_ref(), - ]; - if provided.iter().all(Option::is_none) { + if self.guest_tls_cert.is_some() || self.guest_tls_key.is_some() { + return Err( + "sandbox client certificates are no longer supported; remove OPENSHELL_VM_TLS_CERT and OPENSHELL_VM_TLS_KEY" + .to_string(), + ); + } + if self.guest_tls_ca.is_none() { return if self.requires_tls_materials() { Err( - "https:// openshell endpoint requires OPENSHELL_VM_TLS_CA, OPENSHELL_VM_TLS_CERT, and OPENSHELL_VM_TLS_KEY so the host supervisor can authenticate to the gateway" + "https:// openshell endpoint requires OPENSHELL_VM_TLS_CA so the host supervisor can authenticate the gateway" .to_string(), ) } else { @@ -518,18 +513,7 @@ impl VmDriverConfig { "OPENSHELL_VM_TLS_CA is required when TLS materials are configured".to_string(), ); }; - let Some(cert) = self.guest_tls_cert.clone() else { - return Err( - "OPENSHELL_VM_TLS_CERT is required when TLS materials are configured".to_string(), - ); - }; - let Some(key) = self.guest_tls_key.clone() else { - return Err( - "OPENSHELL_VM_TLS_KEY is required when TLS materials are configured".to_string(), - ); - }; - - for path in [&ca, &cert, &key] { + for path in [&ca] { if !path.is_file() { return Err(format!( "TLS material '{}' does not exist or is not a file", @@ -538,7 +522,7 @@ impl VmDriverConfig { } } - Ok(Some(VmDriverTlsPaths { ca, cert, key })) + Ok(Some(VmDriverTlsPaths { ca })) } } @@ -983,10 +967,7 @@ impl VmDriver { } configure_main_exit_marker(&mut command, state_dir); if let Some(tls) = tls_paths { - command - .env(openshell_core::sandbox_env::TLS_CA, &tls.ca) - .env(openshell_core::sandbox_env::TLS_CERT, &tls.cert) - .env(openshell_core::sandbox_env::TLS_KEY, &tls.key); + command.env(openshell_core::sandbox_env::TLS_CA, &tls.ca); } #[cfg(unix)] let (liveness_read, liveness_write) = nix::unistd::pipe().map_err(|error| { @@ -9758,8 +9739,6 @@ mod tests { let config = VmDriverConfig { grpc_endpoint: "https://127.0.0.1:8443".to_string(), guest_tls_ca: Some(PathBuf::from("/host/ca.crt")), - guest_tls_cert: Some(PathBuf::from("/host/tls.crt")), - guest_tls_key: Some(PathBuf::from("/host/tls.key")), ..Default::default() }; let sandbox = Sandbox { diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index fe0b035023..d7195b4cc3 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -119,9 +119,11 @@ struct Args { #[arg(long = "guest-tls-ca", env = "OPENSHELL_VM_TLS_CA")] guest_tls_ca: Option, + /// Deprecated; client certificates are rejected. #[arg(long = "guest-tls-cert", env = "OPENSHELL_VM_TLS_CERT")] guest_tls_cert: Option, + /// Deprecated; client private keys are rejected. #[arg(long = "guest-tls-key", env = "OPENSHELL_VM_TLS_KEY")] guest_tls_key: Option, diff --git a/crates/openshell-gateway/src/lib.rs b/crates/openshell-gateway/src/lib.rs index 8864e85ede..376e80380b 100644 --- a/crates/openshell-gateway/src/lib.rs +++ b/crates/openshell-gateway/src/lib.rs @@ -187,7 +187,6 @@ fn install_in_tree_compute_drivers(registry: &mut ComputeDriverRegistry) { .map(|registration| { registration .with_telemetry_category(TelemetryComputeDriver::anonymous_category("kubernetes")) - .without_mtls_user_auth() .with_in_process_tracing(openshell_driver_kubernetes::otel_tracing::TRACING) }), #[cfg(feature = "compute-driver-podman")] @@ -307,12 +306,7 @@ impl openshell_server::ComputeDriverFactory for DockerFactory { ) -> openshell_core::Result { let mut config: openshell_driver_docker::DockerComputeConfig = context.driver_config()?; require_guest_tls_for_local_driver(&context, "docker")?; - apply_guest_tls( - &mut config.guest_tls_ca, - &mut config.guest_tls_cert, - &mut config.guest_tls_key, - context.guest_tls_paths(), - ); + apply_guest_tls(&mut config.guest_tls_ca, context.guest_tls_ca()); let driver = openshell_driver_docker::DockerComputeDriver::new( context.gateway_bind_address(), context.gateway_log_level(), @@ -353,12 +347,7 @@ impl openshell_server::ComputeDriverFactory for PodmanFactory { ) -> openshell_core::Result { let mut config = podman_config(context.config_context())?; require_guest_tls_for_local_driver(&context, "podman")?; - apply_guest_tls( - &mut config.guest_tls_ca, - &mut config.guest_tls_cert, - &mut config.guest_tls_key, - context.guest_tls_paths(), - ); + apply_guest_tls(&mut config.guest_tls_ca, context.guest_tls_ca()); let driver = openshell_driver_podman::PodmanComputeDriver::new(config) .await .map_err(|error| openshell_core::Error::execution(error.to_string()))?; @@ -421,7 +410,7 @@ impl openshell_server::ComputeDriverFactory for VmFactory { let mut config = vm_config(context.config_context())?; require_guest_tls_for_local_driver(&context, "vm")?; if config.grpc_endpoint.trim().is_empty() - && (!context.gateway_tls_enabled() || context.guest_tls_paths().is_some()) + && (!context.gateway_tls_enabled() || context.guest_tls_ca().is_some()) { let scheme = if context.gateway_tls_enabled() { "https" @@ -430,12 +419,7 @@ impl openshell_server::ComputeDriverFactory for VmFactory { }; config.grpc_endpoint = format!("{scheme}://127.0.0.1:{}", context.gateway_port()); } - apply_guest_tls( - &mut config.guest_tls_ca, - &mut config.guest_tls_cert, - &mut config.guest_tls_key, - context.guest_tls_paths(), - ); + apply_guest_tls(&mut config.guest_tls_ca, context.guest_tls_ca()); let endpoint = vm::spawn( context.gateway_log_level(), context.gateway_name(), @@ -474,7 +458,7 @@ fn require_guest_tls_for_local_driver( ) -> openshell_core::Result<()> { validate_local_driver_guest_tls( context.gateway_tls_enabled(), - context.guest_tls_paths().is_some(), + context.guest_tls_ca().is_some(), driver_name, ) } @@ -494,7 +478,7 @@ fn validate_local_driver_guest_tls( ) -> openshell_core::Result<()> { if gateway_tls_enabled && !has_guest_tls { return Err(openshell_core::Error::config(format!( - "gateway TLS requires guest_tls_ca, guest_tls_cert, and guest_tls_key in [openshell.gateway] when using the {driver_name} compute driver" + "gateway TLS requires guest_tls_ca in [openshell.gateway] when using the {driver_name} compute driver" ))); } Ok(()) @@ -508,20 +492,11 @@ fn validate_local_driver_guest_tls( feature = "compute-driver-vm" ) ))] -fn apply_guest_tls( - ca: &mut Option, - cert: &mut Option, - key: &mut Option, - defaults: Option<(&std::path::Path, &std::path::Path, &std::path::Path)>, -) { +fn apply_guest_tls(ca: &mut Option, default_ca: Option<&std::path::Path>) { if ca.is_none() - && cert.is_none() - && key.is_none() - && let Some((default_ca, default_cert, default_key)) = defaults + && let Some(default_ca) = default_ca { *ca = Some(default_ca.to_owned()); - *cert = Some(default_cert.to_owned()); - *key = Some(default_key.to_owned()); } } @@ -550,7 +525,7 @@ mod local_driver_tests { } #[test] - fn tls_enabled_local_drivers_require_a_guest_bundle() { + fn tls_enabled_local_drivers_require_a_gateway_ca() { for driver_name in ["docker", "podman", "vm"] { let error = validate_local_driver_guest_tls(true, false, driver_name) .expect_err("TLS-enabled local driver must require guest TLS"); @@ -559,29 +534,16 @@ mod local_driver_tests { assert!(message.contains("guest_tls_ca")); } validate_local_driver_guest_tls(true, true, "docker") - .expect("a complete guest bundle satisfies the requirement"); + .expect("a gateway CA satisfies the requirement"); validate_local_driver_guest_tls(false, false, "docker") .expect("plaintext gateways do not require guest TLS"); } #[test] - fn package_managed_guest_bundle_is_injected_when_driver_paths_are_absent() { + fn package_managed_gateway_ca_is_injected_when_driver_path_is_absent() { let mut ca = None; - let mut cert = None; - let mut key = None; - apply_guest_tls( - &mut ca, - &mut cert, - &mut key, - Some(( - Path::new("/managed/ca.pem"), - Path::new("/managed/client.pem"), - Path::new("/managed/client-key.pem"), - )), - ); + apply_guest_tls(&mut ca, Some(Path::new("/managed/ca.pem"))); assert_eq!(ca, Some(PathBuf::from("/managed/ca.pem"))); - assert_eq!(cert, Some(PathBuf::from("/managed/client.pem"))); - assert_eq!(key, Some(PathBuf::from("/managed/client-key.pem"))); } } diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index b0c6958ca0..d0c00cd786 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -105,15 +105,9 @@ pub struct VmComputeConfig { /// Maximum accepted rootfs tar size, in bytes, before and after decompression. pub rootfs_tar_max_bytes: Option, - /// Host-side CA certificate for the guest's mTLS client bundle. + /// Host-side CA certificate used to authenticate the gateway. pub guest_tls_ca: Option, - /// Host-side client certificate for the guest's mTLS client bundle. - pub guest_tls_cert: Option, - - /// Host-side private key for the guest's mTLS client bundle. - pub guest_tls_key: Option, - /// Corporate forward-proxy settings passed to the VM driver. Flattening /// preserves the shared local-driver TOML field names. #[serde(flatten)] @@ -256,8 +250,6 @@ impl Default for VmComputeConfig { rootfs_tar_staging_dir: None, rootfs_tar_max_bytes: None, guest_tls_ca: None, - guest_tls_cert: None, - guest_tls_key: None, upstream_proxy: UpstreamProxyConfig::default(), proxy_ca_bundle: None, provider_spiffe_workload_api_tcp_endpoint: None, @@ -270,8 +262,6 @@ impl Default for VmComputeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct VmGuestTlsPaths { pub ca: PathBuf, - pub cert: PathBuf, - pub key: PathBuf, } /// Resolve the `openshell-driver-vm` binary path. @@ -504,14 +494,9 @@ pub fn compute_driver_guest_tls_paths( return Ok(None); } - let provided = [ - vm_config.guest_tls_ca.as_ref(), - vm_config.guest_tls_cert.as_ref(), - vm_config.guest_tls_key.as_ref(), - ]; - if provided.iter().all(Option::is_none) { + if vm_config.guest_tls_ca.is_none() { return Err(Error::config( - "vm compute driver requires guest_tls_ca, guest_tls_cert, and guest_tls_key when grpc_endpoint uses https://", + "vm compute driver requires guest_tls_ca when grpc_endpoint uses https://", )); } @@ -520,18 +505,7 @@ pub fn compute_driver_guest_tls_paths( "guest_tls_ca is required when VM guest TLS materials are configured", )); }; - let Some(cert) = vm_config.guest_tls_cert.clone() else { - return Err(Error::config( - "guest_tls_cert is required when VM guest TLS materials are configured", - )); - }; - let Some(key) = vm_config.guest_tls_key.clone() else { - return Err(Error::config( - "guest_tls_key is required when VM guest TLS materials are configured", - )); - }; - - for path in [&ca, &cert, &key] { + for path in [&ca] { if !path.is_file() { return Err(Error::config(format!( "vm guest TLS material '{}' does not exist or is not a file", @@ -540,7 +514,7 @@ pub fn compute_driver_guest_tls_paths( } } - Ok(Some(VmGuestTlsPaths { ca, cert, key })) + Ok(Some(VmGuestTlsPaths { ca })) } /// Launch the VM compute-driver subprocess, wait for its UDS to come up, @@ -599,8 +573,6 @@ pub async fn spawn( append_vm_rootfs_tar_args(&mut command, vm_config); if let Some(tls) = guest_tls_paths { command.arg("--guest-tls-ca").arg(tls.ca); - command.arg("--guest-tls-cert").arg(tls.cert); - command.arg("--guest-tls-key").arg(tls.key); } append_vm_proxy_and_spiffe_args(&mut command, vm_config); @@ -1105,47 +1077,26 @@ mod tests { }; let err = compute_driver_guest_tls_paths(&vm_config) - .expect_err("https vm endpoints should require an explicit guest client bundle"); - assert!( - err.to_string() - .contains("guest_tls_ca, guest_tls_cert, and guest_tls_key") - ); + .expect_err("https vm endpoints should require an explicit gateway CA"); + assert!(err.to_string().contains("guest_tls_ca")); } #[test] - fn vm_compute_driver_tls_uses_guest_bundle_not_gateway_server_identity() { + fn vm_compute_driver_tls_uses_only_gateway_ca() { let dir = tempdir().unwrap(); - let server_cert = dir.path().join("server.crt"); - let server_key = dir.path().join("server.key"); let guest_ca = dir.path().join("guest-ca.crt"); - let guest_cert = dir.path().join("guest.crt"); - let guest_key = dir.path().join("guest.key"); - for path in [ - &server_cert, - &server_key, - &guest_ca, - &guest_cert, - &guest_key, - ] { - std::fs::write(path, path.display().to_string()).unwrap(); - } + std::fs::write(&guest_ca, guest_ca.display().to_string()).unwrap(); let vm_config = VmComputeConfig { grpc_endpoint: "https://gateway.internal:8443".to_string(), guest_tls_ca: Some(guest_ca.clone()), - guest_tls_cert: Some(guest_cert.clone()), - guest_tls_key: Some(guest_key.clone()), ..Default::default() }; let guest_paths = compute_driver_guest_tls_paths(&vm_config) .unwrap() - .expect("https vm endpoints should pass an explicit guest client bundle"); + .expect("https vm endpoints should pass an explicit gateway CA"); assert_eq!(guest_paths.ca, guest_ca); - assert_eq!(guest_paths.cert, guest_cert); - assert_eq!(guest_paths.key, guest_key); - assert_ne!(guest_paths.cert, server_cert); - assert_ne!(guest_paths.key, server_key); } #[test] diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 390e4861eb..298533278a 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -78,9 +78,6 @@ pub enum SandboxIdentitySource { /// Generation-bound gateway JWT validated against the persisted runtime /// identity by [`super::sandbox_jwt::SandboxSessionJwtAuthenticator`]. BootstrapJwt { issuer: String }, - /// Per-sandbox client certificate. Reserved for channel-bound sandbox - /// identity. - BootstrapCert { fingerprint: String }, /// Driver-native credential used to bootstrap a gateway-minted JWT via /// `IssueSandboxToken`. The named compute driver authenticated only the /// sandbox identity and its concrete runtime binding; the gateway still diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9000ab1324..87b67f5e8a 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -180,10 +180,9 @@ struct RunArgs { )] oidc_jwks_allowed_origins: Vec, - /// Enable mTLS client certificate authentication for local single-user gateways. + /// Enable mTLS client certificate authentication for gateway users. /// - /// When unset, this defaults on for drivers registered as local - /// single-player backends when client certificate verification is + /// When unset, this defaults on when client certificate verification is /// configured and no OIDC issuer is present. #[arg( long = "enable-mtls-auth", @@ -357,8 +356,6 @@ fn prepare_server_config_with_drivers( let compute_driver = compute_drivers .select(args.compute_driver.as_deref()) .map_err(|error| miette::miette!("{error}"))?; - let selected_registration = compute_drivers.get(compute_driver.name()); - let local_tls = apply_runtime_defaults(args)?; let guest_tls = GuestTlsPaths::resolve( file.as_ref().map(|file| &file.openshell.gateway), @@ -371,9 +368,7 @@ fn prepare_server_config_with_drivers( let bind = SocketAddr::new(args.bind_address, args.port); let has_client_ca = args.tls_client_ca.is_some(); - let has_oidc = args.oidc_issuer.is_some(); - let mtls_auth_enabled = - resolve_mtls_auth_enabled(args, matches, file.as_ref(), selected_registration); + let mtls_auth_enabled = resolve_mtls_auth_enabled(args, matches, file.as_ref()); if args.disable_tls && has_client_ca { return Err(miette::miette!( @@ -390,14 +385,6 @@ fn prepare_server_config_with_drivers( "mTLS user authentication requires --tls-client-ca so client certificates can be verified." )); } - if mtls_auth_enabled - && selected_registration.is_some_and(|registration| !registration.supports_mtls_user_auth()) - { - return Err(miette::miette!( - "mTLS user authentication is not supported with the selected compute driver. Configure OIDC or a trusted fronting proxy for user authentication." - )); - } - let tls = if args.disable_tls { None } else { @@ -425,7 +412,11 @@ fn prepare_server_config_with_drivers( Some(openshell_core::TlsConfig { cert_path, key_path, - require_client_auth: has_client_ca && !has_oidc, + // Sandboxes authenticate at the application layer with bearer + // identity, so TLS must permit clients without certificates. + // When present, CLI certificates are still verified and may be + // promoted to users by the independently configured mTLS policy. + require_client_auth: false, client_ca_path: args.tls_client_ca.clone(), external_cert_path: ext_cert, external_key_path: ext_key, @@ -830,12 +821,9 @@ fn run_effective_config_preflight( .map(|driver| compute_drivers.select(Some(driver))) .transpose() .map_err(|error| miette::miette!("{error}"))?; - let selected_registration = selection - .as_ref() - .and_then(|selection| compute_drivers.get(selection.name())); let empty_file = ConfigFile::default(); let semantic_file = file.as_ref().unwrap_or(&empty_file); - validate_preflight_semantics(&run, matches, semantic_file, selected_registration)?; + validate_preflight_semantics(&run, matches, semantic_file)?; let mut endpoint_overrides = BTreeMap::new(); if let Some(selection) = selection.as_ref() @@ -898,7 +886,6 @@ fn validate_preflight_semantics( args: &RunArgs, matches: &ArgMatches, file: &ConfigFile, - selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> Result<()> { let gateway = &file.openshell.gateway; validate_grpc_rate_limit_args( @@ -909,8 +896,7 @@ fn validate_preflight_semantics( .map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?; let has_client_ca = args.tls_client_ca.is_some(); - let mtls_auth_enabled = - resolve_mtls_auth_enabled(args, matches, Some(file), selected_registration); + let mtls_auth_enabled = resolve_mtls_auth_enabled(args, matches, Some(file)); if args.disable_tls && has_client_ca { return Err(miette::miette!( "--disable-tls and --tls-client-ca are mutually exclusive" @@ -924,13 +910,6 @@ fn validate_preflight_semantics( "mTLS user authentication requires --tls-client-ca" )); } - if mtls_auth_enabled - && selected_registration.is_some_and(|registration| !registration.supports_mtls_user_auth()) - { - return Err(miette::miette!( - "mTLS user authentication is not supported with the selected compute driver" - )); - } if !args.disable_tls && args.tls_cert.is_some() != args.tls_key.is_some() { return Err(miette::miette!( "gateway TLS requires both --tls-cert and --tls-key" @@ -1240,15 +1219,10 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs) -> Result<()> { Ok(()) } -fn is_singleplayer_driver(registration: Option<&crate::ComputeDriverRegistration>) -> bool { - registration.is_some_and(crate::ComputeDriverRegistration::is_local_singleplayer) -} - fn resolve_mtls_auth_enabled( args: &RunArgs, matches: &ArgMatches, file: Option<&ConfigFile>, - selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> bool { let file_configured = file .and_then(|f| f.openshell.gateway.mtls_auth.as_ref()) @@ -1261,7 +1235,7 @@ fn resolve_mtls_auth_enabled( return false; } - is_singleplayer_driver(selected_registration) + true } #[cfg(test)] @@ -1345,15 +1319,12 @@ mod tests { } } - fn test_registry(name: &str, singleplayer: bool, mtls: bool) -> crate::ComputeDriverRegistry { + fn test_registry(name: &str, singleplayer: bool) -> crate::ComputeDriverRegistry { let mut registration = crate::ComputeDriverRegistration::new(name, 100, None, TestFactory).unwrap(); if singleplayer { registration = registration.with_local_singleplayer(); } - if !mtls { - registration = registration.without_mtls_user_auth(); - } let mut registry = crate::ComputeDriverRegistry::new(); registry.install(registration).unwrap(); registry @@ -1672,7 +1643,7 @@ mod tests { "sqlite::memory:", "--disable-tls", ]); - let registry = test_registry("podman", true, true); + let registry = test_registry("podman", true); let prepared = super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); @@ -1958,7 +1929,7 @@ mod tests { let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman,docker"); let (run, matches) = parse_with_args(&["openshell-gateway"]); - let registry = test_registry("podman", true, true); + let registry = test_registry("podman", true); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), @@ -2147,7 +2118,7 @@ mod tests { } #[test] - fn config_preflight_applies_selected_driver_mtls_capability() { + fn config_preflight_allows_driver_independent_mtls() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2169,16 +2140,15 @@ mod tests { "--enable-mtls-auth", "true", ]); - let registry = test_registry("shared", false, false); + let registry = test_registry("shared", false); - let error = super::run_config_preflight_with_drivers( + super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), run, &matches, ®istry, ) - .expect_err("selected shared driver must reject mTLS user authentication"); - assert!(error.to_string().contains("not supported")); + .expect("gateway mTLS authentication is independent of the selected driver"); } #[test] @@ -2312,7 +2282,7 @@ mod tests { ), ( "guest-tls", - "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/tls/ca.pem'\n", + "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/tls/ca.pem'\ndisable_tls = true\n", ), ( "external-tls", @@ -2407,7 +2377,7 @@ mod tests { let path = dir.path().join("gateway.toml"); std::fs::write( &path, - "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/future/ca.pem'\nguest_tls_cert = '/future/client.pem'\nguest_tls_key = '/future/client-key.pem'\n", + "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/future/ca.pem'\n", ) .unwrap(); let (run, matches) = parse_with_args(&["openshell-gateway"]); @@ -2619,7 +2589,7 @@ mod tests { } #[test] - fn tls_client_certificate_requirement_is_derived_from_ca_and_oidc() { + fn tls_accepts_bearer_clients_with_and_without_oidc() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2627,9 +2597,9 @@ mod tests { let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config_path = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let registry = test_registry("shared", false, false); + let registry = test_registry("shared", false); - for (oidc_issuer, expected) in [(None, true), (Some("https://idp.example.com"), false)] { + for (oidc_issuer, expected) in [(None, false), (Some("https://idp.example.com"), false)] { let mut startup_args = vec![ "openshell-gateway", "--db-url", @@ -2659,7 +2629,7 @@ mod tests { } #[test] - fn mtls_auth_auto_defaults_for_local_tls_driver() { + fn mtls_auth_auto_defaults_when_client_ca_is_configured() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2679,12 +2649,7 @@ mod tests { "/tmp/ca.crt", ]); - assert!(super::resolve_mtls_auth_enabled( - &args, - &matches, - None, - test_registry("local", true, true).get("local") - )); + assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); } #[test] @@ -2719,11 +2684,20 @@ mod tests { assert_eq!(prepared.compute_driver.name(), "local"); assert!(prepared.config.compute_driver.is_none()); assert!(prepared.config.mtls_auth.enabled); + assert!( + !prepared + .config + .tls + .as_ref() + .expect("TLS config") + .require_client_auth, + "sandbox bearer clients must be allowed through the TLS handshake" + ); assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); } #[test] - fn mtls_auth_does_not_auto_default_for_shared_driver() { + fn mtls_auth_default_is_driver_independent() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2743,12 +2717,7 @@ mod tests { "/tmp/ca.crt", ]); - assert!(!super::resolve_mtls_auth_enabled( - &args, - &matches, - None, - test_registry("shared", false, false).get("shared") - )); + assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); } #[test] @@ -2783,8 +2752,7 @@ enabled = false assert!(!super::resolve_mtls_auth_enabled( &args, &matches, - Some(&file), - test_registry("local", true, true).get("local") + Some(&file) )); } @@ -3089,15 +3057,6 @@ ssh_session_ttl_secs = 1234 assert_eq!(file.openshell.gateway.ssh_session_ttl_secs, Some(1234)); } - #[test] - fn singleplayer_behavior_comes_from_registration() { - let local = test_registry("local", true, true); - assert!(super::is_singleplayer_driver(local.get("local"))); - - let shared = test_registry("shared", false, true); - assert!(!super::is_singleplayer_driver(shared.get("shared"))); - } - #[test] fn compute_driver_socket_flag_uses_explicit_driver_name() { let _lock = ENV_LOCK diff --git a/crates/openshell-server/src/compute/driver_config.rs b/crates/openshell-server/src/compute/driver_config.rs index 6a2dfe44c2..20b52d9e34 100644 --- a/crates/openshell-server/src/compute/driver_config.rs +++ b/crates/openshell-server/src/compute/driver_config.rs @@ -17,56 +17,28 @@ use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GuestTlsPaths { ca: PathBuf, - cert: PathBuf, - key: PathBuf, } impl GuestTlsPaths { - pub(crate) fn as_paths(&self) -> (&std::path::Path, &std::path::Path, &std::path::Path) { - (&self.ca, &self.cert, &self.key) + pub(crate) fn as_path(&self) -> &std::path::Path { + &self.ca } -} -impl GuestTlsPaths { - fn configured_paths( - gateway: &config_file::GatewayFileSection, - ) -> (Option<&PathBuf>, Option<&PathBuf>, Option<&PathBuf>) { - ( - gateway.guest_tls_ca.as_ref(), - gateway.guest_tls_cert.as_ref(), - gateway.guest_tls_key.as_ref(), - ) - } - - /// Validate guest TLS relationships without reading certificate files. + /// Validate gateway CA configuration without reading certificate files. pub(crate) fn validate_configuration( gateway: Option<&config_file::GatewayFileSection>, tls_disabled: bool, ) -> std::result::Result<(), String> { - let configured = gateway.map(Self::configured_paths); - let provided = configured - .is_some_and(|(ca, cert, key)| ca.is_some() || cert.is_some() || key.is_some()); - if tls_disabled && provided { - return Err( - "guest_tls_ca, guest_tls_cert, and guest_tls_key require gateway TLS; remove them or omit --disable-tls" - .to_string(), - ); - } - if let Some((ca, cert, key)) = configured - && (ca.is_some() || cert.is_some() || key.is_some()) - && (ca.is_none() || cert.is_none() || key.is_none()) - { + if tls_disabled && gateway.is_some_and(|gateway| gateway.guest_tls_ca.is_some()) { return Err( - "guest TLS requires one complete bundle: guest_tls_ca, guest_tls_cert, and guest_tls_key" - .to_string(), + "guest_tls_ca requires gateway TLS; remove it or omit --disable-tls".to_string(), ); } Ok(()) } - /// Resolve gateway-owned guest TLS inputs. Explicit TOML values take - /// precedence over the package-managed local bundle; partial bundles are - /// rejected before any driver is deserialized or constructed. + /// Explicit gateway CA configuration takes precedence over the + /// package-managed local CA. User client credentials stay on the host. pub(crate) fn resolve( gateway: Option<&config_file::GatewayFileSection>, local: Option<&LocalTlsPaths>, @@ -76,31 +48,17 @@ impl GuestTlsPaths { if tls_disabled { return Ok(None); } - - if let Some((Some(ca), Some(cert), Some(key))) = gateway.map(Self::configured_paths) { - for (field, path) in [ - ("guest_tls_ca", ca), - ("guest_tls_cert", cert), - ("guest_tls_key", key), - ] { - if !path.is_file() { - return Err(format!( - "{field} '{}' does not exist or is not a file", - path.display() - )); - } + if let Some(ca) = gateway.and_then(|gateway| gateway.guest_tls_ca.as_ref()) { + if !ca.is_file() { + return Err(format!( + "guest_tls_ca '{}' does not exist or is not a file", + ca.display() + )); } - return Ok(Some(Self { - ca: ca.clone(), - cert: cert.clone(), - key: key.clone(), - })); + return Ok(Some(Self { ca: ca.clone() })); } - Ok(local.map(|paths| Self { ca: paths.ca.clone(), - cert: paths.client_cert.clone(), - key: paths.client_key.clone(), })) } } @@ -198,18 +156,23 @@ where }) } -/// Reject TLS paths in gateway driver tables. These credentials are gateway -/// inputs and are injected only into the selected local driver after the -/// gateway has validated the complete bundle. +/// Reject TLS paths in gateway driver tables. The gateway CA is injected +/// into the selected local driver after gateway validation. fn reject_driver_owned_guest_tls_fields(table: &toml::Value) -> Result<()> { let Some(table) = table.as_table() else { return Ok(()); }; for field in ["guest_tls_ca", "guest_tls_cert", "guest_tls_key"] { if table.contains_key(field) { - return Err(Error::config(format!( - "{field} belongs in [openshell.gateway], not a [openshell.drivers.*] table" - ))); + let message = if field == "guest_tls_ca" { + "guest_tls_ca belongs in [openshell.gateway], not a [openshell.drivers.*] table" + .to_string() + } else { + format!( + "{field} is no longer supported; remove it because supervisors authenticate with bearer tokens" + ) + }; + return Err(Error::config(message)); } } Ok(()) @@ -291,18 +254,12 @@ mod tests { } #[test] - fn gateway_guest_tls_resolves_explicit_complete_bundle() { + fn gateway_guest_tls_resolves_explicit_ca() { let dir = tempfile::tempdir().expect("temp dir"); let ca = dir.path().join("ca.pem"); - let cert = dir.path().join("cert.pem"); - let key = dir.path().join("key.pem"); - for path in [&ca, &cert, &key] { - std::fs::write(path, b"test").expect("write TLS fixture"); - } + std::fs::write(&ca, b"test").expect("write TLS fixture"); let gateway = config_file::GatewayFileSection { guest_tls_ca: Some(ca.clone()), - guest_tls_cert: Some(cert.clone()), - guest_tls_key: Some(key.clone()), ..Default::default() }; @@ -310,33 +267,7 @@ mod tests { .expect("complete guest TLS should resolve") .expect("guest TLS bundle"); - assert_eq!( - resolved.as_paths(), - (ca.as_path(), cert.as_path(), key.as_path()) - ); - } - - #[test] - fn gateway_guest_tls_rejects_every_partial_bundle() { - let path = PathBuf::from("/tmp/guest-tls.pem"); - for (ca, cert, key) in [ - (Some(path.clone()), None, None), - (None, Some(path.clone()), None), - (None, None, Some(path.clone())), - (Some(path.clone()), Some(path.clone()), None), - (Some(path.clone()), None, Some(path.clone())), - (None, Some(path.clone()), Some(path)), - ] { - let gateway = config_file::GatewayFileSection { - guest_tls_ca: ca, - guest_tls_cert: cert, - guest_tls_key: key, - ..Default::default() - }; - let error = GuestTlsPaths::resolve(Some(&gateway), None, false) - .expect_err("partial guest TLS must fail"); - assert!(error.contains("one complete bundle")); - } + assert_eq!(resolved.as_path(), ca.as_path()); } #[test] @@ -344,8 +275,6 @@ mod tests { let dir = tempfile::tempdir().expect("temp dir"); let gateway = config_file::GatewayFileSection { guest_tls_ca: Some(dir.path().join("missing-ca.pem")), - guest_tls_cert: Some(dir.path().join("missing-cert.pem")), - guest_tls_key: Some(dir.path().join("missing-key.pem")), ..Default::default() }; let error = GuestTlsPaths::resolve(Some(&gateway), None, false) @@ -366,14 +295,7 @@ mod tests { let resolved = GuestTlsPaths::resolve(None, Some(&local), false) .expect("managed bundle should resolve") .expect("guest TLS bundle"); - assert_eq!( - resolved.as_paths(), - ( - Path::new("/managed/ca.pem"), - Path::new("/managed/client-cert.pem"), - Path::new("/managed/client-key.pem"), - ) - ); + assert_eq!(resolved.as_path(), Path::new("/managed/ca.pem")); } #[test] @@ -386,13 +308,11 @@ mod tests { fn gateway_guest_tls_rejects_plaintext_gateway() { let gateway = config_file::GatewayFileSection { guest_tls_ca: Some(PathBuf::from("/tmp/ca.pem")), - guest_tls_cert: Some(PathBuf::from("/tmp/cert.pem")), - guest_tls_key: Some(PathBuf::from("/tmp/key.pem")), ..Default::default() }; let error = GuestTlsPaths::resolve(Some(&gateway), None, true) .expect_err("guest TLS and plaintext gateway conflict"); - assert!(error.contains("require gateway TLS")); + assert!(error.contains("requires gateway TLS")); } #[derive(Debug, Default, Deserialize)] @@ -417,30 +337,29 @@ socket_path = "/run/openshell/kyma.sock" driver_config_from_context::(test_context(Some(&file)), "kyma") .expect_err("local driver TLS field must be rejected"); assert!(local_error.to_string().contains(field)); - assert!(local_error.to_string().contains("[openshell.gateway]")); + let guidance = if field == "guest_tls_ca" { + "[openshell.gateway]" + } else { + "no longer supported; remove it" + }; + assert!(local_error.to_string().contains(guidance)); let remote_error = remote_driver_config_from_context(test_context(Some(&file)), "kyma") .expect_err("remote driver TLS field must be rejected"); assert!(remote_error.to_string().contains(field)); - assert!(remote_error.to_string().contains("[openshell.gateway]")); + assert!(remote_error.to_string().contains(guidance)); } } #[test] fn explicit_gateway_guest_tls_takes_precedence_over_package_bundle() { let dir = tempfile::tempdir().expect("temp dir"); - let explicit = [ - dir.path().join("explicit-ca.pem"), - dir.path().join("explicit-cert.pem"), - dir.path().join("explicit-key.pem"), - ]; + let explicit = [dir.path().join("explicit-ca.pem")]; for path in &explicit { std::fs::write(path, b"explicit").expect("write explicit TLS fixture"); } let gateway = config_file::GatewayFileSection { guest_tls_ca: Some(explicit[0].clone()), - guest_tls_cert: Some(explicit[1].clone()), - guest_tls_key: Some(explicit[2].clone()), ..Default::default() }; let package = LocalTlsPaths { @@ -454,24 +373,13 @@ socket_path = "/run/openshell/kyma.sock" let resolved = GuestTlsPaths::resolve(Some(&gateway), Some(&package), false) .expect("explicit bundle resolves") .expect("guest bundle"); - assert_eq!( - resolved.as_paths(), - ( - explicit[0].as_path(), - explicit[1].as_path(), - explicit[2].as_path() - ) - ); + assert_eq!(resolved.as_path(), explicit[0].as_path()); } #[test] - fn gateway_guest_tls_rejects_directories_for_every_bundle_member() { + fn gateway_guest_tls_rejects_ca_directory() { let dir = tempfile::tempdir().expect("temp dir"); - let files = [ - dir.path().join("ca.pem"), - dir.path().join("cert.pem"), - dir.path().join("key.pem"), - ]; + let files = [dir.path().join("ca.pem")]; for path in &files { std::fs::write(path, b"fixture").expect("write TLS fixture"); } @@ -481,8 +389,6 @@ socket_path = "/run/openshell/kyma.sock" paths[index] = dir.path().to_path_buf(); let gateway = config_file::GatewayFileSection { guest_tls_ca: Some(paths[0].clone()), - guest_tls_cert: Some(paths[1].clone()), - guest_tls_key: Some(paths[2].clone()), ..Default::default() }; let error = GuestTlsPaths::resolve(Some(&gateway), None, false) diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index de3f3df6bd..e442607d9d 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -139,10 +139,6 @@ pub struct GatewayFileSection { // ── Sandbox client TLS ─────────────────────────────────────────────── #[serde(default)] pub guest_tls_ca: Option, - #[serde(default)] - pub guest_tls_cert: Option, - #[serde(default)] - pub guest_tls_key: Option, // ── TLS toggle ─────────────────────────────────────────────────────── /// When `true`, the gateway listens on plaintext HTTP and ignores any diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index d6d35f6ba3..72efa01fbf 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1279,8 +1279,6 @@ pub struct ComputeDriverRegistration { detect: Option bool>, factory: Arc, telemetry_category: TelemetryComputeDriver, - local_singleplayer: bool, - supports_mtls_user_auth: bool, in_process_tracing: Option, } @@ -1311,8 +1309,6 @@ impl ComputeDriverRegistration { detect, factory: Arc::new(factory), telemetry_category: TelemetryComputeDriver::custom(), - local_singleplayer: false, - supports_mtls_user_auth: true, in_process_tracing: None, }) } @@ -1338,17 +1334,10 @@ impl ComputeDriverRegistration { self } - /// Mark a backend whose local deployment should use single-player defaults. + /// Compatibility no-op retained for existing factory registrations. + /// Gateway mTLS user authentication is independent of compute drivers. #[must_use] - pub fn with_local_singleplayer(mut self) -> Self { - self.local_singleplayer = true; - self - } - - /// Mark a backend that requires user authentication other than mTLS. - #[must_use] - pub fn without_mtls_user_auth(mut self) -> Self { - self.supports_mtls_user_auth = false; + pub fn with_local_singleplayer(self) -> Self { self } @@ -1362,16 +1351,6 @@ impl ComputeDriverRegistration { self } - #[must_use] - pub(crate) fn is_local_singleplayer(&self) -> bool { - self.local_singleplayer - } - - #[must_use] - pub(crate) fn supports_mtls_user_auth(&self) -> bool { - self.supports_mtls_user_auth - } - #[must_use] pub fn in_process_tracing(&self) -> Option { self.in_process_tracing @@ -1604,13 +1583,13 @@ impl ComputeDriverBuildContext<'_> { self.config.gateway_tls_enabled() } - /// Gateway client credentials that a local driver may mount into guests. + /// Gateway CA certificate that a local driver may provide to supervisors. #[must_use] - pub fn guest_tls_paths(&self) -> Option<(&Path, &Path, &Path)> { + pub fn guest_tls_ca(&self) -> Option<&Path> { self.config .driver_startup .guest_tls - .map(compute::driver_config::GuestTlsPaths::as_paths) + .map(compute::driver_config::GuestTlsPaths::as_path) } /// Deserialize the selected driver's merged TOML table. diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 7f175a89ad..972df8b965 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -692,7 +692,6 @@ fn gateway_principal_fields(principal: &Principal) -> BTreeMap { "source".to_string(), match &sandbox.source { SandboxIdentitySource::BootstrapJwt { .. } => "bootstrap_jwt", - SandboxIdentitySource::BootstrapCert { .. } => "bootstrap_cert", SandboxIdentitySource::ComputeDriver { .. } => "compute_driver", } .to_string(), @@ -892,8 +891,9 @@ where /// Once sandbox authentication is configured, callers must present an /// explicit credential for authenticated gRPC methods. Missing bearer auth /// is promoted to an mTLS user only when `mtls_auth.enabled` is configured -/// for local single-user gateways, or to an unsafe local developer user when -/// `auth.allow_unauthenticated_users` is explicitly enabled. +/// and the connection presents a verified client certificate, or to an unsafe +/// local developer user when `auth.allow_unauthenticated_users` is explicitly +/// enabled. /// /// When neither OIDC nor sandbox credentials are configured (a barebones /// dev gateway), the chain is left as `None` so the router short-circuits diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index 2030bbac70..843430a63a 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -66,8 +66,8 @@ systemctl --user edit openshell-gateway ## TLS (mTLS) -The RPM enables mutual TLS by default. The gateway requires a valid -client certificate for all API connections. Its primary listener uses +The RPM enables mTLS user authentication by default. CLI clients present a valid +client certificate; supervisors use the gateway CA and sandbox-scoped bearer tokens. Its primary listener uses `127.0.0.1:17670`; Podman supervisor sessions use that same listener. ### Auto-generated certificates @@ -176,25 +176,20 @@ To disable TLS (not recommended for production): ## Sandbox TLS -When mTLS is enabled, the Podman driver bind-mounts the client -certificates into each sandbox container so the supervisor process can -establish an mTLS connection back to the gateway. +When TLS is enabled, the Podman driver bind-mounts the gateway CA into each +supervisor container to authenticate the gateway. Supervisors authenticate their +RPCs with sandbox-scoped bearer tokens. The user client certificate and private +key are not mounted into supervisor or workload containers. -The following TOML fields control the host-side paths of the client -certificates that are mounted into sandbox containers: +The following TOML field controls the host-side CA path: ```toml [openshell.gateway] guest_tls_ca = "/home/user/.local/state/openshell/tls/ca.crt" -guest_tls_cert = "/home/user/.local/state/openshell/tls/client/tls.crt" -guest_tls_key = "/home/user/.local/state/openshell/tls/client/tls.key" ``` -Inside the container, the supervisor reads them from: - -- `/etc/openshell/tls/client/ca.crt` -- `/etc/openshell/tls/client/tls.crt` -- `/etc/openshell/tls/client/tls.key` +Inside the supervisor container, the CA is mounted at +`/etc/openshell/tls/client/ca.crt`. On SELinux-enabled systems, the Podman driver automatically applies the `:z` relabel option to these bind mounts. No manual SELinux @@ -223,7 +218,7 @@ overrides that persist across package upgrades. | `[openshell.drivers.podman].default_image` | `nvcr.io/nvidia/base/ubuntu:24.04` | Default sandbox image. | | `[openshell.drivers.podman].sandbox_runtime_image` | `ghcr.io/nvidia/openshell/sandbox:latest` | Static musl sandbox runtime image mounted into Podman workloads. | | `[openshell.drivers.podman].supervisor_image` | `ghcr.io/nvidia/openshell/supervisor:latest` | Dynamic glibc supervisor image used outside the workload. | -| `[openshell.gateway].guest_tls_ca`, `guest_tls_cert`, `guest_tls_key` | auto-generated paths | Gateway-owned client TLS material injected into the selected local driver and mounted into sandbox containers. | +| `[openshell.gateway].guest_tls_ca` | auto-generated path | Gateway CA injected into the selected local driver for supervisor-to-gateway TLS. Sandbox identity uses a bearer token. | | `[openshell.gateway.tls]` paths | auto-generated paths | Server TLS certificate, key, and client CA. | | `disable_tls` | unset | Set to `true` to disable TLS. | diff --git a/deploy/rpm/QUICKSTART.md b/deploy/rpm/QUICKSTART.md index 107f0a4421..fe0851ebcb 100644 --- a/deploy/rpm/QUICKSTART.md +++ b/deploy/rpm/QUICKSTART.md @@ -67,9 +67,8 @@ On first start, the gateway automatically generates: > **Note:** The primary gateway listener uses the loopback default, > `127.0.0.1:17670`. Host-networked Podman supervisors use this same listener. -> Mutual TLS (mTLS) is -> enabled automatically on first start, requiring a valid client certificate -> for every connection. See CONFIGURATION.md for details. +> mTLS user authentication is enabled automatically on first start. +> Supervisor connections use the gateway CA and sandbox-scoped bearer tokens. See CONFIGURATION.md for details. Verify the service is running: diff --git a/deploy/rpm/TROUBLESHOOTING.md b/deploy/rpm/TROUBLESHOOTING.md index a975ab1e92..acb878f543 100644 --- a/deploy/rpm/TROUBLESHOOTING.md +++ b/deploy/rpm/TROUBLESHOOTING.md @@ -244,14 +244,14 @@ podman pull nvcr.io/nvidia/base/ubuntu:24.04 ### Migrating a TLS-enabled local driver to schema version 2 -Docker, Podman, and VM sandboxes connect back to the gateway with a guest TLS -bundle. Package-managed installs use the complete bundle generated under -`~/.local/state/openshell/tls`, so the RPM default requires no additional TOML. -If you override the listener with custom `--tls-cert` and `--tls-key` inputs and -do not use that managed bundle, configure all three `guest_tls_ca`, -`guest_tls_cert`, and `guest_tls_key` paths under `[openshell.gateway]`. The -gateway now fails at startup instead of allowing sandboxes to fail later. Omit -all three fields when TLS is disabled. +Docker, Podman, and VM supervisors authenticate the gateway with its CA and +authenticate RPCs with sandbox bearer tokens. Package-managed installs use the +CA generated under `~/.local/state/openshell/tls`, so the RPM default requires +no additional TOML. If you override the listener with custom `--tls-cert` and +`--tls-key` inputs and do not use that managed CA, configure `guest_tls_ca` under +`[openshell.gateway]`. Remove the retired `guest_tls_cert` and `guest_tls_key` +fields. The gateway fails at startup if the required CA is missing. Omit +`guest_tls_ca` when TLS is disabled. ### Migrating from gateway.env diff --git a/docs/about/architecture.mdx b/docs/about/architecture.mdx index 4332a1fc5f..5862988396 100644 --- a/docs/about/architecture.mdx +++ b/docs/about/architecture.mdx @@ -126,6 +126,10 @@ that signs credentials, and every credential names exactly one sandbox. | **Supervisor** to **sandbox** | The **supervisor** dials into the workload over the driver's private channel. | Mutual TLS, plus a sandbox JWT. | | Agent to **supervisor** | The agent never connects directly. The **sandbox** relays its traffic over the connection above. | Covered by the supervisor-to-sandbox channel. | +The **supervisor** verifies the gateway's TLS certificate with its CA. User +client certificates and private keys stay outside both the **supervisor** and +workload; the gateway JWT authenticates supervisor RPCs. + ### Getting the first credential The **supervisor** needs a starting credential to prove which sandbox it belongs diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index 3dbf6e524f..957b663fc1 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -33,9 +33,9 @@ The CLI uses one of these authentication modes depending on the gateway's config ### mTLS -The default mode for local Docker, Podman, and VM gateways without OIDC. The CLI presents a client certificate during the TLS handshake, and the gateway can map the verified certificate subject to a local user principal when mTLS user authentication is enabled. +The default mode for gateways with a client CA and no OIDC issuer. The CLI presents a client certificate during the TLS handshake, and the gateway can map the verified certificate subject to a local user principal when mTLS user authentication is enabled. -mTLS user authentication is for local single-user gateways. Kubernetes deployments must use OIDC or a trusted access proxy for user authentication; the Helm chart does not render `mtls_auth`. +mTLS user authentication is gateway policy and is independent of the compute driver. Shared deployments can prefer OIDC or a trusted access proxy for user identity and lifecycle management. Set these environment variables before starting the gateway: @@ -44,13 +44,13 @@ Set these environment variables before starting the gateway: | `OPENSHELL_TLS_CERT` | Path to the gateway server certificate. | | `OPENSHELL_TLS_KEY` | Path to the gateway server private key. | | `OPENSHELL_TLS_CLIENT_CA` | Path to the CA certificate that verifies CLI client certificates. | -| `OPENSHELL_ENABLE_MTLS_AUTH` | Set to `true` to authenticate CLI callers from verified client certificates. Defaults on for local Docker, Podman, and VM gateways with no OIDC issuer. | +| `OPENSHELL_ENABLE_MTLS_AUTH` | Set to `true` to authenticate CLI callers from verified client certificates. Defaults on when a client CA is configured and no OIDC issuer is present. | For local access, the server certificate must be valid for the endpoint the CLI uses. Include `localhost`, `127.0.0.1`, and `::1` in the certificate SANs when users connect to a local gateway through loopback. Package-managed local gateways generate this bundle automatically for the `openshell` gateway name. Homebrew registers `https://localhost:17670`; Debian and RPM use `https://127.0.0.1:17670`. When you register a package-managed local gateway with `openshell gateway add --local --name openshell`, the CLI refreshes its mTLS bundle from the package-managed TLS directory. -On Homebrew, the gateway service also mirrors the Docker sandbox client bundle into `$HOME/.local/state/openshell/homebrew/tls` before startup so Docker Desktop can bind-mount the files into sandbox containers. +On Homebrew, the gateway service also mirrors the gateway CA into `$HOME/.local/state/openshell/homebrew/tls` before startup so Docker Desktop can bind-mount gateway trust into supervisor containers. The driver does not mount the user client certificate or private key. The CLI loads its mTLS bundle from `~/.config/openshell/gateways//mtls/`: @@ -73,7 +73,7 @@ The connection flow: Gateways can validate OpenID Connect access tokens on gRPC requests. Configure OIDC when you want users, operators, or automation to authenticate with an identity provider such as Keycloak, Entra ID, or Okta. -OIDC is application-layer authentication. TLS still controls the transport. If TLS client certificates remain required, the CLI must also have an mTLS bundle for the gateway. +OIDC is application-layer authentication. TLS authenticates the gateway and protects the transport; OIDC clients do not need a TLS client certificate. The gateway validates any client certificate they present against the configured client CA. Configure the gateway with an issuer and audience: @@ -223,7 +223,7 @@ Common identity providers such as Keycloak (RS256), Microsoft Entra ID (RSA), an If `OPENSHELL_OIDC_SCOPES_CLAIM` is set, the gateway also enforces scopes. It accepts space-delimited scope strings such as `scope: "openid sandbox:read"` and JSON arrays such as `scp: ["sandbox:read"]`. Standard OIDC scopes such as `openid`, `profile`, `email`, and `offline_access` are ignored for authorization. `openshell:all` grants access to all scoped methods. -Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the Kubernetes compute driver validates the projected ServiceAccount token with TokenReview, verifies the live pod UID and controlling `Sandbox` ownerReference, and returns the authenticated sandbox ID plus a stable runtime identity. The gateway requires that runtime identity to match the value recorded when it provisioned the sandbox before minting a JWT. Log upload, policy status, provider environment lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. Provider environment responses expose only the credentials and configuration attached to that sandbox, subject to endpoint binding and credential expiry checks. +Supervisor-to-gateway RPCs do not use user OIDC tokens or mTLS user identity. TLS authenticates the gateway using the configured CA; user client certificates and private keys are not mounted into supervisor or workload containers. Each sandbox supervisor presents a gateway-minted `Authorization: Bearer` token scoped to its sandbox ID. On Kubernetes, the Kubernetes compute driver validates the projected ServiceAccount token with TokenReview, verifies the live pod UID and controlling `Sandbox` ownerReference, and returns the authenticated sandbox ID plus a stable runtime identity. The gateway requires that runtime identity to match the value recorded when it provisioned the sandbox before minting a JWT. Log upload, policy status, provider environment lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users authenticate with OIDC, edge auth, local mTLS user authentication, or an explicitly enabled unauthenticated local developer mode. Provider environment responses expose only the credentials and configuration attached to that sandbox, subject to endpoint binding and credential expiry checks. Re-authenticate an OIDC gateway with: diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 3b3c3aaaf0..3b70608661 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -81,11 +81,11 @@ future version. To migrate an existing file: removed `--driver` and `--drivers` flags remain unsupported. 3. Move every compute-driver option into `[openshell.drivers.]`. Schema version 2 does not inherit driver defaults from `[openshell.gateway]`. - Keep only `guest_tls_ca`, `guest_tls_cert`, and `guest_tls_key` at gateway - scope. A TLS-enabled Docker, Podman, or VM gateway requires one complete - guest bundle. Set all three paths unless the package-managed local TLS - bundle supplies them. When TLS is disabled, omit all three. Kubernetes - projects sandbox TLS through `client_tls_secret_name` instead. + Keep `guest_tls_ca` at gateway scope and remove `guest_tls_cert` and + `guest_tls_key`. A TLS-enabled Docker, Podman, or VM gateway requires the + gateway CA unless package-managed local TLS supplies it. When TLS is + disabled, omit it. Kubernetes projects the gateway CA into supervisor Pods + through `client_tls_secret_name` instead. 4. Rename Docker `sandbox_namespace` to `sandbox_label`, Podman `sandbox_ssh_socket_path` to `ssh_socket_path`, and VM `openshell_endpoint` to `grpc_endpoint`. @@ -155,14 +155,11 @@ enable_websocket_tunnel = false # Set true only for local plaintext gateways or trusted TLS termination. disable_tls = false -# Guest TLS paths remain gateway settings. TLS-enabled Docker, Podman, and VM -# gateways require a complete bundle unless package-managed local TLS supplies -# it automatically. Omit all three when TLS is disabled. Kubernetes projects -# sandbox TLS from client_tls_secret_name instead. Driver tables must not repeat -# these fields. +# The supervisor gateway CA remains a gateway setting. TLS-enabled Docker, +# Podman, and VM gateways require it unless package-managed local TLS supplies +# it automatically. Omit it when TLS is disabled. Kubernetes projects the CA +# from client_tls_secret_name instead. Driver tables must not repeat this field. guest_tls_ca = "/etc/openshell/certs/ca.pem" -guest_tls_cert = "/etc/openshell/certs/client.pem" -guest_tls_key = "/etc/openshell/certs/client-key.pem" # Optional gRPC rate limit. Both values must be positive to enable the limit. # Set either value to 0, or omit both, to disable rate limiting. @@ -185,7 +182,7 @@ audience = "urn:openshell:middleware:local-content-guard" max_payload_bytes = 262144 timeout = "500ms" -# Gateway listener TLS (distinct from the per-driver guest_tls_*). +# Gateway listener TLS (distinct from the supervisor gateway CA). # client_ca_path is optional; omit it for HTTPS-only listeners that do not # verify client certificates. [openshell.gateway.tls] @@ -264,9 +261,9 @@ sa_token_ttl_secs = 3600 namespace = "openshell" ``` -Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth] enabled = true` to map a verified client certificate to a CLI user identity. This application-layer identity switch does not control the TLS handshake. When `client_ca_path` is set without OIDC, the listener requires a valid client certificate. When OIDC is configured, bearer-only clients may connect; the listener still validates any client certificate they present against the configured CA. Kubernetes deployments must leave `mtls_auth.enabled` unset and use OIDC or a trusted access proxy; the Helm chart does not render this table. +Set `[openshell.gateway.mtls_auth] enabled = true` to map a verified client certificate to a CLI user identity. This gateway policy is independent of the compute driver and defaults on when a client CA is configured without OIDC. The listener allows bearer-only clients and validates any client certificate presented against the configured CA. Supervisors use `guest_tls_ca` to authenticate the gateway and sandbox bearer tokens to authenticate their RPCs. -The client-certificate handshake policy is derived and has no `require_client_auth` TOML field. This preserves bearer-only OIDC clients and prevents a file setting from silently weakening CA-only gateways. +The client-certificate handshake policy has no `require_client_auth` TOML field. Client certificates are optional at the transport layer; gateway RPC authorization enforces the configured user or sandbox identity. `[openshell.gateway.tls]` supports optional SNI-based dual-certificate mode for deployments that need separate internal and external server certificates. Set `external_cert_path` and `external_key_path` to point at the external (e.g. ACME/publicly-trusted) certificate and key. List the hostnames that should be served with the external certificate in `external_server_names`. Connections whose TLS SNI hostname matches one of those names receive the external certificate; all other connections (including those with no SNI) receive the primary internal certificate from `cert_path`/`key_path`. Both fields must be set together — providing only one is a configuration error. On Kubernetes with the Helm chart, the external certificate is managed automatically when `certManager.serverIssuerRef.name` is set; the chart populates these fields from the cert-manager-issued external server certificate. @@ -703,7 +700,7 @@ Kubernetes configurations set `namespace`, `service_account_name`, and `enable_u ### Kubernetes -The gateway runs as a Pod and creates sandbox Pods in another namespace. mTLS material for sandboxes is delivered through a Kubernetes Secret rather than host-side file paths. +The gateway runs as a Pod and creates paired workload and supervisor Pods in another namespace. The gateway CA is projected from a Kubernetes Secret into supervisor Pods; user client certificate and key entries in that Secret are not exposed to either Pod. ```toml [openshell] @@ -888,7 +885,7 @@ output. ### Docker -Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required). Configure guest mTLS paths once under `[openshell.gateway]`; the gateway validates and injects the bundle into the selected local driver. +Each Docker sandbox uses a workload container with networking disabled and a host-networked supervisor container. Configure the supervisor gateway CA once under `[openshell.gateway]`; the gateway validates and injects it into the selected local driver. The supervisor authenticates RPCs with a sandbox bearer token. ```toml [openshell] @@ -898,10 +895,8 @@ version = 2 bind_address = "127.0.0.1:17670" log_level = "info" compute_driver = "docker" -# Gateway-owned bundle injected into the selected local driver. +# Gateway-owned CA injected into the selected local driver. guest_tls_ca = "/etc/openshell/certs/ca.pem" -guest_tls_cert = "/etc/openshell/certs/client.pem" -guest_tls_key = "/etc/openshell/certs/client-key.pem" [openshell.drivers.docker] socket_path = "/var/run/docker.sock" @@ -974,7 +969,7 @@ path is never mounted into or exposed to the workload container. ### Podman -Each Podman sandbox uses two containers. The workload container runs `openshell-sandbox` with `network=none`; the supervisor container runs on the host network and initiates policy-approved upstream connections. A private volume carries their authenticated Unix-domain socket. Configure guest mTLS paths once under `[openshell.gateway]`; the gateway validates and injects the bundle into the selected local driver. +Each Podman sandbox uses two containers. The workload container runs `openshell-sandbox` with `network=none`; the supervisor container runs on the host network and initiates policy-approved upstream connections. A private volume carries their authenticated Unix-domain socket. Configure the supervisor gateway CA once under `[openshell.gateway]`; the gateway validates and injects it into the selected local driver. The supervisor authenticates RPCs with a sandbox bearer token. ```toml [openshell] @@ -984,10 +979,8 @@ version = 2 bind_address = "127.0.0.1:17670" log_level = "info" compute_driver = "podman" -# Gateway-owned bundle injected into the selected local driver. +# Gateway-owned CA injected into the selected local driver. guest_tls_ca = "/etc/openshell/certs/ca.pem" -guest_tls_cert = "/etc/openshell/certs/client.pem" -guest_tls_key = "/etc/openshell/certs/client-key.pem" [openshell.drivers.podman] network_name = "openshell" @@ -1139,10 +1132,8 @@ bind_address = "127.0.0.1:17670" log_level = "info" # VM is never auto-detected; an explicit entry here is required. compute_driver = "vm" -# Gateway-owned bundle injected into the selected local driver. +# Gateway-owned CA injected into the selected local driver. guest_tls_ca = "/var/lib/openshell/guest-tls/ca.pem" -guest_tls_cert = "/var/lib/openshell/guest-tls/client.pem" -guest_tls_key = "/var/lib/openshell/guest-tls/client-key.pem" [openshell.drivers.vm] state_dir = "/var/lib/openshell/vm" diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index f52a571bd2..505f2cc69b 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -36,6 +36,13 @@ When `compute_driver` is unset, the gateway auto-detects Kubernetes, then Podman Configure driver-specific values, such as images, endpoints, and sizing, under `[openshell.drivers.]`. See the [Gateway Configuration File](/how-it-works/gateways/configuration) reference for every option. +For TLS-enabled Docker, Podman, and MicroVM gateways, set `guest_tls_ca` in +`[openshell.gateway]` or use the package-managed local CA. Remove the retired +`guest_tls_cert` and `guest_tls_key` fields. Supervisors receive only the +CA and authenticate gateway RPCs with sandbox bearer tokens. Kubernetes +projects or stages only the gateway CA from its configured TLS Secret; user +client certificates and private keys stay outside the sandbox boundary. + ### Extension Drivers Any name other than a built-in driver selects an extension driver. Point the gateway at the Unix socket where the driver listens: @@ -221,7 +228,7 @@ Only the OpenShell gateway and the Agent Sandbox controller should be able to ma | `image_pull_policy` | `sandbox.image.pullPolicy` | `always`, `if_not_present`, or `never`. | | `image_pull_secrets` | `server.sandboxImagePullSecrets` | Image-pull Secrets for sandbox pods. | | `grpc_endpoint` | `server.grpcEndpoint` | Gateway endpoint reachable from sandbox pods. | -| `client_tls_secret_name` | `server.tls.clientTlsSecretName` | Secret with sandbox client TLS material. | +| `client_tls_secret_name` | `server.tls.clientTlsSecretName` | Project or stage only `ca.crt` into supervisor Pods to authenticate the gateway. | | `sandbox_runtime_image` | `sandboxRuntime.image.*` | Override the sandbox runtime image. | | `supervisor_image` | `supervisor.image.*` | Override the supervisor image. | | `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Default workspace PVC size. | diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 5b295e3211..87e6be3897 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -8,14 +8,15 @@ keywords: "Generative AI, Cybersecurity, Kubernetes, Authentication, mTLS, OIDC, position: 6 --- -The OpenShell gateway supports two access-control models for human callers on Kubernetes: +The OpenShell gateway supports these access-control models for human callers on Kubernetes: | Model | When to use | |---|---| +| mTLS | Gateways with a configured client CA and no OIDC issuer. The gateway maps a verified client certificate to a user identity. | | OIDC (recommended) | Production deployments. Integrates with an existing identity provider, supports role-based access control, and gives each user their own identity without distributing certificates. | | Reverse-proxy auth termination | An access proxy (Cloudflare Access, ngrok, corporate SSO) authenticates callers in front of the gateway. The gateway trusts the proxy and skips its own client-cert check. | -The Helm chart always generates mTLS certificates at install time. The gateway uses them for transport-layer security regardless of which access-control model you choose. The client bundle in the `openshell-client-tls` secret is used internally by sandbox supervisors, not for granting access to individual users. +The Helm chart generates a gateway TLS certificate and a user client certificate at install time. Supervisor Pods project only `ca.crt` from the client Secret so they can authenticate the gateway; the client certificate and private key are not exposed to sandboxes. Supervisors authenticate their RPCs with gateway-minted sandbox JWTs. For how the CLI resolves gateways and stores credentials, refer to [Gateway Authentication](/how-it-works/gateways/authentication). @@ -43,7 +44,7 @@ helm upgrade openshell \ --set server.tls.clientCaSecretName="" ``` -Set `server.tls.clientCaSecretName=""` when the gateway terminates TLS directly and browsers or CLI clients connect without client certificates. The chart omits `client_ca_path` from `gateway.toml` and does not mount the client-CA volume, leaving HTTPS-only transport with OIDC for user authentication. Do not set the value to `null`; omit the key to use the chart default, or set it to `""` to disable client certificate verification. +Client certificates are optional at the TLS handshake, so OIDC callers can connect without them. Set `server.tls.clientCaSecretName=""` to disable client-certificate verification entirely. The chart omits `client_ca_path` from `gateway.toml` and does not mount the client-CA volume, leaving HTTPS-only transport with OIDC for user authentication. Do not set the value to `null`; omit the key to use the chart default, or set it to `""` to disable client certificate verification. The `audience` value must match the client ID configured in your identity provider for the OpenShell resource server. @@ -112,7 +113,7 @@ helm upgrade openshell \ The gateway still serves TLS and sandbox supervisors still authenticate with gateway-minted sandbox JWTs. User-facing CLI/API calls without OIDC or mTLS credentials are accepted as an unauthenticated local developer principal. The proxy is responsible for authenticating callers and forwarding only authorized traffic. -When the gateway terminates TLS directly and callers connect without client certificates, also set `server.tls.clientCaSecretName=""` as described in the OIDC section above. +To disable client-certificate verification entirely, set `server.tls.clientCaSecretName=""` as described in the OIDC section above. To also disable TLS entirely (when the proxy terminates TLS before the request reaches the gateway): diff --git a/docs/kubernetes/managing-certificates.mdx b/docs/kubernetes/managing-certificates.mdx index 322815cbf1..ee011df57b 100644 --- a/docs/kubernetes/managing-certificates.mdx +++ b/docs/kubernetes/managing-certificates.mdx @@ -8,7 +8,7 @@ keywords: "Generative AI, Cybersecurity, Kubernetes, cert-manager, PKI, TLS, mTL position: 4 --- -The OpenShell gateway uses mTLS certificates for transport between the gateway and sandbox supervisors. These certificates are not Kubernetes user authentication; configure OIDC or a trusted access proxy for user access. The Helm chart supports two ways to provision and manage the certificate bundle: +The OpenShell gateway uses TLS for transport to sandbox supervisors. Supervisor Pods receive the gateway CA, not a client certificate or private key, and authenticate RPCs with sandbox JWTs. The generated client certificate can authenticate user clients when gateway mTLS user authentication is enabled; shared deployments can instead configure OIDC or a trusted access proxy. The Helm chart supports two ways to provision and manage the certificate bundle: | Mode | When to use | |---|---| diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index a310900b98..2f5b346708 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -260,14 +260,14 @@ The gateway secures communication between the CLI, sandbox workloads, and extern ### mTLS -Gateway transport uses TLS, with client certificate checks available where the deployment provides a client CA. Local single-user Docker, Podman, and VM gateways can use the verified client certificate as user authentication. Kubernetes deployments use the certificate bundle for transport and sandbox supervisor connectivity only; configure OIDC or a trusted access proxy for user authentication. +Gateway transport uses TLS, with client certificate checks available where the deployment provides a client CA. mTLS user authentication is gateway policy and does not depend on the compute driver. Sandbox supervisors receive only the gateway CA and authenticate API calls with gateway-minted sandbox JWTs. | Aspect | Detail | |---|---| -| Default | Local TLS bundles enable mTLS user authentication for single-user local gateways. Helm deployments generate mTLS certificates for transport, while sandbox supervisors authenticate API calls with gateway-minted sandbox JWTs. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. | -| What you can change | Configure OIDC or a trusted access proxy for multi-user gateways, set `OPENSHELL_ENABLE_MTLS_AUTH=true` for local single-user gateways, enable `server.auth.allowUnauthenticatedUsers=true` only for trusted local Kubernetes development or a fully trusted proxy, disable TLS only for trusted reverse-proxy setups, or disable loopback service HTTP with `--enable-loopback-service-http=false`. | -| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes the gateway user-auth boundary and must not be exposed to shared or public networks. Treating transport certificates as shared user identity in Kubernetes would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | -| Recommendation | Use local mTLS user authentication only for single-user Docker, Podman, and VM gateways. Use OIDC or a trusted access proxy for Kubernetes and shared deployments. | +| Default | A configured client CA without OIDC enables mTLS user authentication. Sandbox supervisors use CA-only TLS plus gateway-minted sandbox JWTs. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. | +| What you can change | Configure mTLS user authentication, OIDC, or a trusted access proxy at the gateway; enable `server.auth.allowUnauthenticatedUsers=true` only for trusted local Kubernetes development or a fully trusted proxy; disable TLS only for trusted reverse-proxy setups; or disable loopback service HTTP with `--enable-loopback-service-http=false`. | +| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes the gateway user-auth boundary and must not be exposed to shared or public networks. Mounting a user client certificate into a sandbox would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | +| Recommendation | Keep sandbox identity separate from user identity: expose only the gateway CA to sandboxes and require sandbox JWTs. Use managed OIDC or a trusted access proxy when certificate distribution is unsuitable for shared users. | ### SSH Tunnel Authentication diff --git a/e2e/configs/gateway/schema-v2-capability-parity.toml b/e2e/configs/gateway/schema-v2-capability-parity.toml index 3927f81df1..fd64b60b68 100644 --- a/e2e/configs/gateway/schema-v2-capability-parity.toml +++ b/e2e/configs/gateway/schema-v2-capability-parity.toml @@ -94,7 +94,7 @@ id = "gateway-listener-tls-and-sni" topics = ["auth_tls_jwt", "listeners"] origin_main_access_paths = ["--tls-cert / OPENSHELL_TLS_CERT", "--tls-key / OPENSHELL_TLS_KEY", "--tls-client-ca / OPENSHELL_TLS_CLIENT_CA", "[openshell.gateway.tls]"] schema_v2_access_paths = ["[openshell.gateway.tls].{cert_path,key_path,client_ca_path,external_cert_path,external_key_path,external_server_names}", "same CLI and environment variables for primary bundle"] -behavioral_oracle = "The listener presents the primary or configured SNI certificate, derives client-certificate requirements from client CA and OIDC presence, rejects the unsupported require_client_auth file field, and rejects incomplete TLS bundles." +behavioral_oracle = "The listener presents the primary or configured SNI certificate, rejects the unsupported require_client_auth file field, and rejects incomplete server TLS bundles. The frozen baseline derives client-certificate requirements from client CA and OIDC presence; the candidate permits bearer-only connections and validates any presented client certificate against the configured CA." required_environment = "test CA, primary and external certificates, TLS client" test_lane = "e2e-docker" status = "not_run" @@ -113,9 +113,9 @@ status = "not_run" id = "guest-callback-tls-ownership" topics = ["auth_tls_jwt", "docker", "podman", "vm"] origin_main_access_paths = ["[openshell.gateway].{guest_tls_ca,guest_tls_cert,guest_tls_key} inherited by local drivers", "driver-local guest_tls_* overrides"] -schema_v2_access_paths = ["[openshell.gateway].{guest_tls_ca,guest_tls_cert,guest_tls_key} injected only into selected Docker, Podman, or VM driver", "driver tables reject guest_tls_*"] -behavioral_oracle = "A TLS-enabled selected local driver receives one complete guest bundle; partial, misplaced, and plaintext-incompatible bundles fail closed." -required_environment = "test CA and client certificate bundle" +schema_v2_access_paths = ["[openshell.gateway].guest_tls_ca injected only into selected Docker, Podman, or VM driver", "driver tables reject guest_tls_*"] +behavioral_oracle = "A TLS-enabled selected local driver receives the gateway CA and authenticates callbacks with its sandbox bearer credential; legacy client certificate fields, misplaced TLS fields, and plaintext-incompatible CA settings fail closed." +required_environment = "test CA and sandbox bearer credential; client certificate bundle for the frozen baseline" test_lane = "e2e-docker" status = "not_run" @@ -134,7 +134,7 @@ id = "mtls-user-authentication" topics = ["auth_tls_jwt"] origin_main_access_paths = ["--enable-mtls-auth / OPENSHELL_ENABLE_MTLS_AUTH", "[openshell.gateway.mtls_auth].enabled"] schema_v2_access_paths = ["[openshell.gateway.mtls_auth].enabled", "same CLI and environment variable"] -behavioral_oracle = "A verified local client certificate maps to a user principal only when mTLS user auth is enabled and no stronger auth policy replaces it." +behavioral_oracle = "A verified client certificate maps to a user principal only when mTLS user auth is enabled and no stronger auth policy replaces it. The candidate defaults mTLS user auth on when a client CA is configured without OIDC, independently of the compute driver." required_environment = "local driver, test CA, client certificate" test_lane = "e2e-docker" status = "not_run" @@ -274,7 +274,7 @@ id = "kubernetes-core-placement-and-images" topics = ["kubernetes"] origin_main_access_paths = ["[openshell.gateway].{default_image,supervisor_image,client_tls_secret_name,service_account_name,host_gateway_ip,enable_user_namespaces,sa_token_ttl_secs} inherited by Kubernetes", "[openshell.drivers.kubernetes].{namespace,default_image,image_pull_policy,image_pull_secrets,service_account_name,supervisor_image,supervisor_image_pull_policy,grpc_endpoint,ssh_socket_path,client_tls_secret_name,host_gateway_ip,enable_user_namespaces,sa_token_ttl_secs}"] schema_v2_access_paths = ["same fields exclusively in [openshell.drivers.kubernetes]"] -behavioral_oracle = "The Kubernetes driver creates a sandbox Pod with driver-owned namespace, service account, image, pull policy, callback endpoint, SSH socket, TLS Secret, and token TTL." +behavioral_oracle = "The Kubernetes driver applies driver-owned namespace, service account, image, pull policy, callback endpoint, SSH socket, TLS Secret, and token TTL settings. The candidate projects only the gateway CA from the TLS Secret into the separate supervisor Pod and authenticates gateway RPCs with a sandbox bearer credential." required_environment = "Kubernetes cluster, namespace, service account, image pull secret, and client TLS Secret" test_lane = "e2e-kubernetes" status = "not_run" @@ -313,7 +313,7 @@ status = "not_run" id = "vm-launch-and-resource-configuration" topics = ["vm"] origin_main_access_paths = ["[openshell.gateway].{default_image,guest_tls_ca,guest_tls_cert,guest_tls_key} inherited by VM", "[openshell.drivers.vm].{grpc_endpoint,state_dir,driver_dir,default_image,bootstrap_image,krun_log_level,vcpus,mem_mib,overlay_disk_mib,sandbox_uid,sandbox_gid}", "standalone openshell-driver-vm --openshell-endpoint / OPENSHELL_GRPC_ENDPOINT"] -schema_v2_access_paths = ["[openshell.drivers.vm].{grpc_endpoint,state_dir,driver_dir,default_image,bootstrap_image,krun_log_level,vcpus,mem_mib,overlay_disk_mib,sandbox_uid,sandbox_gid}", "[openshell.gateway].{guest_tls_ca,guest_tls_cert,guest_tls_key}", "standalone openshell-driver-vm --grpc-endpoint / OPENSHELL_GRPC_ENDPOINT"] +schema_v2_access_paths = ["[openshell.drivers.vm].{grpc_endpoint,state_dir,driver_dir,default_image,bootstrap_image,krun_log_level,vcpus,mem_mib,overlay_disk_mib,sandbox_uid,sandbox_gid}", "[openshell.gateway].guest_tls_ca", "standalone openshell-driver-vm --grpc-endpoint / OPENSHELL_GRPC_ENDPOINT"] behavioral_oracle = "The gateway finds and launches the VM driver from driver_dir, forwards the TOML grpc_endpoint through the schema-appropriate standalone-driver flag, and launches a guest with the selected state, images, resources, and identity." required_environment = "Linux libkrun/KVM host, VM driver binary, and OCI image" test_lane = "e2e-vm" @@ -323,7 +323,7 @@ status = "not_run" id = "vm-guest-security-and-spiffe" topics = ["vm", "credentials"] origin_main_access_paths = ["[openshell.drivers.vm].{sandbox_uid,sandbox_gid}"] -schema_v2_access_paths = ["[openshell.drivers.vm].{sandbox_uid,sandbox_gid,https_proxy,no_proxy,proxy_auth_file,proxy_auth_allow_insecure,proxy_connect_by_hostname,provider_spiffe_workload_api_tcp_endpoint,provider_spiffe_allow_guest_tcp}", "gateway-owned guest_tls_* bundle"] +schema_v2_access_paths = ["[openshell.drivers.vm].{sandbox_uid,sandbox_gid,https_proxy,no_proxy,proxy_auth_file,proxy_auth_allow_insecure,proxy_connect_by_hostname,provider_spiffe_workload_api_tcp_endpoint,provider_spiffe_allow_guest_tcp}", "gateway-owned guest_tls_ca"] behavioral_oracle = "VM validates non-root guest ownership, callback TLS, proxy safety, and requires an explicit opt-in before exposing a guest-reachable SPIFFE TCP endpoint." required_environment = "Linux libkrun/KVM host, TLS and optional proxy/SPIFFE TCP fixture" test_lane = "e2e-vm" diff --git a/e2e/configs/gateway/schema-v2-intentional-changes.toml b/e2e/configs/gateway/schema-v2-intentional-changes.toml index d0707b8a54..78e23234b7 100644 --- a/e2e/configs/gateway/schema-v2-intentional-changes.toml +++ b/e2e/configs/gateway/schema-v2-intentional-changes.toml @@ -133,9 +133,9 @@ validation_capability_ids = ["podman-runtime-security-and-health"] id = "guest-tls-centralized" category = "ownership" origin_main_contract = "Local driver tables may override guest_tls_ca, guest_tls_cert, and guest_tls_key inherited from gateway scope." -schema_v2_contract = "One complete guest TLS bundle is gateway-owned and injected into the selected Docker, Podman, or VM driver; driver-local fields are rejected." -migration = "Keep guest_tls_ca, guest_tls_cert, and guest_tls_key under [openshell.gateway] only." -rationale = "A single active local driver needs one callback client identity, and centralized validation prevents partial or conflicting bundles." +schema_v2_contract = "The gateway CA is gateway-owned and injected into the selected Docker, Podman, or VM driver for supervisor TLS; legacy client certificate fields and driver-local guest TLS fields are rejected." +migration = "Keep guest_tls_ca under [openshell.gateway] only and remove guest_tls_cert and guest_tls_key. Supervisors authenticate gateway RPCs with sandbox bearer credentials." +rationale = "Gateway TLS trust is separate from sandbox identity. Centralized CA validation supplies supervisor trust without exposing a user client certificate or private key." parity_disposition = "intentional_change" validation_capability_ids = ["guest-callback-tls-ownership"] diff --git a/e2e/parity/test.sh b/e2e/parity/test.sh index 9d7f2e3b20..4de6824f05 100755 --- a/e2e/parity/test.sh +++ b/e2e/parity/test.sh @@ -286,6 +286,9 @@ if external: }, } ) +if external and schema == 2: + del launch["external_driver_environment"]["OPENSHELL_PODMAN_TLS_CERT"] + del launch["external_driver_environment"]["OPENSHELL_PODMAN_TLS_KEY"] Path(os.environ["OPENSHELL_PARITY_LAUNCH_MANIFEST_CAPTURE"]).write_text( json.dumps(launch, separators=(",", ":")) + "\n", encoding="utf-8" ) diff --git a/e2e/parity/verify-results.py b/e2e/parity/verify-results.py index 4b9bc1e7b0..dbe1fc9754 100644 --- a/e2e/parity/verify-results.py +++ b/e2e/parity/verify-results.py @@ -398,10 +398,12 @@ def verify_variant( "OPENSHELL_SANDBOX_RUNTIME_IMAGE", "OPENSHELL_SUPERVISOR_IMAGE", "OPENSHELL_PODMAN_TLS_CA", - "OPENSHELL_PODMAN_TLS_CERT", - "OPENSHELL_PODMAN_TLS_KEY", "OPENSHELL_ENABLE_BIND_MOUNTS", } + tls_fields = {"OPENSHELL_PODMAN_TLS_CA"} + if schema_version == 1: + tls_fields.update({"OPENSHELL_PODMAN_TLS_CERT", "OPENSHELL_PODMAN_TLS_KEY"}) + expected_environment_keys.update(tls_fields) require( isinstance(driver_environment, dict) and set(driver_environment) == expected_environment_keys, @@ -441,11 +443,7 @@ def verify_variant( f"{launch_path}: external driver allowlisted runtime inputs differ", ) tls_paths: set[str] = set() - for field in ( - "OPENSHELL_PODMAN_TLS_CA", - "OPENSHELL_PODMAN_TLS_CERT", - "OPENSHELL_PODMAN_TLS_KEY", - ): + for field in tls_fields: tls_input = driver_environment[field] require( isinstance(tls_input, dict) @@ -458,7 +456,7 @@ def verify_variant( ) tls_paths.add(tls_input["path"]) require( - len(tls_paths) == 3, + len(tls_paths) == len(tls_fields), f"{launch_path}: external driver TLS paths are not distinct", ) else: @@ -659,8 +657,6 @@ def verify_topology( ) for field in ( "OPENSHELL_PODMAN_TLS_CA", - "OPENSHELL_PODMAN_TLS_CERT", - "OPENSHELL_PODMAN_TLS_KEY", ): require( baseline_env[field]["path"] != candidate_env[field]["path"], diff --git a/e2e/python/test_security_tls.py b/e2e/python/test_security_tls.py index 529404a6e4..31e0fa4a0a 100644 --- a/e2e/python/test_security_tls.py +++ b/e2e/python/test_security_tls.py @@ -1,12 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""E2e tests for server mTLS enforcement. +"""E2e tests for gateway TLS and mTLS user authentication. -These tests verify that the OpenShell server correctly requires valid client -certificates signed by the cluster CA. Only callers presenting the provisioned -mTLS client cert should be able to reach the OpenShell gRPC API; all other -connection attempts must be rejected. +TLS accepts CA-only clients so supervisors can use sandbox bearer tokens. +Health is public; user RPCs require an authenticated user. Presented client +certificates must be signed by the gateway CA. """ from __future__ import annotations @@ -21,7 +20,7 @@ import grpc import pytest -from openshell._proto import openshell_pb2, openshell_pb2_grpc +from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc # --------------------------------------------------------------------------- # Helpers @@ -137,14 +136,14 @@ def mtls_certs( class TestServerMtlsEnforcement: - """Verify the server rejects callers without a valid client certificate.""" + """Verify TLS trust and the mTLS user authorization boundary.""" def test_authenticated_client_succeeds( self, server_endpoint: tuple[str, int, str], mtls_certs: tuple[bytes, bytes, bytes], ) -> None: - """A client presenting the correct mTLS cert can call Health.""" + """A verified mTLS user can call Health and a protected user RPC.""" host, port, _ = server_endpoint ca, cert, key = mtls_certs @@ -158,15 +157,21 @@ def test_authenticated_client_succeeds( stub = openshell_pb2_grpc.OpenShellStub(channel) response = stub.Health(openshell_pb2.HealthRequest(), timeout=10) assert response.status == openshell_pb2.SERVICE_STATUS_HEALTHY + stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest( + workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default") + ), + timeout=10, + ) finally: channel.close() - def test_no_client_cert_rejected( + def test_ca_only_client_health_succeeds_but_user_rpc_rejected( self, server_endpoint: tuple[str, int, str], mtls_certs: tuple[bytes, bytes, bytes], ) -> None: - """A client that trusts the CA but presents no client cert is rejected.""" + """CA-only TLS reaches Health but cannot acquire mTLS user identity.""" host, port, _ = server_endpoint ca, _, _ = mtls_certs @@ -175,14 +180,32 @@ def test_no_client_cert_rejected( channel = grpc.secure_channel(f"{host}:{port}", credentials) try: stub = openshell_pb2_grpc.OpenShellStub(channel) + response = stub.Health(openshell_pb2.HealthRequest(), timeout=10) + assert response.status == openshell_pb2.SERVICE_STATUS_HEALTHY with pytest.raises(grpc.RpcError) as exc_info: - stub.Health(openshell_pb2.HealthRequest(), timeout=10) - # The server should terminate the TLS handshake or return - # UNAVAILABLE because the client did not present a certificate. - assert exc_info.value.code() in ( - grpc.StatusCode.UNAVAILABLE, - grpc.StatusCode.UNKNOWN, - ), f"expected UNAVAILABLE or UNKNOWN, got {exc_info.value.code()}" + stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest( + workspace_scope=datamodel_pb2.WorkspaceSelector( + workspace="default" + ) + ), + timeout=10, + ) + assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED + + # An unverified bearer token must not promote the TLS connection + # to a user identity either. + with pytest.raises(grpc.RpcError) as exc_info: + stub.ListSandboxes( + openshell_pb2.ListSandboxesRequest( + workspace_scope=datamodel_pb2.WorkspaceSelector( + workspace="default" + ) + ), + metadata=(("authorization", "Bearer invalid-token"),), + timeout=10, + ) + assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED finally: channel.close() diff --git a/e2e/rust/e2e-vm.sh b/e2e/rust/e2e-vm.sh index 6fd355170b..bd7968fadb 100755 --- a/e2e/rust/e2e-vm.sh +++ b/e2e/rust/e2e-vm.sh @@ -262,8 +262,6 @@ version = 2 bind_address = "127.0.0.1:${HOST_PORT}" compute_driver = "vm" guest_tls_ca = "${PKI_DIR}/ca.crt" -guest_tls_cert = "${PKI_DIR}/client/tls.crt" -guest_tls_key = "${PKI_DIR}/client/tls.key" [openshell.gateway.tls] cert_path = "${PKI_DIR}/server/tls.crt" @@ -301,8 +299,6 @@ if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then --default-image "${SANDBOX_IMAGE}" \ --state-dir "${RUN_STATE_DIR}" \ --guest-tls-ca "${PKI_DIR}/ca.crt" \ - --guest-tls-cert "${PKI_DIR}/client/tls.crt" \ - --guest-tls-key "${PKI_DIR}/client/tls.key" \ >"${DRIVER_LOG}" 2>&1 & DRIVER_PID=$! e2e_wait_for_socket \ diff --git a/e2e/rust/tests/service_bearer_passthrough.rs b/e2e/rust/tests/service_bearer_passthrough.rs index af2fddb76d..3da8a60eb7 100644 --- a/e2e/rust/tests/service_bearer_passthrough.rs +++ b/e2e/rust/tests/service_bearer_passthrough.rs @@ -279,7 +279,7 @@ async fn wait_for_authorization(url: &str, expected: &str) -> Result<(), String> #[tokio::test] async fn service_bearer_passthrough_preserves_authorization_header() { - let sandbox_name = format!("service-auth-{}", std::process::id()); + let sandbox_name = format!("svc-auth-{}", std::process::id()); let create = run_cli(&[ "sandbox", "create", diff --git a/e2e/support/podman-gateway-config.sh b/e2e/support/podman-gateway-config.sh index bd5a39b1f7..e09a208ed7 100755 --- a/e2e/support/podman-gateway-config.sh +++ b/e2e/support/podman-gateway-config.sh @@ -143,8 +143,6 @@ e2e_write_podman_gateway_config() { while IFS= read -r line; do if [ "${line}" = "[openshell.drivers.podman]" ]; then printf 'guest_tls_ca = %s\n' "$(e2e_podman_toml_string "${pki_dir}/ca.crt")" - printf 'guest_tls_cert = %s\n' "$(e2e_podman_toml_string "${pki_dir}/client/tls.crt")" - printf 'guest_tls_key = %s\n\n' "$(e2e_podman_toml_string "${pki_dir}/client/tls.key")" fi printf '%s\n' "${line}" done <"${output}" >"${configured_with_tls}" diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 4b96693ab1..2298c2312c 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -605,8 +605,6 @@ GATEWAY_CONFIG="${STATE_DIR}/gateway.toml" printf '[openshell]\nversion = 2\n\n' printf '[openshell.gateway]\nlog_level = "info"\n' printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" - printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" - printf 'guest_tls_key = %s\n\n' "$(toml_string "${PKI_DIR}/client/tls.key")" e2e_write_gateway_jwt_config "${JWT_DIR}" "openshell-e2e-docker-${HOST_PORT}" if [ "${OIDC_MODE}" != "1" ]; then e2e_write_gateway_mtls_auth_config @@ -638,8 +636,6 @@ if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then printf 'default_image = %s\n' "$(toml_string "${SANDBOX_IMAGE}")" printf 'image_pull_policy = %s\n' "$(toml_string "${SANDBOX_IMAGE_PULL_POLICY}")" printf 'guest_tls_ca = %s\n' "$(toml_string "${PKI_DIR}/ca.crt")" - printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" - printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" printf 'enable_bind_mounts = true\n' printf 'sandbox_runtime_image = %s\n' "$(toml_string "${SANDBOX_RUNTIME_IMAGE}")" printf 'supervisor_image = %s\n' "$(toml_string "${SUPERVISOR_IMAGE}")" diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index 7350dd07e5..20e40fce9d 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -745,6 +745,14 @@ EXTERNAL_DRIVER_ENABLE_BIND_MOUNTS=true EXTERNAL_DRIVER_TLS_CA="${PKI_DIR}/ca.crt" EXTERNAL_DRIVER_TLS_CERT="${PKI_DIR}/client/tls.crt" EXTERNAL_DRIVER_TLS_KEY="${PKI_DIR}/client/tls.key" +# The frozen schema-v1 baseline still requires a gateway client identity. +external_driver_legacy_tls_env=() +if [ "${CONFIG_SCHEMA_VERSION}" = "1" ]; then + external_driver_legacy_tls_env=( + "OPENSHELL_PODMAN_TLS_CERT=${EXTERNAL_DRIVER_TLS_CERT}" + "OPENSHELL_PODMAN_TLS_KEY=${EXTERNAL_DRIVER_TLS_KEY}" + ) +fi if [ -n "${OPENSHELL_PARITY_LAUNCH_MANIFEST_CAPTURE:-}" ]; then driver_transport=in_tree external_driver_grpc_endpoint=null @@ -759,9 +767,15 @@ if [ -n "${OPENSHELL_PARITY_LAUNCH_MANIFEST_CAPTURE:-}" ]; then external_driver_grpc_endpoint="\"${EXTERNAL_DRIVER_GRPC_ENDPOINT}\"" external_driver_host_gateway_ip='"host-gateway"' driver_tls_ca_sha256="$(sha256sum "${EXTERNAL_DRIVER_TLS_CA}" | cut -d' ' -f1)" - driver_tls_cert_sha256="$(sha256sum "${EXTERNAL_DRIVER_TLS_CERT}" | cut -d' ' -f1)" - driver_tls_key_sha256="$(sha256sum "${EXTERNAL_DRIVER_TLS_KEY}" | cut -d' ' -f1)" - external_driver_environment="$(printf '{\"XDG_DATA_HOME\":\"%s\",\"OPENSHELL_COMPUTE_DRIVER_SOCKET\":\"%s\",\"OPENSHELL_PODMAN_SOCKET\":\"%s\",\"OPENSHELL_SANDBOX_IMAGE\":\"%s\",\"OPENSHELL_SANDBOX_IMAGE_PULL_POLICY\":\"%s\",\"OPENSHELL_HEALTH_CHECK_INTERVAL_SECS\":%s,\"OPENSHELL_GRPC_ENDPOINT\":\"%s\",\"OPENSHELL_GATEWAY_PORT\":%s,\"OPENSHELL_NETWORK_NAME\":\"%s\",\"OPENSHELL_STOP_TIMEOUT\":%s,\"OPENSHELL_SANDBOX_RUNTIME_IMAGE\":\"%s\",\"OPENSHELL_SUPERVISOR_IMAGE\":\"%s\",\"OPENSHELL_PODMAN_TLS_CA\":{\"path\":\"%s\",\"sha256\":\"%s\"},\"OPENSHELL_PODMAN_TLS_CERT\":{\"path\":\"%s\",\"sha256\":\"%s\"},\"OPENSHELL_PODMAN_TLS_KEY\":{\"path\":\"%s\",\"sha256\":\"%s\"},\"OPENSHELL_ENABLE_BIND_MOUNTS\":%s}' \ + legacy_tls_manifest="" + if [ "${CONFIG_SCHEMA_VERSION}" = "1" ]; then + driver_tls_cert_sha256="$(sha256sum "${EXTERNAL_DRIVER_TLS_CERT}" | cut -d' ' -f1)" + driver_tls_key_sha256="$(sha256sum "${EXTERNAL_DRIVER_TLS_KEY}" | cut -d' ' -f1)" + legacy_tls_manifest="$(printf ',"OPENSHELL_PODMAN_TLS_CERT":{"path":"%s","sha256":"%s"},"OPENSHELL_PODMAN_TLS_KEY":{"path":"%s","sha256":"%s"}' \ + "${EXTERNAL_DRIVER_TLS_CERT}" "${driver_tls_cert_sha256}" \ + "${EXTERNAL_DRIVER_TLS_KEY}" "${driver_tls_key_sha256}")" + fi + external_driver_environment="$(printf '{\"XDG_DATA_HOME\":\"%s\",\"OPENSHELL_COMPUTE_DRIVER_SOCKET\":\"%s\",\"OPENSHELL_PODMAN_SOCKET\":\"%s\",\"OPENSHELL_SANDBOX_IMAGE\":\"%s\",\"OPENSHELL_SANDBOX_IMAGE_PULL_POLICY\":\"%s\",\"OPENSHELL_HEALTH_CHECK_INTERVAL_SECS\":%s,\"OPENSHELL_GRPC_ENDPOINT\":\"%s\",\"OPENSHELL_GATEWAY_PORT\":%s,\"OPENSHELL_NETWORK_NAME\":\"%s\",\"OPENSHELL_STOP_TIMEOUT\":%s,\"OPENSHELL_SANDBOX_RUNTIME_IMAGE\":\"%s\",\"OPENSHELL_SUPERVISOR_IMAGE\":\"%s\",\"OPENSHELL_PODMAN_TLS_CA\":{\"path\":\"%s\",\"sha256\":\"%s\"}%s,\"OPENSHELL_ENABLE_BIND_MOUNTS\":%s}' \ "${DRIVER_DATA_HOME}" \ "${DRIVER_SOCKET}" \ "${OPENSHELL_PODMAN_SOCKET:-}" \ @@ -776,10 +790,7 @@ if [ -n "${OPENSHELL_PARITY_LAUNCH_MANIFEST_CAPTURE:-}" ]; then "${SUPERVISOR_RUNTIME_IMAGE}" \ "${EXTERNAL_DRIVER_TLS_CA}" \ "${driver_tls_ca_sha256}" \ - "${EXTERNAL_DRIVER_TLS_CERT}" \ - "${driver_tls_cert_sha256}" \ - "${EXTERNAL_DRIVER_TLS_KEY}" \ - "${driver_tls_key_sha256}" \ + "${legacy_tls_manifest}" \ "${EXTERNAL_DRIVER_ENABLE_BIND_MOUNTS}")" fi printf '{"schema_version":%s,"gateway_port":%s,"external_compute_driver":%s,"compute_driver_transport":"%s","external_driver_pull_policy":"%s","supervisor_image":"%s","supervisor_image_id":"%s","supervisor_image_digest":"%s","supervisor_runtime_image":"%s","supervisor_base_image":"%s","supervisor_base_image_id":"%s","supervisor_base_image_digest":"%s","supervisor_base_runtime_image":"%s","supervisor_package_manifest_sha256":"%s","sandbox_image_request":"%s","sandbox_image_id":"%s","sandbox_image_digest":"%s","sandbox_runtime_image":"%s","sandbox_boundary_image":"%s","sandbox_client_image_alias":"%s","sandbox_client_image_alias_id":"%s","gateway_sha256_before_execution":"%s","cli_sha256_before_execution":"%s","conformance_sha256_before_execution":"%s","external_driver_sha256_before_execution":"%s","supervisor_sha256_before_execution":"%s","supervisor_dockerfile_sha256_before_execution":"%s","cli_trace_wrapper_sha256_before_execution":"%s","external_driver_grpc_endpoint":%s,"external_driver_host_gateway_ip":%s,"external_driver_userns":%s,"external_driver_spiffe":%s,"external_driver_proxy":%s,"external_driver_app_armor":%s,"external_driver_environment":%s}\n' \ @@ -838,8 +849,7 @@ if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = "1" ]; then OPENSHELL_SANDBOX_RUNTIME_IMAGE="${SANDBOX_BOUNDARY_IMAGE}" \ OPENSHELL_SUPERVISOR_IMAGE="${SUPERVISOR_RUNTIME_IMAGE}" \ OPENSHELL_PODMAN_TLS_CA="${EXTERNAL_DRIVER_TLS_CA}" \ - OPENSHELL_PODMAN_TLS_CERT="${EXTERNAL_DRIVER_TLS_CERT}" \ - OPENSHELL_PODMAN_TLS_KEY="${EXTERNAL_DRIVER_TLS_KEY}" \ + "${external_driver_legacy_tls_env[@]}" \ OPENSHELL_ENABLE_BIND_MOUNTS="${EXTERNAL_DRIVER_ENABLE_BIND_MOUNTS}" \ "${DRIVER_BIN}" >"${DRIVER_LOG}" 2>&1 & DRIVER_PID=$! diff --git a/python/openshell/gateway_schema_v2_compute_boundary_verifier_test.py b/python/openshell/gateway_schema_v2_compute_boundary_verifier_test.py index aace841cfb..5e93d7cbe2 100644 --- a/python/openshell/gateway_schema_v2_compute_boundary_verifier_test.py +++ b/python/openshell/gateway_schema_v2_compute_boundary_verifier_test.py @@ -107,6 +107,18 @@ def create_variant( policy = "missing" if schema_version == 1 else "if_not_present" package_hash = verifier.sha256(artifact_dir / "supervisor.packages.txt") result = json.loads((results_dir / f"{variant}.json").read_text(encoding="utf-8")) + legacy_tls_environment = {} + if schema_version == 1: + legacy_tls_environment = { + "OPENSHELL_PODMAN_TLS_CERT": { + "path": f"/tmp/{variant}-pki/tls.crt", + "sha256": "9" * 64, + }, + "OPENSHELL_PODMAN_TLS_KEY": { + "path": f"/tmp/{variant}-pki/tls.key", + "sha256": "a" * 64, + }, + } write_json( results_dir / f"{variant}.launch.json", { @@ -164,14 +176,7 @@ def create_variant( "path": f"/tmp/{variant}-pki/ca.crt", "sha256": "8" * 64, }, - "OPENSHELL_PODMAN_TLS_CERT": { - "path": f"/tmp/{variant}-pki/tls.crt", - "sha256": "9" * 64, - }, - "OPENSHELL_PODMAN_TLS_KEY": { - "path": f"/tmp/{variant}-pki/tls.key", - "sha256": "a" * 64, - }, + **legacy_tls_environment, "OPENSHELL_ENABLE_BIND_MOUNTS": True, }, }, diff --git a/rfc/0003-gateway-configuration/README.md b/rfc/0003-gateway-configuration/README.md index 4527c85d08..6e42e76652 100644 --- a/rfc/0003-gateway-configuration/README.md +++ b/rfc/0003-gateway-configuration/README.md @@ -93,10 +93,8 @@ enable_loopback_service_http = true # plaintext listener; guest TLS fields must then be omitted. disable_tls = false -# Gateway-owned TLS bundle injected into the selected local driver. +# Gateway-owned CA injected into the selected local driver for supervisor TLS. guest_tls_ca = "/etc/openshell/certs/ca.pem" -guest_tls_cert = "/etc/openshell/certs/client.pem" -guest_tls_key = "/etc/openshell/certs/client-key.pem" [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" @@ -162,7 +160,7 @@ krun_log_level = 1 Each `[openshell.drivers.]` table is extracted from the parsed file and handed to the driver's initialization function as a raw TOML value. The driver is then responsible for: 1. **Parsing** — deserializing the table into its own typed config struct (e.g. `KubernetesComputeConfig`, `DockerComputeConfig`, `PodmanComputeConfig`, `VmComputeConfig`). -2. **Validation** — applying cross-field checks specific to that driver. Gateway-owned guest TLS paths are validated as one bundle and injected only into the selected local driver before this step. +2. **Validation** — applying cross-field checks specific to that driver. The gateway-owned CA path is validated and injected only into the selected local driver before this step; supervisor identity uses sandbox bearer tokens. 3. **Consumption** — using the resulting struct to initialize internal state. Driver authors define and own their config schema. Adding a new driver does not require changes to the gateway's core `Config` struct or to this RFC. diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 6382428946..c8b763e399 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -71,7 +71,7 @@ Common findings: - `No active gateway`: register one with `openshell gateway add `. - Connection refused: gateway process is not running, service exposure is wrong, or a port-forward/proxy is not active. -- TLS/certificate errors: the endpoint scheme or trust chain is wrong, a local mTLS bundle does not match the gateway CA, or TLS termination does not match the gateway listener. +- TLS/certificate errors: the endpoint scheme or trust chain is wrong, a CLI mTLS bundle does not match the gateway CA, a supervisor is missing the gateway CA, or TLS termination does not match the gateway listener. Workloads and supervisors should not contain a user TLS client certificate or private key. - A Snap refresh restarts the gateway with its migrated mTLS config. The secure Snap gateway uses `https://127.0.0.1:17670` and requires a client bundle in the user's Snap state. Refresh replaces insecure configs without keeping a copy; follow the published Snap installation steps to re-register an old HTTP client. - `Unauthenticated` from an edge or OIDC gateway: refresh stored credentials with `openshell gateway login [name]`, then retry. Use `gateway logout` only when intentionally clearing local credentials. - A direct development endpoint with a private or self-signed certificate can be isolated with `--gateway-endpoint --gateway-insecure`; do not persist or recommend insecure verification for shared gateways. @@ -130,11 +130,13 @@ WebSocket tunnel for edge-proxy CLI access is off unless and RPM package startup migrates only exact package-generated v1 defaults. If an upgraded package still reports an unsupported version, inspect the active prefix or `~/.config/openshell/gateway.toml`; an edited v1 file must follow the published schema-v2 migration steps and must not be overwritten. -Guest TLS CA, certificate, and key paths are the exception to driver ownership: -configure the complete bundle under `[openshell.gateway]`, and the gateway -injects it only into the selected local driver. TLS-enabled Docker, Podman, and -VM drivers fail startup when neither those paths nor the package-managed local -bundle is available; Kubernetes projects its bundle through a Secret. +The supervisor gateway CA is the exception to driver ownership: configure +`guest_tls_ca` under `[openshell.gateway]`, and the gateway injects it only into +the selected local driver. Remove the retired `guest_tls_cert` and +`guest_tls_key` fields. TLS-enabled Docker, Podman, and VM drivers fail startup +when neither that CA nor the package-managed local CA is available. Kubernetes +projects only the gateway CA from a Secret into supervisor Pods; supervisors +authenticate gateway RPCs with sandbox bearer tokens. Custom names use `[openshell.drivers.].socket_path`. A launch-time `--compute-driver-socket` override may also use `docker`, `podman`, `kubernetes`, or `vm`; the endpoint then takes precedence over built-in construction. First-party standalone drivers require the socket parent directory to be owned by the driver's effective UID, force its mode to `0700`, create the socket with mode `0600`, and accept only peers with that same UID. Check the parent and socket separately with `stat`; a gateway running under a different UID cannot connect even when filesystem permissions or group membership would otherwise allow it. Operator-supplied drivers must provide equivalent access control appropriate to their implementation. Check gateway logs for connection errors, `GetCapabilities` failures, missing peer metadata, protocol-major mismatch, unmet required capabilities, or an unexpected advertised driver name. `openshell gateway info` reports successful startup negotiations. The advertised name is diagnostic metadata; negotiated features control optional behavior. The gateway does not create or supervise operator-supplied driver processes or sockets. @@ -574,7 +576,9 @@ Less commonly, `UnknownCA` can occur if the gateway's client-verification CA is misconfigured. The default `clientCaFromServerTlsSecret=true` is correct for all configurations — the internal server certificate is always signed by the chart CA (the same CA that signs the client cert), so its `ca.crt` is -the right trust anchor. Only override this if you intentionally mount a +the right trust anchor. Supervisor Pods project only `ca.crt` from the copied +Secret; `tls.crt` and `tls.key` are reserved for user clients and must not be +visible in a sandbox. Only override this if you intentionally mount a separate client CA via `server.tls.clientCaSecretName`. Verify the mounted client CA matches the CA that signed the client certificate: