Skip to content

Commit ec20be0

Browse files
committed
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 <anewberry@nvidia.com>
1 parent 496ebba commit ec20be0

15 files changed

Lines changed: 120 additions & 10 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ attachments; and asks compute runtimes to create or delete sandbox workloads.
99
- Authenticate clients and sandbox supervisor sessions.
1010
- Serve gRPC APIs for sandbox lifecycle, provider management, policy updates,
1111
settings, logs, watch streams, and relay forwarding.
12-
- Serve HTTP endpoints for health, WebSocket tunnels, and edge-auth flows.
12+
- Serve HTTP endpoints for health and edge-auth flows, plus the opt-in
13+
WebSocket tunnel for edge-proxy deployments.
1314
- Persist domain objects in SQLite or Postgres.
1415
- Resolve endpoint-bound provider environments for sandbox supervisors.
1516
- Coordinate supervisor relay sessions for connect, exec, file sync, and

‎crates/openshell-core/src/config.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ pub struct Config {
194194
/// Gateway user authentication behavior.
195195
pub auth: GatewayAuthConfig,
196196

197+
/// Allow the WebSocket tunnel used by authenticated edge proxies.
198+
/// Disabled for local gateways by default.
199+
pub enable_websocket_tunnel: bool,
200+
197201
/// Disabled-by-default gateway interceptor service configs.
198202
pub gateway_interceptors: Vec<GatewayInterceptorConfig>,
199203

@@ -850,6 +854,7 @@ impl Config {
850854
tls,
851855
oidc: None,
852856
auth: GatewayAuthConfig::default(),
857+
enable_websocket_tunnel: false,
853858
gateway_interceptors: Vec::new(),
854859
provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User],
855860
mtls_auth: MtlsAuthConfig::default(),
@@ -1022,6 +1027,13 @@ impl Config {
10221027
self.service_routing.enable_loopback_service_http = enabled;
10231028
self
10241029
}
1030+
1031+
/// Enable the WebSocket tunnel for an authenticated edge proxy.
1032+
#[must_use]
1033+
pub const fn with_websocket_tunnel(mut self, enabled: bool) -> Self {
1034+
self.enable_websocket_tunnel = enabled;
1035+
self
1036+
}
10251037
}
10261038

10271039
impl Default for ServiceRoutingConfig {

‎crates/openshell-server/src/cli.rs‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ struct RunArgs {
257257
action = ArgAction::Set
258258
)]
259259
enable_loopback_service_http: bool,
260+
261+
/// Enable the WebSocket tunnel for an authenticated edge proxy.
262+
#[arg(
263+
long,
264+
env = "OPENSHELL_ENABLE_WEBSOCKET_TUNNEL",
265+
default_value_t = false,
266+
action = ArgAction::Set
267+
)]
268+
enable_websocket_tunnel: bool,
260269
}
261270

