diff --git a/crates/navigator-bootstrap/src/docker.rs b/crates/navigator-bootstrap/src/docker.rs index 92d889671..c44197bfb 100644 --- a/crates/navigator-bootstrap/src/docker.rs +++ b/crates/navigator-bootstrap/src/docker.rs @@ -121,6 +121,71 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result .wrap_err("failed to negotiate Docker API version with remote daemon") } +/// Find the running openshell gateway container by image name. +/// +/// Lists all running containers and returns the name of the one whose image +/// contains `openshell/cluster`. When `port` is provided, only containers +/// with a matching host port binding are considered — this disambiguates +/// when multiple gateway containers are running on the same host. +/// +/// Fails if zero or multiple containers match. +pub async fn find_gateway_container(docker: &Docker, port: Option) -> Result { + let containers = docker + .list_containers(Some(ListContainersOptionsBuilder::new().all(false).build())) + .await + .into_diagnostic() + .wrap_err("failed to list Docker containers")?; + + let is_gateway_image = |c: &bollard::models::ContainerSummary| { + c.image + .as_deref() + .is_some_and(|img| img.contains("openshell/cluster")) + }; + + let has_port = |c: &bollard::models::ContainerSummary, p: u16| { + c.ports + .as_deref() + .unwrap_or_default() + .iter() + .any(|binding| binding.public_port == Some(p)) + }; + + let container_name = |c: &bollard::models::ContainerSummary| { + c.names + .as_ref() + .and_then(|n| n.first()) + .map(|n| n.trim_start_matches('/').to_string()) + }; + + let matches: Vec = containers + .iter() + .filter(|c| is_gateway_image(c) && port.map_or(true, |p| has_port(c, p))) + .filter_map(container_name) + .collect(); + + match matches.len() { + 0 => { + let hint = if let Some(p) = port { + format!( + "No openshell gateway container found listening on port {p}.\n\ + Is the gateway running? Check with: docker ps" + ) + } else { + "No openshell gateway container found.\n\ + Is the gateway running? Check with: docker ps" + .to_string() + }; + Err(miette::miette!("{hint}")) + } + 1 => Ok(matches.into_iter().next().unwrap()), + _ => Err(miette::miette!( + "Found multiple openshell gateway containers: {}\n\ + Specify the port in the endpoint URL to select one (e.g. https://host:8080).", + matches.join(", ") + )), + } +} + pub async fn ensure_network(docker: &Docker) -> Result<()> { // Always remove and recreate the network to guarantee a clean state. // Stale Docker networks (e.g., from a previous interrupted destroy or diff --git a/crates/navigator-bootstrap/src/lib.rs b/crates/navigator-bootstrap/src/lib.rs index e037f111d..b8c0cdddd 100644 --- a/crates/navigator-bootstrap/src/lib.rs +++ b/crates/navigator-bootstrap/src/lib.rs @@ -33,8 +33,7 @@ use crate::docker::{ ensure_image, ensure_network, ensure_volume, start_container, stop_container, }; use crate::metadata::{ - create_gateway_metadata, create_gateway_metadata_with_host, extract_host_from_ssh_destination, - local_gateway_host, resolve_ssh_hostname, + create_gateway_metadata, create_gateway_metadata_with_host, local_gateway_host, }; use crate::mtls::store_pki_bundle; use crate::pki::generate_pki; @@ -46,9 +45,10 @@ use crate::runtime::{ pub use crate::constants::container_name; pub use crate::docker::{ExistingGatewayInfo, create_ssh_docker_client}; pub use crate::metadata::{ - GatewayMetadata, clear_active_gateway, get_gateway_metadata, list_gateways, - load_active_gateway, load_gateway_metadata, load_last_sandbox, remove_gateway_metadata, - save_active_gateway, save_last_sandbox, store_gateway_metadata, + GatewayMetadata, clear_active_gateway, extract_host_from_ssh_destination, get_gateway_metadata, + list_gateways, load_active_gateway, load_gateway_metadata, load_last_sandbox, + remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, + store_gateway_metadata, }; /// Options for remote SSH deployment. @@ -479,6 +479,30 @@ pub async fn gateway_handle(name: &str, remote: Option<&RemoteOptions>) -> Resul }) } +/// Extract mTLS certificates from an existing gateway container and store +/// them locally so the CLI can connect. +/// +/// Connects to Docker (local or remote via SSH), auto-discovers the running +/// gateway container by image name (narrowed by `port` when provided), reads +/// the PKI bundle from Kubernetes secrets inside it, and writes the client +/// materials (ca.crt, tls.crt, tls.key) to the gateway config directory. +pub async fn extract_and_store_pki( + name: &str, + remote: Option<&RemoteOptions>, + port: Option, +) -> Result<()> { + let docker = match remote { + Some(r) => create_ssh_docker_client(r).await?, + None => Docker::connect_with_local_defaults().into_diagnostic()?, + }; + let cname = docker::find_gateway_container(&docker, port).await?; + let bundle = load_existing_pki_bundle(&docker, &cname, constants::KUBECONFIG_PATH) + .await + .map_err(|e| miette::miette!("Failed to extract TLS certificates: {e}"))?; + store_pki_bundle(name, &bundle)?; + Ok(()) +} + pub async fn ensure_gateway_image(version: &str, registry_token: Option<&str>) -> Result { let docker = Docker::connect_with_local_defaults().into_diagnostic()?; let image_ref = format!("{}:{version}", image::DEFAULT_GATEWAY_IMAGE); diff --git a/crates/navigator-cli/src/bootstrap.rs b/crates/navigator-cli/src/bootstrap.rs index f2f49bcdd..bcbfcffc1 100644 --- a/crates/navigator-cli/src/bootstrap.rs +++ b/crates/navigator-cli/src/bootstrap.rs @@ -4,14 +4,14 @@ //! Auto-bootstrap helpers for sandbox creation. //! //! When `sandbox create` cannot reach a gateway, these helpers determine whether -//! to offer gateway bootstrap, prompt the user for confirmation, and execute the -//! local or remote bootstrap flow. +//! to attempt gateway bootstrap and execute the local or remote bootstrap flow. +//! Bootstrap proceeds automatically unless the user opts out with `--no-bootstrap`. -use crate::tls::TlsOptions; -use dialoguer::Confirm; +use std::time::Duration; + +use crate::tls::{TlsOptions, grpc_client}; use miette::Result; use owo_colors::OwoColorize; -use std::io::IsTerminal; use crate::run::{deploy_gateway_with_panel, print_deploy_summary}; @@ -95,46 +95,55 @@ fn is_connectivity_error(error: &miette::Report) -> bool { connectivity_patterns.iter().any(|p| lower.contains(p)) } -/// Prompt the user to confirm gateway bootstrap. +/// Decide whether gateway bootstrap should proceed. /// -/// When `override_value` is `Some(true)` or `Some(false)`, the decision is -/// made immediately (from `--bootstrap` / `--no-bootstrap`). Otherwise, -/// prompts interactively when stdin is a terminal, or returns an error in -/// non-interactive mode. +/// When `override_value` is `Some(false)` (from `--no-bootstrap`), returns +/// `false` to skip bootstrap. Otherwise returns `true` — a gateway is created +/// automatically without prompting the user. pub fn confirm_bootstrap(override_value: Option) -> Result { - // Explicit flag takes precedence over interactive detection. - if let Some(value) = override_value { - return Ok(value); + if let Some(false) = override_value { + return Ok(false); } + Ok(true) +} - if !std::io::stdin().is_terminal() { - return Err(miette::miette!( - "Gateway not reachable and bootstrap requires confirmation from an interactive terminal.\n\ - Pass --bootstrap to auto-confirm, or run 'openshell gateway start' first." - )); - } - - let confirmed = Confirm::new() - .with_prompt(format!( - "{} No gateway available to launch sandbox in. Create one now?", - "!".yellow() - )) - .default(true) - .interact() - .map_err(|e| miette::miette!("failed to read confirmation: {e}"))?; - - Ok(confirmed) +/// Resolve the gateway name for bootstrap. +/// +/// Respects `$OPENSHELL_GATEWAY` if set, otherwise falls back to the default. +fn resolve_bootstrap_name() -> String { + std::env::var("OPENSHELL_GATEWAY") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_GATEWAY_NAME.to_string()) } /// Bootstrap a local gateway and return refreshed TLS options that pick up the -/// newly-written mTLS certificates. +/// newly-written mTLS certificates, along with the gateway name used. pub async fn run_bootstrap( remote: Option<&str>, ssh_key: Option<&str>, -) -> Result<(TlsOptions, String)> { +) -> Result<(TlsOptions, String, String)> { + let gateway_name = resolve_bootstrap_name(); let location = if remote.is_some() { "remote" } else { "local" }; - let mut options = navigator_bootstrap::DeployOptions::new(DEFAULT_GATEWAY_NAME); + eprintln!(); + eprintln!( + "{} No gateway found — starting one automatically.", + "ℹ".cyan().bold() + ); + eprintln!(); + eprintln!(" The Gateway provides a secure control plane for OpenShell. It streamlines"); + eprintln!(" access for humans and agents alike — handles sandbox orchestration, and"); + eprintln!(" enables secure, concurrent agent workflows."); + eprintln!(); + eprintln!( + " Manage it later with: {} or {}", + "openshell gateway status".bold(), + "openshell gateway stop".bold(), + ); + eprintln!(); + + let mut options = navigator_bootstrap::DeployOptions::new(&gateway_name); if let Some(dest) = remote { let mut remote_opts = navigator_bootstrap::RemoteOptions::new(dest); if let Some(key) = ssh_key { @@ -151,23 +160,59 @@ pub async fn run_bootstrap( options = options.with_registry_token(token); } - let handle = deploy_gateway_with_panel(options, DEFAULT_GATEWAY_NAME, location).await?; + let handle = deploy_gateway_with_panel(options, &gateway_name, location).await?; let server = handle.gateway_endpoint().to_string(); - print_deploy_summary(DEFAULT_GATEWAY_NAME, &handle); + print_deploy_summary(&gateway_name, &handle); // Auto-activate the bootstrapped gateway. - if let Err(err) = navigator_bootstrap::save_active_gateway(DEFAULT_GATEWAY_NAME) { + if let Err(err) = navigator_bootstrap::save_active_gateway(&gateway_name) { tracing::debug!("failed to set active gateway after bootstrap: {err}"); } // Build fresh TLS options that resolve the newly-written mTLS certs from // the default XDG path for this gateway, using the gateway name directly. let tls = TlsOptions::default() - .with_gateway_name(DEFAULT_GATEWAY_NAME) + .with_gateway_name(&gateway_name) .with_default_paths(&server); - Ok((tls, server)) + // Wait for the gateway gRPC endpoint to accept connections before + // handing back to the caller. The Docker health check may pass before + // the gRPC listener is fully ready, so retry with backoff. + wait_for_grpc_ready(&server, &tls).await?; + + Ok((tls, server, gateway_name)) +} + +/// Retry connecting to the gateway gRPC endpoint until it succeeds or a +/// timeout is reached. Uses exponential backoff starting at 500 ms, doubling +/// up to 4 s, with a total deadline of 30 s. +async fn wait_for_grpc_ready(server: &str, tls: &TlsOptions) -> Result<()> { + const MAX_WAIT: Duration = Duration::from_secs(30); + const INITIAL_BACKOFF: Duration = Duration::from_millis(500); + + let start = std::time::Instant::now(); + let mut backoff = INITIAL_BACKOFF; + let mut last_err = None; + + while start.elapsed() < MAX_WAIT { + match grpc_client(server, tls).await { + Ok(_client) => return Ok(()), + Err(err) => { + tracing::debug!( + elapsed = ?start.elapsed(), + "gateway not yet accepting connections: {err:#}" + ); + last_err = Some(err); + } + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(4)); + } + + Err(last_err + .unwrap_or_else(|| miette::miette!("timed out waiting for gateway")) + .wrap_err("gateway deployed but not accepting connections after 30 s")) } #[cfg(test)] diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 619cd76dd..ac459b6ea 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -812,31 +812,47 @@ enum GatewayCommands { ssh_key: Option, }, - /// Add an edge-authenticated gateway. + /// Add an existing gateway. /// - /// Registers an external gateway endpoint that is fronted by an - /// edge proxy (e.g., Cloudflare Access). Opens a browser for - /// authentication and stores the token locally. After adding, the - /// gateway appears in `openshell gateway select`. + /// Registers a gateway endpoint so it appears in `openshell gateway select`. + /// + /// Without extra flags the gateway is treated as an edge-authenticated + /// (cloud) gateway and a browser is opened for authentication. + /// + /// Pass `--remote ` to register a remote mTLS gateway whose + /// Docker daemon is reachable over SSH. Pass `--local` to register a + /// local mTLS gateway running in Docker on this machine. In both cases + /// the CLI extracts mTLS certificates from the running container + /// automatically. + /// + /// An `ssh://` endpoint (e.g., `ssh://user@host:8080`) is shorthand + /// for `--remote user@host` with the endpoint derived from the URL. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Add { - /// Gateway endpoint URL (e.g., `https://8080-3vdegyusg.brevlab.com`). + /// Gateway endpoint URL (e.g., `https://10.0.0.5:8080` or `ssh://user@host:8080`). endpoint: String, /// Gateway name (auto-derived from the endpoint hostname when omitted). #[arg(long)] name: Option, - /// Skip browser authentication (authenticate later with `gateway login`). - #[arg(long)] - no_auth: bool, + /// Register a remote mTLS gateway accessible via SSH. + #[arg(long, conflicts_with = "local")] + remote: Option, + + /// SSH private key for the remote host (used with `--remote` or `ssh://`). + #[arg(long, value_hint = ValueHint::FilePath)] + ssh_key: Option, + + /// Register a local mTLS gateway running in Docker on this machine. + #[arg(long, conflicts_with = "remote")] + local: bool, }, /// Authenticate with an edge-authenticated gateway. /// /// Opens a browser for the edge proxy's login flow and stores the - /// token locally. Use this to re-authenticate when a token expires - /// or to authenticate a gateway added with `--no-auth`. + /// token locally. Use this to re-authenticate when a token expires. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Login { /// Gateway name (defaults to the active gateway). @@ -1066,14 +1082,12 @@ enum SandboxCommands { #[arg(long, overrides_with = "tty")] no_tty: bool, - /// Auto-bootstrap a gateway if none is available. - /// - /// Without this flag, an interactive prompt asks whether to bootstrap; - /// in non-interactive mode the command errors. + /// Auto-bootstrap a gateway if none is available (this is the default). #[arg( long, overrides_with = "no_bootstrap", - help_heading = "BOOTSTRAP FLAGS" + help_heading = "BOOTSTRAP FLAGS", + hide = true )] bootstrap: bool, @@ -1431,9 +1445,18 @@ async fn main() -> Result<()> { GatewayCommands::Add { endpoint, name, - no_auth, + remote, + ssh_key, + local, } => { - run::gateway_add(&endpoint, name.as_deref(), no_auth).await?; + run::gateway_add( + &endpoint, + name.as_deref(), + remote.as_deref(), + ssh_key.as_deref(), + local, + ) + .await?; } GatewayCommands::Login { name } => { let name = name @@ -1786,12 +1809,13 @@ async fn main() -> Result<()> { }; // Resolve --bootstrap / --no-bootstrap into an Option. + // Bootstrap is the default; --no-bootstrap opts out. let bootstrap_override = if no_bootstrap { Some(false) } else if bootstrap { Some(true) } else { - None // prompt or auto-detect + None // auto-bootstrap (default) }; // Resolve --auto-providers / --no-auto-providers. @@ -1828,6 +1852,10 @@ async fn main() -> Result<()> { let endpoint = &ctx.endpoint; let mut tls = tls.with_gateway_name(&ctx.name); apply_edge_auth(&mut tls, &ctx.name); + // The user already has a configured gateway. Disable + // auto-bootstrap in the retry path so we don't + // silently replace their selected gateway with a new + // "openshell" gateway if the connection fails. Box::pin(run::sandbox_create( endpoint, name.as_deref(), @@ -1843,7 +1871,7 @@ async fn main() -> Result<()> { forward, &command, tty_override, - bootstrap_override, + Some(false), auto_providers_override, &tls, )) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index a30a7205d..d0f52e7d3 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -17,8 +17,9 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result, WrapErr}; use navigator_bootstrap::{ DeployOptions, GatewayMetadata, RemoteOptions, clear_active_gateway, container_name, - get_gateway_metadata, list_gateways, load_active_gateway, remove_gateway_metadata, - save_active_gateway, save_last_sandbox, store_gateway_metadata, + extract_host_from_ssh_destination, get_gateway_metadata, list_gateways, load_active_gateway, + remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, + store_gateway_metadata, }; use navigator_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, @@ -743,32 +744,34 @@ pub fn gateway_select(name: Option<&str>, gateway_flag: &Option) -> Resu } fn format_gateway_select_header(gateways: &[GatewayMetadata]) -> String { - let (name_width, endpoint_width) = gateway_select_column_widths(gateways); + let (name_width, endpoint_width, type_width) = gateway_select_column_widths(gateways); format!( - " {: Vec { - let (name_width, endpoint_width) = gateway_select_column_widths(gateways); + let (name_width, endpoint_width, type_width) = gateway_select_column_widths(gateways); gateways .iter() .map(|gateway| { format!( - "{: (usize, usize) { +fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (usize, usize, usize) { let name_width = gateways .iter() .map(|gateway| gateway.name.len()) @@ -781,18 +784,28 @@ fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (usize, usize) .max() .unwrap_or(8) .max(8); + let type_width = gateways + .iter() + .map(|gateway| gateway_type_label(gateway).len()) + .max() + .unwrap_or(4) + .max(4); - (name_width, endpoint_width) + (name_width, endpoint_width, type_width) } fn gateway_type_label(gateway: &GatewayMetadata) -> &'static str { match gateway.auth_mode.as_deref() { - Some("cloudflare_jwt") => "edge", + Some("cloudflare_jwt") => "cloud", _ if gateway.is_remote => "remote", _ => "local", } } +fn gateway_auth_label(gateway: &GatewayMetadata) -> &str { + gateway.auth_mode.as_deref().unwrap_or("unknown") +} + fn gateway_select_with( name: Option<&str>, gateway_flag: &Option, @@ -834,18 +847,79 @@ where Ok(()) } -/// Register an external edge-authenticated gateway. +/// Register an existing gateway. /// -/// Creates local metadata for the given endpoint so it appears in -/// `gateway select`. When `no_auth` is false, opens a browser for -/// authentication and stores the bearer token locally. -pub async fn gateway_add(endpoint: &str, name: Option<&str>, no_auth: bool) -> Result<()> { - // Normalise the endpoint: ensure it has a scheme. - let endpoint = if endpoint.contains("://") { - endpoint.to_string() +/// Without extra flags the gateway is treated as an edge-authenticated (cloud) +/// gateway and a browser is opened for authentication. +/// +/// Pass `remote` (SSH destination) to register a remote mTLS gateway, or +/// `local = true` for a local mTLS gateway. In both cases the CLI extracts +/// mTLS certificates from the running container automatically. +/// +/// An `ssh://` endpoint (e.g., `ssh://user@host:8080`) is shorthand for +/// `--remote user@host` with the gateway endpoint derived from the URL. +pub async fn gateway_add( + endpoint: &str, + name: Option<&str>, + remote: Option<&str>, + ssh_key: Option<&str>, + local: bool, +) -> Result<()> { + // If the endpoint starts with ssh://, parse it into an SSH destination + // and a gateway endpoint automatically. The host is resolved via + // `ssh -G` so that SSH config aliases map to the real hostname/IP. + // e.g. ssh://drew@spark:8080 -> remote="drew@spark", endpoint="https://:8080" + let (endpoint, remote) = if endpoint.starts_with("ssh://") { + if local { + return Err(miette::miette!( + "Cannot use --local with an ssh:// endpoint.\n\ + ssh:// implies a remote gateway." + )); + } + if remote.is_some() { + return Err(miette::miette!( + "Cannot use --remote with an ssh:// endpoint.\n\ + The SSH destination is already embedded in the URL." + )); + } + let parsed = url::Url::parse(endpoint) + .map_err(|e| miette::miette!("Invalid ssh:// URL '{endpoint}': {e}"))?; + let host = parsed + .host_str() + .ok_or_else(|| miette::miette!("ssh:// URL must include a hostname: {endpoint}"))?; + let port = parsed + .port() + .ok_or_else(|| miette::miette!("ssh:// URL must include a port: {endpoint}"))?; + + let ssh_dest = if parsed.username().is_empty() { + host.to_string() + } else { + format!("{}@{host}", parsed.username()) + }; + // Resolve the SSH host alias (e.g. ~/.ssh/config HostName) so the + // endpoint uses the actual hostname/IP that matches the TLS certificate + // SANs — consistent with the `gateway start` path. + let resolved = resolve_ssh_hostname(host); + let https_endpoint = format!("https://{resolved}:{port}"); + + (https_endpoint, Some(ssh_dest)) } else { - format!("https://{endpoint}") + // Normalise the endpoint: ensure it has a scheme. + let endpoint = if endpoint.contains("://") { + endpoint.to_string() + } else { + format!("https://{endpoint}") + }; + (endpoint, remote.map(String::from)) }; + let remote = remote.as_deref(); + + // Validate --ssh-key requires a remote gateway context. + if ssh_key.is_some() && remote.is_none() { + return Err(miette::miette!( + "--ssh-key requires --remote or an ssh:// endpoint" + )); + } // Derive a gateway name from the hostname when none is provided. let derived_name; @@ -860,54 +934,109 @@ pub async fn gateway_add(endpoint: &str, name: Option<&str>, no_auth: bool) -> R &derived_name }; - // Build metadata for an edge-authenticated remote gateway. - let metadata = GatewayMetadata { - name: name.to_string(), - gateway_endpoint: endpoint.clone(), - is_remote: true, - gateway_port: 0, - remote_host: None, - resolved_host: None, - auth_mode: Some("cloudflare_jwt".to_string()), - edge_team_domain: None, - edge_auth_url: None, - }; + // Fail if a gateway with this name already exists. + if get_gateway_metadata(name).is_some() { + return Err(miette::miette!( + "Gateway '{}' already exists.\n\ + Remove it first with: openshell gateway destroy --name {}\n\ + Or choose a different name with: --name ", + name, + name, + )); + } - store_gateway_metadata(name, &metadata)?; - save_active_gateway(name)?; + if remote.is_some() || local { + // mTLS gateway (remote or local). + let remote_opts = remote.map(|dest| { + let mut opts = RemoteOptions::new(dest); + if let Some(key) = ssh_key { + opts = opts.with_ssh_key(key); + } + opts + }); + + // Extract certs BEFORE storing metadata — if this fails the gateway + // is not registered. Pass the endpoint port so the container can be + // identified by its host port binding when multiple gateways run on + // the same Docker host. + let endpoint_port = url::Url::parse(&endpoint).ok().and_then(|u| u.port()); + eprintln!("• Extracting TLS certificates from gateway container..."); + navigator_bootstrap::extract_and_store_pki(name, remote_opts.as_ref(), endpoint_port) + .await?; + + let (remote_host, resolved_host) = if let Some(dest) = remote { + let ssh_host = extract_host_from_ssh_destination(dest); + let resolved = resolve_ssh_hostname(&ssh_host); + (Some(dest.to_string()), Some(resolved)) + } else { + (None, None) + }; + + let metadata = GatewayMetadata { + name: name.to_string(), + gateway_endpoint: endpoint.clone(), + is_remote: !local, + gateway_port: 0, + remote_host, + resolved_host, + auth_mode: Some("mtls".to_string()), + edge_team_domain: None, + edge_auth_url: None, + }; + + store_gateway_metadata(name, &metadata)?; + save_active_gateway(name)?; - if no_auth { eprintln!( "{} Gateway '{}' added and set as active", "✓".green().bold(), name, ); - eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint,); + eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint); + eprintln!( + " {} {}", + "Type:".dimmed(), + if local { "local" } else { "remote" }, + ); + eprintln!("{} TLS certificates extracted", "✓".green().bold()); + } else { + // Cloud (edge-authenticated) gateway. + let metadata = GatewayMetadata { + name: name.to_string(), + gateway_endpoint: endpoint.clone(), + is_remote: true, + gateway_port: 0, + remote_host: None, + resolved_host: None, + auth_mode: Some("cloudflare_jwt".to_string()), + edge_team_domain: None, + edge_auth_url: None, + }; + + store_gateway_metadata(name, &metadata)?; + save_active_gateway(name)?; + + eprintln!( + "{} Gateway '{}' added and set as active", + "✓".green().bold(), + name, + ); + eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint); + eprintln!(" {} cloud", "Type:".dimmed()); eprintln!(); - eprintln!("Authenticate with: {}", "openshell gateway login".dimmed(),); - return Ok(()); - } - // Run the browser-based auth flow. - eprintln!( - "{} Gateway '{}' added and set as active", - "✓".green().bold(), - name, - ); - eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint,); - eprintln!(); - - match crate::auth::browser_auth_flow(&endpoint).await { - Ok(token) => { - navigator_bootstrap::edge_token::store_edge_token(name, &token)?; - eprintln!("{} Authenticated successfully", "✓".green().bold(),); - } - Err(e) => { - eprintln!("{} Authentication skipped: {e}", "!".yellow(),); - eprintln!( - " Authenticate later with: {}", - "openshell gateway login".dimmed(), - ); + match crate::auth::browser_auth_flow(&endpoint).await { + Ok(token) => { + navigator_bootstrap::edge_token::store_edge_token(name, &token)?; + eprintln!("{} Authenticated successfully", "✓".green().bold()); + } + Err(e) => { + eprintln!("{} Authentication skipped: {e}", "!".yellow()); + eprintln!( + " Authenticate later with: {}", + "openshell gateway login".dimmed(), + ); + } } } @@ -968,23 +1097,31 @@ pub fn gateway_list(gateway_flag: &Option) -> Result<()> { .max() .unwrap_or(8) .max(8); + let type_width = gateways + .iter() + .map(|g| gateway_type_label(g).len()) + .max() + .unwrap_or(4) + .max(4); // Print header println!( - " {: String { /// Create a sandbox when no gateway is configured. /// -/// Offers to bootstrap a new gateway first, then delegates to [`sandbox_create`]. +/// Bootstraps a new gateway first, then delegates to [`sandbox_create`]. #[allow(clippy::too_many_arguments)] pub async fn sandbox_create_with_bootstrap( name: Option<&str>, @@ -1581,14 +1718,14 @@ pub async fn sandbox_create_with_bootstrap( Or deploy a new gateway: openshell gateway start" )); } - let (tls, server) = crate::bootstrap::run_bootstrap(remote, ssh_key).await?; - // The bootstrap flow always creates a gateway named "openshell". - let gateway_name = "openshell"; + let (tls, server, gateway_name) = crate::bootstrap::run_bootstrap(remote, ssh_key).await?; + // Disable bootstrap inside sandbox_create so that a transient connection + // failure right after deploy does not trigger a second bootstrap attempt. sandbox_create( &server, name, from, - gateway_name, + &gateway_name, upload, keep, editor, @@ -1599,7 +1736,7 @@ pub async fn sandbox_create_with_bootstrap( forward, command, tty_override, - bootstrap_override, + Some(false), auto_providers_override, &tls, ) @@ -1633,8 +1770,13 @@ pub async fn sandbox_create( )); } - // Try connecting to the gateway. If it fails due to an unreachable gateway, - // offer to bootstrap a local one and retry. + // Try connecting to the gateway. If the connection fails due to a + // connectivity error and bootstrap is allowed, start a new gateway. + // + // bootstrap_override is Some(false) when: + // - the user passed --no-bootstrap + // - an existing gateway was already resolved (don't replace it) + // - we already bootstrapped once (don't double-bootstrap) let (mut client, effective_server, effective_tls) = match grpc_client(server, tls).await { Ok(c) => (c, server.to_string(), tls.clone()), Err(err) => { @@ -1642,9 +1784,26 @@ pub async fn sandbox_create( return Err(err); } if !crate::bootstrap::confirm_bootstrap(bootstrap_override)? { + // The gateway is configured but not reachable. Give the user + // actionable recovery steps instead of a raw connection error. + eprintln!(); + eprintln!( + "{} Gateway '{}' is not reachable.", + "!".yellow(), + gateway_name, + ); + eprintln!(); + eprintln!(" To destroy and recreate the gateway:"); + eprintln!(); + eprintln!( + " {} && {}", + format!("openshell gateway destroy {gateway_name}").cyan(), + "openshell gateway start".cyan(), + ); + eprintln!(); return Err(err); } - let (new_tls, new_server) = crate::bootstrap::run_bootstrap(remote, ssh_key).await?; + let (new_tls, new_server, _) = crate::bootstrap::run_bootstrap(remote, ssh_key).await?; let c = grpc_client(&new_server, &new_tls) .await .wrap_err("bootstrap succeeded but failed to connect to gateway")?; @@ -3997,8 +4156,8 @@ fn format_timestamp_ms(ms: i64) -> String { mod tests { use super::{ GatewayControlTarget, TlsOptions, format_gateway_select_header, - format_gateway_select_items, gateway_select_with, gateway_type_label, git_sync_files, - http_health_check, inferred_provider_type, parse_credential_pairs, + format_gateway_select_items, gateway_auth_label, gateway_select_with, gateway_type_label, + git_sync_files, http_health_check, inferred_provider_type, parse_credential_pairs, resolve_gateway_control_target_from, }; use crate::TEST_ENV_LOCK; @@ -4313,15 +4472,20 @@ mod tests { let items = format_gateway_select_items(&gateways); let header = format_gateway_select_header(&gateways); - assert_eq!(gateway_type_label(&gateways[0]), "edge"); + assert_eq!(gateway_type_label(&gateways[0]), "cloud"); assert_eq!(gateway_type_label(&gateways[1]), "local"); + assert_eq!(gateway_auth_label(&gateways[0]), "cloudflare_jwt"); + assert_eq!(gateway_auth_label(&gateways[1]), "unknown"); assert!(header.contains("NAME")); assert!(header.contains("ENDPOINT")); assert!(header.contains("TYPE")); + assert!(header.contains("AUTH")); assert!(items[0].contains("alpha")); assert!(items[0].contains("https://edge.example.com")); - assert!(items[0].contains("edge")); + assert!(items[0].contains("cloud")); + assert!(items[0].contains("cloudflare_jwt")); assert!(items[1].contains("local")); + assert!(items[1].contains("unknown")); assert!(items[1].contains("http://127.0.0.1:8080")); } diff --git a/e2e/rust/tests/cf_auth_smoke.rs b/e2e/rust/tests/cf_auth_smoke.rs index 46c894651..0db4a613b 100644 --- a/e2e/rust/tests/cf_auth_smoke.rs +++ b/e2e/rust/tests/cf_auth_smoke.rs @@ -89,21 +89,29 @@ async fn gateway_help_shows_add_and_login() { ); } -/// `openshell gateway add --help` must show the endpoint arg and `--no-auth` flag. +/// `openshell gateway add --help` must show the endpoint arg and gateway-type flags. #[tokio::test] async fn gateway_add_help_shows_flags() { let (output, code) = run_isolated(&["gateway", "add", "--help"]).await; assert_eq!(code, 0, "gateway add --help should exit 0:\n{output}"); let clean = strip_ansi(&output); - assert!( - clean.contains("--no-auth"), - "expected '--no-auth' in gateway add --help:\n{clean}" - ); assert!( clean.contains("--name"), "expected '--name' in gateway add --help:\n{clean}" ); + assert!( + clean.contains("--remote"), + "expected '--remote' in gateway add --help:\n{clean}" + ); + assert!( + clean.contains("--ssh-key"), + "expected '--ssh-key' in gateway add --help:\n{clean}" + ); + assert!( + clean.contains("--local"), + "expected '--local' in gateway add --help:\n{clean}" + ); assert!( // The positional argument for the endpoint clean.contains("endpoint") || clean.contains(""), @@ -127,13 +135,13 @@ async fn gateway_login_help_is_recognized() { } // ------------------------------------------------------------------- -// Test 10: `gateway add --no-auth` creates metadata with cloudflare_jwt +// Test 10: `gateway add` creates metadata with cloudflare_jwt // ------------------------------------------------------------------- -/// `openshell gateway add --no-auth` should: +/// `openshell gateway add ` (cloud gateway) should: /// - Create cluster metadata with auth_mode = "cloudflare_jwt" /// - Set the gateway as active -/// - Not attempt browser authentication +/// - Attempt browser authentication (which will fail in CI — non-fatal) #[tokio::test] async fn gateway_add_creates_cf_metadata() { let tmpdir = tempfile::tempdir().expect("create config dir"); @@ -146,14 +154,13 @@ async fn gateway_add_creates_cf_metadata() { "https://my-gateway.example.com", "--name", "test-cf-gw", - "--no-auth", ], ) .await; assert_eq!( code, 0, - "gateway add --no-auth should exit 0:\n{output}" + "gateway add should exit 0 (auth failure is non-fatal):\n{output}" ); // Verify the metadata file was written. @@ -230,14 +237,13 @@ async fn gateway_add_derives_name_from_hostname() { "gateway", "add", "https://my-special-gateway.brevlab.com", - "--no-auth", ], ) .await; assert_eq!( code, 0, - "gateway add --no-auth should exit 0:\n{output}" + "gateway add should exit 0:\n{output}" ); // The derived name should be the hostname. @@ -254,4 +260,153 @@ async fn gateway_add_derives_name_from_hostname() { ); } +// ------------------------------------------------------------------- +// Test 11: `gateway add` flag constraints +// ------------------------------------------------------------------- + +/// `--remote` and `--local` are mutually exclusive. +#[tokio::test] +async fn gateway_add_remote_and_local_conflict() { + let (output, code) = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--remote", + "user@host", + "--local", + ]) + .await; + + assert_ne!( + code, 0, + "--remote and --local together should fail:\n{output}" + ); +} + +/// `--ssh-key` requires `--remote`. +#[tokio::test] +async fn gateway_add_ssh_key_requires_remote() { + let (output, code) = run_isolated(&[ + "gateway", + "add", + "https://example.com", + "--ssh-key", + "/tmp/fake-key", + ]) + .await; + + assert_ne!( + code, 0, + "--ssh-key without --remote should fail:\n{output}" + ); +} + +// ------------------------------------------------------------------- +// Test 12: `gateway add` rejects duplicate names +// ------------------------------------------------------------------- + +/// Adding a gateway with a name that already exists should fail. +#[tokio::test] +async fn gateway_add_rejects_duplicate_name() { + let tmpdir = tempfile::tempdir().expect("create config dir"); + + // First add should succeed. + let (output, code) = run_with_config( + tmpdir.path(), + &[ + "gateway", + "add", + "https://first.example.com", + "--name", + "my-gw", + ], + ) + .await; + assert_eq!(code, 0, "first gateway add should succeed:\n{output}"); + + // Second add with the same name should fail. + let (output, code) = run_with_config( + tmpdir.path(), + &[ + "gateway", + "add", + "https://second.example.com", + "--name", + "my-gw", + ], + ) + .await; + assert_ne!( + code, 0, + "duplicate gateway add should fail:\n{output}" + ); + + let clean = strip_ansi(&output); + assert!( + clean.contains("already exists"), + "error should mention 'already exists':\n{clean}" + ); +} + +// ------------------------------------------------------------------- +// Test 13: `gateway add ssh://` shorthand constraints +// ------------------------------------------------------------------- + +/// `ssh://` endpoint with `--local` should fail. +#[tokio::test] +async fn gateway_add_ssh_url_conflicts_with_local() { + let (output, code) = run_isolated(&[ + "gateway", + "add", + "ssh://user@host:8080", + "--local", + ]) + .await; + + assert_ne!( + code, 0, + "ssh:// with --local should fail:\n{output}" + ); +} + +/// `ssh://` endpoint with `--remote` should fail (redundant). +#[tokio::test] +async fn gateway_add_ssh_url_conflicts_with_remote() { + let (output, code) = run_isolated(&[ + "gateway", + "add", + "ssh://user@host:8080", + "--remote", + "user@host", + ]) + .await; + + assert_ne!( + code, 0, + "ssh:// with --remote should fail:\n{output}" + ); +} + +/// `ssh://` endpoint without a port should fail. +#[tokio::test] +async fn gateway_add_ssh_url_requires_port() { + let (output, code) = run_isolated(&[ + "gateway", + "add", + "ssh://user@host", + ]) + .await; + + assert_ne!( + code, 0, + "ssh:// without port should fail:\n{output}" + ); + + let clean = strip_ansi(&output); + assert!( + clean.contains("port"), + "error should mention port:\n{clean}" + ); +} +