// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! Shared CLI entrypoint for the gateway binaries. use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::collections::BTreeMap; use std::ffi::OsString; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{error, info, warn}; use tracing_subscriber::EnvFilter; use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; use crate::{ComputeDriverRegistry, ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// /// Top-level CLI. When invoked without a subcommand the binary runs the /// gateway server using `RunArgs`. The `generate-certs` subcommand is used by /// the Helm pre-install hook to bootstrap mTLS Secrets. #[derive(Parser, Debug)] #[command(version = openshell_core::VERSION)] #[command(about = "OpenShell gRPC/HTTP server", long_about = None)] struct Cli { #[command(subcommand)] command: Option, #[command(flatten)] run: RunArgs, } #[derive(clap::Subcommand, Debug)] enum Commands { /// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook). GenerateCerts(certgen::CertgenArgs), /// Inspect gateway configuration without starting the service. Config(ConfigArgs), } #[derive(clap::Args, Debug)] struct ConfigArgs { #[command(subcommand)] command: ConfigCommand, } #[derive(clap::Subcommand, Debug)] enum ConfigCommand { /// Validate the selected configuration without modifying it or starting the gateway. Preflight(ConfigPreflightArgs), } #[derive(clap::Args, Debug, Default)] struct ConfigPreflightArgs { /// Explicit configuration path. Overrides `OPENSHELL_GATEWAY_CONFIG` and XDG discovery. #[arg(long, conflicts_with = "gateway_args")] path: Option, /// Gateway daemon arguments to replay after `--`. #[arg(last = true, allow_hyphen_values = true, value_name = "GATEWAY_ARGS")] gateway_args: Vec, } #[derive(clap::Args, Clone, Debug)] #[allow(clippy::struct_excessive_bools)] struct RunArgs { /// Path to a TOML configuration file (see RFC 0003). /// /// When set, gateway-wide settings and per-driver tables are read from /// the file. Gateway command-line flags and `OPENSHELL_*` environment /// variables continue to take precedence over gateway file values. #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, /// Operator-assigned name for this gateway installation. #[arg( long = "name", default_value = DEFAULT_GATEWAY_NAME, env = "OPENSHELL_GATEWAY_NAME" )] name: String, /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, /// Port to bind the server to. #[arg(long, default_value_t = DEFAULT_SERVER_PORT, env = "OPENSHELL_SERVER_PORT")] port: u16, /// Port for unauthenticated health endpoints (healthz, readyz). /// Set to 0 to disable the dedicated health listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_HEALTH_PORT")] health_port: u16, /// Port for the Prometheus metrics endpoint (/metrics). /// Set to 0 to disable the dedicated metrics listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_METRICS_PORT")] metrics_port: u16, /// Log level (trace, debug, info, warn, error). #[arg(long, default_value = "info", env = "OPENSHELL_LOG_LEVEL")] log_level: String, /// Path to TLS certificate file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_CERT")] tls_cert: Option, /// Path to TLS private key file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_KEY")] tls_key: Option, /// Path to CA certificate for client certificate verification (mTLS). #[arg(long, env = "OPENSHELL_TLS_CLIENT_CA")] tls_client_ca: Option, /// Database URL for persistence. /// /// When unset, the gateway stores state under the `XDG` state /// directory. Kept as an Option at the clap layer so the `generate-certs` /// subcommand can run without gateway runtime defaults. #[arg(long, env = "OPENSHELL_DB_URL")] db_url: Option, /// Compute driver configured for this gateway. /// /// Accepts one registered driver name. When unset, the gateway runs /// detection probes supplied by the drivers compiled into the binary. #[arg( long = "compute-driver", env = "OPENSHELL_COMPUTE_DRIVER", value_parser = parse_compute_driver )] compute_driver: Option, /// Path to a Unix domain socket served by a remote compute driver /// implementing `compute_driver.proto`. /// /// When set, the socket is associated with the driver name supplied by /// `--compute-driver` or `OPENSHELL_COMPUTE_DRIVER` and replaces normal /// construction for that selected name, including a compiled registration /// with the same name. The gateway connects to this operator-provided /// endpoint; it does not provision the remote driver. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] compute_driver_socket: Option, /// Disable TLS entirely — listen on plaintext HTTP. /// Use this when the gateway sits behind a reverse proxy or tunnel /// (e.g. Cloudflare Tunnel) that terminates TLS at the edge. #[arg(long, env = "OPENSHELL_DISABLE_TLS")] disable_tls: bool, /// OIDC issuer URL for JWT-based authentication. /// When set, the server validates `authorization: Bearer` tokens on gRPC /// requests against the issuer's JWKS endpoint. #[arg(long, env = "OPENSHELL_OIDC_ISSUER")] oidc_issuer: Option, /// Development only: permit OIDC metadata and JWKS over HTTP when the /// endpoint uses a numeric loopback address. #[arg( long, env = "OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP", default_value_t = false, action = ArgAction::Set )] oidc_dangerously_allow_insecure_http: bool, /// Additional HTTPS origins allowed to serve the issuer's JWKS. #[arg( long, env = "OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS", value_delimiter = ',' )] oidc_jwks_allowed_origins: Vec, /// Enable mTLS client certificate authentication for local single-user gateways. /// /// When unset, this defaults on for drivers registered as local /// single-player backends when client certificate verification is /// configured and no OIDC issuer is present. #[arg( long = "enable-mtls-auth", env = "OPENSHELL_ENABLE_MTLS_AUTH", default_value_t = false, action = ArgAction::Set )] enable_mtls_auth: bool, /// Expected OIDC audience claim (typically the client ID). #[arg(long, env = "OPENSHELL_OIDC_AUDIENCE", default_value = "openshell-cli")] oidc_audience: String, /// JWKS key cache TTL in seconds. #[arg(long, env = "OPENSHELL_OIDC_JWKS_TTL", default_value_t = 3600)] oidc_jwks_ttl: u64, /// Dot-separated path to the roles array in the JWT claims. /// Keycloak: `realm_access.roles` (default). Entra ID: "roles". Okta: "groups". #[arg( long, env = "OPENSHELL_OIDC_ROLES_CLAIM", default_value = "realm_access.roles" )] oidc_roles_claim: String, /// Role name that grants admin access. #[arg( long, env = "OPENSHELL_OIDC_ADMIN_ROLE", default_value = "openshell-admin" )] oidc_admin_role: String, /// Role name that grants standard user access. #[arg( long, env = "OPENSHELL_OIDC_USER_ROLE", default_value = "openshell-user" )] oidc_user_role: String, /// Dot-separated path to the scopes value in the JWT claims. /// When set, the server enforces scope-based permissions on top of roles. /// Keycloak: "scope". Okta: "scp". Leave empty to disable scope enforcement. #[arg(long, env = "OPENSHELL_OIDC_SCOPES_CLAIM", default_value = "")] oidc_scopes_claim: String, /// Maximum gRPC requests allowed per rate-limit window. Set to 0 to disable. #[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_REQUESTS")] grpc_rate_limit_requests: Option, /// gRPC rate-limit window length in seconds. Set to 0 to disable. #[arg(long, env = "OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS")] grpc_rate_limit_window_seconds: Option, /// Subject Alternative Names configured on the gateway server certificate. /// Wildcard DNS SANs also enable sandbox service URLs under that domain. #[arg( long = "server-san", env = "OPENSHELL_SERVER_SAN", value_delimiter = ',' )] server_sans: Vec, /// Enable plaintext HTTP routing for loopback sandbox service URLs. #[arg( long, env = "OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", default_value_t = true, action = ArgAction::Set )] enable_loopback_service_http: bool, /// Enable the WebSocket tunnel for an authenticated edge proxy. #[arg( long, env = "OPENSHELL_ENABLE_WEBSOCKET_TUNNEL", default_value_t = false, action = ArgAction::Set )] enable_websocket_tunnel: bool, } pub fn command() -> Command { Cli::command() .name("openshell-gateway") .bin_name("openshell-gateway") } pub async fn run_cli() -> Result<()> { run_cli_with_compute_drivers(ComputeDriverRegistry::new()).await } /// Run the gateway CLI with the compute drivers linked by the binary. pub async fn run_cli_with_compute_drivers(compute_drivers: ComputeDriverRegistry) -> Result<()> { let matches = command().get_matches(); let cli = Cli::from_arg_matches(&matches).expect("clap validated args"); match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, Some(Commands::Config(args)) => match args.command { ConfigCommand::Preflight(args) => { run_config_preflight_with_drivers(args, cli.run, &matches, &compute_drivers) } }, None => Box::pin(run_from_args(cli.run, matches, compute_drivers)).await, } } fn resolve_legacy_driver_selector_env(args: &mut RunArgs) -> Result { let Some(raw) = std::env::var_os("OPENSHELL_DRIVERS") else { return Ok(false); }; let raw = raw.into_string().map_err(|_| { miette::miette!( "OPENSHELL_DRIVERS contains an invalid compute driver name; expected ASCII letters, digits, '-' or '_'" ) })?; let values = raw.split(',').map(str::trim).collect::>(); if values.len() != 1 || values[0].is_empty() { return Err(miette::miette!( "OPENSHELL_DRIVERS must contain exactly one non-empty compute driver name; comma-delimited lists are not supported" )); } let legacy = openshell_core::config::normalize_compute_driver_name(values[0]).map_err(|_| { miette::miette!( "OPENSHELL_DRIVERS contains an invalid compute driver name; expected ASCII letters, digits, '-' or '_'" ) })?; if let Some(canonical) = args.compute_driver.as_deref() { let canonical = openshell_core::config::normalize_compute_driver_name(canonical) .map_err(|error| miette::miette!("{error}"))?; if canonical != legacy { return Err(miette::miette!( "OPENSHELL_DRIVERS conflicts with the canonical compute-driver selection; remove OPENSHELL_DRIVERS" )); } } else { args.compute_driver = Some(legacy); } Ok(true) } #[cfg(test)] fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result { prepare_server_config_with_drivers(args, matches, &ComputeDriverRegistry::new()) } fn prepare_server_config_with_drivers( args: &mut RunArgs, matches: &ArgMatches, compute_drivers: &ComputeDriverRegistry, ) -> Result { // Load TOML when explicitly requested, or from the default XDG location // when that file exists. Missing default config is not an error: runtime // defaults and OPENSHELL_* env vars are enough for package-managed starts. let config_path = resolve_config_path(args)?; let file: Option = if let Some(path) = config_path { Some(config_file::load(&path).map_err(|e| miette::miette!("{e}"))?) } else { None }; if let Some(file) = file.as_ref() { merge_file_into_args(args, &file.openshell.gateway, matches); } let legacy_compute_driver_env_seen = resolve_legacy_driver_selector_env(args)?; normalize_compute_driver_socket_args(args)?; let compute_driver = compute_drivers .select(args.compute_driver.as_deref()) .map_err(|error| miette::miette!("{error}"))?; let selected_registration = compute_drivers.get(compute_driver.name()); let local_tls = apply_runtime_defaults(args)?; let guest_tls = GuestTlsPaths::resolve( file.as_ref().map(|file| &file.openshell.gateway), local_tls.as_ref(), args.disable_tls, ) .map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?; let local_jwt = defaults::complete_local_jwt_config()?; let bind = SocketAddr::new(args.bind_address, args.port); let has_client_ca = args.tls_client_ca.is_some(); let has_oidc = args.oidc_issuer.is_some(); let mtls_auth_enabled = resolve_mtls_auth_enabled(args, matches, file.as_ref(), selected_registration); if args.disable_tls && has_client_ca { return Err(miette::miette!( "--disable-tls and --tls-client-ca are mutually exclusive. Client certificate verification requires that TLS be enabled." )); } if mtls_auth_enabled && args.disable_tls { return Err(miette::miette!( "mTLS user authentication requires TLS. Remove --disable-tls or disable --enable-mtls-auth." )); } if mtls_auth_enabled && !has_client_ca { return Err(miette::miette!( "mTLS user authentication requires --tls-client-ca so client certificates can be verified." )); } if mtls_auth_enabled && selected_registration.is_some_and(|registration| !registration.supports_mtls_user_auth()) { return Err(miette::miette!( "mTLS user authentication is not supported with the selected compute driver. Configure OIDC or a trusted fronting proxy for user authentication." )); } let tls = if args.disable_tls { None } else { let cert_path = args.tls_cert.clone().ok_or_else(|| { miette::miette!( "--tls-cert is required when TLS is enabled (use --disable-tls to skip)" ) })?; let key_path = args.tls_key.clone().ok_or_else(|| { miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)") })?; // External cert config (SNI-based dual cert) is only configurable // via the TOML file, not CLI flags — it's a deployment-time setting. let (ext_cert, ext_key, ext_names) = file .as_ref() .and_then(|f| f.openshell.gateway.tls.as_ref()) .map(|tls| { ( tls.external_cert_path.clone(), tls.external_key_path.clone(), tls.external_server_names.clone(), ) }) .unwrap_or_default(); Some(openshell_core::TlsConfig { cert_path, key_path, require_client_auth: has_client_ca && !has_oidc, client_ca_path: args.tls_client_ca.clone(), external_cert_path: ext_cert, external_key_path: ext_key, external_server_names: ext_names, }) }; let db_url = args .db_url .clone() .expect("runtime defaults populate db_url"); let name = args.name.trim(); if name.is_empty() { return Err(miette::miette!("gateway name must not be empty")); } let mut config = openshell_core::Config::new(tls) .with_name(name) .with_bind_address(bind) .with_log_level(&args.log_level); if let Some(auth) = file.as_ref().and_then(|f| f.openshell.gateway.auth.clone()) { config.auth = auth; } config.mtls_auth.enabled = mtls_auth_enabled; // Listener addresses for the health and metrics endpoints. The file may // pin a different interface than the main listener (e.g. health on // 127.0.0.1 while gRPC binds 0.0.0.0); the full `SocketAddr` from the // file is preserved unless CLI/env supplied an explicit `--health-port` / // `--metrics-port`, in which case the port overrides the file value // while the IP defaults to `args.bind_address`. let file_gateway = file.as_ref().map(|f| &f.openshell.gateway); let health_bind = resolve_aux_listener( args.bind_address, args.health_port, matches, "health_port", || file_gateway.and_then(|g| g.health_bind_address), ); let metrics_bind = resolve_aux_listener( args.bind_address, args.metrics_port, matches, "metrics_port", || file_gateway.and_then(|g| g.metrics_bind_address), ); if let Some(addr) = health_bind { if args.port == addr.port() { return Err(miette::miette!( "--port and --health-port must be different (both set to {})", args.port )); } config = config.with_health_bind_address(addr); } if let Some(addr) = metrics_bind { if args.port == addr.port() { return Err(miette::miette!( "--port and --metrics-port must be different (both set to {})", args.port )); } if let Some(health) = health_bind && health.port() == addr.port() { return Err(miette::miette!( "--health-port and --metrics-port must be different (both set to {})", health.port() )); } config = config.with_metrics_bind_address(addr); } config = config.with_database_url(db_url); if let Some(driver) = &args.compute_driver { config = config.with_compute_driver(driver); } config = config .with_grpc_rate_limit( args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, ) .with_gateway_interceptors( file.as_ref() .map(|f| f.openshell.gateway.interceptors.clone()) .unwrap_or_default(), ) .with_server_sans(args.server_sans.clone()) .with_loopback_service_http(args.enable_loopback_service_http) .with_websocket_tunnel(args.enable_websocket_tunnel); if let Some(sources) = file .as_ref() .and_then(|file| file.openshell.gateway.provider_profile_sources.clone()) { config = config.with_provider_profile_sources(sources); } if let Some(gateway_file) = file.as_ref().map(|f| &f.openshell.gateway) { if let Some(drivers) = &gateway_file.credential_drivers { config = config.with_credential_drivers(drivers.clone()); } if let Some(default_driver) = &gateway_file.default_credential_driver { config = config.with_default_credential_driver(Some(default_driver.clone())); } } validate_grpc_rate_limit_args( args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, )?; if let Some(socket) = args.compute_driver_socket.clone() { let driver = args .compute_driver .as_ref() .expect("normalize_compute_driver_socket_args sets a driver for socket endpoints"); config = config.with_compute_driver_endpoint(driver.clone(), socket); } if let Some(ttl) = file .as_ref() .and_then(|f| f.openshell.gateway.ssh_session_ttl_secs) { config = config.with_ssh_session_ttl_secs(ttl); } if let Some(mode) = file .as_ref() .and_then(|f| f.openshell.gateway.policy_validation_failure_mode) { config.policy_validation_failure_mode = mode; } if let Some(issuer) = args.oidc_issuer.clone() { config = config.with_oidc(openshell_core::OidcConfig { issuer, dangerously_allow_insecure_http: args.oidc_dangerously_allow_insecure_http, jwks_allowed_origins: args.oidc_jwks_allowed_origins.clone(), audience: args.oidc_audience.clone(), jwks_ttl_secs: args.oidc_jwks_ttl, roles_claim: args.oidc_roles_claim.clone(), admin_role: args.oidc_admin_role.clone(), user_role: args.oidc_user_role.clone(), scopes_claim: args.oidc_scopes_claim.clone(), }); } // `gateway_jwt` is configured through TOML in cluster deployments. Local // package-managed starts also auto-detect the JWT bundle written next to // the generated TLS bundle so upgrades pick up sandbox auth without a // user-authored config file. if let Some(jwt) = file .as_ref() .and_then(|f| f.openshell.gateway.gateway_jwt.clone()) { config.gateway_jwt = Some(jwt); } else if let Some(jwt) = local_jwt { config.gateway_jwt = Some(jwt); } Ok(ServerStartupConfig { config, config_file: file, guest_tls, compute_driver, legacy_compute_driver_env_seen, }) } async fn run_from_args( mut args: RunArgs, matches: ArgMatches, compute_drivers: ComputeDriverRegistry, ) -> Result<()> { let prepared = prepare_server_config_with_drivers(&mut args, &matches, &compute_drivers)?; // Initialize OCSF identity before tracing can emit gateway events. let gateway_identity = crate::gateway_ocsf::GatewayIdentity { name: prepared.config.name.clone(), hostname: crate::compute::lease::replica_id(), }; if !crate::gateway_ocsf::set_identity(gateway_identity) { tracing::debug!("gateway OCSF identity already initialized, keeping existing"); } let tracing_log_bus = TracingLogBus::new(); let ocsf_log = prepared .config_file .as_ref() .and_then(|file| file.openshell.gateway.ocsf_log.clone()) .map(crate::ocsf_log::OcsfLog::start) .transpose() .into_diagnostic()?; let otlp_config = prepared .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()); let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( Some(prepared.config.name.as_str()), Some(prepared.compute_driver.name()), ); let compute_driver_tracing = compute_drivers.in_process_tracing( &prepared.compute_driver, &prepared.config.compute_driver_endpoints, ); let (tracing_handle, setup_error) = crate::tracing_setup::install( &EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)) .to_string(), &tracing_log_bus, ocsf_log.as_ref(), otlp_config, compute_driver_tracing, gateway_resource, ); if prepared.legacy_compute_driver_env_seen { warn!("OPENSHELL_DRIVERS is deprecated; migrate to OPENSHELL_COMPUTE_DRIVER"); } let has_client_ca = prepared .config .tls .as_ref() .and_then(|tls| tls.client_ca_path.as_ref()) .is_some(); let has_oidc = prepared.config.oidc.is_some(); if prepared.config.tls.is_none() { warn!("TLS disabled — listening on plaintext HTTP"); } else { info!("TLS enabled — listening on encrypted HTTPS"); } if has_client_ca { info!("TLS client certificate verification enabled"); } if prepared.config.mtls_auth.enabled { info!("mTLS user authentication enabled"); } if has_oidc { info!("OIDC authentication enabled"); } if let Some(err) = &setup_error { error!( error = %err, "OTLP exporting is configured but could not be started; continuing without it" ); } else if let Some(otlp) = prepared .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()) { info!(endpoint = %otlp.endpoint, "OTLP exporting enabled"); } if prepared.config.auth.allow_unauthenticated_users { warn!( "Unauthenticated user access enabled — only use this for trusted local development or a fully trusted fronting proxy" ); } if !prepared.config.auth.allow_unauthenticated_users && !prepared.config.mtls_auth.enabled && !has_oidc && prepared.config.gateway_jwt.is_none() { warn!( "Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \ the gateway has no authentication mechanism" ); } info!(bind = %prepared.config.bind_address, "Starting OpenShell server"); let result = Box::pin(run_server(prepared, tracing_log_bus, compute_drivers)).await; tracing_handle.shutdown(); if let Some(log) = ocsf_log { log.shutdown().await; } result.into_diagnostic() } fn parse_compute_driver(value: &str) -> std::result::Result { openshell_core::config::normalize_compute_driver_name(value) } #[cfg(test)] #[derive(Clone, Copy)] struct PreflightTestFactory; #[cfg(test)] #[async_trait::async_trait] impl crate::ComputeDriverFactory for PreflightTestFactory { fn validate_config( &self, _context: crate::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { Ok(()) } fn supports_config_preflight(&self) -> bool { true } async fn build( &self, _context: crate::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { unreachable!("config preflight tests must not build drivers") } } #[cfg(test)] fn detect_preflight_test_driver() -> bool { true } #[cfg(test)] fn run_config_preflight( args: ConfigPreflightArgs, run: RunArgs, matches: &ArgMatches, ) -> Result<()> { let mut registry = ComputeDriverRegistry::new(); registry.install(crate::ComputeDriverRegistration::new( "test", 100, Some(detect_preflight_test_driver), PreflightTestFactory, )?)?; run_config_preflight_with_drivers(args, run, matches, ®istry) } fn run_config_preflight_with_drivers( args: ConfigPreflightArgs, run: RunArgs, matches: &ArgMatches, compute_drivers: &ComputeDriverRegistry, ) -> Result<()> { if args.gateway_args.is_empty() { return run_effective_config_preflight(args.path, run, matches, compute_drivers); } let replay_matches = match command().try_get_matches_from( std::iter::once(OsString::from("openshell-gateway")).chain(args.gateway_args), ) { Ok(matches) => matches, Err(error) if matches!( error.kind(), clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion ) => { return Ok(()); } Err(error) => return Err(miette::miette!("{error}")), }; let replay = Cli::from_arg_matches(&replay_matches).map_err(|error| miette::miette!("{error}"))?; if replay.command.is_some() { // A valid non-daemon action does not consume gateway startup // configuration. Let the immediately following invocation perform it. return Ok(()); } run_effective_config_preflight(None, replay.run, &replay_matches, compute_drivers) } fn run_effective_config_preflight( path_override: Option, mut run: RunArgs, matches: &ArgMatches, compute_drivers: &ComputeDriverRegistry, ) -> Result<()> { let path = if path_override.is_some() { path_override } else { resolve_config_path(&run)? }; let file = path .as_ref() .map(|path| config_file::preflight(path).map_err(|error| miette::miette!("{error}"))) .transpose()?; if let Some(file) = file.as_ref() { merge_file_into_args(&mut run, &file.openshell.gateway, matches); } let validation = (|| { // These argument relationships are shared with daemon startup and // remain transport-free. In particular, the deprecated selector must // fail here exactly when the immediately following daemon invocation // would fail. resolve_legacy_driver_selector_env(&mut run)?; normalize_compute_driver_socket_args(&mut run)?; let selection = run .compute_driver .as_deref() .map(|driver| compute_drivers.select(Some(driver))) .transpose() .map_err(|error| miette::miette!("{error}"))?; let selected_registration = selection .as_ref() .and_then(|selection| compute_drivers.get(selection.name())); let empty_file = ConfigFile::default(); let semantic_file = file.as_ref().unwrap_or(&empty_file); validate_preflight_semantics(&run, matches, semantic_file, selected_registration)?; let mut endpoint_overrides = BTreeMap::new(); if let Some(selection) = selection.as_ref() && let Some(socket) = run.compute_driver_socket.clone() { endpoint_overrides.insert(selection.name().to_string(), socket); } let driver_startup = crate::compute::driver_config::DriverStartupContext { file: file.as_ref(), guest_tls: None, gateway_port: run.port, gateway_tls_enabled: !run.disable_tls, endpoint_overrides: &endpoint_overrides, }; if let Some(selection) = selection.as_ref() { crate::validate_compute_driver_config( compute_drivers, selection.name(), run.name.trim(), SocketAddr::new(run.bind_address, run.port), &run.log_level, driver_startup, true, )?; } else if file.is_some() { // Runtime auto-detection may connect local API sockets or launch a // bounded discovery command. Preflight must not perform those // side effects, so validate every configured table that could be // auto-selected. This fails closed without deserializing unrelated // opt-in or remote driver tables. for driver_name in compute_drivers.auto_detectable_driver_names() { if !semantic_file.openshell.drivers.contains_key(driver_name) { continue; } crate::validate_compute_driver_config( compute_drivers, driver_name, run.name.trim(), SocketAddr::new(run.bind_address, run.port), &run.log_level, driver_startup, true, )?; } } Ok(()) })(); match (validation, path.as_ref()) { (Ok(()), _) => Ok(()), (Err(_), Some(path)) => Err(miette::miette!( "{}", config_file::ConfigPreflightError::invalid_current(path) )), (Err(error), None) => Err(error), } } fn validate_preflight_semantics( args: &RunArgs, matches: &ArgMatches, file: &ConfigFile, selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> Result<()> { let gateway = &file.openshell.gateway; validate_grpc_rate_limit_args( args.grpc_rate_limit_requests, args.grpc_rate_limit_window_seconds, )?; GuestTlsPaths::validate_configuration(Some(gateway), args.disable_tls) .map_err(|error| miette::miette!("invalid gateway guest TLS configuration: {error}"))?; let has_client_ca = args.tls_client_ca.is_some(); let mtls_auth_enabled = resolve_mtls_auth_enabled(args, matches, Some(file), selected_registration); if args.disable_tls && has_client_ca { return Err(miette::miette!( "--disable-tls and --tls-client-ca are mutually exclusive" )); } if mtls_auth_enabled && args.disable_tls { return Err(miette::miette!("mTLS user authentication requires TLS")); } if mtls_auth_enabled && !has_client_ca { return Err(miette::miette!( "mTLS user authentication requires --tls-client-ca" )); } if mtls_auth_enabled && selected_registration.is_some_and(|registration| !registration.supports_mtls_user_auth()) { return Err(miette::miette!( "mTLS user authentication is not supported with the selected compute driver" )); } if !args.disable_tls && args.tls_cert.is_some() != args.tls_key.is_some() { return Err(miette::miette!( "gateway TLS requires both --tls-cert and --tls-key" )); } if !args.disable_tls && has_client_ca && args.tls_cert.is_none() && args.tls_key.is_none() { return Err(miette::miette!( "an explicit --tls-client-ca requires --tls-cert and --tls-key" )); } if !args.disable_tls && let Some(tls) = gateway.tls.as_ref() { crate::tls::validate_external_cert_config( tls.external_cert_path.as_deref(), tls.external_key_path.as_deref(), &tls.external_server_names, ) .map_err(|error| miette::miette!("{error}"))?; } openshell_gateway_interceptors::validate_configs(&gateway.interceptors) .map_err(|error| miette::miette!("{error}"))?; let mut middleware_names = std::collections::HashSet::new(); for middleware in &file.openshell.supervisor.middleware { let registration = openshell_core::proto::SupervisorMiddlewareService::try_from(middleware) .map_err(|error| miette::miette!("{error}"))?; openshell_supervisor_middleware::validate_registration_config(®istration)?; if !middleware_names.insert(registration.name) { return Err(miette::miette!( "duplicate supervisor middleware registration" )); } } if args.name.trim().is_empty() { return Err(miette::miette!("gateway name must not be empty")); } let health_bind = resolve_aux_listener( args.bind_address, args.health_port, matches, "health_port", || gateway.health_bind_address, ); let metrics_bind = resolve_aux_listener( args.bind_address, args.metrics_port, matches, "metrics_port", || gateway.metrics_bind_address, ); if health_bind.is_some_and(|address| address.port() == args.port) || metrics_bind.is_some_and(|address| address.port() == args.port) || health_bind .zip(metrics_bind) .is_some_and(|(health, metrics)| health.port() == metrics.port()) { return Err(miette::miette!("gateway listener ports must be distinct")); } Ok(()) } fn resolve_config_path(args: &RunArgs) -> Result> { if let Some(path) = args.config.clone() { return Ok(Some(path)); } let default_path = defaults::default_gateway_config_path()?; match std::fs::symlink_metadata(&default_path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), // Fail closed when the path exists or cannot be inspected. Returning it // lets the shared loader produce the same safe, path-specific error used // for an explicit configuration instead of treating it as absent. Ok(_) | Err(_) => Ok(Some(default_path)), } } fn apply_runtime_defaults(args: &mut RunArgs) -> Result> { let local_tls = if args.disable_tls { None } else { defaults::complete_local_tls_paths()? }; if args.db_url.is_none() { args.db_url = Some(defaults::default_database_url()?); } if !args.disable_tls && args.tls_cert.is_none() && args.tls_key.is_none() && args.tls_client_ca.is_none() && let Some(paths) = &local_tls { args.tls_cert = Some(paths.server_cert.clone()); args.tls_key = Some(paths.server_key.clone()); args.tls_client_ca = Some(paths.ca.clone()); } Ok(local_tls) } /// Returns `true` when an argument's value came from clap's built-in default /// (or was never supplied at all). When the predicate is `true`, the loader /// is free to replace the value with one read from the TOML config file. fn arg_defaulted(matches: &ArgMatches, id: &str) -> bool { matches!( matches.value_source(id), None | Some(ValueSource::DefaultValue) ) } /// Resolve the bind address for an auxiliary listener (health / metrics). /// /// The precedence is: /// 1. CLI flag or `OPENSHELL_*` env var explicitly set on the corresponding /// port argument → `bind_address:port` (port from CLI, IP from the main /// listener interface). /// 2. Full `SocketAddr` from `[openshell.gateway].{health,metrics}_bind_address` /// → used as-is (this is how operators pin a loopback-only health port /// on a gateway whose gRPC listener is bound publicly). /// 3. Otherwise the listener is disabled (returns `None`). fn resolve_aux_listener( bind_ip: IpAddr, port_arg: u16, matches: &ArgMatches, port_id: &str, file_addr: impl FnOnce() -> Option, ) -> Option { if !arg_defaulted(matches, port_id) { if port_arg == 0 { return None; } return Some(SocketAddr::new(bind_ip, port_arg)); } if let Some(addr) = file_addr() { return Some(addr); } if port_arg == 0 { None } else { Some(SocketAddr::new(bind_ip, port_arg)) } } /// Apply gateway-wide values from `[openshell.gateway]` onto `RunArgs` for /// every argument that is still sourced from clap's built-in default. /// /// The function intentionally does not touch `database_url` — that secret is /// env-only and the loader already rejected it when it appears in the file. fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: &ArgMatches) { if let Some(name) = &file.name && arg_defaulted(matches, "name") { args.name.clone_from(name); } if let Some(addr) = file.bind_address { if arg_defaulted(matches, "bind_address") { args.bind_address = addr.ip(); } if arg_defaulted(matches, "port") { args.port = addr.port(); } } // Note: file's full health_bind_address / metrics_bind_address are // consumed in `run_from_args`'s listener-resolution block so the IP // half of the SocketAddr is preserved. Copying only the port here // would silently relocate a loopback-intended listener onto the // public bind address. if let Some(level) = &file.log_level && arg_defaulted(matches, "log_level") { args.log_level.clone_from(level); } if let Some(driver) = &file.compute_driver && arg_defaulted(matches, "compute_driver") { args.compute_driver = Some(driver.clone()); } if let Some(sans) = &file.server_sans && args.server_sans.is_empty() && arg_defaulted(matches, "server_sans") { args.server_sans.clone_from(sans); } if let Some(enabled) = file.enable_loopback_service_http && arg_defaulted(matches, "enable_loopback_service_http") { args.enable_loopback_service_http = enabled; } if let Some(enabled) = file.enable_websocket_tunnel && arg_defaulted(matches, "enable_websocket_tunnel") { args.enable_websocket_tunnel = enabled; } if let Some(mtls_auth) = &file.mtls_auth && arg_defaulted(matches, "enable_mtls_auth") { args.enable_mtls_auth = mtls_auth.enabled; } if let Some(disabled) = file.disable_tls && arg_defaulted(matches, "disable_tls") { args.disable_tls = disabled; } // TLS gateway listener fields if let Some(tls) = &file.tls { if args.tls_cert.is_none() && arg_defaulted(matches, "tls_cert") { args.tls_cert = Some(tls.cert_path.clone()); } if args.tls_key.is_none() && arg_defaulted(matches, "tls_key") { args.tls_key = Some(tls.key_path.clone()); } if args.tls_client_ca.is_none() && arg_defaulted(matches, "tls_client_ca") { args.tls_client_ca.clone_from(&tls.client_ca_path); } } // OIDC fields if let Some(oidc) = &file.oidc { if args.oidc_issuer.is_none() && arg_defaulted(matches, "oidc_issuer") { args.oidc_issuer = Some(oidc.issuer.clone()); } if arg_defaulted(matches, "oidc_dangerously_allow_insecure_http") { args.oidc_dangerously_allow_insecure_http = oidc.dangerously_allow_insecure_http; } if args.oidc_jwks_allowed_origins.is_empty() && arg_defaulted(matches, "oidc_jwks_allowed_origins") { args.oidc_jwks_allowed_origins .clone_from(&oidc.jwks_allowed_origins); } if arg_defaulted(matches, "oidc_audience") { args.oidc_audience.clone_from(&oidc.audience); } if arg_defaulted(matches, "oidc_jwks_ttl") { args.oidc_jwks_ttl = oidc.jwks_ttl_secs; } if arg_defaulted(matches, "oidc_roles_claim") { args.oidc_roles_claim.clone_from(&oidc.roles_claim); } if arg_defaulted(matches, "oidc_admin_role") { args.oidc_admin_role.clone_from(&oidc.admin_role); } if arg_defaulted(matches, "oidc_user_role") { args.oidc_user_role.clone_from(&oidc.user_role); } if arg_defaulted(matches, "oidc_scopes_claim") { args.oidc_scopes_claim.clone_from(&oidc.scopes_claim); } } if let Some(requests) = file.grpc_rate_limit_requests && args.grpc_rate_limit_requests.is_none() && arg_defaulted(matches, "grpc_rate_limit_requests") { args.grpc_rate_limit_requests = Some(requests); } if let Some(window) = file.grpc_rate_limit_window_seconds && args.grpc_rate_limit_window_seconds.is_none() && arg_defaulted(matches, "grpc_rate_limit_window_seconds") { args.grpc_rate_limit_window_seconds = Some(window); } } fn validate_grpc_rate_limit_args(requests: Option, window_seconds: Option) -> Result<()> { let disabled = matches!(requests, Some(0)) || matches!(window_seconds, Some(0)); if disabled { return Ok(()); } if matches!( (requests, window_seconds), (Some(requests), None) if requests > 0 ) || matches!( (requests, window_seconds), (None, Some(window_seconds)) if window_seconds > 0 ) { return Err(miette::miette!( "gRPC rate limiting requires both --grpc-rate-limit-requests and --grpc-rate-limit-window-seconds (TOML keys grpc_rate_limit_requests and grpc_rate_limit_window_seconds) to be positive; set either value to 0 to disable" )); } Ok(()) } fn normalize_compute_driver_socket_args(args: &mut RunArgs) -> Result<()> { let Some(socket) = args.compute_driver_socket.as_ref() else { return Ok(()); }; if socket.as_os_str().is_empty() { return Err(miette::miette!( "--compute-driver-socket must not be an empty path" )); } if args.compute_driver.is_none() { return Err(miette::miette!( "--compute-driver-socket requires --compute-driver or OPENSHELL_COMPUTE_DRIVER=" )); } let driver = args .compute_driver .as_deref() .expect("explicit compute driver is required for socket endpoints"); args.compute_driver = Some( openshell_core::config::normalize_compute_driver_name(driver) .map_err(|err| miette::miette!("{err}"))?, ); Ok(()) } fn is_singleplayer_driver(registration: Option<&crate::ComputeDriverRegistration>) -> bool { registration.is_some_and(crate::ComputeDriverRegistration::is_local_singleplayer) } fn resolve_mtls_auth_enabled( args: &RunArgs, matches: &ArgMatches, file: Option<&ConfigFile>, selected_registration: Option<&crate::ComputeDriverRegistration>, ) -> bool { let file_configured = file .and_then(|f| f.openshell.gateway.mtls_auth.as_ref()) .is_some(); if file_configured || !arg_defaulted(matches, "enable_mtls_auth") { return args.enable_mtls_auth; } if args.disable_tls || args.tls_client_ca.is_none() || args.oidc_issuer.is_some() { return false; } is_singleplayer_driver(selected_registration) } #[cfg(test)] mod tests { use super::{Cli, command}; use crate::TEST_ENV_LOCK as ENV_LOCK; use clap::Parser; use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicUsize, Ordering}; static REGISTRY_DETECTION_CALLS: AtomicUsize = AtomicUsize::new(0); static REJECTING_VALIDATION_CALLS: AtomicUsize = AtomicUsize::new(0); fn detect_registered_local() -> bool { REGISTRY_DETECTION_CALLS.fetch_add(1, Ordering::SeqCst); true } #[derive(Clone, Copy)] struct TestFactory; #[async_trait::async_trait] impl crate::ComputeDriverFactory for TestFactory { fn validate_config( &self, _context: crate::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { Ok(()) } fn supports_config_preflight(&self) -> bool { true } async fn build( &self, _context: crate::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { unreachable!("CLI metadata tests do not build drivers") } } #[derive(Clone, Copy)] struct LegacyFactory; // Deliberately implements only the pre-preflight factory contract. #[async_trait::async_trait] impl crate::ComputeDriverFactory for LegacyFactory { async fn build( &self, _context: crate::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { unreachable!("preflight must not build a legacy driver") } } #[derive(Clone, Copy)] struct RejectingValidationFactory; #[async_trait::async_trait] impl crate::ComputeDriverFactory for RejectingValidationFactory { fn validate_config( &self, _context: crate::ComputeDriverConfigContext<'_>, ) -> openshell_core::Result<()> { REJECTING_VALIDATION_CALLS.fetch_add(1, Ordering::SeqCst); Err(openshell_core::Error::config( "selected driver validation hook invoked", )) } fn supports_config_preflight(&self) -> bool { true } async fn build( &self, _context: crate::ComputeDriverBuildContext<'_>, ) -> openshell_core::Result { unreachable!("preflight must not build the selected driver") } } fn test_registry(name: &str, singleplayer: bool, mtls: bool) -> crate::ComputeDriverRegistry { let mut registration = crate::ComputeDriverRegistration::new(name, 100, None, TestFactory).unwrap(); if singleplayer { registration = registration.with_local_singleplayer(); } if !mtls { registration = registration.without_mtls_user_auth(); } let mut registry = crate::ComputeDriverRegistry::new(); registry.install(registration).unwrap(); registry } fn detected_local_registry() -> crate::ComputeDriverRegistry { let registration = crate::ComputeDriverRegistration::new( "local", 100, Some(detect_registered_local), TestFactory, ) .unwrap() .with_local_singleplayer(); let mut registry = crate::ComputeDriverRegistry::new(); registry.install(registration).unwrap(); registry } struct EnvVarGuard { key: &'static str, original: Option, } impl EnvVarGuard { #[allow(unsafe_code)] fn set(key: &'static str, value: &str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::set_var(key, value) }; Self { key, original } } #[allow(unsafe_code)] fn remove(key: &'static str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::remove_var(key) }; Self { key, original } } } impl Drop for EnvVarGuard { #[allow(unsafe_code)] fn drop(&mut self) { match self.original.as_deref() { // SAFETY: tests serialize environment mutation with ENV_LOCK. Some(value) => unsafe { std::env::set_var(self.key, value) }, // SAFETY: tests serialize environment mutation with ENV_LOCK. None => unsafe { std::env::remove_var(self.key) }, } } } #[test] fn command_uses_gateway_binary_name() { let mut help = Vec::new(); command().write_long_help(&mut help).unwrap(); let help = String::from_utf8(help).unwrap(); assert!(help.contains("openshell-gateway")); } #[test] fn command_exposes_version() { let cmd = command(); let version = cmd.get_version().unwrap(); assert_eq!(version.to_string(), openshell_core::VERSION); } #[test] fn command_defaults_bind_address_to_loopback() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_parses_bind_address() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--bind-address", "127.0.0.1", ]) .unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_reads_bind_address_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_BIND_ADDRESS", "0.0.0.0"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) .expect("env should provide bind address"); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); } #[test] fn command_enables_loopback_service_http_by_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(cli.run.enable_loopback_service_http); } #[test] fn websocket_tunnel_is_disabled_by_default_and_can_be_enabled_from_file() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); assert!(!args.enable_websocket_tunnel); let file = config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n"); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(args.enable_websocket_tunnel); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--enable-websocket-tunnel=false", ]); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(!args.enable_websocket_tunnel, "CLI flag must override file"); } #[test] fn command_disables_loopback_service_http_with_false_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--enable-loopback-service-http=false", ]) .unwrap(); assert!(!cli.run.enable_loopback_service_http); } #[test] fn command_reads_loopback_service_http_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "false"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(!cli.run.enable_loopback_service_http); } #[test] fn command_parses_oidc_insecure_http_acknowledgement_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--oidc-dangerously-allow-insecure-http", "true", ]) .expect("launcher-style boolean flag and value should parse"); assert!(cli.run.oidc_dangerously_allow_insecure_http); } #[test] fn command_reads_server_san_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_SERVER_SAN", "*.apps.example.com"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert_eq!(cli.run.server_sans, vec!["*.apps.example.com".to_string()]); } #[test] fn command_reads_mtls_auth_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_MTLS_AUTH", "true"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert!(cli.run.enable_mtls_auth); } #[test] fn command_parses_grpc_rate_limit_flags() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS"); let _g2 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--grpc-rate-limit-requests", "120", "--grpc-rate-limit-window-seconds", "60", ]) .unwrap(); assert_eq!(cli.run.grpc_rate_limit_requests, Some(120)); assert_eq!(cli.run.grpc_rate_limit_window_seconds, Some(60)); } #[test] fn validate_grpc_rate_limit_args_requires_positive_pair() { assert!(super::validate_grpc_rate_limit_args(None, None).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(0), None).is_ok()); assert!(super::validate_grpc_rate_limit_args(None, Some(0)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(0), Some(60)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), Some(0)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), Some(60)).is_ok()); assert!(super::validate_grpc_rate_limit_args(Some(120), None).is_err()); assert!(super::validate_grpc_rate_limit_args(None, Some(60)).is_err()); } #[test] fn command_rejects_removed_driver_flags() { let err = command() .try_get_matches_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--sandbox-image", "example/sandbox:latest", ]) .expect_err("driver implementation flags should not be accepted"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn command_rejects_legacy_compute_driver_flags() { for flag in ["--driver", "--drivers"] { let err = command() .try_get_matches_from([ "openshell-gateway", "--db-url", "sqlite::memory:", flag, "docker", ]) .expect_err("legacy compute driver selector must be rejected"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } } #[test] fn legacy_compute_driver_environment_accepts_one_normalized_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", " PodMan "); let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); assert_eq!(args.compute_driver.as_deref(), Some("podman")); } #[test] fn legacy_compute_driver_environment_flows_through_server_preparation() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config_path = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--disable-tls", ]); let registry = test_registry("podman", true, true); let prepared = super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); assert_eq!(prepared.compute_driver.name(), "podman"); assert!(prepared.legacy_compute_driver_env_seen); } #[test] fn legacy_compute_driver_environment_rejects_empty_plural_and_invalid_values() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); for value in ["", " ", ",", "podman,", ",podman", "podman,docker"] { let legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", value); let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let error = super::resolve_legacy_driver_selector_env(&mut args) .expect_err("empty and plural legacy selectors must be rejected"); assert!(error.to_string().contains("exactly one non-empty")); drop(legacy); } let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman/path"); let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let error = super::resolve_legacy_driver_selector_env(&mut args) .expect_err("invalid legacy selector must be rejected"); assert!(error.to_string().contains("invalid compute driver name")); assert!(!error.to_string().contains("podman/path")); } #[test] fn legacy_compute_driver_environment_allows_equal_canonical_and_rejects_conflict() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "PODMAN"); let (mut equal, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "podman", ]); assert!(super::resolve_legacy_driver_selector_env(&mut equal).unwrap()); assert_eq!(equal.compute_driver.as_deref(), Some("podman")); let (mut conflict, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "docker", ]); let error = super::resolve_legacy_driver_selector_env(&mut conflict) .expect_err("different canonical and legacy selectors must conflict"); assert!(error.to_string().contains("conflicts")); assert!(!error.to_string().contains("podman")); assert!(!error.to_string().contains("docker")); } #[test] fn legacy_compute_driver_environment_supports_remote_driver_socket() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "kyma"); let _socket = EnvVarGuard::set( "OPENSHELL_COMPUTE_DRIVER_SOCKET", "/run/openshell/kyma.sock", ); let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); super::normalize_compute_driver_socket_args(&mut args).unwrap(); assert_eq!(args.compute_driver.as_deref(), Some("kyma")); assert_eq!( args.compute_driver_socket.as_deref(), Some(std::path::Path::new("/run/openshell/kyma.sock")) ); } #[test] fn command_rejects_removed_ssh_endpoint_flags() { for flag in [ "--ssh-gateway-host", "--ssh-gateway-port", "--sandbox-ssh-port", ] { let err = command() .try_get_matches_from([ "openshell-gateway", "--db-url", "sqlite::memory:", flag, "x", ]) .expect_err("SSH endpoint flags should not be accepted"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } } #[test] fn generate_certs_subcommand_parses_without_db_url() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--server-secret-name", "openshell-server-tls", "--client-secret-name", "openshell-client-tls", "--jwt-secret-name", "openshell-jwt-keys", "--server-san", "openshell.example.com", "--server-san", "10.0.0.1", ]) .expect("generate-certs should parse without --db-url"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn generate_certs_local_mode_parses_without_kube_flags() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--output-dir", "/tmp/openshell-certgen", ]) .expect("--output-dir should make namespace/secret-name flags optional"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn generate_certs_jwt_only_parses_without_tls_secret_names() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--jwt-only", "--jwt-secret-name", "openshell-jwt-keys", ]) .expect("--jwt-only should make TLS secret-name flags optional"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn config_preflight_subcommand_parses_without_runtime_requirements() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _db = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let cli = Cli::try_parse_from([ "openshell-gateway", "config", "preflight", "--path", "/tmp/gateway.toml", ]) .expect("config preflight should parse without runtime arguments"); assert!(matches!( cli.command, Some(super::Commands::Config(super::ConfigArgs { command: super::ConfigCommand::Preflight(_) })) )); } #[test] fn config_preflight_path_and_daemon_replay_are_mutually_exclusive() { let error = command() .try_get_matches_from([ "openshell-gateway", "config", "preflight", "--path", "/tmp/gateway.toml", "--", "--disable-tls", ]) .expect_err("manual path and daemon replay must be mutually exclusive"); assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } #[test] fn config_preflight_replay_validates_effective_daemon_flags() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let _requests = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS"); let _window = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS"); let (run, matches) = parse_with_args(&["openshell-gateway"]); let error = super::run_config_preflight( super::ConfigPreflightArgs { gateway_args: ["--grpc-rate-limit-requests", "10"] .map(std::ffi::OsString::from) .to_vec(), ..Default::default() }, run.clone(), &matches, ) .expect_err("unpaired replayed rate limit must fail preflight"); assert!(error.to_string().contains("requires both")); super::run_config_preflight( super::ConfigPreflightArgs { gateway_args: [ "--grpc-rate-limit-requests", "10", "--grpc-rate-limit-window-seconds", "60", ] .map(std::ffi::OsString::from) .to_vec(), ..Default::default() }, run, &matches, ) .expect("paired replayed rate limit must pass preflight"); } #[test] fn config_preflight_matches_driver_selector_and_registry_semantics() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman,docker"); let (run, matches) = parse_with_args(&["openshell-gateway"]); let registry = test_registry("podman", true, true); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), run, &matches, ®istry, ) .expect_err("plural legacy selector must fail preflight as it fails startup"); assert!(error.to_string().contains("exactly one non-empty")); } #[test] fn config_preflight_validates_selected_driver_without_building_it() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&[ "openshell-gateway", "--compute-driver", "local", "--disable-tls", ]); let mut registry = crate::ComputeDriverRegistry::new(); registry .install( crate::ComputeDriverRegistration::new( "local", 100, None, RejectingValidationFactory, ) .unwrap(), ) .unwrap(); REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), run, &matches, ®istry, ) .expect_err("selected driver validation hook must run"); assert!(error.to_string().contains("validation hook invoked")); assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); } #[test] fn config_preflight_rejects_factory_without_side_effect_free_validation() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&[ "openshell-gateway", "--compute-driver", "legacy", "--disable-tls", ]); let mut registry = crate::ComputeDriverRegistry::new(); registry .install( crate::ComputeDriverRegistration::new("legacy", 100, None, LegacyFactory).unwrap(), ) .unwrap(); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), run, &matches, ®istry, ) .expect_err("unsupported source-free preflight must fail closed"); assert!( error .to_string() .contains("does not support side-effect-free") ); } #[test] fn config_preflight_validates_configured_auto_detectable_driver_without_probing() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let config = config_home.path().join("gateway.toml"); std::fs::write( &config, "[openshell]\nversion = 2\n[openshell.gateway]\ndisable_tls = true\n[openshell.drivers.local]\n", ) .unwrap(); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&["openshell-gateway"]); let mut registry = crate::ComputeDriverRegistry::new(); registry .install( crate::ComputeDriverRegistration::new( "local", 100, Some(detect_registered_local), RejectingValidationFactory, ) .unwrap(), ) .unwrap(); REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs { path: Some(config), ..Default::default() }, run, &matches, ®istry, ) .expect_err("auto-detectable driver table validation hook must run"); assert!(error.to_string().contains("category=malformed")); assert!(!error.to_string().contains("validation hook invoked")); assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); } #[test] fn config_preflight_auto_validation_skips_absent_and_opt_in_driver_tables() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let config = config_home.path().join("gateway.toml"); std::fs::write( &config, "[openshell]\nversion = 2\n[openshell.gateway]\ndisable_tls = true\n[openshell.drivers.opt-in]\n", ) .unwrap(); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&["openshell-gateway"]); let mut registry = crate::ComputeDriverRegistry::new(); for registration in [ crate::ComputeDriverRegistration::new( "local", 100, Some(detect_registered_local), RejectingValidationFactory, ) .unwrap(), crate::ComputeDriverRegistration::new("opt-in", 200, None, RejectingValidationFactory) .unwrap(), ] { registry.install(registration).unwrap(); } REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); super::run_config_preflight_with_drivers( super::ConfigPreflightArgs { path: Some(config), ..Default::default() }, run, &matches, ®istry, ) .expect("unconfigured auto-detectable and configured opt-in drivers must be skipped"); assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 0); } #[test] fn config_preflight_applies_selected_driver_mtls_capability() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&[ "openshell-gateway", "--compute-driver", "shared", "--tls-cert", "/tls/server.pem", "--tls-key", "/tls/server-key.pem", "--tls-client-ca", "/tls/ca.pem", "--enable-mtls-auth", "true", ]); let registry = test_registry("shared", false, false); let error = super::run_config_preflight_with_drivers( super::ConfigPreflightArgs::default(), run, &matches, ®istry, ) .expect_err("selected shared driver must reject mTLS user authentication"); assert!(error.to_string().contains("not supported")); } #[test] fn config_preflight_validates_explicit_remote_driver_endpoint() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let (run, matches) = parse_with_args(&[ "openshell-gateway", "--compute-driver", "remote", "--disable-tls", ]); let error = super::run_config_preflight(super::ConfigPreflightArgs::default(), run, &matches) .expect_err("remote driver without socket_path must fail preflight"); assert!(error.to_string().contains("requires socket_path")); } #[test] fn config_preflight_validates_explicit_path_without_creating_state() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let state_parent = tempfile::tempdir().unwrap(); let state_home = state_parent.path().join("not-created"); let config = config_home.path().join("gateway.toml"); std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _state = EnvVarGuard::set("XDG_STATE_HOME", state_home.to_str().unwrap()); let (run, matches) = parse_with_args(&["openshell-gateway"]); super::run_config_preflight( super::ConfigPreflightArgs { path: Some(config), ..Default::default() }, run, &matches, ) .expect("valid explicit config passes preflight"); assert!( !state_home.exists(), "preflight must not create runtime state" ); } #[test] fn config_preflight_explicit_path_overrides_environment_selection() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join("legacy.toml"); let current = dir.path().join("current.toml"); std::fs::write(&legacy, "[openshell]\nversion = 1\n").unwrap(); std::fs::write(¤t, "[openshell]\nversion = 2\n").unwrap(); let _config_env = EnvVarGuard::set("OPENSHELL_GATEWAY_CONFIG", legacy.to_str().unwrap()); let (run, matches) = parse_with_args(&["openshell-gateway"]); let error = super::run_config_preflight( super::ConfigPreflightArgs::default(), run.clone(), &matches, ) .expect_err("environment-selected legacy config must fail"); assert!(error.to_string().contains("category=legacy_schema_v1")); super::run_config_preflight( super::ConfigPreflightArgs { path: Some(current), ..Default::default() }, run, &matches, ) .expect("explicit preflight path must override environment selection"); } #[test] fn config_preflight_allows_absent_auto_discovery_but_rejects_explicit_absence() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _config_home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let (run, matches) = parse_with_args(&["openshell-gateway"]); super::run_config_preflight(super::ConfigPreflightArgs::default(), run.clone(), &matches) .expect("absent auto-discovered config is optional"); let missing = config_home.path().join("missing.toml"); let error = super::run_config_preflight( super::ConfigPreflightArgs { path: Some(missing.clone()), ..Default::default() }, run, &matches, ) .expect_err("explicit missing config must fail"); assert!(error.to_string().contains("category=missing_path")); assert!(error.to_string().contains(&missing.display().to_string())); } #[test] fn config_preflight_rejects_effective_semantic_errors() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let dir = tempfile::tempdir().unwrap(); let cases = [ ( "driver-selector", "[openshell]\nversion = 2\n[openshell.gateway]\ncompute_driver = 'secret-driver-marker'\ndisable_tls = true\n", ), ( "rate-limit", "[openshell]\nversion = 2\n[openshell.gateway]\nname = 'secret-semantic-marker'\ngrpc_rate_limit_requests = 10\n", ), ( "guest-tls", "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/tls/ca.pem'\n", ), ( "external-tls", "[openshell]\nversion = 2\n[openshell.gateway.tls]\ncert_path = '/tls/server.pem'\nkey_path = '/tls/server-key.pem'\nexternal_cert_path = '/tls/external.pem'\nexternal_server_names = ['external.example']\n", ), ( "interceptor", "[openshell]\nversion = 2\n[[openshell.gateway.interceptors]]\nname = ''\ngrpc_endpoint = 'https://interceptor.example'\n", ), ( "middleware", "[openshell]\nversion = 2\n[[openshell.supervisor.middleware]]\nname = 'guard'\ngrpc_endpoint = 'http://127.0.0.1:50051'\nallow_insecure_transport = true\nmax_payload_bytes = 1024\ntimeout = 'invalid'\n", ), ]; for (name, contents) in cases { let path = dir.path().join(format!("{name}.toml")); std::fs::write(&path, contents).unwrap(); let before = std::fs::read(&path).unwrap(); let (run, matches) = parse_with_args(&["openshell-gateway"]); let result = super::run_config_preflight( super::ConfigPreflightArgs { path: Some(path.clone()), ..Default::default() }, run, &matches, ); let Err(error) = result else { panic!("{name}: invalid effective configuration passed preflight"); }; assert!(error.to_string().contains("category=malformed"), "{name}"); assert!(error.to_string().contains("detected_version=2"), "{name}"); assert!(!error.to_string().contains("secret-")); assert!(!format!("{error:?}").contains("secret-")); assert_eq!(std::fs::read(&path).unwrap(), before, "{name}"); } } #[test] fn config_preflight_matches_effective_tls_environment_semantics() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let dir = tempfile::tempdir().unwrap(); let partial_external = dir.path().join("partial-external.toml"); std::fs::write( &partial_external, "[openshell]\nversion = 2\n[openshell.gateway.tls]\ncert_path = '/tls/server.pem'\nkey_path = '/tls/server-key.pem'\nexternal_cert_path = '/tls/external.pem'\nexternal_server_names = ['external.example']\n", ) .unwrap(); let disable_tls = EnvVarGuard::set("OPENSHELL_DISABLE_TLS", "true"); let (run, matches) = parse_with_args(&["openshell-gateway"]); super::run_config_preflight( super::ConfigPreflightArgs { path: Some(partial_external), ..Default::default() }, run, &matches, ) .expect("inactive TLS table must not block an effective plaintext gateway"); drop(disable_tls); let config = dir.path().join("client-ca-only.toml"); std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); let _disable_tls = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); let _client_ca = EnvVarGuard::set("OPENSHELL_TLS_CLIENT_CA", "/tls/ca.pem"); let _cert = EnvVarGuard::remove("OPENSHELL_TLS_CERT"); let _key = EnvVarGuard::remove("OPENSHELL_TLS_KEY"); let (run, matches) = parse_with_args(&["openshell-gateway"]); let error = super::run_config_preflight( super::ConfigPreflightArgs { path: Some(config), ..Default::default() }, run, &matches, ) .expect_err("client CA without an explicit server pair must fail before cert generation"); assert!(error.to_string().contains("category=malformed")); } #[test] fn config_preflight_allows_complete_future_generated_tls_paths() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("gateway.toml"); std::fs::write( &path, "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/future/ca.pem'\nguest_tls_cert = '/future/client.pem'\nguest_tls_key = '/future/client-key.pem'\n", ) .unwrap(); let (run, matches) = parse_with_args(&["openshell-gateway"]); super::run_config_preflight( super::ConfigPreflightArgs { path: Some(path), ..Default::default() }, run, &matches, ) .expect("complete package-generated TLS paths may not exist before certificate generation"); } #[test] fn generate_certs_backend_ca_configmap_flags_parse() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let cli = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--jwt-only", "--jwt-secret-name", "openshell-jwt-keys", "--backend-ca-configmap-name", "openshell-backend-ca", "--backend-ca-source-secret", "openshell-server-tls", ]) .expect("backend CA ConfigMap flags should parse with --jwt-only"); assert!(matches!( cli.command, Some(super::Commands::GenerateCerts(_)) )); } #[test] fn generate_certs_backend_ca_source_secret_requires_configmap_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); let err = Cli::try_parse_from([ "openshell-gateway", "generate-certs", "--namespace", "openshell", "--jwt-only", "--jwt-secret-name", "openshell-jwt-keys", "--backend-ca-source-secret", "openshell-server-tls", ]) .expect_err("--backend-ca-source-secret should require --backend-ca-configmap-name"); assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); } #[test] fn bare_invocation_with_no_db_url_parses_for_runtime_defaults() { // db_url is Option at the clap level so subcommand parsing // does not require it. The Run path fills a default URL from XDG // state when neither CLI nor env supplied one. let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_DB_URL"); let cli = Cli::try_parse_from(["openshell-gateway"]).expect("parses without --db-url"); assert!(cli.command.is_none()); assert!(cli.run.db_url.is_none()); } // ── Config-file merge tests ────────────────────────────────────────── // // `merge_file_into_args` is the bridge between `config_file::ConfigFile` // and `RunArgs`. These cases lock in the precedence rule: // // CLI flag > OPENSHELL_* env var > TOML file > built-in default // // by exercising each combination on representative gateway fields. use super::{ConfigFile, merge_file_into_args}; use clap::FromArgMatches; fn parse_with_args(argv: &[&str]) -> (super::RunArgs, clap::ArgMatches) { let matches = command().try_get_matches_from(argv).expect("parses"); let cli = Cli::from_arg_matches(&matches).expect("from arg matches"); (cli.run, matches) } fn config_file_from_toml(toml: &str) -> ConfigFile { toml::from_str(toml).expect("valid TOML in test fixture") } #[test] fn rejects_legacy_drivers_flag() { let error = command() .try_get_matches_from(["openshell-gateway", "--drivers", "docker"]) .expect_err("legacy --drivers flag must be rejected"); assert!(error.to_string().contains("--drivers")); } #[test] fn default_config_path_is_loaded_only_when_present() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _g2 = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap()); let (args, _) = parse_with_args(&["openshell-gateway"]); assert_eq!(super::resolve_config_path(&args).unwrap(), None); let config = tmp.path().join("openshell").join("gateway.toml"); std::fs::create_dir_all(config.parent().unwrap()).unwrap(); std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); assert_eq!(super::resolve_config_path(&args).unwrap(), Some(config)); } #[test] fn explicit_config_path_is_returned_even_when_missing() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let (args, _) = parse_with_args(&["openshell-gateway", "--config", "/tmp/missing.toml"]); assert_eq!( super::resolve_config_path(&args).unwrap(), Some(std::path::PathBuf::from("/tmp/missing.toml")) ); } #[test] fn runtime_defaults_populate_database_url_from_xdg_state() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::set("XDG_STATE_HOME", tmp.path().to_str().unwrap()); let (mut args, _) = parse_with_args(&["openshell-gateway", "--disable-tls"]); let local_tls = super::apply_runtime_defaults(&mut args).unwrap(); let expected = format!( "sqlite:{}", tmp.path() .join("openshell") .join("gateway") .join("openshell.db") .display() ); assert!(local_tls.is_none()); assert_eq!(args.db_url.as_deref(), Some(expected.as_str())); assert!(tmp.path().join("openshell").join("gateway").is_dir()); } #[test] fn runtime_defaults_use_complete_local_tls_bundle() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let tls = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); let _g2 = EnvVarGuard::remove("OPENSHELL_TLS_CERT"); let _g3 = EnvVarGuard::remove("OPENSHELL_TLS_KEY"); let _g4 = EnvVarGuard::remove("OPENSHELL_TLS_CLIENT_CA"); let _g5 = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); let _g6 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _g7 = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()); std::fs::create_dir_all(tls.path().join("server")).unwrap(); std::fs::create_dir_all(tls.path().join("client")).unwrap(); for rel in [ "ca.crt", "server/tls.crt", "server/tls.key", "client/tls.crt", "client/tls.key", ] { std::fs::write(tls.path().join(rel), "pem").unwrap(); } let (mut args, _) = parse_with_args(&["openshell-gateway"]); let local_tls = super::apply_runtime_defaults(&mut args) .unwrap() .expect("complete bundle should be returned"); assert_eq!(args.tls_cert, Some(tls.path().join("server/tls.crt"))); assert_eq!(args.tls_key, Some(tls.path().join("server/tls.key"))); assert_eq!(args.tls_client_ca, Some(tls.path().join("ca.crt"))); assert_eq!(local_tls.client_cert, tls.path().join("client/tls.crt")); } #[test] fn tls_client_certificate_requirement_is_derived_from_ca_and_oidc() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); let _config_path = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let registry = test_registry("shared", false, false); for (oidc_issuer, expected) in [(None, true), (Some("https://idp.example.com"), false)] { let mut startup_args = vec![ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "shared", "--tls-cert", "/tls/server.crt", "--tls-key", "/tls/server.key", "--tls-client-ca", "/tls/ca.crt", ]; if let Some(issuer) = oidc_issuer { startup_args.extend(["--oidc-issuer", issuer]); } let (mut args, matches) = parse_with_args(&startup_args); let prepared = super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); assert_eq!( prepared.config.tls.as_ref().unwrap().require_client_auth, expected, "oidc issuer: {oidc_issuer:?}" ); } } #[test] fn mtls_auth_auto_defaults_for_local_tls_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "local", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); assert!(super::resolve_mtls_auth_enabled( &args, &matches, None, test_registry("local", true, true).get("local") )); } #[test] fn registry_detection_drives_auth_defaults_once() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let config = tempfile::tempdir().unwrap(); let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config.path().to_str().unwrap()); let _mtls = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let _drivers = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); let registry = detected_local_registry(); let prepared = super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); assert_eq!(prepared.compute_driver.name(), "local"); assert!(prepared.config.compute_driver.is_none()); assert!(prepared.config.mtls_auth.enabled); assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); } #[test] fn mtls_auth_does_not_auto_default_for_shared_driver() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "shared", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); assert!(!super::resolve_mtls_auth_enabled( &args, &matches, None, test_registry("shared", false, false).get("shared") )); } #[test] fn file_mtls_auth_value_overrides_local_auto_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "local", "--tls-cert", "/tmp/server.crt", "--tls-key", "/tmp/server.key", "--tls-client-ca", "/tmp/ca.crt", ]); let file = config_file_from_toml( r" [openshell.gateway.mtls_auth] enabled = false ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(!super::resolve_mtls_auth_enabled( &args, &matches, Some(&file), test_registry("local", true, true).get("local") )); } #[test] fn file_value_applies_when_cli_uses_default() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_SERVER_PORT"); let _g3 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); let _g4 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] name = "production-us-west" bind_address = "0.0.0.0:9090" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(args.port, 9090); assert_eq!(args.log_level, "debug"); assert_eq!(args.name, "production-us-west"); } #[test] fn cli_flag_overrides_file_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let _g2 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); let _g3 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--log-level", "warn", "--name", "cli-gateway", ]); let file = config_file_from_toml( r#" [openshell.gateway] name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "warn", "CLI flag must win over file"); assert_eq!(args.name, "cli-gateway"); } #[test] fn env_var_overrides_file_value() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_LOG_LEVEL", "trace"); let _g2 = EnvVarGuard::set("OPENSHELL_GATEWAY_NAME", "env-gateway"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] name = "file-gateway" log_level = "debug" "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.log_level, "trace", "env var must win over file"); assert_eq!(args.name, "env-gateway"); } #[test] fn compute_driver_file_value_and_cli_environment_precedence_are_explicit() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let file = config_file_from_toml( r#" [openshell.gateway] compute_driver = "podman" "#, ); let canonical_guard = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let (mut file_args, file_matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); merge_file_into_args(&mut file_args, &file.openshell.gateway, &file_matches); assert_eq!(file_args.compute_driver.as_deref(), Some("podman")); let (mut cli_args, cli_matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "docker", ]); merge_file_into_args(&mut cli_args, &file.openshell.gateway, &cli_matches); assert_eq!(cli_args.compute_driver.as_deref(), Some("docker")); drop(canonical_guard); let _canonical = EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "vm"); let (mut env_args, env_matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); merge_file_into_args(&mut env_args, &file.openshell.gateway, &env_matches); assert_eq!(env_args.compute_driver.as_deref(), Some("vm")); } #[test] fn legacy_compute_driver_environment_conflicts_with_file_selection() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "docker"); let file = config_file_from_toml( r#" [openshell.gateway] compute_driver = "podman" "#, ); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); let error = super::resolve_legacy_driver_selector_env(&mut args) .expect_err("different file and legacy selectors must conflict"); assert!(error.to_string().contains("conflicts")); } #[test] fn file_oidc_block_populates_oidc_args() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_OIDC_ISSUER"); let _g2 = EnvVarGuard::remove("OPENSHELL_OIDC_AUDIENCE"); let _g3 = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP"); let _g4 = EnvVarGuard::remove("OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway.oidc] issuer = "https://idp.example.com" audience = "openshell-cli" dangerously_allow_insecure_http = true jwks_allowed_origins = ["https://keys.example.com"] "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com")); assert_eq!(args.oidc_audience, "openshell-cli"); assert!(args.oidc_dangerously_allow_insecure_http); assert_eq!( args.oidc_jwks_allowed_origins, ["https://keys.example.com".to_string()] ); } #[test] fn file_grpc_rate_limit_populates_args_when_cli_omits() { let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] grpc_rate_limit_requests = 100 grpc_rate_limit_window_seconds = 30 ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.grpc_rate_limit_requests, Some(100)); assert_eq!(args.grpc_rate_limit_window_seconds, Some(30)); } #[test] fn cli_grpc_rate_limit_overrides_file_value() { let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--grpc-rate-limit-requests", "20", ]); let file = config_file_from_toml( r" [openshell.gateway] grpc_rate_limit_requests = 100 grpc_rate_limit_window_seconds = 30 ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.grpc_rate_limit_requests, Some(20)); assert_eq!(args.grpc_rate_limit_window_seconds, Some(30)); } #[test] fn aux_listener_preserves_file_ip_against_public_bind() { use std::net::SocketAddr; let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); let (_args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file_addr: SocketAddr = "127.0.0.1:8081".parse().unwrap(); let resolved = super::resolve_aux_listener( IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0, &matches, "health_port", || Some(file_addr), ); assert_eq!( resolved, Some(file_addr), "TOML health_bind_address 127.0.0.1:8081 must not be relocated to 0.0.0.0:8081" ); } #[test] fn aux_listener_cli_port_overrides_file_addr() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); let (_args, matches) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--health-port", "9999", ]); let file_addr: std::net::SocketAddr = "127.0.0.1:8081".parse().unwrap(); let resolved = super::resolve_aux_listener( IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9999, &matches, "health_port", || Some(file_addr), ); assert_eq!( resolved, Some("0.0.0.0:9999".parse().unwrap()), "CLI flag must win over file value" ); } #[test] fn file_disable_tls_applies() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] disable_tls = true ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!(args.disable_tls); } #[test] fn file_ssh_session_ttl_secs_is_parsed() { // The loader must accept and surface the documented key. The actual // wiring into `Config` happens in `run_from_args` against the parsed // file (not via `merge_file_into_args`, since there is no matching // `RunArgs` field), so this test pins the schema half. let file = config_file_from_toml( r" [openshell.gateway] ssh_session_ttl_secs = 1234 ", ); assert_eq!(file.openshell.gateway.ssh_session_ttl_secs, Some(1234)); } #[test] fn singleplayer_behavior_comes_from_registration() { let local = test_registry("local", true, true); assert!(super::is_singleplayer_driver(local.get("local"))); let shared = test_registry("shared", false, true); assert!(!super::is_singleplayer_driver(shared.get("shared"))); } #[test] fn compute_driver_socket_flag_uses_explicit_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let (mut args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "Kyma", "--compute-driver-socket", "/run/openshell/kyma.sock", ]); super::normalize_compute_driver_socket_args(&mut args).unwrap(); assert_eq!( args.compute_driver_socket.as_deref(), Some(std::path::Path::new("/run/openshell/kyma.sock")) ); assert_eq!(args.compute_driver.as_deref(), Some("kyma")); } #[test] fn compute_driver_socket_requires_explicit_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let (mut args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver-socket", "/run/openshell/kyma.sock", ]); let err = super::normalize_compute_driver_socket_args(&mut args).unwrap_err(); assert!( err.to_string().contains("requires --compute-driver "), "unexpected error: {err}" ); } #[test] fn compute_driver_socket_accepts_canonical_builtin_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let (mut args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "docker", "--compute-driver-socket", "/run/openshell/extension.sock", ]); super::normalize_compute_driver_socket_args(&mut args).unwrap(); assert_eq!(args.compute_driver.as_deref(), Some("docker")); } #[test] fn compute_driver_socket_accepts_vm_endpoint() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); let (mut args, _) = parse_with_args(&[ "openshell-gateway", "--db-url", "sqlite::memory:", "--compute-driver", "vm", "--compute-driver-socket", "/run/openshell/vm.sock", ]); super::normalize_compute_driver_socket_args(&mut args).unwrap(); assert_eq!(args.compute_driver.as_deref(), Some("vm")); } #[test] fn compute_driver_socket_reads_from_env_var() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::set( "OPENSHELL_COMPUTE_DRIVER_SOCKET", "/var/run/openshell/kyma.sock", ); let _g2 = EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "kyma"); let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); super::normalize_compute_driver_socket_args(&mut args).unwrap(); assert_eq!( args.compute_driver_socket.as_deref(), Some(std::path::Path::new("/var/run/openshell/kyma.sock")) ); assert_eq!(args.compute_driver.as_deref(), Some("kyma")); } #[test] fn file_populates_service_routing_fields() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g1 = EnvVarGuard::remove("OPENSHELL_SERVER_SAN"); let _g2 = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r#" [openshell.gateway] server_sans = ["gateway.local", "*.dev.openshell.localhost"] enable_loopback_service_http = false "#, ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!( args.server_sans, vec![ "gateway.local".to_string(), "*.dev.openshell.localhost".to_string() ] ); assert!(!args.enable_loopback_service_http); } #[test] fn env_var_overrides_file_loopback_service_http() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _g = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "true"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml( r" [openshell.gateway] enable_loopback_service_http = false ", ); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert!( args.enable_loopback_service_http, "env var must win over file" ); } #[test] fn canonical_file_driver_selector_populates_cli_args() { let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); let file = config_file_from_toml("[openshell.gateway]\ncompute_driver = \"podman\"\n"); merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.compute_driver.as_deref(), Some("podman")); } #[test] fn server_config_preparation_ignores_unselected_driver_tables() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let local_tls = tempfile::tempdir().unwrap(); let _g1 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); let _g2 = EnvVarGuard::set( "OPENSHELL_LOCAL_TLS_DIR", local_tls.path().to_str().unwrap(), ); let config_path = state.path().join("gateway.toml"); std::fs::write( &config_path, r#" [openshell] version = 2 [openshell.gateway] policy_validation_failure_mode = "retain_last_valid" [openshell.drivers.docker] unknown_docker_key = true [openshell.drivers.vm] mem_mib = "not-a-number" "#, ) .unwrap(); let (mut args, matches) = parse_with_args(&[ "openshell-gateway", "--config", config_path.to_str().unwrap(), "--db-url", "sqlite::memory:", "--compute-driver", "podman", "--disable-tls", ]); let prepared = super::prepare_server_config(&mut args, &matches).expect("server config is prepared"); assert_eq!(prepared.config.compute_driver.as_deref(), Some("podman")); assert_eq!( prepared.config.policy_validation_failure_mode, openshell_core::PolicyValidationFailureMode::RetainLastValid ); let file = prepared.config_file.expect("config file is preserved"); assert!(file.openshell.drivers.contains_key("docker")); assert!(file.openshell.drivers.contains_key("vm")); } }