Files
OpenShell/crates/openshell-server/src/cli.rs
T
5548405fcb feat(credentials): add provider credential storage drivers (#2437)
* feat(credentials): add provider credential storage drivers

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>

* fix(credentials): harden credential update handling

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>

* fix(credentials): harden credential driver security, correctness, and performance

Address review findings from the credential storage drivers PR:

- Route additional_credentials through the driver on refresh to prevent
  silent data loss for multi-credential providers (e.g. AWS STS)
- Clean up stored credential handles on CAS failure during refresh to
  prevent orphaned secrets in external backends
- Enforce namespace validation in the Kubernetes Secrets driver to
  prevent cross-namespace credential access when allow_reference_namespace
  is not enabled
- Cache Vault Kubernetes auth tokens with 80% TTL to avoid re-authenticating
  on every credential operation
- Parallelize resolve_credentials in all three drivers using try_join_all
  for faster sandbox startup
- Add existingSecret support for the KEK Secret to fix helm template/GitOps
  workflows where lookup returns empty and regenerates the key
- Document RBAC blast radius for the Kubernetes Secrets credential driver
  and recommend a dedicated namespace

Signed-off-by: Varsha Prasad <varshaprasad96@gmail.com>
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* fix(credentials): add optimistic concurrency, fix thundering herd, parallelize operations

Use resourceVersion optimistic concurrency with retry loop for K8s
Secret ownership checks to prevent TOCTOU races. Switch Vault token
cache from RwLock to Mutex with double-check pattern to prevent
thundering herd on cache miss. Parallelize credential store and delete
operations across independent keys using try_join_all.

Signed-off-by: Varsha Prasad <varshaprasad96@gmail.com>
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* fix(credentials): handle partial failures, add delete retry, consolidate cleanup

Replace try_join_all with join_all in credential store/delete operations
to handle partial failures — successfully-stored handles are cleaned up
when another key fails. Add retry loop with conflict detection to
db-credstore delete_credential, matching the K8s driver pattern.
Consolidate 4 manual cleanup_pre_stored_provider_credentials call sites
into a single error handler using an async block. Remove inconsistent
.trim() from db-credstore validate_handle_owner.

Signed-off-by: Varsha Prasad <varshaprasad96@gmail.com>
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* fix(credentials): fix retry loop guard and remove unprotected validation

Remove attempt-count guard from 409/Aborted match arms in retry loops
so the post-loop Status::aborted error is reachable after exhausting
retries. Previously, last-attempt conflicts fell through to the
catch-all error arm, producing misleading Status::unavailable errors.

Remove duplicate validation calls that ran after
prepare_provider_credential_update but outside the cleanup-protected
async block, which would leak pre-stored handles on failure.

Signed-off-by: Varsha Prasad <varshaprasad96@gmail.com>
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* fix(credentials): add workspace/provider UUID to credential backend paths

Include workspace and provider ID in credential backend object paths to ensure
cross-workspace uniqueness and prevent credential collision (GATOR-1806c9be-01).

- Updated credential driver proto to include workspace and provider_id fields
- Modified Vault driver to include workspace/provider_id in managed_secret_path
- Modified Kubernetes Secrets driver to include workspace/provider_id in
  credential_owner_id and managed_secret_name
- Updated all credential runtime calls to pass workspace/provider_id
- Updated tests to use the new signatures

This prevents two workspaces sharing the same external credential store from
colliding on provider names, which was a critical security issue (CWE-639).

* fix(credentials): preserve provider-level expiration for handle-backed credentials

Compute effective expiration from both provider and driver values using the
earliest non-zero timestamp and skip expired values before insertion
(GATOR-1806c9be-02).

- Modified resolve_provider_handles to check provider credential_expires_at_ms
- Skip expired credentials during resolution instead of returning them
- Use effective expiration (min of provider and driver) in resolution results
- Fix inference.rs to preserve earliest expiration when merging

This ensures handle-backed credentials respect the same expiration semantics
as inline credentials.

* fix(credentials): stage refresh changes under new handles before validation

Stage credential replacements under new immutable handles instead of reusing
existing handles to prevent overwriting committed values before validation/CAS
(GATOR-1806c9be-03).

- Stage credentials with empty existing_handles map to force new handle creation
- Validate and CAS before the new values are committed to backend storage
- Delete old handles only after successful CAS
- On CAS failure, delete only the newly staged handles
- This prevents CWE-362/CWE-367 race conditions where failed refreshes could
  still modify or delete the active credential

The fix ensures that a rejected refresh cannot modify the backend object still
referenced by the committed provider record.

* fix(credentials): add timeouts to credential driver RPCs

Apply configured timeouts to both startup capability negotiation and runtime
RPCs to prevent indefinite hangs (GATOR-1806c9be-05).

- Add DEFAULT_CREDENTIAL_DRIVER_RPC_TIMEOUT_SECS constant (30s)
- Apply timeout to GetCapabilities during startup connection
- Apply timeout to all runtime RPCs (store, delete, resolve)
- Use tokio::time::timeout to bound the entire GetCapabilities operation
  during startup, not just the socket connection
- Return contextual deadline errors on timeout

This prevents a faulty or overloaded driver from hanging gateway operations
indefinitely.

* fix(credentials): fix test to use consistent workspace/provider identity

The Kubernetes auth Vault resolve test was constructing a managed path with
test-workspace/test-provider-id but sending default/prov-123 in the request,
causing validation to reject the request (GATOR-18e32351-01).

- Update test to use test-workspace and test-provider-id in the request to
  match the logical_path construction
- This ensures the test exercises the intended code path and validates
  Kubernetes auth resolution properly

The test now passes and correctly validates identity enforcement.

* fix(credentials): use unique staging ID for refresh to avoid overwrites

Stage refresh replacements under genuinely distinct immutable handles using
a unique staging ID to prevent overwriting committed values (GATOR-1806c9be-03).

- Generate a unique staging ID using UUID for each refresh operation
- Use this staging ID when storing credentials instead of the real provider ID
- Pass the same staging ID during cleanup on failure to delete only staged objects
- This ensures deterministic paths (Vault) and object names (K8s) don't collide
  with the committed provider's credentials

The fix prevents failed refreshes from silently replacing active credentials
or breaking providers by deleting still-referenced backend objects.

* fix(credentials): wrap credential driver RPCs in local timeouts

Add local tokio::time::timeout wrappers around credential driver RPCs to
bound non-compliant or stalled UDS peers (GATOR-1806c9be-05).

- Wrap StoreCredential, DeleteCredential, and ResolveCredentials in local timeouts
- Return contextual deadline_exceeded errors when timeouts occur
- Keep existing gRPC timeout metadata for compliant implementations
- GetCapabilities during startup was already wrapped in previous commit

This ensures a faulty local driver cannot hang gateway operations indefinitely,
even if it accepts the connection but never responds to the RPC.

* fix(credentials): preserve ownership for staged refreshes

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(credentials): bound startup capability probe

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* test(provider): authenticate credential handler requests

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(ci): grant actions read to credential driver e2e

Signed-off-by: Seth Jennings <sjenning@redhat.com>

---------

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
Signed-off-by: Varsha Prasad <varshaprasad96@gmail.com>
Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
Signed-off-by: Seth Jennings <sjenning@redhat.com>
Co-authored-by: Taylor Mutch <taylormutch@gmail.com>
Co-authored-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
2026-08-05 16:29:41 +00:00

1867 lines
64 KiB
Rust

// 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::ComputeDriverKind;
use openshell_core::config::DEFAULT_SERVER_PORT;
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::{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<Commands>,
#[command(flatten)]
run: RunArgs,
}
#[derive(clap::Subcommand, Debug)]
enum Commands {
/// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook).
GenerateCerts(certgen::CertgenArgs),
}
#[derive(clap::Args, 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<PathBuf>,
/// 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<PathBuf>,
/// Path to TLS private key file (required unless --disable-tls).
#[arg(long, env = "OPENSHELL_TLS_KEY")]
tls_key: Option<PathBuf>,
/// Path to CA certificate for client certificate verification (mTLS).
#[arg(long, env = "OPENSHELL_TLS_CLIENT_CA")]
tls_client_ca: Option<PathBuf>,
/// 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<String>,
/// Compute drivers configured for this gateway.
///
/// Accepts a comma-delimited list such as `kubernetes` or
/// `kubernetes,podman`. The configuration format is future-proofed for
/// multiple drivers, but the gateway currently requires exactly one.
/// When unset, the gateway auto-detects the driver based on the runtime
/// environment (Kubernetes → Podman → Docker). VM is never
/// auto-detected and requires explicit configuration.
#[arg(
long,
alias = "driver",
env = "OPENSHELL_DRIVERS",
value_delimiter = ',',
value_parser = parse_compute_driver
)]
drivers: Vec<String>,
/// Path to a Unix domain socket served by a remote compute driver
/// implementing `compute_driver.proto`.
///
/// When set, the socket is associated with the single driver name supplied
/// by `--drivers` or `OPENSHELL_DRIVERS`. Reserved built-in driver names
/// such as Docker, Podman, Kubernetes, and VM do not accept socket
/// endpoints.
#[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")]
compute_driver_socket: Option<PathBuf>,
/// 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<String>,
/// Enable mTLS client certificate authentication for local single-user gateways.
///
/// When unset, this defaults on for Docker, Podman, and VM gateways that
/// have client certificate verification configured and no OIDC issuer.
/// Kubernetes deployments must use OIDC or fronting-proxy auth instead.
#[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<u64>,
/// 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<u64>,
/// 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<String>,
/// Enable plaintext HTTP routing for loopback sandbox service URLs.
#[arg(
long,
env = "OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP",
default_value_t = true,
action = ArgAction::Set
)]
enable_loopback_service_http: bool,
}
pub fn command() -> Command {
Cli::command()
.name("openshell-gateway")
.bin_name("openshell-gateway")
}
pub async fn run_cli() -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|e| miette::miette!("failed to install rustls crypto provider: {e:?}"))?;
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,
None => Box::pin(run_from_args(cli.run, matches)).await,
}
}
fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result<ServerStartupConfig> {
// 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<ConfigFile> = 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);
}
normalize_compute_driver_socket_args(args, matches)?;
let local_tls = apply_runtime_defaults(args)?;
let guest_tls = local_tls.as_ref().map(GuestTlsPaths::from);
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());
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
&& matches!(
effective_single_driver(args),
Some(ComputeDriverKind::Kubernetes)
)
{
return Err(miette::miette!(
"mTLS user authentication is not supported with the Kubernetes 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)")
})?;
Some(openshell_core::TlsConfig {
cert_path,
key_path,
require_client_auth: has_client_ca && !has_oidc,
client_ca_path: args.tls_client_ca.clone(),
})
};
let db_url = args
.db_url
.clone()
.expect("runtime defaults populate db_url");
let mut config = openshell_core::Config::new(tls)
.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)
.with_compute_drivers(args.drivers.clone())
.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);
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
.drivers
.first()
.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,
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,
})
}
async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
let prepared = prepare_server_config(&mut args, &matches)?;
let tracing_log_bus = TracingLogBus::new();
let otlp_config = prepared
.config_file
.as_ref()
.and_then(|f| f.openshell.gateway.otlp.as_ref());
let (tracing_handle, setup_error) = crate::tracing_setup::install(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)),
&tracing_log_bus,
otlp_config,
);
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)).await;
tracing_handle.shutdown();
result.into_diagnostic()
}
fn parse_compute_driver(value: &str) -> std::result::Result<String, String> {
openshell_core::config::normalize_compute_driver_name(value)
}
fn resolve_config_path(args: &RunArgs) -> Result<Option<PathBuf>> {
if let Some(path) = args.config.clone() {
return Ok(Some(path));
}
let default_path = defaults::default_gateway_config_path()?;
Ok(default_path.is_file().then_some(default_path))
}
fn apply_runtime_defaults(args: &mut RunArgs) -> Result<Option<LocalTlsPaths>> {
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<SocketAddr>,
) -> Option<SocketAddr> {
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(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(drivers) = &file.compute_drivers
&& arg_defaulted(matches, "drivers")
{
args.drivers.clone_from(drivers);
}
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(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_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<u64>, window_seconds: Option<u64>) -> 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, matches: &ArgMatches) -> 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 arg_defaulted(matches, "drivers") {
return Err(miette::miette!(
"--compute-driver-socket requires --drivers <name> or OPENSHELL_DRIVERS=<name> to select a non-reserved compute driver name"
));
}
match args.drivers.as_slice() {
[driver] => {
let driver = openshell_core::config::normalize_compute_driver_name(driver)
.map_err(|err| miette::miette!("{err}"))?;
if matches!(
driver.parse::<ComputeDriverKind>().ok(),
Some(
ComputeDriverKind::Docker
| ComputeDriverKind::Podman
| ComputeDriverKind::Kubernetes
| ComputeDriverKind::Vm
)
) {
return Err(miette::miette!(
"--compute-driver-socket cannot be combined with reserved built-in compute driver '{driver}'"
));
}
args.drivers[0] = driver;
Ok(())
}
drivers => Err(miette::miette!(
"--compute-driver-socket requires exactly one compute driver name, got: {}",
drivers.join(",")
)),
}
}
fn effective_single_driver(args: &RunArgs) -> Option<ComputeDriverKind> {
match args.drivers.as_slice() {
[] => openshell_core::config::detect_driver(),
[driver] => driver.parse().ok(),
_ => None,
}
}
fn is_singleplayer_driver(args: &RunArgs) -> bool {
matches!(
effective_single_driver(args),
Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm)
)
}
fn resolve_mtls_auth_enabled(
args: &RunArgs,
matches: &ArgMatches,
file: Option<&ConfigFile>,
) -> 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(args)
}
#[cfg(test)]
mod tests {
use super::{Cli, command};
use crate::TEST_ENV_LOCK as ENV_LOCK;
use clap::Parser;
use std::net::{IpAddr, Ipv4Addr};
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
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 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_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_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 bare_invocation_with_no_db_url_parses_for_runtime_defaults() {
// db_url is Option<String> 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 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 = 1\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/gateway/openshell.db").display()
);
assert!(local_tls.is_none());
assert_eq!(args.db_url.as_deref(), Some(expected.as_str()));
assert!(tmp.path().join("openshell/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 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:",
"--drivers",
"docker",
"--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]
fn mtls_auth_does_not_auto_default_for_kubernetes_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:",
"--drivers",
"kubernetes",
"--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]
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:",
"--drivers",
"docker",
"--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]
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 (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
r#"
[openshell.gateway]
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");
}
#[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 (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--log-level",
"warn",
]);
let file = config_file_from_toml(
r#"
[openshell.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");
}
#[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 (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
let file = config_file_from_toml(
r#"
[openshell.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");
}
#[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 (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"
"#,
);
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");
}
#[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_driver_matches_only_one_local_driver() {
for driver in ["docker", "podman", "vm"] {
let (args, _) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
driver,
]);
assert!(
super::is_singleplayer_driver(&args),
"{driver} should be singleplayer"
);
}
let (k8s, _) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
"kubernetes",
]);
assert!(!super::is_singleplayer_driver(&k8s));
let (multi, _) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
"docker,podman",
]);
assert!(!super::is_singleplayer_driver(&multi));
}
#[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_DRIVERS");
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
"Kyma",
"--compute-driver-socket",
"/run/openshell/kyma.sock",
]);
super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap();
assert_eq!(
args.compute_driver_socket.as_deref(),
Some(std::path::Path::new("/run/openshell/kyma.sock"))
);
assert_eq!(args.drivers, ["kyma"]);
assert!(super::effective_single_driver(&args).is_none());
}
#[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_DRIVERS");
let (mut args, matches) = 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, &matches).unwrap_err();
assert!(
err.to_string().contains("requires --drivers <name>"),
"unexpected error: {err}"
);
}
#[test]
fn compute_driver_socket_rejects_reserved_builtin_drivers() {
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_DRIVERS");
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
"docker",
"--compute-driver-socket",
"/run/openshell/extension.sock",
]);
let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err();
assert!(
err.to_string()
.contains("cannot be combined with reserved built-in compute driver 'docker'"),
"unexpected error: {err}"
);
}
#[test]
fn compute_driver_socket_rejects_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_DRIVERS");
let (mut args, matches) = parse_with_args(&[
"openshell-gateway",
"--db-url",
"sqlite::memory:",
"--drivers",
"vm",
"--compute-driver-socket",
"/run/openshell/vm.sock",
]);
let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err();
assert!(
err.to_string()
.contains("cannot be combined with reserved built-in compute driver 'vm'"),
"unexpected error: {err}"
);
}
#[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_DRIVERS", "kyma");
let (mut args, matches) =
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap();
assert_eq!(
args.compute_driver_socket.as_deref(),
Some(std::path::Path::new("/var/run/openshell/kyma.sock"))
);
assert_eq!(args.drivers, ["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 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.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:",
"--drivers",
"podman",
"--disable-tls",
]);
let prepared =
super::prepare_server_config(&mut args, &matches).expect("server config is prepared");
assert_eq!(prepared.config.compute_drivers, vec!["podman".to_string()]);
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"));
}
#[test]
fn driver_inherits_shared_image_from_gateway_section() {
// [openshell.gateway].default_image inherits into the K8s driver
// table when the driver-specific table does not set it.
let file = config_file_from_toml(
r#"
[openshell.gateway]
default_image = "ghcr.io/nvidia/openshell/sandbox:1.0"
[openshell.drivers.kubernetes]
namespace = "agents"
"#,
);
let merged = crate::config_file::driver_table(
super::ComputeDriverKind::Kubernetes.as_str(),
&file.openshell.gateway,
file.openshell.drivers.get("kubernetes"),
);
let parsed = merged
.try_into::<openshell_driver_kubernetes::KubernetesComputeConfig>()
.expect("merged table deserializes");
assert_eq!(parsed.default_image, "ghcr.io/nvidia/openshell/sandbox:1.0");
assert_eq!(parsed.namespace, "agents");
}
#[test]
fn driver_specific_value_overrides_gateway_inheritance() {
let file = config_file_from_toml(
r#"
[openshell.gateway]
default_image = "gateway-default:1.0"
[openshell.drivers.kubernetes]
default_image = "k8s-specific:1.0"
"#,
);
let merged = crate::config_file::driver_table(
super::ComputeDriverKind::Kubernetes.as_str(),
&file.openshell.gateway,
file.openshell.drivers.get("kubernetes"),
);
let parsed = merged
.try_into::<openshell_driver_kubernetes::KubernetesComputeConfig>()
.expect("deserializes");
assert_eq!(parsed.default_image, "k8s-specific:1.0");
}
}