diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 16158c0dc..90bdd7fe9 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -113,7 +113,6 @@ Check required Helm deployment secrets: ```bash kubectl -n openshell get secret \ - openshell-ssh-handshake \ openshell-server-tls \ openshell-server-client-ca \ openshell-client-tls diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index a2a973011..900565255 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -27,9 +27,6 @@ pub const DEFAULT_SERVER_PORT: u16 = 8080; /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; -/// Default allowed clock skew for SSH handshake validation, in seconds. -pub const DEFAULT_SSH_HANDSHAKE_SKEW_SECS: u64 = 300; - /// Default Podman bridge network name. pub const DEFAULT_NETWORK_NAME: &str = "openshell"; @@ -273,14 +270,6 @@ pub struct Config { #[serde(default = "default_sandbox_ssh_socket_path")] pub sandbox_ssh_socket_path: String, - /// Shared secret for gateway-to-sandbox SSH handshake. - #[serde(default)] - pub ssh_handshake_secret: String, - - /// Allowed clock skew for SSH handshake validation, in seconds. - #[serde(default = "default_ssh_handshake_skew_secs")] - pub ssh_handshake_skew_secs: u64, - /// TTL for SSH session tokens, in seconds. 0 disables expiry. #[serde(default = "default_ssh_session_ttl_secs")] pub ssh_session_ttl_secs: u64, @@ -413,8 +402,6 @@ impl Config { ssh_connect_path: default_ssh_connect_path(), sandbox_ssh_port: default_sandbox_ssh_port(), sandbox_ssh_socket_path: default_sandbox_ssh_socket_path(), - ssh_handshake_secret: String::new(), - ssh_handshake_skew_secs: default_ssh_handshake_skew_secs(), ssh_session_ttl_secs: default_ssh_session_ttl_secs(), client_tls_secret_name: String::new(), host_gateway_ip: String::new(), @@ -534,20 +521,6 @@ impl Config { self } - /// Create a new configuration with the SSH handshake secret. - #[must_use] - pub fn with_ssh_handshake_secret(mut self, secret: impl Into) -> Self { - self.ssh_handshake_secret = secret.into(); - self - } - - /// Create a new configuration with SSH handshake skew allowance. - #[must_use] - pub const fn with_ssh_handshake_skew_secs(mut self, secs: u64) -> Self { - self.ssh_handshake_skew_secs = secs; - self - } - /// Create a new configuration with the SSH session TTL. #[must_use] pub const fn with_ssh_session_ttl_secs(mut self, secs: u64) -> Self { @@ -613,10 +586,6 @@ const fn default_sandbox_ssh_port() -> u16 { DEFAULT_SSH_PORT } -const fn default_ssh_handshake_skew_secs() -> u64 { - DEFAULT_SSH_HANDSHAKE_SKEW_SECS -} - const fn default_ssh_session_ttl_secs() -> u64 { 86400 // 24 hours } diff --git a/crates/openshell-driver-kubernetes/src/config.rs b/crates/openshell-driver-kubernetes/src/config.rs index cbf55423d..be093c2ad 100644 --- a/crates/openshell-driver-kubernetes/src/config.rs +++ b/crates/openshell-driver-kubernetes/src/config.rs @@ -15,8 +15,6 @@ pub struct KubernetesComputeConfig { pub supervisor_image_pull_policy: String, pub grpc_endpoint: String, pub ssh_socket_path: String, - pub ssh_handshake_secret: String, - pub ssh_handshake_skew_secs: u64, pub client_tls_secret_name: String, pub host_gateway_ip: String, pub enable_user_namespaces: bool, diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 668a18d8c..f8d084473 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -166,10 +166,6 @@ impl KubernetesComputeDriver { &self.config.ssh_socket_path } - pub const fn ssh_handshake_skew_secs(&self) -> u64 { - self.config.ssh_handshake_skew_secs - } - fn watch_api(&self) -> Api { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, SANDBOX_VERSION, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); @@ -286,10 +282,6 @@ impl KubernetesComputeDriver { } } - fn ssh_handshake_secret(&self) -> &str { - &self.config.ssh_handshake_secret - } - pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { let name = sandbox.name.as_str(); info!( @@ -317,8 +309,6 @@ impl KubernetesComputeDriver { sandbox_name: &sandbox.name, grpc_endpoint: &self.config.grpc_endpoint, ssh_socket_path: self.ssh_socket_path(), - ssh_handshake_secret: self.ssh_handshake_secret(), - ssh_handshake_skew_secs: self.ssh_handshake_skew_secs(), client_tls_secret_name: &self.config.client_tls_secret_name, host_gateway_ip: &self.config.host_gateway_ip, enable_user_namespaces: self.config.enable_user_namespaces, @@ -938,8 +928,6 @@ struct SandboxPodParams<'a> { sandbox_name: &'a str, grpc_endpoint: &'a str, ssh_socket_path: &'a str, - ssh_handshake_secret: &'a str, - ssh_handshake_skew_secs: u64, client_tls_secret_name: &'a str, host_gateway_ip: &'a str, enable_user_namespaces: bool, @@ -1091,8 +1079,6 @@ fn sandbox_template_to_k8s( params.sandbox_name, params.grpc_endpoint, params.ssh_socket_path, - params.ssh_handshake_secret, - params.ssh_handshake_skew_secs, !params.client_tls_secret_name.is_empty(), ); @@ -1245,8 +1231,6 @@ fn build_env_list( sandbox_name: &str, grpc_endpoint: &str, ssh_socket_path: &str, - ssh_handshake_secret: &str, - ssh_handshake_skew_secs: u64, tls_enabled: bool, ) -> Vec { let mut env = existing_env.cloned().unwrap_or_default(); @@ -1258,8 +1242,6 @@ fn build_env_list( sandbox_name, grpc_endpoint, ssh_socket_path, - ssh_handshake_secret, - ssh_handshake_skew_secs, tls_enabled, ); env @@ -1276,15 +1258,12 @@ fn apply_env_map( // Required env vars are passed individually for clarity at call sites; grouping into a struct // would not improve readability for this internal helper. -#[allow(clippy::too_many_arguments)] fn apply_required_env( env: &mut Vec, sandbox_id: &str, sandbox_name: &str, grpc_endpoint: &str, ssh_socket_path: &str, - ssh_handshake_secret: &str, - ssh_handshake_skew_secs: u64, tls_enabled: bool, ) { upsert_env(env, "OPENSHELL_SANDBOX_ID", sandbox_id); @@ -1294,12 +1273,6 @@ fn apply_required_env( if !ssh_socket_path.is_empty() { upsert_env(env, "OPENSHELL_SSH_SOCKET_PATH", ssh_socket_path); } - upsert_env(env, "OPENSHELL_SSH_HANDSHAKE_SECRET", ssh_handshake_secret); - upsert_env( - env, - "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", - &ssh_handshake_skew_secs.to_string(), - ); // TLS cert paths for sandbox-to-server mTLS. Only set when TLS is enabled // and the client TLS secret is mounted into the sandbox pod. if tls_enabled { @@ -1454,7 +1427,7 @@ mod tests { use prost_types::{Struct, Value, value::Kind}; #[test] - fn apply_required_env_always_injects_ssh_handshake_secret() { + fn apply_required_env_does_not_inject_handshake_env() { let mut env = Vec::new(); apply_required_env( &mut env, @@ -1462,21 +1435,19 @@ mod tests { "my-sandbox", "https://endpoint:8080", "0.0.0.0:2222", - "my-secret-value", - 300, true, ); - let secret_entry = env - .iter() - .find(|e| { - e.get("name").and_then(|v| v.as_str()) == Some("OPENSHELL_SSH_HANDSHAKE_SECRET") - }) - .expect("OPENSHELL_SSH_HANDSHAKE_SECRET must be present in env"); - assert_eq!( - secret_entry.get("value").and_then(|v| v.as_str()), - Some("my-secret-value") - ); + for name in [ + "OPENSHELL_SSH_HANDSHAKE_SECRET", + "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", + ] { + assert!( + !env.iter() + .any(|e| e.get("name").and_then(|v| v.as_str()) == Some(name)), + "{name} must not appear in sandbox pod env after handshake-secret removal" + ); + } } #[test] @@ -1614,8 +1585,6 @@ mod tests { "my-sandbox", "https://endpoint:8080", "0.0.0.0:2222", - "secret", - 300, true, // tls_enabled ); diff --git a/crates/openshell-driver-kubernetes/src/main.rs b/crates/openshell-driver-kubernetes/src/main.rs index 9e39e9d28..dc0aa592f 100644 --- a/crates/openshell-driver-kubernetes/src/main.rs +++ b/crates/openshell-driver-kubernetes/src/main.rs @@ -46,12 +46,6 @@ struct Args { )] sandbox_ssh_socket_path: String, - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] - ssh_handshake_secret: String, - - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value_t = 300)] - ssh_handshake_skew_secs: u64, - #[arg(long, env = "OPENSHELL_CLIENT_TLS_SECRET_NAME")] client_tls_secret_name: Option, @@ -87,8 +81,6 @@ async fn main() -> Result<()> { supervisor_image_pull_policy: args.supervisor_image_pull_policy.unwrap_or_default(), grpc_endpoint: args.grpc_endpoint.unwrap_or_default(), ssh_socket_path: args.sandbox_ssh_socket_path, - ssh_handshake_secret: args.ssh_handshake_secret, - ssh_handshake_skew_secs: args.ssh_handshake_skew_secs, client_tls_secret_name: args.client_tls_secret_name.unwrap_or_default(), host_gateway_ip: args.host_gateway_ip.unwrap_or_default(), enable_user_namespaces: args.enable_user_namespaces, diff --git a/crates/openshell-driver-podman/README.md b/crates/openshell-driver-podman/README.md index d853bb5ea..646979229 100644 --- a/crates/openshell-driver-podman/README.md +++ b/crates/openshell-driver-podman/README.md @@ -214,13 +214,9 @@ via sandbox templates: - `OPENSHELL_SANDBOX_ID` - `OPENSHELL_ENDPOINT` - `OPENSHELL_SSH_SOCKET_PATH` -- `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` - `OPENSHELL_CONTAINER_IMAGE` - `OPENSHELL_SANDBOX_COMMAND` -The `PodmanComputeConfig::Debug` implementation redacts the handshake secret as -`[REDACTED]`. - ## Sandbox Lifecycle ### Creation Flow @@ -238,18 +234,15 @@ sequenceDiagram D->>P: pull_image(supervisor, "missing") D->>P: pull_image(sandbox_image, policy) - D->>P: create_secret(handshake) - Note over D: On failure below, rollback secret - D->>P: create_volume(workspace) - Note over D: On failure below, rollback volume + secret + Note over D: On failure below, rollback volume D->>P: create_container(spec) alt Conflict (409) - D->>P: remove_volume + remove_secret + D->>P: remove_volume D-->>GW: AlreadyExists end - Note over D: On failure below, rollback container + volume + secret + Note over D: On failure below, rollback container + volume D->>P: start_container D-->>GW: Ok @@ -299,8 +292,6 @@ Podman resources after out-of-band container removal or label drift. | `OPENSHELL_GATEWAY_PORT` | `--gateway-port` | `8080` | Gateway port used for endpoint auto-detection by the standalone binary. | | `OPENSHELL_NETWORK_NAME` | `--network-name` | `openshell` | Podman bridge network name. | | `OPENSHELL_SANDBOX_SSH_PORT` | `--sandbox-ssh-port` | `2222` | SSH compatibility port inside the container. | -| `OPENSHELL_SSH_HANDSHAKE_SECRET` | `--ssh-handshake-secret` | Required standalone, gateway-generated in-process | Shared secret for the NSSH1 handshake. | -| `OPENSHELL_SSH_HANDSHAKE_SKEW_SECS` | `--ssh-handshake-skew-secs` | `300` | Allowed timestamp skew for SSH handshake validation. | | `OPENSHELL_SANDBOX_SSH_SOCKET_PATH` | `--sandbox-ssh-socket-path` | `/run/openshell/ssh.sock` | Standalone driver only: supervisor Unix socket path in `PodmanComputeConfig`. In-gateway Podman uses server `config.sandbox_ssh_socket_path`. | | `OPENSHELL_STOP_TIMEOUT` | `--stop-timeout` | `10` | Container stop timeout in seconds. | | `OPENSHELL_SUPERVISOR_IMAGE` | `--supervisor-image` | `openshell/supervisor:latest` through the gateway, required standalone | OCI image containing the supervisor binary. | diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 69bfd69c0..834fb21a2 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -381,23 +381,6 @@ impl PodmanClient { } } - /// Perform a versioned HTTP request with a raw byte body (not JSON). - async fn request_raw( - &self, - method: hyper::Method, - path: &str, - content_type: &str, - body: Bytes, - ) -> Result<(hyper::StatusCode, Bytes), PodmanApiError> { - let req = Self::build_request( - method, - &format!("/{API_VERSION}{path}"), - Full::new(body), - Some(content_type), - ); - self.send_request(req, API_TIMEOUT).await - } - /// POST a JSON body and ignore 409 Conflict (resource already exists). async fn create_ignore_conflict(&self, path: &str, body: &Value) -> Result<(), PodmanApiError> { match self @@ -550,64 +533,6 @@ impl PodmanClient { Ok(gateway) } - // ── Secret operations ──────────────────────────────────────────────── - - /// Create a Podman secret with the given name and raw value. - /// - /// Idempotent: if a secret with the same name already exists it is - /// replaced (delete + recreate) so the value is always up-to-date. - pub async fn create_secret(&self, name: &str, value: &[u8]) -> Result<(), PodmanApiError> { - validate_name(name)?; - let encoded_name = url_encode(name); - let path = format!("/libpod/secrets/create?name={encoded_name}"); - let (status, bytes) = self - .request_raw( - hyper::Method::POST, - &path, - "application/octet-stream", - Bytes::copy_from_slice(value), - ) - .await?; - - match status.as_u16() { - 200 | 201 => Ok(()), - 409 => { - // Secret already exists — replace it. - self.remove_secret(name).await?; - let (status2, bytes2) = self - .request_raw( - hyper::Method::POST, - &path, - "application/octet-stream", - Bytes::copy_from_slice(value), - ) - .await?; - if status2.is_success() { - Ok(()) - } else { - Err(error_from_response(status2.as_u16(), &bytes2)) - } - } - _ => Err(error_from_response(status.as_u16(), &bytes)), - } - } - - /// Remove a Podman secret by name. Idempotent (not-found is ignored). - pub async fn remove_secret(&self, name: &str) -> Result<(), PodmanApiError> { - validate_name(name)?; - match self - .request_ok( - hyper::Method::DELETE, - &format!("/libpod/secrets/{name}"), - None, - ) - .await - { - Ok(()) | Err(PodmanApiError::NotFound(_)) => Ok(()), - Err(e) => Err(e), - } - } - // ── Image operations ──────────────────────────────────────────────── /// Pull an image if it is not already present locally. diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index d82b8d0b0..43f7d1fd0 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use openshell_core::config::{ - DEFAULT_NETWORK_NAME, DEFAULT_SSH_HANDSHAKE_SKEW_SECS, DEFAULT_SSH_PORT, - DEFAULT_STOP_TIMEOUT_SECS, DEFAULT_SUPERVISOR_IMAGE, + DEFAULT_NETWORK_NAME, DEFAULT_SSH_PORT, DEFAULT_STOP_TIMEOUT_SECS, DEFAULT_SUPERVISOR_IMAGE, }; use std::path::PathBuf; use std::str::FromStr; @@ -90,10 +89,6 @@ pub struct PodmanComputeConfig { pub network_name: String, /// SSH port inside the container. pub ssh_port: u16, - /// Shared secret for the NSSH1 SSH handshake. - pub ssh_handshake_secret: String, - /// Maximum clock skew in seconds for SSH handshake timestamps. - pub ssh_handshake_skew_secs: u64, /// Container stop timeout in seconds (SIGTERM → SIGKILL). pub stop_timeout_secs: u32, /// OCI image containing the openshell-sandbox supervisor binary. @@ -192,8 +187,6 @@ impl Default for PodmanComputeConfig { sandbox_ssh_socket_path: "/run/openshell/ssh.sock".to_string(), network_name: DEFAULT_NETWORK_NAME.to_string(), ssh_port: DEFAULT_SSH_PORT, - ssh_handshake_secret: String::new(), - ssh_handshake_skew_secs: DEFAULT_SSH_HANDSHAKE_SKEW_SECS, stop_timeout_secs: DEFAULT_STOP_TIMEOUT_SECS, supervisor_image: DEFAULT_SUPERVISOR_IMAGE.to_string(), guest_tls_ca: None, @@ -214,8 +207,6 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("sandbox_ssh_socket_path", &self.sandbox_ssh_socket_path) .field("network_name", &self.network_name) .field("ssh_port", &self.ssh_port) - .field("ssh_handshake_secret", &"[REDACTED]") - .field("ssh_handshake_skew_secs", &self.ssh_handshake_skew_secs) .field("stop_timeout_secs", &self.stop_timeout_secs) .field("supervisor_image", &self.supervisor_image) .field("guest_tls_ca", &self.guest_tls_ca) diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 3c5df292f..ea204195a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -63,15 +63,6 @@ pub fn volume_name(sandbox_id: &str) -> String { format!("{VOLUME_PREFIX}{sandbox_id}-workspace") } -/// Podman secret name prefix. -const SECRET_PREFIX: &str = "openshell-handshake-"; - -/// Build the Podman secret name for a sandbox's SSH handshake secret. -#[must_use] -pub fn secret_name(sandbox_id: &str) -> String { - format!("{SECRET_PREFIX}{sandbox_id}") -} - /// Truncate a container ID to 12 characters (standard short form). #[must_use] pub fn short_id(id: &str) -> String { @@ -272,13 +263,6 @@ fn build_env( "OPENSHELL_SSH_SOCKET_PATH".into(), config.sandbox_ssh_socket_path.clone(), ); - // NOTE: The SSH handshake secret is injected via a Podman secret - // (see the "secrets" field below) rather than a plaintext env var. - // This prevents exposure through `podman inspect`. - env.insert( - "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS".into(), - config.ssh_handshake_skew_secs.to_string(), - ); env.insert("OPENSHELL_CONTAINER_IMAGE".into(), image.to_string()); env.insert("OPENSHELL_SANDBOX_COMMAND".into(), "sleep infinity".into()); @@ -491,16 +475,7 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi start_period: 5_000_000_000, }, resource_limits, - // Inject the SSH handshake secret via Podman's secret_env map so it - // does not appear in `podman inspect` output. The libpod SpecGenerator - // uses `secret_env` (map of env_var → secret_name) for env-type secrets, - // distinct from `secrets` which only handles file mounts under /run/secrets/. - // The secret is created by the driver before the container - // (see `PodmanComputeDriver::create_sandbox`). - secret_env: BTreeMap::from([( - "OPENSHELL_SSH_HANDSHAKE_SECRET".into(), - secret_name(&sandbox.id), - )]), + secret_env: BTreeMap::new(), stop_timeout: config.stop_timeout_secs, // Inject stable host aliases into /etc/hosts so sandbox containers can // reach services on the host. `host.openshell.internal` is the driver- @@ -676,11 +651,6 @@ mod tests { ); } - #[test] - fn secret_name_uses_id() { - assert_eq!(secret_name("abc-123"), "openshell-handshake-abc-123"); - } - #[test] fn short_id_truncates() { assert_eq!(short_id("abc123def456789"), "abc123def456"); @@ -736,30 +706,28 @@ mod tests { } #[test] - fn container_spec_uses_secret_env_not_plaintext() { + fn container_spec_does_not_inject_handshake_env() { let sandbox = test_sandbox("test-id", "test-name"); let config = test_config(); let spec = build_container_spec(&sandbox, &config); - // The handshake secret must NOT appear in the plaintext env map. let env_map = spec["env"].as_object().expect("env should be an object"); - assert!( - !env_map.contains_key("OPENSHELL_SSH_HANDSHAKE_SECRET"), - "handshake secret should not be in plaintext env" - ); + for name in [ + "OPENSHELL_SSH_HANDSHAKE_SECRET", + "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", + ] { + assert!( + !env_map.contains_key(name), + "{name} must not appear in container env" + ); + } - // It should appear in secret_env (the libpod env-type secret map) instead. let secret_env = spec["secret_env"] .as_object() .expect("secret_env should be an object"); assert!( - secret_env.contains_key("OPENSHELL_SSH_HANDSHAKE_SECRET"), - "secret_env should map OPENSHELL_SSH_HANDSHAKE_SECRET to its secret name" - ); - assert_eq!( - secret_env["OPENSHELL_SSH_HANDSHAKE_SECRET"].as_str(), - Some("openshell-handshake-test-id"), - "secret_env value should be the Podman secret name for the sandbox" + !secret_env.contains_key("OPENSHELL_SSH_HANDSHAKE_SECRET"), + "handshake secret must not appear in secret_env" ); } @@ -961,7 +929,6 @@ mod tests { default_image: "test-image:latest".to_string(), grpc_endpoint: "http://localhost:50051".to_string(), sandbox_ssh_socket_path: "/run/openshell/test-ssh.sock".to_string(), - ssh_handshake_secret: "test-secret-value".to_string(), ..PodmanComputeConfig::default() } } diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index ad4d7a192..1f37d40d8 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -221,12 +221,11 @@ impl PodmanComputeDriver { } // Validate the composed container name early, before creating any - // resources (secret, volume), so we don't leave orphans when the - // name is invalid. + // resources (volume), so we don't leave orphans when the name is + // invalid. let name = validated_container_name(&sandbox.name)?; let vol_name = container::volume_name(&sandbox.id); - let sec_name = container::secret_name(&sandbox.id); info!( sandbox_id = %sandbox.id, @@ -265,35 +264,25 @@ impl PodmanComputeDriver { .await .map_err(ComputeDriverError::from)?; - // 2. Create the SSH handshake secret via the Podman secrets API - // so it is not exposed in `podman inspect` output. - self.client - .create_secret(&sec_name, self.config.ssh_handshake_secret.as_bytes()) - .await - .map_err(ComputeDriverError::from)?; - - // 3. Create workspace volume. + // 2. Create workspace volume. if let Err(e) = self.client.create_volume(&vol_name).await { - let _ = self.client.remove_secret(&sec_name).await; return Err(ComputeDriverError::from(e)); } - // 4. Create container. + // 3. Create container. let spec = container::build_container_spec(sandbox, &self.config); match self.client.create_container(&spec).await { Ok(_) => {} Err(PodmanApiError::Conflict(_)) => { - // Clean up the volume and secret we just created. They are - // keyed by *this* sandbox's ID, not the conflicting - // container's ID (which has the same name but a different - // ID), so they would be orphaned otherwise. + // Clean up the volume we just created. It is keyed by *this* + // sandbox's ID, not the conflicting container's ID (which + // has the same name but a different ID), so it would be + // orphaned otherwise. let _ = self.client.remove_volume(&vol_name).await; - let _ = self.client.remove_secret(&sec_name).await; return Err(ComputeDriverError::AlreadyExists); } Err(e) => { let _ = self.client.remove_volume(&vol_name).await; - let _ = self.client.remove_secret(&sec_name).await; return Err(ComputeDriverError::from(e)); } } @@ -307,7 +296,6 @@ impl PodmanComputeDriver { ); let _ = self.client.remove_container(&name).await; let _ = self.client.remove_volume(&vol_name).await; - let _ = self.client.remove_secret(&sec_name).await; return Err(ComputeDriverError::from(e)); } @@ -394,7 +382,7 @@ impl PodmanComputeDriver { Err(e) => return Err(ComputeDriverError::from(e)), }; - // Remove workspace volume and handshake secret. + // Remove workspace volume. let vol = container::volume_name(sandbox_id); if let Err(e) = self.client.remove_volume(&vol).await { warn!( @@ -405,16 +393,6 @@ impl PodmanComputeDriver { "Failed to remove workspace volume" ); } - let sec = container::secret_name(sandbox_id); - if let Err(e) = self.client.remove_secret(&sec).await { - warn!( - sandbox_id = %sandbox_id, - sandbox_name = %sandbox_name, - secret = %sec, - error = %e, - "Failed to remove handshake secret" - ); - } Ok(container_existed) } @@ -753,7 +731,6 @@ mod tests { let sandbox_name = "demo"; let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); - let secret_name = container::secret_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "delete-not-found", vec![ @@ -761,7 +738,6 @@ mod tests { StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), StubResponse::new(StatusCode::NO_CONTENT, ""), - StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); @@ -800,10 +776,6 @@ mod tests { "DELETE {}", api_path(&format!("/libpod/volumes/{volume_name}")) ), - format!( - "DELETE {}", - api_path(&format!("/libpod/secrets/{secret_name}")) - ), ] ); let _ = std::fs::remove_file(socket_path); @@ -815,7 +787,6 @@ mod tests { let sandbox_name = "demo"; let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); - let secret_name = container::secret_name(sandbox_id); let inspect_body = serde_json::json!({ "Id": "container-id", "Name": format!("/{container_name}"), @@ -837,7 +808,6 @@ mod tests { StubResponse::new(StatusCode::NO_CONTENT, ""), StubResponse::new(StatusCode::NO_CONTENT, ""), StubResponse::new(StatusCode::NO_CONTENT, ""), - StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let driver = test_driver(socket_path.clone()); @@ -855,16 +825,10 @@ mod tests { .clone(); assert_eq!( requests[3..], - [ - format!( - "DELETE {}", - api_path(&format!("/libpod/volumes/{volume_name}")) - ), - format!( - "DELETE {}", - api_path(&format!("/libpod/secrets/{secret_name}")) - ), - ] + [format!( + "DELETE {}", + api_path(&format!("/libpod/volumes/{volume_name}")) + )] ); let _ = std::fs::remove_file(socket_path); } diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index df4c90d13..96a28461c 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -348,7 +348,6 @@ mod tests { let sandbox_name = "demo"; let container_name = container::container_name(sandbox_name); let volume_name = container::volume_name(sandbox_id); - let secret_name = container::secret_name(sandbox_id); let (socket_path, request_log, handle) = spawn_podman_stub( "forward-id", vec![ @@ -356,7 +355,6 @@ mod tests { StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), StubResponse::new(StatusCode::NOT_FOUND, r#"{"message":"gone"}"#), StubResponse::new(StatusCode::NO_CONTENT, ""), - StubResponse::new(StatusCode::NO_CONTENT, ""), ], ); let service = test_service(socket_path.clone()); @@ -404,10 +402,6 @@ mod tests { "DELETE {}", api_path(&format!("/libpod/volumes/{volume_name}")) ), - format!( - "DELETE {}", - api_path(&format!("/libpod/secrets/{secret_name}")) - ), ] ); let _ = std::fs::remove_file(socket_path); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index 9095915dd..9c31100ac 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -9,10 +9,7 @@ use tracing::info; use tracing_subscriber::EnvFilter; use openshell_core::VERSION; -use openshell_core::config::{ - DEFAULT_NETWORK_NAME, DEFAULT_SSH_HANDSHAKE_SKEW_SECS, DEFAULT_SSH_PORT, - DEFAULT_STOP_TIMEOUT_SECS, -}; +use openshell_core::config::{DEFAULT_NETWORK_NAME, DEFAULT_SSH_PORT, DEFAULT_STOP_TIMEOUT_SECS}; use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; use openshell_driver_podman::config::ImagePullPolicy; use openshell_driver_podman::{ComputeDriverService, PodmanComputeConfig, PodmanComputeDriver}; @@ -73,12 +70,6 @@ struct Args { #[arg(long, env = "OPENSHELL_SANDBOX_SSH_PORT", default_value_t = DEFAULT_SSH_PORT)] sandbox_ssh_port: u16, - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] - ssh_handshake_secret: String, - - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value_t = DEFAULT_SSH_HANDSHAKE_SKEW_SECS)] - ssh_handshake_skew_secs: u64, - /// Container stop timeout in seconds (SIGTERM → SIGKILL). #[arg(long, env = "OPENSHELL_STOP_TIMEOUT", default_value_t = DEFAULT_STOP_TIMEOUT_SECS)] stop_timeout: u32, @@ -122,8 +113,6 @@ async fn main() -> Result<()> { sandbox_ssh_socket_path: args.sandbox_ssh_socket_path, network_name: args.network_name, ssh_port: args.sandbox_ssh_port, - ssh_handshake_secret: args.ssh_handshake_secret, - ssh_handshake_skew_secs: args.ssh_handshake_skew_secs, stop_timeout_secs: args.stop_timeout, supervisor_image: args.supervisor_image, guest_tls_ca: args.podman_tls_ca, diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index b797f4835..918ed4a34 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -108,8 +108,6 @@ pub struct VmDriverConfig { pub state_dir: PathBuf, pub launcher_bin: Option, pub default_image: String, - pub ssh_handshake_secret: String, - pub ssh_handshake_skew_secs: u64, pub log_level: String, pub krun_log_level: u32, pub vcpus: u8, @@ -129,8 +127,6 @@ impl Default for VmDriverConfig { state_dir: PathBuf::from("target/openshell-vm-driver"), launcher_bin: None, default_image: String::new(), - ssh_handshake_secret: String::new(), - ssh_handshake_skew_secs: 300, log_level: "info".to_string(), krun_log_level: 1, vcpus: DEFAULT_VCPUS, @@ -2221,10 +2217,6 @@ fn build_guest_environment( "OPENSHELL_LOG_LEVEL".to_string(), sandbox_log_level(sandbox, &config.log_level), ), - ( - "OPENSHELL_SSH_HANDSHAKE_SECRET".to_string(), - config.ssh_handshake_secret.clone(), - ), ]); if config.requires_tls_materials() { environment.extend(HashMap::from([ @@ -2798,7 +2790,6 @@ mod tests { fn build_guest_environment_sets_supervisor_defaults() { let config = VmDriverConfig { openshell_endpoint: "http://127.0.0.1:8080".to_string(), - ssh_handshake_secret: "secret".to_string(), ..Default::default() }; let sandbox = Sandbox { @@ -2818,8 +2809,9 @@ mod tests { "OPENSHELL_SSH_SOCKET_PATH={GUEST_SSH_SOCKET_PATH}" ))); assert!( - env.contains(&"OPENSHELL_SSH_HANDSHAKE_SECRET=secret".to_string()), - "SSH handshake secret must be passed to the guest" + !env.iter() + .any(|e| e.starts_with("OPENSHELL_SSH_HANDSHAKE_SECRET=")), + "SSH handshake secret must not be injected into guest env" ); } @@ -2827,7 +2819,6 @@ mod tests { fn build_guest_environment_uses_endpoint_override_for_tap() { let config = VmDriverConfig { openshell_endpoint: "http://127.0.0.1:8080".to_string(), - ssh_handshake_secret: "secret".to_string(), ..Default::default() }; let sandbox = Sandbox { @@ -3030,7 +3021,6 @@ mod tests { fn build_guest_environment_includes_tls_paths_for_https_endpoint() { let config = VmDriverConfig { openshell_endpoint: "https://127.0.0.1:8443".to_string(), - ssh_handshake_secret: "secret".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")), diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index ed9967f4a..5fbdd4a7a 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -90,12 +90,6 @@ struct Args { )] state_dir: PathBuf, - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] - ssh_handshake_secret: Option, - - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value_t = 300)] - ssh_handshake_skew_secs: u64, - #[arg(long = "guest-tls-ca", env = "OPENSHELL_VM_TLS_CA")] guest_tls_ca: Option, @@ -193,8 +187,6 @@ async fn main() -> Result<()> { state_dir: args.state_dir.clone(), launcher_bin: None, default_image: args.default_image.clone(), - ssh_handshake_secret: args.ssh_handshake_secret.clone().unwrap_or_default(), - ssh_handshake_skew_secs: args.ssh_handshake_skew_secs, log_level: args.log_level.clone(), krun_log_level: args.krun_log_level, vcpus: args.vcpus, diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs index cc35f67b5..e1a8ea5b5 100644 --- a/crates/openshell-sandbox/src/grpc_client.rs +++ b/crates/openshell-sandbox/src/grpc_client.rs @@ -14,7 +14,6 @@ use openshell_core::proto::{ SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; -use tonic::service::interceptor::InterceptedService; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; @@ -84,54 +83,21 @@ pub async fn connect_channel_pub(endpoint: &str) -> Result { connect_channel(endpoint).await } -/// Interceptor that injects the sandbox shared secret into every gRPC request. +/// Connect to the `OpenShell` server. /// -/// The server validates this header on sandbox-to-server RPCs (`GetSandboxConfig`, -/// `GetSandboxProviderEnvironment`, etc.) instead of requiring an OIDC Bearer token. -#[derive(Clone)] -pub struct SandboxSecretInterceptor { - secret: Option>, -} - -impl tonic::service::Interceptor for SandboxSecretInterceptor { - fn call( - &mut self, - mut req: tonic::Request<()>, - ) -> std::result::Result, tonic::Status> { - if let Some(ref val) = self.secret { - req.metadata_mut().insert("x-sandbox-secret", val.clone()); - } - Ok(req) - } -} - -type AuthenticatedClient = OpenShellClient>; -type AuthenticatedInferenceClient = - InferenceClient>; - -fn sandbox_secret_interceptor() -> SandboxSecretInterceptor { - let secret = std::env::var("OPENSHELL_SSH_HANDSHAKE_SECRET") - .ok() - .and_then(|s| s.parse().ok()); - SandboxSecretInterceptor { secret } -} - -/// Connect to the `OpenShell` server with sandbox secret authentication. -async fn connect(endpoint: &str) -> Result { +/// Sandboxes authenticate to the gateway via the mTLS client certificate +/// configured by `connect_channel`. They do not present an OIDC Bearer +/// token; the gateway recognises sandbox-class callers by absence of a +/// Bearer header on the request. +async fn connect(endpoint: &str) -> Result> { let channel = connect_channel(endpoint).await?; - Ok(OpenShellClient::with_interceptor( - channel, - sandbox_secret_interceptor(), - )) + Ok(OpenShellClient::new(channel)) } -/// Connect to the inference service with sandbox secret authentication. -async fn connect_inference(endpoint: &str) -> Result { +/// Connect to the inference service. +async fn connect_inference(endpoint: &str) -> Result> { let channel = connect_channel(endpoint).await?; - Ok(InferenceClient::with_interceptor( - channel, - sandbox_secret_interceptor(), - )) + Ok(InferenceClient::new(channel)) } /// Fetch sandbox policy from `OpenShell` server via gRPC. @@ -151,7 +117,7 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result, sandbox_id: &str, ) -> Result> { let response = client @@ -175,7 +141,7 @@ async fn fetch_policy_with_client( /// Sync a locally-discovered policy using an existing client connection. async fn sync_policy_with_client( - client: &mut AuthenticatedClient, + client: &mut OpenShellClient, sandbox: &str, policy: &ProtoSandboxPolicy, ) -> Result<()> { @@ -269,7 +235,7 @@ pub async fn fetch_provider_environment( /// and status reporting, avoiding per-request TLS handshake overhead. #[derive(Clone)] pub struct CachedOpenShellClient { - client: AuthenticatedClient, + client: OpenShellClient, } /// Settings poll result returned by [`CachedOpenShellClient::poll_settings`]. @@ -299,7 +265,7 @@ impl CachedOpenShellClient { } /// Get a clone of the underlying tonic client for direct RPC calls. - pub fn raw_client(&self) -> AuthenticatedClient { + pub fn raw_client(&self) -> OpenShellClient { self.client.clone() } @@ -393,32 +359,3 @@ pub async fn fetch_inference_bundle(endpoint: &str) -> Result, policy_data: Option, ssh_socket_path: Option, - ssh_handshake_secret: Option, - ssh_handshake_skew_secs: u64, _health_check: bool, _health_port: u16, inference_routes: Option, @@ -618,8 +616,6 @@ pub async fn run_sandbox( if let Some(listen_path) = ssh_socket_path.clone() { let policy_clone = policy.clone(); let workdir_clone = workdir.clone(); - let _ = ssh_handshake_secret; // retained in the signature for compat; unused - let _ = ssh_handshake_skew_secs; let proxy_url = ssh_proxy_url; let netns_fd = ssh_netns_fd; let ca_paths = ca_file_paths.clone(); diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 6ae1bd5fe..5154940ea 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -83,14 +83,6 @@ struct Args { #[arg(long, env = "OPENSHELL_SSH_SOCKET_PATH")] ssh_socket_path: Option, - /// Shared secret for gateway-to-sandbox SSH handshake. - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] - ssh_handshake_secret: Option, - - /// Allowed clock skew for SSH handshake validation. - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value = "300")] - ssh_handshake_skew_secs: u64, - /// Path to YAML inference routes for standalone routing. /// When set, inference routes are loaded from this file instead of /// fetching a bundle from the gateway. @@ -288,8 +280,6 @@ fn main() -> Result<()> { args.policy_rules, args.policy_data, args.ssh_socket_path, - args.ssh_handshake_secret, - args.ssh_handshake_skew_secs, args.health_check, args.health_port, args.inference_routes, diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 0dc513836..66828570a 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -22,18 +22,12 @@ use std::process::Stdio; use tokio::process::{Child, Command}; use tracing::debug; -const SSH_HANDSHAKE_SECRET_ENV: &str = "OPENSHELL_SSH_HANDSHAKE_SECRET"; - fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { for (key, value) in provider_env { cmd.env(key, value); } } -fn scrub_sensitive_env(cmd: &mut Command) { - cmd.env_remove(SSH_HANDSHAKE_SECRET_ENV); -} - #[cfg(unix)] #[allow(unsafe_code, clippy::borrow_as_ptr)] pub fn harden_child_process() -> Result<()> { @@ -161,7 +155,6 @@ impl ProcessHandle { .kill_on_drop(true) .env("OPENSHELL_SANDBOX", "1"); - scrub_sensitive_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { @@ -288,7 +281,6 @@ impl ProcessHandle { .kill_on_drop(true) .env("OPENSHELL_SANDBOX", "1"); - scrub_sensitive_env(&mut cmd); inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { @@ -804,21 +796,6 @@ mod tests { assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); } - #[tokio::test] - async fn scrub_sensitive_env_removes_ssh_handshake_secret() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()) - .env(SSH_HANDSHAKE_SECRET_ENV, "super-secret"); - - scrub_sensitive_env(&mut cmd); - - let output = cmd.output().await.expect("spawn env"); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - assert!(!stdout.contains(SSH_HANDSHAKE_SECRET_ENV)); - } - #[tokio::test] async fn inject_provider_env_sets_placeholder_values() { let mut cmd = Command::new("/usr/bin/env"); diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index d3b74aa81..68f5e923a 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -22,13 +22,14 @@ use tokio::sync::RwLock; use tonic::Status; use tracing::{debug, info, warn}; -/// Internal metadata header set by the auth middleware after it validates -/// a sandbox-secret-authenticated request. This is stripped from all incoming -/// requests first so external callers cannot spoof it. +/// Internal metadata header set by the auth middleware to mark a request as +/// originating from a sandbox. This is stripped from all incoming requests +/// first so external callers cannot spoof it. pub const INTERNAL_AUTH_SOURCE_HEADER: &str = "x-openshell-auth-source"; -/// Internal auth-source marker for requests authenticated via the shared -/// sandbox secret. -pub const AUTH_SOURCE_SANDBOX_SECRET: &str = "sandbox-secret"; +/// Internal auth-source marker for requests originating from a sandbox +/// (no OIDC Bearer; trust derives from the mTLS channel or operator's +/// fronting proxy). +pub const AUTH_SOURCE_SANDBOX: &str = "sandbox"; /// Truly unauthenticated methods — health probes and infrastructure. const UNAUTHENTICATED_METHODS: &[&str] = &[ @@ -39,10 +40,11 @@ const UNAUTHENTICATED_METHODS: &[&str] = &[ /// Path prefixes that bypass OIDC validation (gRPC reflection, health probes). const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; -/// Sandbox-to-server RPCs that use the shared sandbox secret instead of -/// OIDC Bearer tokens. These require the `x-sandbox-secret` metadata header -/// matching the server's SSH handshake secret. -const SANDBOX_SECRET_METHODS: &[&str] = &[ +/// Sandbox-to-server RPCs that are called by sandboxes instead of CLI +/// users. These do not require an OIDC Bearer token; the gRPC channel's +/// mTLS handshake (or the operator's fronting proxy when +/// `--disable-gateway-auth` is set) is the trust boundary. +const SANDBOX_METHODS: &[&str] = &[ "/openshell.v1.OpenShell/ReportPolicyStatus", "/openshell.v1.OpenShell/PushSandboxLogs", "/openshell.v1.OpenShell/GetSandboxProviderEnvironment", @@ -51,17 +53,19 @@ const SANDBOX_SECRET_METHODS: &[&str] = &[ "/openshell.inference.v1.Inference/GetInferenceBundle", ]; -/// Methods that accept either OIDC Bearer token (CLI users) or sandbox -/// secret (supervisor). `UpdateConfig` is called by both CLI -/// (policy/settings mutations) and the sandbox supervisor (policy sync on -/// startup). `OpenShell/GetSandboxConfig` serves CLI settings reads while -/// remaining compatible with sandbox-secret-authenticated callers. +/// Methods that accept either an OIDC Bearer token (CLI users, full scope) +/// or no Bearer (sandbox supervisor, sandbox-restricted scope). +/// `UpdateConfig` is called by both CLI (policy/settings mutations) and the +/// sandbox supervisor (policy sync on startup). +/// `OpenShell/GetSandboxConfig` serves CLI settings reads while remaining +/// compatible with sandbox callers. const DUAL_AUTH_METHODS: &[&str] = &[ "/openshell.v1.OpenShell/UpdateConfig", "/openshell.v1.OpenShell/GetSandboxConfig", ]; -/// Returns `true` if the method accepts either Bearer or sandbox-secret auth. +/// Returns `true` if the method accepts either an OIDC Bearer token or a +/// sandbox-class caller (no Bearer). pub fn is_dual_auth_method(path: &str) -> bool { DUAL_AUTH_METHODS.contains(&path) } @@ -74,28 +78,10 @@ pub fn is_unauthenticated_method(path: &str) -> bool { .any(|prefix| path.starts_with(prefix)) } -/// Returns `true` if the method authenticates via the sandbox shared secret -/// rather than an OIDC Bearer token. -pub fn is_sandbox_secret_method(path: &str) -> bool { - SANDBOX_SECRET_METHODS.contains(&path) -} - -/// Validate the `x-sandbox-secret` header against the server's handshake secret. -#[allow(clippy::result_large_err)] -pub fn validate_sandbox_secret( - headers: &http::HeaderMap, - expected_secret: &str, -) -> Result<(), Status> { - let provided = headers - .get("x-sandbox-secret") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| Status::unauthenticated("sandbox secret required for this method"))?; - - if provided != expected_secret { - return Err(Status::unauthenticated("invalid sandbox secret")); - } - - Ok(()) +/// Returns `true` if the method is an exclusively sandbox-class call (does +/// not accept OIDC Bearer). +pub fn is_sandbox_method(path: &str) -> bool { + SANDBOX_METHODS.contains(&path) } /// Remove internal auth-source markers from the request before any auth @@ -104,20 +90,20 @@ pub fn clear_internal_auth_markers(headers: &mut http::HeaderMap) { headers.remove(INTERNAL_AUTH_SOURCE_HEADER); } -/// Mark the request as authenticated via the shared sandbox secret. -pub fn mark_sandbox_secret_authenticated(headers: &mut http::HeaderMap) { +/// Mark the request as originating from a sandbox caller. +pub fn mark_sandbox_caller(headers: &mut http::HeaderMap) { headers.insert( INTERNAL_AUTH_SOURCE_HEADER, - http::HeaderValue::from_static(AUTH_SOURCE_SANDBOX_SECRET), + http::HeaderValue::from_static(AUTH_SOURCE_SANDBOX), ); } -/// Returns `true` if the request metadata indicates sandbox-secret auth. -pub fn is_sandbox_secret_authenticated(metadata: &tonic::metadata::MetadataMap) -> bool { +/// Returns `true` if the request metadata indicates a sandbox caller. +pub fn is_sandbox_caller(metadata: &tonic::metadata::MetadataMap) -> bool { metadata .get(INTERNAL_AUTH_SOURCE_HEADER) .and_then(|v| v.to_str().ok()) - == Some(AUTH_SOURCE_SANDBOX_SECRET) + == Some(AUTH_SOURCE_SANDBOX) } /// Cached JWKS key set fetched from the OIDC issuer. @@ -443,9 +429,7 @@ mod tests { assert!(!is_unauthenticated_method( "/openshell.v1.OpenShell/CreateSandbox" )); - assert!(!is_sandbox_secret_method( - "/openshell.v1.OpenShell/CreateSandbox" - )); + assert!(!is_sandbox_method("/openshell.v1.OpenShell/CreateSandbox")); } #[test] @@ -464,30 +448,28 @@ mod tests { } #[test] - fn sandbox_rpcs_use_sandbox_secret() { - assert!(is_sandbox_secret_method( + fn sandbox_rpcs_are_sandbox_methods() { + assert!(is_sandbox_method( "/openshell.sandbox.v1.SandboxService/GetSandboxConfig" )); - assert!(is_sandbox_secret_method( + assert!(is_sandbox_method( "/openshell.v1.OpenShell/GetSandboxProviderEnvironment" )); - assert!(is_sandbox_secret_method( + assert!(is_sandbox_method( "/openshell.v1.OpenShell/ReportPolicyStatus" )); - assert!(is_sandbox_secret_method( - "/openshell.v1.OpenShell/PushSandboxLogs" - )); - assert!(is_sandbox_secret_method( + assert!(is_sandbox_method("/openshell.v1.OpenShell/PushSandboxLogs")); + assert!(is_sandbox_method( "/openshell.v1.OpenShell/SubmitPolicyAnalysis" )); - assert!(is_sandbox_secret_method( + assert!(is_sandbox_method( "/openshell.inference.v1.Inference/GetInferenceBundle" )); } #[test] fn openshell_get_sandbox_config_is_dual_auth() { - assert!(!is_sandbox_secret_method( + assert!(!is_sandbox_method( "/openshell.v1.OpenShell/GetSandboxConfig" )); assert!(is_dual_auth_method( @@ -496,17 +478,28 @@ mod tests { } #[test] - fn sandbox_secret_validation() { + fn sandbox_caller_marker_round_trips_through_metadata() { let mut headers = http::HeaderMap::new(); - headers.insert("x-sandbox-secret", "test-secret".parse().unwrap()); - assert!(validate_sandbox_secret(&headers, "test-secret").is_ok()); - assert!(validate_sandbox_secret(&headers, "wrong-secret").is_err()); + mark_sandbox_caller(&mut headers); + let metadata = tonic::metadata::MetadataMap::from_headers(headers); + assert!(is_sandbox_caller(&metadata)); } #[test] - fn sandbox_secret_missing_header() { - let headers = http::HeaderMap::new(); - assert!(validate_sandbox_secret(&headers, "test-secret").is_err()); + fn unmarked_request_is_not_sandbox_caller() { + let metadata = tonic::metadata::MetadataMap::new(); + assert!(!is_sandbox_caller(&metadata)); + } + + #[test] + fn clear_internal_markers_strips_spoofed_header() { + let mut headers = http::HeaderMap::new(); + headers.insert( + INTERNAL_AUTH_SOURCE_HEADER, + http::HeaderValue::from_static(AUTH_SOURCE_SANDBOX), + ); + clear_internal_auth_markers(&mut headers); + assert!(headers.get(INTERNAL_AUTH_SOURCE_HEADER).is_none()); } #[test] diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 534e3da37..f856e5ad1 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -6,10 +6,7 @@ use clap::{Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; -use openshell_core::config::{ - DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SERVER_PORT, DEFAULT_SSH_HANDSHAKE_SKEW_SECS, - DEFAULT_SSH_PORT, -}; +use openshell_core::config::{DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SERVER_PORT, DEFAULT_SSH_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::info; @@ -138,13 +135,6 @@ struct RunArgs { /// SSH port inside sandbox pods. #[arg(long, env = "OPENSHELL_SANDBOX_SSH_PORT", default_value_t = DEFAULT_SSH_PORT)] sandbox_ssh_port: u16, - /// Shared secret for gateway-to-sandbox SSH handshake. - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] - ssh_handshake_secret: Option, - - /// Allowed clock skew in seconds for SSH handshake. - #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value_t = DEFAULT_SSH_HANDSHAKE_SKEW_SECS)] - ssh_handshake_skew_secs: u64, /// Kubernetes secret name containing client TLS materials for sandbox pods. #[arg(long, env = "OPENSHELL_CLIENT_TLS_SECRET_NAME")] @@ -401,8 +391,7 @@ async fn run_from_args(args: RunArgs) -> Result<()> { .with_ssh_gateway_host(args.ssh_gateway_host) .with_ssh_gateway_port(args.ssh_gateway_port) .with_ssh_connect_path(args.ssh_connect_path) - .with_sandbox_ssh_port(args.sandbox_ssh_port) - .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs); + .with_sandbox_ssh_port(args.sandbox_ssh_port); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); @@ -416,10 +405,6 @@ async fn run_from_args(args: RunArgs) -> Result<()> { config = config.with_grpc_endpoint(endpoint); } - if let Some(secret) = args.ssh_handshake_secret { - config = config.with_ssh_handshake_secret(secret); - } - if let Some(name) = args.client_tls_secret_name { config = config.with_client_tls_secret_name(name); } diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs index 1e62d4942..76f3b7325 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-server/src/compute/vm.rs @@ -441,17 +441,6 @@ pub async fn spawn( if !vm_config.default_image.trim().is_empty() { command.arg("--default-image").arg(&vm_config.default_image); } - // Only forward the handshake secret when one is configured. The VM - // driver does not consume it, but accepts it for parity with the - // Kubernetes/Podman drivers; passing an empty value is noise. - if !config.ssh_handshake_secret.is_empty() { - command - .arg("--ssh-handshake-secret") - .arg(&config.ssh_handshake_secret); - } - command - .arg("--ssh-handshake-skew-secs") - .arg(config.ssh_handshake_skew_secs.to_string()); command .arg("--krun-log-level") .arg(vm_config.krun_log_level.to_string()); diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index d5a47bcba..8457b3f01 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -308,31 +308,31 @@ fn truncate_for_log(input: &str, max_chars: usize) -> String { } } -fn is_sandbox_secret_authenticated(request: &Request) -> bool { - oidc::is_sandbox_secret_authenticated(request.metadata()) +fn is_sandbox_caller(request: &Request) -> bool { + oidc::is_sandbox_caller(request.metadata()) } -/// Sandbox-secret-authenticated callers may only perform sandbox-scoped policy -/// sync. They must not be able to mutate global config or sandbox settings. -fn validate_sandbox_secret_update(req: &UpdateConfigRequest) -> Result<(), Status> { +/// Sandbox-class callers may only perform sandbox-scoped policy sync. They +/// must not mutate global config or sandbox settings. +fn validate_sandbox_caller_update(req: &UpdateConfigRequest) -> Result<(), Status> { if req.global { return Err(Status::permission_denied( - "sandbox secret cannot mutate global config", + "sandbox callers cannot mutate global config", )); } if req.delete_setting { return Err(Status::permission_denied( - "sandbox secret cannot delete settings", + "sandbox callers cannot delete settings", )); } if req.name.trim().is_empty() { return Err(Status::permission_denied( - "sandbox secret may only perform sandbox policy sync", + "sandbox callers may only perform sandbox policy sync", )); } if req.policy.is_none() || !req.setting_key.trim().is_empty() { return Err(Status::permission_denied( - "sandbox secret may only perform sandbox policy sync", + "sandbox callers may only perform sandbox policy sync", )); } Ok(()) @@ -645,10 +645,10 @@ pub(super) async fn handle_update_config( state: &Arc, request: Request, ) -> Result, Status> { - let sandbox_secret_auth = is_sandbox_secret_authenticated(&request); + let sandbox_caller = is_sandbox_caller(&request); let req = request.into_inner(); - if sandbox_secret_auth { - validate_sandbox_secret_update(&req)?; + if sandbox_caller { + validate_sandbox_caller_update(&req)?; } let key = req.setting_key.trim(); let has_policy = req.policy.is_some(); @@ -2771,46 +2771,46 @@ mod tests { use tonic::Code; #[test] - fn sandbox_secret_update_validation_allows_sandbox_policy_sync() { + fn sandbox_caller_update_validation_allows_sandbox_policy_sync() { let req = UpdateConfigRequest { name: "sandbox-1".to_string(), policy: Some(ProtoSandboxPolicy::default()), ..Default::default() }; - assert!(validate_sandbox_secret_update(&req).is_ok()); + assert!(validate_sandbox_caller_update(&req).is_ok()); } #[test] - fn sandbox_secret_update_validation_rejects_global_mutation() { + fn sandbox_caller_update_validation_rejects_global_mutation() { let req = UpdateConfigRequest { global: true, policy: Some(ProtoSandboxPolicy::default()), ..Default::default() }; - let err = validate_sandbox_secret_update(&req).unwrap_err(); + let err = validate_sandbox_caller_update(&req).unwrap_err(); assert_eq!(err.code(), Code::PermissionDenied); } #[test] - fn sandbox_secret_update_validation_rejects_setting_mutation() { + fn sandbox_caller_update_validation_rejects_setting_mutation() { let req = UpdateConfigRequest { name: "sandbox-1".to_string(), setting_key: "inference.model".to_string(), setting_value: Some(SettingValue { value: None }), ..Default::default() }; - let err = validate_sandbox_secret_update(&req).unwrap_err(); + let err = validate_sandbox_caller_update(&req).unwrap_err(); assert_eq!(err.code(), Code::PermissionDenied); } #[test] - fn sandbox_secret_marker_detected_from_metadata() { + fn sandbox_caller_marker_detected_from_metadata() { let mut req = Request::new(()); req.metadata_mut().insert( oidc::INTERNAL_AUTH_SOURCE_HEADER, - oidc::AUTH_SOURCE_SANDBOX_SECRET.parse().unwrap(), + oidc::AUTH_SOURCE_SANDBOX.parse().unwrap(), ); - assert!(is_sandbox_secret_authenticated(&req)); + assert!(is_sandbox_caller(&req)); } // ---- Sandbox without policy ---- @@ -3892,9 +3892,7 @@ mod tests { ); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( - Config::new(None) - .with_database_url("sqlite::memory:?cache=shared") - .with_ssh_handshake_secret("test-secret"), + Config::new(None).with_database_url("sqlite::memory:?cache=shared"), store, compute, SandboxIndex::new(), diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 2ed4d439d..c27d97c32 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -844,9 +844,7 @@ mod tests { ); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( - Config::new(None) - .with_database_url("sqlite::memory:?cache=shared") - .with_ssh_handshake_secret("test-secret"), + Config::new(None).with_database_url("sqlite::memory:?cache=shared"), store, compute, SandboxIndex::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 65ac69acb..17d0f7157 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1256,9 +1256,7 @@ mod tests { let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); let compute = new_test_runtime(store.clone()).await; Arc::new(ServerState::new( - Config::new(None) - .with_database_url("sqlite::memory:") - .with_ssh_handshake_secret("test-secret"), + Config::new(None).with_database_url("sqlite::memory:"), store, compute, SandboxIndex::new(), diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index eaca911e4..729011729 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -157,15 +157,6 @@ pub async fn run_server( if database_url.is_empty() { return Err(Error::config("database_url is required")); } - let driver = configured_compute_driver(&config)?; - if config.ssh_handshake_secret.is_empty() - && !matches!(driver, ComputeDriverKind::Docker | ComputeDriverKind::Vm) - { - return Err(Error::config( - "ssh_handshake_secret is required. Set --ssh-handshake-secret or OPENSHELL_SSH_HANDSHAKE_SECRET", - )); - } - let store = Arc::new(Store::connect(database_url).await?); let oidc_cache = if let Some(ref oidc) = config.oidc { @@ -489,8 +480,6 @@ async fn build_compute_runtime( supervisor_image_pull_policy, grpc_endpoint: config.grpc_endpoint.clone(), ssh_socket_path: config.sandbox_ssh_socket_path.clone(), - ssh_handshake_secret: config.ssh_handshake_secret.clone(), - ssh_handshake_skew_secs: config.ssh_handshake_skew_secs, client_tls_secret_name: config.client_tls_secret_name.clone(), host_gateway_ip: config.host_gateway_ip.clone(), enable_user_namespaces: config.enable_user_namespaces, @@ -579,8 +568,6 @@ async fn build_compute_runtime( sandbox_ssh_socket_path: config.sandbox_ssh_socket_path.clone(), network_name, ssh_port: config.sandbox_ssh_port, - ssh_handshake_secret: config.ssh_handshake_secret.clone(), - ssh_handshake_skew_secs: config.ssh_handshake_skew_secs, stop_timeout_secs, supervisor_image, guest_tls_ca: podman_tls_ca, diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 93e58d202..9313d21c6 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -144,7 +144,6 @@ impl MultiplexService { GrpcRouter::new(openshell, inference), self.state.oidc_cache.clone(), authz_policy, - self.state.config.ssh_handshake_secret.clone(), ); let http_service = http_router(self.state.clone()); @@ -233,13 +232,17 @@ where /// Authentication is provider-specific (currently OIDC via `oidc.rs`). /// Authorization is provider-agnostic (via `authz.rs`). This separation /// aligns with RFC 0001's control-plane identity design. +/// +/// Sandbox-class methods (`oidc::is_sandbox_method`) accept callers without +/// a Bearer token: the gRPC channel's mTLS handshake is the trust +/// boundary. The router marks such requests with the +/// `INTERNAL_AUTH_SOURCE_HEADER` so handlers (`policy.rs`) can apply +/// sandbox-restricted scope. #[derive(Clone)] pub struct AuthGrpcRouter { inner: S, oidc_cache: Option>, authz_policy: Option, - /// SSH handshake secret used to validate sandbox-to-server RPCs. - sandbox_secret: String, } impl AuthGrpcRouter { @@ -247,13 +250,11 @@ impl AuthGrpcRouter { inner: S, oidc_cache: Option>, authz_policy: Option, - sandbox_secret: String, ) -> Self { Self { inner, oidc_cache, authz_policy, - sandbox_secret, } } } @@ -279,7 +280,6 @@ where fn call(&mut self, req: Request) -> Self::Future { let oidc_cache = self.oidc_cache.clone(); let authz_policy = self.authz_policy.clone(); - let sandbox_secret = self.sandbox_secret.clone(); let mut inner = self.inner.clone(); Box::pin(async move { @@ -298,28 +298,21 @@ where return inner.ready().await?.call(req).await; } - // Sandbox-to-server RPCs — authenticated via shared secret, - // not OIDC Bearer tokens. - if oidc::is_sandbox_secret_method(&path) { - if let Err(status) = oidc::validate_sandbox_secret(req.headers(), &sandbox_secret) { - let response = status.into_http(); - let (parts, body) = response.into_parts(); - let body = tonic::body::BoxBody::new(body); - return Ok(Response::from_parts(parts, body)); - } - oidc::mark_sandbox_secret_authenticated(req.headers_mut()); + // Sandbox-class RPCs — no Bearer expected. The gRPC channel's + // mTLS handshake (or the operator's fronting proxy when + // `--disable-gateway-auth` is set) is the trust boundary. + if oidc::is_sandbox_method(&path) { + oidc::mark_sandbox_caller(req.headers_mut()); return inner.ready().await?.call(req).await; } - // Dual-auth methods (e.g. UpdateConfig) — accept either a - // Bearer token (CLI users) or sandbox secret (supervisor). - if oidc::is_dual_auth_method(&path) - && oidc::validate_sandbox_secret(req.headers(), &sandbox_secret).is_ok() - { - oidc::mark_sandbox_secret_authenticated(req.headers_mut()); + // Dual-auth methods (e.g. UpdateConfig) — Bearer present grants + // full scope (CLI users); Bearer absent marks the caller as + // sandbox-class for restricted scope downstream. + if oidc::is_dual_auth_method(&path) && !has_bearer_token(req.headers()) { + oidc::mark_sandbox_caller(req.headers_mut()); return inner.ready().await?.call(req).await; } - // Fall through to Bearer token validation below. // Extract Bearer token from the authorization header. let token = req @@ -457,6 +450,13 @@ where } } +fn has_bearer_token(headers: &http::HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("Bearer ")) +} + fn grpc_method_from_path(path: &str) -> String { path.rsplit('/').next().unwrap_or(path).to_string() } diff --git a/deploy/helm/openshell/templates/ssh-handshake-secret-hook.yaml b/deploy/helm/openshell/templates/ssh-handshake-secret-hook.yaml deleted file mode 100644 index ad444847b..000000000 --- a/deploy/helm/openshell/templates/ssh-handshake-secret-hook.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -{{- if .Values.sshHandshake.hook.enabled }} -{{- $name := .Values.server.sshHandshakeSecretName }} -{{- $ns := .Release.Namespace }} -{{- $existing := lookup "v1" "Secret" $ns $name }} -{{- if not $existing }} -{{- $hex := .Values.sshHandshake.value }} -{{- if not $hex }} -{{- $hex = printf "%s%s" (uuidv4 | replace "-" "") (uuidv4 | replace "-" "") }} -{{- end }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ $name }} - namespace: {{ $ns }} - labels: - {{- include "openshell.labels" . | nindent 4 }} - annotations: - helm.sh/hook: pre-install,pre-upgrade - helm.sh/hook-weight: "-20" -type: Opaque -stringData: - secret: {{ $hex | quote }} -{{- end }} -{{- end }} diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 2d3f731af..0b4e2e4f4 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -94,11 +94,6 @@ spec: - name: OPENSHELL_ENABLE_USER_NAMESPACES value: "true" {{- end }} - - name: OPENSHELL_SSH_HANDSHAKE_SECRET - valueFrom: - secretKeyRef: - name: {{ .Values.server.sshHandshakeSecretName | quote }} - key: secret {{- if .Values.server.disableTls }} - name: OPENSHELL_DISABLE_TLS value: "true" diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 7630554f2..2db93cda2 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -99,10 +99,6 @@ server: sshGatewayPort: 0 # TLS configuration for the server. The server always terminates mTLS # directly and requires client certificates. - # Name of the Kubernetes Secret holding the NSSH1 HMAC handshake key. - # The secret must contain a `secret` key with the hex-encoded HMAC key. - # By default a pre-install/pre-upgrade hook creates it when missing (see sshHandshake). - sshHandshakeSecretName: "openshell-ssh-handshake" # Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get # hostAliases entries mapping host.docker.internal and host.openshell.internal # to this IP, allowing them to reach services running on the Docker host. @@ -153,16 +149,6 @@ server: networkPolicy: enabled: true -# NSSH1 SSH gateway handshake Secret (`server.sshHandshakeSecretName`). -# Helm hook creates it only when the Secret does not already exist (safe upgrades). -# Set sshHandshake.value from a gitignored values file for a stable dev secret. -sshHandshake: - hook: - enabled: true - # 64 hex chars (32 bytes), matching openshell-bootstrap. If empty, Helm generates - # a random value at install template time (two UUIDs, dashes stripped). - value: "" - # PKI bootstrap via a pre-install/pre-upgrade hook Job. # Runs `openshell-gateway generate-certs` to create the server and client TLS # Secrets in-cluster. Key material is written directly to K8s Secrets and diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 5e3c4eef2..f748aa8ec 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -97,14 +97,6 @@ gRPC and HTTP, secured by mutual TLS (mTLS) by default. : Image pull policy: Always, IfNotPresent, Never. Environment: **OPENSHELL_SANDBOX_IMAGE_PULL_POLICY**. -**--ssh-handshake-secret** *SECRET* -: Shared secret for gateway-to-sandbox SSH handshake. - Environment: **OPENSHELL_SSH_HANDSHAKE_SECRET**. - -**--ssh-handshake-skew-secs** *SECONDS* -: Allowed clock skew in seconds for SSH handshake. Default: **30**. - Environment: **OPENSHELL_SSH_HANDSHAKE_SKEW_SECS**. - **--ssh-gateway-host** *HOST* : Public host for the SSH gateway endpoint. Default: **127.0.0.1**. Environment: **OPENSHELL_SSH_GATEWAY_HOST**. diff --git a/deploy/man/openshell-gateway.env.5.md b/deploy/man/openshell-gateway.env.5.md index a4e715edd..1c73f7443 100644 --- a/deploy/man/openshell-gateway.env.5.md +++ b/deploy/man/openshell-gateway.env.5.md @@ -33,18 +33,10 @@ The systemd user unit reads it via: EnvironmentFile=-~/.config/openshell/gateway.env The **-** prefix means the service starts normally if the file does not -exist (the unit has built-in defaults for all required settings except -the SSH handshake secret). +exist (the unit has built-in defaults for all required settings). # VARIABLES -## Required - -**OPENSHELL_SSH_HANDSHAKE_SECRET** -: Shared HMAC secret for gateway-to-sandbox SSH handshake - authentication. Auto-generated as a 32-byte hex string on first - start. To regenerate: **openssl rand -hex 32**. - ## Gateway **OPENSHELL_BIND_ADDRESS** (default: 0.0.0.0) diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index 04caaaae5..de2e1e694 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -160,7 +160,6 @@ across package upgrades. | `OPENSHELL_LOG_LEVEL` | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error` | | `OPENSHELL_DRIVERS` | `podman` | Compute driver (`podman`, `docker`, `kubernetes`) | | `OPENSHELL_DB_URL` | `sqlite://$XDG_STATE_HOME/openshell/gateway.db` | SQLite database URL for state persistence | -| `OPENSHELL_SSH_HANDSHAKE_SECRET` | (auto-generated) | Shared secret for sandbox SSH authentication | | `OPENSHELL_DISABLE_GATEWAY_AUTH` | (unset) | Set to `true` to skip mTLS client certificate checks | ### TLS settings diff --git a/deploy/rpm/init-gateway-env.sh b/deploy/rpm/init-gateway-env.sh index 299a19041..c93be3161 100644 --- a/deploy/rpm/init-gateway-env.sh +++ b/deploy/rpm/init-gateway-env.sh @@ -11,8 +11,8 @@ # Usage: # init-gateway-env.sh # -# The generated file contains an auto-generated SSH handshake secret -# and commented defaults for all gateway environment variables. +# The generated file contains commented defaults for gateway +# environment variables. set -euo pipefail @@ -26,9 +26,6 @@ fi # ── Create parent directory ───────────────────────────────────────── mkdir -p "$(dirname "${ENV_FILE}")" -# ── Generate SSH handshake secret ─────────────────────────────────── -SECRET=$(od -An -tx1 -N32 /dev/urandom | tr -dc 0-9a-f) - # ── Write environment file ────────────────────────────────────────── cat > "${ENV_FILE}" << EOF # OpenShell Gateway Environment Configuration @@ -37,13 +34,6 @@ cat > "${ENV_FILE}" << EOF # Run 'openshell-gateway --help' for the full list of options. # See /usr/share/doc/openshell-gateway/ for guides. -# ---- Required ---- - -# Shared secret for gateway-to-sandbox SSH handshake authentication. -# Auto-generated on first start. To regenerate: -# openssl rand -hex 32 -OPENSHELL_SSH_HANDSHAKE_SECRET=${SECRET} - # ---- Optional (uncomment to override defaults) ---- # Database URL for gateway state persistence. diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index e95ce854b..47de42129 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -123,7 +123,7 @@ The connection flow: 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. When OIDC is enabled, sandbox supervisors authenticate their internal RPCs with the shared sandbox secret so log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync continue to work. +Supervisor-to-gateway RPCs do not use user OIDC tokens. The gRPC channel's mTLS handshake is the trust boundary: the gateway treats requests with a verified client cert and no `Authorization: Bearer` header as sandbox-class callers. Log upload, policy status, credential environment lookup, inference bundle lookup, and sandbox config sync run with sandbox-restricted scope, while CLI users present a Bearer token for full-scope access. Operators using `--disable-gateway-auth` (TLS terminated at a fronting proxy) must enforce caller authentication at the proxy, since the gateway does not see a verified client cert in that mode. Re-authenticate an OIDC gateway with: