// 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::{Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; use openshell_core::config::{ DEFAULT_DOCKER_NETWORK_NAME, DEFAULT_SERVER_PORT, DEFAULT_SSH_HANDSHAKE_SKEW_SECS, DEFAULT_SSH_PORT, }; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::info; use tracing_subscriber::EnvFilter; use crate::certgen; use crate::compute::{DockerComputeConfig, VmComputeConfig}; use crate::{run_server, tracing_bus::TracingLogBus}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// /// Top-level CLI. When invoked without a subcommand the binary runs the /// gateway server using `RunArgs`. The `generate-certs` subcommand is used by /// the Helm pre-install hook to bootstrap mTLS Secrets. #[derive(Parser, Debug)] #[command(version = openshell_core::VERSION)] #[command(about = "OpenShell gRPC/HTTP server", long_about = None)] struct Cli { #[command(subcommand)] command: Option, #[command(flatten)] run: RunArgs, } #[derive(clap::Subcommand, Debug)] enum Commands { /// Generate mTLS PKI and write Kubernetes Secrets (Helm pre-install hook). GenerateCerts(certgen::CertgenArgs), } #[derive(clap::Args, Debug)] struct RunArgs { /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, /// Port to bind the server to. #[arg(long, default_value_t = DEFAULT_SERVER_PORT, env = "OPENSHELL_SERVER_PORT")] port: u16, /// Port for unauthenticated health endpoints (healthz, readyz). /// Set to 0 to disable the dedicated health listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_HEALTH_PORT")] health_port: u16, /// Port for the Prometheus metrics endpoint (/metrics). /// Set to 0 to disable the dedicated metrics listener. #[arg(long, default_value_t = 0, env = "OPENSHELL_METRICS_PORT")] metrics_port: u16, /// Log level (trace, debug, info, warn, error). #[arg(long, default_value = "info", env = "OPENSHELL_LOG_LEVEL")] log_level: String, /// Path to TLS certificate file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_CERT")] tls_cert: Option, /// Path to TLS private key file (required unless --disable-tls). #[arg(long, env = "OPENSHELL_TLS_KEY")] tls_key: Option, /// Path to CA certificate for client certificate verification (mTLS). #[arg(long, env = "OPENSHELL_TLS_CLIENT_CA")] tls_client_ca: Option, /// Database URL for persistence. /// /// Required when running the gateway. Validated at the call site rather /// than as a clap-level requirement so the `generate-certs` subcommand /// (which does not need a database) can run without it. #[arg(long, env = "OPENSHELL_DB_URL")] db_url: Option, /// Compute drivers configured for this gateway. /// /// Accepts a comma-delimited list such as `kubernetes` or /// `kubernetes,podman`. The configuration format is future-proofed for /// multiple drivers, but the gateway currently requires exactly one. /// When unset, the gateway auto-detects the driver based on the runtime /// environment (Kubernetes → Podman → Docker CLI or socket). VM is never /// auto-detected and requires explicit configuration. #[arg( long, alias = "driver", env = "OPENSHELL_DRIVERS", value_delimiter = ',', value_parser = parse_compute_driver )] drivers: Vec, /// Kubernetes namespace for sandboxes. #[arg(long, env = "OPENSHELL_SANDBOX_NAMESPACE", default_value = "default")] sandbox_namespace: String, /// Default container image for sandboxes. #[arg(long, env = "OPENSHELL_SANDBOX_IMAGE")] sandbox_image: Option, /// Kubernetes `imagePullPolicy` for sandbox pods (Always, `IfNotPresent`, Never). #[arg(long, env = "OPENSHELL_SANDBOX_IMAGE_PULL_POLICY")] sandbox_image_pull_policy: Option, /// gRPC endpoint for sandboxes to callback to `OpenShell`. /// This should be reachable from within the Kubernetes cluster. #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] grpc_endpoint: Option, /// Public host for the SSH gateway. #[arg(long, env = "OPENSHELL_SSH_GATEWAY_HOST", default_value = "127.0.0.1")] ssh_gateway_host: String, /// Public port for the SSH gateway. #[arg(long, env = "OPENSHELL_SSH_GATEWAY_PORT", default_value_t = DEFAULT_SERVER_PORT)] ssh_gateway_port: u16, /// HTTP path for SSH CONNECT/upgrade. #[arg( long, env = "OPENSHELL_SSH_CONNECT_PATH", default_value = "/connect/ssh" )] ssh_connect_path: String, /// SSH port inside sandbox pods. #[arg(long, env = "OPENSHELL_SANDBOX_SSH_PORT", default_value_t = DEFAULT_SSH_PORT)] sandbox_ssh_port: u16, /// Shared secret for gateway-to-sandbox SSH handshake. #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SECRET")] ssh_handshake_secret: Option, /// Allowed clock skew in seconds for SSH handshake. #[arg(long, env = "OPENSHELL_SSH_HANDSHAKE_SKEW_SECS", default_value_t = DEFAULT_SSH_HANDSHAKE_SKEW_SECS)] ssh_handshake_skew_secs: u64, /// Kubernetes secret name containing client TLS materials for sandbox pods. #[arg(long, env = "OPENSHELL_CLIENT_TLS_SECRET_NAME")] client_tls_secret_name: Option, /// Host gateway IP for sandbox pod hostAliases. /// When set, sandbox pods get hostAliases entries mapping /// host.docker.internal and host.openshell.internal to this IP. #[arg(long, env = "OPENSHELL_HOST_GATEWAY_IP")] host_gateway_ip: Option, /// Working directory for VM driver sandbox state. #[arg( long, env = "OPENSHELL_VM_DRIVER_STATE_DIR", default_value_os_t = VmComputeConfig::default_state_dir() )] vm_driver_state_dir: PathBuf, /// Directory searched for compute-driver binaries (e.g. /// `openshell-driver-vm`) when an explicit binary override isn't /// configured. When unset, the gateway searches /// `$HOME/.local/libexec/openshell`, `/usr/libexec/openshell`, /// `/usr/local/libexec/openshell`, `/usr/local/libexec`, then a sibling /// of the gateway binary. #[arg(long, env = "OPENSHELL_DRIVER_DIR")] driver_dir: Option, /// libkrun log level used by the VM helper. #[arg( long, env = "OPENSHELL_VM_KRUN_LOG_LEVEL", default_value_t = VmComputeConfig::default_krun_log_level() )] vm_krun_log_level: u32, /// Default vCPU count for VM sandboxes. #[arg( long, env = "OPENSHELL_VM_DRIVER_VCPUS", default_value_t = VmComputeConfig::default_vcpus() )] vm_vcpus: u8, /// Default memory allocation for VM sandboxes, in MiB. #[arg( long, env = "OPENSHELL_VM_DRIVER_MEM_MIB", default_value_t = VmComputeConfig::default_mem_mib() )] vm_mem_mib: u32, /// CA certificate installed into VM sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_VM_TLS_CA")] vm_tls_ca: Option, /// Client certificate installed into VM sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_VM_TLS_CERT")] vm_tls_cert: Option, /// Client private key installed into VM sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_VM_TLS_KEY")] vm_tls_key: Option, /// Linux `openshell-sandbox` binary bind-mounted into Docker sandboxes. /// /// When unset the gateway falls back to (in order) a sibling /// `openshell-sandbox` next to the gateway binary, a local cargo build, /// or extracting the binary from `--docker-supervisor-image`. #[arg(long, env = "OPENSHELL_DOCKER_SUPERVISOR_BIN")] docker_supervisor_bin: Option, /// Image the Docker driver pulls to extract the Linux /// `openshell-sandbox` binary when no explicit `--docker-supervisor-bin` /// override or local build is available. Defaults to /// `ghcr.io/nvidia/openshell/supervisor:`. #[arg(long, env = "OPENSHELL_DOCKER_SUPERVISOR_IMAGE")] docker_supervisor_image: Option, /// CA certificate bind-mounted into Docker sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_DOCKER_TLS_CA")] docker_tls_ca: Option, /// Client certificate bind-mounted into Docker sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_DOCKER_TLS_CERT")] docker_tls_cert: Option, /// Client private key bind-mounted into Docker sandboxes for gateway mTLS. #[arg(long, env = "OPENSHELL_DOCKER_TLS_KEY")] docker_tls_key: Option, /// Docker bridge network used for sandbox containers. #[arg( long, env = "OPENSHELL_DOCKER_NETWORK_NAME", default_value = DEFAULT_DOCKER_NETWORK_NAME )] docker_network_name: String, /// Enable Kubernetes user namespace isolation (hostUsers: false) for /// sandbox pods. #[arg(long, env = "OPENSHELL_ENABLE_USER_NAMESPACES")] enable_user_namespaces: bool, /// 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, /// Disable gateway authentication (mTLS client certificate requirement). /// When set, the TLS handshake accepts connections without a client /// certificate. Ignored when --disable-tls is set. #[arg(long, env = "OPENSHELL_DISABLE_GATEWAY_AUTH")] disable_gateway_auth: 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, /// 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, } pub fn command() -> Command { Cli::command() .name("openshell-gateway") .bin_name("openshell-gateway") } pub async fn run_cli() -> Result<()> { rustls::crypto::ring::default_provider() .install_default() .map_err(|e| miette::miette!("failed to install rustls crypto provider: {e:?}"))?; let cli = Cli::from_arg_matches(&command().get_matches()).expect("clap validated args"); match cli.command { Some(Commands::GenerateCerts(args)) => certgen::run(args).await, None => Box::pin(run_from_args(cli.run)).await, } } async fn run_from_args(args: RunArgs) -> Result<()> { let tracing_log_bus = TracingLogBus::new(); tracing_log_bus.install_subscriber( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)), ); let bind = SocketAddr::new(args.bind_address, args.port); let tls = if args.disable_tls { None } else { let cert_path = args.tls_cert.ok_or_else(|| { miette::miette!( "--tls-cert is required when TLS is enabled (use --disable-tls to skip)" ) })?; let key_path = args.tls_key.ok_or_else(|| { miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)") })?; let client_ca_path = args.tls_client_ca.ok_or_else(|| { miette::miette!( "--tls-client-ca is required when TLS is enabled (use --disable-tls to skip)" ) })?; Some(openshell_core::TlsConfig { cert_path, key_path, client_ca_path, allow_unauthenticated: args.disable_gateway_auth, }) }; let db_url = args .db_url .ok_or_else(|| miette::miette!("--db-url is required (or set OPENSHELL_DB_URL)"))?; let mut config = openshell_core::Config::new(tls) .with_bind_address(bind) .with_log_level(&args.log_level); if args.health_port != 0 { if args.port == args.health_port { return Err(miette::miette!( "--port and --health-port must be different (both set to {})", args.port )); } let health_bind = SocketAddr::new(args.bind_address, args.health_port); config = config.with_health_bind_address(health_bind); } if args.metrics_port != 0 { if args.port == args.metrics_port { return Err(miette::miette!( "--port and --metrics-port must be different (both set to {})", args.port )); } if args.health_port != 0 && args.health_port == args.metrics_port { return Err(miette::miette!( "--health-port and --metrics-port must be different (both set to {})", args.health_port )); } let metrics_bind = SocketAddr::new(args.bind_address, args.metrics_port); config = config.with_metrics_bind_address(metrics_bind); } config = config .with_database_url(db_url) .with_compute_drivers(args.drivers) .with_sandbox_namespace(args.sandbox_namespace) .with_ssh_gateway_host(args.ssh_gateway_host) .with_ssh_gateway_port(args.ssh_gateway_port) .with_ssh_connect_path(args.ssh_connect_path) .with_sandbox_ssh_port(args.sandbox_ssh_port) .with_ssh_handshake_skew_secs(args.ssh_handshake_skew_secs); if let Some(image) = args.sandbox_image { config = config.with_sandbox_image(image); } if let Some(policy) = args.sandbox_image_pull_policy { config = config.with_sandbox_image_pull_policy(policy); } if let Some(endpoint) = args.grpc_endpoint { config = config.with_grpc_endpoint(endpoint); } if let Some(secret) = args.ssh_handshake_secret { config = config.with_ssh_handshake_secret(secret); } if let Some(name) = args.client_tls_secret_name { config = config.with_client_tls_secret_name(name); } if let Some(ip) = args.host_gateway_ip { config = config.with_host_gateway_ip(ip); } if let Some(issuer) = args.oidc_issuer { config = config.with_oidc(openshell_core::OidcConfig { issuer, audience: args.oidc_audience, jwks_ttl_secs: args.oidc_jwks_ttl, roles_claim: args.oidc_roles_claim, admin_role: args.oidc_admin_role, user_role: args.oidc_user_role, scopes_claim: args.oidc_scopes_claim, }); } config.enable_user_namespaces = args.enable_user_namespaces; let vm_config = VmComputeConfig { state_dir: args.vm_driver_state_dir, driver_dir: args.driver_dir, default_image: config.sandbox_image.clone(), krun_log_level: args.vm_krun_log_level, vcpus: args.vm_vcpus, mem_mib: args.vm_mem_mib, guest_tls_ca: args.vm_tls_ca, guest_tls_cert: args.vm_tls_cert, guest_tls_key: args.vm_tls_key, }; let docker_config = DockerComputeConfig { supervisor_bin: args.docker_supervisor_bin, supervisor_image: args.docker_supervisor_image, guest_tls_ca: args.docker_tls_ca, guest_tls_cert: args.docker_tls_cert, guest_tls_key: args.docker_tls_key, network_name: args.docker_network_name, }; if args.disable_tls { info!("TLS disabled — listening on plaintext HTTP"); } else if args.disable_gateway_auth { info!("Gateway auth disabled — accepting connections without client certificates"); } info!(bind = %config.bind_address, "Starting OpenShell server"); run_server(config, vm_config, docker_config, tracing_log_bus) .await .into_diagnostic() } fn parse_compute_driver(value: &str) -> std::result::Result { value.parse() } #[cfg(test)] mod tests { use super::{Cli, command}; use clap::Parser; use std::net::{IpAddr, Ipv4Addr}; use std::sync::{LazyLock, Mutex}; static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); struct EnvVarGuard { key: &'static str, original: Option, } impl EnvVarGuard { #[allow(unsafe_code)] fn set(key: &'static str, value: &str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::set_var(key, value) }; Self { key, original } } #[allow(unsafe_code)] fn remove(key: &'static str) -> Self { let original = std::env::var(key).ok(); // SAFETY: tests serialize environment mutation with ENV_LOCK. unsafe { std::env::remove_var(key) }; Self { key, original } } } impl Drop for EnvVarGuard { #[allow(unsafe_code)] fn drop(&mut self) { match self.original.as_deref() { // SAFETY: tests serialize environment mutation with ENV_LOCK. Some(value) => unsafe { std::env::set_var(self.key, value) }, // SAFETY: tests serialize environment mutation with ENV_LOCK. None => unsafe { std::env::remove_var(self.key) }, } } } #[test] fn command_uses_gateway_binary_name() { let mut help = Vec::new(); command().write_long_help(&mut help).unwrap(); let help = String::from_utf8(help).unwrap(); assert!(help.contains("openshell-gateway")); } #[test] fn command_exposes_version() { let cmd = command(); let version = cmd.get_version().unwrap(); assert_eq!(version.to_string(), openshell_core::VERSION); } #[test] fn command_defaults_bind_address_to_loopback() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_parses_bind_address() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); let cli = Cli::try_parse_from([ "openshell-gateway", "--db-url", "sqlite::memory:", "--bind-address", "127.0.0.1", ]) .unwrap(); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); } #[test] fn command_reads_bind_address_from_env() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _guard = EnvVarGuard::set("OPENSHELL_BIND_ADDRESS", "0.0.0.0"); let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) .expect("env should provide bind address"); assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); } #[test] fn 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", "--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 bare_invocation_with_no_db_url_errors_at_runtime_not_parse_time() { // db_url is Option at the clap level so subcommand parsing // does not require it. The Run path validates it inside // run_from_args. This test asserts the parse step succeeds with no // --db-url, mirroring what the runtime check sees. 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()); } }