mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-12 04:39:51 +08:00
* refactor(auth): separate sandbox identity from TLS Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(auth): clarify gateway mTLS behavior Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(auth): include workspace scope in TLS authorization checks Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(e2e): bound service auth sandbox names for large PIDs Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
3288 lines
118 KiB
Rust
3288 lines
118 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::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<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),
|
|
/// 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<PathBuf>,
|
|
|
|
/// Gateway daemon arguments to replay after `--`.
|
|
#[arg(last = true, allow_hyphen_values = true, value_name = "GATEWAY_ARGS")]
|
|
gateway_args: Vec<OsString>,
|
|
}
|
|
|
|
#[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<PathBuf>,
|
|
|
|
/// 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<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 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<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 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<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>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Enable mTLS client certificate authentication for gateway users.
|
|
///
|
|
/// When unset, this defaults on 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<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,
|
|
|
|
/// 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<bool> {
|
|
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::<Vec<_>>();
|
|
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<ServerStartupConfig> {
|
|
prepare_server_config_with_drivers(args, matches, &ComputeDriverRegistry::new())
|
|
}
|
|
|
|
fn prepare_server_config_with_drivers(
|
|
args: &mut RunArgs,
|
|
matches: &ArgMatches,
|
|
compute_drivers: &ComputeDriverRegistry,
|
|
) -> 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);
|
|
}
|
|
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 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 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."
|
|
));
|
|
}
|
|
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,
|
|
// Sandboxes authenticate at the application layer with bearer
|
|
// identity, so TLS must permit clients without certificates.
|
|
// When present, CLI certificates are still verified and may be
|
|
// promoted to users by the independently configured mTLS policy.
|
|
require_client_auth: false,
|
|
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<String, String> {
|
|
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<crate::ComputeDriverInstance> {
|
|
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<PathBuf>,
|
|
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 empty_file = ConfigFile::default();
|
|
let semantic_file = file.as_ref().unwrap_or(&empty_file);
|
|
validate_preflight_semantics(&run, matches, semantic_file)?;
|
|
|
|
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,
|
|
) -> 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));
|
|
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 !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<Option<PathBuf>> {
|
|
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<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(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<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) -> 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 <name> or OPENSHELL_COMPUTE_DRIVER=<name>"
|
|
));
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
#[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<crate::ComputeDriverInstance> {
|
|
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<crate::ComputeDriverInstance> {
|
|
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<crate::ComputeDriverInstance> {
|
|
unreachable!("preflight must not build the selected driver")
|
|
}
|
|
}
|
|
|
|
fn test_registry(name: &str, singleplayer: bool) -> crate::ComputeDriverRegistry {
|
|
let mut registration =
|
|
crate::ComputeDriverRegistration::new(name, 100, None, TestFactory).unwrap();
|
|
if singleplayer {
|
|
registration = registration.with_local_singleplayer();
|
|
}
|
|
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<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 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);
|
|
|
|
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);
|
|
|
|
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_allows_driver_independent_mtls() {
|
|
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);
|
|
|
|
super::run_config_preflight_with_drivers(
|
|
super::ConfigPreflightArgs::default(),
|
|
run,
|
|
&matches,
|
|
®istry,
|
|
)
|
|
.expect("gateway mTLS authentication is independent of the selected driver");
|
|
}
|
|
|
|
#[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'\ndisable_tls = true\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'\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<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 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_accepts_bearer_clients_with_and_without_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);
|
|
|
|
for (oidc_issuer, expected) in [(None, false), (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_when_client_ca_is_configured() {
|
|
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]
|
|
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!(
|
|
!prepared
|
|
.config
|
|
.tls
|
|
.as_ref()
|
|
.expect("TLS config")
|
|
.require_client_auth,
|
|
"sandbox bearer clients must be allowed through the TLS handshake"
|
|
);
|
|
assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn mtls_auth_default_is_driver_independent() {
|
|
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]
|
|
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]
|
|
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 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 <name>"),
|
|
"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"));
|
|
}
|
|
}
|