262271
pub fn command() -> Command {
@@ -508,7 +517,8 @@ fn prepare_server_config_with_drivers(
508517
.unwrap_or_default(),
509518
)
510519
.with_server_sans(args.server_sans.clone())
511-
.with_loopback_service_http(args.enable_loopback_service_http);
520+
.with_loopback_service_http(args.enable_loopback_service_http)
521+
.with_websocket_tunnel(args.enable_websocket_tunnel);
512522
if let Some(sources) = file
513523
.as_ref()
514524
.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:
10891099
{
10901100
args.enable_loopback_service_http = enabled;
10911101
}
1102+
if let Some(enabled) = file.enable_websocket_tunnel
1103+
&& arg_defaulted(matches, "enable_websocket_tunnel")
1104+
{
1105+
args.enable_websocket_tunnel = enabled;
1106+
}
10921107
if let Some(mtls_auth) = &file.mtls_auth
10931108
&& arg_defaulted(matches, "enable_mtls_auth")
10941109
{
@@ -1440,6 +1455,30 @@ mod tests {
14401455
assert!(cli.run.enable_loopback_service_http);
14411456
}
14421457

1458+
#[test]
1459+
fn websocket_tunnel_is_disabled_by_default_and_can_be_enabled_from_file() {
1460+
let _lock = ENV_LOCK
1461+
.lock()
1462+
.unwrap_or_else(std::sync::PoisonError::into_inner);
1463+
let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL");
1464+
let (mut args, matches) =
1465+
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
1466+
assert!(!args.enable_websocket_tunnel);
1467+
1468+
let file = config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n");
1469+
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
1470+
assert!(args.enable_websocket_tunnel);
1471+
1472+
let (mut args, matches) = parse_with_args(&[
1473+
"openshell-gateway",
1474+
"--db-url",
1475+
"sqlite::memory:",
1476+
"--enable-websocket-tunnel=false",
1477+
]);
1478+
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
1479+
assert!(!args.enable_websocket_tunnel, "CLI flag must override file");
1480+
}
1481+
14431482
#[test]
14441483
fn command_disables_loopback_service_http_with_false_value() {
14451484
let _lock = ENV_LOCK

‎crates/openshell-server/src/config_file.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ pub struct GatewayFileSection {
132132
/// Enable plaintext HTTP routing for loopback sandbox service URLs.
133133
#[serde(default)]
134134
pub enable_loopback_service_http: Option<bool>,
135+
/// Enable the WebSocket tunnel for an authenticated edge proxy.
136+
#[serde(default)]
137+
pub enable_websocket_tunnel: Option<bool>,
135138

136139
// ── Sandbox client TLS ───────────────────────────────────────────────
137140
#[serde(default)]

‎crates/openshell-server/src/http.rs‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,14 @@ async fn render_metrics(State(handle): State<PrometheusHandle>) -> impl IntoResp
179179

180180
/// Create the HTTP router served on the multiplexed gateway port.
181181
pub fn http_router(state: Arc<crate::ServerState>) -> Router {
182-
crate::ws_tunnel::router(state.clone())
183-
.merge(crate::auth::router(state.clone()))
184-
.layer(middleware::from_fn_with_state(
185-
state,
186-
sandbox_service_routing_first,
187-
))
182+
let mut router = crate::auth::router(state.clone());
183+
if state.config.enable_websocket_tunnel {
184+
router = router.merge(crate::ws_tunnel::router(state.clone()));
185+
}
186+
router.layer(middleware::from_fn_with_state(
187+
state,
188+
sandbox_service_routing_first,
189+
))
188190
}
189191

190192
/// Create the plaintext loopback-only router for browser service endpoints.

‎crates/openshell-server/src/lib.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,9 @@ mod tests {
19131913
use tokio::sync::watch;
19141914

19151915
use crate::tls_test_utils::generate_test_certs_with_ca;
1916+
use axum::body::Body;
1917+
use http::{Request, StatusCode};
1918+
use tower::ServiceExt;
19161919

19171920
fn tls_enabled_config() -> Config {
19181921
Config::new(Some(openshell_core::TlsConfig {
@@ -2174,6 +2177,27 @@ mod tests {
21742177
))
21752178
}
21762179

2180+
#[tokio::test]
2181+
async fn websocket_tunnel_is_mounted_only_when_enabled() {
2182+
let state = test_state("127.0.0.1:17670".parse().unwrap(), true).await;
2183+
let response = super::http_router(state.clone())
2184+
.oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap())
2185+
.await
2186+
.unwrap();
2187+
assert_eq!(response.status(), StatusCode::NOT_FOUND);
2188+
2189+
let mut enabled = state;
2190+
Arc::get_mut(&mut enabled)
2191+
.unwrap()
2192+
.config
2193+
.enable_websocket_tunnel = true;
2194+
let response = super::http_router(enabled)
2195+
.oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap())
2196+
.await
2197+
.unwrap();
2198+
assert_ne!(response.status(), StatusCode::NOT_FOUND);
2199+
}
2200+
21772201
async fn start_tls_gateway_listener(
21782202
bind_addr: &str,
21792203
enable_loopback_service_http: bool,

‎deploy/helm/openshell/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ discovery endpoint or its TLS CA.
306306
| 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. |
307307
| server.enableLoopbackServiceHttp | bool | `true` | Enable plaintext HTTP routing for loopback sandbox service URLs on TLS-enabled gateways. |
308308
| 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. |
309+
| server.enableWebsocketTunnel | bool | `false` | Enable the WebSocket tunnel used by CLI/SDK clients behind an authenticated edge proxy. Leave disabled for direct gateway installs. |
309310
| 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. |
310311
| 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). |
311312
| server.grpcRateLimit.requests | int | `0` | Maximum gRPC requests allowed per window. Must be positive (alongside windowSeconds) to enable rate limiting; 0 (default) disables it. |

‎deploy/helm/openshell/templates/gateway-config.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ data:
5757
disable_tls = true
5858
{{- end }}
5959
enable_loopback_service_http = {{ .Values.server.enableLoopbackServiceHttp }}
60+
enable_websocket_tunnel = {{ .Values.server.enableWebsocketTunnel }}
6061
{{- $sans := list -}}
6162
{{- if and .Values.certManager.enabled .Values.certManager.serverDnsNames }}
6263
{{- $sans = .Values.certManager.serverDnsNames }}

‎deploy/helm/openshell/tests/gateway_config_test.yaml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ release:
1313
namespace: my-namespace
1414

1515
tests:
16+
- it: disables the WebSocket tunnel unless explicitly enabled for an edge proxy
17+
template: templates/gateway-config.yaml
18+
asserts:
19+
- matchRegex:
20+
path: data["gateway.toml"]
21+
pattern: '(?m)^enable_websocket_tunnel\s*=\s*false$'
22+
23+
- it: enables the WebSocket tunnel when requested
24+
template: templates/gateway-config.yaml
25+
set:
26+
server.enableWebsocketTunnel: true
27+
asserts:
28+
- matchRegex:
29+
path: data["gateway.toml"]
30+
pattern: '(?m)^enable_websocket_tunnel\s*=\s*true$'
31+
1632
- it: defaults to label admission with caller driver config disabled
1733
template: templates/gateway-config.yaml
1834
asserts:

‎deploy/helm/openshell/values.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ server:
328328
# -- Enable plaintext HTTP routing for loopback sandbox service URLs on
329329
# TLS-enabled gateways.
330330
enableLoopbackServiceHttp: true
331+
# -- Enable the WebSocket tunnel used by CLI/SDK clients behind an
332+
# authenticated edge proxy. Leave disabled for direct gateway installs.
333+
enableWebsocketTunnel: false
331334
# -- Posture when a candidate sandbox policy fails validation. `fail_closed`
332335
# deactivates the previous policy; `retain_last_valid` keeps it active.
333336
policyValidationFailureMode: fail_closed

0 commit comments

Comments
 (0)