// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! `OpenShell` Server library. //! //! This crate provides the server implementation for `OpenShell`, including: //! - gRPC service implementation //! - HTTP health endpoints //! - Protocol multiplexing (gRPC + HTTP on same port) //! - mTLS support //! //! Compiled-in compute drivers are installed into a registry at gateway //! startup. Runtime selection only consults that registry or a configured //! external endpoint; it does not switch on driver names. mod auth; pub mod certgen; pub mod cli; mod compute; pub mod config_file; mod config_update_operation; mod credentials; mod defaults; mod gateway_listener; mod gateway_ocsf; mod grpc; mod http; mod middleware; mod multiplex; mod ocsf_log; mod otel_tracing; mod pagination; mod persistence; pub(crate) mod policy_store; mod provider_profile_sources; mod provider_refresh; mod readiness; mod sandbox_index; mod sandbox_watch; mod service_routing; mod ssh_sessions; mod storage_proto; mod supervisor_owner; pub mod supervisor_session; mod telemetry; #[cfg(any(test, feature = "test-support"))] pub mod test_support; mod tls; #[cfg(test)] pub(crate) mod tls_test_utils; pub mod tracing_bus; mod tracing_setup; mod watch_cursor; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; use openshell_core::net::set_tcp_nodelay_best_effort; use openshell_core::telemetry::TelemetryComputeDriver; use openshell_core::{Config, Error, ObjectLabels, Result}; use openshell_extension_core::{ BearerTokenSlot, ExtensionAudience, ExtensionCallerKind, ExtensionKind, MAX_EXTENSION_TOKEN_TTL, }; use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::{BTreeMap, HashMap}; use std::io::ErrorKind; use std::net::SocketAddr; use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::LazyLock; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tracing::{debug, error, info, warn}; #[cfg(test)] pub(crate) static TEST_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); /// Serializes tests that assert on captured spans, which share one exporter. #[cfg(test)] pub(crate) static TEST_TRACING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); pub(crate) fn install_jsonwebtoken_crypto_provider() { let _ = jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER.install_default(); } use compute::ComputeRuntime; use gateway_listener::{BoundGatewayListener, bind_gateway_listener}; pub use grpc::OpenShellService; pub use http::{health_router, http_router, metrics_router, service_http_router}; /// Deriving `Debug` is safe here: `BearerTokenSlot` renders only its expiry, /// and an extension audience is configuration rather than secret material. #[derive(Debug)] struct GatewayExtensionCredential { name: String, audience: ExtensionAudience, slot: BearerTokenSlot, ttl: Duration, } fn extension_token_ttl(issuer: &auth::sandbox_jwt::ExtensionJwtIssuer) -> Duration { issuer.token_ttl().min(MAX_EXTENSION_TOKEN_TTL) } /// Mint the gateway-caller credential for one extension registration. /// /// Returns `Ok(None)` when the operator has explicitly opted the registration /// out of extension authentication. The opt-out is deliberately loud: it /// downgrades a security boundary, so it is reported once per registration at /// startup rather than being silently tolerated. fn mint_gateway_extension_credential( issuer: &Arc, kind: ExtensionKind, name: &str, audience: &str, endpoint: &str, allow_insecure_transport: bool, ) -> Result> { // A middleware endpoint must be reachable from sandbox supervisors, so a // gateway-local Unix socket is only an option for interceptors. let (accepted, supports_unix) = match kind { ExtensionKind::Middleware => ("https://", false), ExtensionKind::Interceptor => ("https:// or unix://", true), _ => { return Err(Error::config(format!( "extension kind '{kind}' is not supported by gateway authentication" ))); } }; if allow_insecure_transport { warn!( extension = %name, endpoint = %endpoint, "extension authentication is DISABLED for this registration by \ allow_insecure_transport; OpenShell attaches no caller credential \ and the service cannot distinguish OpenShell from any other \ network client. Use {accepted} with the opt-out removed outside \ trusted-network development deployments." ); return Ok(None); } let transport_supported = endpoint.starts_with("https://") || (supports_unix && endpoint.starts_with("unix://")); if !transport_supported { return Err(Error::config(format!( "authenticated {kind} '{name}' must use {accepted}; set \ allow_insecure_transport = true to opt this registration out of \ extension authentication instead" ))); } let audience = ExtensionAudience::new(audience.to_string()).map_err(|error| { Error::config(format!( "extension '{name}' has an invalid audience: {error}" )) })?; let ttl = extension_token_ttl(issuer); let minted = issuer .mint_extension_token(&audience, ExtensionCallerKind::Gateway, None, ttl) .map_err(|status| { Error::config(format!( "failed to mint credential for extension '{name}': {}", status.message() )) })?; let slot = BearerTokenSlot::new(&minted.token, minted.expires_at_ms).map_err(|error| { Error::config(format!( "failed to install credential for extension '{name}': {error}" )) })?; Ok(Some(GatewayExtensionCredential { name: name.to_string(), audience, slot, ttl, })) } fn spawn_gateway_extension_token_refresh( issuer: Arc, credentials: Vec, ) { if credentials.is_empty() { return; } tokio::spawn(async move { loop { let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| { i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) }); let remaining_ms = credentials .iter() .filter_map(|credential| credential.slot.expires_at_ms()) .min() .map_or(60_000, |expiry_ms| expiry_ms.saturating_sub(now_ms)); let refresh_delay = if remaining_ms <= 0 { Duration::from_millis(100) } else { Duration::from_millis( u64::try_from(remaining_ms) .unwrap_or(u64::MAX) .saturating_mul(4) .checked_div(5) .unwrap_or(100) .max(100), ) }; tokio::time::sleep(refresh_delay).await; for credential in &credentials { match issuer.mint_extension_token( &credential.audience, ExtensionCallerKind::Gateway, None, credential.ttl, ) { Ok(minted) => { if let Err(error) = credential.slot.update(&minted.token, minted.expires_at_ms) { warn!( extension = %credential.name, error = %error, "failed to rotate gateway extension credential" ); } } Err(status) => warn!( extension = %credential.name, error = %status, "failed to mint gateway extension credential" ), } } } }); } pub use multiplex::{MultiplexService, MultiplexedService}; pub use persistence::Store; use sandbox_index::SandboxIndex; use sandbox_watch::SandboxWatchBus; pub use tls::TlsAcceptor; use tracing_bus::TracingLogBus; pub(crate) struct ServerStartupConfig { pub config: Config, pub config_file: Option, pub guest_tls: Option, pub compute_driver: ComputeDriverSelection, pub legacy_compute_driver_env_seen: bool, } /// Server state shared across handlers. #[derive(Debug)] pub struct ServerState { /// Server configuration. pub config: Config, /// Persistence store. pub store: Arc, /// Compute orchestration over the configured driver. pub compute: ComputeRuntime, /// Credential-driver selection and resolution runtime. pub credentials: credentials::CredentialRuntime, /// In-memory sandbox correlation index. pub sandbox_index: SandboxIndex, /// In-memory bus for sandbox update notifications. pub sandbox_watch_bus: SandboxWatchBus, /// In-memory bus for server process logs. pub tracing_log_bus: TracingLogBus, /// In-memory anonymous telemetry accounting for active sandbox sessions. pub(crate) telemetry: telemetry::TelemetryState, /// Active SSH tunnel connection counts per session token. pub ssh_connections_by_token: Mutex>, /// Active SSH tunnel connection counts per sandbox id. pub ssh_connections_by_sandbox: Mutex>, /// Serializes settings mutations (global and sandbox) to prevent /// read-modify-write races. Held for the duration of any setting /// set/delete operation, including the precedence check on sandbox /// mutations that reads global state. pub settings_mutex: tokio::sync::Mutex<()>, /// Registry of active supervisor sessions and pending relay channels. /// /// Stored as `Arc` so compiled compute drivers can be constructed before /// `ServerState` and still /// query session state to surface supervisor readiness. pub supervisor_sessions: Arc, /// Set once graceful gateway shutdown begins so stream handlers can /// distinguish expected transport closes from runtime failures. pub(crate) gateway_shutting_down: AtomicBool, /// Stable identity for this gateway process. pub replica_id: String, /// Internal endpoint other gateway replicas can dial for peer RPCs. pub peer_endpoint: Option, /// Reused peer connections, peer token, and owner lookups for relay /// forwarding. Keeps per-relay cost off the connection and auth paths. pub peer_routes: Arc, /// Idle HTTP/1 upstreams to sandbox services, so routed requests reuse a /// relay instead of opening one per request. pub service_upstreams: Arc, /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, /// Typed extension JWT issuer and public verification metadata. pub extension_jwt_issuer: Option>, /// Launch-scoped gateway and Sandbox Protocol token authority. pub sandbox_session_jwt_authority: Option>, /// Optional selected-driver authenticator for the `IssueSandboxToken` /// bootstrap path. pub compute_driver_authenticator: Option>, /// Optional K8s `ServiceAccount` authenticator for gateway peer RPCs. pub peer_authenticator: Option>, /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, /// Per-sandbox bound on extension credential minting, which resolves the /// caller's effective policy on every request. pub(crate) extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter, /// Immutable gateway interceptor execution plan. `None` when disabled. pub(crate) gateway_interceptors: Option, /// Gateway-local provider profile sources. User-imported profiles are read /// on demand when the user source is configured. pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, /// OIDC admin role name for workspace-level authorization. /// Empty when OIDC is not configured — `authorize_workspace()` treats /// every authenticated user as Platform Admin in that case. pub admin_role: String, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { matches!( error.kind(), ErrorKind::UnexpectedEof | ErrorKind::ConnectionReset ) } fn is_benign_connection_close(error: &(dyn std::error::Error + 'static)) -> bool { openshell_core::transport_errors::is_expected_transport_close_error(error) } impl ServerState { /// Create new server state. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new( config: Config, store: Arc, compute: ComputeRuntime, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, oidc_cache: Option>, ) -> Self { let credentials = credentials::CredentialRuntime::from_config_with_store(&config, Arc::clone(&store)) .expect("server config should be validated before ServerState::new"); Self::new_with_credentials( config, store, compute, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, oidc_cache, credentials, ) } /// Create new server state with an already-initialized credential runtime. #[must_use] #[allow(clippy::too_many_arguments)] pub fn new_with_credentials( config: Config, store: Arc, compute: ComputeRuntime, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, oidc_cache: Option>, credentials: credentials::CredentialRuntime, ) -> Self { let replica_id = compute::lease::replica_id(); let peer_endpoint = derive_peer_endpoint(&config); let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); let admin_role = config .oidc .as_ref() .map_or_else(String::new, |oidc| oidc.admin_role.clone()); Self { config, store, compute, credentials, sandbox_index, sandbox_watch_bus, tracing_log_bus, telemetry: telemetry::TelemetryState::new(), ssh_connections_by_token: Mutex::new(HashMap::new()), ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, gateway_shutting_down: AtomicBool::new(false), replica_id, peer_endpoint, peer_routes: Arc::new(supervisor_session::PeerRouteCache::default()), service_upstreams: Arc::new(service_routing::ServiceUpstreamPool::default()), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, extension_jwt_issuer: None, sandbox_session_jwt_authority: None, compute_driver_authenticator: None, peer_authenticator: None, grpc_rate_limiter, gateway_interceptors: None, provider_profile_sources: provider_profile_sources::ProviderProfileSources::with_default_sources(), admin_role, } } } fn derive_peer_endpoint(config: &Config) -> Option { if let Ok(endpoint) = std::env::var("OPENSHELL_PEER_ENDPOINT") && !endpoint.trim().is_empty() { return Some(endpoint.trim().to_string()); } let pod_name = std::env::var("OPENSHELL_POD_NAME").ok()?; let namespace = std::env::var("OPENSHELL_POD_NAMESPACE").ok()?; let service = std::env::var("OPENSHELL_PEER_SERVICE_NAME").ok()?; if pod_name.trim().is_empty() || namespace.trim().is_empty() || service.trim().is_empty() { return None; } let scheme = if config.tls.is_some() { "https" } else { "http" }; Some(format!( "{scheme}://{pod}.{service}.{namespace}.svc.cluster.local:{port}", pod = pod_name.trim(), service = service.trim(), namespace = namespace.trim(), port = config.bind_address.port() )) } /// Reject a plaintext peer endpoint on a gateway that serves TLS. /// /// Peer relay traffic carries whole supervisor sessions between replicas. The /// chart renders a plaintext peer endpoint only when the gateway itself serves fn validate_peer_endpoint_scheme(config: &Config, peer_endpoint: &str) -> Result<()> { if peer_endpoint.starts_with("https://") { return Ok(()); } if config.tls.is_some() { return Err(Error::config(format!( "gateway peer endpoint {peer_endpoint} is plaintext but this gateway serves TLS; \ set an https:// OPENSHELL_PEER_ENDPOINT so peer relay traffic is not downgraded" ))); } warn!( peer_endpoint, "gateway peer relay traffic is plaintext because this gateway does not serve TLS" ); Ok(()) } /// Run the `OpenShell` server. /// /// This starts a multiplexed gRPC/HTTP server on the configured bind address. /// /// # Errors /// /// Returns an error if the server fails to start or encounters a fatal error. pub(crate) async fn run_server( startup: ServerStartupConfig, tracing_log_bus: TracingLogBus, compute_drivers: ComputeDriverRegistry, ) -> Result<()> { let ServerStartupConfig { config, config_file, guest_tls, compute_driver, legacy_compute_driver_env_seen: _, } = startup; let (shutdown_tx, shutdown_rx) = watch::channel(false); auth::descriptor_authz::init() .map_err(|error| Error::config(format!("invalid gRPC authorization metadata: {error}")))?; let database_url = config.database_url.trim(); if database_url.is_empty() { return Err(Error::config("database_url is required")); } // Load signing material before connecting remote extensions so their // startup Describe calls can authenticate with gateway-caller tokens. let (extension_jwt_issuer, sandbox_session_jwt_authority) = if let Some(ref jwt) = config.gateway_jwt { let signing_pem = std::fs::read(&jwt.signing_key_path).map_err(|e| { Error::config(format!( "failed to read sandbox JWT signing key from {}: {e}", jwt.signing_key_path.display() )) })?; let public_pem = std::fs::read(&jwt.public_key_path).map_err(|e| { Error::config(format!( "failed to read sandbox JWT public key from {}: {e}", jwt.public_key_path.display() )) })?; let kid = std::fs::read_to_string(&jwt.kid_path) .map_err(|e| { Error::config(format!( "failed to read sandbox JWT kid from {}: {e}", jwt.kid_path.display() )) })? .trim() .to_string(); if kid.is_empty() { return Err(Error::config(format!( "sandbox JWT kid file {} is empty", jwt.kid_path.display() ))); } let issuer = Arc::new( auth::sandbox_jwt::ExtensionJwtIssuer::from_pem( &signing_pem, &public_pem, kid.clone(), &jwt.gateway_id, jwt.token_ttl(), ) .map_err(Error::config)?, ); let session_authority = Arc::new( auth::sandbox_jwt::SandboxSessionJwtAuthority::from_pem( &signing_pem, &public_pem, kid, &jwt.gateway_id, jwt.sandbox_token_ttl(), ) .map_err(Error::config)?, ); info!( gateway_id = %jwt.gateway_id, ttl_secs = jwt.ttl_secs.map(std::num::NonZeroU64::get), "gateway-minted sandbox JWT enabled" ); (Some(issuer), Some(session_authority)) } else { (None, None) }; let middleware_registrations = config_file .as_ref() .map(|file| { file.openshell .supervisor .middleware .iter() .map(openshell_core::proto::SupervisorMiddlewareService::try_from) .collect::, _>>() }) .transpose() .map_err(|error| Error::config(format!("middleware registration failed: {error}")))? .unwrap_or_default(); let mut gateway_extension_credentials = Vec::new(); let middleware_registry = Arc::new( if let Some(issuer) = extension_jwt_issuer.as_ref() { let mut slots = HashMap::new(); for registration in &middleware_registrations { if let Some(credential) = mint_gateway_extension_credential( issuer, ExtensionKind::Middleware, ®istration.name, ®istration.audience, ®istration.grpc_endpoint, registration.allow_insecure_transport, )? { slots.insert(registration.name.clone(), credential.slot.clone()); gateway_extension_credentials.push(credential); } } MiddlewareRegistry::connect_services_authenticated( openshell_supervisor_middleware_builtins::services(), middleware_registrations, &slots, ) .await } else { MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), middleware_registrations, ) .await } .map_err(|error| Error::config(format!("middleware registration failed: {error}")))?, ); let store = Arc::new(Store::connect(database_url).await?); let credentials = credentials::CredentialRuntime::from_config_file_with_store( &config, config_file.as_ref(), Arc::clone(&store), ) .await?; let oidc_cache = if let Some(ref oidc) = config.oidc { // Validate RBAC configuration before starting. let policy = auth::authz::AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), scopes_enabled: !oidc.scopes_claim.is_empty(), }; policy.validate().map_err(Error::config)?; let cache = auth::oidc::JwksCache::new(oidc) .await .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; info!("OIDC JWT validation enabled (issuer: {})", oidc.issuer); Some(Arc::new(cache)) } else { None }; let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); let driver_startup = compute::driver_config::DriverStartupContext { file: config_file.as_ref(), guest_tls: guest_tls.as_ref(), gateway_port: config.bind_address.port(), gateway_tls_enabled: config.tls.is_some(), endpoint_overrides: &config.compute_driver_endpoints, }; let compute = build_compute_runtime( &compute_drivers, &compute_driver, &config, driver_startup, store.clone(), sandbox_index.clone(), sandbox_watch_bus.clone(), tracing_log_bus.clone(), supervisor_sessions.clone(), shutdown_rx.clone(), ) .await?; let gateway_interceptors = if let Some(issuer) = extension_jwt_issuer.as_ref() { let mut slots = BTreeMap::new(); for interceptor in &config.gateway_interceptors { let audience = interceptor.resolved_audience(); if let Some(credential) = mint_gateway_extension_credential( issuer, ExtensionKind::Interceptor, &interceptor.name, audience.as_ref(), &interceptor.grpc_endpoint, interceptor.allow_insecure_transport, )? { slots.insert(interceptor.name.clone(), credential.slot.clone()); gateway_extension_credentials.push(credential); } } openshell_gateway_interceptors::initialize_authenticated( config.gateway_interceptors.clone(), slots, ) .await } else { openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()).await } .map_err(|e| Error::config(format!("gateway interceptor initialization failed: {e}")))?; let provider_profile_sources = provider_profile_sources::ProviderProfileSources::from_config( &config.provider_profile_sources, gateway_interceptors.as_ref(), ) .map_err(|err| { Error::config(format!( "provider profile source configuration failed: {err}" )) })?; info!( sources = ?provider_profile_sources.source_ids(), "provider profile sources configured" ); let mut state = ServerState::new_with_credentials( config.clone(), store.clone(), compute, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, oidc_cache, credentials, ); state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; state.extension_jwt_issuer = extension_jwt_issuer.clone(); state.sandbox_session_jwt_authority = sandbox_session_jwt_authority; if let Some(issuer) = extension_jwt_issuer { spawn_gateway_extension_token_refresh(issuer, gateway_extension_credentials); } if state.sandbox_session_jwt_authority.is_some() && state.compute.supports_sandbox_authentication() { state.compute_driver_authenticator = Some(Arc::new( auth::compute_driver::ComputeDriverAuthenticator::new(state.compute.clone()), )); info!( driver = state.compute.configured_driver_name(), "compute-driver sandbox bootstrap authenticator enabled" ); } let peer_routing_expected = state.peer_endpoint.is_some() && !state.store.is_single_replica(); if let Some(peer_endpoint) = state.peer_endpoint.as_deref() && peer_routing_expected { validate_peer_endpoint_scheme(&state.config, peer_endpoint)?; } if state.peer_endpoint.is_none() && !state.store.is_single_replica() { warn!( "no gateway peer endpoint configured; this replica owns its supervisor sessions but \ peers cannot reach it. Single-gateway deployments are unaffected; set \ OPENSHELL_PEER_ENDPOINT on every replica when running more than one." ); } if std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() { let namespace = std::env::var("OPENSHELL_POD_NAMESPACE").ok(); let service_account = std::env::var("OPENSHELL_SERVICE_ACCOUNT_NAME").ok(); match (namespace, service_account) { (Some(namespace), Some(service_account)) if !namespace.trim().is_empty() && !service_account.trim().is_empty() => { let required_labels = auth::peer::required_pod_labels_from_env().map_err(Error::config)?; match kube::Client::try_default().await { Ok(client) => { let audience = auth::peer::peer_token_audience_from_env(); let resolver = Arc::new(auth::peer::LiveGatewayPeerResolver::new( client, namespace.trim(), audience.clone(), service_account.trim().to_string(), required_labels, )); let cache_ttl = auth::peer::peer_token_cache_ttl_from_env(); let resolver = Arc::new(auth::peer::CachingGatewayPeerResolver::new( resolver, cache_ttl, )); let authenticator = auth::peer::PeerServiceAccountAuthenticator::new(resolver); state.peer_authenticator = Some(Arc::new(authenticator)); info!( namespace = %namespace.trim(), service_account = %service_account.trim(), audience, token_cache_ttl_secs = cache_ttl.as_secs(), "gateway peer ServiceAccount TokenReview authentication enabled" ); } Err(err) if peer_routing_expected => { return Err(Error::config(format!( "in-cluster K8s client construction failed ({err}); \ gateway peer authentication is required because \ OPENSHELL_PEER_ENDPOINT is configured" ))); } Err(err) => warn!( error = %err, "in-cluster K8s client construction failed; \ gateway peer ServiceAccount authentication is disabled" ), } } _ if peer_routing_expected => { return Err(Error::config( "OPENSHELL_POD_NAMESPACE or OPENSHELL_SERVICE_ACCOUNT_NAME missing; \ both are required for gateway peer authentication because \ OPENSHELL_PEER_ENDPOINT is configured" .to_string(), )); } _ => { debug!( "OPENSHELL_POD_NAMESPACE or OPENSHELL_SERVICE_ACCOUNT_NAME missing; \ gateway peer ServiceAccount authentication disabled" ); } } } else if peer_routing_expected { return Err(Error::config( "OPENSHELL_PEER_ENDPOINT is configured but the gateway is not running in a \ Kubernetes cluster, so gateway peer authentication is unavailable" .to_string(), )); } let state = Arc::new(state); // Reconcile local-driver running intent before watchers spawn so their // first snapshots observe the post-start backend state. Explicitly stopped // sandboxes remain stopped. ensure_default_workspace(&store).await?; grpc::policy::validate_provider_composition_startup_preflight(&state) .await .map_err(|error| { Error::config(format!( "provider policy composition startup preflight failed: {}", error.message() )) })?; grpc::policy::invalidate_endpoint_status_on_startup(&state) .await .map_err(|error| { Error::execution(format!( "tool server endpoint-status startup reconciliation failed: {}", error.message() )) })?; let gateway_listener = bind_gateway_listener(config.bind_address).await?; // Create the multiplexed service let service = MultiplexService::new(state.clone()); // Bind the unauthenticated health endpoint on a separate port when configured. if let Some(health_bind_address) = config.health_bind_address { let health_listener = TcpListener::bind(health_bind_address).await.map_err(|e| { Error::transport(format!( "failed to bind health port {health_bind_address}: {e}" )) })?; info!(address = %health_bind_address, "Health server listening"); // `health_router` returns immediately; the listener serves // `Initializing → 503` until the background monitor publishes the // first real probe outcome, so the endpoint is always responsive. let router = health_router(store.clone()); tokio::spawn(async move { if let Err(e) = axum::serve(health_listener, router.into_make_service()).await { error!("Health server error: {e}"); } }); } else { info!("Health server disabled"); } // Bind the Prometheus metrics endpoint on a dedicated port when configured. if let Some(metrics_bind_address) = config.metrics_bind_address { let prometheus_handle = PrometheusBuilder::new() .install_recorder() .map_err(|e| Error::config(format!("failed to install metrics recorder: {e}")))?; let metrics_listener = TcpListener::bind(metrics_bind_address).await.map_err(|e| { Error::transport(format!( "failed to bind metrics port {metrics_bind_address}: {e}", )) })?; info!(address = %metrics_bind_address, "Metrics server listening"); tokio::spawn(async move { if let Err(e) = axum::serve( metrics_listener, metrics_router(prometheus_handle).into_make_service(), ) .await { error!("Metrics server error: {e}"); } }); } else { info!("Metrics server disabled"); } // Build TLS acceptor when TLS is configured; otherwise serve plaintext. let tls_acceptor = if let Some(tls) = &config.tls { let acceptor = TlsAcceptor::from_files( &tls.cert_path, &tls.key_path, tls.client_ca_path.as_deref(), tls.require_client_auth, tls.external_cert_path.as_deref(), tls.external_key_path.as_deref(), tls.external_server_names.clone(), )?; // Spawn file-watcher-based TLS certificate reload worker. // Watches parent directories of cert/key/CA files and atomically // reloads when changes are detected. acceptor.spawn_reload_worker(shutdown_rx.clone()); Some(acceptor) } else { info!("TLS disabled — accepting plaintext connections"); None }; let enable_loopback_service_http = config.service_routing.enable_loopback_service_http; let listener_task = tokio::spawn(serve_gateway_listener( gateway_listener, service.clone(), tls_acceptor.clone(), enable_loopback_service_http, shutdown_rx.clone(), )); // Deadlines must run while restored supervisors wait for policy repair. let (startup_tx, startup_rx) = watch::channel(false); state.compute.spawn_watchers( shutdown_rx.clone(), startup_rx, state.sandbox_session_jwt_authority.clone(), ); // Serve the gateway before reconciling persisted sandboxes so restored // supervisors can fetch policy and register their sessions. if let Err(err) = state .compute .start_persisted_sandboxes_with_authentication( |sandbox| { let state = state.clone(); let sandbox = sandbox.clone(); async move { if state.sandbox_session_jwt_authority.is_none() { return Ok(Vec::new()); } let authentication = grpc::mint_persisted_authentication(&state, &sandbox) .map_err(|error| error.to_string())?; serde_json::to_vec(&authentication) .map_err(|error| format!("encode launch authentication: {error}")) } }, |_| async { Ok(()) }, |_| {}, ) .await { warn!(error = %err, "Failed to start persisted sandboxes during startup"); } startup_tx.send_replace(true); // The poller exists to observe writes made by other replicas. Single- // replica backends have none, so it would only add load. if !store.is_single_replica() { sandbox_watch::spawn_store_poller( store.clone(), state.sandbox_watch_bus.clone(), sandbox_watch::DEFAULT_STORE_POLL_INTERVAL, shutdown_rx.clone(), ); } ssh_sessions::spawn_session_reaper(store.clone(), Duration::from_hours(1)); supervisor_session::spawn_relay_reaper(state.clone(), Duration::from_secs(30)); provider_refresh::spawn_refresh_worker(state.clone(), Duration::from_mins(1)); shutdown_signal().await; info!("Shutdown signal received; stopping gateway"); state.gateway_shutting_down.store(true, Ordering::Release); state.supervisor_sessions.close_admission(); let _ = shutdown_tx.send(true); if let Err(err) = listener_task.await { warn!(error = %err, "Gateway listener task failed during shutdown"); } let compute_cleanup = state.compute.cleanup_on_shutdown().await; // A stopped supervisor may still have a detached task deleting its owner // record. Drain it even when compute cleanup failed before exiting Tokio. let session_cleanup = state .supervisor_sessions .shutdown(Duration::from_secs(10)) .await; if let Err(err) = &session_cleanup { warn!(error = %err, "Gateway supervisor session cleanup incomplete"); } compute_cleanup .map_err(|err| Error::execution(format!("gateway shutdown cleanup failed: {err}")))?; session_cleanup.map_err(Error::execution)?; Ok(()) } async fn serve_gateway_listener( bound_listener: BoundGatewayListener, service: MultiplexService, tls_acceptor: Option, enable_loopback_service_http: bool, mut shutdown: watch::Receiver, ) { let BoundGatewayListener { listener, address: listen_addr, } = bound_listener; loop { let accepted = tokio::select! { changed = shutdown.changed() => { if changed.is_err() || *shutdown.borrow() { break; } continue; } accepted = listener.accept() => accepted, }; let (stream, addr) = match accepted { Ok(conn) => conn, Err(e) => { error!(error = %e, listen = %listen_addr, "Failed to accept connection"); continue; } }; set_tcp_nodelay_best_effort(&stream); 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 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 { if is_benign_connection_close(e.as_ref()) { debug!(error = %e, client = %addr, listen = %listen_addr, "Plaintext service HTTP connection closed"); } else { error!(error = %e, client = %addr, listen = %listen_addr, "Plaintext service HTTP connection error"); } } } Ok(ConnectionProtocol::PlainHttp) => { warn!( client = %addr, listen = %listen_addr, "Rejected plaintext HTTP on gateway listener" ); } Ok(ConnectionProtocol::Tls | ConnectionProtocol::Unknown) => { // acceptor.acceptor() snapshots the current TLS config; // the returned acceptor owns an Arc that stays alive for // the full duration of the handshake. match acceptor.acceptor().accept(stream).await { Ok(tls_stream) => { let peer_identity = multiplex::extract_peer_identity(&tls_stream); if let Err(e) = service .serve_with_peer_identity(tls_stream, peer_identity) .await { if is_benign_connection_close(e.as_ref()) { debug!(error = %e, client = %addr, "Connection closed"); } else { 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 { tokio::spawn(async move { if let Err(e) = service.serve(stream).await { if is_benign_connection_close(e.as_ref()) { debug!(error = %e, client = %addr, "Connection closed"); } else { error!(error = %e, client = %addr, "Connection error"); } } }); } } async fn shutdown_signal() { #[cfg(unix)] { tokio::select! { () = ctrl_c_signal() => {} () = terminate_signal() => {} } } #[cfg(not(unix))] { ctrl_c_signal().await; } } async fn ctrl_c_signal() { if let Err(err) = tokio::signal::ctrl_c().await { warn!(error = %err, "Failed to install Ctrl-C signal handler"); std::future::pending::<()>().await; } } #[cfg(unix)] async fn terminate_signal() { let Ok(mut signal) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) else { warn!("Failed to install SIGTERM signal handler"); std::future::pending::<()>().await; return; }; let _ = signal.recv().await; } pub use compute::{ AcquiredRemoteDriverEndpoint, DriverWatchStream, ManagedDriverProcess, SharedComputeDriver, }; /// Driver instance returned by a compiled compute-driver factory. pub enum ComputeDriverInstance { /// A driver hosted in the gateway process. InProcess(SharedComputeDriver), /// A driver process launched and owned by the gateway. ManagedRemote(AcquiredRemoteDriverEndpoint), } /// Factory for a compute driver linked into a gateway binary. #[async_trait::async_trait] pub trait ComputeDriverFactory: Send + Sync { /// Validate selected-driver configuration without starting a driver, /// connecting a transport, or modifying runtime state. /// /// The default preserves source compatibility for existing out-of-tree /// factories during normal startup. Package preflight rejects a selected /// factory unless [`Self::supports_config_preflight`] is also overridden /// to return `true`. fn validate_config(&self, _context: ComputeDriverConfigContext<'_>) -> Result<()> { Ok(()) } /// Whether [`Self::validate_config`] fully validates this factory's /// configuration without runtime side effects. fn supports_config_preflight(&self) -> bool { false } async fn build(&self, context: ComputeDriverBuildContext<'_>) -> Result; } /// One named compiled-driver registration. #[derive(Clone)] pub struct ComputeDriverRegistration { name: String, detection_priority: u16, detect: Option bool>, factory: Arc, telemetry_category: TelemetryComputeDriver, in_process_tracing: Option, } impl std::fmt::Debug for ComputeDriverRegistration { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("ComputeDriverRegistration") .field("name", &self.name) .field("detection_priority", &self.detection_priority) .field("has_detection_probe", &self.detect.is_some()) .finish_non_exhaustive() } } impl ComputeDriverRegistration { /// Define a compiled driver. Lower detection priorities are preferred. pub fn new( name: impl Into, detection_priority: u16, detect: Option bool>, factory: impl ComputeDriverFactory + 'static, ) -> Result { let name = openshell_core::config::normalize_compute_driver_name(&name.into()) .map_err(Error::config)?; Ok(Self { name, detection_priority, detect, factory: Arc::new(factory), telemetry_category: TelemetryComputeDriver::custom(), in_process_tracing: None, }) } /// Compatibility no-op retained for source compatibility with schema-v1 /// factory registrations. Schema v2 never inherits gateway keys into a /// driver table; move every driver setting under /// `[openshell.drivers.]`. #[deprecated( since = "0.0.0", note = "schema v2 does not inherit gateway keys into driver configuration" )] #[must_use] pub fn with_inherited_config_keys(self, _keys: &'static [&'static str]) -> Self { self } /// Assign a bounded telemetry category chosen by the binary composition /// boundary. Runtime driver names are never used as telemetry values. #[must_use] pub fn with_telemetry_category(mut self, category: TelemetryComputeDriver) -> Self { self.telemetry_category = category; self } /// Compatibility no-op retained for existing factory registrations. /// Gateway mTLS user authentication is independent of compute drivers. #[must_use] pub fn with_local_singleplayer(self) -> Self { self } /// Attach process-wide tracing for this in-process compiled driver. #[must_use] pub fn with_in_process_tracing( mut self, tracing: openshell_otel::ComputeDriverTracing, ) -> Self { self.in_process_tracing = Some(tracing); self } #[must_use] pub fn in_process_tracing(&self) -> Option { self.in_process_tracing } } /// Registry of compute drivers compiled into this gateway binary. /// /// Like `SQLx`'s `Any` driver registry, installation is explicit at the binary /// composition boundary while runtime selection is generic. #[derive(Clone, Default)] pub struct ComputeDriverRegistry { drivers: BTreeMap, } #[derive(Clone, Debug)] struct ComputeDriverDetection { available: Vec, } impl ComputeDriverDetection { fn selected(&self) -> Option<&str> { self.available.first().map(String::as_str) } } #[derive(Clone, Debug)] pub(crate) enum ComputeDriverSelection { Configured { name: String }, AutoDetected(ComputeDriverDetection), } impl ComputeDriverSelection { pub(crate) fn name(&self) -> &str { match self { Self::Configured { name } => name, Self::AutoDetected(detection) => detection .selected() .expect("auto-detected selection has an available driver"), } } } impl ComputeDriverRegistry { #[must_use] pub fn new() -> Self { Self::default() } /// Install a compiled driver factory. pub fn install(&mut self, registration: ComputeDriverRegistration) -> Result<()> { let name = registration.name.clone(); match self.drivers.entry(name.clone()) { std::collections::btree_map::Entry::Vacant(entry) => { entry.insert(registration); Ok(()) } std::collections::btree_map::Entry::Occupied(_) => Err(Error::config(format!( "compute driver '{name}' registered twice" ))), } } /// Names installed into this gateway binary, in lexical order. pub fn installed_driver_names(&self) -> impl Iterator { self.drivers.keys().map(String::as_str) } /// Names whose runtime registrations participate in auto-detection. /// /// This does not execute detection probes. Package preflight uses it to /// validate configured candidate tables without connecting local sockets /// or starting discovery commands. pub(crate) fn auto_detectable_driver_names(&self) -> impl Iterator { self.drivers .values() .filter(|registration| registration.detect.is_some()) .map(|registration| registration.name.as_str()) } pub(crate) fn get(&self, name: &str) -> Option<&ComputeDriverRegistration> { self.drivers.get(name) } fn in_process_tracing( &self, selection: &ComputeDriverSelection, endpoint_overrides: &BTreeMap, ) -> Option { let name = selection.name(); if endpoint_overrides.contains_key(name) { return None; } self.get(name) .and_then(ComputeDriverRegistration::in_process_tracing) } fn detect(&self) -> ComputeDriverDetection { let mut candidates = self .drivers .values() .filter(|registration| registration.detect.is_some()) .collect::>(); candidates.sort_by(|left, right| { left.detection_priority .cmp(&right.detection_priority) .then_with(|| left.name.cmp(&right.name)) }); let available = candidates .into_iter() .filter(|registration| registration.detect.is_some_and(|detect| detect())) .map(|registration| registration.name.clone()) .collect(); ComputeDriverDetection { available } } pub(crate) fn select(&self, configured_driver: Option<&str>) -> Result { match configured_driver { None => { let detection = self.detect(); if detection.selected().is_none() { return Err(Error::config( "no compute driver configured and auto-detection found no suitable installed \ driver; set --compute-driver or OPENSHELL_COMPUTE_DRIVER=", )); } Ok(ComputeDriverSelection::AutoDetected(detection)) } Some(driver) => { let name = openshell_core::config::normalize_compute_driver_name(driver) .map_err(Error::config)?; Ok(ComputeDriverSelection::Configured { name }) } } } } /// Read-only inputs available while validating a selected compute driver. /// /// This context deliberately exposes no shutdown handle, runtime store, or /// transport client. Implementations must remain deterministic and must not /// start processes, connect sockets, or modify state. #[derive(Clone, Copy)] pub struct ComputeDriverConfigContext<'a> { driver_name: &'a str, gateway_name: &'a str, gateway_bind_address: SocketAddr, gateway_log_level: &'a str, driver_startup: compute::driver_config::DriverStartupContext<'a>, } impl ComputeDriverConfigContext<'_> { #[must_use] pub fn driver_name(&self) -> &str { self.driver_name } #[must_use] pub fn gateway_name(&self) -> &str { self.gateway_name } #[must_use] pub fn gateway_bind_address(&self) -> SocketAddr { self.gateway_bind_address } #[must_use] pub fn gateway_log_level(&self) -> &str { self.gateway_log_level } #[must_use] pub fn gateway_port(&self) -> u16 { self.driver_startup.gateway_port } #[must_use] pub fn gateway_tls_enabled(&self) -> bool { self.driver_startup.gateway_tls_enabled } /// Deserialize the selected driver's merged TOML table. pub fn driver_config(&self) -> Result where T: Default + serde::de::DeserializeOwned, { compute::driver_config::driver_config_from_context(self.driver_startup, self.driver_name) } } pub struct ComputeDriverBuildContext<'a> { config: ComputeDriverConfigContext<'a>, shutdown_rx: watch::Receiver, } impl ComputeDriverBuildContext<'_> { #[must_use] pub fn config_context(&self) -> ComputeDriverConfigContext<'_> { self.config } #[must_use] pub fn driver_name(&self) -> &str { self.config.driver_name() } #[must_use] pub fn gateway_name(&self) -> &str { self.config.gateway_name() } #[must_use] pub fn gateway_bind_address(&self) -> SocketAddr { self.config.gateway_bind_address() } #[must_use] pub fn gateway_log_level(&self) -> &str { self.config.gateway_log_level() } #[must_use] pub fn gateway_port(&self) -> u16 { self.config.gateway_port() } #[must_use] pub fn gateway_tls_enabled(&self) -> bool { self.config.gateway_tls_enabled() } /// Gateway CA certificate that a local driver may provide to supervisors. #[must_use] pub fn guest_tls_ca(&self) -> Option<&Path> { self.config .driver_startup .guest_tls .map(compute::driver_config::GuestTlsPaths::as_path) } /// Deserialize the selected driver's merged TOML table. pub fn driver_config(&self) -> Result where T: Default + serde::de::DeserializeOwned, { self.config.driver_config() } #[must_use] pub fn shutdown_receiver(&self) -> watch::Receiver { self.shutdown_rx.clone() } #[must_use] pub fn otlp_config(&self) -> Option<&config_file::OtlpConfig> { self.config .driver_startup .file .and_then(|file| file.openshell.gateway.otlp.as_ref()) } } #[allow(clippy::too_many_arguments)] async fn build_compute_runtime( registry: &ComputeDriverRegistry, selection: &ComputeDriverSelection, config: &Config, driver_startup: compute::driver_config::DriverStartupContext<'_>, store: Arc, sandbox_index: SandboxIndex, sandbox_watch_bus: SandboxWatchBus, tracing_log_bus: TracingLogBus, supervisor_sessions: Arc, shutdown_rx: watch::Receiver, ) -> Result { let driver = validate_compute_driver_config( registry, selection.name(), &config.name, config.bind_address, &config.log_level, driver_startup, false, )?; let telemetry_compute_driver = driver.telemetry_compute_driver(registry); let admission = compute::driver_config::admission_config_from_context(driver_startup, driver.name())?; info!(driver = %driver.name(), "Using compute driver"); let runtime = match driver { ConfiguredComputeDriver::Registered(registration) => { let build_context = ComputeDriverBuildContext { config: ComputeDriverConfigContext { driver_name: ®istration.name, gateway_name: &config.name, gateway_bind_address: config.bind_address, gateway_log_level: &config.log_level, driver_startup, }, shutdown_rx, }; let instance = registration.factory.build(build_context).await?; match instance { ComputeDriverInstance::InProcess(driver) => ComputeRuntime::from_driver( registration.name, driver, None, store, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, ) .await .map_err(|error| { Error::execution(format!("failed to create compute runtime: {error}")) })?, ComputeDriverInstance::ManagedRemote(mut endpoint) => { endpoint.name = registration.name; ComputeRuntime::new_remote_driver( endpoint, store, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, ) .await .map_err(|error| { Error::execution(format!("failed to create compute runtime: {error}")) })? } } } ConfiguredComputeDriver::Remote { name } => { let remote_config = compute::driver_config::remote_driver_config_from_context(driver_startup, &name)?; info!( driver = %name, socket = %remote_config.socket_path.display(), "Using remote compute driver endpoint" ); let endpoint = compute::connect_remote_compute_driver(name, &remote_config.socket_path) .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))?; ComputeRuntime::new_remote_driver( endpoint, store, sandbox_index, sandbox_watch_bus, tracing_log_bus, supervisor_sessions, ) .await .map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))? } }; let runtime = runtime .with_admission_policy(admission) .map_err(Error::config)?; Ok(runtime.with_telemetry_compute_driver(telemetry_compute_driver)) } #[derive(Debug, Clone)] enum ConfiguredComputeDriver { Registered(ComputeDriverRegistration), Remote { name: String }, } impl ConfiguredComputeDriver { fn name(&self) -> &str { match self { Self::Registered(registration) => ®istration.name, Self::Remote { name } => name, } } fn telemetry_compute_driver(&self, registry: &ComputeDriverRegistry) -> TelemetryComputeDriver { match self { Self::Registered(registration) => registration.telemetry_category, Self::Remote { name } => registry .get(name) .map_or_else(TelemetryComputeDriver::custom, |registration| { registration.telemetry_category }), } } } #[cfg(test)] fn configured_compute_driver( registry: &ComputeDriverRegistry, config: &Config, driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { let selection = registry.select(config.compute_driver.as_deref())?; resolve_configured_compute_driver(registry, selection.name(), driver_startup) } #[allow(clippy::too_many_arguments)] fn validate_compute_driver_config( registry: &ComputeDriverRegistry, driver_name: &str, gateway_name: &str, gateway_bind_address: SocketAddr, gateway_log_level: &str, driver_startup: compute::driver_config::DriverStartupContext<'_>, require_preflight_support: bool, ) -> Result { let driver = resolve_configured_compute_driver(registry, driver_name, driver_startup)?; match &driver { ConfiguredComputeDriver::Registered(registration) => { if require_preflight_support && !registration.factory.supports_config_preflight() { return Err(Error::config(format!( "compute driver '{}' does not support side-effect-free configuration preflight", registration.name ))); } registration .factory .validate_config(ComputeDriverConfigContext { driver_name: ®istration.name, gateway_name, gateway_bind_address, gateway_log_level, driver_startup, })?; } ConfiguredComputeDriver::Remote { name } => { compute::driver_config::remote_driver_config_from_context(driver_startup, name)?; } } Ok(driver) } fn resolve_configured_compute_driver( registry: &ComputeDriverRegistry, driver_name: &str, driver_startup: compute::driver_config::DriverStartupContext<'_>, ) -> Result { let name = openshell_core::config::normalize_compute_driver_name(driver_name) .map_err(Error::config)?; // An operator-provided endpoint replaces normal construction for the // selected name, including a compiled registration with the same name. // The gateway connects to it; it does not provision the remote driver. if driver_startup.endpoint_overrides.contains_key(&name) { return Ok(ConfiguredComputeDriver::Remote { name }); } if let Some(registration) = registry.get(&name) { return Ok(ConfiguredComputeDriver::Registered(registration.clone())); } Ok(ConfiguredComputeDriver::Remote { name }) } pub(crate) async fn ensure_default_workspace(store: &Store) -> Result<()> { use grpc::workspace::{DEFAULT_WORKSPACE_NAME, WORKSPACE_OBJECT_TYPE}; use openshell_core::proto::Workspace; use openshell_core::proto::datamodel::v1::ObjectMeta; use prost::Message; let id = uuid::Uuid::new_v4().to_string(); let workspace = Workspace { metadata: Some(ObjectMeta { id: id.clone(), name: DEFAULT_WORKSPACE_NAME.to_string(), created_time: openshell_core::time::timestamp_from_millis( persistence::current_time_ms(), ) .ok(), labels: HashMap::new(), annotations: HashMap::new(), resource_version: 0, workspace: String::new(), deletion_time: None, }), status: Some(openshell_core::proto::datamodel::v1::WorkspaceStatus { phase: openshell_core::proto::datamodel::v1::WorkspacePhase::Active.into(), }), }; let labels_map = workspace.object_labels(); let labels_json = if labels_map.as_ref().is_none_or(HashMap::is_empty) { None } else { Some( serde_json::to_string(&labels_map).map_err(|e| Error::Config { message: format!("failed to serialize labels: {e}"), })?, ) }; match store .put_if( WORKSPACE_OBJECT_TYPE, &id, DEFAULT_WORKSPACE_NAME, "", &workspace.encode_to_vec(), labels_json.as_deref(), persistence::WriteCondition::MustCreate, ) .await { Ok(_) => { info!("Created default workspace"); Ok(()) } Err(persistence::PersistenceError::UniqueViolation { .. }) => { debug!("Default workspace already exists"); Ok(()) } Err(e) => Err(Error::config(format!( "failed to ensure default workspace: {e}" ))), } } #[cfg(test)] mod tests { use super::{ BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listener, classify_initial_bytes, configured_compute_driver, extension_token_ttl, is_benign_tls_handshake_failure, mint_gateway_extension_credential, serve_gateway_listener, validate_peer_endpoint_scheme, }; use openshell_core::{ Config, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; use std::io::{Error, ErrorKind}; use std::net::SocketAddr; use std::sync::{ Arc, LazyLock, Mutex, atomic::{AtomicBool, Ordering}, }; use std::time::Duration; use tempfile::{TempDir, tempdir}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use crate::tls_test_utils::generate_test_certs_with_ca; use axum::body::Body; use http::{Request, StatusCode}; use tower::ServiceExt; fn tls_enabled_config() -> Config { Config::new(Some(openshell_core::TlsConfig { cert_path: "/tmp/cert.pem".into(), key_path: "/tmp/key.pem".into(), client_ca_path: None, require_client_auth: false, external_cert_path: None, external_key_path: None, external_server_names: Vec::new(), })) } #[test] fn plaintext_peer_endpoint_is_rejected_on_a_tls_gateway() { let error = validate_peer_endpoint_scheme(&tls_enabled_config(), "http://10.0.0.1:8080") .expect_err("plaintext peer endpoint must not be accepted alongside gateway TLS"); assert!( error.to_string().contains("plaintext"), "error should name the downgrade: {error}" ); } #[test] fn https_peer_endpoint_is_accepted_on_a_tls_gateway() { validate_peer_endpoint_scheme(&tls_enabled_config(), "https://10.0.0.1:8080").unwrap(); } #[test] fn plaintext_peer_endpoint_is_allowed_on_a_plaintext_gateway() { let config = Config::new(None); assert!(config.tls.is_none()); validate_peer_endpoint_scheme(&config, "http://10.0.0.1:8080").unwrap(); } static DETECTION_PROBE_ORDER: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); fn record_detection_probe(name: &'static str, available: bool) -> bool { DETECTION_PROBE_ORDER.lock().unwrap().push(name); available } fn unavailable_first_probe() -> bool { record_detection_probe("first", false) } fn available_second_probe() -> bool { record_detection_probe("second", true) } fn available_third_probe() -> bool { record_detection_probe("third", true) } fn extension_test_issuer() -> Arc { extension_test_issuer_with_ttl(Duration::from_mins(15)) } fn extension_test_issuer_with_ttl( ttl: Duration, ) -> Arc { let material = openshell_bootstrap::jwt::generate_jwt_key().expect("jwt key"); Arc::new( crate::auth::sandbox_jwt::ExtensionJwtIssuer::from_pem( material.signing_key_pem.as_bytes(), material.public_key_pem.as_bytes(), material.kid, "gateway-a", ttl, ) .expect("issuer"), ) } #[test] fn gateway_ttl_bounds_extension_ttl() { let issuer = extension_test_issuer_with_ttl(Duration::from_mins(15)); assert_eq!(extension_token_ttl(&issuer), Duration::from_mins(15)); } #[test] fn extension_token_ttl_is_capped_at_one_hour() { let issuer = extension_test_issuer_with_ttl(Duration::from_hours(24)); assert_eq!(extension_token_ttl(&issuer), Duration::from_hours(1)); let short = extension_test_issuer_with_ttl(Duration::from_mins(5)); assert_eq!(extension_token_ttl(&short), Duration::from_mins(5)); } #[test] fn plaintext_extension_endpoint_is_rejected_unless_explicitly_opted_out() { let issuer = extension_test_issuer(); // Default posture: a plaintext endpoint cannot carry a bearer // credential, so startup fails and names the opt-out. let error = mint_gateway_extension_credential( &issuer, ExtensionKind::Middleware, "content-guard", "urn:openshell:extension:middleware:content-guard", "http://host.openshell.internal:50051", false, ) .expect_err("plaintext endpoint must not silently downgrade"); assert!(error.to_string().contains("allow_insecure_transport")); // Explicit opt-out starts the gateway with no credential attached. assert!( mint_gateway_extension_credential( &issuer, ExtensionKind::Middleware, "content-guard", "urn:openshell:extension:middleware:content-guard", "http://host.openshell.internal:50051", true, ) .expect("opt-out must be permitted") .is_none() ); } #[test] fn authenticated_extension_endpoints_mint_a_credential() { let issuer = extension_test_issuer(); let credential = mint_gateway_extension_credential( &issuer, ExtensionKind::Middleware, "content-guard", "urn:openshell:extension:middleware:content-guard", "https://content-guard.example:50051", false, ) .expect("credential") .expect("authenticated endpoint mints a credential"); assert_eq!(credential.name, "content-guard"); assert!(credential.slot.expires_at_ms().is_some_and(|ms| ms > 0)); // Unix sockets are gateway-local, so only interceptors can use them. // A middleware endpoint must also be reachable from every supervisor. let error = mint_gateway_extension_credential( &issuer, ExtensionKind::Middleware, "content-guard", "urn:openshell:extension:middleware:content-guard", "unix:///run/openshell/content-guard.sock", false, ) .expect_err("middleware cannot be reached over a gateway-local socket"); assert!(error.to_string().contains("must use https://")); assert!( mint_gateway_extension_credential( &issuer, ExtensionKind::Interceptor, "quota", "urn:openshell:extension:interceptor:quota", "unix:///run/openshell/interceptors/quota.sock", false, ) .expect("credential") .is_some() ); } fn test_driver_startup<'a>( config: &'a Config, file: Option<&'a super::config_file::ConfigFile>, ) -> crate::compute::driver_config::DriverStartupContext<'a> { crate::compute::driver_config::DriverStartupContext { file, guest_tls: None, gateway_port: openshell_core::config::DEFAULT_SERVER_PORT, gateway_tls_enabled: false, endpoint_overrides: &config.compute_driver_endpoints, } } fn test_compute_drivers() -> super::ComputeDriverRegistry { let mut registry = super::ComputeDriverRegistry::new(); for (name, priority) in [("alpha", 100), ("beta", 200), ("gamma", 300)] { registry .install( super::ComputeDriverRegistration::new( name, priority, None, TestComputeDriverFactory, ) .unwrap() .with_telemetry_category( openshell_core::telemetry::TelemetryComputeDriver::anonymous_category( "registered", ), ), ) .unwrap(); } registry } #[derive(Clone, Copy)] struct TestComputeDriverFactory; // Omitting validate_config exercises source compatibility for out-of-tree // factories written before package preflight introduced that hook. #[async_trait::async_trait] impl super::ComputeDriverFactory for TestComputeDriverFactory { async fn build( &self, _context: super::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { unreachable!("selection tests do not construct the driver") } } fn test_tls_acceptor() -> (TempDir, TlsAcceptor) { let dir = tempdir().expect("failed to create tempdir"); generate_test_certs_with_ca(dir.path()); let acceptor = TlsAcceptor::from_files( &dir.path().join("server-cert.pem"), &dir.path().join("server-key.pem"), Some(&dir.path().join("ca.pem")), false, None, None, Vec::new(), ) .expect("failed to build tls acceptor"); (dir, acceptor) } async fn test_state( bind_addr: SocketAddr, enable_loopback_service_http: bool, ) -> Arc { let store = Arc::new( crate::persistence::Store::connect("sqlite::memory:?cache=shared") .await .expect("failed to create test store"), ); let compute = crate::compute::new_test_runtime(store.clone()).await; Arc::new(ServerState::new( Config::new(None) .with_database_url("sqlite::memory:?cache=shared") .with_bind_address(bind_addr) .with_server_sans(["*.dev.openshell.localhost"]) .with_loopback_service_http(enable_loopback_service_http) .with_credential_drivers(["test-static"]), store, compute, crate::sandbox_index::SandboxIndex::new(), crate::sandbox_watch::SandboxWatchBus::new(), crate::tracing_bus::TracingLogBus::new(), Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), None, )) } #[tokio::test] async fn websocket_tunnel_is_mounted_only_when_enabled() { let state = test_state("127.0.0.1:17670".parse().unwrap(), true).await; let response = super::http_router(state.clone()) .oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let mut enabled = state; Arc::get_mut(&mut enabled) .unwrap() .config .enable_websocket_tunnel = true; let response = super::http_router(enabled) .oneshot(Request::get("/_ws_tunnel").body(Body::empty()).unwrap()) .await .unwrap(); assert_ne!(response.status(), StatusCode::NOT_FOUND); } async fn start_tls_gateway_listener( bind_addr: &str, enable_loopback_service_http: bool, ) -> ( SocketAddr, watch::Sender, tokio::task::JoinHandle<()>, TempDir, ) { let listener = TcpListener::bind(bind_addr) .await .expect("failed to bind test listener"); let listen_addr = listener.local_addr().expect("failed to read local addr"); let state = test_state(listen_addr, enable_loopback_service_http).await; let service = MultiplexService::new(state); let (tls_dir, tls_acceptor) = test_tls_acceptor(); let (shutdown_tx, shutdown_rx) = watch::channel(false); let handle = tokio::spawn(serve_gateway_listener( BoundGatewayListener { listener, address: listen_addr, }, service, Some(tls_acceptor), enable_loopback_service_http, shutdown_rx, )); (listen_addr, shutdown_tx, handle, tls_dir) } async fn send_plain_http(addr: SocketAddr, request: String) -> String { let connect_addr: SocketAddr = format!("127.0.0.1:{}", addr.port()) .parse() .expect("failed to build loopback connect addr"); let mut stream = TcpStream::connect(connect_addr) .await .expect("failed to connect to test listener"); stream .write_all(request.as_bytes()) .await .expect("failed to write request"); let mut response = Vec::new(); let read_result = tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut response)) .await .expect("timed out reading response"); if let Err(err) = read_result && err.kind() != ErrorKind::ConnectionReset { panic!("failed to read response: {err}"); } String::from_utf8_lossy(&response).into_owned() } fn service_request(addr: SocketAddr, extra_headers: &[(&str, &str)]) -> String { let mut request = format!( "GET / HTTP/1.1\r\nHost: default--my-sandbox--web.dev.openshell.localhost:{}\r\nConnection: close\r\n", addr.port() ); for (name, value) in extra_headers { request.push_str(name); request.push_str(": "); request.push_str(value); request.push_str("\r\n"); } request.push_str("\r\n"); request } async fn stop_listener(shutdown: watch::Sender, handle: tokio::task::JoinHandle<()>) { let _ = shutdown.send(true); let _ = tokio::time::timeout(Duration::from_secs(2), handle).await; } #[test] fn classifies_probe_style_tls_disconnects_as_benign() { for kind in [ErrorKind::UnexpectedEof, ErrorKind::ConnectionReset] { let error = Error::new(kind, "probe disconnected"); assert!(is_benign_tls_handshake_failure(&error)); } } #[test] fn preserves_real_tls_failures_as_errors() { for kind in [ ErrorKind::InvalidData, ErrorKind::PermissionDenied, ErrorKind::Other, ] { let error = Error::new(kind, "real tls failure"); assert!(!is_benign_tls_handshake_failure(&error)); } } #[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)); } #[tokio::test] async fn plaintext_service_http_listener_rejects_non_loopback_bind() { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("0.0.0.0:0", true).await; let response = send_plain_http(addr, service_request(addr, &[])).await; assert!( response.is_empty(), "non-loopback gateway listener should drop plaintext service HTTP, got: {response:?}" ); stop_listener(shutdown, handle).await; } #[tokio::test] async fn plaintext_service_http_rejects_cross_origin_browser_contexts() { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let cases = [ ( "cross-site fetch metadata", vec![("Sec-Fetch-Site", "cross-site")], ), ( "same-site sibling fetch metadata", vec![("Sec-Fetch-Site", "same-site")], ), ( "mismatched origin", vec![( "Origin", "http://other-sandbox--web.dev.openshell.localhost:8080", )], ), ( "mismatched referer", vec![( "Referer", "http://other-sandbox--web.dev.openshell.localhost:8080/page", )], ), ]; for (name, headers) in cases { let response = send_plain_http(addr, service_request(addr, &headers)).await; assert!( response.starts_with("HTTP/1.1 403 Forbidden"), "{name} should be rejected before service lookup, got: {response:?}" ); assert!( response.contains("Cross-origin service request rejected"), "{name} should explain the service rejection, got: {response:?}" ); } stop_listener(shutdown, handle).await; } #[tokio::test] async fn plaintext_service_http_allows_same_origin_browser_context_to_reach_service_lookup() { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let origin = format!( "http://default--my-sandbox--web.dev.openshell.localhost:{}", addr.port() ); let response = send_plain_http( addr, service_request( addr, &[("Sec-Fetch-Site", "same-origin"), ("Origin", &origin)], ), ) .await; assert!( response.starts_with("HTTP/1.1 404 Not Found"), "same-origin browser context should pass CSRF guard and miss only because no endpoint exists, got: {response:?}" ); assert!( !response.contains("Cross-origin service request rejected"), "same-origin browser context should not be rejected as cross-origin, got: {response:?}" ); stop_listener(shutdown, handle).await; } #[tokio::test] async fn plaintext_service_http_does_not_expose_grpc_gateway() { let (addr, shutdown, handle, _tls_dir) = start_tls_gateway_listener("127.0.0.1:0", true).await; let grpc_endpoint = format!("http://127.0.0.1:{}", addr.port()); let grpc_succeeded = tokio::time::timeout(Duration::from_secs(2), async { match OpenShellClient::connect(grpc_endpoint).await { Ok(mut client) => client.health(HealthRequest {}).await.is_ok(), Err(_) => false, } }) .await .expect("timed out checking plaintext gRPC exposure"); assert!( !grpc_succeeded, "plaintext service HTTP must not expose successful gateway gRPC" ); let request = format!( "POST /openshell.v1.OpenShell/Health HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/grpc\r\nTE: trailers\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", addr.port() ); let response = send_plain_http(addr, request).await; assert!( response.starts_with("HTTP/1.1 404 Not Found"), "plaintext service HTTP router should not serve gateway gRPC, got: {response:?}" ); assert!( !response.contains("grpc-status: 0"), "plaintext service HTTP must not return a successful gRPC response: {response:?}" ); stop_listener(shutdown, handle).await; } #[test] fn configured_compute_driver_triggers_auto_detection_when_empty() { fn available() -> bool { true } let mut registry = super::ComputeDriverRegistry::new(); registry .install( super::ComputeDriverRegistration::new( "detected", 100, Some(available), TestComputeDriverFactory, ) .unwrap(), ) .unwrap(); let config = Config::new(None); let result = configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) .unwrap(); let ConfiguredComputeDriver::Registered(registration) = result else { panic!("auto-detection must select a registered driver"); }; assert_eq!(registration.name, "detected"); } #[test] fn registry_detection_reports_available_drivers_in_priority_order() { DETECTION_PROBE_ORDER.lock().unwrap().clear(); let mut registry = super::ComputeDriverRegistry::new(); registry .install( super::ComputeDriverRegistration::new( "third", 300, Some(available_third_probe), TestComputeDriverFactory, ) .unwrap(), ) .unwrap(); registry .install( super::ComputeDriverRegistration::new( "first", 100, Some(unavailable_first_probe), TestComputeDriverFactory, ) .unwrap(), ) .unwrap(); registry .install( super::ComputeDriverRegistration::new( "second", 200, Some(available_second_probe), TestComputeDriverFactory, ) .unwrap(), ) .unwrap(); let detection = registry.detect(); assert_eq!(detection.selected(), Some("second")); assert_eq!( detection .available .iter() .map(String::as_str) .collect::>(), vec!["second", "third"] ); assert_eq!( DETECTION_PROBE_ORDER.lock().unwrap().as_slice(), ["first", "second", "third"] ); assert_eq!( registry.installed_driver_names().collect::>(), vec!["first", "second", "third"] ); } #[test] fn configured_compute_driver_accepts_registered_name() { let config = Config::new(None).with_compute_driver("beta"); let registry = test_compute_drivers(); let driver = configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) .unwrap(); assert_eq!( driver.telemetry_compute_driver(®istry).as_str(), "registered" ); assert!(matches!( driver, ConfiguredComputeDriver::Registered(registration) if registration.name == "beta" )); } #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_driver("kyma"); let registry = test_compute_drivers(); let driver = configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) .unwrap(); assert_eq!( driver.telemetry_compute_driver(®istry).as_str(), "custom" ); match driver { ConfiguredComputeDriver::Remote { name } => { assert_eq!(name, "kyma"); } ConfiguredComputeDriver::Registered(other) => { panic!( "expected remote driver, got registered driver {}", other.name ) } } } #[test] fn configured_compute_driver_uses_endpoint_override() { let config = Config::new(None) .with_compute_driver("alpha") .with_compute_driver_endpoint("alpha", "/run/openshell/alpha.sock"); let registry = test_compute_drivers(); let driver = configured_compute_driver(®istry, &config, test_driver_startup(&config, None)) .unwrap(); assert_eq!( driver.telemetry_compute_driver(®istry).as_str(), "registered" ); assert!(matches!( driver, ConfiguredComputeDriver::Remote { name } if name == "alpha" )); } #[test] fn configured_compute_driver_uses_builtin_endpoint_override() { let config = Config::new(None) .with_compute_driver("beta") .with_compute_driver_endpoint("beta", "/run/openshell/beta.sock"); let driver = configured_compute_driver( &test_compute_drivers(), &config, test_driver_startup(&config, None), ) .unwrap(); assert!(matches!( driver, ConfiguredComputeDriver::Remote { name } if name == "beta" )); } #[tokio::test] async fn failed_gateway_listener_bind_does_not_attempt_persisted_sandbox_start() { let occupied_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let occupied_address = occupied_listener.local_addr().unwrap(); let start_attempted = AtomicBool::new(false); let result: openshell_core::Result<()> = async { let _listener = bind_gateway_listener(occupied_address).await?; start_attempted.store(true, Ordering::SeqCst); Ok(()) } .await; assert!( result.is_err(), "binding the occupied gateway address should fail" ); assert!( !start_attempted.load(Ordering::SeqCst), "persisted sandbox start must not run before the gateway listener is bound" ); } }