mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
feat(gateway): support adding remote and local gateways (#262)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user