Files
Seth Jennings 2493d415c2 feat(extensions)!: normalize protocol negotiation (#3352)
* feat(extensions)!: normalize protocol negotiation

Closes #3057

Introduce a shared extension handshake, enforce protocol and capability compatibility across extension families, and expose immutable negotiated snapshots through gateway info and the Go SDK.

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(credentials): fail fast on negotiation errors

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(extensions): validate gateway handshake metadata

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(go-sdk): re-export extension kind constants

Signed-off-by: Seth Jennings <sjenning@redhat.com>

* fix(extensions): fail fast on credential handshake rejection

Signed-off-by: Seth Jennings <sjenning@redhat.com>

---------

Signed-off-by: Seth Jennings <sjenning@redhat.com>
2026-09-21 13:19:39 -07:00

2407 lines
84 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use crate::color::Colorize;
use crate::tls::{
TlsOptions, build_insecure_rustls_config, build_rustls_config, grpc_client,
require_tls_materials,
};
use bytes::Bytes;
use dialoguer::{Select, theme::ColorfulTheme};
use http_body_util::Full;
use hyper::{Request, StatusCode};
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use openshell_bootstrap::{
GatewayMetadata, clear_active_gateway, extract_host_from_ssh_destination,
gateway_metadata_source, get_gateway_metadata, list_gateways, list_gateways_with_source,
load_active_gateway, load_user_active_gateway, remove_gateway_metadata, resolve_ssh_hostname,
save_active_gateway, store_gateway_metadata,
};
use openshell_bootstrap::{GatewayMetadataSource, ListedGateway};
use openshell_core::proto::{ExtensionKind, GetGatewayInfoRequest, HealthRequest, ServiceStatus};
use std::io::IsTerminal;
use std::path::PathBuf;
use tonic::{Code, Status};
#[derive(Debug, Clone)]
struct GatewayInfoView {
gateway: String,
server: String,
auth: Option<&'static str>,
status: String,
version: String,
compute_drivers: Vec<ComputeDriverInfoView>,
extensions: Vec<ExtensionInfoView>,
}
#[derive(Debug, Clone)]
struct ComputeDriverInfoView {
name: String,
capabilities: ComputeDriverCapabilitiesView,
}
#[derive(Debug, Clone)]
struct ComputeDriverCapabilitiesView {
driver_name: String,
driver_version: String,
}
#[derive(Debug, Clone)]
struct ExtensionInfoView {
kind: String,
configured_name: String,
implementation_name: String,
implementation_version: String,
protocol_version: String,
supported_capabilities: Vec<String>,
required_capabilities: Vec<String>,
}
/// Show gateway status.
#[allow(clippy::branches_sharing_code)]
pub async fn gateway_status(
gateway_name: &str,
server: &str,
output: &str,
tls: &TlsOptions,
auth_preparation_error: Option<&str>,
) -> Result<()> {
// Build status data before any output.
let is_bearer = tls.is_bearer_auth();
let (status_str, version, error, http_status, authentication): (
&str,
Option<String>,
Option<String>,
Option<String>,
GatewayAuthenticationState,
) = match grpc_client(server, tls).await {
Ok(mut client) => match client.health(HealthRequest {}).await {
Ok(response) => {
let health = response.into_inner();
let auth = match auth_preparation_error {
Some(e) => GatewayAuthenticationState::Failed(e.to_string()),
None => gateway_authentication_state(
client
.get_gateway_info(GetGatewayInfoRequest {})
.await
.map(|_| ()),
tls,
server,
),
};
("connected", Some(health.version), None, None, auth)
}
Err(e) => {
let auth = auth_preparation_error.map_or_else(
|| {
GatewayAuthenticationState::Unverified(
"gRPC health check failed".to_string(),
)
},
|err| GatewayAuthenticationState::Failed(err.to_string()),
);
http_health_check(server, tls).await?.map_or_else(
|| ("error", None, Some(e.to_string()), None, auth.clone()),
|http| {
let hs = Some(http.to_string());
if http.is_success() {
(
"connected_http",
None,
Some(e.to_string()),
hs,
auth.clone(),
)
} else {
("error", None, Some(e.to_string()), hs, auth.clone())
}
},
)
}
},
Err(e) => {
let auth = auth_preparation_error.map_or_else(
|| GatewayAuthenticationState::Unverified("gateway unreachable".to_string()),
|err| GatewayAuthenticationState::Failed(err.to_string()),
);
http_health_check(server, tls).await?.map_or_else(
|| {
(
"disconnected",
None,
Some(e.to_string()),
None,
auth.clone(),
)
},
|http| {
let hs = Some(http.to_string());
if http.is_success() {
(
"connected_http",
None,
Some(e.to_string()),
hs,
auth.clone(),
)
} else {
("disconnected", None, Some(e.to_string()), hs, auth.clone())
}
},
)
}
};
let json_data = status_to_json(
gateway_name,
server,
is_bearer,
status_str,
&version,
&error,
&http_status,
&authentication,
);
if crate::output::print_output_single(output, &json_data, Clone::clone)? {
return Ok(());
}
// Human-readable output.
println!("{}", "Server Status".cyan().bold());
println!();
println!(" {} {}", "Gateway:".dimmed(), gateway_name);
println!(" {} {}", "Server:".dimmed(), server);
match status_str {
"connected" => {
println!(" {} {}", "Status:".dimmed(), "Connected".green());
print_gateway_authentication_state(&authentication);
if let Some(ref v) = version {
println!(" {} {}", "Version:".dimmed(), v);
}
}
"connected_http" => {
println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow());
if let Some(ref hs) = http_status {
println!(" {} {}", "HTTP: ".dimmed(), hs);
}
if let Some(ref e) = error {
println!(" {} {}", "gRPC error:".dimmed(), e);
}
print_gateway_authentication_state(&authentication);
}
"error" => {
println!(" {} {}", "Status:".dimmed(), "Error".red());
if let Some(ref hs) = http_status {
println!(" {} {}", "HTTP:".dimmed(), hs);
if let Some(ref e) = error {
println!(" {} {}", "gRPC error:".dimmed(), e);
}
} else if let Some(ref e) = error {
println!(" {} {}", "Error:".dimmed(), e);
}
print_gateway_authentication_state(&authentication);
}
_ => {
println!(" {} {}", "Status:".dimmed(), "Disconnected".red());
if let Some(ref hs) = http_status {
println!(" {} {}", "HTTP:".dimmed(), hs);
}
if let Some(ref e) = error {
println!(" {} {}", "Error:".dimmed(), e);
}
print_gateway_authentication_state(&authentication);
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GatewayAuthenticationState {
Authenticated(&'static str),
NotRequired(&'static str),
Failed(String),
Unverified(String),
}
fn gateway_authentication_state(
result: std::result::Result<(), Status>,
tls: &TlsOptions,
server: &str,
) -> GatewayAuthenticationState {
match result {
Ok(()) if tls.oidc_token.is_some() => GatewayAuthenticationState::Authenticated("OIDC"),
Ok(()) if tls.edge_token.is_some() => GatewayAuthenticationState::Authenticated("edge"),
Ok(()) if server.starts_with("https://") => {
GatewayAuthenticationState::Authenticated("mTLS transport")
}
Ok(()) => GatewayAuthenticationState::NotRequired("gateway"),
Err(status) if status.code() == Code::Unauthenticated => {
GatewayAuthenticationState::Failed(status.message().to_string())
}
Err(status) if status.code() == Code::PermissionDenied => {
let provider = if tls.oidc_token.is_some() {
"OIDC; authorization denied"
} else if tls.edge_token.is_some() {
"edge; authorization denied"
} else if server.starts_with("https://") {
"mTLS transport; authorization denied"
} else {
"authorization denied"
};
GatewayAuthenticationState::Authenticated(provider)
}
Err(status) if status.code() == Code::Unimplemented && tls.edge_token.is_some() => {
GatewayAuthenticationState::Authenticated("edge")
}
Err(status)
if status.code() == Code::Unimplemented
&& !tls.is_bearer_auth()
&& server.starts_with("http://") =>
{
GatewayAuthenticationState::NotRequired("gateway")
}
Err(status)
if status.code() == Code::Unimplemented
&& !tls.is_bearer_auth()
&& server.starts_with("https://") =>
{
GatewayAuthenticationState::Authenticated("mTLS transport")
}
Err(status) if status.code() == Code::Unimplemented => {
GatewayAuthenticationState::Unverified(
"gateway does not support authentication checks".to_string(),
)
}
Err(status) => GatewayAuthenticationState::Unverified(status.message().to_string()),
}
}
fn print_gateway_authentication_state(state: &GatewayAuthenticationState) {
match state {
GatewayAuthenticationState::Authenticated(provider) => println!(
" {} {} ({provider})",
"Authentication:".dimmed(),
"Authenticated".green()
),
GatewayAuthenticationState::NotRequired(mode) => println!(
" {} {} ({mode})",
"Authentication:".dimmed(),
"Not required".green()
),
GatewayAuthenticationState::Failed(error) => println!(
" {} {} ({error})",
"Authentication:".dimmed(),
"Failed".red()
),
GatewayAuthenticationState::Unverified(reason) => println!(
" {} {} ({reason})",
"Authentication:".dimmed(),
"Unverified".yellow()
),
}
}
fn authentication_state_to_json(state: &GatewayAuthenticationState) -> serde_json::Value {
match state {
GatewayAuthenticationState::Authenticated(provider) => serde_json::json!({
"status": "authenticated",
"provider": provider,
}),
GatewayAuthenticationState::NotRequired(mode) => serde_json::json!({
"status": "not_required",
"mode": mode,
}),
GatewayAuthenticationState::Failed(error) => serde_json::json!({
"status": "failed",
"error": error,
}),
GatewayAuthenticationState::Unverified(reason) => serde_json::json!({
"status": "unverified",
"reason": reason,
}),
}
}
#[allow(clippy::too_many_arguments)]
fn status_to_json(
gateway_name: &str,
server: &str,
is_bearer: bool,
status: &str,
version: &Option<String>,
error: &Option<String>,
http_status: &Option<String>,
authentication: &GatewayAuthenticationState,
) -> serde_json::Value {
let mut obj = serde_json::json!({
"gateway": gateway_name,
"server": server,
"status": status,
"authentication": authentication_state_to_json(authentication),
});
let map = obj.as_object_mut().expect("json! returns object");
if is_bearer {
map.insert("auth".into(), serde_json::json!("edge_bearer"));
}
if let Some(v) = version {
map.insert("version".into(), serde_json::json!(v));
}
if let Some(hs) = http_status {
map.insert("http_status".into(), serde_json::json!(hs));
}
if let Some(e) = error {
map.insert("error".into(), serde_json::json!(e));
}
obj
}
fn gateway_service_status_name(status: i32) -> &'static str {
match ServiceStatus::try_from(status) {
Ok(ServiceStatus::Healthy) => "healthy",
Ok(ServiceStatus::Degraded) => "degraded",
Ok(ServiceStatus::Unhealthy) => "unhealthy",
Ok(ServiceStatus::Unspecified) | Err(_) => "unknown",
}
}
/// Show elevated gateway runtime information.
pub async fn gateway_info(
gateway_name: &str,
server: &str,
tls: &TlsOptions,
output: &str,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let info = client
.get_gateway_info(GetGatewayInfoRequest {})
.await
.map_err(|err| match err.code() {
Code::Unimplemented => {
miette!("gateway info is not supported by this gateway version")
}
Code::PermissionDenied => miette!("gateway info requires admin privileges: {err}"),
_ => miette!("get_gateway_info failed: {err}"),
})?
.into_inner();
let extensions = info
.extensions
.into_iter()
.map(|extension| ExtensionInfoView {
kind: extension_kind_name(extension.kind).to_string(),
configured_name: extension.configured_name,
implementation_name: extension.implementation_name,
implementation_version: extension.implementation_version,
protocol_version: format!("{}.{}", extension.protocol_major, extension.protocol_minor),
supported_capabilities: extension.supported_capabilities,
required_capabilities: extension.required_capabilities,
})
.collect();
let view = GatewayInfoView {
gateway: gateway_name.to_string(),
server: server.to_string(),
auth: tls.is_bearer_auth().then_some("bearer"),
status: gateway_service_status_name(info.status).to_string(),
version: info.gateway_version,
compute_drivers: info
.compute_drivers
.into_iter()
.map(|driver| {
let capabilities = driver.capabilities.unwrap_or_default();
ComputeDriverInfoView {
name: driver.name,
capabilities: ComputeDriverCapabilitiesView {
driver_name: capabilities.driver_name,
driver_version: capabilities.driver_version,
},
}
})
.collect(),
extensions,
};
print_gateway_info(&view, output)
}
pub fn gateway_info_not_configured() -> Result<()> {
Err(miette!(
"No gateway configured.\nRegister a gateway with: openshell gateway add <endpoint>"
))
}
fn print_gateway_info(view: &GatewayInfoView, output: &str) -> Result<()> {
if crate::output::print_output_single(output, view, gateway_info_to_json)? {
return Ok(());
}
println!("{}", "Gateway Info".cyan().bold());
println!();
println!(" {} {}", "Gateway:".dimmed(), view.gateway);
println!(" {} {}", "Server:".dimmed(), view.server);
if view.auth.is_some() {
println!(" {} Edge (bearer token)", "Auth:".dimmed());
}
println!(" {} {}", "Status:".dimmed(), view.status);
println!(" {} {}", "Version:".dimmed(), view.version);
print_compute_driver_info(&view.compute_drivers);
print_extension_info(&view.extensions);
Ok(())
}
fn extension_kind_name(kind: i32) -> &'static str {
match ExtensionKind::try_from(kind).unwrap_or(ExtensionKind::Unspecified) {
ExtensionKind::ComputeDriver => "compute-driver",
ExtensionKind::CredentialDriver => "credential-driver",
ExtensionKind::GatewayInterceptor => "gateway-interceptor",
ExtensionKind::SupervisorMiddleware => "supervisor-middleware",
ExtensionKind::Unspecified => "unspecified",
}
}
fn print_extension_info(extensions: &[ExtensionInfoView]) {
if extensions.is_empty() {
return;
}
println!(" {}", "Extensions:".dimmed());
for extension in extensions {
println!(" {} ({})", extension.configured_name, extension.kind);
println!(
" {} {} {} (protocol {})",
"Implementation:".dimmed(),
extension.implementation_name,
extension.implementation_version,
extension.protocol_version
);
if !extension.supported_capabilities.is_empty() {
println!(
" {} {}",
"Capabilities:".dimmed(),
extension.supported_capabilities.join(", ")
);
}
if !extension.required_capabilities.is_empty() {
println!(
" {} {}",
"Requires gateway:".dimmed(),
extension.required_capabilities.join(", ")
);
}
}
}
fn print_compute_driver_info(drivers: &[ComputeDriverInfoView]) {
if drivers.is_empty() {
return;
}
println!(" {}", "Compute drivers:".dimmed());
for driver in drivers {
println!(" {}", driver.name);
if driver.capabilities.driver_name != driver.name {
println!(
" {} {}",
"Driver name:".dimmed(),
driver.capabilities.driver_name
);
}
println!(
" {} {}",
"Driver version:".dimmed(),
driver.capabilities.driver_version
);
}
}
fn gateway_info_to_json(view: &GatewayInfoView) -> serde_json::Value {
serde_json::json!({
"gateway": &view.gateway,
"server": &view.server,
"auth": view.auth,
"status": &view.status,
"version": &view.version,
"compute_drivers": view
.compute_drivers
.iter()
.map(|driver| serde_json::json!({
"name": &driver.name,
"capabilities": {
"driver_name": &driver.capabilities.driver_name,
"driver_version": &driver.capabilities.driver_version,
},
}))
.collect::<Vec<_>>(),
"extensions": view.extensions.iter().map(|extension| serde_json::json!({
"kind": &extension.kind,
"configured_name": &extension.configured_name,
"implementation_name": &extension.implementation_name,
"implementation_version": &extension.implementation_version,
"protocol_version": &extension.protocol_version,
"supported_capabilities": &extension.supported_capabilities,
"required_capabilities": &extension.required_capabilities,
})).collect::<Vec<_>>(),
})
}
/// Set the active gateway.
pub fn gateway_use(name: &str) -> Result<()> {
// Verify the gateway exists
get_gateway_metadata(name).ok_or_else(|| {
miette::miette!(
"No gateway metadata found for '{name}'.\n\
Register it first with: openshell gateway add <endpoint> --name {name}\n\
Or list available gateways: openshell gateway select"
)
})?;
save_active_gateway(name)?;
eprintln!("{} Active gateway set to '{name}'", "✓".green().bold());
if let Some(warning) = gateway_env_override_warning(name) {
eprintln!("{} {warning}", "⚠".yellow().bold());
}
Ok(())
}
fn gateway_env_override_warning(selected_name: &str) -> Option<String> {
let env_name = std::env::var("OPENSHELL_GATEWAY").ok()?;
if env_name.is_empty() || env_name == selected_name {
return None;
}
Some(format!(
"OPENSHELL_GATEWAY={env_name} is set and will override this selection.\n Unset it or run: export OPENSHELL_GATEWAY={selected_name}"
))
}
pub fn gateway_select(name: Option<&str>, gateway_flag: &Option<String>) -> Result<()> {
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
gateway_select_with(name, gateway_flag, interactive, |gateways, default| {
let prompt = format!(
"Select a gateway\n{}",
format_gateway_select_header(gateways)
);
let items = format_gateway_select_items(gateways);
Select::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.items(&items)
.default(default)
.report(false)
.interact_opt()
.into_diagnostic()
.map(|selection| selection.map(|index| gateways[index].name.clone()))
})
}
fn format_gateway_select_header(gateways: &[GatewayMetadata]) -> String {
let (name_width, endpoint_width, type_width) = gateway_select_column_widths(gateways);
format!(
" {:<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, type_width) = gateway_select_column_widths(gateways);
gateways
.iter()
.map(|gateway| {
format!(
"{:<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, usize) {
let name_width = gateways
.iter()
.map(|gateway| gateway.name.len())
.max()
.unwrap_or(4)
.max(4);
let endpoint_width = gateways
.iter()
.map(|gateway| gateway.gateway_endpoint.len())
.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, type_width)
}
fn gateway_type_label(gateway: &GatewayMetadata) -> &'static str {
match gateway.auth_mode.as_deref() {
Some("cloudflare_jwt") => "cloud",
_ if gateway.is_remote => "remote",
_ => "local",
}
}
fn gateway_auth_label(gateway: &GatewayMetadata) -> &str {
match gateway.auth_mode.as_deref() {
Some(auth_mode) => auth_mode,
None if gateway.gateway_endpoint.starts_with("http://") => "plaintext",
None => "mtls",
}
}
fn is_loopback_gateway_endpoint(endpoint: &str) -> bool {
let Ok(parsed) = url::Url::parse(endpoint) else {
return false;
};
match parsed.host() {
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
None => false,
}
}
/// Check whether mTLS client certs exist on disk for a gateway name.
fn mtls_certs_exist_for_gateway(name: &str) -> bool {
openshell_core::paths::xdg_config_dir().is_ok_and(|d| {
let mtls = d.join("openshell").join("gateways").join(name).join("mtls");
mtls.join("ca.crt").is_file()
&& mtls.join("tls.crt").is_file()
&& mtls.join("tls.key").is_file()
})
}
fn package_managed_tls_dirs() -> Vec<PathBuf> {
if let Some(path) = std::env::var_os("OPENSHELL_LOCAL_TLS_DIR") {
return vec![PathBuf::from(path)];
}
let mut dirs = Vec::new();
if cfg!(target_os = "macos") {
dirs.push(PathBuf::from("/opt/homebrew/var/openshell/tls"));
dirs.push(PathBuf::from("/usr/local/var/openshell/tls"));
}
let state_dir = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")));
if let Some(state_dir) = state_dir {
dirs.push(state_dir.join("openshell/tls"));
}
dirs
}
fn import_local_package_mtls_bundle(name: &str) -> Result<Option<PathBuf>> {
for dir in package_managed_tls_dirs() {
let ca = dir.join("ca.crt");
let cert = dir.join("client/tls.crt");
let key = dir.join("client/tls.key");
if !(ca.is_file() && cert.is_file() && key.is_file()) {
continue;
}
let bundle = openshell_bootstrap::pki::PkiBundle {
ca_cert_pem: std::fs::read_to_string(&ca)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", ca.display()))?,
ca_key_pem: String::new(),
server_cert_pem: String::new(),
server_key_pem: String::new(),
client_cert_pem: std::fs::read_to_string(&cert)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", cert.display()))?,
client_key_pem: std::fs::read_to_string(&key)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", key.display()))?,
// CLI never holds the gateway's JWT signing material — only the
// gateway needs it. Fill the JWT fields with placeholders.
jwt_signing_key_pem: String::new(),
jwt_public_key_pem: String::new(),
jwt_key_id: String::new(),
};
openshell_bootstrap::mtls::store_pki_bundle(name, &bundle)
.wrap_err_with(|| format!("failed to store mTLS bundle for gateway '{name}'"))?;
return Ok(Some(dir));
}
Ok(None)
}
fn plaintext_gateway_is_remote(endpoint: &str, remote: Option<&str>, local: bool) -> bool {
if local {
return false;
}
if remote.is_some() {
return true;
}
!is_loopback_gateway_endpoint(endpoint)
}
fn plaintext_gateway_metadata(
name: &str,
endpoint: &str,
remote: Option<&str>,
local: bool,
) -> GatewayMetadata {
let (remote_host, resolved_host) = remote.map_or((None, None), |dest| {
let ssh_host = extract_host_from_ssh_destination(dest);
let resolved = resolve_ssh_hostname(&ssh_host);
(Some(dest.to_string()), Some(resolved))
});
GatewayMetadata {
name: name.to_string(),
gateway_endpoint: endpoint.to_string(),
is_remote: plaintext_gateway_is_remote(endpoint, remote, local),
gateway_port: 0,
remote_host,
resolved_host,
auth_mode: Some("plaintext".to_string()),
..Default::default()
}
}
fn gateway_select_with<F>(
name: Option<&str>,
gateway_flag: &Option<String>,
interactive: bool,
choose_gateway: F,
) -> Result<()>
where
F: FnOnce(&[GatewayMetadata], usize) -> Result<Option<String>>,
{
if let Some(name) = name {
return gateway_use(name);
}
let gateways = list_gateways()?;
if gateways.is_empty() || !interactive {
gateway_list(gateway_flag, "table")?;
if !gateways.is_empty() {
eprintln!();
eprintln!(
"Select a gateway with: {}",
"openshell gateway select <name>".dimmed()
);
}
return Ok(());
}
let active = gateway_flag.clone().or_else(load_active_gateway);
let default = active
.as_deref()
.and_then(|name| gateways.iter().position(|gateway| gateway.name == name))
.unwrap_or(0);
if let Some(name) = choose_gateway(&gateways, default)? {
gateway_use(&name)?;
} else {
eprintln!("{} Gateway selection cancelled", "!".yellow());
}
Ok(())
}
/// Register an existing gateway.
///
/// An `http://...` endpoint is registered as a direct plaintext gateway with
/// no mTLS certificate lookup or browser authentication.
///
/// Without extra flags, an `https://...` endpoint 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 mTLS certificates
/// must already exist in the gateway config directory.
///
/// An `ssh://` endpoint (e.g., `ssh://user@host:8080`) is shorthand for
/// `--remote user@host` with the gateway endpoint derived from the URL.
#[allow(clippy::too_many_arguments)]
pub async fn gateway_add(
endpoint: &str,
name: Option<&str>,
remote: Option<&str>,
local: bool,
oidc_issuer: Option<&str>,
oidc_client_id: &str,
oidc_audience: Option<&str>,
oidc_scopes: Option<&str>,
gateway_insecure: 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.
let resolved = resolve_ssh_hostname(host);
let https_endpoint = format!("https://{resolved}:{port}");
(https_endpoint, Some(ssh_dest))
} else {
// 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();
// Derive a gateway name from the hostname when none is provided.
// Loopback endpoints use the canonical "openshell" name, matching the
// convention in local cert generation and default_tls_dir.
let derived_name;
let name = if let Some(n) = name {
n
} else if is_loopback_gateway_endpoint(&endpoint) {
derived_name = "openshell".to_string();
&derived_name
} else {
// Parse out just the host portion of the URL.
derived_name = url::Url::parse(&endpoint)
.ok()
.and_then(|u| u.host_str().map(String::from))
.unwrap_or_else(|| endpoint.clone());
&derived_name
};
match gateway_metadata_source(name)? {
Some(GatewayMetadataSource::User) => {
return Err(miette::miette!(
"Gateway '{}' already exists.\n\
Remove it first with: openshell gateway remove {}\n\
Or choose a different name with: --name <name>",
name,
name,
));
}
Some(GatewayMetadataSource::System) | None => {}
}
// OIDC takes precedence over plaintext/mTLS/edge detection — the user
// explicitly opted in with --oidc-issuer regardless of scheme.
if let Some(issuer) = oidc_issuer {
let previous_active = load_user_active_gateway();
let metadata = GatewayMetadata {
name: name.to_string(),
gateway_endpoint: endpoint.clone(),
is_remote: !local,
auth_mode: Some("oidc".to_string()),
oidc_issuer: Some(issuer.to_string()),
oidc_client_id: Some(oidc_client_id.to_string()),
oidc_audience: oidc_audience.map(String::from),
oidc_scopes: oidc_scopes.map(String::from),
..Default::default()
};
store_gateway_metadata(name, &metadata)?;
save_active_gateway(name)?;
eprintln!(
"{} Gateway '{}' added and set as active",
"✓".green().bold(),
name,
);
eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint);
eprintln!(" {} oidc", "Auth:".dimmed());
eprintln!();
let auth_skipped = is_browser_suppressed();
// Check for client_credentials env var (CI mode).
let auth_ok = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() {
match crate::oidc_auth::oidc_client_credentials_flow(
issuer,
oidc_client_id,
oidc_audience,
oidc_scopes,
gateway_insecure,
)
.await
{
Ok(bundle) => {
openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?;
eprintln!(
"{} Authenticated via client credentials",
"✓".green().bold()
);
true
}
Err(e) => {
eprintln!("{} Authentication failed: {e}", "!".yellow());
false
}
}
} else if is_browser_suppressed() {
match crate::oidc_auth::oidc_device_code_flow(
issuer,
oidc_client_id,
oidc_audience,
oidc_scopes,
gateway_insecure,
)
.await
{
Ok(bundle) => {
openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?;
eprintln!("{} Authenticated via device code", "✓".green().bold());
true
}
Err(e) => {
eprintln!("{} Authentication failed: {e}", "!".yellow());
false
}
}
} else {
match crate::oidc_auth::oidc_browser_auth_flow(
issuer,
oidc_client_id,
oidc_audience,
oidc_scopes,
gateway_insecure,
false,
)
.await
{
Ok(bundle) => {
openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?;
eprintln!("{} Authenticated successfully", "✓".green().bold());
true
}
Err(e) => {
eprintln!("{} Authentication failed: {e}", "!".yellow());
false
}
}
};
if !auth_ok && !auth_skipped {
rollback_gateway_registration(name, previous_active.as_deref());
}
return Ok(());
}
if endpoint.starts_with("http://") {
// Warn if mTLS certs exist for this gateway — the user likely
// meant to use https:// instead of http://.
let has_mtls_certs = mtls_certs_exist_for_gateway(name);
if has_mtls_certs {
let https_endpoint = endpoint.replacen("http://", "https://", 1);
let suggestion = if is_loopback_gateway_endpoint(&endpoint) {
format!("openshell gateway add --local {https_endpoint}")
} else {
format!("openshell gateway add {https_endpoint}")
};
eprintln!(
"{} mTLS certificates found for gateway '{name}'. Did you mean to use https?",
"⚠".yellow().bold(),
);
eprintln!(" Try: {suggestion}");
}
let metadata = plaintext_gateway_metadata(name, &endpoint, remote, local);
let gateway_type = gateway_type_label(&metadata);
let gateway_auth = gateway_auth_label(&metadata);
store_gateway_metadata(name, &metadata)?;
save_active_gateway(name)?;
// Verify the gateway is reachable.
let tls = TlsOptions::default();
if !gateway_reachable(&endpoint, &tls).await {
eprintln!(
"{} Gateway is not reachable at {endpoint}",
"⚠".yellow().bold(),
);
if !has_mtls_certs {
eprintln!(" Verify the gateway is running and the endpoint is correct.");
}
}
eprintln!(
"{} Gateway '{}' added and set as active",
"✓".green().bold(),
name,
);
eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint);
eprintln!(" {} {}", "Type:".dimmed(), gateway_type);
eprintln!(" {} {}", "Auth:".dimmed(), gateway_auth);
return Ok(());
}
if remote.is_some() || local {
// mTLS gateway (remote or local).
let imported_mtls_dir = if local {
import_local_package_mtls_bundle(name)?
} else {
None
};
let certs_on_disk = imported_mtls_dir.is_some() || mtls_certs_exist_for_gateway(name);
if !certs_on_disk {
return Err(miette::miette!(
"mTLS certificates for gateway '{name}' were not found.\n\
Expected them under the default gateway config directory.\n\
Start the gateway package first so it provisions client TLS material, \
then retry: openshell gateway add {endpoint}{}",
if local { " --local" } else { "" },
));
}
let (remote_host, resolved_host) = remote.map_or((None, None), |dest| {
let ssh_host = extract_host_from_ssh_destination(dest);
let resolved = resolve_ssh_hostname(&ssh_host);
(Some(dest.to_string()), Some(resolved))
});
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()),
..Default::default()
};
store_gateway_metadata(name, &metadata)?;
save_active_gateway(name)?;
// Verify the gateway is reachable over mTLS.
let tls = TlsOptions::default().with_gateway_name(name);
if !gateway_reachable(&endpoint, &tls).await {
eprintln!(
"{} Gateway is not reachable at {endpoint}. Verify the gateway is running.",
"⚠".yellow().bold(),
);
}
eprintln!(
"{} Gateway '{}' added and set as active",
"✓".green().bold(),
name,
);
eprintln!(" {} {}", "Endpoint:".dimmed(), endpoint);
eprintln!(
" {} {}",
"Type:".dimmed(),
if local { "local" } else { "remote" },
);
eprintln!("{} TLS certificates present", "✓".green().bold());
} else {
// Cloud (edge-authenticated) gateway.
let previous_active = load_user_active_gateway();
let metadata = GatewayMetadata {
name: name.to_string(),
gateway_endpoint: endpoint.clone(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
..Default::default()
};
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!();
let auth_skipped = is_browser_suppressed();
let auth_ok = match crate::auth::browser_auth_flow(&endpoint).await {
Ok(token) => {
openshell_bootstrap::edge_token::store_edge_token(name, &token)?;
eprintln!("{} Authenticated successfully", "✓".green().bold());
true
}
Err(e) => {
eprintln!("{} Authentication failed: {e}", "!".yellow());
false
}
};
if !auth_ok && !auth_skipped {
rollback_gateway_registration(name, previous_active.as_deref());
}
}
Ok(())
}
/// Re-authenticate with an edge-authenticated or OIDC gateway.
///
/// Dispatches to the appropriate auth flow based on `auth_mode`.
pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> {
let metadata = openshell_bootstrap::load_gateway_metadata(name).map_err(|_| {
miette::miette!(
"Unknown gateway '{name}'.\n\
List available gateways: openshell gateway select"
)
})?;
match metadata.auth_mode.as_deref() {
Some("cloudflare_jwt") => {
let token = crate::auth::browser_auth_flow(&metadata.gateway_endpoint).await?;
openshell_bootstrap::edge_token::store_edge_token(name, &token)?;
eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold());
}
Some("oidc") => {
let issuer = metadata.oidc_issuer.as_deref().ok_or_else(|| {
miette::miette!("Gateway '{name}' has OIDC auth but no issuer URL in metadata")
})?;
let client_id = metadata
.oidc_client_id
.as_deref()
.unwrap_or("openshell-cli");
let audience = metadata.oidc_audience.as_deref();
let scopes = metadata.oidc_scopes.as_deref();
let force_fresh_login =
openshell_bootstrap::oidc_token::oidc_login_prompt_required(name);
let bundle = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() {
crate::oidc_auth::oidc_client_credentials_flow(
issuer,
client_id,
audience,
scopes,
gateway_insecure,
)
.await?
} else if is_browser_suppressed() {
crate::oidc_auth::oidc_device_code_flow(
issuer,
client_id,
audience,
scopes,
gateway_insecure,
)
.await?
} else {
crate::oidc_auth::oidc_browser_auth_flow(
issuer,
client_id,
audience,
scopes,
gateway_insecure,
force_fresh_login,
)
.await?
};
let username = jwt_preferred_username(&bundle.access_token);
openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?;
openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name)?;
if let Some(user) = username {
eprintln!(
"{} Authenticated to gateway '{name}' as {user}",
"✓".green().bold(),
);
} else {
eprintln!("{} Authenticated to gateway '{name}'", "✓".green().bold());
}
}
_ => {
return Err(miette::miette!(
"Gateway '{name}' does not use edge or OIDC authentication.\n\
Only edge-authenticated and OIDC gateways support browser login."
));
}
}
Ok(())
}
/// Extract `preferred_username` from a JWT payload without signature verification.
fn jwt_preferred_username(token: &str) -> Option<String> {
let payload = token.split('.').nth(1)?;
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
claims
.get("preferred_username")
.and_then(|v| v.as_str())
.map(String::from)
}
/// Clear stored authentication credentials for a gateway.
pub fn gateway_logout(name: &str) -> Result<()> {
let metadata = openshell_bootstrap::load_gateway_metadata(name).map_err(|_| {
miette::miette!(
"Unknown gateway '{name}'.\n\
List available gateways: openshell gateway select"
)
})?;
match metadata.auth_mode.as_deref() {
Some("oidc") => {
openshell_bootstrap::oidc_token::remove_oidc_token(name)?;
openshell_bootstrap::oidc_token::request_oidc_login_prompt(name)?;
}
Some("cloudflare_jwt") => {
openshell_bootstrap::edge_token::remove_edge_token(name)?;
}
_ => {
return Err(miette::miette!(
"Gateway '{name}' uses {} authentication — no stored credentials to clear.",
metadata.auth_mode.as_deref().unwrap_or("mtls")
));
}
}
eprintln!("{} Logged out of gateway '{name}'", "✓".green().bold());
Ok(())
}
/// List all registered gateways.
pub fn gateway_list(gateway_flag: &Option<String>, output: &str) -> Result<()> {
let gateways = list_gateways_with_source()?;
let active = gateway_flag.clone().or_else(load_active_gateway);
if crate::output::print_output_collection(output, &gateways, |g| gateway_to_json(g, &active))? {
return Ok(());
}
if gateways.is_empty() {
println!("No gateways found.");
println!();
println!(
"Register a gateway with: {}",
"openshell gateway add <endpoint>".dimmed()
);
return Ok(());
}
// Calculate column widths
let name_width = gateways
.iter()
.map(|g| g.metadata.name.len())
.max()
.unwrap_or(4)
.max(4);
let endpoint_width = gateways
.iter()
.map(|g| g.metadata.gateway_endpoint.len())
.max()
.unwrap_or(8)
.max(8);
let type_width = gateways
.iter()
.map(|g| gateway_type_label(&g.metadata).len())
.max()
.unwrap_or(4)
.max(4);
let source_width = gateways
.iter()
.map(|g| g.source.label().len())
.max()
.unwrap_or(6)
.max(6);
let auth_width = gateways
.iter()
.map(|g| gateway_auth_label(&g.metadata).len())
.max()
.unwrap_or(4)
.max(4);
let remote_labels: Vec<Option<String>> = gateways
.iter()
.map(|g| gateway_remote_label(&g.metadata))
.collect();
let show_remote = remote_labels.iter().any(Option::is_some);
let remote_width = remote_labels
.iter()
.filter_map(|label| label.as_ref().map(String::len))
.max()
.unwrap_or(6)
.max(6);
// Print header
if show_remote {
println!(
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {:<auth_width$} {:<remote_width$}",
"NAME".bold(),
"ENDPOINT".bold(),
"TYPE".bold(),
"SOURCE".bold(),
"AUTH".bold(),
"REMOTE".bold(),
);
} else {
println!(
" {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {}",
"NAME".bold(),
"ENDPOINT".bold(),
"TYPE".bold(),
"SOURCE".bold(),
"AUTH".bold(),
);
}
// Print rows
for (gateway, remote_label) in gateways.iter().zip(remote_labels.iter()) {
let metadata = &gateway.metadata;
let is_active = active.as_deref() == Some(&metadata.name);
let marker = if is_active { "*" } else { " " };
let gw_type = gateway_type_label(metadata);
let gw_auth = gateway_auth_label(metadata);
let line = if show_remote {
let remote_label = remote_label.as_deref().unwrap_or("-");
format!(
"{marker} {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {:<auth_width$} {:<remote_width$}",
metadata.name,
metadata.gateway_endpoint,
gw_type,
gateway.source.label(),
gw_auth,
remote_label,
)
} else {
format!(
"{marker} {:<name_width$} {:<endpoint_width$} {:<type_width$} {:<source_width$} {gw_auth}",
metadata.name,
metadata.gateway_endpoint,
gw_type,
gateway.source.label(),
)
};
if is_active {
println!("{}", line.green());
} else {
println!("{line}");
}
}
Ok(())
}
fn gateway_to_json(gateway: &ListedGateway, active: &Option<String>) -> serde_json::Value {
let metadata = &gateway.metadata;
serde_json::json!({
"name": metadata.name,
"endpoint": metadata.gateway_endpoint,
"type": gateway_type_label(metadata),
"source": gateway.source.label(),
"auth": gateway_auth_label(metadata),
"active": active.as_deref() == Some(&metadata.name),
"is_remote": metadata.is_remote,
"remote_host": &metadata.remote_host,
"resolved_host": &metadata.resolved_host,
})
}
fn gateway_remote_label(gateway: &GatewayMetadata) -> Option<String> {
match (&gateway.remote_host, &gateway.resolved_host) {
(Some(remote), Some(resolved)) if remote != resolved => {
Some(format!("{remote} -> {resolved}"))
}
(Some(remote), _) => Some(remote.clone()),
(None, Some(resolved)) => Some(resolved.clone()),
(None, None) => None,
}
}
async fn http_health_check(server: &str, tls: &TlsOptions) -> Result<Option<StatusCode>> {
let base = server.trim_end_matches('/');
let uri: hyper::Uri = format!("{base}/healthz").parse().into_diagnostic()?;
let scheme = uri.scheme_str().unwrap_or("https");
let https = if tls.gateway_insecure && scheme.eq_ignore_ascii_case("https") {
let insecure_config = build_insecure_rustls_config()?;
HttpsConnectorBuilder::new()
.with_tls_config(insecure_config)
.https_or_http()
.enable_http1()
.build()
} else if scheme.eq_ignore_ascii_case("http") || tls.is_bearer_auth() {
HttpsConnectorBuilder::new()
.with_native_roots()
.into_diagnostic()?
.https_or_http()
.enable_http1()
.build()
} else {
let materials = require_tls_materials(server, tls)?;
let tls_config = build_rustls_config(&materials)?;
HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_only()
.enable_http1()
.build()
};
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build(https);
let mut req_builder = Request::builder().method("GET").uri(uri);
// Inject edge authentication headers when an edge token is configured.
if let Some(ref token) = tls.edge_token {
req_builder = req_builder
.header("Cf-Access-Jwt-Assertion", token.as_str())
.header("Cookie", format!("CF_Authorization={token}"));
}
let req = req_builder
.body(Full::new(Bytes::new()))
.into_diagnostic()?;
let resp = client.request(req).await.into_diagnostic()?;
Ok(Some(resp.status()))
}
async fn gateway_reachable(server: &str, tls: &TlsOptions) -> bool {
if let Ok(mut client) = grpc_client(server, tls).await
&& client.health(HealthRequest {}).await.is_ok()
{
return true;
}
matches!(http_health_check(server, tls).await, Ok(Some(status)) if status.is_success())
}
fn is_browser_suppressed() -> bool {
std::env::var("OPENSHELL_NO_BROWSER").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn rollback_gateway_registration(name: &str, previous_active: Option<&str>) {
remove_gateway_registration(name);
if let Some(prev) = previous_active
&& let Err(e) = save_active_gateway(prev)
{
tracing::warn!("failed to restore previous active gateway '{prev}': {e}");
eprintln!(
"{} Failed to restore previous active gateway '{}': {e}",
"!".yellow(),
prev,
);
}
eprintln!(
"{} Registration for '{}' removed. Fix the issue and retry gateway add.",
"!".yellow(),
name,
);
}
fn remove_gateway_registration(name: &str) {
if let Err(err) = openshell_bootstrap::edge_token::remove_edge_token(name) {
tracing::debug!("failed to remove edge token: {err}");
}
if let Err(err) = openshell_bootstrap::oidc_token::remove_oidc_token(name) {
tracing::debug!("failed to remove oidc token: {err}");
}
if let Err(err) = openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name) {
tracing::debug!("failed to clear oidc login prompt marker: {err}");
}
if let Err(err) = remove_gateway_metadata(name) {
tracing::debug!("failed to remove gateway metadata: {err}");
}
if load_active_gateway().as_deref() == Some(name)
&& let Err(err) = clear_active_gateway()
{
tracing::debug!("failed to clear active gateway: {err}");
}
}
/// Remove a local gateway registration without touching the gateway service.
pub fn gateway_remove(name: &str) -> Result<()> {
match gateway_metadata_source(name)? {
Some(GatewayMetadataSource::User) => {}
Some(GatewayMetadataSource::System) => {
return Err(miette::miette!(
"Gateway registration '{name}' is installed by the system and cannot be removed from user config.\n\
Register a per-user gateway with the same name to override it, or select another gateway."
));
}
None => {
return Err(miette::miette!(
"No gateway metadata found for '{name}'.\n\
List available gateways: openshell gateway select"
));
}
}
remove_gateway_registration(name);
eprintln!(
"{} Gateway registration '{name}' removed.",
"✓".green().bold()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
ComputeDriverCapabilitiesView, ComputeDriverInfoView, ExtensionInfoView,
GatewayAuthenticationState, GatewayInfoView, TlsOptions, format_gateway_select_header,
format_gateway_select_items, gateway_add, gateway_auth_label, gateway_authentication_state,
gateway_env_override_warning, gateway_info_to_json, gateway_remote_label,
gateway_select_with, gateway_to_json, gateway_type_label, http_health_check,
import_local_package_mtls_bundle, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
plaintext_gateway_is_remote,
};
use crate::TEST_ENV_LOCK;
use crate::test_utils::{EnvVarGuard, with_tmp_xdg};
use hyper::StatusCode;
use openshell_bootstrap::{
GatewayMetadata, GatewayMetadataSource, ListedGateway, load_active_gateway,
load_gateway_metadata, load_user_active_gateway, store_gateway_metadata,
};
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::thread;
use tonic::Status;
fn with_tmp_xdg_and_system<F: FnOnce()>(tmp: &Path, system: &Path, f: F) {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let xdg_guard = EnvVarGuard::set(
"XDG_CONFIG_HOME",
tmp.to_str().expect("temp path should be utf-8"),
);
let system_guard = EnvVarGuard::set(
"OPENSHELL_SYSTEM_GATEWAY_DIR",
system.to_str().expect("system path should be utf-8"),
);
f();
drop(system_guard);
drop(xdg_guard);
}
fn edge_registration(name: &str, endpoint: &str) -> GatewayMetadata {
GatewayMetadata {
name: name.to_string(),
gateway_endpoint: endpoint.to_string(),
is_remote: true,
auth_mode: Some("cloudflare_jwt".to_string()),
..Default::default()
}
}
#[test]
fn gateway_status_reports_authenticated_oidc_probe() {
let mut tls = TlsOptions::default();
tls.oidc_token = Some("token".to_string());
let state = gateway_authentication_state(Ok(()), &tls, "https://gateway.example.com");
assert_eq!(state, GatewayAuthenticationState::Authenticated("OIDC"));
}
#[test]
fn gateway_status_treats_authorization_denial_as_authenticated() {
let mut tls = TlsOptions::default();
tls.oidc_token = Some("token".to_string());
let state = gateway_authentication_state(
Err(Status::permission_denied("role 'openshell-admin' required")),
&tls,
"https://gateway.example.com",
);
assert_eq!(
state,
GatewayAuthenticationState::Authenticated("OIDC; authorization denied")
);
}
#[test]
fn gateway_status_reports_rejected_bearer_token() {
let mut tls = TlsOptions::default();
tls.oidc_token = Some("expired".to_string());
let state = gateway_authentication_state(
Err(Status::unauthenticated("invalid token: ExpiredSignature")),
&tls,
"https://gateway.example.com",
);
assert_eq!(
state,
GatewayAuthenticationState::Failed("invalid token: ExpiredSignature".to_string())
);
}
#[test]
fn gateway_status_marks_oidc_unverified_on_older_gateway() {
let mut tls = TlsOptions::default();
tls.oidc_token = Some("token".to_string());
let state = gateway_authentication_state(
Err(Status::unimplemented("unknown service")),
&tls,
"https://gateway.example.com",
);
assert_eq!(
state,
GatewayAuthenticationState::Unverified(
"gateway does not support authentication checks".to_string()
)
);
}
#[test]
fn gateway_status_reports_local_plaintext_auth_not_required() {
let state =
gateway_authentication_state(Ok(()), &TlsOptions::default(), "http://127.0.0.1:8080");
assert_eq!(state, GatewayAuthenticationState::NotRequired("gateway"));
}
#[test]
fn gateway_select_uses_explicit_name_without_prompting() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
store_gateway_metadata(
"alpha",
&edge_registration("alpha", "https://alpha.example.com"),
)
.expect("store gateway");
let mut prompted = false;
gateway_select_with(Some("alpha"), &None, true, |_, _| {
prompted = true;
Ok(None)
})
.expect("select explicit gateway");
assert_eq!(load_active_gateway().as_deref(), Some("alpha"));
assert!(!prompted, "explicit gateway should skip prompting");
});
}
#[test]
fn gateway_env_override_warning_mentions_masked_selection() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = EnvVarGuard::set("OPENSHELL_GATEWAY", "openshell");
let warning = gateway_env_override_warning("docker-dev").expect("env override should warn");
assert!(
warning.contains("OPENSHELL_GATEWAY=openshell"),
"warning should name the overriding env var: {warning}"
);
assert!(
warning.contains("export OPENSHELL_GATEWAY=docker-dev"),
"warning should suggest updating the env var: {warning}"
);
}
#[test]
fn gateway_env_override_warning_skips_matching_gateway() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _env = EnvVarGuard::set("OPENSHELL_GATEWAY", "docker-dev");
assert_eq!(gateway_env_override_warning("docker-dev"), None);
}
#[test]
fn gateway_select_prefers_active_gateway_as_default_choice() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
store_gateway_metadata(
"alpha",
&edge_registration("alpha", "https://alpha.example.com"),
)
.expect("store alpha");
store_gateway_metadata(
"beta",
&edge_registration("beta", "https://beta.example.com"),
)
.expect("store beta");
super::save_active_gateway("beta").expect("save active gateway");
let mut seen_default = None;
gateway_select_with(None, &None, true, |gateways, default| {
seen_default = Some(default);
Ok(Some(gateways[default].name.clone()))
})
.expect("interactive selection");
assert_eq!(seen_default, Some(1));
assert_eq!(load_active_gateway().as_deref(), Some("beta"));
});
}
#[test]
fn gateway_select_non_interactive_lists_gateways_without_prompting() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
store_gateway_metadata(
"alpha",
&edge_registration("alpha", "https://alpha.example.com"),
)
.expect("store gateway");
let mut prompted = false;
gateway_select_with(None, &None, false, |_, _| {
prompted = true;
Ok(None)
})
.expect("non-interactive selection");
assert!(!prompted, "non-interactive mode should not prompt");
assert_eq!(load_active_gateway(), None);
});
}
#[test]
fn gateway_select_items_include_endpoint_and_type() {
let gateways = vec![
edge_registration("alpha", "https://edge.example.com"),
GatewayMetadata {
name: "local".to_string(),
gateway_endpoint: "http://127.0.0.1:8080".to_string(),
gateway_port: 8080,
..Default::default()
},
];
let items = format_gateway_select_items(&gateways);
let header = format_gateway_select_header(&gateways);
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]), "plaintext");
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("cloud"));
assert!(items[0].contains("cloudflare_jwt"));
assert!(items[1].contains("local"));
assert!(items[1].contains("plaintext"));
assert!(items[1].contains("http://127.0.0.1:8080"));
}
#[test]
fn gateway_to_json_includes_config_source() {
let gateway = ListedGateway {
metadata: GatewayMetadata {
name: "local-vm".to_string(),
gateway_endpoint: "http://127.0.0.1:17670".to_string(),
auth_mode: Some("plaintext".to_string()),
..Default::default()
},
source: GatewayMetadataSource::System,
};
let json = gateway_to_json(&gateway, &Some("local-vm".to_string()));
assert_eq!(json["source"], "system");
assert_eq!(json["type"], "local");
assert_eq!(json["auth"], "plaintext");
assert_eq!(json["active"], true);
}
#[test]
fn gateway_to_json_includes_remote_registration_details() {
let gateway = ListedGateway {
metadata: GatewayMetadata {
name: "remote-vm".to_string(),
gateway_endpoint: "https://127.0.0.1:17670".to_string(),
is_remote: true,
remote_host: Some("user@gateway-alias".to_string()),
resolved_host: Some("10.0.0.5".to_string()),
auth_mode: Some("mtls".to_string()),
..Default::default()
},
source: GatewayMetadataSource::User,
};
let json = gateway_to_json(&gateway, &Some("local-vm".to_string()));
assert_eq!(json["source"], "user");
assert_eq!(json["type"], "remote");
assert_eq!(json["auth"], "mtls");
assert_eq!(json["active"], false);
assert_eq!(json["is_remote"], true);
assert_eq!(json["remote_host"], "user@gateway-alias");
assert_eq!(json["resolved_host"], "10.0.0.5");
assert_eq!(
gateway_remote_label(&gateway.metadata).as_deref(),
Some("user@gateway-alias -> 10.0.0.5")
);
}
#[test]
fn gateway_info_json_includes_compute_drivers_when_available() {
let view = GatewayInfoView {
gateway: "openshell".to_string(),
server: "https://127.0.0.1:17670".to_string(),
auth: Some("bearer"),
status: "healthy".to_string(),
version: "0.0.75".to_string(),
compute_drivers: vec![ComputeDriverInfoView {
name: "podman".to_string(),
capabilities: ComputeDriverCapabilitiesView {
driver_name: "podman".to_string(),
driver_version: "0.0.75".to_string(),
},
}],
extensions: vec![ExtensionInfoView {
kind: "compute-driver".to_string(),
configured_name: "podman".to_string(),
implementation_name: "openshell/podman".to_string(),
implementation_version: "0.0.75".to_string(),
protocol_version: "1.0".to_string(),
supported_capabilities: vec!["openshell.compute.contract".to_string()],
required_capabilities: vec!["openshell.compute.contract".to_string()],
}],
};
let json = gateway_info_to_json(&view);
assert_eq!(json["gateway"], "openshell");
assert_eq!(json["status"], "healthy");
assert_eq!(json["version"], "0.0.75");
assert_eq!(json["compute_drivers"][0]["name"], "podman");
assert_eq!(
json["compute_drivers"][0]["capabilities"]["driver_name"],
"podman"
);
assert_eq!(json["extensions"][0]["protocol_version"], "1.0");
assert_eq!(
json["compute_drivers"][0]["capabilities"]["driver_version"],
"0.0.75"
);
}
#[test]
fn gateway_info_json_includes_empty_compute_driver_list() {
let view = GatewayInfoView {
gateway: "openshell".to_string(),
server: "https://127.0.0.1:17670".to_string(),
auth: None,
status: "healthy".to_string(),
version: "0.0.74".to_string(),
compute_drivers: Vec::new(),
extensions: Vec::new(),
};
let json = gateway_info_to_json(&view);
assert!(
json["compute_drivers"]
.as_array()
.is_some_and(Vec::is_empty)
);
}
#[test]
fn gateway_auth_label_defaults_https_gateways_to_mtls() {
let gateway = GatewayMetadata {
name: "local".to_string(),
gateway_endpoint: "https://127.0.0.1:8080".to_string(),
gateway_port: 8080,
..Default::default()
};
assert_eq!(gateway_auth_label(&gateway), "mtls");
}
#[test]
fn package_managed_tls_dirs_respects_override() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _tls_dir = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", "/tmp/openshell-test-tls");
assert_eq!(
package_managed_tls_dirs(),
vec![PathBuf::from("/tmp/openshell-test-tls")],
);
}
#[test]
fn import_local_package_mtls_bundle_copies_client_materials() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let package_tls = tmpdir.path().join("package-tls");
fs::create_dir_all(package_tls.join("client")).expect("create package tls dir");
fs::write(package_tls.join("ca.crt"), "ca").expect("write ca");
fs::write(package_tls.join("client/tls.crt"), "client cert").expect("write cert");
fs::write(package_tls.join("client/tls.key"), "client key").expect("write key");
with_tmp_xdg(tmpdir.path(), || {
let _tls_dir = EnvVarGuard::set(
"OPENSHELL_LOCAL_TLS_DIR",
package_tls.to_str().expect("temp path should be utf-8"),
);
let imported =
import_local_package_mtls_bundle("openshell").expect("import local bundle");
assert_eq!(imported.as_deref(), Some(package_tls.as_path()));
let mtls = tmpdir.path().join("openshell/gateways/openshell/mtls");
assert_eq!(fs::read_to_string(mtls.join("ca.crt")).unwrap(), "ca");
assert_eq!(
fs::read_to_string(mtls.join("tls.crt")).unwrap(),
"client cert",
);
assert_eq!(
fs::read_to_string(mtls.join("tls.key")).unwrap(),
"client key",
);
});
}
#[test]
fn mtls_certs_exist_for_gateway_uses_explicit_name_for_loopback_endpoint() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let mtls = tmpdir.path().join("openshell/gateways/k8s/mtls");
fs::create_dir_all(&mtls).expect("create mtls dir");
fs::write(mtls.join("ca.crt"), "ca").expect("write ca");
fs::write(mtls.join("tls.crt"), "client cert").expect("write cert");
fs::write(mtls.join("tls.key"), "client key").expect("write key");
with_tmp_xdg(tmpdir.path(), || {
assert!(mtls_certs_exist_for_gateway("k8s"));
assert!(!mtls_certs_exist_for_gateway("openshell"));
});
}
#[test]
fn plaintext_gateway_locality_infers_loopback_endpoints_as_local() {
assert!(!plaintext_gateway_is_remote(
"http://127.0.0.1:8080",
None,
false,
));
assert!(!plaintext_gateway_is_remote(
"http://localhost:8080",
None,
false,
));
assert!(!plaintext_gateway_is_remote(
"http://[::1]:8080",
None,
false,
));
}
#[test]
fn plaintext_gateway_locality_treats_non_loopback_endpoints_as_remote_without_local_flag() {
assert!(plaintext_gateway_is_remote(
"http://gateway.example.com:8080",
None,
false,
));
assert!(plaintext_gateway_is_remote(
"http://10.0.0.5:8080",
None,
false,
));
}
#[test]
fn gateway_add_registers_plaintext_loopback_gateway_without_local_flag() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
runtime.block_on(async {
gateway_add(
"http://127.0.0.1:8080",
None,
None,
false,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("register plaintext gateway");
});
// Loopback endpoints derive the canonical "openshell" gateway
// name, matching local cert generation and default_tls_dir conventions.
let metadata = load_gateway_metadata("openshell").expect("load stored gateway");
assert_eq!(metadata.auth_mode.as_deref(), Some("plaintext"));
assert!(!metadata.is_remote);
assert_eq!(metadata.gateway_endpoint, "http://127.0.0.1:8080");
assert_eq!(load_active_gateway().as_deref(), Some("openshell"));
});
}
#[test]
fn gateway_add_respects_local_flag_for_plaintext_registrations() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
runtime.block_on(async {
gateway_add(
"http://gateway.example.com:8080",
Some("dev-http"),
None,
true,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("register plaintext gateway");
});
let metadata = load_gateway_metadata("dev-http").expect("load stored gateway");
assert_eq!(metadata.auth_mode.as_deref(), Some("plaintext"));
assert!(!metadata.is_remote);
assert_eq!(metadata.gateway_endpoint, "http://gateway.example.com:8080");
assert_eq!(load_active_gateway().as_deref(), Some("dev-http"));
});
}
#[tokio::test]
async fn http_health_check_supports_plain_http_endpoints() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let addr = listener.local_addr().expect("listener addr");
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
let mut buf = [0_u8; 1024];
let _ = stream.read(&mut buf).expect("read request");
let response = concat!(
"HTTP/1.1 200 OK\r\n",
"Content-Length: 2\r\n",
"Content-Type: text/plain\r\n",
"Connection: close\r\n\r\n",
"ok"
);
stream
.write_all(response.as_bytes())
.expect("write response");
});
let status = http_health_check(&format!("http://{addr}"), &TlsOptions::default())
.await
.expect("health check");
server.join().expect("server thread");
assert_eq!(status, Some(StatusCode::OK));
}
#[test]
fn gateway_add_oidc_rolls_back_on_auth_failure() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
// Register a working plaintext gateway first so we can verify
// the active gateway is restored after rollback.
runtime.block_on(async {
gateway_add(
"http://127.0.0.1:9999",
Some("existing-gw"),
None,
false,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("register seed gateway");
});
assert_eq!(load_active_gateway().as_deref(), Some("existing-gw"));
// Attempt OIDC gateway add against an unreachable issuer.
// Auth will fail (connection refused), triggering rollback.
runtime.block_on(async {
gateway_add(
"https://gateway.example.com",
Some("oidc-fail"),
None,
false,
Some("http://127.0.0.1:1/realms/nonexistent"),
"openshell-cli",
None,
None,
false,
)
.await
.expect("gateway_add should not return Err on auth failure");
});
// The failed registration should have been rolled back.
assert!(
load_gateway_metadata("oidc-fail").is_err(),
"failed OIDC gateway should be removed after auth failure"
);
// The previously active gateway should be restored.
assert_eq!(
load_active_gateway().as_deref(),
Some("existing-gw"),
"active gateway should be restored after rollback"
);
});
}
#[test]
fn gateway_add_oidc_rollback_keeps_system_active_fallback_userless() {
let user = tempfile::tempdir().expect("create user tmpdir");
let system = tempfile::tempdir().expect("create system tmpdir");
with_tmp_xdg_and_system(user.path(), system.path(), || {
fs::write(system.path().join("active_gateway"), "system-default")
.expect("write system active gateway");
assert_eq!(load_user_active_gateway(), None);
assert_eq!(load_active_gateway().as_deref(), Some("system-default"));
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
runtime.block_on(async {
gateway_add(
"https://gateway.example.com",
Some("oidc-fail"),
None,
false,
Some("http://127.0.0.1:1/realms/nonexistent"),
"openshell-cli",
None,
None,
false,
)
.await
.expect("gateway_add should not return Err on auth failure");
});
assert!(
load_gateway_metadata("oidc-fail").is_err(),
"failed OIDC gateway should be removed after auth failure"
);
assert_eq!(
load_user_active_gateway(),
None,
"rollback should not persist the system fallback into user config"
);
assert_eq!(load_active_gateway().as_deref(), Some("system-default"));
});
}
#[test]
fn gateway_add_cloud_rolls_back_on_auth_failure() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
with_tmp_xdg(tmpdir.path(), || {
let _no_browser = EnvVarGuard::set("OPENSHELL_NO_BROWSER", "0");
let _browser_auth_failure = EnvVarGuard::set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1");
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
// Register a working plaintext gateway first.
runtime.block_on(async {
gateway_add(
"http://127.0.0.1:9999",
Some("existing-gw"),
None,
false,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("register seed gateway");
});
assert_eq!(load_active_gateway().as_deref(), Some("existing-gw"));
// Attempt cloud gateway add. Keep browser suppression disabled so
// auth failure still rolls back the registration, but use the
// test-only auth failure hook instead of opening the OS browser.
runtime.block_on(async {
gateway_add(
"https://127.0.0.1:1",
Some("cloud-fail"),
None,
false,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("gateway_add should not return Err on auth failure");
});
// The failed registration should have been rolled back.
assert!(
load_gateway_metadata("cloud-fail").is_err(),
"failed cloud gateway should be removed after auth failure"
);
assert_eq!(
load_active_gateway().as_deref(),
Some("existing-gw"),
"active gateway should be restored after rollback"
);
});
}
#[test]
fn gateway_add_cloud_rollback_keeps_system_active_fallback_userless() {
let user = tempfile::tempdir().expect("create user tmpdir");
let system = tempfile::tempdir().expect("create system tmpdir");
with_tmp_xdg_and_system(user.path(), system.path(), || {
let _no_browser = EnvVarGuard::set("OPENSHELL_NO_BROWSER", "0");
let _browser_auth_failure = EnvVarGuard::set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1");
fs::write(system.path().join("active_gateway"), "system-default")
.expect("write system active gateway");
assert_eq!(load_user_active_gateway(), None);
assert_eq!(load_active_gateway().as_deref(), Some("system-default"));
let runtime = tokio::runtime::Runtime::new().expect("create runtime");
runtime.block_on(async {
gateway_add(
"https://127.0.0.1:1",
Some("cloud-fail"),
None,
false,
None,
"openshell-cli",
None,
None,
false,
)
.await
.expect("gateway_add should not return Err on auth failure");
});
assert!(
load_gateway_metadata("cloud-fail").is_err(),
"failed cloud gateway should be removed after auth failure"
);
assert_eq!(
load_user_active_gateway(),
None,
"rollback should not persist the system fallback into user config"
);
assert_eq!(load_active_gateway().as_deref(), Some("system-default"));
});
}
#[test]
fn status_to_json_connected() {
let auth = GatewayAuthenticationState::Authenticated("mTLS transport");
let json = super::status_to_json(
"my-gw",
"http://127.0.0.1:8090",
false,
"connected",
&Some("1.2.3".to_string()),
&None,
&None,
&auth,
);
assert_eq!(json["gateway"], "my-gw");
assert_eq!(json["server"], "http://127.0.0.1:8090");
assert_eq!(json["status"], "connected");
assert_eq!(json["version"], "1.2.3");
assert_eq!(json["authentication"]["status"], "authenticated");
assert!(json.get("auth").is_none());
assert!(json.get("error").is_none());
assert!(json.get("http_status").is_none());
}
#[test]
fn status_to_json_disconnected_with_error() {
let auth = GatewayAuthenticationState::Unverified("gateway unreachable".to_string());
let json = super::status_to_json(
"broken-gw",
"http://10.0.0.1:8090",
false,
"disconnected",
&None,
&Some("connection refused".to_string()),
&None,
&auth,
);
assert_eq!(json["status"], "disconnected");
assert_eq!(json["error"], "connection refused");
assert_eq!(json["authentication"]["status"], "unverified");
assert!(json.get("version").is_none());
}
#[test]
fn status_to_json_connected_http_with_bearer() {
let auth = GatewayAuthenticationState::Failed("token expired".to_string());
let json = super::status_to_json(
"edge-gw",
"https://edge.example.com",
true,
"connected_http",
&None,
&Some("gRPC unavailable".to_string()),
&Some("200 OK".to_string()),
&auth,
);
assert_eq!(json["status"], "connected_http");
assert_eq!(json["auth"], "edge_bearer");
assert_eq!(json["error"], "gRPC unavailable");
assert_eq!(json["http_status"], "200 OK");
assert_eq!(json["authentication"]["status"], "failed");
assert!(json.get("version").is_none());
}
}