mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-07 08:55:37 +08:00
* feat(workspace): implement workspace model (Phase 1 of RFC 0011) Implements workspace and membership model providing hard isolation boundaries for multi-player OpenShell deployments. Workspace CRUD with Kubernetes-style Terminating phase for graceful deletion. All resources scoped by workspace via ObjectMeta. Membership RPCs for workspace access control. Persistence migration shifts name uniqueness to (object_type, workspace, name). Provider profiles support platform and workspace scoping. Service routing uses workspace-prefixed DNS labels. Inference routes renamed and workspace-scoped with DeleteInferenceRoute RPC. Python SDK with WorkspaceClient, two-method list pattern (workspace-scoped and for_all_workspaces), and workspace parameter on all methods. CLI workspace flags, TUI workspace cycling. K8s driver filters unmanaged CRs and uses delete preconditions. Podman driver uses immutable container IDs. Label serialization fixed across all put_if call sites. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(cli): delegate sandbox upload command to existing upload function The standalone `sandbox upload` command reimplemented upload logic inline with two bugs: it used `Path::exists()` which follows symlinks (rejecting dangling symlinks), and it ran git-aware filtering on symlink sources. The `run::sandbox_upload()` function already handles both cases correctly via `sandbox_upload_plan()`. Replace the inline logic with a call to the existing function. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): shorten sandbox names and fix test compatibility Shorten the sandbox name in initial_sparse_policy_is_acknowledged_as_loaded from 'e2e-2159-sparse-enrich' (22 chars) to 'e2e-sparse-enrich' (17 chars) to comply with MAX_ROUTABLE_NAME_LEN (19 chars). Also capture stderr in create_keep_with_args so future sandbox creation failures include the actual CLI error instead of reporting empty output. Signed-off-by: Derek Carr <decarr@redhat.com> * test(workspace): add test coverage for workspace CRUD and persistence isolation Add unit tests for workspace create happy path, get round-trip, get not-found, get empty-name rejection, already-exists error, and resolve_workspace not-found. Add persistence test proving cross-workspace name uniqueness (same name in different workspaces produces separate records). Add workspace name max-length boundary tests. Fix e2e harness to include stderr in name-parse-failure error path. Align Python e2e test_workspace_crud with try/finally pattern. Document provider profile catalog workspace scoping gap in RFC 0011. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(examples): update examples for workspace model compatibility Shorten sandbox names in demo scripts to fit the 19-character MAX_ROUTABLE_NAME_LEN limit: policy-demo prefix to pd-, multi-agent notepad derives a short SANDBOX_TAG from the run ID, governance interceptor uses gs-PID-RANDOM. Update vscode-remote-sandbox.md SSH host aliases from openshell-{name} to openshell-{name}.{workspace} format. Signed-off-by: Derek Carr <decarr@redhat.com> * feat(sdk): add workspace-scoped client and workspace CRUD Add WorkspaceScopedClient modeled after kube::Api::namespaced — captures workspace once and injects it into every sandbox request. Add workspace CRUD methods (create, get, list, delete) and list_sandboxes_all_workspaces on OpenShellClient. Extend SandboxRef with workspace field and add WorkspaceRef type. Include mock tests for all new operations. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(lint): resolve clippy warnings in workspace test assertions Signed-off-by: Derek Carr <decarr@redhat.com> * fix(docs): convert indented code blocks to fenced in RFC 0011 Signed-off-by: Derek Carr <decarr@redhat.com> * fix(lint): resolve clippy warnings and apply cargo fmt across workspace Auto-format with cargo fmt and fix clippy warnings exposed by the reformat: unnecessary qualifications, map_unwrap_or, identical match arms, unused variable prefix, dead code annotations, and let-unit-value in e2e harness. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): address workspace scoping issues from review - Add workspace field to settings JSON output (CLI) - Skip Podman containers missing workspace label instead of defaulting to empty string, matching K8s driver behavior - Add resource_version to list_by_scope SELECT in both SQLite and Postgres backends, with regression test - Gate PolicyLocalContext proposal/lookup routes on workspace readiness, returning 503 when workspace is not yet discovered - Block sandbox and provider creation in TUI all-workspaces mode - Clear workspace vectors in TUI reset_sandbox_state Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): make provider profile catalog workspace-aware Thread workspace through snapshot_catalog so the EffectiveProviderProfileCatalog enforces workspace boundaries on both read and write paths. UserProviderProfileSource now loads platform-scoped profiles (workspace "") plus the target workspace's profiles, preventing cross-workspace duplicate profile ID collisions that previously caused global catalog failures. Update RFC 0011 to reflect catalog scoping is implemented in Phase 1 rather than deferred to future work. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(persistence): include workspace column in atomic policy revision INSERT put_policy_revision_atomic omitted the workspace column from the INSERT into the objects table in both SQLite and Postgres backends, causing atomically-written policy revisions to lose their workspace association. Add workspace field to AtomicPolicyRevisionWrite and thread it through both backend INSERT statements, matching the non-atomic put_policy_revision path which already included it. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(proxy): skip ancestor walk when socket owner is the entrypoint collect_ancestor_identities walked the entire process tree above the entrypoint when the connecting process was the entrypoint itself, SHA256-hashing every ancestor binary (IDE, shell, container runtime). On dev machines with large binaries in the ancestor chain this exceeded the 30-second test timeout. When start_pid == stop_pid there are no intermediate ancestors to verify, so return an empty list immediately. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): make provider profile catalog scope-aware Allow the same profile ID at platform and workspace scopes by introducing layered catalog entries where workspace profiles shadow platform profiles. Add source and scope fields to the ProviderProfile proto and CLI output. Migrate List/Get handlers to the catalog, fixing divergence with runtime profile resolution. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): align podman e2e labels with centralized driver constants The podman driver moved its container labels to the centralized openshell.ai/ prefix, but the e2e test harness and cleanup script still referenced the old openshell.sandbox-* keys, causing the local_driver_token_restart test to fail on container lookup. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(e2e): align python profile isolation test with scope-aware catalog Platform profiles are now visible in workspace listings as fallbacks per the layered catalog design. Update the assertion to match. Signed-off-by: Derek Carr <decarr@redhat.com> * fix(workspace): honor profile_workspace in runtime profile resolution Runtime profile lookups now consult provider.profile_workspace via get_type_profile_for_scope. Providers created with --global-profile (profile_workspace="") resolve to the platform profile even when a workspace profile shadows the same ID. All 6 runtime call sites updated; type-only call sites remain scope-agnostic. Signed-off-by: Derek Carr <decarr@redhat.com> --------- Signed-off-by: Derek Carr <decarr@redhat.com>
1517 lines
54 KiB
Rust
1517 lines
54 KiB
Rust
// 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
|
|
//!
|
|
//! TODO(driver-abstraction): `build_compute_runtime` still switches on
|
|
//! built-in driver names and calls driver-specific constructors
|
|
//! ([`ComputeRuntime::new_kubernetes`], [`ComputeRuntime::new_docker`],
|
|
//! [`compute::vm::spawn`] + [`ComputeRuntime::new_remote_driver`],
|
|
//! [`ComputeRuntime::new_podman`]). Endpoint-backed drivers now share the
|
|
//! remote `compute_driver.proto` path, so new remote drivers should enter
|
|
//! through named endpoint acquisition rather than gateway-wide socket side
|
|
//! channels. Once we have a generalized compute-driver registry, the remaining
|
|
//! per-arm wiring here should collapse to driver construction records that
|
|
//! produce either an in-process `SharedComputeDriver` or an acquired remote
|
|
//! endpoint, then hand the rest of the gateway a uniform [`ComputeRuntime`].
|
|
//! The VM launch plumbing now lives in [`compute::vm`]; keep this file limited
|
|
//! to selecting and acquiring drivers.
|
|
|
|
mod auth;
|
|
pub mod certgen;
|
|
pub mod cli;
|
|
mod compute;
|
|
pub mod config_file;
|
|
mod defaults;
|
|
mod grpc;
|
|
mod http;
|
|
mod inference;
|
|
mod middleware;
|
|
mod multiplex;
|
|
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;
|
|
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 ws_tunnel;
|
|
|
|
use metrics_exporter_prometheus::PrometheusBuilder;
|
|
use openshell_core::{ComputeDriverKind, Config, Error, ObjectLabels, Result};
|
|
use openshell_supervisor_middleware::MiddlewareRegistry;
|
|
use std::collections::HashMap;
|
|
use std::io::ErrorKind;
|
|
use std::net::SocketAddr;
|
|
#[cfg(test)]
|
|
use std::sync::LazyLock;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::sync::watch;
|
|
use tracing::{debug, error, info, warn};
|
|
|
|
#[cfg(test)]
|
|
pub(crate) static TEST_ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
|
|
|
use compute::ComputeRuntime;
|
|
pub use grpc::OpenShellService;
|
|
pub use http::{health_router, http_router, metrics_router, service_http_router};
|
|
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<config_file::ConfigFile>,
|
|
pub guest_tls: Option<compute::driver_config::GuestTlsPaths>,
|
|
}
|
|
|
|
/// Server state shared across handlers.
|
|
#[derive(Debug)]
|
|
pub struct ServerState {
|
|
/// Server configuration.
|
|
pub config: Config,
|
|
|
|
/// Persistence store.
|
|
pub store: Arc<Store>,
|
|
|
|
/// Compute orchestration over the configured driver.
|
|
pub compute: ComputeRuntime,
|
|
|
|
/// 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<HashMap<String, u32>>,
|
|
|
|
/// Active SSH tunnel connection counts per sandbox id.
|
|
pub ssh_connections_by_sandbox: Mutex<HashMap<String, u32>>,
|
|
|
|
/// 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 compute drivers (e.g. the Docker driver)
|
|
/// can be constructed before `ServerState` and still
|
|
/// query session state to surface supervisor readiness.
|
|
pub supervisor_sessions: Arc<supervisor_session::SupervisorSessionRegistry>,
|
|
|
|
/// Validated built-in and operator-registered supervisor middleware.
|
|
pub middleware_registry: Arc<MiddlewareRegistry>,
|
|
|
|
/// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured.
|
|
pub oidc_cache: Option<Arc<auth::oidc::JwksCache>>,
|
|
|
|
/// Gateway-minted sandbox JWT issuer. `None` when `config.gateway_jwt`
|
|
/// is not configured; in that mode `IssueSandboxToken` returns
|
|
/// `Status::unavailable`. Populated at startup from the on-disk key
|
|
/// material that `certgen` writes.
|
|
pub sandbox_jwt_issuer: Option<Arc<auth::sandbox_jwt::SandboxJwtIssuer>>,
|
|
|
|
/// Authenticator that validates gateway-minted sandbox JWTs on every
|
|
/// inbound request. Always set when `sandbox_jwt_issuer` is, so callers
|
|
/// presenting a freshly minted token are recognized.
|
|
pub sandbox_jwt_authenticator: Option<Arc<auth::sandbox_jwt::SandboxJwtAuthenticator>>,
|
|
|
|
/// Optional K8s `ServiceAccount` authenticator that backs the
|
|
/// `IssueSandboxToken` bootstrap path. Only present when the gateway
|
|
/// runs in-cluster.
|
|
pub k8s_sa_authenticator: Option<Arc<auth::k8s_sa::K8sServiceAccountAuthenticator>>,
|
|
|
|
/// Gateway-wide gRPC request rate limiter shared by every multiplex path.
|
|
pub(crate) grpc_rate_limiter: Option<multiplex::GrpcRateLimiter>,
|
|
|
|
/// Immutable gateway interceptor execution plan. `None` when disabled.
|
|
pub(crate) gateway_interceptors:
|
|
Option<openshell_gateway_interceptors::GatewayInterceptorRuntime>,
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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 {
|
|
let msg = error.to_string();
|
|
msg.contains("connection closed")
|
|
|| msg.contains("connection reset")
|
|
|| msg.contains("connection error")
|
|
|| msg.contains("error reading a body from connection")
|
|
|| msg.contains("broken pipe")
|
|
}
|
|
|
|
impl ServerState {
|
|
/// Create new server state.
|
|
#[must_use]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
config: Config,
|
|
store: Arc<Store>,
|
|
compute: ComputeRuntime,
|
|
sandbox_index: SandboxIndex,
|
|
sandbox_watch_bus: SandboxWatchBus,
|
|
tracing_log_bus: TracingLogBus,
|
|
supervisor_sessions: Arc<supervisor_session::SupervisorSessionRegistry>,
|
|
oidc_cache: Option<Arc<auth::oidc::JwksCache>>,
|
|
) -> Self {
|
|
let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config);
|
|
Self {
|
|
config,
|
|
store,
|
|
compute,
|
|
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,
|
|
middleware_registry: Arc::new(MiddlewareRegistry::default()),
|
|
oidc_cache,
|
|
sandbox_jwt_issuer: None,
|
|
sandbox_jwt_authenticator: None,
|
|
k8s_sa_authenticator: None,
|
|
grpc_rate_limiter,
|
|
gateway_interceptors: None,
|
|
provider_profile_sources:
|
|
provider_profile_sources::ProviderProfileSources::with_default_sources(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
) -> Result<()> {
|
|
let ServerStartupConfig {
|
|
config,
|
|
config_file,
|
|
guest_tls,
|
|
} = startup;
|
|
|
|
let database_url = config.database_url.trim();
|
|
if database_url.is_empty() {
|
|
return Err(Error::config("database_url is required"));
|
|
}
|
|
|
|
let middleware_registrations = config_file
|
|
.as_ref()
|
|
.map(|file| {
|
|
file.openshell
|
|
.supervisor
|
|
.middleware
|
|
.iter()
|
|
.map(Into::into)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let middleware_registry = Arc::new(
|
|
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 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(
|
|
&config,
|
|
driver_startup,
|
|
store.clone(),
|
|
sandbox_index.clone(),
|
|
sandbox_watch_bus.clone(),
|
|
tracing_log_bus.clone(),
|
|
supervisor_sessions.clone(),
|
|
)
|
|
.await?;
|
|
let gateway_interceptors =
|
|
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(
|
|
config.clone(),
|
|
store.clone(),
|
|
compute,
|
|
sandbox_index,
|
|
sandbox_watch_bus,
|
|
tracing_log_bus,
|
|
supervisor_sessions,
|
|
oidc_cache,
|
|
);
|
|
state.middleware_registry = middleware_registry;
|
|
state.gateway_interceptors = gateway_interceptors;
|
|
state.provider_profile_sources = provider_profile_sources;
|
|
|
|
// Load the gateway-minted sandbox JWT signing key when configured.
|
|
// Optional so single-driver dev deployments without certgen continue
|
|
// to start. The helm-deployed gateway and the RPM init script populate
|
|
// `gateway_jwt` once `certgen` has produced the on-disk material.
|
|
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 = auth::sandbox_jwt::SandboxJwtIssuer::from_pem(
|
|
&signing_pem,
|
|
kid.clone(),
|
|
&jwt.gateway_id,
|
|
Duration::from_secs(jwt.ttl_secs),
|
|
)
|
|
.map_err(Error::config)?;
|
|
let authenticator =
|
|
auth::sandbox_jwt::SandboxJwtAuthenticator::from_pem(&public_pem, kid, &jwt.gateway_id)
|
|
.map_err(Error::config)?;
|
|
info!(
|
|
gateway_id = %jwt.gateway_id,
|
|
ttl_secs = jwt.ttl_secs,
|
|
"gateway-minted sandbox JWT enabled"
|
|
);
|
|
state.sandbox_jwt_issuer = Some(Arc::new(issuer));
|
|
state.sandbox_jwt_authenticator = Some(Arc::new(authenticator));
|
|
}
|
|
|
|
// K8s ServiceAccount bootstrap authenticator. Only constructed when
|
|
// the gateway is running in-cluster (kubelet provides the API host
|
|
// env var) and has a sandbox JWT issuer to mint replacements against;
|
|
// outside the cluster we can't call the apiserver's TokenReview API,
|
|
// and without the issuer there's nothing to exchange the SA token for.
|
|
if state.sandbox_jwt_issuer.is_some() && std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() {
|
|
// Pod lookups and TokenReview identity checks must match the sandbox
|
|
// namespace and service account used by the Kubernetes driver.
|
|
let kubernetes_config =
|
|
compute::driver_config::kubernetes_config_for_k8s_sa_bootstrap(config_file.as_ref())?;
|
|
let sandbox_namespace = kubernetes_config.namespace;
|
|
let sandbox_service_account = kubernetes_config.service_account_name;
|
|
match kube::Client::try_default().await {
|
|
Ok(client) => {
|
|
let resolver = Arc::new(auth::k8s_sa::LiveK8sResolver::new(
|
|
client,
|
|
&sandbox_namespace,
|
|
"openshell-gateway".to_string(),
|
|
sandbox_service_account.clone(),
|
|
));
|
|
let authenticator = auth::k8s_sa::K8sServiceAccountAuthenticator::new(resolver);
|
|
state.k8s_sa_authenticator = Some(Arc::new(authenticator));
|
|
info!(
|
|
namespace = %sandbox_namespace,
|
|
service_account = %sandbox_service_account,
|
|
"K8s ServiceAccount bootstrap authenticator enabled"
|
|
);
|
|
}
|
|
Err(e) => warn!(
|
|
error = %e,
|
|
"in-cluster K8s client construction failed; \
|
|
K8s ServiceAccount bootstrap is disabled"
|
|
),
|
|
}
|
|
}
|
|
|
|
let state = Arc::new(state);
|
|
|
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
|
|
|
// Resume sandboxes that were stopped during the previous gateway
|
|
// shutdown so the running compute state matches the persisted store.
|
|
// Runs before watchers spawn so the watch loop sees the post-resume
|
|
// snapshot on its first poll.
|
|
ensure_default_workspace(&store).await?;
|
|
|
|
if let Err(err) = state.compute.resume_persisted_sandboxes().await {
|
|
warn!(error = %err, "Failed to resume persisted sandboxes during startup");
|
|
}
|
|
|
|
state.compute.spawn_watchers(shutdown_rx.clone());
|
|
ssh_sessions::spawn_session_reaper(store.clone(), Duration::from_secs(3600));
|
|
supervisor_session::spawn_relay_reaper(state.clone(), Duration::from_secs(30));
|
|
provider_refresh::spawn_refresh_worker(state.clone(), Duration::from_secs(60));
|
|
|
|
// Create the multiplexed service
|
|
let service = MultiplexService::new(state.clone());
|
|
|
|
let gateway_listener_addresses =
|
|
gateway_listener_addresses(config.bind_address, state.compute.gateway_bind_addresses());
|
|
let mut gateway_listeners = Vec::with_capacity(gateway_listener_addresses.len());
|
|
for address in gateway_listener_addresses {
|
|
let listener = TcpListener::bind(address)
|
|
.await
|
|
.map_err(|e| Error::transport(format!("failed to bind to {address}: {e}")))?;
|
|
let local_addr = listener.local_addr().unwrap_or(address);
|
|
info!(address = %local_addr, "Server listening");
|
|
gateway_listeners.push((listener, local_addr));
|
|
}
|
|
|
|
// 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,
|
|
)?;
|
|
|
|
// 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 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(),
|
|
)));
|
|
}
|
|
|
|
shutdown_signal().await;
|
|
info!("Shutdown signal received; stopping gateway");
|
|
let _ = shutdown_tx.send(true);
|
|
|
|
for task in listener_tasks {
|
|
if let Err(err) = task.await {
|
|
warn!(error = %err, "Gateway listener task failed during shutdown");
|
|
}
|
|
}
|
|
|
|
state
|
|
.compute
|
|
.cleanup_on_shutdown()
|
|
.await
|
|
.map_err(|err| Error::execution(format!("gateway shutdown cleanup failed: {err}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn gateway_listener_addresses(
|
|
bind_address: SocketAddr,
|
|
extra_addresses: &[SocketAddr],
|
|
) -> Vec<SocketAddr> {
|
|
let mut addresses = vec![bind_address];
|
|
for address in extra_addresses {
|
|
if !addresses
|
|
.iter()
|
|
.any(|existing| listener_covers(*existing, *address))
|
|
{
|
|
addresses.push(*address);
|
|
}
|
|
}
|
|
addresses
|
|
}
|
|
|
|
fn listener_covers(existing: SocketAddr, requested: SocketAddr) -> bool {
|
|
if existing == requested {
|
|
return true;
|
|
}
|
|
if existing.port() != requested.port() {
|
|
return false;
|
|
}
|
|
|
|
match (existing.ip(), requested.ip()) {
|
|
(std::net::IpAddr::V4(existing), std::net::IpAddr::V4(_)) => existing.is_unspecified(),
|
|
(std::net::IpAddr::V6(existing), std::net::IpAddr::V6(_)) => existing.is_unspecified(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
async fn serve_gateway_listener(
|
|
listener: TcpListener,
|
|
listen_addr: SocketAddr,
|
|
service: MultiplexService,
|
|
tls_acceptor: Option<TlsAcceptor>,
|
|
enable_loopback_service_http: bool,
|
|
mut shutdown: watch::Receiver<bool>,
|
|
) {
|
|
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;
|
|
}
|
|
};
|
|
|
|
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<ConnectionProtocol> {
|
|
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<TlsAcceptor>,
|
|
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 non-loopback 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;
|
|
}
|
|
|
|
// Internal wiring helper: each argument is a distinct piece of runtime state
|
|
// that must be passed through, so the count is justified.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn build_compute_runtime(
|
|
config: &Config,
|
|
driver_startup: compute::driver_config::DriverStartupContext<'_>,
|
|
store: Arc<Store>,
|
|
sandbox_index: SandboxIndex,
|
|
sandbox_watch_bus: SandboxWatchBus,
|
|
tracing_log_bus: TracingLogBus,
|
|
supervisor_sessions: Arc<supervisor_session::SupervisorSessionRegistry>,
|
|
) -> Result<ComputeRuntime> {
|
|
let driver = configured_compute_driver(config, driver_startup)?;
|
|
info!(driver = %driver.name(), "Using compute driver");
|
|
|
|
let runtime = match driver {
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Kubernetes) => {
|
|
warn_if_kubernetes_sandbox_jwt_expiry_disabled(config);
|
|
let k8s_config =
|
|
compute::driver_config::kubernetes_config_from_context(driver_startup)?;
|
|
ComputeRuntime::new_kubernetes(
|
|
k8s_config,
|
|
store,
|
|
sandbox_index,
|
|
sandbox_watch_bus,
|
|
tracing_log_bus,
|
|
supervisor_sessions.clone(),
|
|
)
|
|
.await
|
|
}
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Docker) => {
|
|
let docker_config = compute::driver_config::docker_config_from_context(driver_startup)?;
|
|
ComputeRuntime::new_docker(
|
|
config.clone(),
|
|
docker_config,
|
|
store,
|
|
sandbox_index,
|
|
sandbox_watch_bus,
|
|
tracing_log_bus,
|
|
supervisor_sessions,
|
|
)
|
|
.await
|
|
}
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Podman) => {
|
|
let podman_config = compute::driver_config::podman_config_from_context(driver_startup)?;
|
|
ComputeRuntime::new_podman(
|
|
podman_config,
|
|
store,
|
|
sandbox_index,
|
|
sandbox_watch_bus,
|
|
tracing_log_bus,
|
|
supervisor_sessions,
|
|
)
|
|
.await
|
|
}
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Vm) => {
|
|
let vm_config = compute::driver_config::vm_config_from_context(driver_startup)?;
|
|
let endpoint = compute::vm::spawn(config, &vm_config).await?;
|
|
ComputeRuntime::new_remote_driver(
|
|
endpoint,
|
|
store,
|
|
sandbox_index,
|
|
sandbox_watch_bus,
|
|
tracing_log_bus,
|
|
supervisor_sessions,
|
|
)
|
|
.await
|
|
}
|
|
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
|
|
}
|
|
};
|
|
|
|
runtime.map_err(|e| Error::execution(format!("failed to create compute runtime: {e}")))
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum ConfiguredComputeDriver {
|
|
Builtin(ComputeDriverKind),
|
|
Remote { name: String },
|
|
}
|
|
|
|
impl ConfiguredComputeDriver {
|
|
fn name(&self) -> &str {
|
|
match self {
|
|
Self::Builtin(kind) => kind.as_str(),
|
|
Self::Remote { name } => name,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn configured_compute_driver(
|
|
config: &Config,
|
|
driver_startup: compute::driver_config::DriverStartupContext<'_>,
|
|
) -> Result<ConfiguredComputeDriver> {
|
|
match config.compute_drivers.as_slice() {
|
|
[] => match openshell_core::config::detect_driver() {
|
|
Some(ComputeDriverKind::Vm) => Err(Error::config(
|
|
"vm compute driver is opt-in only; set --drivers vm or OPENSHELL_DRIVERS=vm",
|
|
)),
|
|
Some(driver) => Ok(ConfiguredComputeDriver::Builtin(driver)),
|
|
None => Err(Error::config(
|
|
"no compute driver configured and auto-detection found no suitable driver; \
|
|
set --drivers or OPENSHELL_DRIVERS to kubernetes, podman, docker, or vm",
|
|
)),
|
|
},
|
|
[driver] => resolve_configured_compute_driver(driver, driver_startup),
|
|
drivers => Err(Error::config(format!(
|
|
"multiple compute drivers are not supported yet; configured drivers: {}",
|
|
drivers.join(",")
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn resolve_configured_compute_driver(
|
|
driver_name: &str,
|
|
driver_startup: compute::driver_config::DriverStartupContext<'_>,
|
|
) -> Result<ConfiguredComputeDriver> {
|
|
let name = openshell_core::config::normalize_compute_driver_name(driver_name)
|
|
.map_err(Error::config)?;
|
|
let driver_kind = builtin_compute_driver(&name);
|
|
if driver_kind.is_some() && driver_startup.endpoint_overrides.contains_key(&name) {
|
|
return Err(Error::config(format!(
|
|
"compute driver '{name}' is a reserved built-in driver and cannot be selected with a socket endpoint"
|
|
)));
|
|
}
|
|
|
|
if let Some(kind) = driver_kind {
|
|
return Ok(ConfiguredComputeDriver::Builtin(kind));
|
|
}
|
|
|
|
Ok(ConfiguredComputeDriver::Remote { name })
|
|
}
|
|
|
|
fn builtin_compute_driver(name: &str) -> Option<ComputeDriverKind> {
|
|
name.parse().ok()
|
|
}
|
|
|
|
fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool {
|
|
config
|
|
.gateway_jwt
|
|
.as_ref()
|
|
.is_some_and(|jwt| jwt.ttl_secs == 0)
|
|
}
|
|
|
|
fn warn_if_kubernetes_sandbox_jwt_expiry_disabled(config: &Config) {
|
|
if kubernetes_sandbox_jwt_expiry_disabled(config) {
|
|
warn!(
|
|
"Kubernetes gateway configured with non-expiring sandbox JWTs (gateway_jwt.ttl_secs = 0); set ttl_secs > 0 for shared Kubernetes deployments"
|
|
);
|
|
}
|
|
}
|
|
|
|
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_at_ms: persistence::current_time_ms(),
|
|
labels: HashMap::new(),
|
|
annotations: HashMap::new(),
|
|
resource_version: 0,
|
|
workspace: String::new(),
|
|
deletion_timestamp_ms: 0,
|
|
}),
|
|
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::{
|
|
ConfiguredComputeDriver, ConnectionProtocol, MultiplexService, ServerState, TlsAcceptor,
|
|
allow_plaintext_service_http, classify_initial_bytes, configured_compute_driver,
|
|
gateway_listener_addresses, is_benign_tls_handshake_failure,
|
|
kubernetes_sandbox_jwt_expiry_disabled, serve_gateway_listener,
|
|
};
|
|
use openshell_core::{
|
|
ComputeDriverKind, Config,
|
|
proto::{HealthRequest, open_shell_client::OpenShellClient},
|
|
};
|
|
use std::io::{Error, ErrorKind};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
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, install_rustls_provider};
|
|
|
|
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_tls_acceptor() -> (TempDir, TlsAcceptor) {
|
|
install_rustls_provider();
|
|
|
|
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,
|
|
)
|
|
.expect("failed to build tls acceptor");
|
|
|
|
(dir, acceptor)
|
|
}
|
|
|
|
async fn test_state(
|
|
bind_addr: SocketAddr,
|
|
enable_loopback_service_http: bool,
|
|
) -> Arc<ServerState> {
|
|
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),
|
|
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,
|
|
))
|
|
}
|
|
|
|
async fn start_tls_gateway_listener(
|
|
bind_addr: &str,
|
|
enable_loopback_service_http: bool,
|
|
) -> (
|
|
SocketAddr,
|
|
watch::Sender<bool>,
|
|
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(
|
|
listener,
|
|
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<bool>, 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() {
|
|
let config = Config::new(None).with_compute_drivers(std::iter::empty::<String>());
|
|
// Empty drivers triggers auto-detection, which may return Some or None
|
|
// depending on the environment. This test verifies the auto-detection path
|
|
// is taken rather than immediately returning an error.
|
|
let result = configured_compute_driver(&config, test_driver_startup(&config, None));
|
|
// Either we get a detected driver or an error about none being detected.
|
|
match result {
|
|
Ok(ConfiguredComputeDriver::Builtin(driver)) => {
|
|
assert!(
|
|
matches!(
|
|
driver,
|
|
ComputeDriverKind::Kubernetes
|
|
| ComputeDriverKind::Docker
|
|
| ComputeDriverKind::Podman
|
|
),
|
|
"auto-detected unexpected driver: {driver:?}"
|
|
);
|
|
}
|
|
Ok(ConfiguredComputeDriver::Remote { name }) => {
|
|
panic!("auto-detection returned remote driver: {name}");
|
|
}
|
|
Err(e) => {
|
|
assert!(
|
|
e.to_string()
|
|
.contains("auto-detection found no suitable driver"),
|
|
"unexpected error: {e}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_rejects_multiple_entries() {
|
|
let config = Config::new(None)
|
|
.with_compute_drivers([ComputeDriverKind::Kubernetes, ComputeDriverKind::Podman]);
|
|
let err =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err();
|
|
assert!(
|
|
err.to_string()
|
|
.contains("multiple compute drivers are not supported yet")
|
|
);
|
|
assert!(err.to_string().contains("kubernetes,podman"));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_accepts_podman() {
|
|
let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]);
|
|
let driver =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap();
|
|
assert!(matches!(
|
|
driver,
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Podman)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_accepts_vm() {
|
|
let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Vm]);
|
|
let driver =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap();
|
|
assert!(matches!(
|
|
driver,
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Vm)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_accepts_docker() {
|
|
let config = Config::new(None).with_compute_drivers([ComputeDriverKind::Docker]);
|
|
let driver =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap();
|
|
assert!(matches!(
|
|
driver,
|
|
ConfiguredComputeDriver::Builtin(ComputeDriverKind::Docker)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_resolves_named_remote() {
|
|
let config = Config::new(None).with_compute_drivers(["kyma"]);
|
|
|
|
let driver =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap();
|
|
|
|
match driver {
|
|
ConfiguredComputeDriver::Remote { name } => {
|
|
assert_eq!(name, "kyma");
|
|
}
|
|
ConfiguredComputeDriver::Builtin(other) => {
|
|
panic!("expected remote driver, got builtin driver {other:?}")
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_rejects_vm_endpoint_from_config() {
|
|
let config = Config::new(None)
|
|
.with_compute_drivers([ComputeDriverKind::Vm])
|
|
.with_compute_driver_endpoint("vm", "/run/openshell/vm.sock");
|
|
|
|
let err =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("reserved built-in driver and cannot be selected with a socket endpoint"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_compute_driver_rejects_builtin_endpoint() {
|
|
let config = Config::new(None)
|
|
.with_compute_drivers([ComputeDriverKind::Docker])
|
|
.with_compute_driver_endpoint("docker", "/run/openshell/docker.sock");
|
|
|
|
let err =
|
|
configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("cannot be selected with a socket endpoint"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kubernetes_sandbox_jwt_expiry_disabled_warns_for_zero_ttl() {
|
|
fn config_with_jwt_ttl(ttl_secs: u64) -> Config {
|
|
let mut config = Config::new(None);
|
|
config.gateway_jwt = Some(openshell_core::GatewayJwtConfig {
|
|
signing_key_path: "/tmp/signing.pem".into(),
|
|
public_key_path: "/tmp/public.pem".into(),
|
|
kid_path: "/tmp/kid".into(),
|
|
gateway_id: "openshell".to_string(),
|
|
ttl_secs,
|
|
});
|
|
config
|
|
}
|
|
|
|
assert!(kubernetes_sandbox_jwt_expiry_disabled(
|
|
&config_with_jwt_ttl(0)
|
|
));
|
|
assert!(!kubernetes_sandbox_jwt_expiry_disabled(
|
|
&config_with_jwt_ttl(3600)
|
|
));
|
|
assert!(!kubernetes_sandbox_jwt_expiry_disabled(&Config::new(None)));
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_listener_addresses_skip_driver_address_covered_by_wildcard() {
|
|
let primary: SocketAddr = "0.0.0.0:8080".parse().unwrap();
|
|
let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap();
|
|
|
|
assert_eq!(
|
|
gateway_listener_addresses(primary, &[docker, docker]),
|
|
vec![primary]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gateway_listener_addresses_include_driver_address_on_distinct_ip() {
|
|
let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
|
let docker: SocketAddr = "172.18.0.1:8080".parse().unwrap();
|
|
|
|
assert_eq!(
|
|
gateway_listener_addresses(primary, &[docker, docker]),
|
|
vec![primary, docker]
|
|
);
|
|
}
|
|
}
|