From ec20be05bcfe04591dcddaca5a80ca0c28088e70 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Fri, 25 Sep 2026 16:32:50 -0700 Subject: [PATCH] fix(gateway)!: make the WebSocket tunnel opt-in The /_ws_tunnel endpoint pipes a WebSocket into the full gRPC service. On a plaintext loopback gateway any web page could open it, since browsers do not apply CORS to WebSocket upgrades. Mount the tunnel only when enable_websocket_tunnel is set (config file, --enable-websocket-tunnel, or OPENSHELL_ENABLE_WEBSOCKET_TUNNEL; server.enableWebsocketTunnel in Helm). BREAKING CHANGE: gateways behind an authenticating edge proxy must set enable_websocket_tunnel = true for CLI edge-tunnel connections. Signed-off-by: Drew Newberry --- architecture/gateway.md | 3 +- crates/openshell-core/src/config.rs | 12 ++++++ crates/openshell-server/src/cli.rs | 41 ++++++++++++++++++- crates/openshell-server/src/config_file.rs | 3 ++ crates/openshell-server/src/http.rs | 14 ++++--- crates/openshell-server/src/lib.rs | 24 +++++++++++ deploy/helm/openshell/README.md | 1 + .../openshell/templates/gateway-config.yaml | 1 + .../openshell/tests/gateway_config_test.yaml | 16 ++++++++ deploy/helm/openshell/values.yaml | 3 ++ docs/how-it-works/gateways/authentication.mdx | 2 +- docs/how-it-works/gateways/configuration.mdx | 2 + e2e/with-docker-gateway.sh | 2 + e2e/with-podman-gateway.sh | 2 + skills/debug-openshell-cluster/SKILL.md | 4 +- 15 files changed, 120 insertions(+), 10 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 45cb5efd70..a301fae123 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -9,7 +9,8 @@ attachments; and asks compute runtimes to create or delete sandbox workloads. - Authenticate clients and sandbox supervisor sessions. - Serve gRPC APIs for sandbox lifecycle, provider management, policy updates, settings, logs, watch streams, and relay forwarding. -- Serve HTTP endpoints for health, WebSocket tunnels, and edge-auth flows. +- Serve HTTP endpoints for health and edge-auth flows, plus the opt-in + WebSocket tunnel for edge-proxy deployments. - Persist domain objects in SQLite or Postgres. - Resolve endpoint-bound provider environments for sandbox supervisors. - Coordinate supervisor relay sessions for connect, exec, file sync, and diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 0d4a9f4325..c85fc4308a 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -194,6 +194,10 @@ pub struct Config { /// Gateway user authentication behavior. pub auth: GatewayAuthConfig, + /// Allow the WebSocket tunnel used by authenticated edge proxies. + /// Disabled for local gateways by default. + pub enable_websocket_tunnel: bool, + /// Disabled-by-default gateway interceptor service configs. pub gateway_interceptors: Vec, @@ -850,6 +854,7 @@ impl Config { tls, oidc: None, auth: GatewayAuthConfig::default(), + enable_websocket_tunnel: false, gateway_interceptors: Vec::new(), provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User], mtls_auth: MtlsAuthConfig::default(), @@ -1022,6 +1027,13 @@ impl Config { self.service_routing.enable_loopback_service_http = enabled; self } + + /// Enable the WebSocket tunnel for an authenticated edge proxy. + #[must_use] + pub const fn with_websocket_tunnel(mut self, enabled: bool) -> Self { + self.enable_websocket_tunnel = enabled; + self + } } impl Default for ServiceRoutingConfig { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index ca7396f2bc..f531d88c5d 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -257,6 +257,15 @@ struct RunArgs { action = ArgAction::Set )] enable_loopback_service_http: bool, + + /// Enable the WebSocket tunnel for an authenticated edge proxy. + #[arg( + long, + env = "OPENSHELL_ENABLE_WEBSOCKET_TUNNEL", + default_value_t = false, + action = ArgAction::Set + )] + enable_websocket_tunnel: bool, } pub fn command() -> Command { @@ -508,7 +517,8 @@ fn prepare_server_config_with_drivers( .unwrap_or_default(), ) .with_server_sans(args.server_sans.clone()) - .with_loopback_service_http(args.enable_loopback_service_http); + .with_loopback_service_http(args.enable_loopback_service_http) + .with_websocket_tunnel(args.enable_websocket_tunnel); if let Some(sources) = file .as_ref() .and_then(|file| file.openshell.gateway.provider_profile_sources.clone()) @@ -1089,6 +1099,11 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: { args.enable_loopback_service_http = enabled; } + if let Some(enabled) = file.enable_websocket_tunnel + && arg_defaulted(matches, "enable_websocket_tunnel") + { + args.enable_websocket_tunnel = enabled; + } if let Some(mtls_auth) = &file.mtls_auth && arg_defaulted(matches, "enable_mtls_auth") { @@ -1440,6 +1455,30 @@ mod tests { assert!(cli.run.enable_loopback_service_http); } + #[test] + fn websocket_tunnel_is_disabled_by_default_and_can_be_enabled_from_file() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL"); + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + assert!(!args.enable_websocket_tunnel); + + let file = config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n"); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(args.enable_websocket_tunnel); + + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--enable-websocket-tunnel=false", + ]); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(!args.enable_websocket_tunnel, "CLI flag must override file"); + } + #[test] fn command_disables_loopback_service_http_with_false_value() { let _lock = ENV_LOCK diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 6cde6aa461..87180766ab 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -132,6 +132,9 @@ pub struct GatewayFileSection { /// Enable plaintext HTTP routing for loopback sandbox service URLs. #[serde(default)] pub enable_loopback_service_http: Option, + /// Enable the WebSocket tunnel for an authenticated edge proxy. + #[serde(default)] + pub enable_websocket_tunnel: Option, // ── Sandbox client TLS ─────────────────────────────────────────────── #[serde(default)] diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 63b59edf04..cf37aa18b9 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -179,12 +179,14 @@ async fn render_metrics(State(handle): State) -> impl IntoResp /// Create the HTTP router served on the multiplexed gateway port. pub fn http_router(state: Arc) -> Router { - crate::ws_tunnel::router(state.clone()) - .merge(crate::auth::router(state.clone())) - .layer(middleware::from_fn_with_state( - state, - sandbox_service_routing_first, - )) + let mut router = crate::auth::router(state.clone()); + if state.config.enable_websocket_tunnel { + router = router.merge(crate::ws_tunnel::router(state.clone())); + } + router.layer(middleware::from_fn_with_state( + state, + sandbox_service_routing_first, + )) } /// Create the plaintext loopback-only router for browser service endpoints. diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a51e17a9ad..ea1f74c0f4 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1913,6 +1913,9 @@ mod tests { use tokio::sync::watch; use crate::tls_test_utils::generate_test_certs_with_ca; + use axum::body::Body; + use http::{Request, StatusCode}; + use tower::ServiceExt; fn tls_enabled_config() -> Config { Config::new(Some(openshell_core::TlsConfig { @@ -2174,6 +2177,27 @@ mod tests { )) } + #[tokio::test] + async fn websocket_tunnel_is_mounted_only_when_enabled() { + let state = test_state("127.0.0.1:17670".parse().unwrap(), true).await; + let response = super::http_router(state.clone()) + .oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let mut enabled = state; + Arc::get_mut(&mut enabled) + .unwrap() + .config + .enable_websocket_tunnel = true; + let response = super::http_router(enabled) + .oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_ne!(response.status(), StatusCode::NOT_FOUND); + } + async fn start_tls_gateway_listener( bind_addr: &str, enable_loopback_service_http: bool, diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 4ab3517b27..4d09c32de8 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -306,6 +306,7 @@ discovery endpoint or its TLS CA. | server.drivers.kubernetes.workspaceMode | string | `"shared"` | How workspaces map to Kubernetes namespaces. "shared" (default): all sandboxes in a single namespace. "managed": auto-creates per-workspace namespaces. "operator": uses pre-provisioned namespaces. | | server.enableLoopbackServiceHttp | bool | `true` | Enable plaintext HTTP routing for loopback sandbox service URLs on TLS-enabled gateways. | | server.enableUserNamespaces | bool | `false` | Enable Kubernetes user namespace isolation (hostUsers: false) for sandbox pods. Requires Kubernetes 1.33+ with user namespace support available (beta through 1.35, GA in 1.36+), plus a supporting container runtime and Linux 5.12+. When enabled, container UID 0 maps to an unprivileged host UID and capabilities become namespaced. | +| server.enableWebsocketTunnel | bool | `false` | Enable the WebSocket tunnel used by CLI/SDK clients behind an authenticated edge proxy. Leave disabled for direct gateway installs. | | server.externalDbSecret | string | `""` | Name of a pre-existing Opaque Secret containing a PostgreSQL connection URI (key: uri). When set, the gateway reads OPENSHELL_DB_URL from this Secret instead of using dbUrl. The Secret must contain a `uri` key, e.g. postgresql://user:pass@host:5432/dbname. | | server.grpcEndpoint | string | `""` | gRPC endpoint sandboxes call back into the gateway. Leave empty to derive it from the chart fullname, release namespace, service port, and disableTls flag, for example https://openshell.openshell.svc.cluster.local:8080. Override only when sandboxes must reach the gateway via a different hostname (e.g. an external ingress or a host alias). | | server.grpcRateLimit.requests | int | `0` | Maximum gRPC requests allowed per window. Must be positive (alongside windowSeconds) to enable rate limiting; 0 (default) disables it. | diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index b1ec38bd0a..0449871f7d 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -57,6 +57,7 @@ data: disable_tls = true {{- end }} enable_loopback_service_http = {{ .Values.server.enableLoopbackServiceHttp }} + enable_websocket_tunnel = {{ .Values.server.enableWebsocketTunnel }} {{- $sans := list -}} {{- if and .Values.certManager.enabled .Values.certManager.serverDnsNames }} {{- $sans = .Values.certManager.serverDnsNames }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 83fbb7aa08..eacfdfa2c4 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -13,6 +13,22 @@ release: namespace: my-namespace tests: + - it: disables the WebSocket tunnel unless explicitly enabled for an edge proxy + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^enable_websocket_tunnel\s*=\s*false$' + + - it: enables the WebSocket tunnel when requested + template: templates/gateway-config.yaml + set: + server.enableWebsocketTunnel: true + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^enable_websocket_tunnel\s*=\s*true$' + - it: defaults to label admission with caller driver config disabled template: templates/gateway-config.yaml asserts: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index b48a7c269f..426f3ae424 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -328,6 +328,9 @@ server: # -- Enable plaintext HTTP routing for loopback sandbox service URLs on # TLS-enabled gateways. enableLoopbackServiceHttp: true + # -- Enable the WebSocket tunnel used by CLI/SDK clients behind an + # authenticated edge proxy. Leave disabled for direct gateway installs. + enableWebsocketTunnel: false # -- Posture when a candidate sandbox policy fails validation. `fail_closed` # deactivates the previous policy; `retain_last_valid` keeps it active. policyValidationFailureMode: fail_closed diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index a05cf7f678..7abb989276 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -246,7 +246,7 @@ a workspace. For membership commands, refer to ### Edge JWT (cloud gateways) -For gateways behind a reverse proxy that handles authentication (e.g. Cloudflare Access), the CLI uses a browser-based login flow and routes traffic through a WebSocket tunnel. +For gateways behind a reverse proxy that handles authentication (e.g. Cloudflare Access), the CLI uses a browser-based login flow and routes traffic through a WebSocket tunnel. The tunnel is disabled by default; enable it with `enable_websocket_tunnel = true` under `[openshell.gateway]` (Helm: `server.enableWebsocketTunnel=true`). **Registration flow** (`openshell gateway add https://gateway.example.com`): diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index c1963c3253..fc9d5aa8c2 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -149,6 +149,8 @@ policy_validation_failure_mode = "fail_closed" server_sans = ["openshell", "*.dev.openshell.localhost"] # Allow plaintext HTTP routing for loopback sandbox service URLs. enable_loopback_service_http = true +# Serve the WebSocket tunnel for CLI clients behind an authenticating edge proxy. +enable_websocket_tunnel = false # Set true only for local plaintext gateways or trusted TLS termination. disable_tls = false diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 9d1197a21d..4b96693ab1 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -662,6 +662,8 @@ GATEWAY_ARGS=( --compute-driver docker --tls-cert "${PKI_DIR}/server/tls.crt" --tls-key "${PKI_DIR}/server/tls.key" + # edge_tunnel_e2e exercises the opt-in WebSocket tunnel. + --enable-websocket-tunnel true --db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc" ) diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index 3b3c5cf46c..7350dd07e5 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -856,6 +856,8 @@ GATEWAY_ARGS=( --health-port "${HEALTH_PORT}" --tls-cert "${PKI_DIR}/server/tls.crt" --tls-key "${PKI_DIR}/server/tls.key" + # edge_tunnel_e2e exercises the opt-in WebSocket tunnel. + --enable-websocket-tunnel true --db-url "sqlite:${STATE_DIR}/gateway.db?mode=rwc" --log-level info ) diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 8e865f13ec..d654d38094 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -123,7 +123,9 @@ Gateway configuration requires `[openshell] version = 2`, a singular `--drivers` selectors rather than silently migrating them. One valid, nonempty `OPENSHELL_DRIVERS` value remains a deprecated environment-only alias when the canonical selector is absent; the gateway selects that driver with a warning. -Empty, invalid, comma-delimited, or conflicting values fail startup. Homebrew +Empty, invalid, comma-delimited, or conflicting values fail startup. The +WebSocket tunnel for edge-proxy CLI access is off unless +`enable_websocket_tunnel = true` (`server.enableWebsocketTunnel` in Helm). Homebrew 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.