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-cli/src/main.rs b/crates/openshell-cli/src/main.rs index e370d1f27..643f9f6a9 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 @@ -272,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 @@ -409,6 +418,13 @@ enum Commands { command: Option, }, + /// Expose sandbox services. + #[command(alias = "svc", after_help = SERVICE_EXAMPLES, 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 +1652,24 @@ 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, + + /// Loopback TCP port inside the sandbox. + #[arg(value_name = "TARGET-PORT")] + target_port: u16, + + /// Service name. + service: Option, + }, +} + #[tokio::main] #[allow(clippy::large_stack_frames)] // CLI dispatch holds many futures; OK at top level. async fn main() -> Result<()> { @@ -1920,6 +1954,23 @@ async fn main() -> Result<()> { } }, + // ----------------------------------------------------------- + // Service exposure + // ----------------------------------------------------------- + Some(Commands::Service { + command: + Some(ServiceCommands::Expose { + sandbox, + service, + target_port, + }), + }) => { + 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?; + } // ----------------------------------------------------------- // Top-level logs (was `sandbox logs`) // ----------------------------------------------------------- @@ -2657,6 +2708,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") @@ -3519,4 +3577,77 @@ 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:?}"), + } + } + + #[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/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 2797bd66c..c88e72be7 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -28,17 +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, + 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}; @@ -3401,6 +3402,83 @@ fn parse_credential_pairs(items: &[String]) -> Result> { Ok(map) } +pub async fn service_expose( + server: &str, + sandbox: &str, + service: &str, + target_port: u16, + 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: true, + }) + .await + .map_err(service_expose_status_error)? + .into_inner(); + + 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()); + } + 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), + url::Url::parse(gateway_endpoint), + ) else { + return service_url.to_string(); + }; + + if service_url + .set_port(gateway_endpoint.port_or_known_default()) + .is_err() + { + return service_url.to_string(); + } + + service_url.to_string() +} + pub async fn provider_create( server: &str, name: &str, @@ -5787,6 +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_expose_status_error, service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use hyper::StatusCode; @@ -5797,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::{ @@ -6159,6 +6239,62 @@ 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( + "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 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-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..ca85bb8de 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -36,6 +36,12 @@ 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 root used to derive browser-facing sandbox service domains. +pub const DEFAULT_SERVICE_BASE_DOMAIN_ROOT: &str = "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"; @@ -301,6 +307,24 @@ pub struct Config { /// plus a supporting container runtime and Linux 5.12+. #[serde(default)] pub enable_user_namespaces: bool, + + /// Browser-facing sandbox service routing configuration. + #[serde(default)] + pub service_routing: ServiceRoutingConfig, +} + +/// Browser-facing sandbox service routing configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +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, + + /// 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. @@ -414,6 +438,7 @@ impl Config { client_tls_secret_name: String::new(), host_gateway_ip: String::new(), enable_user_namespaces: false, + service_routing: ServiceRoutingConfig::default(), } } @@ -563,12 +588,67 @@ impl Config { self.oidc = Some(oidc); self } + + /// Configure browser-facing sandbox service base domains. + #[must_use] + 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.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(), + } + } } fn default_bind_address() -> SocketAddr { "127.0.0.1:8080".parse().expect("valid default address") } +fn default_service_base_domains() -> Vec { + vec![default_service_base_domain_for_gateway( + DEFAULT_GATEWAY_NAME, + )] +} + +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}") +} + +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 { "info".to_string() } @@ -653,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-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/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/cli.rs b/crates/openshell-server/src/cli.rs index a3098c1cf..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")] @@ -297,6 +298,23 @@ 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, + + /// Base domains accepted for sandbox service routing. + #[arg( + long = "service-base-domain", + env = "OPENSHELL_SERVICE_BASE_DOMAINS", + 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 { @@ -393,7 +411,9 @@ 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_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); @@ -571,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/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 16f016081..8c0fa730e 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,10 +17,10 @@ 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, + ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, + GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, + GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, + GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, ImportProviderProfilesRequest, ImportProviderProfilesResponse, @@ -30,10 +31,10 @@ use openshell_core::proto::{ ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, - SandboxStreamEvent, ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, - SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, UndoDraftChunkResponse, - UpdateConfigRequest, UpdateConfigResponse, UpdateProviderRequest, WatchSandboxRequest, - open_shell_server::OpenShell, + SandboxStreamEvent, ServiceEndpointResponse, ServiceStatus, SubmitPolicyAnalysisRequest, + SubmitPolicyAnalysisResponse, SupervisorMessage, TcpForwardFrame, UndoDraftChunkRequest, + UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, UpdateProviderRequest, + WatchSandboxRequest, open_shell_server::OpenShell, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -260,6 +261,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/service.rs b/crates/openshell-server/src/grpc/service.rs new file mode 100644 index 000000000..406707215 --- /dev/null +++ b/crates/openshell-server/src/grpc/service.rs @@ -0,0 +1,166 @@ +// 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::service_routing; + +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_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")); + } + + 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 = service_routing::endpoint_key(&req.sandbox, &req.service); + let (id, created_at_ms, created) = match state + .store + .get_message_by_name::(&key) + .await + { + 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}"))), + }; + + let endpoint = ServiceEndpoint { + metadata: Some(ObjectMeta { + id, + name: key, + created_at_ms, + 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: true, + }; + + state + .store + .put_message(&endpoint) + .await + .map_err(|e| Status::internal(format!("persist endpoint failed: {e}")))?; + + 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), + 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"))); + } + 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" + ))); + } + 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 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/http.rs b/crates/openshell-server/src/http.rs index 7ca9cb8bf..40f0b39d0 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::{HeaderMap, StatusCode, header}, + middleware::{self, Next}, + response::IntoResponse, + routing::get, +}; use metrics_exporter_prometheus::PrometheusHandle; use serde::Serialize; use std::sync::Arc; @@ -59,5 +66,242 @@ 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, + sandbox_service_routing_first, + )) +} + +/// 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, + next: Next, +) -> impl IntoResponse { + 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(); + } + 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) { + 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 + .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 bca6e44aa..93ccdc9dc 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -31,6 +31,7 @@ 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; @@ -50,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; @@ -298,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(), ))); } @@ -363,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 { @@ -384,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 { @@ -634,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}; @@ -660,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 new file mode 100644 index 000000000..1e0aa6e0e --- /dev/null +++ b/crates/openshell-server/src/service_routing.rs @@ -0,0 +1,1065 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Browser-facing HTTP routing for sandbox service endpoints. + +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::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::{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 { + ENDPOINT_OBJECT_TYPE + } +} + +pub fn endpoint_key(sandbox: &str, service: &str) -> String { + if service.is_empty() { + sandbox.to_string() + } else { + format!("{sandbox}--{service}") + } +} + +pub fn endpoint_url( + config: &openshell_core::Config, + sandbox: &str, + service: &str, +) -> Option { + let host = endpoint_host(&config.service_routing, sandbox, service)?; + 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 { + format!("{scheme}://{host}:{port}/") + } else { + format!("{scheme}://{host}/") + }) +} + +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(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)> { + let host = host.split_once(':').map_or(host, |(name, _)| name); + 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) = 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())); + } + None +} + +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_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.service_routing) else { + return StatusCode::NOT_FOUND.into_response(); + }; + + match proxy_to_endpoint(state, req, sandbox_name, service_name).await { + Ok(response) => response.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, 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) { + let err = ServiceRouteError::endpoint_unavailable(); + emit_service_http_failure( + &state, + &req, + &sandbox_name, + &service_name, + Some(&endpoint), + &err, + ); + return Err(err); + } + + let sandbox = match state + .store + .get_message::(&endpoint.sandbox_id) + .await + { + 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"); + 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) { + 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 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: RELAY_TARGET_HOST.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, "sandbox service routing: supervisor relay unavailable"); + 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(|_| { + 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"); + 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() + .handshake(TokioIo::new(relay)) + .await + .map_err(|err| { + warn!(error = %err, "sandbox service routing: failed to start upstream HTTP client"); + let route_err = ServiceRouteError::service_unreachable(); + emit_service_relay_failure(&endpoint, target_port, route_err.reason); + route_err + })?; + + if websocket_upgrade { + tokio::spawn(async move { + if let Err(err) = conn.with_upgrades().await { + warn!(error = %err, "sandbox service routing: upstream WebSocket connection failed"); + } + }); + } else { + tokio::spawn(async move { + if let Err(err) = conn.await { + 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, "sandbox service routing: upstream HTTP request failed"); + 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_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)) => { + 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, "sandbox service routing: downstream WebSocket upgrade failed"); + } + (_, Err(err)) => { + warn!(error = %err, "sandbox service routing: 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, "sandbox service routing: failed to load service endpoint"); + ServiceRouteError::internal_error() + })? + .ok_or_else(ServiceRouteError::endpoint_not_found) +} + +fn build_upstream_request( + req: Request, + target_port: u16, + preserve_upgrade_headers: bool, +) -> 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(|_| ServiceRouteError::invalid_request())?; + + let mut builder = Request::builder() + .method(parts.method) + .uri(uri) + .version(http::Version::HTTP_11); + + let headers = builder + .headers_mut() + .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))) + || 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(|_| 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> { + headers.get(header::HOST)?.to_str().ok() +} + +pub 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") +} + +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![ + "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_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())) + .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!( + parse_host("my-sandbox--web.dev.openshell.localhost", &config()), + Some(("my-sandbox".to_string(), "web".to_string())) + ); + } + + #[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!( + parse_host("my-sandbox--web.dev.openshell.localhost:8080", &config()), + Some(("my-sandbox".to_string(), "web".to_string())) + ); + } + + #[test] + 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 + ); + } + + #[test] + 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_sandbox_service_request(&request, &config())); + } + + #[test] + 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_sandbox_service_request(&request, &config())); + } + + #[test] + 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_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() + .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..94b0394ab 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, 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..5e57aa613 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -96,6 +96,10 @@ spec: - name: OPENSHELL_ENABLE_USER_NAMESPACES value: "true" {{- 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 66935c25f..18f003ad0 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -130,6 +130,11 @@ 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: # 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..5377f7ef5 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -46,6 +46,7 @@ spec: adminRole: "__OIDC_ADMIN_ROLE__" userRole: "__OIDC_USER_ROLE__" scopesClaim: "__OIDC_SCOPES_CLAIM__" + serviceBaseDomains: __SERVICE_BASE_DOMAINS__ tls: certSecretName: openshell-server-tls clientCaSecretName: openshell-server-client-ca 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/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index b43a6846a..6cab829f0 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -139,6 +139,35 @@ openshell sandbox list --selector env=dev,team=platform ## Monitor and Debug +## Expose HTTP Services + +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 +``` + +Expose a service that listens on loopback inside the sandbox: + +```shell +openshell service expose my-sandbox 8080 +``` + +Pass an optional service name to create a named service URL: + +```shell +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`. + + +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. + + + List all sandboxes: ```shell 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 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.