// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! TOML configuration file loader for the gateway. //! //! See `rfc/0003-gateway-configuration/README.md` for the file format. This //! module parses the file into [`ConfigFile`], rejects fields that must be //! supplied via env/CLI (database URL), and provides //! [`driver_table`] which returns a driver-owned //! `[openshell.drivers.]` table without gateway-level inheritance. //! //! The merge precedence for gateway process settings is: //! ```text //! CLI flag > OPENSHELL_* env var > TOML file > built-in default //! ``` //! Driver implementation settings are configured in the TOML driver tables. //! Per-field application of gateway file values happens in [`crate::cli`], //! which uses clap's `ArgMatches::value_source` to detect arguments that fell //! back to their default and are therefore eligible for replacement by file //! values. use std::collections::BTreeMap; use std::fs::OpenOptions; use std::io::{Cursor, Read as _}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use base64::Engine as _; use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{ GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, }; use serde::{Deserialize, Serialize}; /// Gateway configuration schema version supported by this build. pub const SCHEMA_VERSION: u32 = 2; /// Root of the gateway TOML config file. /// /// The file is rooted at `[openshell]` to reserve room for future components /// (CLI, sandbox, router) to share a single config file without key /// collisions. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigFile { #[serde(default)] pub openshell: OpenShellRoot, } /// `[openshell]` table. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OpenShellRoot { /// Gateway configuration schema version. Loaded files must set this to /// [`SCHEMA_VERSION`]. #[serde(default)] pub version: Option, #[serde(default)] pub gateway: GatewayFileSection, #[serde(default)] pub supervisor: SupervisorFileSection, /// `[openshell.drivers.]` tables — passed verbatim to each driver /// crate's `Deserialize` impl. Stored as raw [`toml::Value`] so each /// driver can evolve its schema /// independently of this crate. #[serde(default)] pub drivers: BTreeMap, /// `[openshell.credential_drivers.]` tables — passed verbatim to /// credential driver implementations after gateway-level selection. #[serde(default)] pub credential_drivers: BTreeMap, } /// `[openshell.gateway]` section. /// /// All fields are `Option` so the loader can tell whether a key was set /// in the file (`Some`) or not (`None` — value is taken from CLI/env/default). /// Driver-specific settings belong exclusively in /// `[openshell.drivers.]` tables. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayFileSection { // ── Identity ───────────────────────────────────────────────────────── /// Operator-assigned name for this gateway installation. #[serde(default)] pub name: Option, // ── Listeners ──────────────────────────────────────────────────────── #[serde(default)] pub bind_address: Option, #[serde(default)] pub health_bind_address: Option, #[serde(default)] pub metrics_bind_address: Option, // ── Logging ────────────────────────────────────────────────────────── #[serde(default)] pub log_level: Option, // ── Drivers ────────────────────────────────────────────────────────── /// Explicit compute driver selection. `None` enables auto-detection. #[serde(default, skip_serializing_if = "Option::is_none")] pub compute_driver: Option, #[serde(default)] pub credential_drivers: Option>, #[serde(default)] pub default_credential_driver: Option, #[serde(default)] pub credential_storage: Option, // ── Sandbox / SSH ──────────────────────────────────────────────────── #[serde(default)] pub ssh_session_ttl_secs: Option, #[serde(default)] pub grpc_rate_limit_requests: Option, #[serde(default)] pub grpc_rate_limit_window_seconds: Option, /// Security posture when a sandbox rejects a candidate policy generation. #[serde(default)] pub policy_validation_failure_mode: Option, // ── Service routing ────────────────────────────────────────────────── /// Subject Alternative Names configured on the gateway server certificate. /// Wildcard DNS SANs also enable sandbox service URLs under that domain. #[serde(default)] pub server_sans: Option>, /// Enable plaintext HTTP routing for loopback sandbox service URLs. #[serde(default)] pub enable_loopback_service_http: Option, /// Enable the WebSocket tunnel for an authenticated edge proxy. #[serde(default)] pub enable_websocket_tunnel: Option, // ── Sandbox client TLS ─────────────────────────────────────────────── #[serde(default)] pub guest_tls_ca: Option, // ── TLS toggle ─────────────────────────────────────────────────────── /// When `true`, the gateway listens on plaintext HTTP and ignores any /// `[openshell.gateway.tls]` table. Mirrors `--disable-tls`. #[serde(default)] pub disable_tls: Option, // ── Nested tables ──────────────────────────────────────────────────── #[serde(default)] pub tls: Option, #[serde(default)] pub oidc: Option, #[serde(default)] pub auth: Option, #[serde(default)] pub interceptors: Vec, #[serde(default)] pub provider_profile_sources: Option>, #[serde(default)] pub mtls_auth: Option, #[serde(default)] pub gateway_jwt: Option, #[serde(default)] pub otlp: Option, #[serde(default)] pub ocsf_log: Option, // ── Disallowed-in-file fields ──────────────────────────────────────── // // Captured so we can produce a friendly "set this via env/CLI instead" // error rather than a generic "unknown field" message. Validated and // rejected in [`load`]. #[serde(default)] pub database_url: Option, } /// Gateway listener TLS fields accepted in `gateway.toml`. /// /// Client-certificate handshake policy is derived from the presence of a /// client CA and OIDC configuration. It is intentionally not operator-settable /// in TOML because changing it can either weaken CA-only gateways or require a /// second credential from OIDC clients. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GatewayTlsFileConfig { pub cert_path: PathBuf, pub key_path: PathBuf, #[serde(default)] pub client_ca_path: Option, #[serde(default)] pub external_cert_path: Option, #[serde(default)] pub external_key_path: Option, #[serde(default)] pub external_server_names: Vec, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OcsfLogRotation { Never, #[default] Daily, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OcsfSchemaVersion { #[serde(rename = "1.1")] V1_1, #[serde(rename = "1.3")] V1_3, } impl OcsfSchemaVersion { pub const fn as_str(self) -> &'static str { match self { Self::V1_1 => "1.1", Self::V1_3 => "1.3", } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(try_from = "RawOcsfLogConfig")] pub struct OcsfLogConfig { pub path: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub schema_version: Option, pub rotation: OcsfLogRotation, #[serde(skip_serializing_if = "Option::is_none")] pub max_files: Option, pub queue_capacity: std::num::NonZeroUsize, pub queue_max_bytes: std::num::NonZeroUsize, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawOcsfLogConfig { path: PathBuf, schema_version: Option, #[serde(default)] rotation: OcsfLogRotation, max_files: Option, queue_capacity: Option, queue_max_bytes: Option, } impl TryFrom for OcsfLogConfig { type Error = &'static str; fn try_from(raw: RawOcsfLogConfig) -> Result { if raw.path.as_os_str().is_empty() { return Err("ocsf_log.path must not be empty"); } if raw.rotation == OcsfLogRotation::Never && raw.max_files.is_some() { return Err("ocsf_log.max_files requires daily rotation"); } Ok(Self { path: raw.path, schema_version: raw.schema_version, rotation: raw.rotation, max_files: (raw.rotation == OcsfLogRotation::Daily).then(|| { raw.max_files .unwrap_or(std::num::NonZeroUsize::new(7).unwrap()) }), queue_capacity: raw .queue_capacity .unwrap_or(std::num::NonZeroUsize::new(10_000).unwrap()), queue_max_bytes: raw .queue_max_bytes .unwrap_or(std::num::NonZeroUsize::new(16 * 1024 * 1024).unwrap()), }) } } /// `[openshell.gateway.otlp]` section. /// /// Presence of this table enables OTLP export; there is no `enabled` flag. /// SDK tuning knobs are deliberately absent — see [`crate::otel_tracing`] for what /// this table owns and what the `OTEL_*` environment variables own. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OtlpConfig { /// OTLP/gRPC collector endpoint, e.g. /// `http://otel-collector.observability.svc:4317`. pub endpoint: String, /// `service.name` resource attribute. Defaults to `openshell-gateway`. #[serde(default)] pub service_name: Option, } /// `[openshell.supervisor]` section. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SupervisorFileSection { /// Statically registered supervisor middleware services. Registration is /// operator-owned and changes require a gateway restart. #[serde(default)] pub middleware: Vec, } /// One `[[openshell.supervisor.middleware]]` supervisor middleware registration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MiddlewareServiceFileConfig { /// Operator-facing name used for diagnostics. pub name: String, /// HTTP or HTTPS gRPC endpoint reachable by the gateway and supervisors. pub grpc_endpoint: String, /// Optional PEM trust-root bundle for an HTTPS endpoint. #[serde(default)] pub tls_ca_cert_path: Option, /// Exact JWT audience for this service. Defaults to a kind-scoped value /// derived from the registration name. #[serde(default)] pub audience: Option, /// Opt out of extension authentication for this registration, permitting a /// plaintext `http://` endpoint with no bearer credential. Development and /// trusted-network deployments only. #[serde(default)] pub allow_insecure_transport: bool, /// Operator-owned logical payload limit for every binding exposed by this /// service, including HTTP bodies and complete WebSocket messages. #[serde(alias = "max_body_bytes")] pub max_payload_bytes: u64, /// Default RPC timeout using an integer with an `ms` or `s` suffix. #[serde(default)] pub timeout: Option, } impl TryFrom<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { type Error = ConfigFileError; fn try_from(config: &MiddlewareServiceFileConfig) -> Result { let tls_ca_cert_pem = match &config.tls_ca_cert_path { Some(path) => { let pem = std::fs::read(path).map_err(|source| ConfigFileError::MiddlewareTlsCaRead { name: config.name.clone(), path: path.clone(), source, })?; sanitize_ca_cert_pem(&config.name, path, &pem)? } None => Vec::new(), }; Ok(Self { name: config.name.clone(), grpc_endpoint: config.grpc_endpoint.clone(), max_payload_bytes: config.max_payload_bytes, request_timeout: config .timeout .as_deref() .map(openshell_core::middleware::parse_middleware_timeout) .transpose() .map_err(|_| ConfigFileError::InvalidValue { field: "openshell.supervisor_middleware.services.timeout", message: "must be a duration between 10ms and 30s", })? .map(openshell_core::time::duration_from_std) .transpose() .map_err(|_| ConfigFileError::InvalidValue { field: "openshell.supervisor_middleware.services.timeout", message: "duration is outside the protobuf range", })?, tls_ca_cert_pem, audience: config .audience .as_deref() .filter(|audience| !audience.is_empty()) .map_or_else( || format!("urn:openshell:extension:middleware:{}", config.name), ToString::to_string, ), allow_insecure_transport: config.allow_insecure_transport, }) } } fn sanitize_ca_cert_pem(name: &str, path: &Path, pem: &[u8]) -> Result, ConfigFileError> { let mut sanitized = Vec::new(); let mut certificate_count = 0; for item in rustls_pemfile::read_all(&mut Cursor::new(pem)) { let item = item.map_err(|source| ConfigFileError::MiddlewareTlsCaInvalid { name: name.to_string(), path: path.to_path_buf(), message: source.to_string(), })?; let rustls_pemfile::Item::X509Certificate(certificate) = item else { return Err(ConfigFileError::MiddlewareTlsCaInvalid { name: name.to_string(), path: path.to_path_buf(), message: "PEM bundle contains a non-certificate block".to_string(), }); }; certificate_count += 1; sanitized.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); let encoded = base64::engine::general_purpose::STANDARD.encode(certificate.as_ref()); for line in encoded.as_bytes().chunks(64) { sanitized.extend_from_slice(line); sanitized.push(b'\n'); } sanitized.extend_from_slice(b"-----END CERTIFICATE-----\n"); } if certificate_count == 0 { return Err(ConfigFileError::MiddlewareTlsCaInvalid { name: name.to_string(), path: path.to_path_buf(), message: "PEM bundle does not contain a certificate".to_string(), }); } Ok(sanitized) } #[derive(Debug, thiserror::Error)] pub enum ConfigFileError { #[error("failed to read gateway config file '{}': {source}", path.display())] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("gateway config path '{}' is not a regular file ({kind})", path.display())] NonRegular { path: PathBuf, kind: &'static str }, #[error("failed to parse gateway config file '{}': {source}", path.display())] Parse { path: PathBuf, #[source] source: Box, }, #[error( "gateway config schema version is required; add `[openshell]` and `version = {SCHEMA_VERSION}`" )] MissingVersion, #[error( "unsupported gateway config version {version}; this build requires version {SCHEMA_VERSION}; migrate legacy fields to the version {SCHEMA_VERSION} schema" )] UnsupportedVersion { version: u32 }, #[error( "`{field}` is not allowed in the gateway config file — set the {env} env var or pass {cli} on the command line" )] SecretInFile { field: &'static str, env: &'static str, cli: &'static str, }, #[error("invalid gateway config field `{field}`: {message}")] InvalidValue { field: &'static str, message: &'static str, }, #[error("invalid gateway config field `openshell.drivers.{name}`: expected a TOML table")] InvalidDriverTable { name: String }, #[error( "failed to read TLS CA certificate for supervisor middleware '{name}' from '{}': {source}", path.display() )] MiddlewareTlsCaRead { name: String, path: PathBuf, #[source] source: std::io::Error, }, #[error( "invalid TLS CA certificate for supervisor middleware '{name}' at '{}': {message}", path.display() )] MiddlewareTlsCaInvalid { name: String, path: PathBuf, message: String, }, } const CONFIG_MIGRATION_URL: &str = "https://docs.nvidia.com/openshell/latest/how-it-works/gateways/configuration#migrate-to-schema-version-2"; /// Stable package-preflight failure with no configuration contents attached. #[derive(Debug, thiserror::Error)] #[error( "gateway config preflight failed: path='{}' category={} detected_version={}; the file was preserved unchanged; migrate it before restarting the gateway: {CONFIG_MIGRATION_URL}", path.display(), category, detected_version )] pub struct ConfigPreflightError { path: PathBuf, category: &'static str, detected_version: String, } impl ConfigPreflightError { pub(crate) fn invalid_current(path: &Path) -> Self { Self { path: path.to_path_buf(), category: "malformed", detected_version: SCHEMA_VERSION.to_string(), } } #[cfg(test)] fn category(&self) -> &'static str { self.category } #[cfg(test)] fn detected_version(&self) -> &str { &self.detected_version } } fn non_regular_kind(metadata: &std::fs::Metadata) -> &'static str { let file_type = metadata.file_type(); if file_type.is_symlink() { "symlink" } else if file_type.is_dir() { "directory" } else { "non-regular" } } fn read_config_contents(path: &Path) -> Result { let path_buf = path.to_path_buf(); let metadata = std::fs::symlink_metadata(path).map_err(|source| ConfigFileError::Io { path: path_buf.clone(), source, })?; if !metadata.file_type().is_file() { return Err(ConfigFileError::NonRegular { path: path_buf, kind: non_regular_kind(&metadata), }); } let mut options = OpenOptions::new(); options.read(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt as _; options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK); } let mut file = options.open(path).map_err(|source| ConfigFileError::Io { path: path_buf.clone(), source, })?; let opened_metadata = file.metadata().map_err(|source| ConfigFileError::Io { path: path_buf.clone(), source, })?; if !opened_metadata.file_type().is_file() { return Err(ConfigFileError::NonRegular { path: path_buf, kind: non_regular_kind(&opened_metadata), }); } let mut contents = String::new(); file.read_to_string(&mut contents) .map_err(|source| ConfigFileError::Io { path: path.to_path_buf(), source, })?; Ok(contents) } fn parse_and_validate(path: &Path, contents: &str) -> Result { if contents.trim().is_empty() { return Err(ConfigFileError::MissingVersion); } let file: ConfigFile = toml::from_str(contents).map_err(|source| ConfigFileError::Parse { path: path.to_path_buf(), source: Box::new(source), })?; match file.openshell.version { Some(SCHEMA_VERSION) => {} Some(version) => return Err(ConfigFileError::UnsupportedVersion { version }), None => return Err(ConfigFileError::MissingVersion), } if file.openshell.gateway.database_url.is_some() { return Err(ConfigFileError::SecretInFile { field: "database_url", env: "OPENSHELL_DB_URL", cli: "--db-url", }); } if file .openshell .gateway .credential_drivers .as_ref() .is_some_and(Vec::is_empty) { return Err(ConfigFileError::InvalidValue { field: "openshell.gateway.credential_drivers", message: "omit the field to use default encrypted gateway credential storage, or specify exactly one external credential driver", }); } if let Some((name, _)) = file .openshell .drivers .iter() .find(|(_, value)| !value.is_table()) { return Err(ConfigFileError::InvalidDriverTable { name: name.clone() }); } Ok(file) } fn detected_version(contents: &str) -> String { let Ok(value) = contents.parse::() else { return "unavailable".to_string(); }; let Some(openshell) = value.get("openshell") else { return "missing".to_string(); }; let Some(openshell) = openshell.as_table() else { return "unavailable".to_string(); }; openshell.get("version").map_or_else( || "missing".to_string(), |version| { version .as_integer() .map_or_else(|| "unavailable".to_string(), |version| version.to_string()) }, ) } fn preflight_error( path: &Path, detected_version: String, source: ConfigFileError, ) -> ConfigPreflightError { let category = match &source { ConfigFileError::NonRegular { .. } => "non_regular_path", ConfigFileError::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound => { "missing_path" } ConfigFileError::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidData => { "malformed" } ConfigFileError::Io { .. } => "unreadable_path", ConfigFileError::MissingVersion => "missing_version", ConfigFileError::UnsupportedVersion { version: 1 } => "legacy_schema_v1", ConfigFileError::UnsupportedVersion { version } if *version > SCHEMA_VERSION => { "future_version" } ConfigFileError::UnsupportedVersion { .. } => "unsupported_version", ConfigFileError::Parse { .. } | ConfigFileError::SecretInFile { .. } | ConfigFileError::InvalidValue { .. } | ConfigFileError::InvalidDriverTable { .. } | ConfigFileError::MiddlewareTlsCaRead { .. } | ConfigFileError::MiddlewareTlsCaInvalid { .. } => "malformed", }; ConfigPreflightError { path: path.to_path_buf(), category, detected_version, } } /// Validate a gateway configuration without starting the gateway or writing state. #[cfg_attr(target_os = "windows", allow(clippy::result_large_err))] pub fn preflight(path: &Path) -> Result { let contents = read_config_contents(path) .map_err(|source| preflight_error(path, "unavailable".to_string(), source))?; let version = detected_version(&contents); if version == "missing" { return Err(preflight_error( path, version, ConfigFileError::MissingVersion, )); } if let Ok(version_number) = version.parse::() && version_number != SCHEMA_VERSION { return Err(preflight_error( path, version, ConfigFileError::UnsupportedVersion { version: version_number, }, )); } parse_and_validate(path, &contents).map_err(|source| preflight_error(path, version, source)) } /// Load and validate a TOML config file. /// /// Configuration files must declare exactly [`SCHEMA_VERSION`]. Running /// without a config file still uses CLI, environment, and built-in defaults. #[cfg_attr(target_os = "windows", allow(clippy::result_large_err))] pub fn load(path: &Path) -> Result { let contents = read_config_contents(path)?; parse_and_validate(path, &contents) } /// Return a driver's table without gateway-level inheritance. /// Driver-specific configuration belongs exclusively to /// `[openshell.drivers.]` in schema version 2. pub fn driver_table( _driver_name: &str, _gateway: &GatewayFileSection, raw: Option<&toml::Value>, ) -> toml::Value { match raw { Some(toml::Value::Table(table)) => toml::Value::Table(table.clone()), _ => toml::Value::Table(toml::Table::new()), } } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn write_raw_tmp(contents: &str) -> tempfile::NamedTempFile { let mut tmp = tempfile::Builder::new() .suffix(".toml") .tempfile() .expect("tempfile"); tmp.write_all(contents.as_bytes()).expect("write"); tmp } fn write_tmp(contents: &str) -> tempfile::NamedTempFile { if contents.contains("[openshell]") { write_raw_tmp(contents) } else { write_raw_tmp(&format!("[openshell]\nversion = 2\n\n{contents}")) } } #[test] fn empty_file_requires_schema_version() { let tmp = write_raw_tmp(""); assert!(matches!( load(tmp.path()), Err(ConfigFileError::MissingVersion) )); } #[test] fn compute_driver_entries_must_be_tables() { for value in ["\"not-a-table\"", "[\"also\", \"not-a-table\"]", "42"] { let tmp = write_raw_tmp(&format!( "[openshell]\nversion = 2\n\n[openshell.drivers]\ndocker = {value}\n" )); assert!(matches!( load(tmp.path()), Err(ConfigFileError::InvalidDriverTable { ref name }) if name == "docker" )); } } #[test] fn canonical_compute_driver_is_singular() { let file: ConfigFile = toml::from_str( r#" [openshell.gateway] compute_driver = "docker" "#, ) .expect("canonical compute driver parses"); assert_eq!( file.openshell.gateway.compute_driver.as_deref(), Some("docker") ); } #[test] fn legacy_compute_drivers_list_is_rejected() { let error = toml::from_str::("[openshell.gateway]\ncompute_drivers = [\"docker\"]\n") .expect_err("legacy compute_drivers must be rejected"); assert!(error.to_string().contains("compute_drivers")); } #[test] fn compute_driver_rejects_non_string_values() { let error = toml::from_str::( r" [openshell.gateway] compute_driver = 42 ", ) .expect_err("compute driver must be a string"); assert!(error.to_string().contains("invalid type")); } #[test] fn compute_driver_serialization_uses_scalar_name() { let file = ConfigFile { openshell: OpenShellRoot { gateway: GatewayFileSection { compute_driver: Some("docker".to_string()), ..Default::default() }, ..Default::default() }, }; let serialized = toml::to_string(&file).expect("config serializes"); assert!(serialized.contains("compute_driver = \"docker\"")); } #[test] fn parses_full_example() { let toml = r#" [openshell] version = 2 [openshell.gateway] bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" log_level = "info" compute_driver = "kubernetes" credential_drivers = ["kubernetes-secrets"] grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 policy_validation_failure_mode = "retain_last_valid" [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" client_ca_path = "/etc/openshell/certs/client-ca.pem" [openshell.gateway.oidc] issuer = "https://idp.example.com/realms/openshell" audience = "openshell-cli" jwks_allowed_origins = ["https://keys.example.com"] dangerously_allow_insecure_http = false [openshell.drivers.kubernetes] namespace = "agents" default_image = "ghcr.io/nvidia/openshell/sandbox:latest" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" client_tls_secret_name = "openshell-sandbox-tls" service_account_name = "openshell-sandbox" grpc_endpoint = "https://openshell-gateway.agents.svc:8080" [openshell.credential_drivers.kubernetes-secrets] namespace = "agents" "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid file parses"); let gw = &file.openshell.gateway; assert_eq!(gw.log_level.as_deref(), Some("info")); assert_eq!(gw.grpc_rate_limit_requests, Some(120)); assert_eq!(gw.grpc_rate_limit_window_seconds, Some(60)); assert_eq!( gw.policy_validation_failure_mode, Some(openshell_core::PolicyValidationFailureMode::RetainLastValid) ); assert!(gw.tls.is_some()); let oidc = gw.oidc.as_ref().expect("OIDC config parses"); assert!(!oidc.dangerously_allow_insecure_http); assert_eq!( oidc.jwks_allowed_origins, ["https://keys.example.com".to_string()] ); assert_eq!( gw.credential_drivers.as_deref(), Some(&["kubernetes-secrets".to_string()][..]) ); assert!(gw.default_credential_driver.is_none()); let kubernetes = file .openshell .drivers .get("kubernetes") .and_then(toml::Value::as_table) .expect("kubernetes driver table"); assert_eq!( kubernetes .get("default_image") .and_then(toml::Value::as_str), Some("ghcr.io/nvidia/openshell/sandbox:latest") ); assert!( file.openshell .credential_drivers .contains_key("kubernetes-secrets") ); } #[test] fn rejects_explicit_empty_credential_drivers() { let tmp = write_tmp( r" [openshell.gateway] credential_drivers = [] ", ); let err = load(tmp.path()).unwrap_err(); assert!(err.to_string().contains("credential_drivers")); assert!(err.to_string().contains("omit the field")); } #[test] fn parses_gateway_otlp_config() { let toml = r#" [openshell.gateway.otlp] endpoint = "http://otel-collector.observability.svc:4317" service_name = "openshell-gateway-dev" "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid otlp config parses"); let otlp = file.openshell.gateway.otlp.expect("otlp config"); assert_eq!( otlp.endpoint, "http://otel-collector.observability.svc:4317" ); assert_eq!(otlp.service_name.as_deref(), Some("openshell-gateway-dev")); } #[test] fn gateway_accepts_a_single_ocsf_log_destination() { let tmp = write_tmp("[openshell.gateway.ocsf_log]\npath = 'events.jsonl'\n"); let config = load(tmp.path()) .unwrap() .openshell .gateway .ocsf_log .unwrap(); assert_eq!(config.rotation, OcsfLogRotation::Daily); assert_eq!(config.schema_version, None); assert_eq!(config.max_files.unwrap().get(), 7); assert_eq!(config.queue_capacity.get(), 10_000); assert_eq!(config.queue_max_bytes.get(), 16 * 1024 * 1024); assert!(ConfigFile::default().openshell.gateway.ocsf_log.is_none()); let tmp = write_tmp( "[openshell.gateway.ocsf_log]\npath = 'events.jsonl'\nschema_version = '1.3'\n", ); assert_eq!( load(tmp.path()) .unwrap() .openshell .gateway .ocsf_log .unwrap() .schema_version, Some(OcsfSchemaVersion::V1_3) ); } #[test] fn ocsf_log_rejects_invalid_and_unshipped_options() { for settings in [ "", "path = ''", "path = 'log'\nqueue_capacity = 0", "path = 'log'\nqueue_max_bytes = 0", "path = 'log'\nmax_files = 0", "path = 'log'\nrotation = 'hourly'", "path = 'log'\nschema_version = ''", "path = 'log'\nschema_version = '1.8'", "path = 'log'\nkind = 'jsonl'", "path = 'log'\nrotation = 'never'\nmax_files = 7", ] { let tmp = write_tmp(&format!("[openshell.gateway.ocsf_log]\n{settings}\n")); assert!(load(tmp.path()).is_err(), "accepted {settings}"); } let tmp = write_tmp("[openshell.gateway.ocsf_log]\npath = 'log'\nrotation = 'never'\n"); let config = load(tmp.path()).unwrap(); let encoded = toml::to_string(&config).unwrap(); assert!(toml::from_str::(&encoded).is_ok()); } #[test] fn otlp_config_requires_only_endpoint() { let toml = r#" [openshell.gateway.otlp] endpoint = "http://127.0.0.1:4317" "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("minimal otlp config parses"); let otlp = file.openshell.gateway.otlp.expect("otlp config"); assert_eq!(otlp.endpoint, "http://127.0.0.1:4317"); assert!(otlp.service_name.is_none()); } #[test] fn otlp_config_rejects_unknown_fields() { let toml = r#" [openshell.gateway.otlp] endpoint = "http://127.0.0.1:4317" protocol = "http" "#; let tmp = write_tmp(toml); assert!(load(tmp.path()).is_err(), "unknown otlp field is rejected"); } #[test] fn otlp_config_rejects_sdk_tuning_keys() { // Sampling, batching, and limits are the SDK's env-var surface. A // `deny_unknown_fields` rejection is the signal that they do not // belong in the config file. let toml = r#" [openshell.gateway.otlp] endpoint = "http://127.0.0.1:4317" sampler = "traceidratio" "#; let tmp = write_tmp(toml); assert!( load(tmp.path()).is_err(), "sampler is configured via OTEL_TRACES_SAMPLER, not TOML" ); } #[test] fn rejects_unknown_policy_validation_failure_mode() { let tmp = write_tmp( r#" [openshell.gateway] policy_validation_failure_mode = "keep_old" "#, ); let error = load(tmp.path()).expect_err("unknown posture must fail TOML validation"); assert!(error.to_string().contains("policy_validation_failure_mode")); } #[test] fn parses_gateway_auth_config() { let toml = r" [openshell.gateway.auth] allow_unauthenticated_users = true "; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid auth config parses"); let auth = file.openshell.gateway.auth.expect("auth config"); assert!(auth.allow_unauthenticated_users); } #[test] fn parses_supervisor_middleware_registration() { let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) .expect("test certificate"); let mut ca = tempfile::Builder::new() .suffix(".pem") .tempfile() .expect("CA tempfile"); ca.write_all(certificate.cert.pem().as_bytes()) .expect("write CA"); let toml = r#" [[openshell.supervisor.middleware]] name = "local-guard" grpc_endpoint = "https://127.0.0.1:50051" tls_ca_cert_path = 'CA_PATH' audience = "urn:openshell:middleware:local-guard" max_payload_bytes = 262144 timeout = "2s" "# .replace("CA_PATH", &ca.path().display().to_string()); let tmp = write_tmp(&toml); let file = load(tmp.path()).expect("valid middleware registration parses"); assert_eq!( file.openshell.supervisor.middleware, vec![MiddlewareServiceFileConfig { name: "local-guard".into(), grpc_endpoint: "https://127.0.0.1:50051".into(), tls_ca_cert_path: Some(ca.path().to_path_buf()), audience: Some("urn:openshell:middleware:local-guard".into()), allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: Some("2s".into()), }] ); let registration = SupervisorMiddlewareService::try_from(&file.openshell.supervisor.middleware[0]) .expect("valid CA resolves"); assert_eq!(registration.request_timeout.unwrap().seconds, 2); let registered_pem = String::from_utf8(registration.tls_ca_cert_pem) .expect("registered CA remains PEM text") .replace("\r\n", "\n"); let generated_pem = certificate.cert.pem().replace("\r\n", "\n"); assert_eq!(registered_pem, generated_pem); assert_eq!( registration.audience, "urn:openshell:middleware:local-guard" ); } #[test] fn middleware_registration_defaults_audience_to_name() { let mut config = MiddlewareServiceFileConfig { name: "local-guard".into(), grpc_endpoint: "https://guard.example:50051".into(), tls_ca_cert_path: None, audience: None, allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, }; let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); assert_eq!( registration.audience, "urn:openshell:extension:middleware:local-guard" ); assert!(registration.tls_ca_cert_pem.is_empty()); config.audience = Some(String::new()); let registration = SupervisorMiddlewareService::try_from(&config).unwrap(); assert_eq!( registration.audience, "urn:openshell:extension:middleware:local-guard" ); } #[test] fn middleware_registration_rejects_invalid_ca_pem() { let mut ca = tempfile::Builder::new() .suffix(".pem") .tempfile() .expect("CA tempfile"); ca.write_all(b"not a certificate").expect("write CA"); let config = MiddlewareServiceFileConfig { name: "local-guard".into(), grpc_endpoint: "https://guard.example:50051".into(), tls_ca_cert_path: Some(ca.path().to_path_buf()), audience: None, allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, }; let error = SupervisorMiddlewareService::try_from(&config) .expect_err("invalid CA must fail before service connection"); assert!(matches!( error, ConfigFileError::MiddlewareTlsCaInvalid { .. } )); } #[test] fn middleware_registration_rejects_ca_bundle_with_private_key() { use std::io::Write as _; let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).expect("test certificate"); let mut ca = tempfile::Builder::new() .suffix(".pem") .tempfile() .expect("CA tempfile"); ca.write_all(certificate.cert.pem().as_bytes()) .expect("write certificate"); ca.write_all(certificate.key_pair.serialize_pem().as_bytes()) .expect("write private key"); let config = MiddlewareServiceFileConfig { name: "local-guard".into(), grpc_endpoint: "https://guard.example:50051".into(), tls_ca_cert_path: Some(ca.path().to_path_buf()), audience: None, allow_insecure_transport: false, max_payload_bytes: 262_144, timeout: None, }; let error = SupervisorMiddlewareService::try_from(&config) .expect_err("private key material must never be distributed to a sandbox"); assert!(matches!( error, ConfigFileError::MiddlewareTlsCaInvalid { .. } )); assert!(error.to_string().contains("non-certificate block")); } #[test] fn parses_gateway_interceptor_tls_and_audience() { let tmp = write_tmp( r#" [[openshell.gateway.interceptors]] name = "quota" grpc_endpoint = "https://quota.example:50051" tls_ca_cert_path = "/etc/openshell/quota-ca.pem" audience = "urn:openshell:interceptor:quota" "#, ); let file = load(tmp.path()).expect("valid interceptor config parses"); let interceptor = &file.openshell.gateway.interceptors[0]; assert_eq!( interceptor.tls_ca_cert_path.as_deref(), Some(Path::new("/etc/openshell/quota-ca.pem")) ); assert_eq!( interceptor.resolved_audience(), "urn:openshell:interceptor:quota" ); } #[test] fn parses_legacy_supervisor_middleware_payload_limit() { let toml = r#" [[openshell.supervisor.middleware]] name = "local-guard" grpc_endpoint = "http://127.0.0.1:50051" max_body_bytes = 262144 "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("legacy middleware registration parses"); assert_eq!( file.openshell.supervisor.middleware[0].max_payload_bytes, 262_144 ); } #[test] fn parses_provider_profile_source_composition() { let toml = r#" [openshell.gateway] provider_profile_sources = [ { type = "user" }, { type = "interceptor", name = "provider-governance" }, ] "#; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid provider profile sources parse"); assert_eq!( file.openshell.gateway.provider_profile_sources, Some(vec![ GatewayProviderProfileSourceConfig::User, GatewayProviderProfileSourceConfig::Interceptor { name: "provider-governance".to_string(), }, ]) ); } #[test] fn rejects_the_removed_builtin_provider_profile_source() { let toml = r#" [openshell.gateway] provider_profile_sources = [ { type = "builtin" }, { type = "user" }, ] "#; let tmp = write_tmp(toml); let error = load(tmp.path()).expect_err("the builtin source was removed"); let message = error.to_string(); assert!(message.contains("import-only"), "{message}"); assert!( message.contains("openshell provider profile import"), "{message}" ); } #[test] fn rejects_database_url_in_file() { let toml = r#" [openshell.gateway] database_url = "sqlite::memory:" "#; let tmp = write_tmp(toml); let err = load(tmp.path()).expect_err("database_url must be rejected"); assert!(matches!( err, ConfigFileError::SecretInFile { field: "database_url", .. } )); } #[test] fn rejects_unknown_gateway_field() { let toml = r" [openshell.gateway] nonsense = true "; let tmp = write_tmp(toml); let err = load(tmp.path()).expect_err("unknown field must be rejected"); assert!(matches!(err, ConfigFileError::Parse { .. })); } #[test] fn rejects_operator_selected_tls_client_auth_policy() { let toml = r#" [openshell.gateway.tls] cert_path = "/tls/server.crt" key_path = "/tls/server.key" client_ca_path = "/tls/ca.crt" require_client_auth = false "#; let tmp = write_tmp(toml); let error = load(tmp.path()).expect_err("derived TLS client-auth policy must be rejected"); assert!(matches!(error, ConfigFileError::Parse { .. })); assert!(error.to_string().contains("require_client_auth")); } #[test] fn rejects_removed_driver_fields_at_gateway_scope() { for field in [ "sandbox_namespace = \"agents\"", "default_image = \"sandbox:latest\"", "supervisor_image = \"supervisor:latest\"", "client_tls_secret_name = \"sandbox-tls\"", "service_account_name = \"sandbox-sa\"", "host_gateway_ip = \"10.0.0.1\"", "enable_user_namespaces = true", "sa_token_ttl_secs = 3600", ] { let tmp = write_tmp(&format!("[openshell.gateway]\n{field}\n")); let err = load(tmp.path()).expect_err("gateway-scoped driver field must be rejected"); assert!( matches!(err, ConfigFileError::Parse { .. }), "field: {field}" ); } } #[test] fn rejects_unknown_field_in_nested_gateway_jwt_table() { // Regression guard for the class of silent-misconfig bug fixed in // PR #1661: a key indented under the wrong table header (here, // `sandbox_namespace` landing under `[openshell.gateway.gateway_jwt]` // instead of `[openshell.gateway]`) must be rejected rather than // silently ignored. let toml = r#" [openshell.gateway.gateway_jwt] signing_key_path = "/tmp/jwt/signing.pem" public_key_path = "/tmp/jwt/public.pem" kid_path = "/tmp/jwt/kid" sandbox_namespace = "agents" "#; let tmp = write_tmp(toml); let err = load(tmp.path()) .expect_err("unknown field in nested gateway_jwt table must be rejected"); assert!(matches!(err, ConfigFileError::Parse { .. })); } #[test] fn rejects_removed_ssh_endpoint_fields() { let toml = r" [openshell.gateway] ssh_gateway_port = 8080 "; let tmp = write_tmp(toml); let err = load(tmp.path()).expect_err("removed SSH endpoint keys must be rejected"); assert!(matches!(err, ConfigFileError::Parse { .. })); } #[test] fn rejects_legacy_version() { let tmp = write_raw_tmp("[openshell]\nversion = 1\n"); let err = load(tmp.path()).expect_err("version 1 must be rejected"); assert!(matches!( err, ConfigFileError::UnsupportedVersion { version: 1 } )); } #[test] fn rejects_missing_version_in_nonempty_file() { let tmp = write_raw_tmp("[openshell]\n\n[openshell.gateway]\nname = \"test\"\n"); assert!(matches!( load(tmp.path()), Err(ConfigFileError::MissingVersion) )); } #[test] fn rejects_future_version() { let tmp = write_raw_tmp("[openshell]\nversion = 3\n"); assert!(matches!( load(tmp.path()), Err(ConfigFileError::UnsupportedVersion { version: 3 }) )); } #[test] fn accepts_current_version() { let tmp = write_raw_tmp("[openshell]\nversion = 2\n"); load(tmp.path()).expect("schema version 2 must be accepted"); preflight(tmp.path()).expect("schema version 2 must pass preflight"); } #[test] fn preflight_classifies_versions_and_malformed_files_without_disclosing_contents() { for (contents, category, version) in [ ("[openshell]\nversion = 1\n", "legacy_schema_v1", "1"), ("[openshell]\n", "missing_version", "missing"), ("[openshell]\nversion = 3\n", "future_version", "3"), ("[openshell]\nversion = 0\n", "unsupported_version", "0"), ("[openshell]\nversion = 'two'\n", "malformed", "unavailable"), ( "[openshell]\nversion = 2\nsecret-value = [", "malformed", "unavailable", ), ( "[openshell]\nversion = 2\n[openshell.gateway]\nunknown = 'secret-value'\n", "malformed", "2", ), ] { let tmp = write_raw_tmp(contents); let error = preflight(tmp.path()).expect_err("preflight must reject fixture"); assert_eq!(error.category(), category, "contents: {contents}"); assert_eq!(error.detected_version(), version, "contents: {contents}"); let diagnostic = error.to_string(); assert!(diagnostic.contains(&format!("category={category}"))); assert!(diagnostic.contains(&format!("detected_version={version}"))); assert!(diagnostic.contains("the file was preserved unchanged")); assert!(diagnostic.contains(CONFIG_MIGRATION_URL)); assert!(!diagnostic.contains("secret-value")); assert!(!format!("{error:?}").contains("secret-value")); assert!(std::error::Error::source(&error).is_none()); } } #[test] fn preflight_classifies_missing_path() { let path = Path::new("/nonexistent/openshell-preflight.toml"); let error = preflight(path).expect_err("missing explicit path must fail"); assert_eq!(error.category(), "missing_path"); assert_eq!(error.detected_version(), "unavailable"); assert!(error.to_string().contains(&path.display().to_string())); } #[test] fn preflight_rejects_directory_as_non_regular() { let tmp = tempfile::tempdir().unwrap(); let error = preflight(tmp.path()).expect_err("directory must fail preflight"); assert_eq!(error.category(), "non_regular_path"); assert_eq!(error.detected_version(), "unavailable"); } #[cfg(unix)] #[test] fn preflight_and_runtime_reject_symlinks_without_following_them() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("target.toml"); let link = dir.path().join("gateway.toml"); std::fs::write(&target, "[openshell]\nversion = 2\n").unwrap(); symlink(&target, &link).unwrap(); let error = preflight(&link).expect_err("symlink must fail preflight"); assert_eq!(error.category(), "non_regular_path"); assert!(matches!( load(&link), Err(ConfigFileError::NonRegular { .. }) )); } #[cfg(unix)] #[test] fn preflight_rejects_fifo_without_blocking() { use nix::sys::stat::Mode; use nix::unistd::mkfifo; let dir = tempfile::tempdir().unwrap(); let fifo = dir.path().join("gateway.toml"); mkfifo(&fifo, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); let error = preflight(&fifo).expect_err("FIFO must fail preflight"); assert_eq!(error.category(), "non_regular_path"); } #[test] fn preflight_classifies_non_utf8_as_malformed() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), [0xff, 0xfe]).unwrap(); let error = preflight(tmp.path()).expect_err("non-UTF-8 config must fail"); assert_eq!(error.category(), "malformed"); assert_eq!(error.detected_version(), "unavailable"); } #[test] fn driver_table_uses_only_driver_owned_values() { let raw = toml::toml! { default_image = "driver-specific" socket_path = "/run/openshell/driver.sock" }; let table = driver_table( "alpha", &GatewayFileSection::default(), Some(&toml::Value::Table(raw)), ); let table = table.as_table().expect("driver table"); assert_eq!( table.get("default_image").and_then(toml::Value::as_str), Some("driver-specific") ); assert_eq!( table.get("socket_path").and_then(toml::Value::as_str), Some("/run/openshell/driver.sock") ); } #[test] fn driver_table_does_not_inject_gateway_values() { let table = driver_table("alpha", &GatewayFileSection::default(), None); assert!(table.as_table().expect("driver table").is_empty()); } #[test] fn missing_path_is_io_error() { let err = load(Path::new("/nonexistent/openshell-gateway.toml")) .expect_err("missing file must be io error"); assert!(matches!(err, ConfigFileError::Io { .. })); } /// Contract test: the RPM default config template must parse against the /// current schema and must pin the settings that Podman deployments require. /// /// This test loads `deploy/rpm/gateway.toml.default` through the same /// `load()` path that the gateway uses at runtime, catching: /// - template corruption or unknown fields (`deny_unknown_fields`) /// - schema drift (version bump or field renames) /// - accidental addition of a wildcard bind-address override /// - accidental changes to the configured compute driver #[test] fn rpm_default_config_parses_and_has_podman_defaults() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../deploy/rpm/gateway.toml.default"); let config = load(&path).expect("deploy/rpm/gateway.toml.default must parse against current schema"); let gw = &config.openshell.gateway; if let Some(addr) = gw.bind_address { assert!( !addr.ip().is_unspecified(), "RPM default config must not expose the primary listener on every interface" ); } assert_eq!( gw.compute_driver.as_deref(), Some("podman"), "RPM default must pin compute_driver to podman to prevent unexpected \ driver selection when Docker is also installed" ); let podman = driver_table( "podman", &config.openshell.gateway, config.openshell.drivers.get("podman"), ); assert_eq!( podman .get("health_check_interval_secs") .and_then(toml::Value::as_integer), Some(10), "RPM defaults must retain Podman's readiness health check" ); } }