feat(gateway): support adding remote and local gateways (#262)

This commit is contained in:
Drew Newberry
2026-03-12 16:19:47 -07:00
committed by GitHub
parent fcf12dff62
commit 6a40f2bed8
6 changed files with 630 additions and 149 deletions
+65
View File
@@ -121,6 +121,71 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result<Docker>
.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<u16>) -> Result<String> {
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<String> = 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
+29 -5
View File
@@ -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<u16>,
) -> 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<String> {
let docker = Docker::connect_with_local_defaults().into_diagnostic()?;
let image_ref = format!("{}:{version}", image::DEFAULT_GATEWAY_IMAGE);
+83 -38
View File
@@ -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<bool>) -> Result<bool> {
// 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)]
+48 -20
View File
@@ -812,31 +812,47 @@ enum GatewayCommands {
ssh_key: Option<String>,
},
/// 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 <ssh-dest>` 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<String>,
/// 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<String>,
/// SSH private key for the remote host (used with `--remote` or `ssh://`).
#[arg(long, value_hint = ValueHint::FilePath)]
ssh_key: Option<String>,
/// 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<bool>.
// 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,
))
+238 -74
View File
@@ -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<String>) -> 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!(
" {:<name_width$} {:<endpoint_width$} {}",
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {}",
"NAME".bold(),
"ENDPOINT".bold(),
"TYPE".bold(),
"AUTH".bold(),
)
}
fn format_gateway_select_items(gateways: &[GatewayMetadata]) -> Vec<String> {
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!(
"{:<name_width$} {:<endpoint_width$} {}",
"{:<name_width$} {:<endpoint_width$} {:<type_width$} {}",
gateway.name,
gateway.gateway_endpoint,
gateway_type_label(gateway),
gateway_auth_label(gateway),
)
})
.collect()
}
fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (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<F>(
name: Option<&str>,
gateway_flag: &Option<String>,
@@ -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://<resolved>: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,
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<String>) -> 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!(
" {:<name_width$} {:<endpoint_width$} {}",
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {}",
"NAME".bold(),
"ENDPOINT".bold(),
"TYPE".bold(),
"AUTH".bold(),
);
// Print rows
for gateway in &gateways {
let is_active = active.as_deref() == Some(&gateway.name);
let marker = if is_active { "*" } else { " " };
let gateway_type = gateway_type_label(gateway);
let gw_type = gateway_type_label(gateway);
let gw_auth = gateway_auth_label(gateway);
let line = format!(
"{marker} {:<name_width$} {:<endpoint_width$} {gateway_type}",
gateway.name, gateway.gateway_endpoint,
"{marker} {:<name_width$} {:<endpoint_width$} {:<type_width$} {gw_auth}",
gateway.name, gateway.gateway_endpoint, gw_type,
);
if is_active {
println!("{}", line.green());
@@ -1556,7 +1693,7 @@ fn shell_escape(s: &str) -> 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"));
}
+167 -12
View File
@@ -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("<ENDPOINT>"),
@@ -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 <endpoint> --no-auth` should:
/// `openshell gateway add <endpoint>` (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}"
);
}