From 3caa2ca3f5a73e6eae3a2de39bd29974b57b97d2 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 29 Apr 2026 17:46:34 -0700 Subject: [PATCH 01/13] feat(gateway): add local-domain service routing --- crates/openshell-cli/src/main.rs | 55 ++ crates/openshell-cli/src/run.rs | 34 ++ .../tests/ensure_providers_integration.rs | 9 + .../openshell-cli/tests/mtls_integration.rs | 9 + .../tests/provider_commands_integration.rs | 9 + .../sandbox_create_lifecycle_integration.rs | 9 + .../sandbox_name_fallback_integration.rs | 9 + crates/openshell-core/src/config.rs | 55 ++ crates/openshell-core/src/metadata.rs | 22 +- crates/openshell-sandbox/src/ssh.rs | 2 +- crates/openshell-server/src/cli.rs | 23 +- crates/openshell-server/src/grpc/mod.rs | 24 +- crates/openshell-server/src/grpc/sandbox.rs | 6 +- crates/openshell-server/src/grpc/service.rs | 134 +++++ crates/openshell-server/src/http.rs | 26 +- crates/openshell-server/src/lib.rs | 1 + crates/openshell-server/src/local_domain.rs | 478 ++++++++++++++++++ .../tests/auth_endpoint_integration.rs | 10 + .../tests/edge_tunnel_auth.rs | 9 + .../tests/multiplex_integration.rs | 9 + .../tests/multiplex_tls_integration.rs | 9 + .../tests/supervisor_relay_integration.rs | 22 +- .../tests/ws_tunnel_integration.rs | 9 + .../helm/openshell/templates/statefulset.yaml | 8 + deploy/helm/openshell/values.yaml | 4 + .../kube/manifests/openshell-helmchart.yaml | 4 + docs/reference/browser-certificates.mdx | 138 +++++ docs/sandboxes/manage-sandboxes.mdx | 21 + proto/openshell.proto | 37 ++ 29 files changed, 1164 insertions(+), 21 deletions(-) create mode 100644 crates/openshell-server/src/grpc/service.rs create mode 100644 crates/openshell-server/src/local_domain.rs create mode 100644 docs/reference/browser-certificates.mdx diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index e370d1f27..686127f70 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -198,6 +198,7 @@ const HELP_TEMPLATE: &str = "\ \x1b[1mSANDBOX COMMANDS\x1b[0m sandbox: Manage sandboxes + service: Expose sandbox services forward: Manage port forwarding to a sandbox logs: View sandbox logs policy: Manage sandbox policy @@ -409,6 +410,13 @@ enum Commands { command: Option, }, + /// Expose sandbox services. + #[command(help_template = SUBCOMMAND_HELP_TEMPLATE)] + Service { + #[command(subcommand)] + command: Option, + }, + /// View sandbox logs. #[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Logs { @@ -1636,6 +1644,28 @@ enum ForwardCommands { }, } +#[derive(Subcommand, Debug)] +enum ServiceCommands { + /// Expose an HTTP service running inside a sandbox. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Expose { + /// Sandbox name. + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] + sandbox: String, + + /// Service name. + service: String, + + /// Loopback TCP port inside the sandbox. + #[arg(long)] + target_port: u16, + + /// Print and enable the local-domain URL for this service. + #[arg(long)] + domain: bool, + }, +} + #[tokio::main] #[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { @@ -1920,6 +1950,24 @@ async fn main() -> Result<()> { } }, + // ----------------------------------------------------------- + // Service exposure + // ----------------------------------------------------------- + Some(Commands::Service { + command: + Some(ServiceCommands::Expose { + sandbox, + service, + target_port, + domain, + }), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_edge_auth(&mut tls, &ctx.name); + run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, domain, &tls) + .await?; + } // ----------------------------------------------------------- // Top-level logs (was `sandbox logs`) // ----------------------------------------------------------- @@ -2657,6 +2705,13 @@ async fn main() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Service { command: None }) => { + Cli::command() + .find_subcommand_mut("service") + .expect("service subcommand exists") + .print_help() + .expect("Failed to print help"); + } Some(Commands::Policy { command: None }) => { Cli::command() .find_subcommand_mut("policy") diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 2797bd66c..6d3a37b13 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -39,6 +39,7 @@ use openshell_core::proto::{ SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, + ExposeServiceRequest, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -3401,6 +3402,39 @@ fn parse_credential_pairs(items: &[String]) -> Result> { Ok(map) } +pub async fn service_expose( + server: &str, + sandbox: &str, + service: &str, + target_port: u16, + domain: bool, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .expose_service(ExposeServiceRequest { + sandbox: sandbox.to_string(), + service: service.to_string(), + target_port: u32::from(target_port), + domain, + }) + .await + .map_err(|status| miette::miette!("expose service failed: {status}"))? + .into_inner(); + + println!( + "{} Exposed service {} on sandbox {} -> 127.0.0.1:{}", + "✓".green().bold(), + service.bold(), + sandbox.bold(), + target_port, + ); + if !response.url.is_empty() { + println!(" URL: {}", response.url.cyan()); + } + Ok(()) +} + pub async fn provider_create( server: &str, name: &str, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index f1a11e661..674f65032 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -213,6 +213,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index c95e2cf98..920366385 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -170,6 +170,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index fbe824cbf..b94a4279b 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -274,6 +274,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index a2fedab82..9ca091e71 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -226,6 +226,15 @@ impl OpenShell for TestOpenShell { })) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 94d5b3cfa..67440678e 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -201,6 +201,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 4922f5355..62706e1ba 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -36,6 +36,9 @@ pub const DEFAULT_NETWORK_NAME: &str = "openshell"; /// Default Docker bridge network name for local sandboxes. pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; +/// Default suffix for browser-facing local-domain routes. +pub const DEFAULT_LOCAL_DOMAIN_SUFFIX: &str = "openshell.localhost"; + /// Default OCI image for the openshell-sandbox supervisor binary. pub const DEFAULT_SUPERVISOR_IMAGE: &str = "openshell/supervisor:latest"; @@ -301,6 +304,26 @@ pub struct Config { /// plus a supporting container runtime and Linux 5.12+. #[serde(default)] pub enable_user_namespaces: bool, + + /// Local-domain routing configuration for browser-facing sandbox HTTP services. + #[serde(default)] + pub local_domain: LocalDomainConfig, +} + +/// Browser-facing local-domain routing configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalDomainConfig { + /// Enable local-domain routing. + #[serde(default)] + pub enabled: bool, + + /// Cluster label used in `.` hostnames. + #[serde(default)] + pub cluster: String, + + /// Hostname suffix, defaults to `openshell.localhost`. + #[serde(default = "default_local_domain_suffix")] + pub suffix: String, } /// TLS configuration. @@ -414,6 +437,8 @@ impl Config { client_tls_secret_name: String::new(), host_gateway_ip: String::new(), enable_user_namespaces: false, + enable_user_namespaces: false, + local_domain: LocalDomainConfig::default(), } } @@ -563,12 +588,42 @@ impl Config { self.oidc = Some(oidc); self } + + /// Configure local-domain routing for browser-facing sandbox HTTP services. + #[must_use] + pub fn with_local_domain( + mut self, + enabled: bool, + cluster: impl Into, + suffix: impl Into, + ) -> Self { + self.local_domain = LocalDomainConfig { + enabled, + cluster: cluster.into(), + suffix: suffix.into(), + }; + self + } +} + +impl Default for LocalDomainConfig { + fn default() -> Self { + Self { + enabled: false, + cluster: String::new(), + suffix: default_local_domain_suffix(), + } + } } fn default_bind_address() -> SocketAddr { "127.0.0.1:8080".parse().expect("valid default address") } +fn default_local_domain_suffix() -> String { + DEFAULT_LOCAL_DOMAIN_SUFFIX.to_string() +} + fn default_log_level() -> String { "info".to_string() } diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index e7ffea61a..6f7b7b0a4 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -6,7 +6,8 @@ //! These traits provide uniform access to `ObjectMeta` fields across all resource types. use crate::proto::{ - InferenceRoute, ObjectForTest, Provider, Sandbox, SshSession, StoredProviderProfile, + InferenceRoute, ObjectForTest, Provider, Sandbox, ServiceEndpoint, SshSession, + StoredProviderProfile, }; use std::collections::HashMap; @@ -101,6 +102,25 @@ impl ObjectLabels for SshSession { } } +// Implementations for ServiceEndpoint +impl ObjectId for ServiceEndpoint { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for ServiceEndpoint { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for ServiceEndpoint { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + // Implementations for InferenceRoute impl ObjectId for InferenceRoute { fn object_id(&self) -> &str { diff --git a/crates/openshell-sandbox/src/ssh.rs b/crates/openshell-sandbox/src/ssh.rs index 355fdc037..046fc715b 100644 --- a/crates/openshell-sandbox/src/ssh.rs +++ b/crates/openshell-sandbox/src/ssh.rs @@ -590,7 +590,7 @@ impl SshHandler { /// the calling thread's network namespace permanently — a tokio blocking-pool /// thread could be reused for unrelated tasks and must not be contaminated. /// On non-Linux platforms (no network namespace support), we connect directly. -async fn connect_in_netns( +pub async fn connect_in_netns( addr: &str, netns_fd: Option, ) -> std::io::Result { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index a3098c1cf..7df04d57e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -297,6 +297,22 @@ struct RunArgs { /// Keycloak: "scope". Okta: "scp". Leave empty to disable scope enforcement. #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] oidc_scopes_claim: String, + + /// Enable local-domain HTTP routing for browser-facing sandbox services. + #[arg(long, env = "OPENSHELL_LOCAL_DOMAIN_ENABLED")] + local_domain_enabled: bool, + + /// Cluster label used in local-domain hostnames. + #[arg(long, env = "OPENSHELL_LOCAL_DOMAIN_CLUSTER", default_value = "")] + local_domain_cluster: String, + + /// Suffix used in local-domain hostnames. + #[arg( + long, + env = "OPENSHELL_LOCAL_DOMAIN_SUFFIX", + default_value = openshell_core::config::DEFAULT_LOCAL_DOMAIN_SUFFIX + )] + local_domain_suffix: String, } pub fn command() -> Command { @@ -393,7 +409,12 @@ 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_sandbox_ssh_port(args.sandbox_ssh_port) - .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs); + .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs) + .with_local_domain( + args.local_domain_enabled, + args.local_domain_cluster, + args.local_domain_suffix, + ); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 16f016081..b7bca97f1 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -6,6 +6,7 @@ pub mod policy; mod provider; mod sandbox; +mod service; mod validation; use openshell_core::proto::{ @@ -16,11 +17,12 @@ use openshell_core::proto::{ DeleteProviderProfileResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, DetachSandboxProviderRequest, DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, - ExecSandboxRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, - GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, - GetGatewayConfigResponse, GetProviderProfileRequest, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, - GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, + GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, + GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, ImportProviderProfilesResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, @@ -30,8 +32,9 @@ use openshell_core::proto::{ ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, - SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, + SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, + SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, UpdateProviderRequest, WatchSandboxRequest, open_shell_server::OpenShell, }; @@ -260,6 +263,13 @@ impl OpenShell for OpenShellService { sandbox::handle_create_ssh_session(&self.state, request).await } + async fn expose_service( + &self, + request: Request, + ) -> Result, Status> { + service::handle_expose_service(&self.state, request).await + } + async fn revoke_ssh_session( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index ad37a5482..52314a12b 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -656,7 +656,11 @@ pub(super) async fn handle_exec_sandbox( // while still failing quickly during normal operation. let (channel_id, relay_rx) = state .supervisor_sessions - .open_relay(sandbox.object_id(), std::time::Duration::from_secs(15)) + .open_relay( + sandbox.object_id(), + None, + std::time::Duration::from_secs(15), + ) .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs new file mode 100644 index 000000000..000211b66 --- /dev/null +++ b/crates/openshell-server/src/grpc/service.rs @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::sync::Arc; + +use openshell_core::ObjectId; +use openshell_core::proto::datamodel::v1::ObjectMeta; +use openshell_core::proto::{ + ExposeServiceRequest, Sandbox, ServiceEndpoint, ServiceEndpointResponse, +}; +use tonic::{Request, Response, Status}; +use uuid::Uuid; + +use crate::ServerState; +use crate::local_domain; + +const MAX_SERVICE_NAME_LEN: usize = 28; +const MAX_SANDBOX_NAME_LEN: usize = 28; + +pub(super) async fn handle_expose_service( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.into_inner(); + validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; + validate_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; + if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { + return Err(Status::invalid_argument("target_port must be in 1..=65535")); + } + + let sandbox = state + .store + .get_message_by_name::(&req.sandbox) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + + let now = + super::current_time_ms().map_err(|e| Status::internal(format!("clock error: {e}")))?; + let key = local_domain::endpoint_key(&req.sandbox, &req.service); + let id = match state + .store + .get_message_by_name::(&key) + .await + { + Ok(Some(existing)) => existing.object_id().to_string(), + Ok(None) => Uuid::new_v4().to_string(), + Err(e) => return Err(Status::internal(format!("fetch endpoint failed: {e}"))), + }; + + let endpoint = ServiceEndpoint { + metadata: Some(ObjectMeta { + id, + name: key, + created_at_ms: now, + labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), + }), + sandbox_id: sandbox.object_id().to_string(), + sandbox_name: req.sandbox.clone(), + service_name: req.service.clone(), + target_port: req.target_port, + domain: req.domain, + }; + + state + .store + .put_message(&endpoint) + .await + .map_err(|e| Status::internal(format!("persist endpoint failed: {e}")))?; + + let url = if req.domain { + local_domain::endpoint_url(&state.config, &req.sandbox, &req.service).unwrap_or_default() + } else { + String::new() + }; + + Ok(Response::new(ServiceEndpointResponse { + endpoint: Some(endpoint), + url, + })) +} + +#[allow(clippy::result_large_err)] +fn validate_endpoint_name(field: &str, value: &str, max_len: usize) -> Result<(), Status> { + if value.is_empty() { + return Err(Status::invalid_argument(format!("{field} is required"))); + } + if value.len() > max_len { + return Err(Status::invalid_argument(format!( + "{field} must be at most {max_len} characters for local-domain routing" + ))); + } + if value.contains("--") { + return Err(Status::invalid_argument(format!( + "{field} must not contain '--'" + ))); + } + if !is_dns_label(value) { + return Err(Status::invalid_argument(format!( + "{field} must be a lowercase DNS label" + ))); + } + Ok(()) +} + +fn is_dns_label(value: &str) -> bool { + if value.starts_with('-') || value.ends_with('-') { + return false; + } + value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_good_endpoint_name() { + validate_endpoint_name("service", "web-api", 28).unwrap(); + } + + #[test] + fn rejects_separator_in_endpoint_name() { + assert!(validate_endpoint_name("service", "web--api", 28).is_err()); + } + + #[test] + fn rejects_uppercase_endpoint_name() { + assert!(validate_endpoint_name("service", "Web", 28).is_err()); + } +} diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 7ca9cb8bf..0d4f4c8b4 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -3,7 +3,14 @@ //! HTTP health endpoints using Axum. -use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; +use axum::{ + Json, Router, + extract::{Request, State}, + http::StatusCode, + middleware::{self, Next}, + response::IntoResponse, + routing::get, +}; use metrics_exporter_prometheus::PrometheusHandle; use serde::Serialize; use std::sync::Arc; @@ -59,5 +66,20 @@ async fn render_metrics(State(handle): State) -> impl IntoResp /// Create the HTTP router. pub fn http_router(state: Arc) -> Router { - crate::ws_tunnel::router(state.clone()).merge(crate::auth::router(state)) + crate::ws_tunnel::router(state.clone()) + .merge(crate::auth::router(state.clone())) + .layer(middleware::from_fn_with_state(state, local_domain_first)) +} + +async fn local_domain_first( + State(state): State>, + req: Request, + next: Next, +) -> impl IntoResponse { + if crate::local_domain::is_local_domain_request(&req, &state.config.local_domain) { + return crate::local_domain::proxy_local_domain_request(state, req) + .await + .into_response(); + } + next.run(req).await.into_response() } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index bca6e44aa..fe4d01e08 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -26,6 +26,7 @@ mod compute; mod grpc; mod http; mod inference; +mod local_domain; mod multiplex; mod persistence; pub(crate) mod policy_store; diff --git a/crates/openshell-server/src/local_domain.rs b/crates/openshell-server/src/local_domain.rs new file mode 100644 index 000000000..06b03f920 --- /dev/null +++ b/crates/openshell-server/src/local_domain.rs @@ -0,0 +1,478 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Local-domain HTTP routing for sandbox service endpoints. + +use axum::{body::Body, response::IntoResponse}; +use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; +use hyper_util::rt::TokioIo; +use openshell_core::ObjectId; +use openshell_core::config::LocalDomainConfig; +use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tracing::warn; + +use crate::ServerState; +use crate::persistence::{ObjectType, Store}; + +const ENDPOINT_OBJECT_TYPE: &str = "service_endpoint"; + +impl ObjectType for ServiceEndpoint { + fn object_type() -> &'static str { + ENDPOINT_OBJECT_TYPE + } +} + +pub fn endpoint_key(sandbox: &str, service: &str) -> String { + format!("{sandbox}--{service}") +} + +pub fn endpoint_url( + config: &openshell_core::Config, + sandbox: &str, + service: &str, +) -> Option { + if !config.local_domain.enabled { + return None; + } + let host = endpoint_host(&config.local_domain, sandbox, service)?; + let scheme = if config.tls.is_some() { + "https" + } else { + "http" + }; + let port = config.bind_address.port(); + let include_port = !matches!((scheme, port), ("https", 443) | ("http", 80)); + Some(if include_port { + format!("{scheme}://{host}:{port}/") + } else { + format!("{scheme}://{host}/") + }) +} + +fn endpoint_host(config: &LocalDomainConfig, sandbox: &str, service: &str) -> Option { + if config.cluster.is_empty() || config.suffix.is_empty() { + return None; + } + Some(format!( + "{}--{}.{}.{}", + sandbox, service, config.cluster, config.suffix + )) +} + +pub fn parse_host(host: &str, config: &LocalDomainConfig) -> Option<(String, String)> { + if !config.enabled || config.cluster.is_empty() || config.suffix.is_empty() { + return None; + } + + let host = host.split_once(':').map_or(host, |(name, _)| name); + let expected_suffix = format!(".{}.{}", config.cluster, config.suffix); + let encoded = host.strip_suffix(&expected_suffix)?; + let (sandbox, service) = encoded.split_once("--")?; + if sandbox.is_empty() || service.is_empty() || sandbox.contains("--") || service.contains("--") + { + return None; + } + Some((sandbox.to_string(), service.to_string())) +} + +pub fn is_local_domain_request(req: &Request, config: &LocalDomainConfig) -> bool { + request_host(req).is_some_and(|host| parse_host(host, config).is_some()) +} + +pub async fn proxy_local_domain_request( + state: Arc, + req: Request, +) -> impl IntoResponse { + let Some(host) = request_host(&req) else { + return StatusCode::NOT_FOUND.into_response(); + }; + let Some((sandbox_name, service_name)) = parse_host(host, &state.config.local_domain) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match proxy_to_endpoint(state, req, sandbox_name, service_name).await { + Ok(response) => response.into_response(), + Err(status) => status.into_response(), + } +} + +async fn proxy_to_endpoint( + state: Arc, + mut req: Request, + sandbox_name: String, + service_name: String, +) -> Result, StatusCode> { + let endpoint = load_endpoint(&state.store, &sandbox_name, &service_name).await?; + if !endpoint.domain || endpoint.target_port == 0 || endpoint.target_port > u32::from(u16::MAX) { + return Err(StatusCode::NOT_FOUND); + } + + let sandbox = state + .store + .get_message::(&endpoint.sandbox_id) + .await + .map_err(|err| { + warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "local-domain: failed to load sandbox"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + if SandboxPhase::try_from(sandbox.phase).ok() != Some(SandboxPhase::Ready) { + return Err(StatusCode::PRECONDITION_FAILED); + } + let target_port = u16::try_from(endpoint.target_port).map_err(|_| StatusCode::NOT_FOUND)?; + + let websocket_upgrade = is_websocket_upgrade(&req); + let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut req)); + + let (_channel_id, relay_rx) = state + .supervisor_sessions + .open_relay_with_target( + sandbox.object_id(), + relay_open::Target::Tcp(TcpRelayTarget { + host: "127.0.0.1".to_string(), + port: u32::from(target_port), + }), + endpoint.object_id().to_string(), + Duration::from_secs(15), + ) + .await + .map_err(|err| { + warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "local-domain: supervisor relay unavailable"); + StatusCode::BAD_GATEWAY + })?; + + let relay = tokio::time::timeout(Duration::from_secs(10), relay_rx) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)? + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + let (mut sender, conn) = hyper::client::conn::http1::Builder::new() + .handshake(TokioIo::new(relay)) + .await + .map_err(|err| { + warn!(error = %err, "local-domain: failed to start upstream HTTP client"); + StatusCode::BAD_GATEWAY + })?; + + if websocket_upgrade { + tokio::spawn(async move { + if let Err(err) = conn.with_upgrades().await { + warn!(error = %err, "local-domain: upstream WebSocket connection failed"); + } + }); + } else { + tokio::spawn(async move { + if let Err(err) = conn.await { + warn!(error = %err, "local-domain: upstream HTTP connection failed"); + } + }); + } + + let upstream = build_upstream_request(req, target_port, websocket_upgrade)?; + let mut response = sender.send_request(upstream).await.map_err(|err| { + warn!(error = %err, "local-domain: upstream HTTP request failed"); + StatusCode::BAD_GATEWAY + })?; + + if websocket_upgrade && response.status() == StatusCode::SWITCHING_PROTOCOLS { + let upstream_upgrade = hyper::upgrade::on(&mut response); + let downstream_upgrade = downstream_upgrade.ok_or(StatusCode::BAD_GATEWAY)?; + tokio::spawn(async move { + match (downstream_upgrade.await, upstream_upgrade.await) { + (Ok(downstream), Ok(upstream)) => { + let mut downstream = TokioIo::new(downstream); + let mut upstream = TokioIo::new(upstream); + let _ = tokio::io::copy_bidirectional(&mut downstream, &mut upstream).await; + let _ = downstream.shutdown().await; + let _ = upstream.shutdown().await; + } + (Err(err), _) => { + warn!(error = %err, "local-domain: downstream WebSocket upgrade failed"); + } + (_, Err(err)) => { + warn!(error = %err, "local-domain: upstream WebSocket upgrade failed"); + } + } + }); + + let (parts, _) = response.into_parts(); + return Ok(Response::from_parts(parts, Body::empty())); + } + + let (parts, body) = response.into_parts(); + Ok(Response::from_parts(parts, Body::new(body))) +} + +async fn load_endpoint( + store: &Store, + sandbox_name: &str, + service_name: &str, +) -> Result { + let key = endpoint_key(sandbox_name, service_name); + store + .get_message_by_name::(&key) + .await + .map_err(|err| { + warn!(error = %err, endpoint = %key, "local-domain: failed to load service endpoint"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND) +} + +fn build_upstream_request( + req: Request, + target_port: u16, + preserve_upgrade_headers: bool, +) -> Result, StatusCode> { + let (parts, body) = req.into_parts(); + let path = parts.uri.path_and_query().map_or("/", |path| path.as_str()); + let uri = path + .parse::() + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let mut builder = Request::builder() + .method(parts.method) + .uri(uri) + .version(http::Version::HTTP_11); + + let headers = builder + .headers_mut() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + for (name, value) in &parts.headers { + if (is_hop_by_hop_header(name) + && !(preserve_upgrade_headers && is_websocket_hop_by_hop_header(name))) + || is_gateway_auth_header(name) + { + continue; + } + if name == header::COOKIE { + if let Some(cookie) = sanitize_cookie_header(value) { + headers.append(name, cookie); + } + continue; + } + headers.append(name, value.clone()); + } + headers.insert( + header::HOST, + format!("127.0.0.1:{target_port}").parse().unwrap(), + ); + + builder + .body(body) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn host_header(headers: &HeaderMap) -> Option<&str> { + headers.get(header::HOST)?.to_str().ok() +} + +fn request_host(req: &Request) -> Option<&str> { + host_header(req.headers()).or_else(|| req.uri().authority().map(http::uri::Authority::as_str)) +} + +fn is_websocket_upgrade(req: &Request) -> bool { + req.method() == Method::GET + && header_value_is(req.headers(), header::UPGRADE, "websocket") + && header_contains_token(req.headers(), header::CONNECTION, "upgrade") +} + +fn header_value_is(headers: &HeaderMap, name: header::HeaderName, expected: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case(expected)) +} + +fn header_contains_token(headers: &HeaderMap, name: header::HeaderName, token: &str) -> bool { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) + }) +} + +fn is_hop_by_hop_header(name: &header::HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "host" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn is_websocket_hop_by_hop_header(name: &header::HeaderName) -> bool { + matches!(name.as_str(), "connection" | "upgrade") +} + +fn is_gateway_auth_header(name: &header::HeaderName) -> bool { + matches!( + name.as_str(), + "authorization" + | "cf-access-jwt-assertion" + | "x-forwarded-client-cert" + | "x-ssl-client-cert" + | "x-client-cert" + ) +} + +fn sanitize_cookie_header(value: &HeaderValue) -> Option { + let value = value.to_str().ok()?; + let cookies = value + .split(';') + .filter_map(|cookie| { + let cookie = cookie.trim(); + let (name, _) = cookie.split_once('=')?; + (!is_gateway_auth_cookie(name.trim())).then_some(cookie) + }) + .collect::>(); + + if cookies.is_empty() { + return None; + } + + HeaderValue::from_str(&cookies.join("; ")).ok() +} + +fn is_gateway_auth_cookie(name: &str) -> bool { + name.eq_ignore_ascii_case("CF_Authorization") || name.eq_ignore_ascii_case("cf-authorization") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> LocalDomainConfig { + LocalDomainConfig { + enabled: true, + cluster: "dev".to_string(), + suffix: "openshell.localhost".to_string(), + } + } + + #[test] + fn parses_local_domain_host() { + assert_eq!( + parse_host("my-sandbox--web.dev.openshell.localhost", &config()), + Some(("my-sandbox".to_string(), "web".to_string())) + ); + } + + #[test] + fn parses_local_domain_host_with_port() { + assert_eq!( + parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), + Some(("my-sandbox".to_string(), "web".to_string())) + ); + } + + #[test] + fn rejects_wrong_cluster() { + assert_eq!( + parse_host("my-sandbox--web.prod.openshell.localhost", &config()), + None + ); + } + + #[test] + fn identifies_local_domain_request_from_host_header() { + let request = Request::builder() + .uri("/") + .header(header::HOST, "my-sandbox--web.dev.openshell.localhost") + .body(Body::empty()) + .unwrap(); + assert!(is_local_domain_request(&request, &config())); + } + + #[test] + fn identifies_local_domain_request_from_http2_authority() { + let request = Request::builder() + .uri("https://my-sandbox--web.dev.openshell.localhost/") + .body(Body::empty()) + .unwrap(); + assert!(is_local_domain_request(&request, &config())); + } + + #[test] + fn ignores_non_local_domain_request() { + let request = Request::builder() + .uri("/") + .header(header::HOST, "127.0.0.1:8080") + .body(Body::empty()) + .unwrap(); + assert!(!is_local_domain_request(&request, &config())); + } + + #[test] + fn strips_gateway_auth_headers_from_upstream_request() { + let request = Request::builder() + .uri("https://my-sandbox--web.dev.openshell.localhost/path") + .header(header::AUTHORIZATION, "Bearer gateway-token") + .header("cf-access-jwt-assertion", "edge-token") + .header("x-forwarded-client-cert", "cert") + .header( + header::COOKIE, + "theme=dark; CF_Authorization=edge-cookie; app=session", + ) + .header("x-app-header", "kept") + .body(Body::empty()) + .unwrap(); + + let upstream = build_upstream_request(request, 8080, false).unwrap(); + + assert_eq!(upstream.uri(), "/path"); + assert!(!upstream.headers().contains_key(header::AUTHORIZATION)); + assert!(!upstream.headers().contains_key("cf-access-jwt-assertion")); + assert!(!upstream.headers().contains_key("x-forwarded-client-cert")); + assert_eq!( + upstream.headers()[header::COOKIE], + "theme=dark; app=session" + ); + assert_eq!(upstream.headers()["x-app-header"], "kept"); + } + + #[test] + fn detects_websocket_upgrade_request() { + let request = Request::builder() + .method(Method::GET) + .uri("/chat?session=main") + .header(header::CONNECTION, "keep-alive, Upgrade") + .header(header::UPGRADE, "websocket") + .body(Body::empty()) + .unwrap(); + + assert!(is_websocket_upgrade(&request)); + } + + #[test] + fn preserves_websocket_upgrade_headers_for_upstream_request() { + let request = Request::builder() + .method(Method::GET) + .uri("https://my-sandbox--web.dev.openshell.localhost/chat?session=main") + .header(header::CONNECTION, "Upgrade") + .header(header::UPGRADE, "websocket") + .header("sec-websocket-key", "abc") + .body(Body::empty()) + .unwrap(); + + let upstream = build_upstream_request(request, 8080, true).unwrap(); + + assert_eq!(upstream.uri(), "/chat?session=main"); + assert_eq!(upstream.headers()[header::CONNECTION], "Upgrade"); + assert_eq!(upstream.headers()[header::UPGRADE], "websocket"); + assert_eq!(upstream.headers()["sec-websocket-key"], "abc"); + assert_eq!(upstream.headers()[header::HOST], "127.0.0.1:8080"); + } +} diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index f160f98b8..91559db52 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -507,6 +507,16 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { )) } + async fn expose_service( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index 689cfcf59..b0eba11f4 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -170,6 +170,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index 9cab950db..b595c2333 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -128,6 +128,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 21b75c12c..194de0ed0 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -141,6 +141,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 2d722b051..a697cf1fc 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -169,6 +169,12 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn expose_service( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn revoke_ssh_session( &self, _: tonic::Request, @@ -436,7 +442,7 @@ async fn relay_round_trips_bytes() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", Duration::from_secs(2)) + .open_relay("sbx", None, Duration::from_secs(2)) .await .expect("open_relay"); @@ -466,7 +472,7 @@ async fn relay_closes_cleanly_when_gateway_drops() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", Duration::from_secs(2)) + .open_relay("sbx", None, Duration::from_secs(2)) .await .expect("open_relay"); let _ = session_rx.recv().await.expect("RelayOpen"); @@ -491,7 +497,7 @@ async fn relay_sees_eof_when_supervisor_closes() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", Duration::from_secs(2)) + .open_relay("sbx", None, Duration::from_secs(2)) .await .expect("open_relay"); let _ = session_rx.recv().await.expect("RelayOpen"); @@ -536,7 +542,7 @@ async fn open_relay_times_out_when_no_session() { let _channel = spawn_gateway(Arc::clone(®istry)).await; let err = registry - .open_relay("missing", Duration::from_millis(100)) + .open_relay("missing", None, Duration::from_millis(100)) .await .expect_err("should time out"); assert_eq!(err.code(), tonic::Code::Unavailable); @@ -549,13 +555,13 @@ async fn concurrent_relays_multiplex_independently() { let mut session_rx = register_session(®istry, "sbx"); let (id_a, rx_a) = registry - .open_relay("sbx", Duration::from_secs(2)) + .open_relay("sbx", None, Duration::from_secs(2)) .await .expect("open_relay a"); let _ = session_rx.recv().await.expect("RelayOpen a"); let (id_b, rx_b) = registry - .open_relay("sbx", Duration::from_secs(2)) + .open_relay("sbx", None, Duration::from_secs(2)) .await .expect("open_relay b"); let _ = session_rx.recv().await.expect("RelayOpen b"); @@ -602,7 +608,7 @@ async fn open_relay_enforces_per_sandbox_cap_under_concurrent_burst() { for _ in 0..64 { let r = Arc::clone(®istry); handles.push(tokio::spawn(async move { - r.open_relay("sbx", Duration::from_secs(1)).await + r.open_relay("sbx", None, Duration::from_secs(1)).await })); } @@ -629,7 +635,7 @@ async fn open_relay_enforces_per_sandbox_cap_under_concurrent_burst() { // leak onto unrelated tenants. let _other_rx = register_session_with_capacity(®istry, "sbx-other", 8); registry - .open_relay("sbx-other", Duration::from_secs(1)) + .open_relay("sbx-other", None, Duration::from_secs(1)) .await .expect("other sandbox should not be affected by sbx cap"); } diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index 14d5e9bb7..2a8c08dfc 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -164,6 +164,15 @@ impl OpenShell for TestOpenShell { Ok(Response::new(CreateSshSessionResponse::default())) } + async fn expose_service( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ServiceEndpointResponse::default(), + )) + } + async fn revoke_ssh_session( &self, _request: tonic::Request, diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 4a0b70621..e136629ca 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -96,6 +96,14 @@ spec: - name: OPENSHELL_ENABLE_USER_NAMESPACES value: "true" {{- end }} + {{- if .Values.server.localDomain.enabled }} + - name: OPENSHELL_LOCAL_DOMAIN_ENABLED + value: "true" + - name: OPENSHELL_LOCAL_DOMAIN_CLUSTER + value: {{ .Values.server.localDomain.cluster | quote }} + - name: OPENSHELL_LOCAL_DOMAIN_SUFFIX + value: {{ .Values.server.localDomain.suffix | quote }} + {{- end }} - name: OPENSHELL_SSH_HANDSHAKE_SECRET valueFrom: secretKeyRef: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 66935c25f..5e18f1e92 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -130,6 +130,10 @@ server: # Disable TLS entirely — the server listens on plaintext HTTP. # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false + localDomain: + enabled: false + cluster: "" + suffix: "openshell.localhost" tls: # K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server certSecretName: openshell-server-tls diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index ea4e370dc..678215c10 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -46,6 +46,10 @@ spec: adminRole: "__OIDC_ADMIN_ROLE__" userRole: "__OIDC_USER_ROLE__" scopesClaim: "__OIDC_SCOPES_CLAIM__" + localDomain: + enabled: __LOCAL_DOMAIN_ENABLED__ + cluster: __LOCAL_DOMAIN_CLUSTER__ + suffix: __LOCAL_DOMAIN_SUFFIX__ tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca diff --git a/docs/reference/browser-certificates.mdx b/docs/reference/browser-certificates.mdx new file mode 100644 index 000000000..5ed551bd5 --- /dev/null +++ b/docs/reference/browser-certificates.mdx @@ -0,0 +1,138 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Browser Certificates" +description: "Trust an OpenShell gateway CA and install the client certificate for browser access to local-domain sandbox services." +keywords: "Generative AI, Cybersecurity, Browser Certificates, Firefox, mTLS, Local Domain" +position: 2 +--- + +OpenShell local-domain URLs use the same gateway mTLS model as the CLI. Your browser needs two certificate changes before it can open `https://--..openshell.localhost:/`: + +- Trust the gateway CA so the browser accepts the gateway server certificate. +- Install the client certificate so the gateway accepts the browser connection. + +The CLI stores gateway certificates under `~/.config/openshell/gateways//mtls/`: + +| File | Browser use | +|---|---| +| `ca.crt` | Import as a trusted certificate authority. | +| `tls.crt` and `tls.key` | Convert to PKCS#12, then import as the browser client certificate. | + +## Create a Client Certificate Bundle + +Browsers import client certificates as PKCS#12 (`.p12`) bundles. Create one from the OpenShell PEM files: + +```shell +GATEWAY=navigator # replace with your gateway name +MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" + +openssl pkcs12 -export \ + -inkey "$MTLS_DIR/tls.key" \ + -in "$MTLS_DIR/tls.crt" \ + -certfile "$MTLS_DIR/ca.crt" \ + -name "OpenShell $GATEWAY client" \ + -out "$MTLS_DIR/openshell-$GATEWAY-client.p12" \ + -passout pass: +``` + +This writes `openshell--client.p12` with an empty import password. If you set a password, enter it when the browser asks during import. + +## Firefox + +Firefox uses its own NSS certificate database and does not always use the macOS keychain. + +### Manual Import + +Open Firefox settings and import both certificates: + +1. Open `about:preferences#privacy`. +2. Scroll to **Certificates** and select **View Certificates**. +3. In **Authorities**, select **Import**, choose `~/.config/openshell/gateways//mtls/ca.crt`, and enable **Trust this CA to identify websites**. +4. In **Your Certificates**, select **Import**, choose `~/.config/openshell/gateways//mtls/openshell--client.p12`, and enter the PKCS#12 password. If you used the command above, leave it blank. +5. Restart Firefox. + +### CLI Import + +Install the NSS tools, close Firefox, then import into the active Firefox profile: + +```shell +brew install nss + +GATEWAY=navigator # replace with your gateway name +MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" +PROFILE="$HOME/Library/Application Support/Firefox/Profiles/" + +certutil -A \ + -d "sql:$PROFILE" \ + -n "OpenShell $GATEWAY CA" \ + -t "CT,C,C" \ + -i "$MTLS_DIR/ca.crt" + +pk12util \ + -d "sql:$PROFILE" \ + -i "$MTLS_DIR/openshell-$GATEWAY-client.p12" \ + -W "" +``` + +Find the active profile directory in `about:profiles` before running the commands. Restart Firefox after importing. If Firefox prompts for a client certificate when you visit the local-domain URL, choose the `OpenShell client` certificate. + +## Chrome, Edge, and Safari on macOS + +Chrome, Edge, and Safari generally use the macOS keychain. Import the CA into your login keychain and mark it trusted: + +```shell +GATEWAY=navigator # replace with your gateway name +MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" + +security add-trusted-cert \ + -d \ + -r trustRoot \ + -k "$HOME/Library/Keychains/login.keychain-db" \ + "$MTLS_DIR/ca.crt" +``` + +macOS Keychain may reject the default OpenSSL 3 PKCS#12 bundle. Create a Keychain-compatible bundle: + +```shell +GATEWAY=navigator # replace with your gateway name +MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" + +openssl pkcs12 -export -legacy \ + -inkey "$MTLS_DIR/tls.key" \ + -in "$MTLS_DIR/tls.crt" \ + -certfile "$MTLS_DIR/ca.crt" \ + -name "OpenShell $GATEWAY client" \ + -out "$MTLS_DIR/openshell-$GATEWAY-client-keychain.p12" \ + -passout pass:openshell +``` + +Import it for Safari from the command line: + +```shell +security import "$MTLS_DIR/openshell-$GATEWAY-client-keychain.p12" \ + -k "$HOME/Library/Keychains/login.keychain-db" \ + -P "openshell" \ + -f pkcs12 \ + -T "/Applications/Safari.app" +``` + +Or import the client `.p12` into Keychain Access: + +1. Open **Keychain Access**. +2. Select the **login** keychain. +3. Choose **File > Import Items**. +4. Select `~/.config/openshell/gateways//mtls/openshell--client-keychain.p12`. +5. Enter the PKCS#12 password. If you used the command above, enter `openshell`. + +Restart the browser after importing certificates. + +## Troubleshooting + +If the browser still shows a certificate warning, recreate gateways created before local-domain routing so their server certificate includes local-domain SANs: + +```shell +openshell gateway start --recreate +``` + +If the gateway rejects the browser connection, confirm the client certificate is installed in **Your Certificates** in Firefox or in the login keychain on macOS. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index b43a6846a..ee4983769 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -139,6 +139,27 @@ openshell sandbox list --selector env=dev,team=platform ## Monitor and Debug +## Expose HTTP Services + +For local browser testing, use a new local gateway or recreate a gateway created before local-domain routing was enabled by default: + +```shell +openshell gateway start --recreate +``` + +Expose a service that listens on loopback inside the sandbox: + +```shell +openshell service expose my-sandbox web --target-port 8080 --domain +``` + +OpenShell prints a URL in the form `https://--..openshell.localhost:/`. HTTP traffic enters the same gateway listener as gRPC and routes by the `Host` header before gateway routes such as `/auth`, so application paths are preserved for the sandbox service. + + +This local-domain path is an early spike. It covers HTTP requests over the existing supervisor relay. Import the gateway CA and client certificate before using mTLS-protected browser access; see [Browser Certificates](/reference/browser-certificates). WebSocket upgrades, endpoint deletion, renewal, and remote/public domains are follow-up work. + + + List all sandboxes: ```shell diff --git a/proto/openshell.proto b/proto/openshell.proto index 883c1576c..ea3621da8 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -48,6 +48,9 @@ service OpenShell { // Create a short-lived SSH session for a sandbox. rpc CreateSshSession(CreateSshSessionRequest) returns (CreateSshSessionResponse); + // Create or update a sandbox HTTP service endpoint for local-domain routing. + rpc ExposeService(ExposeServiceRequest) returns (ServiceEndpointResponse); + // Revoke a previously issued SSH session. rpc RevokeSshSession(RevokeSshSessionRequest) returns (RevokeSshSessionResponse); @@ -457,6 +460,40 @@ message CreateSshSessionResponse { int64 expires_at_ms = 8; } +// Request to expose an HTTP service running inside a sandbox. +message ExposeServiceRequest { + // Sandbox name. + string sandbox = 1; + // Service name within the sandbox. + string service = 2; + // Loopback TCP port inside the sandbox. + uint32 target_port = 3; + // Whether to print/use the local-domain URL. + bool domain = 4; +} + +// Persisted sandbox service endpoint. +message ServiceEndpoint { + // Kubernetes-style metadata. + openshell.datamodel.v1.ObjectMeta metadata = 1; + // Sandbox object ID. + string sandbox_id = 2; + // Sandbox name. + string sandbox_name = 3; + // Service name within the sandbox. + string service_name = 4; + // Loopback TCP port inside the sandbox. + uint32 target_port = 5; + // Whether local-domain routing is enabled for this endpoint. + bool domain = 6; +} + +// Response containing a service endpoint and, when available, its local URL. +message ServiceEndpointResponse { + ServiceEndpoint endpoint = 1; + string url = 2; +} + // Revoke SSH session request. message RevokeSshSessionRequest { // Session token to revoke. From 1232595a5ce9f9af78eff4a1bd1d035f6aec21b9 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 29 Apr 2026 18:25:24 -0700 Subject: [PATCH 02/13] feat(gateway): enable local-domain routing by default --- crates/openshell-cli/src/run.rs | 43 ++++++++++++++++++- crates/openshell-core/src/config.rs | 15 +++---- crates/openshell-server/src/cli.rs | 16 +++---- crates/openshell-server/src/local_domain.rs | 6 +-- .../helm/openshell/templates/statefulset.yaml | 4 -- deploy/helm/openshell/values.yaml | 3 +- .../kube/manifests/openshell-helmchart.yaml | 1 - docs/sandboxes/manage-sandboxes.mdx | 4 +- 8 files changed, 58 insertions(+), 34 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 6d3a37b13..a9bebb98a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3430,11 +3430,30 @@ pub async fn service_expose( target_port, ); if !response.url.is_empty() { - println!(" URL: {}", response.url.cyan()); + let url = service_url_for_gateway(&response.url, server); + println!(" URL: {}", url.cyan()); } Ok(()) } +fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { + let (Ok(mut service_url), Ok(gateway_endpoint)) = ( + url::Url::parse(service_url), + url::Url::parse(gateway_endpoint), + ) else { + return service_url.to_string(); + }; + + if service_url.set_scheme(gateway_endpoint.scheme()).is_err() { + return service_url.to_string(); + } + if service_url.set_port(gateway_endpoint.port()).is_err() { + return service_url.to_string(); + } + + service_url.to_string() +} + pub async fn provider_create( server: &str, name: &str, @@ -6193,6 +6212,28 @@ mod tests { assert!(dockerfile_sources_supported_for_gateway(None)); } + #[test] + fn service_url_for_gateway_uses_external_gateway_port() { + assert_eq!( + service_url_for_gateway( + "https://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", + "https://127.0.0.1:31886" + ), + "https://quiet-flamingo--openclaw.navigator.openshell.localhost:31886/" + ); + } + + #[test] + fn service_url_for_gateway_omits_default_external_port() { + assert_eq!( + service_url_for_gateway( + "http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", + "https://gateway.example.com" + ), + "https://quiet-flamingo--openclaw.navigator.openshell.localhost/" + ); + } + #[test] fn ready_false_condition_message_prefers_reason_and_message() { let status = SandboxStatus { diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 62706e1ba..c790f280e 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -313,12 +313,8 @@ pub struct Config { /// Browser-facing local-domain routing configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalDomainConfig { - /// Enable local-domain routing. - #[serde(default)] - pub enabled: bool, - /// Cluster label used in `.` hostnames. - #[serde(default)] + #[serde(default = "default_local_domain_cluster")] pub cluster: String, /// Hostname suffix, defaults to `openshell.localhost`. @@ -593,12 +589,10 @@ impl Config { #[must_use] pub fn with_local_domain( mut self, - enabled: bool, cluster: impl Into, suffix: impl Into, ) -> Self { self.local_domain = LocalDomainConfig { - enabled, cluster: cluster.into(), suffix: suffix.into(), }; @@ -609,8 +603,7 @@ impl Config { impl Default for LocalDomainConfig { fn default() -> Self { Self { - enabled: false, - cluster: String::new(), + cluster: default_local_domain_cluster(), suffix: default_local_domain_suffix(), } } @@ -624,6 +617,10 @@ fn default_local_domain_suffix() -> String { DEFAULT_LOCAL_DOMAIN_SUFFIX.to_string() } +fn default_local_domain_cluster() -> String { + "openshell".to_string() +} + fn default_log_level() -> String { "info".to_string() } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 7df04d57e..d82633f38 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -298,12 +298,12 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] oidc_scopes_claim: String, - /// Enable local-domain HTTP routing for browser-facing sandbox services. - #[arg(long, env = "OPENSHELL_LOCAL_DOMAIN_ENABLED")] - local_domain_enabled: bool, - /// Cluster label used in local-domain hostnames. - #[arg(long, env = "OPENSHELL_LOCAL_DOMAIN_CLUSTER", default_value = "")] + #[arg( + long, + env = "OPENSHELL_LOCAL_DOMAIN_CLUSTER", + default_value = "openshell" + )] local_domain_cluster: String, /// Suffix used in local-domain hostnames. @@ -410,11 +410,7 @@ async fn run_from_args(args: RunArgs) -> Result<()> { .with_ssh_gateway_port(args.ssh_gateway_port) .with_sandbox_ssh_port(args.sandbox_ssh_port) .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs) - .with_local_domain( - args.local_domain_enabled, - args.local_domain_cluster, - args.local_domain_suffix, - ); + .with_local_domain(args.local_domain_cluster, args.local_domain_suffix); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); diff --git a/crates/openshell-server/src/local_domain.rs b/crates/openshell-server/src/local_domain.rs index 06b03f920..ce96e71f1 100644 --- a/crates/openshell-server/src/local_domain.rs +++ b/crates/openshell-server/src/local_domain.rs @@ -34,9 +34,6 @@ pub fn endpoint_url( sandbox: &str, service: &str, ) -> Option { - if !config.local_domain.enabled { - return None; - } let host = endpoint_host(&config.local_domain, sandbox, service)?; let scheme = if config.tls.is_some() { "https" @@ -63,7 +60,7 @@ fn endpoint_host(config: &LocalDomainConfig, sandbox: &str, service: &str) -> Op } pub fn parse_host(host: &str, config: &LocalDomainConfig) -> Option<(String, String)> { - if !config.enabled || config.cluster.is_empty() || config.suffix.is_empty() { + if config.cluster.is_empty() || config.suffix.is_empty() { return None; } @@ -356,7 +353,6 @@ mod tests { fn config() -> LocalDomainConfig { LocalDomainConfig { - enabled: true, cluster: "dev".to_string(), suffix: "openshell.localhost".to_string(), } diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index e136629ca..5965ad487 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -96,14 +96,10 @@ spec: - name: OPENSHELL_ENABLE_USER_NAMESPACES value: "true" {{- end }} - {{- if .Values.server.localDomain.enabled }} - - name: OPENSHELL_LOCAL_DOMAIN_ENABLED - value: "true" - name: OPENSHELL_LOCAL_DOMAIN_CLUSTER value: {{ .Values.server.localDomain.cluster | quote }} - name: OPENSHELL_LOCAL_DOMAIN_SUFFIX value: {{ .Values.server.localDomain.suffix | quote }} - {{- end }} - name: OPENSHELL_SSH_HANDSHAKE_SECRET valueFrom: secretKeyRef: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 5e18f1e92..2561b6946 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -131,8 +131,7 @@ server: # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false localDomain: - enabled: false - cluster: "" + cluster: "openshell" suffix: "openshell.localhost" tls: # K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index 678215c10..4ac9cada9 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -47,7 +47,6 @@ spec: userRole: "__OIDC_USER_ROLE__" scopesClaim: "__OIDC_SCOPES_CLAIM__" localDomain: - enabled: __LOCAL_DOMAIN_ENABLED__ cluster: __LOCAL_DOMAIN_CLUSTER__ suffix: __LOCAL_DOMAIN_SUFFIX__ tls: diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index ee4983769..4f0dc3832 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -141,7 +141,7 @@ openshell sandbox list --selector env=dev,team=platform ## Expose HTTP Services -For local browser testing, use a new local gateway or recreate a gateway created before local-domain routing was enabled by default: +Local-domain routing is enabled for gateways by default. Recreate gateways created before local-domain routing so their generated TLS certificate includes the local-domain SANs: ```shell openshell gateway start --recreate @@ -156,7 +156,7 @@ openshell service expose my-sandbox web --target-port 8080 --domain OpenShell prints a URL in the form `https://--..openshell.localhost:/`. HTTP traffic enters the same gateway listener as gRPC and routes by the `Host` header before gateway routes such as `/auth`, so application paths are preserved for the sandbox service. -This local-domain path is an early spike. It covers HTTP requests over the existing supervisor relay. Import the gateway CA and client certificate before using mTLS-protected browser access; see [Browser Certificates](/reference/browser-certificates). WebSocket upgrades, endpoint deletion, renewal, and remote/public domains are follow-up work. +Import the gateway CA and client certificate before using mTLS-protected browser access; see [Browser Certificates](/reference/browser-certificates). Endpoint deletion, renewal, and remote/public domains are follow-up work. From d8748c872ea42d8004005b9677fb256786d5b087 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 30 Apr 2026 18:08:17 -0700 Subject: [PATCH 03/13] refactor(gateway): rename sandbox service routing config [skip ci] --- crates/openshell-cli/src/main.rs | 2 +- crates/openshell-core/src/config.rs | 72 ++++++------ crates/openshell-server/src/cli.rs | 21 ++-- crates/openshell-server/src/grpc/service.rs | 8 +- crates/openshell-server/src/http.rs | 11 +- crates/openshell-server/src/lib.rs | 2 +- .../{local_domain.rs => service_routing.rs} | 105 ++++++++++-------- .../helm/openshell/templates/statefulset.yaml | 6 +- deploy/helm/openshell/values.yaml | 5 +- .../kube/manifests/openshell-helmchart.yaml | 4 +- docs/reference/browser-certificates.mdx | 8 +- docs/sandboxes/manage-sandboxes.mdx | 2 +- 12 files changed, 128 insertions(+), 118 deletions(-) rename crates/openshell-server/src/{local_domain.rs => service_routing.rs} (81%) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 686127f70..7351ca5cc 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1660,7 +1660,7 @@ enum ServiceCommands { #[arg(long)] target_port: u16, - /// Print and enable the local-domain URL for this service. + /// Print and enable the browser URL for this sandbox service. #[arg(long)] domain: bool, }, diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index c790f280e..3cb124e4e 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -36,8 +36,11 @@ pub const DEFAULT_NETWORK_NAME: &str = "openshell"; /// Default Docker bridge network name for local sandboxes. pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; -/// Default suffix for browser-facing local-domain routes. -pub const DEFAULT_LOCAL_DOMAIN_SUFFIX: &str = "openshell.localhost"; +/// Default root used to derive browser-facing sandbox service domains. +pub const DEFAULT_SERVICE_BASE_DOMAIN_ROOT: &str = "openshell.localhost"; + +/// Default browser-facing sandbox service base domain. +pub const DEFAULT_SERVICE_BASE_DOMAIN: &str = "openshell.openshell.localhost"; /// Default OCI image for the openshell-sandbox supervisor binary. pub const DEFAULT_SUPERVISOR_IMAGE: &str = "openshell/supervisor:latest"; @@ -305,21 +308,18 @@ pub struct Config { #[serde(default)] pub enable_user_namespaces: bool, - /// Local-domain routing configuration for browser-facing sandbox HTTP services. + /// Browser-facing sandbox service routing configuration. #[serde(default)] - pub local_domain: LocalDomainConfig, + pub service_routing: ServiceRoutingConfig, } -/// Browser-facing local-domain routing configuration. +/// Browser-facing sandbox service routing configuration. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalDomainConfig { - /// Cluster label used in `.` hostnames. - #[serde(default = "default_local_domain_cluster")] - pub cluster: String, - - /// Hostname suffix, defaults to `openshell.localhost`. - #[serde(default = "default_local_domain_suffix")] - pub suffix: String, +pub struct ServiceRoutingConfig { + /// Base domains accepted for `sandbox--service.` routes. + /// The first domain is used when the gateway prints endpoint URLs. + #[serde(default = "default_service_base_domains")] + pub service_base_domains: Vec, } /// TLS configuration. @@ -433,8 +433,7 @@ impl Config { client_tls_secret_name: String::new(), host_gateway_ip: String::new(), enable_user_namespaces: false, - enable_user_namespaces: false, - local_domain: LocalDomainConfig::default(), + service_routing: ServiceRoutingConfig::default(), } } @@ -585,26 +584,32 @@ impl Config { self } - /// Configure local-domain routing for browser-facing sandbox HTTP services. + /// Configure browser-facing sandbox service base domains. #[must_use] - pub fn with_local_domain( - mut self, - cluster: impl Into, - suffix: impl Into, - ) -> Self { - self.local_domain = LocalDomainConfig { - cluster: cluster.into(), - suffix: suffix.into(), + pub fn with_service_base_domains(mut self, domains: I) -> Self + where + I: IntoIterator, + S: Into, + { + let domains: Vec = domains + .into_iter() + .filter_map(|domain| normalize_service_base_domain(domain.into())) + .collect(); + self.service_routing = ServiceRoutingConfig { + service_base_domains: if domains.is_empty() { + default_service_base_domains() + } else { + domains + }, }; self } } -impl Default for LocalDomainConfig { +impl Default for ServiceRoutingConfig { fn default() -> Self { Self { - cluster: default_local_domain_cluster(), - suffix: default_local_domain_suffix(), + service_base_domains: default_service_base_domains(), } } } @@ -613,12 +618,17 @@ fn default_bind_address() -> SocketAddr { "127.0.0.1:8080".parse().expect("valid default address") } -fn default_local_domain_suffix() -> String { - DEFAULT_LOCAL_DOMAIN_SUFFIX.to_string() +fn default_service_base_domains() -> Vec { + vec![DEFAULT_SERVICE_BASE_DOMAIN.to_string()] +} + +pub fn default_service_base_domain_for_gateway(name: &str) -> String { + format!("{name}.{DEFAULT_SERVICE_BASE_DOMAIN_ROOT}") } -fn default_local_domain_cluster() -> String { - "openshell".to_string() +fn normalize_service_base_domain(domain: String) -> Option { + let domain = domain.trim().trim_matches('.'); + (!domain.is_empty()).then(|| domain.to_string()) } fn default_log_level() -> String { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index d82633f38..89e3f73f1 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -298,21 +298,14 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] oidc_scopes_claim: String, - /// Cluster label used in local-domain hostnames. + /// Base domains accepted for sandbox service routing. #[arg( - long, - env = "OPENSHELL_LOCAL_DOMAIN_CLUSTER", - default_value = "openshell" - )] - local_domain_cluster: String, - - /// Suffix used in local-domain hostnames. - #[arg( - long, - env = "OPENSHELL_LOCAL_DOMAIN_SUFFIX", - default_value = openshell_core::config::DEFAULT_LOCAL_DOMAIN_SUFFIX + long = "service-base-domain", + env = "OPENSHELL_SERVICE_BASE_DOMAINS", + value_delimiter = ',', + default_value = openshell_core::config::DEFAULT_SERVICE_BASE_DOMAIN )] - local_domain_suffix: String, + service_base_domains: Vec, } pub fn command() -> Command { @@ -410,7 +403,7 @@ async fn run_from_args(args: RunArgs) -> Result<()> { .with_ssh_gateway_port(args.ssh_gateway_port) .with_sandbox_ssh_port(args.sandbox_ssh_port) .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs) - .with_local_domain(args.local_domain_cluster, args.local_domain_suffix); + .with_service_base_domains(args.service_base_domains); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 000211b66..7bb0ff338 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -13,7 +13,7 @@ use tonic::{Request, Response, Status}; use uuid::Uuid; use crate::ServerState; -use crate::local_domain; +use crate::service_routing; const MAX_SERVICE_NAME_LEN: usize = 28; const MAX_SANDBOX_NAME_LEN: usize = 28; @@ -38,7 +38,7 @@ pub(super) async fn handle_expose_service( let now = super::current_time_ms().map_err(|e| Status::internal(format!("clock error: {e}")))?; - let key = local_domain::endpoint_key(&req.sandbox, &req.service); + let key = service_routing::endpoint_key(&req.sandbox, &req.service); let id = match state .store .get_message_by_name::(&key) @@ -70,7 +70,7 @@ pub(super) async fn handle_expose_service( .map_err(|e| Status::internal(format!("persist endpoint failed: {e}")))?; let url = if req.domain { - local_domain::endpoint_url(&state.config, &req.sandbox, &req.service).unwrap_or_default() + service_routing::endpoint_url(&state.config, &req.sandbox, &req.service).unwrap_or_default() } else { String::new() }; @@ -88,7 +88,7 @@ fn validate_endpoint_name(field: &str, value: &str, max_len: usize) -> Result<() } if value.len() > max_len { return Err(Status::invalid_argument(format!( - "{field} must be at most {max_len} characters for local-domain routing" + "{field} must be at most {max_len} characters for sandbox service routing" ))); } if value.contains("--") { diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 0d4f4c8b4..7bc6a2043 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -68,16 +68,19 @@ async fn render_metrics(State(handle): State) -> impl IntoResp 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, local_domain_first)) + .layer(middleware::from_fn_with_state( + state, + sandbox_service_routing_first, + )) } -async fn local_domain_first( +async fn sandbox_service_routing_first( State(state): State>, req: Request, next: Next, ) -> impl IntoResponse { - if crate::local_domain::is_local_domain_request(&req, &state.config.local_domain) { - return crate::local_domain::proxy_local_domain_request(state, req) + if crate::service_routing::is_sandbox_service_request(&req, &state.config.service_routing) { + return crate::service_routing::proxy_sandbox_service_request(state, req) .await .into_response(); } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index fe4d01e08..44dd641f7 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -26,12 +26,12 @@ mod compute; mod grpc; mod http; mod inference; -mod local_domain; mod multiplex; mod persistence; pub(crate) mod policy_store; mod sandbox_index; mod sandbox_watch; +mod service_routing; mod ssh_sessions; pub mod supervisor_session; mod tls; diff --git a/crates/openshell-server/src/local_domain.rs b/crates/openshell-server/src/service_routing.rs similarity index 81% rename from crates/openshell-server/src/local_domain.rs rename to crates/openshell-server/src/service_routing.rs index ce96e71f1..92dde7cb3 100644 --- a/crates/openshell-server/src/local_domain.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Local-domain HTTP routing for sandbox service endpoints. +//! Browser-facing HTTP routing for sandbox service endpoints. use axum::{body::Body, response::IntoResponse}; use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; use hyper_util::rt::TokioIo; use openshell_core::ObjectId; -use openshell_core::config::LocalDomainConfig; +use openshell_core::config::ServiceRoutingConfig; use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open}; use std::sync::Arc; use std::time::Duration; @@ -34,7 +34,7 @@ pub fn endpoint_url( sandbox: &str, service: &str, ) -> Option { - let host = endpoint_host(&config.local_domain, sandbox, service)?; + let host = endpoint_host(&config.service_routing, sandbox, service)?; let scheme = if config.tls.is_some() { "https" } else { @@ -49,44 +49,43 @@ pub fn endpoint_url( }) } -fn endpoint_host(config: &LocalDomainConfig, sandbox: &str, service: &str) -> Option { - if config.cluster.is_empty() || config.suffix.is_empty() { - return None; - } - Some(format!( - "{}--{}.{}.{}", - sandbox, service, config.cluster, config.suffix - )) +fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { + let base_domain = config.service_base_domains.first()?; + Some(format!("{sandbox}--{service}.{base_domain}")) } -pub fn parse_host(host: &str, config: &LocalDomainConfig) -> Option<(String, String)> { - if config.cluster.is_empty() || config.suffix.is_empty() { - return None; - } - +pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { let host = host.split_once(':').map_or(host, |(name, _)| name); - let expected_suffix = format!(".{}.{}", config.cluster, config.suffix); - let encoded = host.strip_suffix(&expected_suffix)?; - let (sandbox, service) = encoded.split_once("--")?; - if sandbox.is_empty() || service.is_empty() || sandbox.contains("--") || service.contains("--") - { - return None; + for base_domain in &config.service_base_domains { + let expected_suffix = format!(".{base_domain}"); + let Some(encoded) = host.strip_suffix(&expected_suffix) else { + continue; + }; + let (sandbox, service) = encoded.split_once("--")?; + if sandbox.is_empty() + || service.is_empty() + || sandbox.contains("--") + || service.contains("--") + { + return None; + } + return Some((sandbox.to_string(), service.to_string())); } - Some((sandbox.to_string(), service.to_string())) + None } -pub fn is_local_domain_request(req: &Request, config: &LocalDomainConfig) -> bool { +pub fn is_sandbox_service_request(req: &Request, config: &ServiceRoutingConfig) -> bool { request_host(req).is_some_and(|host| parse_host(host, config).is_some()) } -pub async fn proxy_local_domain_request( +pub async fn proxy_sandbox_service_request( state: Arc, req: Request, ) -> impl IntoResponse { let Some(host) = request_host(&req) else { return StatusCode::NOT_FOUND.into_response(); }; - let Some((sandbox_name, service_name)) = parse_host(host, &state.config.local_domain) else { + let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { return StatusCode::NOT_FOUND.into_response(); }; @@ -112,7 +111,7 @@ async fn proxy_to_endpoint( .get_message::(&endpoint.sandbox_id) .await .map_err(|err| { - warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "local-domain: failed to load sandbox"); + warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: failed to load sandbox"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; @@ -137,7 +136,7 @@ async fn proxy_to_endpoint( ) .await .map_err(|err| { - warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "local-domain: supervisor relay unavailable"); + warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: supervisor relay unavailable"); StatusCode::BAD_GATEWAY })?; @@ -150,27 +149,27 @@ async fn proxy_to_endpoint( .handshake(TokioIo::new(relay)) .await .map_err(|err| { - warn!(error = %err, "local-domain: failed to start upstream HTTP client"); + warn!(error = %err, "sandbox service routing: failed to start upstream HTTP client"); StatusCode::BAD_GATEWAY })?; if websocket_upgrade { tokio::spawn(async move { if let Err(err) = conn.with_upgrades().await { - warn!(error = %err, "local-domain: upstream WebSocket connection failed"); + warn!(error = %err, "sandbox service routing: upstream WebSocket connection failed"); } }); } else { tokio::spawn(async move { if let Err(err) = conn.await { - warn!(error = %err, "local-domain: upstream HTTP connection failed"); + warn!(error = %err, "sandbox service routing: upstream HTTP connection failed"); } }); } let upstream = build_upstream_request(req, target_port, websocket_upgrade)?; let mut response = sender.send_request(upstream).await.map_err(|err| { - warn!(error = %err, "local-domain: upstream HTTP request failed"); + warn!(error = %err, "sandbox service routing: upstream HTTP request failed"); StatusCode::BAD_GATEWAY })?; @@ -187,10 +186,10 @@ async fn proxy_to_endpoint( let _ = upstream.shutdown().await; } (Err(err), _) => { - warn!(error = %err, "local-domain: downstream WebSocket upgrade failed"); + warn!(error = %err, "sandbox service routing: downstream WebSocket upgrade failed"); } (_, Err(err)) => { - warn!(error = %err, "local-domain: upstream WebSocket upgrade failed"); + warn!(error = %err, "sandbox service routing: upstream WebSocket upgrade failed"); } } }); @@ -213,7 +212,7 @@ async fn load_endpoint( .get_message_by_name::(&key) .await .map_err(|err| { - warn!(error = %err, endpoint = %key, "local-domain: failed to load service endpoint"); + warn!(error = %err, endpoint = %key, "sandbox service routing: failed to load service endpoint"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND) @@ -351,15 +350,17 @@ fn is_gateway_auth_cookie(name: &str) -> bool { mod tests { use super::*; - fn config() -> LocalDomainConfig { - LocalDomainConfig { - cluster: "dev".to_string(), - suffix: "openshell.localhost".to_string(), + fn config() -> ServiceRoutingConfig { + ServiceRoutingConfig { + service_base_domains: vec![ + "dev.openshell.localhost".to_string(), + "svc.gateway.localhost".to_string(), + ], } } #[test] - fn parses_local_domain_host() { + fn parses_sandbox_service_host() { assert_eq!( parse_host("my-sandbox--web.dev.openshell.localhost", &config()), Some(("my-sandbox".to_string(), "web".to_string())) @@ -367,7 +368,7 @@ mod tests { } #[test] - fn parses_local_domain_host_with_port() { + fn parses_sandbox_service_host_with_port() { assert_eq!( parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), Some(("my-sandbox".to_string(), "web".to_string())) @@ -375,7 +376,15 @@ mod tests { } #[test] - fn rejects_wrong_cluster() { + fn parses_alternate_service_base_domain() { + assert_eq!( + parse_host("my-sandbox--web.svc.gateway.localhost", &config()), + Some(("my-sandbox".to_string(), "web".to_string())) + ); + } + + #[test] + fn rejects_unknown_base_domain() { assert_eq!( parse_host("my-sandbox--web.prod.openshell.localhost", &config()), None @@ -383,32 +392,32 @@ mod tests { } #[test] - fn identifies_local_domain_request_from_host_header() { + fn identifies_sandbox_service_request_from_host_header() { let request = Request::builder() .uri("/") .header(header::HOST, "my-sandbox--web.dev.openshell.localhost") .body(Body::empty()) .unwrap(); - assert!(is_local_domain_request(&request, &config())); + assert!(is_sandbox_service_request(&request, &config())); } #[test] - fn identifies_local_domain_request_from_http2_authority() { + fn identifies_sandbox_service_request_from_http2_authority() { let request = Request::builder() .uri("https://my-sandbox--web.dev.openshell.localhost/") .body(Body::empty()) .unwrap(); - assert!(is_local_domain_request(&request, &config())); + assert!(is_sandbox_service_request(&request, &config())); } #[test] - fn ignores_non_local_domain_request() { + fn ignores_non_sandbox_service_request() { let request = Request::builder() .uri("/") .header(header::HOST, "127.0.0.1:8080") .body(Body::empty()) .unwrap(); - assert!(!is_local_domain_request(&request, &config())); + assert!(!is_sandbox_service_request(&request, &config())); } #[test] diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 5965ad487..dd7cc8534 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -96,10 +96,8 @@ spec: - name: OPENSHELL_ENABLE_USER_NAMESPACES value: "true" {{- end }} - - name: OPENSHELL_LOCAL_DOMAIN_CLUSTER - value: {{ .Values.server.localDomain.cluster | quote }} - - name: OPENSHELL_LOCAL_DOMAIN_SUFFIX - value: {{ .Values.server.localDomain.suffix | quote }} + - name: OPENSHELL_SERVICE_BASE_DOMAINS + value: {{ join "," .Values.server.serviceBaseDomains | quote }} - name: OPENSHELL_SSH_HANDSHAKE_SECRET valueFrom: secretKeyRef: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 2561b6946..8b5f728d2 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -130,9 +130,8 @@ server: # Disable TLS entirely — the server listens on plaintext HTTP. # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false - localDomain: - cluster: "openshell" - suffix: "openshell.localhost" + serviceBaseDomains: + - "openshell.openshell.localhost" tls: # K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server certSecretName: openshell-server-tls diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index 4ac9cada9..5377f7ef5 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -46,9 +46,7 @@ spec: adminRole: "__OIDC_ADMIN_ROLE__" userRole: "__OIDC_USER_ROLE__" scopesClaim: "__OIDC_SCOPES_CLAIM__" - localDomain: - cluster: __LOCAL_DOMAIN_CLUSTER__ - suffix: __LOCAL_DOMAIN_SUFFIX__ + serviceBaseDomains: __SERVICE_BASE_DOMAINS__ tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca diff --git a/docs/reference/browser-certificates.mdx b/docs/reference/browser-certificates.mdx index 5ed551bd5..9b56997d2 100644 --- a/docs/reference/browser-certificates.mdx +++ b/docs/reference/browser-certificates.mdx @@ -2,12 +2,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 title: "Browser Certificates" -description: "Trust an OpenShell gateway CA and install the client certificate for browser access to local-domain sandbox services." +description: "Trust an OpenShell gateway CA and install the client certificate for browser access to sandbox services." keywords: "Generative AI, Cybersecurity, Browser Certificates, Firefox, mTLS, Local Domain" position: 2 --- -OpenShell local-domain URLs use the same gateway mTLS model as the CLI. Your browser needs two certificate changes before it can open `https://--..openshell.localhost:/`: +OpenShell sandbox service URLs use the same gateway mTLS model as the CLI. Your browser needs two certificate changes before it can open `https://--..openshell.localhost:/`: - Trust the gateway CA so the browser accepts the gateway server certificate. - Install the client certificate so the gateway accepts the browser connection. @@ -75,7 +75,7 @@ pk12util \ -W "" ``` -Find the active profile directory in `about:profiles` before running the commands. Restart Firefox after importing. If Firefox prompts for a client certificate when you visit the local-domain URL, choose the `OpenShell client` certificate. +Find the active profile directory in `about:profiles` before running the commands. Restart Firefox after importing. If Firefox prompts for a client certificate when you visit the sandbox service URL, choose the `OpenShell client` certificate. ## Chrome, Edge, and Safari on macOS @@ -129,7 +129,7 @@ Restart the browser after importing certificates. ## Troubleshooting -If the browser still shows a certificate warning, recreate gateways created before local-domain routing so their server certificate includes local-domain SANs: +If the browser still shows a certificate warning, recreate gateways created before sandbox service routing so their server certificate includes service base domain SANs: ```shell openshell gateway start --recreate diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 4f0dc3832..a13b40c61 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -141,7 +141,7 @@ openshell sandbox list --selector env=dev,team=platform ## Expose HTTP Services -Local-domain routing is enabled for gateways by default. Recreate gateways created before local-domain routing so their generated TLS certificate includes the local-domain SANs: +Sandbox service routing is enabled for gateways by default. Recreate gateways created before this routing mode so their generated TLS certificate includes the service base domain SANs: ```shell openshell gateway start --recreate From 648f0349b7e6b889c6017e9cd70d58b344273015 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 30 Apr 2026 18:11:36 -0700 Subject: [PATCH 04/13] refactor(gateway): expose sandbox service URLs by default --- crates/openshell-cli/src/main.rs | 8 +------- crates/openshell-cli/src/run.rs | 3 +-- crates/openshell-server/src/grpc/service.rs | 9 +++------ docs/sandboxes/manage-sandboxes.mdx | 2 +- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7351ca5cc..6f22b6712 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1659,10 +1659,6 @@ enum ServiceCommands { /// Loopback TCP port inside the sandbox. #[arg(long)] target_port: u16, - - /// Print and enable the browser URL for this sandbox service. - #[arg(long)] - domain: bool, }, } @@ -1959,14 +1955,12 @@ async fn main() -> Result<()> { sandbox, service, target_port, - domain, }), }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_edge_auth(&mut tls, &ctx.name); - run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, domain, &tls) - .await?; + run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls).await?; } // ----------------------------------------------------------- // Top-level logs (was `sandbox logs`) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a9bebb98a..ab4e0103e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3407,7 +3407,6 @@ pub async fn service_expose( sandbox: &str, service: &str, target_port: u16, - domain: bool, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3416,7 +3415,7 @@ pub async fn service_expose( sandbox: sandbox.to_string(), service: service.to_string(), target_port: u32::from(target_port), - domain, + domain: true, }) .await .map_err(|status| miette::miette!("expose service failed: {status}"))? diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 7bb0ff338..011b1e1b9 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -60,7 +60,7 @@ pub(super) async fn handle_expose_service( sandbox_name: req.sandbox.clone(), service_name: req.service.clone(), target_port: req.target_port, - domain: req.domain, + domain: true, }; state @@ -69,11 +69,8 @@ pub(super) async fn handle_expose_service( .await .map_err(|e| Status::internal(format!("persist endpoint failed: {e}")))?; - let url = if req.domain { - service_routing::endpoint_url(&state.config, &req.sandbox, &req.service).unwrap_or_default() - } else { - String::new() - }; + let url = service_routing::endpoint_url(&state.config, &req.sandbox, &req.service) + .unwrap_or_default(); Ok(Response::new(ServiceEndpointResponse { endpoint: Some(endpoint), diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index a13b40c61..7b7314358 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -150,7 +150,7 @@ openshell gateway start --recreate Expose a service that listens on loopback inside the sandbox: ```shell -openshell service expose my-sandbox web --target-port 8080 --domain +openshell service expose my-sandbox web --target-port 8080 ``` OpenShell prints a URL in the form `https://--..openshell.localhost:/`. HTTP traffic enters the same gateway listener as gRPC and routes by the `Host` header before gateway routes such as `/auth`, so application paths are preserved for the sandbox service. From d82e0342ac3cf51fdb246d4779d1534b78598dc3 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 30 Apr 2026 18:18:46 -0700 Subject: [PATCH 05/13] refactor(gateway): derive default service base domain --- crates/openshell-core/src/config.rs | 8 +++++--- crates/openshell-server/src/cli.rs | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 3cb124e4e..67863a002 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -39,8 +39,8 @@ pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; /// Default root used to derive browser-facing sandbox service domains. pub const DEFAULT_SERVICE_BASE_DOMAIN_ROOT: &str = "openshell.localhost"; -/// Default browser-facing sandbox service base domain. -pub const DEFAULT_SERVICE_BASE_DOMAIN: &str = "openshell.openshell.localhost"; +/// Default gateway name used when no gateway-specific service base domain exists. +pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; /// Default OCI image for the openshell-sandbox supervisor binary. pub const DEFAULT_SUPERVISOR_IMAGE: &str = "openshell/supervisor:latest"; @@ -619,7 +619,9 @@ fn default_bind_address() -> SocketAddr { } fn default_service_base_domains() -> Vec { - vec![DEFAULT_SERVICE_BASE_DOMAIN.to_string()] + vec![default_service_base_domain_for_gateway( + DEFAULT_GATEWAY_NAME, + )] } pub fn default_service_base_domain_for_gateway(name: &str) -> String { diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 89e3f73f1..15ac7b39e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -302,8 +302,7 @@ struct RunArgs { #[arg( long = "service-base-domain", env = "OPENSHELL_SERVICE_BASE_DOMAINS", - value_delimiter = ',', - default_value = openshell_core::config::DEFAULT_SERVICE_BASE_DOMAIN + value_delimiter = ',' )] service_base_domains: Vec, } From 4960b8894c7c49b0cda74340f51d90a00e196667 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 30 Apr 2026 18:59:32 -0700 Subject: [PATCH 06/13] chore(ci): rerun branch checks From 4a5dd2dd54ce0f361f55520a08eb2207bbae82bf Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 11 May 2026 21:10:22 -0700 Subject: [PATCH 07/13] fix(cli): align service exposure auth after rebase --- crates/openshell-cli/src/main.rs | 2 +- crates/openshell-cli/src/run.rs | 24 ++++++++++++------------ crates/openshell-server/src/grpc/mod.rs | 5 ++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 6f22b6712..27fe19cfc 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1959,7 +1959,7 @@ async fn main() -> Result<()> { }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); - apply_edge_auth(&mut tls, &ctx.name); + apply_auth(&mut tls, &ctx.name); run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls).await?; } // ----------------------------------------------------------- diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ab4e0103e..15407a588 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -28,18 +28,18 @@ use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, DeleteProviderProfileRequest, DeleteProviderRequest, DeleteSandboxRequest, - DetachSandboxProviderRequest, ExecSandboxRequest, GetClusterInferenceRequest, - GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, - GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource, - PolicyStatus, Provider, ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, - RejectDraftChunkRequest, RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, SettingValue, - TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest, - WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, - ExposeServiceRequest, + DetachSandboxProviderRequest, ExecSandboxRequest, ExposeServiceRequest, + GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, + GetGatewayConfigRequest, GetProviderProfileRequest, GetProviderRequest, + GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, + GetSandboxRequest, HealthRequest, ImportProviderProfilesRequest, LintProviderProfilesRequest, + ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, + ListSandboxProvidersRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider, + ProviderProfile, ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, + SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, + TcpRelayTarget, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, + exec_sandbox_event, setting_value, tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index b7bca97f1..9d93b9b16 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -20,9 +20,8 @@ use openshell_core::proto::{ ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, - GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, - GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, - GetSandboxPolicyStatusResponse, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, + GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, ImportProviderProfilesResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, From 3b01d18de657893a30e5936c830ebcdf2d41592d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 11 May 2026 21:34:33 -0700 Subject: [PATCH 08/13] fix(gateway): align service routing relay after rebase --- crates/openshell-cli/src/run.rs | 1 + crates/openshell-server/src/grpc/mod.rs | 5 ++--- crates/openshell-server/src/grpc/sandbox.rs | 6 +----- crates/openshell-server/src/service_routing.rs | 6 +++++- .../tests/supervisor_relay_integration.rs | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 15407a588..9f4bc2516 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5839,6 +5839,7 @@ mod tests { inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message, ready_false_condition_message, resolve_from, sandbox_should_persist, + service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 9d93b9b16..8c0fa730e 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -33,9 +33,8 @@ use openshell_core::proto::{ ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, - UndoDraftChunkResponse, - UpdateConfigRequest, UpdateConfigResponse, UpdateProviderRequest, WatchSandboxRequest, - open_shell_server::OpenShell, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, UpdateProviderRequest, + WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 52314a12b..ad37a5482 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -656,11 +656,7 @@ pub(super) async fn handle_exec_sandbox( // while still failing quickly during normal operation. let (channel_id, relay_rx) = state .supervisor_sessions - .open_relay( - sandbox.object_id(), - None, - std::time::Duration::from_secs(15), - ) + .open_relay(sandbox.object_id(), std::time::Duration::from_secs(15)) .await .map_err(|e| Status::unavailable(format!("supervisor relay failed: {e}")))?; diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 92dde7cb3..41a5fe9a2 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -143,7 +143,11 @@ async fn proxy_to_endpoint( let relay = tokio::time::timeout(Duration::from_secs(10), relay_rx) .await .map_err(|_| StatusCode::BAD_GATEWAY)? - .map_err(|_| StatusCode::BAD_GATEWAY)?; + .map_err(|_| StatusCode::BAD_GATEWAY)? + .map_err(|err| { + warn!(error = %err, "sandbox service routing: relay target open failed"); + StatusCode::BAD_GATEWAY + })?; let (mut sender, conn) = hyper::client::conn::http1::Builder::new() .handshake(TokioIo::new(relay)) diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index a697cf1fc..94b0394ab 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -442,7 +442,7 @@ async fn relay_round_trips_bytes() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", None, Duration::from_secs(2)) + .open_relay("sbx", Duration::from_secs(2)) .await .expect("open_relay"); @@ -472,7 +472,7 @@ async fn relay_closes_cleanly_when_gateway_drops() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", None, Duration::from_secs(2)) + .open_relay("sbx", Duration::from_secs(2)) .await .expect("open_relay"); let _ = session_rx.recv().await.expect("RelayOpen"); @@ -497,7 +497,7 @@ async fn relay_sees_eof_when_supervisor_closes() { let mut session_rx = register_session(®istry, "sbx"); let (channel_id, relay_rx) = registry - .open_relay("sbx", None, Duration::from_secs(2)) + .open_relay("sbx", Duration::from_secs(2)) .await .expect("open_relay"); let _ = session_rx.recv().await.expect("RelayOpen"); @@ -542,7 +542,7 @@ async fn open_relay_times_out_when_no_session() { let _channel = spawn_gateway(Arc::clone(®istry)).await; let err = registry - .open_relay("missing", None, Duration::from_millis(100)) + .open_relay("missing", Duration::from_millis(100)) .await .expect_err("should time out"); assert_eq!(err.code(), tonic::Code::Unavailable); @@ -555,13 +555,13 @@ async fn concurrent_relays_multiplex_independently() { let mut session_rx = register_session(®istry, "sbx"); let (id_a, rx_a) = registry - .open_relay("sbx", None, Duration::from_secs(2)) + .open_relay("sbx", Duration::from_secs(2)) .await .expect("open_relay a"); let _ = session_rx.recv().await.expect("RelayOpen a"); let (id_b, rx_b) = registry - .open_relay("sbx", None, Duration::from_secs(2)) + .open_relay("sbx", Duration::from_secs(2)) .await .expect("open_relay b"); let _ = session_rx.recv().await.expect("RelayOpen b"); @@ -608,7 +608,7 @@ async fn open_relay_enforces_per_sandbox_cap_under_concurrent_burst() { for _ in 0..64 { let r = Arc::clone(®istry); handles.push(tokio::spawn(async move { - r.open_relay("sbx", None, Duration::from_secs(1)).await + r.open_relay("sbx", Duration::from_secs(1)).await })); } @@ -635,7 +635,7 @@ async fn open_relay_enforces_per_sandbox_cap_under_concurrent_burst() { // leak onto unrelated tenants. let _other_rx = register_session_with_capacity(®istry, "sbx-other", 8); registry - .open_relay("sbx-other", None, Duration::from_secs(1)) + .open_relay("sbx-other", Duration::from_secs(1)) .await .expect("other sandbox should not be affected by sbx cap"); } From 1fc0da8ffb1ca2b8e441e6319b3473c9b0b37357 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 11 May 2026 22:40:38 -0700 Subject: [PATCH 09/13] feat(gateway): add loopback service HTTP routing --- architecture/gateway.md | 13 ++ crates/openshell-core/src/config.rs | 47 +++- crates/openshell-server/src/cli.rs | 59 ++++- crates/openshell-server/src/http.rs | 217 +++++++++++++++++- crates/openshell-server/src/lib.rs | 143 +++++++++++- crates/openshell-server/src/multiplex.rs | 22 +- .../openshell-server/src/service_routing.rs | 66 +++++- .../helm/openshell/templates/statefulset.yaml | 2 + deploy/helm/openshell/values.yaml | 3 + docs/kubernetes/setup.mdx | 1 + docs/reference/browser-certificates.mdx | 138 ----------- docs/sandboxes/manage-sandboxes.mdx | 6 +- docs/security/best-practices.mdx | 6 +- 13 files changed, 552 insertions(+), 171 deletions(-) delete mode 100644 docs/reference/browser-certificates.mdx diff --git a/architecture/gateway.md b/architecture/gateway.md index a1320cfaa..b307fb2dd 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -25,6 +25,13 @@ identity. The gateway listens on one service port and multiplexes gRPC and HTTP traffic. The default deployment mode is mTLS: clients and sandbox workloads present a certificate signed by the deployment CA before reaching application handlers. +When that service port is bound to loopback, the listener can also accept +plaintext HTTP on the same port for sandbox service subdomains only. That local +browser path is enabled by default and disabled with +`--enable-loopback-service-http=false`; it never serves gateway APIs, auth, +health, metrics, or tunnel routes. The plaintext service router also rejects +browser requests whose Fetch Metadata, Origin, or Referer headers indicate a +cross-origin or sibling-subdomain request. Supported auth modes: @@ -141,6 +148,12 @@ inside the sandbox. The gateway validates the token and sandbox readiness, sends a targeted `RelayOpen` to the supervisor, then bridges `TcpForwardFrame::Data` to `RelayFrame::Data` until either side closes. +Browser service URLs use the same supervisor relay path after host-based +routing resolves `sandbox--service.` to a stored service endpoint. +TLS-enabled loopback gateways print `http://` URLs when loopback plaintext +service HTTP is enabled; non-loopback TLS gateways continue to print `https://` +URLs. + For `target.tcp`, the gateway only accepts loopback destinations such as `localhost`, `127.0.0.0/8`, or `::1`. The gateway never needs to know or dial a sandbox pod IP; supervisors connect outbound and bridge only the explicit target diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 67863a002..ca85bb8de 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -320,6 +320,11 @@ pub struct ServiceRoutingConfig { /// The first domain is used when the gateway prints endpoint URLs. #[serde(default = "default_service_base_domains")] pub service_base_domains: Vec, + + /// Enable TLS-enabled loopback gateway listeners to also accept plaintext + /// HTTP for sandbox service hostnames. + #[serde(default = "default_enable_loopback_service_http")] + pub enable_loopback_service_http: bool, } /// TLS configuration. @@ -595,21 +600,28 @@ impl Config { .into_iter() .filter_map(|domain| normalize_service_base_domain(domain.into())) .collect(); - self.service_routing = ServiceRoutingConfig { - service_base_domains: if domains.is_empty() { - default_service_base_domains() - } else { - domains - }, + self.service_routing.service_base_domains = if domains.is_empty() { + default_service_base_domains() + } else { + domains }; self } + + /// Enable or disable plaintext HTTP routing for loopback sandbox service + /// hostnames on TLS-enabled gateway listeners. + #[must_use] + pub const fn with_loopback_service_http(mut self, enabled: bool) -> Self { + self.service_routing.enable_loopback_service_http = enabled; + self + } } impl Default for ServiceRoutingConfig { fn default() -> Self { Self { service_base_domains: default_service_base_domains(), + enable_loopback_service_http: default_enable_loopback_service_http(), } } } @@ -624,6 +636,10 @@ fn default_service_base_domains() -> Vec { )] } +const fn default_enable_loopback_service_http() -> bool { + true +} + pub fn default_service_base_domain_for_gateway(name: &str) -> String { format!("{name}.{DEFAULT_SERVICE_BASE_DOMAIN_ROOT}") } @@ -717,6 +733,25 @@ mod tests { assert!(cfg.health_bind_address.is_none()); } + #[test] + fn service_routing_allows_loopback_plaintext_http_by_default() { + let cfg = Config::new(None); + assert!(cfg.service_routing.enable_loopback_service_http); + } + + #[test] + fn service_base_domain_update_preserves_loopback_plaintext_http_flag() { + let cfg = Config::new(None) + .with_loopback_service_http(false) + .with_service_base_domains(["dev.openshell.localhost"]); + + assert_eq!( + cfg.service_routing.service_base_domains, + vec!["dev.openshell.localhost"] + ); + assert!(!cfg.service_routing.enable_loopback_service_http); + } + #[test] fn config_with_health_bind_address_sets_address() { let addr: SocketAddr = "0.0.0.0:9090".parse().expect("valid address"); diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 15ac7b39e..b2330d48e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -3,7 +3,7 @@ //! Shared CLI entrypoint for the gateway binaries. -use clap::{Command, CommandFactory, FromArgMatches, Parser}; +use clap::{ArgAction, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; use openshell_core::config::{ @@ -42,6 +42,7 @@ enum Commands { } #[derive(clap::Args, Debug)] +#[allow(clippy::struct_excessive_bools)] struct RunArgs { /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] @@ -305,6 +306,15 @@ struct RunArgs { value_delimiter = ',' )] service_base_domains: Vec, + + /// Enable plaintext HTTP routing for loopback sandbox service URLs. + #[arg( + long, + env = "OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", + default_value_t = true, + action = ArgAction::Set + )] + enable_loopback_service_http: bool, } pub fn command() -> Command { @@ -402,7 +412,8 @@ async fn run_from_args(args: RunArgs) -> Result<()> { .with_ssh_gateway_port(args.ssh_gateway_port) .with_sandbox_ssh_port(args.sandbox_ssh_port) .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs) - .with_service_base_domains(args.service_base_domains); + .with_service_base_domains(args.service_base_domains) + .with_loopback_service_http(args.enable_loopback_service_http); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); @@ -580,6 +591,50 @@ mod tests { assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); } + #[test] + fn command_enables_loopback_service_http_by_default() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); + + let cli = + Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + + assert!(cli.run.enable_loopback_service_http); + } + + #[test] + fn command_disables_loopback_service_http_with_false_value() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); + + let cli = Cli::try_parse_from([ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--enable-loopback-service-http=false", + ]) + .unwrap(); + + assert!(!cli.run.enable_loopback_service_http); + } + + #[test] + fn command_reads_loopback_service_http_from_env() { + let _lock = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "false"); + + let cli = + Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + + assert!(!cli.run.enable_loopback_service_http); + } + #[test] fn generate_certs_subcommand_parses_without_db_url() { let _lock = ENV_LOCK diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 7bc6a2043..5c7967599 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -6,7 +6,7 @@ use axum::{ Json, Router, extract::{Request, State}, - http::StatusCode, + http::{HeaderMap, StatusCode, header}, middleware::{self, Next}, response::IntoResponse, routing::get, @@ -74,6 +74,16 @@ pub fn http_router(state: Arc) -> Router { )) } +/// Create the plaintext loopback-only router for browser service endpoints. +/// +/// This router intentionally exposes only sandbox service routing. It does not +/// include gRPC, auth, health, metrics, or WebSocket tunnel routes. +pub fn service_http_router(state: Arc) -> Router { + Router::new() + .fallback(sandbox_service_routing_only) + .with_state(state) +} + async fn sandbox_service_routing_first( State(state): State>, req: Request, @@ -86,3 +96,208 @@ async fn sandbox_service_routing_first( } next.run(req).await.into_response() } + +async fn sandbox_service_routing_only( + State(state): State>, + req: Request, +) -> impl IntoResponse { + if !crate::service_routing::is_sandbox_service_request(&req, &state.config.service_routing) { + return StatusCode::NOT_FOUND.into_response(); + } + if !browser_context_allows_plaintext_service_request(&req) { + return StatusCode::FORBIDDEN.into_response(); + } + crate::service_routing::proxy_sandbox_service_request(state, req) + .await + .into_response() +} + +fn browser_context_allows_plaintext_service_request(req: &Request) -> bool { + if let Some(fetch_site) = header_str(req.headers(), "sec-fetch-site") + && !matches!( + fetch_site.to_ascii_lowercase().as_str(), + "same-origin" | "none" + ) + { + return false; + } + + if let Some(origin) = header_str(req.headers(), header::ORIGIN.as_str()) { + let Some(request_origin) = request_origin(req) else { + return false; + }; + return parse_origin(origin).is_some_and(|origin| origin == request_origin); + } + + if let Some(referer) = header_str(req.headers(), header::REFERER.as_str()) { + let Some(request_origin) = request_origin(req) else { + return false; + }; + return parse_origin(referer).is_some_and(|origin| origin == request_origin); + } + + true +} + +fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name)?.to_str().ok() +} + +#[derive(Debug, Eq, PartialEq)] +struct Origin { + scheme: String, + host: String, + port: u16, +} + +fn request_origin(req: &Request) -> Option { + let host = crate::service_routing::request_host(req)?; + parse_origin_authority("http", host) +} + +fn parse_origin(value: &str) -> Option { + if value.eq_ignore_ascii_case("null") { + return None; + } + let (scheme, rest) = value.split_once("://")?; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + parse_origin_authority(scheme, &rest[..authority_end]) +} + +fn parse_origin_authority(scheme: &str, authority: &str) -> Option { + let scheme = scheme.to_ascii_lowercase(); + let default_port = match scheme.as_str() { + "http" => 80, + "https" => 443, + _ => return None, + }; + let authority = authority.trim(); + if authority.is_empty() || authority.contains('@') { + return None; + } + + let (host, port) = split_host_port(authority)?; + let host = normalize_host(host)?; + Some(Origin { + scheme, + host, + port: port.unwrap_or(default_port), + }) +} + +fn split_host_port(authority: &str) -> Option<(&str, Option)> { + if let Some(rest) = authority.strip_prefix('[') { + let (host, rest) = rest.split_once(']')?; + let port = if rest.is_empty() { + None + } else { + Some(rest.strip_prefix(':')?.parse().ok()?) + }; + return Some((host, port)); + } + + match authority.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) => { + Some((host, Some(port.parse().ok()?))) + } + Some(_) if authority.matches(':').count() == 1 => None, + _ => Some((authority, None)), + } +} + +fn normalize_host(host: &str) -> Option { + let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); + (!host.is_empty()).then_some(host) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service_request(headers: &[(&str, &str)]) -> Request { + let mut builder = Request::builder() + .uri("/some/path") + .header(header::HOST, "sandbox--web.dev.openshell.localhost:8080"); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(axum::body::Body::empty()).unwrap() + } + + #[test] + fn plaintext_service_browser_context_allows_direct_tools() { + let req = service_request(&[]); + + assert!(browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_allows_same_origin_fetch_metadata() { + let req = service_request(&[("sec-fetch-site", "same-origin")]); + + assert!(browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_allows_direct_navigation_fetch_metadata() { + let req = service_request(&[("sec-fetch-site", "none")]); + + assert!(browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_rejects_cross_site_fetch_metadata() { + let req = service_request(&[("sec-fetch-site", "cross-site")]); + + assert!(!browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_rejects_same_site_sibling_requests() { + let req = service_request(&[("sec-fetch-site", "same-site")]); + + assert!(!browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_requires_matching_origin() { + let req = + service_request(&[("origin", "http://sandbox--web.dev.openshell.localhost:8080")]); + + assert!(browser_context_allows_plaintext_service_request(&req)); + + let req = service_request(&[( + "origin", + "http://sandbox--other.dev.openshell.localhost:8080", + )]); + + assert!(!browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_requires_matching_referer() { + let req = service_request(&[( + "referer", + "http://sandbox--web.dev.openshell.localhost:8080/page", + )]); + + assert!(browser_context_allows_plaintext_service_request(&req)); + + let req = service_request(&[( + "referer", + "http://sandbox--other.dev.openshell.localhost:8080/page", + )]); + + assert!(!browser_context_allows_plaintext_service_request(&req)); + } + + #[test] + fn plaintext_service_browser_context_rejects_mismatched_origin_scheme() { + let req = service_request(&[( + "origin", + "https://sandbox--web.dev.openshell.localhost:8080", + )]); + + assert!(!browser_context_allows_plaintext_service_request(&req)); + } +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 44dd641f7..93ccdc9dc 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -51,7 +51,7 @@ use tracing::{debug, error, info, warn}; use compute::{ComputeRuntime, DockerComputeConfig, VmComputeConfig}; pub use grpc::OpenShellService; -pub use http::{health_router, http_router, metrics_router}; +pub use http::{health_router, http_router, metrics_router, service_http_router}; pub use multiplex::{MultiplexService, MultiplexedService}; use openshell_driver_kubernetes::KubernetesComputeConfig; use persistence::Store; @@ -299,12 +299,14 @@ pub async fn run_server( let (shutdown_tx, shutdown_rx) = watch::channel(false); let mut listener_tasks = Vec::with_capacity(gateway_listeners.len()); + let enable_loopback_service_http = config.service_routing.enable_loopback_service_http; for (listener, listen_addr) in gateway_listeners { listener_tasks.push(tokio::spawn(serve_gateway_listener( listener, listen_addr, service.clone(), tls_acceptor.clone(), + enable_loopback_service_http, shutdown_rx.clone(), ))); } @@ -364,6 +366,7 @@ async fn serve_gateway_listener( listen_addr: SocketAddr, service: MultiplexService, tls_acceptor: Option, + enable_loopback_service_http: bool, mut shutdown: watch::Receiver, ) { loop { @@ -385,31 +388,118 @@ async fn serve_gateway_listener( } }; - spawn_gateway_connection(stream, addr, service.clone(), tls_acceptor.clone()); + spawn_gateway_connection( + stream, + addr, + listen_addr, + service.clone(), + tls_acceptor.clone(), + enable_loopback_service_http, + ); } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectionProtocol { + Tls, + PlainHttp, + Unknown, +} + +async fn classify_connection_protocol(stream: &TcpStream) -> std::io::Result { + let mut prefix = [0_u8; 8]; + let read = stream.peek(&mut prefix).await?; + Ok(classify_initial_bytes(&prefix[..read])) +} + +fn classify_initial_bytes(prefix: &[u8]) -> ConnectionProtocol { + if looks_like_tls(prefix) { + ConnectionProtocol::Tls + } else if looks_like_http(prefix) { + ConnectionProtocol::PlainHttp + } else { + ConnectionProtocol::Unknown + } +} + +fn looks_like_tls(prefix: &[u8]) -> bool { + prefix.len() >= 3 && prefix[0] == 0x16 && prefix[1] == 0x03 +} + +fn looks_like_http(prefix: &[u8]) -> bool { + const METHODS: [&[u8]; 10] = [ + b"GET ", + b"POST ", + b"PUT ", + b"PATCH ", + b"DELETE ", + b"HEAD ", + b"OPTIONS ", + b"TRACE ", + b"CONNECT ", + b"PRI ", + ]; + + if prefix.is_empty() { + return false; + } + METHODS + .iter() + .any(|method| method.starts_with(prefix) || prefix.starts_with(method)) +} + +fn allow_plaintext_service_http( + enabled: bool, + listen_addr: SocketAddr, + peer_addr: SocketAddr, +) -> bool { + enabled && listen_addr.ip().is_loopback() && peer_addr.ip().is_loopback() +} + fn spawn_gateway_connection( stream: TcpStream, addr: SocketAddr, + listen_addr: SocketAddr, service: MultiplexService, tls_acceptor: Option, + enable_loopback_service_http: bool, ) { if let Some(acceptor) = tls_acceptor { tokio::spawn(async move { - match acceptor.inner().accept(stream).await { - Ok(tls_stream) => { - if let Err(e) = service.serve(tls_stream).await { - error!(error = %e, client = %addr, "Connection error"); + match classify_connection_protocol(&stream).await { + Ok(ConnectionProtocol::PlainHttp) + if allow_plaintext_service_http( + enable_loopback_service_http, + listen_addr, + addr, + ) => + { + if let Err(e) = service.serve_service_http(stream).await { + error!(error = %e, client = %addr, listen = %listen_addr, "Plaintext service HTTP connection error"); } } - Err(e) => { - if is_benign_tls_handshake_failure(&e) { - debug!(error = %e, client = %addr, "TLS handshake closed early"); - } else { - error!(error = %e, client = %addr, "TLS handshake failed"); + Ok(ConnectionProtocol::PlainHttp) => { + warn!(client = %addr, listen = %listen_addr, "Rejected plaintext HTTP on non-loopback gateway listener"); + } + Ok(ConnectionProtocol::Tls | ConnectionProtocol::Unknown) => { + match acceptor.inner().accept(stream).await { + Ok(tls_stream) => { + if let Err(e) = service.serve(tls_stream).await { + error!(error = %e, client = %addr, "Connection error"); + } + } + Err(e) => { + if is_benign_tls_handshake_failure(&e) { + debug!(error = %e, client = %addr, "TLS handshake closed early"); + } else { + error!(error = %e, client = %addr, "TLS handshake failed"); + } + } } } + Err(e) => { + debug!(error = %e, client = %addr, "Failed to inspect connection preface"); + } } }); } else { @@ -635,6 +725,7 @@ fn configured_compute_driver(config: &Config) -> Result { #[cfg(test)] mod tests { use super::{ + ConnectionProtocol, allow_plaintext_service_http, classify_initial_bytes, configured_compute_driver, gateway_listener_addresses, is_benign_tls_handshake_failure, }; use openshell_core::{ComputeDriverKind, Config}; @@ -661,6 +752,36 @@ mod tests { } } + #[test] + fn classifies_tls_and_plain_http_prefaces() { + assert_eq!( + classify_initial_bytes(&[0x16, 0x03, 0x01, 0x00]), + ConnectionProtocol::Tls + ); + assert_eq!( + classify_initial_bytes(b"GET / HTTP/1.1\r\n"), + ConnectionProtocol::PlainHttp + ); + assert_eq!(classify_initial_bytes(b"G"), ConnectionProtocol::PlainHttp); + assert_eq!( + classify_initial_bytes(b"\x00\x01\x02"), + ConnectionProtocol::Unknown + ); + } + + #[test] + fn plaintext_service_http_requires_loopback_listener_and_peer() { + let loopback: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let peer: SocketAddr = "127.0.0.1:54000".parse().unwrap(); + let wildcard: SocketAddr = "0.0.0.0:8080".parse().unwrap(); + let remote_peer: SocketAddr = "192.0.2.10:54000".parse().unwrap(); + + assert!(allow_plaintext_service_http(true, loopback, peer)); + assert!(!allow_plaintext_service_http(false, loopback, peer)); + assert!(!allow_plaintext_service_http(true, wildcard, peer)); + assert!(!allow_plaintext_service_http(true, loopback, remote_peer)); + } + #[test] fn configured_compute_driver_triggers_auto_detection_when_empty() { let config = Config::new(None).with_compute_drivers([]); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index bca9a2171..9f74723eb 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -14,6 +14,7 @@ use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, server::conn::auto::Builder, + service::TowerToHyperService, }; use metrics::{counter, histogram}; use openshell_core::proto::{ @@ -31,7 +32,7 @@ use tracing::Span; use crate::{ OpenShellService, ServerState, auth::authz::AuthzPolicy, auth::oidc, http_router, - inference::InferenceService, + inference::InferenceService, service_http_router, }; /// Request-ID generator that produces a UUID v4 for each inbound request. @@ -169,6 +170,25 @@ impl MultiplexService { Ok(()) } + + /// Serve a plaintext HTTP connection for sandbox service endpoints only. + pub async fn serve_service_http( + &self, + stream: S, + ) -> Result<(), Box> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let http_service = TowerToHyperService::new(request_id_middleware!(service_http_router( + self.state.clone() + ))); + + Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), http_service) + .await?; + + Ok(()) + } } /// Combined gRPC service that routes between `OpenShell` and Inference services diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 41a5fe9a2..5a722dd81 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -35,11 +35,7 @@ pub fn endpoint_url( service: &str, ) -> Option { let host = endpoint_host(&config.service_routing, sandbox, service)?; - let scheme = if config.tls.is_some() { - "https" - } else { - "http" - }; + let scheme = endpoint_scheme(config); let port = config.bind_address.port(); let include_port = !matches!((scheme, port), ("https", 443) | ("http", 80)); Some(if include_port { @@ -49,6 +45,17 @@ pub fn endpoint_url( }) } +fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { + if config.tls.is_none() + || (config.bind_address.ip().is_loopback() + && config.service_routing.enable_loopback_service_http) + { + "http" + } else { + "https" + } +} + fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { let base_domain = config.service_base_domains.first()?; Some(format!("{sandbox}--{service}.{base_domain}")) @@ -270,7 +277,7 @@ fn host_header(headers: &HeaderMap) -> Option<&str> { headers.get(header::HOST)?.to_str().ok() } -fn request_host(req: &Request) -> Option<&str> { +pub fn request_host(req: &Request) -> Option<&str> { host_header(req.headers()).or_else(|| req.uri().authority().map(http::uri::Authority::as_str)) } @@ -360,9 +367,56 @@ mod tests { "dev.openshell.localhost".to_string(), "svc.gateway.localhost".to_string(), ], + ..ServiceRoutingConfig::default() + } + } + + fn tls_config() -> openshell_core::TlsConfig { + openshell_core::TlsConfig { + cert_path: "server.crt".into(), + key_path: "server.key".into(), + client_ca_path: "ca.crt".into(), + allow_unauthenticated: false, } } + #[test] + fn endpoint_url_uses_plain_http_for_loopback_tls_gateway() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_service_base_domains(["dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "my-sandbox", "web").as_deref(), + Some("http://my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + + #[test] + fn endpoint_url_keeps_https_for_non_loopback_tls_gateway() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("0.0.0.0:8080".parse().unwrap()) + .with_service_base_domains(["dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "my-sandbox", "web").as_deref(), + Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + + #[test] + fn endpoint_url_keeps_https_when_loopback_plaintext_http_is_disabled() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_service_base_domains(["dev.openshell.localhost"]) + .with_loopback_service_http(false); + + assert_eq!( + endpoint_url(&cfg, "my-sandbox", "web").as_deref(), + Some("https://my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + #[test] fn parses_sandbox_service_host() { assert_eq!( diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index dd7cc8534..5e57aa613 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -98,6 +98,8 @@ spec: {{- end }} - name: OPENSHELL_SERVICE_BASE_DOMAINS value: {{ join "," .Values.server.serviceBaseDomains | quote }} + - name: OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP + value: {{ .Values.server.enableLoopbackServiceHttp | quote }} - name: OPENSHELL_SSH_HANDSHAKE_SECRET valueFrom: secretKeyRef: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 8b5f728d2..18f003ad0 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -130,6 +130,9 @@ server: # Disable TLS entirely — the server listens on plaintext HTTP. # Set to true when a reverse proxy / tunnel terminates TLS at the edge. disableTls: false + # Enable plaintext HTTP routing for loopback sandbox service URLs on + # TLS-enabled gateways. + enableLoopbackServiceHttp: true serviceBaseDomains: - "openshell.openshell.localhost" tls: diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index 84fccc036..25180a924 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -136,6 +136,7 @@ The most commonly changed values are: | `server.grpcEndpoint` | Endpoint that sandbox supervisors use to call back to the gateway. Must be reachable from inside the cluster. | | `server.sshGatewayHost` / `server.sshGatewayPort` | Public host and port returned to CLI clients for SSH proxy connections. Required when the gateway is exposed externally. | | `server.disableTls` | Run the gateway over plaintext HTTP. Use only behind a trusted transport. | +| `server.enableLoopbackServiceHttp` | Enable local plaintext HTTP for loopback sandbox service URLs. Defaults to `true`. | | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect based on cluster version: clusters running Kubernetes 1.35 or later use `image-volume` (ImageVolume GA in 1.36); older clusters use `init-container`. Set explicitly to `image-volume` on Kubernetes 1.33 or 1.34 with the ImageVolume feature gate enabled, or to `init-container` to force the legacy path on any version. | Use a values file for repeatable deployments: diff --git a/docs/reference/browser-certificates.mdx b/docs/reference/browser-certificates.mdx deleted file mode 100644 index 9b56997d2..000000000 --- a/docs/reference/browser-certificates.mdx +++ /dev/null @@ -1,138 +0,0 @@ ---- -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -title: "Browser Certificates" -description: "Trust an OpenShell gateway CA and install the client certificate for browser access to sandbox services." -keywords: "Generative AI, Cybersecurity, Browser Certificates, Firefox, mTLS, Local Domain" -position: 2 ---- - -OpenShell sandbox service URLs use the same gateway mTLS model as the CLI. Your browser needs two certificate changes before it can open `https://--..openshell.localhost:/`: - -- Trust the gateway CA so the browser accepts the gateway server certificate. -- Install the client certificate so the gateway accepts the browser connection. - -The CLI stores gateway certificates under `~/.config/openshell/gateways//mtls/`: - -| File | Browser use | -|---|---| -| `ca.crt` | Import as a trusted certificate authority. | -| `tls.crt` and `tls.key` | Convert to PKCS#12, then import as the browser client certificate. | - -## Create a Client Certificate Bundle - -Browsers import client certificates as PKCS#12 (`.p12`) bundles. Create one from the OpenShell PEM files: - -```shell -GATEWAY=navigator # replace with your gateway name -MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" - -openssl pkcs12 -export \ - -inkey "$MTLS_DIR/tls.key" \ - -in "$MTLS_DIR/tls.crt" \ - -certfile "$MTLS_DIR/ca.crt" \ - -name "OpenShell $GATEWAY client" \ - -out "$MTLS_DIR/openshell-$GATEWAY-client.p12" \ - -passout pass: -``` - -This writes `openshell--client.p12` with an empty import password. If you set a password, enter it when the browser asks during import. - -## Firefox - -Firefox uses its own NSS certificate database and does not always use the macOS keychain. - -### Manual Import - -Open Firefox settings and import both certificates: - -1. Open `about:preferences#privacy`. -2. Scroll to **Certificates** and select **View Certificates**. -3. In **Authorities**, select **Import**, choose `~/.config/openshell/gateways//mtls/ca.crt`, and enable **Trust this CA to identify websites**. -4. In **Your Certificates**, select **Import**, choose `~/.config/openshell/gateways//mtls/openshell--client.p12`, and enter the PKCS#12 password. If you used the command above, leave it blank. -5. Restart Firefox. - -### CLI Import - -Install the NSS tools, close Firefox, then import into the active Firefox profile: - -```shell -brew install nss - -GATEWAY=navigator # replace with your gateway name -MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" -PROFILE="$HOME/Library/Application Support/Firefox/Profiles/" - -certutil -A \ - -d "sql:$PROFILE" \ - -n "OpenShell $GATEWAY CA" \ - -t "CT,C,C" \ - -i "$MTLS_DIR/ca.crt" - -pk12util \ - -d "sql:$PROFILE" \ - -i "$MTLS_DIR/openshell-$GATEWAY-client.p12" \ - -W "" -``` - -Find the active profile directory in `about:profiles` before running the commands. Restart Firefox after importing. If Firefox prompts for a client certificate when you visit the sandbox service URL, choose the `OpenShell client` certificate. - -## Chrome, Edge, and Safari on macOS - -Chrome, Edge, and Safari generally use the macOS keychain. Import the CA into your login keychain and mark it trusted: - -```shell -GATEWAY=navigator # replace with your gateway name -MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" - -security add-trusted-cert \ - -d \ - -r trustRoot \ - -k "$HOME/Library/Keychains/login.keychain-db" \ - "$MTLS_DIR/ca.crt" -``` - -macOS Keychain may reject the default OpenSSL 3 PKCS#12 bundle. Create a Keychain-compatible bundle: - -```shell -GATEWAY=navigator # replace with your gateway name -MTLS_DIR="$HOME/.config/openshell/gateways/$GATEWAY/mtls" - -openssl pkcs12 -export -legacy \ - -inkey "$MTLS_DIR/tls.key" \ - -in "$MTLS_DIR/tls.crt" \ - -certfile "$MTLS_DIR/ca.crt" \ - -name "OpenShell $GATEWAY client" \ - -out "$MTLS_DIR/openshell-$GATEWAY-client-keychain.p12" \ - -passout pass:openshell -``` - -Import it for Safari from the command line: - -```shell -security import "$MTLS_DIR/openshell-$GATEWAY-client-keychain.p12" \ - -k "$HOME/Library/Keychains/login.keychain-db" \ - -P "openshell" \ - -f pkcs12 \ - -T "/Applications/Safari.app" -``` - -Or import the client `.p12` into Keychain Access: - -1. Open **Keychain Access**. -2. Select the **login** keychain. -3. Choose **File > Import Items**. -4. Select `~/.config/openshell/gateways//mtls/openshell--client-keychain.p12`. -5. Enter the PKCS#12 password. If you used the command above, enter `openshell`. - -Restart the browser after importing certificates. - -## Troubleshooting - -If the browser still shows a certificate warning, recreate gateways created before sandbox service routing so their server certificate includes service base domain SANs: - -```shell -openshell gateway start --recreate -``` - -If the gateway rejects the browser connection, confirm the client certificate is installed in **Your Certificates** in Firefox or in the login keychain on macOS. diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 7b7314358..63de27473 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -141,7 +141,7 @@ openshell sandbox list --selector env=dev,team=platform ## Expose HTTP Services -Sandbox service routing is enabled for gateways by default. Recreate gateways created before this routing mode so their generated TLS certificate includes the service base domain SANs: +Sandbox service routing is enabled for gateways by default. HTTPS service URLs need the service base domain in the gateway certificate SANs, so recreate older gateways before using remote or non-loopback service URLs: ```shell openshell gateway start --recreate @@ -153,10 +153,10 @@ Expose a service that listens on loopback inside the sandbox: openshell service expose my-sandbox web --target-port 8080 ``` -OpenShell prints a URL in the form `https://--..openshell.localhost:/`. HTTP traffic enters the same gateway listener as gRPC and routes by the `Host` header before gateway routes such as `/auth`, so application paths are preserved for the sandbox service. +For loopback gateways, OpenShell prints a URL in the form `http://--..openshell.localhost:/`. Browser traffic enters the same gateway listener as mTLS-protected gRPC, but plaintext HTTP is accepted only from loopback clients and only for sandbox service hostnames. Gateway APIs, auth routes, health endpoints, and non-service hostnames remain unavailable over plaintext HTTP. Cross-origin and sibling-subdomain browser requests are rejected before reaching the sandbox service. Disable this local browser path with `--enable-loopback-service-http=false` or `OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false`. -Import the gateway CA and client certificate before using mTLS-protected browser access; see [Browser Certificates](/reference/browser-certificates). Endpoint deletion, renewal, and remote/public domains are follow-up work. +For remote or non-loopback gateways, browser service URLs remain HTTPS and require normal gateway authentication. Endpoint deletion, renewal, and remote/public domains are follow-up work. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 227bb4567..913cba50e 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -261,9 +261,9 @@ For Helm deployments, provide a CA and certificate bundle through the Kubernetes | Aspect | Detail | |---|---| -| Default | mTLS required. Both client and server present certificates that the deployment CA signed. | -| What you can change | Enable dual-auth mode (`allow_unauthenticated=true`) for Cloudflare Tunnel deployments, or disable TLS entirely for trusted reverse-proxy setups. | -| Risk if relaxed | Dual-auth mode accepts clients without certificates and defers authentication to the HTTP layer (Cloudflare JWT). Disabling TLS removes transport-level authentication entirely. | +| Default | mTLS required for gateway APIs and sandbox callbacks. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. | +| What you can change | Enable dual-auth mode (`allow_unauthenticated=true`) for Cloudflare Tunnel deployments, disable TLS entirely for trusted reverse-proxy setups, or disable loopback service HTTP with `--enable-loopback-service-http=false`. | +| Risk if relaxed | Dual-auth mode accepts clients without certificates and defers authentication to the HTTP layer (Cloudflare JWT). Disabling TLS removes transport-level authentication entirely. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | | Recommendation | Use mTLS (the default) unless deploying behind Cloudflare or a trusted reverse proxy. | ### SSH Tunnel Authentication From 4bb53805299c70bba116c5b9cb511f992adacf7b Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 11 May 2026 23:14:10 -0700 Subject: [PATCH 10/13] fix(cli): preserve service URL scheme --- crates/openshell-cli/src/run.rs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 9f4bc2516..3155c0070 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3443,10 +3443,10 @@ fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String return service_url.to_string(); }; - if service_url.set_scheme(gateway_endpoint.scheme()).is_err() { - return service_url.to_string(); - } - if service_url.set_port(gateway_endpoint.port()).is_err() { + if service_url + .set_port(gateway_endpoint.port_or_known_default()) + .is_err() + { return service_url.to_string(); } @@ -6227,13 +6227,35 @@ mod tests { fn service_url_for_gateway_omits_default_external_port() { assert_eq!( service_url_for_gateway( - "http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", + "https://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", "https://gateway.example.com" ), "https://quiet-flamingo--openclaw.navigator.openshell.localhost/" ); } + #[test] + fn service_url_for_gateway_preserves_service_scheme() { + assert_eq!( + service_url_for_gateway( + "http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", + "https://127.0.0.1:31886" + ), + "http://quiet-flamingo--openclaw.navigator.openshell.localhost:31886/" + ); + } + + #[test] + fn service_url_for_gateway_uses_gateway_default_port() { + assert_eq!( + service_url_for_gateway( + "http://quiet-flamingo--openclaw.navigator.openshell.localhost:8080/", + "https://gateway.example.com" + ), + "http://quiet-flamingo--openclaw.navigator.openshell.localhost:443/" + ); + } + #[test] fn ready_false_condition_message_prefers_reason_and_message() { let status = SandboxStatus { From 5e60b3383b2dcef2283d56e6dfff2f0986edf037 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 12 May 2026 08:17:39 -0700 Subject: [PATCH 11/13] feat(service): allow unnamed service exposure --- crates/openshell-cli/src/main.rs | 60 +++++++++++++++++-- crates/openshell-cli/src/run.rs | 23 ++++--- crates/openshell-server/src/grpc/service.rs | 29 ++++++++- .../openshell-server/src/service_routing.rs | 55 ++++++++++++++--- docs/sandboxes/manage-sandboxes.mdx | 10 +++- 5 files changed, 155 insertions(+), 22 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 27fe19cfc..05eb8c343 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1653,12 +1653,12 @@ enum ServiceCommands { #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] sandbox: String, - /// Service name. - service: String, - /// Loopback TCP port inside the sandbox. - #[arg(long)] + #[arg(value_name = "TARGET-PORT")] target_port: u16, + + /// Service name. + service: Option, }, } @@ -1960,6 +1960,7 @@ async fn main() -> Result<()> { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); apply_auth(&mut tls, &ctx.name); + let service = service.unwrap_or_default(); run::service_expose(&ctx.endpoint, &sandbox, &service, target_port, &tls).await?; } // ----------------------------------------------------------- @@ -3568,4 +3569,55 @@ mod tests { } } } + + #[test] + fn service_expose_accepts_positional_target_port_and_service() { + let cli = Cli::try_parse_from([ + "openshell", + "service", + "expose", + "my-sandbox", + "8080", + "api", + ]) + .expect("service expose positional target port should parse"); + + match cli.command { + Some(Commands::Service { + command: + Some(ServiceCommands::Expose { + sandbox, + target_port, + service, + }), + }) => { + assert_eq!(sandbox, "my-sandbox"); + assert_eq!(target_port, 8080); + assert_eq!(service.as_deref(), Some("api")); + } + other => panic!("expected service expose command, got: {other:?}"), + } + } + + #[test] + fn service_expose_allows_omitted_service_name() { + let cli = Cli::try_parse_from(["openshell", "service", "expose", "my-sandbox", "8080"]) + .expect("service expose should allow omitting the service name"); + + match cli.command { + Some(Commands::Service { + command: + Some(ServiceCommands::Expose { + sandbox, + target_port, + service, + }), + }) => { + assert_eq!(sandbox, "my-sandbox"); + assert_eq!(target_port, 8080); + assert_eq!(service, None); + } + other => panic!("expected service expose command, got: {other:?}"), + } + } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 3155c0070..899aac02f 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3421,13 +3421,22 @@ pub async fn service_expose( .map_err(|status| miette::miette!("expose service failed: {status}"))? .into_inner(); - println!( - "{} Exposed service {} on sandbox {} -> 127.0.0.1:{}", - "✓".green().bold(), - service.bold(), - sandbox.bold(), - target_port, - ); + if service.is_empty() { + println!( + "{} Exposed sandbox {} -> 127.0.0.1:{}", + "✓".green().bold(), + sandbox.bold(), + target_port, + ); + } else { + println!( + "{} Exposed service {} on sandbox {} -> 127.0.0.1:{}", + "✓".green().bold(), + service.bold(), + sandbox.bold(), + target_port, + ); + } if !response.url.is_empty() { let url = service_url_for_gateway(&response.url, server); println!(" URL: {}", url.cyan()); diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index 011b1e1b9..bf05b8f1a 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -24,7 +24,7 @@ pub(super) async fn handle_expose_service( ) -> Result, Status> { let req = request.into_inner(); validate_endpoint_name("sandbox", &req.sandbox, MAX_SANDBOX_NAME_LEN)?; - validate_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; + validate_optional_endpoint_name("service", &req.service, MAX_SERVICE_NAME_LEN)?; if req.target_port == 0 || req.target_port > u32::from(u16::MAX) { return Err(Status::invalid_argument("target_port must be in 1..=65535")); } @@ -83,6 +83,23 @@ fn validate_endpoint_name(field: &str, value: &str, max_len: usize) -> Result<() if value.is_empty() { return Err(Status::invalid_argument(format!("{field} is required"))); } + validate_non_empty_endpoint_name(field, value, max_len) +} + +#[allow(clippy::result_large_err)] +fn validate_optional_endpoint_name(field: &str, value: &str, max_len: usize) -> Result<(), Status> { + if value.is_empty() { + return Ok(()); + } + validate_non_empty_endpoint_name(field, value, max_len) +} + +#[allow(clippy::result_large_err)] +fn validate_non_empty_endpoint_name( + field: &str, + value: &str, + max_len: usize, +) -> Result<(), Status> { if value.len() > max_len { return Err(Status::invalid_argument(format!( "{field} must be at most {max_len} characters for sandbox service routing" @@ -119,11 +136,21 @@ mod tests { validate_endpoint_name("service", "web-api", 28).unwrap(); } + #[test] + fn validates_empty_optional_service_name() { + validate_optional_endpoint_name("service", "", 28).unwrap(); + } + #[test] fn rejects_separator_in_endpoint_name() { assert!(validate_endpoint_name("service", "web--api", 28).is_err()); } + #[test] + fn rejects_empty_required_endpoint_name() { + assert!(validate_endpoint_name("sandbox", "", 28).is_err()); + } + #[test] fn rejects_uppercase_endpoint_name() { assert!(validate_endpoint_name("service", "Web", 28).is_err()); diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 5a722dd81..3212c6219 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -26,7 +26,11 @@ impl ObjectType for ServiceEndpoint { } pub fn endpoint_key(sandbox: &str, service: &str) -> String { - format!("{sandbox}--{service}") + if service.is_empty() { + sandbox.to_string() + } else { + format!("{sandbox}--{service}") + } } pub fn endpoint_url( @@ -58,7 +62,11 @@ fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { fn endpoint_host(config: &ServiceRoutingConfig, sandbox: &str, service: &str) -> Option { let base_domain = config.service_base_domains.first()?; - Some(format!("{sandbox}--{service}.{base_domain}")) + Some(if service.is_empty() { + format!("{sandbox}.{base_domain}") + } else { + format!("{sandbox}--{service}.{base_domain}") + }) } pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, String)> { @@ -68,12 +76,15 @@ pub fn parse_host(host: &str, config: &ServiceRoutingConfig) -> Option<(String, let Some(encoded) = host.strip_suffix(&expected_suffix) else { continue; }; - let (sandbox, service) = encoded.split_once("--")?; - if sandbox.is_empty() - || service.is_empty() - || sandbox.contains("--") - || service.contains("--") - { + let (sandbox, service) = if let Some((sandbox, service)) = encoded.split_once("--") { + if service.is_empty() || service.contains("--") { + return None; + } + (sandbox, service) + } else { + (encoded, "") + }; + if sandbox.is_empty() || sandbox.contains("--") { return None; } return Some((sandbox.to_string(), service.to_string())); @@ -392,6 +403,18 @@ mod tests { ); } + #[test] + fn endpoint_url_omits_service_label_for_empty_service_name() { + let cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_service_base_domains(["dev.openshell.localhost"]); + + assert_eq!( + endpoint_url(&cfg, "my-sandbox", "").as_deref(), + Some("http://my-sandbox.dev.openshell.localhost:8080/") + ); + } + #[test] fn endpoint_url_keeps_https_for_non_loopback_tls_gateway() { let cfg = openshell_core::Config::new(Some(tls_config())) @@ -425,6 +448,22 @@ mod tests { ); } + #[test] + fn parses_sandbox_host_without_service_label() { + assert_eq!( + parse_host("my-sandbox.dev.openshell.localhost", &config()), + Some(("my-sandbox".to_string(), String::new())) + ); + } + + #[test] + fn rejects_empty_service_label_separator() { + assert_eq!( + parse_host("my-sandbox--.dev.openshell.localhost", &config()), + None + ); + } + #[test] fn parses_sandbox_service_host_with_port() { assert_eq!( diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 63de27473..0d1d60ed9 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -150,10 +150,16 @@ openshell gateway start --recreate Expose a service that listens on loopback inside the sandbox: ```shell -openshell service expose my-sandbox web --target-port 8080 +openshell service expose my-sandbox 8080 ``` -For loopback gateways, OpenShell prints a URL in the form `http://--..openshell.localhost:/`. Browser traffic enters the same gateway listener as mTLS-protected gRPC, but plaintext HTTP is accepted only from loopback clients and only for sandbox service hostnames. Gateway APIs, auth routes, health endpoints, and non-service hostnames remain unavailable over plaintext HTTP. Cross-origin and sibling-subdomain browser requests are rejected before reaching the sandbox service. Disable this local browser path with `--enable-loopback-service-http=false` or `OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false`. +Pass an optional service name to create a named service URL: + +```shell +openshell service expose my-sandbox 8080 web +``` + +For loopback gateways, OpenShell prints a URL in the form `http://..openshell.localhost:/` or `http://--..openshell.localhost:/` when a service name is provided. Browser traffic enters the same gateway listener as mTLS-protected gRPC, but plaintext HTTP is accepted only from loopback clients and only for sandbox service hostnames. Gateway APIs, auth routes, health endpoints, and non-service hostnames remain unavailable over plaintext HTTP. Cross-origin and sibling-subdomain browser requests are rejected before reaching the sandbox service. Disable this local browser path with `--enable-loopback-service-http=false` or `OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false`. For remote or non-loopback gateways, browser service URLs remain HTTPS and require normal gateway authentication. Endpoint deletion, renewal, and remote/public domains are follow-up work. From 39ed6ad70f59fe55fd58912d6490b6e8965760ea Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 12 May 2026 08:27:03 -0700 Subject: [PATCH 12/13] feat(cli): add service command alias --- crates/openshell-cli/src/main.rs | 32 ++++++++++++++++++++++++++++- docs/sandboxes/manage-sandboxes.mdx | 2 ++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 05eb8c343..643f9f6a9 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -273,6 +273,14 @@ const FORWARD_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m $ openshell forward list "; +const SERVICE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m + svc + +\x1b[1mEXAMPLES\x1b[0m + $ openshell service expose my-sandbox 8080 + $ openshell service expose my-sandbox 8080 web +"; + const LOGS_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m lg @@ -411,7 +419,7 @@ enum Commands { }, /// Expose sandbox services. - #[command(help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(alias = "svc", after_help = SERVICE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Service { #[command(subcommand)] command: Option, @@ -3620,4 +3628,26 @@ mod tests { other => panic!("expected service expose command, got: {other:?}"), } } + + #[test] + fn service_alias_parses_service_commands() { + let cli = Cli::try_parse_from(["openshell", "svc", "expose", "my-sandbox", "8080"]) + .expect("svc alias should parse service commands"); + + match cli.command { + Some(Commands::Service { + command: + Some(ServiceCommands::Expose { + sandbox, + target_port, + service, + }), + }) => { + assert_eq!(sandbox, "my-sandbox"); + assert_eq!(target_port, 8080); + assert_eq!(service, None); + } + other => panic!("expected service expose command, got: {other:?}"), + } + } } diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 0d1d60ed9..6cab829f0 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -159,6 +159,8 @@ Pass an optional service name to create a named service URL: openshell service expose my-sandbox 8080 web ``` +Use `openshell svc` as shorthand for `openshell service`. + For loopback gateways, OpenShell prints a URL in the form `http://..openshell.localhost:/` or `http://--..openshell.localhost:/` when a service name is provided. Browser traffic enters the same gateway listener as mTLS-protected gRPC, but plaintext HTTP is accepted only from loopback clients and only for sandbox service hostnames. Gateway APIs, auth routes, health endpoints, and non-service hostnames remain unavailable over plaintext HTTP. Cross-origin and sibling-subdomain browser requests are rejected before reaching the sandbox service. Disable this local browser path with `--enable-loopback-service-http=false` or `OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP=false`. From 06ced74d072dab4d274487fedfda64e9bf96cad3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 12 May 2026 09:22:24 -0700 Subject: [PATCH 13/13] fix(service): tighten endpoint auth and diagnostics --- crates/openshell-cli/src/run.rs | 34 +- crates/openshell-server/src/auth/authz.rs | 12 + crates/openshell-server/src/grpc/service.rs | 16 +- crates/openshell-server/src/http.rs | 6 +- .../openshell-server/src/service_routing.rs | 543 +++++++++++++++++- 5 files changed, 575 insertions(+), 36 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 899aac02f..c88e72be7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3418,7 +3418,7 @@ pub async fn service_expose( domain: true, }) .await - .map_err(|status| miette::miette!("expose service failed: {status}"))? + .map_err(service_expose_status_error)? .into_inner(); if service.is_empty() { @@ -3444,6 +3444,23 @@ pub async fn service_expose( Ok(()) } +fn service_expose_status_error(status: Status) -> miette::Report { + let message = status.message(); + match status.code() { + Code::PermissionDenied => { + miette!("expose service failed: permission denied (requires sandbox:write)") + } + Code::Unauthenticated => miette!("expose service failed: authentication required"), + Code::NotFound if message == "sandbox not found" => { + miette!("expose service failed: sandbox not found") + } + Code::InvalidArgument if !message.is_empty() => { + miette!("expose service failed: invalid request: {message}") + } + _ => miette!("expose service failed: {status}"), + } +} + fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { let (Ok(mut service_url), Ok(gateway_endpoint)) = ( url::Url::parse(service_url), @@ -5848,7 +5865,7 @@ mod tests { inferred_provider_type, package_managed_tls_dirs, parse_cli_setting_value, parse_credential_pairs, plaintext_gateway_is_remote, provisioning_timeout_message, ready_false_condition_message, resolve_from, sandbox_should_persist, - service_url_for_gateway, + service_expose_status_error, service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; @@ -5859,6 +5876,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::process::Command; use std::thread; + use tonic::Status; use openshell_bootstrap::GatewayMetadata; use openshell_core::proto::{ @@ -6265,6 +6283,18 @@ mod tests { ); } + #[test] + fn service_expose_status_error_mentions_required_scope() { + let report = service_expose_status_error(Status::permission_denied( + "scope 'sandbox:write' required", + )); + + assert_eq!( + report.to_string(), + "expose service failed: permission denied (requires sandbox:write)" + ); + } + #[test] fn ready_false_condition_message_prefers_reason_and_message() { let status = SandboxStatus { diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 70d9d738c..e5210fc4d 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -62,6 +62,7 @@ const SCOPED_METHODS: &[(&str, &str)] = &[ ("/openshell.v1.OpenShell/ForwardTcp", "sandbox:write"), ("/openshell.v1.OpenShell/CreateSshSession", "sandbox:write"), ("/openshell.v1.OpenShell/RevokeSshSession", "sandbox:write"), + ("/openshell.v1.OpenShell/ExposeService", "sandbox:write"), ( "/openshell.v1.OpenShell/AttachSandboxProvider", "sandbox:write", @@ -426,6 +427,11 @@ mod tests { .check(&id, "/openshell.v1.OpenShell/ForwardTcp") .is_ok() ); + assert!( + policy + .check(&id, "/openshell.v1.OpenShell/ExposeService") + .is_ok() + ); assert!( policy .check(&id, "/openshell.v1.OpenShell/AttachSandboxProvider") @@ -452,6 +458,12 @@ mod tests { .unwrap_err(); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("sandbox:write")); + + let err = policy + .check(&id, "/openshell.v1.OpenShell/ExposeService") + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("sandbox:write")); } #[test] diff --git a/crates/openshell-server/src/grpc/service.rs b/crates/openshell-server/src/grpc/service.rs index bf05b8f1a..406707215 100644 --- a/crates/openshell-server/src/grpc/service.rs +++ b/crates/openshell-server/src/grpc/service.rs @@ -39,13 +39,20 @@ pub(super) async fn handle_expose_service( let now = super::current_time_ms().map_err(|e| Status::internal(format!("clock error: {e}")))?; let key = service_routing::endpoint_key(&req.sandbox, &req.service); - let id = match state + let (id, created_at_ms, created) = match state .store .get_message_by_name::(&key) .await { - Ok(Some(existing)) => existing.object_id().to_string(), - Ok(None) => Uuid::new_v4().to_string(), + Ok(Some(existing)) => ( + existing.object_id().to_string(), + existing + .metadata + .as_ref() + .map_or(now, |metadata| metadata.created_at_ms), + false, + ), + Ok(None) => (Uuid::new_v4().to_string(), now, true), Err(e) => return Err(Status::internal(format!("fetch endpoint failed: {e}"))), }; @@ -53,7 +60,7 @@ pub(super) async fn handle_expose_service( metadata: Some(ObjectMeta { id, name: key, - created_at_ms: now, + created_at_ms, labels: HashMap::from([("sandbox".to_string(), req.sandbox.clone())]), }), sandbox_id: sandbox.object_id().to_string(), @@ -71,6 +78,7 @@ pub(super) async fn handle_expose_service( let url = service_routing::endpoint_url(&state.config, &req.sandbox, &req.service) .unwrap_or_default(); + service_routing::emit_service_endpoint_config_event(&endpoint, &url, created); Ok(Response::new(ServiceEndpointResponse { endpoint: Some(endpoint), diff --git a/crates/openshell-server/src/http.rs b/crates/openshell-server/src/http.rs index 5c7967599..40f0b39d0 100644 --- a/crates/openshell-server/src/http.rs +++ b/crates/openshell-server/src/http.rs @@ -105,7 +105,11 @@ async fn sandbox_service_routing_only( return StatusCode::NOT_FOUND.into_response(); } if !browser_context_allows_plaintext_service_request(&req) { - return StatusCode::FORBIDDEN.into_response(); + crate::service_routing::emit_cross_origin_service_http_rejection(&state, &req); + return crate::service_routing::service_error_response( + StatusCode::FORBIDDEN, + "Cross-origin service request rejected", + ); } crate::service_routing::proxy_sandbox_service_request(state, req) .await diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 3212c6219..1e0aa6e0e 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -3,21 +3,34 @@ //! Browser-facing HTTP routing for sandbox service endpoints. -use axum::{body::Body, response::IntoResponse}; +use axum::{ + body::Body, + response::{IntoResponse, Response as AxumResponse}, +}; use http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header}; use hyper_util::rt::TokioIo; -use openshell_core::ObjectId; use openshell_core::config::ServiceRoutingConfig; use openshell_core::proto::{Sandbox, SandboxPhase, ServiceEndpoint, TcpRelayTarget, relay_open}; +use openshell_core::{ObjectId, VERSION}; +use openshell_ocsf::{ + ActionId, ActivityId, ConfigStateChangeBuilder, DispositionId, Endpoint, HttpActivityBuilder, + HttpRequest, HttpResponse as OcsfHttpResponse, NetworkActivityBuilder, OCSF_TARGET, OcsfEvent, + SandboxContext, SeverityId, StateId, StatusId, Url as OcsfUrl, +}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncWriteExt; -use tracing::warn; +use tracing::{info, warn}; use crate::ServerState; use crate::persistence::{ObjectType, Store}; const ENDPOINT_OBJECT_TYPE: &str = "service_endpoint"; +const ROUTING_RULE_NAME: &str = "sandbox_service_routing"; +const ROUTING_RULE_TYPE: &str = "gateway"; +const RELAY_RULE_NAME: &str = "sandbox_service_relay"; +const RELAY_TARGET_HOST: &str = "127.0.0.1"; impl ObjectType for ServiceEndpoint { fn object_type() -> &'static str { @@ -109,34 +122,184 @@ pub async fn proxy_sandbox_service_request( match proxy_to_endpoint(state, req, sandbox_name, service_name).await { Ok(response) => response.into_response(), - Err(status) => status.into_response(), + Err(err) => err.into_response(), } } +#[derive(Debug, Clone)] +struct ServiceRouteError { + status: StatusCode, + message: &'static str, + reason: &'static str, +} + +impl ServiceRouteError { + const fn new(status: StatusCode, message: &'static str, reason: &'static str) -> Self { + Self { + status, + message, + reason, + } + } + + const fn endpoint_not_found() -> Self { + Self::new( + StatusCode::NOT_FOUND, + "Service endpoint not found", + "service endpoint not found", + ) + } + + const fn endpoint_unavailable() -> Self { + Self::new( + StatusCode::NOT_FOUND, + "Service endpoint is not available", + "service endpoint unavailable", + ) + } + + const fn sandbox_not_ready() -> Self { + Self::new( + StatusCode::PRECONDITION_FAILED, + "Sandbox is not ready", + "sandbox not ready", + ) + } + + const fn service_unreachable() -> Self { + Self::new( + StatusCode::BAD_GATEWAY, + "Service endpoint is not reachable", + "service endpoint unreachable", + ) + } + + const fn invalid_request() -> Self { + Self::new( + StatusCode::BAD_REQUEST, + "Invalid service request", + "invalid service request", + ) + } + + const fn internal_error() -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Service endpoint is not available", + "service endpoint internal error", + ) + } +} + +impl IntoResponse for ServiceRouteError { + fn into_response(self) -> AxumResponse { + service_error_response(self.status, self.message) + } +} + +pub fn service_error_response(status: StatusCode, message: &'static str) -> AxumResponse { + ( + status, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + message, + ) + .into_response() +} + async fn proxy_to_endpoint( state: Arc, mut req: Request, sandbox_name: String, service_name: String, -) -> Result, StatusCode> { - let endpoint = load_endpoint(&state.store, &sandbox_name, &service_name).await?; +) -> Result, ServiceRouteError> { + let endpoint = match load_endpoint(&state.store, &sandbox_name, &service_name).await { + Ok(endpoint) => endpoint, + Err(err) => { + emit_service_http_failure(&state, &req, &sandbox_name, &service_name, None, &err); + return Err(err); + } + }; if !endpoint.domain || endpoint.target_port == 0 || endpoint.target_port > u32::from(u16::MAX) { - return Err(StatusCode::NOT_FOUND); + let err = ServiceRouteError::endpoint_unavailable(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); } - let sandbox = state + let sandbox = match state .store .get_message::(&endpoint.sandbox_id) .await - .map_err(|err| { + { + Ok(Some(sandbox)) => sandbox, + Ok(None) => { + let err = ServiceRouteError::endpoint_unavailable(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); + } + Err(err) => { warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: failed to load sandbox"); - StatusCode::INTERNAL_SERVER_ERROR - })? - .ok_or(StatusCode::NOT_FOUND)?; + let route_err = ServiceRouteError::internal_error(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &route_err, + ); + return Err(route_err); + } + }; if SandboxPhase::try_from(sandbox.phase).ok() != Some(SandboxPhase::Ready) { - return Err(StatusCode::PRECONDITION_FAILED); + let err = ServiceRouteError::sandbox_not_ready(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); + } + let Ok(target_port) = u16::try_from(endpoint.target_port) else { + let err = ServiceRouteError::endpoint_unavailable(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); + }; + if upstream_uri_path(&req).is_err() { + let err = ServiceRouteError::invalid_request(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); } - let target_port = u16::try_from(endpoint.target_port).map_err(|_| StatusCode::NOT_FOUND)?; let websocket_upgrade = is_websocket_upgrade(&req); let downstream_upgrade = websocket_upgrade.then(|| hyper::upgrade::on(&mut req)); @@ -146,7 +309,7 @@ async fn proxy_to_endpoint( .open_relay_with_target( sandbox.object_id(), relay_open::Target::Tcp(TcpRelayTarget { - host: "127.0.0.1".to_string(), + host: RELAY_TARGET_HOST.to_string(), port: u32::from(target_port), }), endpoint.object_id().to_string(), @@ -155,16 +318,28 @@ async fn proxy_to_endpoint( .await .map_err(|err| { warn!(error = %err, sandbox_id = %endpoint.sandbox_id, "sandbox service routing: supervisor relay unavailable"); - StatusCode::BAD_GATEWAY + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err })?; let relay = tokio::time::timeout(Duration::from_secs(10), relay_rx) .await - .map_err(|_| StatusCode::BAD_GATEWAY)? - .map_err(|_| StatusCode::BAD_GATEWAY)? + .map_err(|_| { + let err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, "relay claim timed out"); + err + })? + .map_err(|_| { + let err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, "relay claim canceled"); + err + })? .map_err(|err| { warn!(error = %err, "sandbox service routing: relay target open failed"); - StatusCode::BAD_GATEWAY + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err })?; let (mut sender, conn) = hyper::client::conn::http1::Builder::new() @@ -172,7 +347,9 @@ async fn proxy_to_endpoint( .await .map_err(|err| { warn!(error = %err, "sandbox service routing: failed to start upstream HTTP client"); - StatusCode::BAD_GATEWAY + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err })?; if websocket_upgrade { @@ -192,12 +369,18 @@ async fn proxy_to_endpoint( let upstream = build_upstream_request(req, target_port, websocket_upgrade)?; let mut response = sender.send_request(upstream).await.map_err(|err| { warn!(error = %err, "sandbox service routing: upstream HTTP request failed"); - StatusCode::BAD_GATEWAY + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err })?; if websocket_upgrade && response.status() == StatusCode::SWITCHING_PROTOCOLS { let upstream_upgrade = hyper::upgrade::on(&mut response); - let downstream_upgrade = downstream_upgrade.ok_or(StatusCode::BAD_GATEWAY)?; + let downstream_upgrade = downstream_upgrade.ok_or_else(|| { + let err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, "websocket upgrade unavailable"); + err + })?; tokio::spawn(async move { match (downstream_upgrade.await, upstream_upgrade.await) { (Ok(downstream), Ok(upstream)) => { @@ -228,28 +411,28 @@ async fn load_endpoint( store: &Store, sandbox_name: &str, service_name: &str, -) -> Result { +) -> Result { let key = endpoint_key(sandbox_name, service_name); store .get_message_by_name::(&key) .await .map_err(|err| { warn!(error = %err, endpoint = %key, "sandbox service routing: failed to load service endpoint"); - StatusCode::INTERNAL_SERVER_ERROR + ServiceRouteError::internal_error() })? - .ok_or(StatusCode::NOT_FOUND) + .ok_or_else(ServiceRouteError::endpoint_not_found) } fn build_upstream_request( req: Request, target_port: u16, preserve_upgrade_headers: bool, -) -> Result, StatusCode> { +) -> Result, ServiceRouteError> { let (parts, body) = req.into_parts(); let path = parts.uri.path_and_query().map_or("/", |path| path.as_str()); let uri = path .parse::() - .map_err(|_| StatusCode::BAD_REQUEST)?; + .map_err(|_| ServiceRouteError::invalid_request())?; let mut builder = Request::builder() .method(parts.method) @@ -258,7 +441,7 @@ fn build_upstream_request( let headers = builder .headers_mut() - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + .ok_or_else(ServiceRouteError::internal_error)?; for (name, value) in &parts.headers { if (is_hop_by_hop_header(name) && !(preserve_upgrade_headers && is_websocket_hop_by_hop_header(name))) @@ -281,7 +464,14 @@ fn build_upstream_request( builder .body(body) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) + .map_err(|_| ServiceRouteError::internal_error()) +} + +fn upstream_uri_path(req: &Request) -> Result<&str, ServiceRouteError> { + let path = req.uri().path_and_query().map_or("/", |path| path.as_str()); + path.parse::() + .map(|_| path) + .map_err(|_| ServiceRouteError::invalid_request()) } fn host_header(headers: &HeaderMap) -> Option<&str> { @@ -368,10 +558,239 @@ fn is_gateway_auth_cookie(name: &str) -> bool { name.eq_ignore_ascii_case("CF_Authorization") || name.eq_ignore_ascii_case("cf-authorization") } +pub fn emit_service_endpoint_config_event(endpoint: &ServiceEndpoint, url: &str, created: bool) { + let event = build_service_endpoint_config_event(endpoint, url, created); + emit_gateway_ocsf_event(&endpoint.sandbox_id, event); +} + +pub fn emit_cross_origin_service_http_rejection(state: &ServerState, req: &Request) { + let Some(host) = request_host(req) else { + return; + }; + let Some((sandbox_name, service_name)) = parse_host(host, &state.config.service_routing) else { + return; + }; + let err = ServiceRouteError::new( + StatusCode::FORBIDDEN, + "Cross-origin service request rejected", + "cross-origin service request rejected", + ); + emit_service_http_failure(state, req, &sandbox_name, &service_name, None, &err); +} + +fn emit_service_http_failure( + state: &ServerState, + req: &Request, + sandbox_name: &str, + service_name: &str, + endpoint: Option<&ServiceEndpoint>, + err: &ServiceRouteError, +) { + let event = build_service_http_failure_event( + state.config.bind_address.port(), + req, + sandbox_name, + service_name, + endpoint, + err, + ); + let sandbox_id = endpoint.map_or("", |endpoint| endpoint.sandbox_id.as_str()); + emit_gateway_ocsf_event(sandbox_id, event); +} + +fn emit_service_relay_failure(endpoint: &ServiceEndpoint, target_port: u16, reason: &str) { + let event = build_service_relay_failure_event(endpoint, target_port, reason); + emit_gateway_ocsf_event(&endpoint.sandbox_id, event); +} + +fn build_service_endpoint_config_event( + endpoint: &ServiceEndpoint, + url: &str, + created: bool, +) -> OcsfEvent { + let service_label = service_display_name(&endpoint.sandbox_name, &endpoint.service_name); + let state_label = if created { + "service_endpoint_created" + } else { + "service_endpoint_updated" + }; + let ctx = gateway_ocsf_ctx(&endpoint.sandbox_id, &endpoint.sandbox_name); + let mut builder = ConfigStateChangeBuilder::new(&ctx) + .state(StateId::Enabled, state_label) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(format!( + "Service endpoint exposed {service_label} -> {RELAY_TARGET_HOST}:{}", + endpoint.target_port + )) + .unmapped("endpoint_name", endpoint_name(endpoint)) + .unmapped("service_name", endpoint.service_name.clone()) + .unmapped("target_port", u64::from(endpoint.target_port)); + + if !url.is_empty() { + builder = builder.unmapped("url", url.to_string()); + } + + builder.build() +} + +fn build_service_http_failure_event( + bind_port: u16, + req: &Request, + sandbox_name: &str, + service_name: &str, + endpoint: Option<&ServiceEndpoint>, + err: &ServiceRouteError, +) -> OcsfEvent { + let host = request_host(req).unwrap_or("unknown"); + let (hostname, port) = split_authority_for_event(host, bind_port); + let ctx = gateway_ocsf_ctx( + endpoint.map_or("", |endpoint| endpoint.sandbox_id.as_str()), + sandbox_name, + ); + HttpActivityBuilder::new(&ctx) + .activity(http_activity_for_method(req.method())) + .action(ActionId::Denied) + .disposition(if err.status.is_server_error() { + DispositionId::Error + } else { + DispositionId::Blocked + }) + .severity(if err.status.is_server_error() { + SeverityId::Low + } else { + SeverityId::Medium + }) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + req.method().as_str(), + OcsfUrl::new("http", &hostname, req.uri().path(), port), + )) + .http_response(OcsfHttpResponse { + code: err.status.as_u16(), + }) + .dst_endpoint(Endpoint::from_domain(&hostname, port)) + .firewall_rule(ROUTING_RULE_NAME, ROUTING_RULE_TYPE) + .status_detail(err.reason) + .message(format!( + "{}: {}", + err.message, + service_display_name(sandbox_name, service_name) + )) + .build() +} + +fn build_service_relay_failure_event( + endpoint: &ServiceEndpoint, + target_port: u16, + reason: &str, +) -> OcsfEvent { + NetworkActivityBuilder::new(&gateway_ocsf_ctx( + &endpoint.sandbox_id, + &endpoint.sandbox_name, + )) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Error) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_ip_str(RELAY_TARGET_HOST, target_port)) + .firewall_rule(RELAY_RULE_NAME, ROUTING_RULE_TYPE) + .status_detail(reason) + .message(format!( + "Service endpoint is not reachable: {}", + service_display_name(&endpoint.sandbox_name, &endpoint.service_name) + )) + .unmapped("endpoint_name", endpoint_name(endpoint)) + .unmapped("service_name", endpoint.service_name.clone()) + .build() +} + +fn emit_gateway_ocsf_event(sandbox_id: &str, event: OcsfEvent) { + let message = event.format_shorthand(); + info!( + target: OCSF_TARGET, + sandbox_id = %sandbox_id, + message = %message + ); +} + +fn gateway_ocsf_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: "openshell/gateway".to_string(), + hostname: "openshell-gateway".to_string(), + product_version: VERSION.to_string(), + proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + proxy_port: 0, + } +} + +fn endpoint_name(endpoint: &ServiceEndpoint) -> String { + endpoint.metadata.as_ref().map_or_else( + || endpoint_key(&endpoint.sandbox_name, &endpoint.service_name), + |metadata| metadata.name.clone(), + ) +} + +fn service_display_name(sandbox_name: &str, service_name: &str) -> String { + if service_name.is_empty() { + sandbox_name.to_string() + } else { + format!("{sandbox_name}/{service_name}") + } +} + +fn split_authority_for_event(authority: &str, default_port: u16) -> (String, u16) { + let authority = authority.trim(); + match authority.rsplit_once(':') { + Some((host, port)) if !host.is_empty() && port.chars().all(|ch| ch.is_ascii_digit()) => ( + host.trim_end_matches('.').to_ascii_lowercase(), + port.parse().unwrap_or(default_port), + ), + _ => ( + authority.trim_end_matches('.').to_ascii_lowercase(), + default_port, + ), + } +} + +fn http_activity_for_method(method: &Method) -> ActivityId { + match method.as_str() { + "CONNECT" => ActivityId::Open, + "DELETE" => ActivityId::Close, + "GET" => ActivityId::Reset, + "HEAD" => ActivityId::Fail, + "OPTIONS" => ActivityId::Refuse, + "POST" => ActivityId::Traffic, + "PUT" => ActivityId::Listen, + "TRACE" => ActivityId::Trace, + "PATCH" => ActivityId::Patch, + _ => ActivityId::Other, + } +} + #[cfg(test)] mod tests { use super::*; + fn endpoint() -> ServiceEndpoint { + ServiceEndpoint { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "endpoint-id".to_string(), + name: "my-sandbox--web".to_string(), + created_at_ms: 1_700_000_000_000, + labels: std::collections::HashMap::default(), + }), + sandbox_id: "sandbox-id".to_string(), + sandbox_name: "my-sandbox".to_string(), + service_name: "web".to_string(), + target_port: 8080, + domain: true, + } + } + fn config() -> ServiceRoutingConfig { ServiceRoutingConfig { service_base_domains: vec![ @@ -517,6 +936,72 @@ mod tests { assert!(!is_sandbox_service_request(&request, &config())); } + #[test] + fn service_route_errors_return_plain_text() { + let response = ServiceRouteError::sandbox_not_ready().into_response(); + + assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/plain; charset=utf-8" + ); + } + + #[test] + fn service_endpoint_config_event_includes_endpoint_metadata() { + let event = + build_service_endpoint_config_event(&endpoint(), "http://my-sandbox--web.local/", true); + let json = event.to_json().unwrap(); + + assert_eq!(json["class_uid"], 5019); + assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web"); + assert_eq!(json["unmapped"]["service_name"], "web"); + assert_eq!(json["unmapped"]["target_port"], 8080); + assert!( + event + .format_shorthand() + .contains("Service endpoint exposed my-sandbox/web") + ); + } + + #[test] + fn service_http_failure_event_omits_query_strings() { + let request = Request::builder() + .method(Method::GET) + .uri("/secret?token=should-not-log") + .header( + header::HOST, + "my-sandbox--web.dev.openshell.localhost:18080", + ) + .body(Body::empty()) + .unwrap(); + + let err = ServiceRouteError::new( + StatusCode::FORBIDDEN, + "Cross-origin service request rejected", + "cross-origin service request rejected", + ); + let event = + build_service_http_failure_event(18080, &request, "my-sandbox", "web", None, &err); + let json = event.to_json().unwrap(); + + assert_eq!(json["class_uid"], 4002); + assert_eq!(json["http_request"]["url"]["path"], "/secret"); + assert_eq!(json["http_response"]["code"], 403); + assert!(!event.format_shorthand().contains("should-not-log")); + } + + #[test] + fn service_relay_failure_event_records_loopback_target() { + let event = build_service_relay_failure_event(&endpoint(), 8080, "relay unavailable"); + let json = event.to_json().unwrap(); + + assert_eq!(json["class_uid"], 4001); + assert_eq!(json["dst_endpoint"]["ip"], RELAY_TARGET_HOST); + assert_eq!(json["dst_endpoint"]["port"], 8080); + assert_eq!(json["unmapped"]["endpoint_name"], "my-sandbox--web"); + } + #[test] fn strips_gateway_auth_headers_from_upstream_request() { let request = Request::builder()