From dbe5aba49f24e6c410cb74afa4e5b1cbed17c892 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Wed, 21 Oct 2026 16:51:58 -0700 Subject: [PATCH] refactor(cli): use Rust SDK transport Signed-off-by: Taylor Mutch --- Cargo.lock | 3 - crates/openshell-cli/Cargo.toml | 5 - crates/openshell-cli/src/completers.rs | 19 +- crates/openshell-cli/src/edge_tunnel.rs | 261 --------------------- crates/openshell-cli/src/lib.rs | 1 - crates/openshell-cli/src/tls.rs | 291 +++++++----------------- crates/openshell-sdk/README.md | 5 +- crates/openshell-sdk/src/config.rs | 38 +++- crates/openshell-sdk/src/lib.rs | 2 +- crates/openshell-sdk/src/transport.rs | 16 +- 10 files changed, 132 insertions(+), 509 deletions(-) delete mode 100644 crates/openshell-cli/src/edge_tunnel.rs diff --git a/Cargo.lock b/Cargo.lock index 006d8dd374..4b9671dad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4346,11 +4346,8 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", - "tokio-rustls", "tokio-stream", - "tokio-tungstenite 0.26.2", "tonic", - "tower", "tracing", "tracing-subscriber", "url", diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index a6cb1fc895..bc7266e4a4 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -54,8 +54,6 @@ hyper-util = { workspace = true } hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging"] } rustls = { workspace = true } rustls-pemfile = { workspace = true } -tokio-rustls = { workspace = true } -tower = { workspace = true } reqwest = { workspace = true } # Error handling @@ -71,9 +69,6 @@ tempfile = "3" oauth2 = { version = "5", default-features = false, features = ["reqwest"] } base64 = { workspace = true } -# WebSocket (Cloudflare tunnel proxy) -tokio-tungstenite = { workspace = true } - # Streams futures = { workspace = true } tokio-stream = { workspace = true } diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index 5cf3669ecb..2b88046fb9 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -9,14 +9,10 @@ use openshell_bootstrap::edge_token::load_edge_token; use openshell_bootstrap::oidc_token::{is_token_expired, load_oidc_token, store_oidc_token}; use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metadata}; use openshell_core::ObjectName; -use openshell_core::auth::EdgeAuthInterceptor; -use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWorkspacesRequest}; -use tonic::service::interceptor::InterceptedService; -use tonic::transport::Channel; use crate::oidc_auth::oidc_refresh_token; -use crate::tls::{TlsOptions, build_channel}; +use crate::tls::{GrpcClient, TlsOptions, grpc_client}; /// Complete gateway names from local metadata files (no network call). pub fn complete_gateway_names(_prefix: &OsStr) -> Vec { @@ -130,10 +126,7 @@ fn resolve_active_gateway() -> Option<(String, String)> { Some((metadata.gateway_endpoint, name)) } -async fn completion_grpc_client( - server: &str, - gateway_name: &str, -) -> Option>> { +async fn completion_grpc_client(server: &str, gateway_name: &str) -> Option { let mut tls_opts = TlsOptions::default().with_gateway_name(gateway_name); tls_opts.gateway_insecure = std::env::var("OPENSHELL_GATEWAY_INSECURE") .is_ok_and(|v| !v.is_empty() && v != "0" && v != "false"); @@ -172,13 +165,7 @@ async fn completion_grpc_client( } } - let channel = build_channel(server, &tls_opts).await.ok()?; - let interceptor = EdgeAuthInterceptor::new( - tls_opts.oidc_token.as_deref(), - tls_opts.edge_token.as_deref(), - ) - .ok()?; - Some(OpenShellClient::with_interceptor(channel, interceptor)) + grpc_client(server, &tls_opts).await.ok() } /// Run an async future on a dedicated thread to avoid nested tokio runtime panics. diff --git a/crates/openshell-cli/src/edge_tunnel.rs b/crates/openshell-cli/src/edge_tunnel.rs deleted file mode 100644 index e9b1a92668..0000000000 --- a/crates/openshell-cli/src/edge_tunnel.rs +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Edge-authenticated WebSocket tunnel proxy. -//! -//! When the gateway sits behind a reverse proxy with edge authentication -//! (e.g., Cloudflare Access), gRPC (HTTP/2 POST) requests are rejected at the -//! edge because browser-flow JWTs only authenticate GET requests. -//! -//! The workaround is to open a **WebSocket** to the edge (WebSocket upgrades -//! are GET requests, which the edge authenticates normally) and then pipe raw -//! TCP bytes through WebSocket binary frames. -//! -//! This module implements the pattern: -//! -//! 1. Bind a local TCP listener on an ephemeral port. -//! 2. For each accepted connection, open a WebSocket (`wss://`) to the -//! gateway's tunnel endpoint with the bearer token in upgrade headers. -//! 3. Bidirectionally pipe bytes between the local TCP stream and the -//! WebSocket. -//! -//! The gRPC [`Channel`] then connects to `http://127.0.0.1:` -//! (plaintext) — the edge handles TLS, and the WebSocket carries the raw -//! bytes to the origin. - -use futures::stream::{SplitSink, SplitStream}; -use futures::{SinkExt, StreamExt}; -use miette::{IntoDiagnostic, Result}; -use openshell_core::net::set_tcp_nodelay_best_effort; -use std::net::SocketAddr; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderValue; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; -use tracing::{debug, error, warn}; - -/// A running edge-authenticated tunnel proxy. -/// -/// The proxy listens on a local TCP port and tunnels each connection over a -/// WebSocket to the edge. The listener runs in a detached task for the -/// lifetime of the current process. -pub struct EdgeTunnelProxy { - /// Local address the proxy is listening on (e.g. `127.0.0.1:54321`). - pub local_addr: SocketAddr, -} - -/// Configuration for establishing the WebSocket tunnel. -#[derive(Clone)] -struct TunnelConfig { - /// The `wss://` URL to connect to (derived from the gateway endpoint). - ws_url: String, - /// The bearer token for edge authentication. - edge_token: String, -} - -/// Start the local tunnel proxy. -/// -/// Returns an [`EdgeTunnelProxy`] with the local address to connect to. -/// The proxy runs as a background tokio task. -pub async fn start_tunnel_proxy( - gateway_endpoint: &str, - edge_token: &str, -) -> Result { - let listener = TcpListener::bind("127.0.0.1:0").await.into_diagnostic()?; - let local_addr = listener.local_addr().into_diagnostic()?; - - // Convert the gateway endpoint to a WebSocket URL. - // https://foo.com -> wss://foo.com - // http://foo.com -> ws://foo.com (edge proxy over plain HTTP, uncommon but handled) - let ws_url = format!( - "{}/_ws_tunnel", - gateway_endpoint - .replacen("https://", "wss://", 1) - .replacen("http://", "ws://", 1) - .trim_end_matches('/') - ); - - let config = Arc::new(TunnelConfig { - ws_url, - edge_token: edge_token.to_string(), - }); - - debug!( - local_addr = %local_addr, - gateway = %gateway_endpoint, - "starting edge tunnel proxy" - ); - - // Spawn the accept loop. - tokio::spawn(accept_loop(listener, config)); - - Ok(EdgeTunnelProxy { local_addr }) -} - -/// Accept loop: for each incoming TCP connection, spawn a handler that -/// opens a WebSocket to the edge and pipes bytes bidirectionally. -async fn accept_loop(listener: TcpListener, config: Arc) { - loop { - match listener.accept().await { - Ok((stream, peer)) => { - debug!(peer = %peer, "accepted local tunnel connection"); - set_tcp_nodelay_best_effort(&stream); - let config = Arc::clone(&config); - tokio::spawn(async move { - if let Err(e) = handle_connection(stream, &config).await { - warn!(peer = %peer, error = %e, "tunnel connection failed"); - } - }); - } - Err(e) => { - error!(error = %e, "failed to accept tunnel connection"); - // Brief backoff to avoid tight error loops. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - } -} - -/// Handle a single tunneled connection: open a WebSocket to the edge and -/// bidirectionally copy bytes. -async fn handle_connection(tcp_stream: TcpStream, config: &TunnelConfig) -> Result<()> { - let ws_stream = open_ws(config).await?; - let (ws_sink, ws_source) = ws_stream.split(); - let (tcp_read, tcp_write) = tokio::io::split(tcp_stream); - - // Two tasks: TCP->WS and WS->TCP. Abort the peer task once either - // direction finishes so the connection tears down promptly. - let mut tcp_to_ws = tokio::spawn(copy_tcp_to_ws(tcp_read, ws_sink)); - let mut ws_to_tcp = tokio::spawn(copy_ws_to_tcp(ws_source, tcp_write)); - - tokio::select! { - res = &mut tcp_to_ws => { - if let Err(e) = res { - debug!(error = %e, "tcp->ws task panicked"); - } - ws_to_tcp.abort(); - } - res = &mut ws_to_tcp => { - if let Err(e) = res { - debug!(error = %e, "ws->tcp task panicked"); - } - tcp_to_ws.abort(); - } - } - - Ok(()) -} - -/// Open a WebSocket connection to the edge proxy. -async fn open_ws(config: &TunnelConfig) -> Result>> { - let mut request = (&config.ws_url).into_client_request().into_diagnostic()?; - - // Inject the bearer token via multiple headers for compatibility with - // Cloudflare Access (which checks `Cf-Access-Token`, the - // `CF_Authorization` cookie, and the `Cf-Access-Jwt-Assertion` header). - let token_val = HeaderValue::from_str(&config.edge_token) - .map_err(|e| miette::miette!("invalid edge token header value: {e}"))?; - request - .headers_mut() - .insert("Cf-Access-Token", token_val.clone()); - request - .headers_mut() - .insert("Cf-Access-Jwt-Assertion", token_val); - request.headers_mut().insert( - "Cookie", - HeaderValue::from_str(&format!("CF_Authorization={}", config.edge_token)) - .map_err(|e| miette::miette!("invalid edge token cookie value: {e}"))?, - ); - - debug!(url = %config.ws_url, "opening WebSocket to edge"); - - let (ws_stream, response) = tokio_tungstenite::connect_async(request) - .await - .map_err(|e| miette::miette!("WebSocket connect failed: {e}"))?; - - let tcp = match ws_stream.get_ref() { - MaybeTlsStream::Plain(tcp) => Some(tcp), - MaybeTlsStream::Rustls(tls) => Some(tls.get_ref().0), - // `MaybeTlsStream` is #[non_exhaustive]; surface any future/unknown - // variant so a silent TCP_NODELAY miss doesn't go unnoticed. - _ => { - debug!("edge tunnel: unrecognized MaybeTlsStream variant; skipping TCP_NODELAY"); - None - } - }; - if let Some(tcp) = tcp { - set_tcp_nodelay_best_effort(tcp); - } - - debug!( - status = %response.status(), - "WebSocket connected to edge" - ); - - Ok(ws_stream) -} - -/// Copy bytes from a local TCP reader into WebSocket binary frames. -async fn copy_tcp_to_ws( - mut tcp_read: tokio::io::ReadHalf, - mut ws_sink: SplitSink>, Message>, -) { - let mut buf = vec![0u8; 32 * 1024]; - loop { - match tcp_read.read(&mut buf).await { - Ok(0) => { - // EOF — send a close frame. - let _ = ws_sink.close().await; - break; - } - Ok(n) => { - if ws_sink - .send(Message::Binary(buf[..n].to_vec().into())) - .await - .is_err() - { - break; - } - } - Err(e) => { - debug!(error = %e, "tcp read error"); - let _ = ws_sink.close().await; - break; - } - } - } -} - -/// Copy bytes from WebSocket binary frames into a local TCP writer. -async fn copy_ws_to_tcp( - mut ws_source: SplitStream>>, - mut tcp_write: tokio::io::WriteHalf, -) { - while let Some(msg) = ws_source.next().await { - match msg { - Ok(Message::Binary(data)) => { - if tcp_write.write_all(&data).await.is_err() { - break; - } - } - Ok(Message::Close(_)) => break, - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => { - // Handled automatically by tungstenite. - } - Ok(Message::Text(text)) => { - // Some proxies send text frames — treat as binary. - if tcp_write.write_all(text.as_bytes()).await.is_err() { - break; - } - } - Err(e) => { - debug!(error = %e, "ws read error"); - break; - } - } - } - let _ = tcp_write.shutdown().await; -} diff --git a/crates/openshell-cli/src/lib.rs b/crates/openshell-cli/src/lib.rs index c7801f8191..2eebd61f5a 100644 --- a/crates/openshell-cli/src/lib.rs +++ b/crates/openshell-cli/src/lib.rs @@ -16,7 +16,6 @@ pub mod auth; pub mod color; pub(crate) mod commands; pub mod completers; -pub mod edge_tunnel; pub mod oidc_auth; pub mod output; pub(crate) mod policy_update; diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index f96cbe01f4..6dceb395de 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -2,30 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 use miette::{IntoDiagnostic, Result, WrapErr}; -use openshell_core::auth::EdgeAuthInterceptor; -use openshell_core::net::set_tcp_nodelay_best_effort; -use openshell_core::proto::open_shell_client::OpenShellClient; +use openshell_sdk::{AuthConfig, ClientConfig, ClientIdentity, OpenShellClient}; use rustls::{ RootCertStore, client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}, }; -use std::collections::HashMap; -use std::future::Future; use std::io::Cursor; -use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::OnceLock; -use std::time::Duration; -use tokio::sync::Mutex; use tonic::metadata::AsciiMetadataValue; -use tonic::service::interceptor::InterceptedService; -use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; -use tracing::debug; -use url::{Host, Url}; +use tonic::transport::Channel; /// Concrete gRPC client type used by all commands. -pub type GrpcClient = OpenShellClient>; +pub type GrpcClient = openshell_sdk::raw::AuthedGrpcClient; #[derive(Clone, Debug, Default)] pub struct TlsOptions { @@ -222,35 +211,6 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result Result { - let ca_cert = Certificate::from_pem(materials.ca.clone()); - let identity = Identity::from_pem(materials.cert.clone(), materials.key.clone()); - tls_config_with_server_name( - server, - ClientTlsConfig::new() - .ca_certificate(ca_cert) - .identity(identity), - ) -} - -fn tls_config_with_server_name(server: &str, config: ClientTlsConfig) -> Result { - Ok(config.domain_name(tls_server_name(server)?)) -} - -fn tls_server_name(server: &str) -> Result { - let endpoint = Url::parse(server) - .into_diagnostic() - .wrap_err_with(|| format!("invalid gateway endpoint '{server}'"))?; - let host = endpoint - .host() - .ok_or_else(|| miette::miette!("gateway endpoint '{server}' has no host"))?; - Ok(match host { - Host::Domain(domain) => domain.to_string(), - Host::Ipv4(address) => address.to_string(), - Host::Ipv6(address) => address.to_string(), - }) -} - #[derive(Debug)] struct InsecureServerCertVerifier; @@ -291,39 +251,6 @@ impl ServerCertVerifier for InsecureServerCertVerifier { } } -#[derive(Clone)] -struct InsecureTlsConnector { - tls_connector: tokio_rustls::TlsConnector, -} - -impl tower::Service for InsecureTlsConnector { - type Response = hyper_util::rt::TokioIo>; - type Error = Box; - type Future = - std::pin::Pin> + Send>>; - - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, uri: hyper::Uri) -> Self::Future { - let tls_connector = self.tls_connector.clone(); - Box::pin(async move { - let host = uri.host().unwrap_or("localhost").to_string(); - let port = uri.port_u16().unwrap_or(443); - let addr = format!("{host}:{port}"); - let tcp = tokio::net::TcpStream::connect(addr).await?; - set_tcp_nodelay_best_effort(&tcp); - let server_name = ServerName::try_from(host)?; - let tls_stream = tls_connector.connect(server_name, tcp).await?; - Ok(hyper_util::rt::TokioIo::new(tls_stream)) - }) - } -} - pub fn build_insecure_rustls_config() -> Result { let config = openshell_crypto::tls::client_builder() .dangerous() @@ -332,136 +259,63 @@ pub fn build_insecure_rustls_config() -> Result { Ok(config) } -/// Tunnel proxy addresses keyed by upstream endpoint + token. -/// -/// Each distinct edge-authenticated gateway gets its own local proxy instead of -/// reusing the first gateway touched in the current process. -static EDGE_TUNNEL_ADDRS: OnceLock>> = OnceLock::new(); - -async fn edge_tunnel_addr(server: &str, token: &str) -> Result { - let key = (server.to_string(), token.to_string()); - let registry = EDGE_TUNNEL_ADDRS.get_or_init(|| Mutex::new(HashMap::new())); - - { - let addrs = registry.lock().await; - if let Some(addr) = addrs.get(&key).copied() { - return Ok(addr); +fn sdk_client_config(server: &str, tls: &TlsOptions) -> Result { + let mut config = ClientConfig::new(server); + config.insecure_skip_verify = tls.gateway_insecure; + let uses_edge_tunnel = tls.oidc_token.is_none() && tls.edge_token.is_some(); + config.auth = tls + .oidc_token + .as_deref() + .map(AuthConfig::oidc) + .or_else(|| tls.edge_token.clone().map(AuthConfig::EdgeJwt)); + + // Plaintext, the edge tunnel, and insecure TLS do not consume certificate + // material. For verified HTTPS, preserve the CLI's existing behavior: + // mTLS is required without bearer auth and optional with OIDC. + if server.starts_with("https://") && !tls.gateway_insecure && !uses_edge_tunnel { + if tls.oidc_token.is_some() { + if let Ok(materials) = require_tls_materials(server, tls) { + config.ca_cert = Some(materials.ca); + config.client_identity = Some(ClientIdentity::new(materials.cert, materials.key)); + } else { + let resolved = tls.with_default_paths(server); + config.ca_cert = resolved + .ca + .as_ref() + .and_then(|ca_path| std::fs::read(ca_path).ok()); + } + } else { + let materials = require_tls_materials(server, tls)?; + config.ca_cert = Some(materials.ca); + config.client_identity = Some(ClientIdentity::new(materials.cert, materials.key)); } } - let proxy = crate::edge_tunnel::start_tunnel_proxy(server, token).await?; - debug!( - local_addr = %proxy.local_addr, - server, - "edge tunnel proxy started, routing gRPC through local proxy" - ); - - let mut addrs = registry.lock().await; - Ok(*addrs.entry(key).or_insert(proxy.local_addr)) + Ok(config) } +/// Build a gateway channel through `openshell-sdk` while retaining the CLI's +/// filesystem-aware certificate discovery. pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { - if server.starts_with("http://") { - let endpoint = Endpoint::from_shared(server.to_string()) - .into_diagnostic()? - .connect_timeout(Duration::from_secs(10)) - .http2_adaptive_window(true) - .http2_keep_alive_interval(Duration::from_secs(10)) - .keep_alive_while_idle(true); - return endpoint.connect().await.into_diagnostic(); - } - - // When Cloudflare edge bearer auth is active and the server is HTTPS, - // route traffic through a local WebSocket tunnel proxy instead. - // OIDC tokens bypass the tunnel — they connect directly. - if tls.edge_token.is_some() && server.starts_with("https://") { - let token = tls - .edge_token - .as_deref() - .ok_or_else(|| miette::miette!("edge token required for tunnel"))?; - let local_addr = edge_tunnel_addr(server, token).await?; - - // Connect to the local tunnel proxy over plaintext HTTP/2. - let local_url = format!("http://{local_addr}"); - let endpoint = Endpoint::from_shared(local_url) - .into_diagnostic()? - .connect_timeout(Duration::from_secs(10)) - .http2_adaptive_window(true) - .http2_keep_alive_interval(Duration::from_secs(10)) - .keep_alive_while_idle(true); - return endpoint.connect().await.into_diagnostic(); - } - - if tls.gateway_insecure && server.starts_with("https://") { - tracing::warn!("TLS certificate verification is disabled — do not use in production"); - let rustls_config = build_insecure_rustls_config()?; - let tls_connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(rustls_config)); - let connector = InsecureTlsConnector { tls_connector }; - // Use http:// so tonic does not layer its own TLS on top — our - // connector performs TLS with the insecure config. - let http_uri = server.replacen("https://", "http://", 1); - let endpoint = Endpoint::from_shared(http_uri) - .into_diagnostic()? - .connect_timeout(Duration::from_secs(10)) - .http2_keep_alive_interval(Duration::from_secs(10)) - .keep_alive_while_idle(true); - return endpoint - .connect_with_connector(connector) - .await - .into_diagnostic(); - } - - let mut endpoint = Endpoint::from_shared(server.to_string()) - .into_diagnostic()? - .connect_timeout(Duration::from_secs(10)) - .http2_adaptive_window(true) - .http2_keep_alive_interval(Duration::from_secs(10)) - .keep_alive_while_idle(true); - - let tls_config = if tls.oidc_token.is_some() { - // Bearer auth over HTTPS: use mTLS certs for the transport layer when - // available (server may still require client certs), and layer the - // Bearer token on top via the interceptor. - if let Ok(materials) = require_tls_materials(server, tls) { - build_tonic_tls_config(server, &materials)? - } else { - let resolved = tls.with_default_paths(server); - let config = resolved - .ca - .as_ref() - .and_then(|ca_path| std::fs::read(ca_path).ok()) - .map_or_else( - || ClientTlsConfig::new().with_enabled_roots(), - |ca_pem| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem)), - ); - tls_config_with_server_name(server, config)? - } - } else if tls.edge_token.is_some() { - // Edge bearer mode — routed through tunnel above; if we reach here - // the server is not HTTPS so connect plaintext. - return endpoint.connect().await.into_diagnostic(); - } else { - // Standard mTLS: private CA + client cert. - let materials = require_tls_materials(server, tls)?; - build_tonic_tls_config(server, &materials)? - }; - endpoint = endpoint.tls_config(tls_config).into_diagnostic()?; - endpoint.connect().await.into_diagnostic() + let config = sdk_client_config(server, tls)?; + openshell_sdk::transport::build_channel(&config) + .await + .into_diagnostic() } -/// Build a gRPC [`OpenShellClient`]. +/// Build the SDK client and return its raw gRPC escape hatch. /// /// When `tls.edge_token` is set, the returned client is wrapped with an /// interceptor that injects authentication headers on every request. /// Otherwise, standard mTLS is used (interceptor is a no-op). pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result { - let channel = build_channel(server, tls).await?; - let interceptor = interceptor_from_tls(tls)?; - Ok(OpenShellClient::with_interceptor(channel, interceptor)) -} - -fn interceptor_from_tls(tls: &TlsOptions) -> Result { - EdgeAuthInterceptor::new(tls.oidc_token.as_deref(), tls.edge_token.as_deref()) + let config = sdk_client_config(server, tls)?; + // Keep the SDK's transport state machine out of every command future. + // Most CLI commands compose this helper into larger async operations. + let client = Box::pin(OpenShellClient::connect(config)) + .await + .into_diagnostic()?; + Ok(client.raw_grpc()) } /// Gateway replica that owns the sandbox named in `response`, when it says. @@ -510,10 +364,14 @@ pub fn retry_unrouted(replica: Option<&AsciiMetadataValue>, status: &tonic::Stat #[cfg(test)] mod tests { - use super::{owner_replica, retry_unrouted, routed_to, tls_server_name, with_owner_hint}; + use super::{ + TlsOptions, owner_replica, retry_unrouted, routed_to, sdk_client_config, with_owner_hint, + }; use openshell_core::replica_routing::{ OWNER_REPLICA_HEADER, OWNER_REQUEST_HEADER, ROUTE_REPLICA_HEADER, }; + use openshell_sdk::AuthConfig; + use std::io::Write; #[test] fn owner_hint_is_requested_explicitly() { @@ -587,19 +445,44 @@ mod tests { } #[test] - fn tls_server_name_normalizes_bracketed_ipv6_endpoint() { - assert_eq!( - tls_server_name("https://[::1]:17670").unwrap(), - "::1", - "rustls accepts an unbracketed IPv6 address as an IP server name" + fn sdk_config_carries_cli_mtls_materials() { + let mut ca = tempfile::NamedTempFile::new().unwrap(); + let mut cert = tempfile::NamedTempFile::new().unwrap(); + let mut key = tempfile::NamedTempFile::new().unwrap(); + ca.write_all(b"ca pem").unwrap(); + cert.write_all(b"cert pem").unwrap(); + key.write_all(b"key pem").unwrap(); + + let tls = TlsOptions::new( + Some(ca.path().to_path_buf()), + Some(cert.path().to_path_buf()), + Some(key.path().to_path_buf()), ); + let config = sdk_client_config("https://gateway.example", &tls).unwrap(); + + assert_eq!(config.ca_cert.as_deref(), Some(b"ca pem".as_slice())); + let identity = config.client_identity.unwrap(); + assert_eq!(identity.certificate, b"cert pem"); + assert_eq!(identity.private_key, b"key pem"); } #[test] - fn tls_server_name_preserves_dns_endpoint() { - assert_eq!( - tls_server_name("https://localhost:17670").unwrap(), - "localhost" - ); + fn sdk_config_maps_oidc_without_requiring_mtls() { + let tls = TlsOptions { + oidc_token: Some("access-token".to_string()), + ..Default::default() + }; + + let config = sdk_client_config("https://gateway.example", &tls).unwrap(); + + assert!(matches!(config.auth, Some(AuthConfig::Oidc { .. }))); + assert!(config.client_identity.is_none()); + } + + #[test] + fn sdk_config_ignores_certificates_for_plaintext() { + let config = sdk_client_config("http://127.0.0.1:8080", &TlsOptions::default()).unwrap(); + assert!(config.ca_cert.is_none()); + assert!(config.client_identity.is_none()); } } diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 68c95e0c5b..1e2e1a1896 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -34,12 +34,11 @@ for the refreshed access token. - Plaintext (local development) - Server-authenticated TLS (system roots, or a pinned private CA via `ca_cert`) +- Mutual TLS via an in-memory `ClientIdentity` - OIDC bearer over HTTPS (gateways behind an OAuth2/OIDC IdP) - Cloudflare Access tunnel (hosted gateways) - Insecure TLS (development/debug; certificate verification disabled) -mTLS (client certificates) is not supported. - ## Public surface `OpenShellClient::connect(ClientConfig)` returns a connected client exposing @@ -178,7 +177,7 @@ These helpers use the raw client's authentication slot. They do not perform OIDC | Module | Purpose | |---|---| | `client` | High-level `OpenShellClient` and the curated sandbox surface. | -| `config` | `ClientConfig`, `AuthConfig`. | +| `config` | `ClientConfig`, `AuthConfig`, `ClientIdentity`. | | `transport` | Channel construction, TLS resolution, request interceptors. | | `auth` | `EdgeAuthInterceptor` for bearer-token attachment. | | `oidc` | OIDC token handling at the transport layer. | diff --git a/crates/openshell-sdk/src/config.rs b/crates/openshell-sdk/src/config.rs index bc4945c8ee..14c318c7cb 100644 --- a/crates/openshell-sdk/src/config.rs +++ b/crates/openshell-sdk/src/config.rs @@ -4,9 +4,9 @@ //! Public input types for the SDK: how callers describe a gateway and the //! credentials used to talk to it. //! -//! The CLI keeps its own filesystem-aware `TlsOptions` for transport plumbing -//! and reuses the SDK's OIDC refresh primitive. Other consumers describe the -//! complete transport with [`ClientConfig`]. +//! Filesystem-aware callers such as the CLI resolve certificate paths before +//! constructing [`ClientConfig`]. The SDK accepts certificate contents so it +//! remains independent of filesystem layout and gateway-name resolution. use crate::refresh::Refresh; use std::sync::Arc; @@ -54,10 +54,30 @@ impl AuthConfig { } } +/// PEM-encoded client identity used for mutual TLS. +/// +/// The certificate may contain a chain. The private key must be PKCS#1, +/// PKCS#8, or SEC1 PEM as accepted by tonic/rustls. +#[derive(Clone)] +pub struct ClientIdentity { + pub certificate: Vec, + pub private_key: Vec, +} + +impl ClientIdentity { + pub fn new(certificate: impl Into>, private_key: impl Into>) -> Self { + Self { + certificate: certificate.into(), + private_key: private_key.into(), + } + } +} + /// Configuration for opening a gRPC channel to an `OpenShell` gateway. /// -/// Consumed by `openshell_sdk::transport::grpc_client`. One `ClientConfig` per logical connection; -/// callers that want connection pooling cache the resulting `tonic::Channel`. +/// Consumed by [`crate::OpenShellClient::connect`] and +/// [`crate::transport::build_channel`]. Use one `ClientConfig` per logical +/// connection; callers that want connection pooling cache the resulting client. // // NOTE: // - `gateway` is a full URL (`http://...` or `https://...`) so the scheme @@ -65,9 +85,8 @@ impl AuthConfig { // today's CLI convention; matches the RFC's `pub gateway: String`. // - `ca_cert` pins a private-CA certificate (PEM-encoded). `None` falls // back to the platform's system roots. -// - This SDK does not speak mTLS. Gateways requiring client certificates -// are handled by `openshell-cli`'s legacy mTLS path until product -// retires that auth method. +// - `client_identity` carries already-resolved PEM bytes. The SDK deliberately +// does not know where a caller stores certificates. // - `insecure_skip_verify` is a separate flag rather than a third // `AuthConfig` variant because it's a transport concern (cert // verification) that's orthogonal to auth. @@ -89,6 +108,9 @@ pub struct ClientConfig { /// roots. Ignored for plaintext gateways and when /// `insecure_skip_verify` is enabled. pub ca_cert: Option>, + /// Client certificate and private key for mutual TLS. Ignored for + /// plaintext gateways, Cloudflare Access tunnels, and insecure TLS. + pub client_identity: Option, /// Bearer-token auth mode. `None` = anonymous TLS over HTTPS, or /// plaintext when `gateway` is `http://`. pub auth: Option, diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index 5459fa7532..950d1285ff 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -45,7 +45,7 @@ pub mod types; pub use auth::EdgeAuthInterceptor; pub use client::{OpenShellClient, WorkspaceScopedClient}; -pub use config::{AuthConfig, ClientConfig}; +pub use config::{AuthConfig, ClientConfig, ClientIdentity}; pub use error::SdkError; pub use pagination::{Page, Pager}; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; diff --git a/crates/openshell-sdk/src/transport.rs b/crates/openshell-sdk/src/transport.rs index 07ced55215..9741637b57 100644 --- a/crates/openshell-sdk/src/transport.rs +++ b/crates/openshell-sdk/src/transport.rs @@ -2,10 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 //! gRPC transport stack: channel construction and the insecure-TLS connector. -//! -//! mTLS is intentionally out of scope here. Gateways that require client -//! certificates are handled by `openshell-cli`'s legacy path until the auth -//! method is retired. use crate::config::{AuthConfig, ClientConfig}; use crate::edge_tunnel; @@ -21,7 +17,7 @@ use std::net::SocketAddr; use std::sync::OnceLock; use std::time::Duration; use tokio::sync::Mutex; -use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint}; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; use tracing::debug; /// Standard endpoint settings used by every dialed connection. @@ -84,7 +80,7 @@ async fn edge_tunnel_addr(server: &str, token: &str) -> Result { /// | `http://` | (any) | (any) | plaintext, ignore tls | /// | `https://` | `Some(EdgeJwt)` | (any) | tunnel proxy + plaintext to local proxy | /// | `https://` | (any) | `true` | `InsecureTlsConnector`, no verification | -/// | `https://` | `Some(Oidc)` or `None` | `false` | tonic TLS, pin `ca_cert` if set, system roots otherwise | +/// | `https://` | `Some(Oidc)` or `None` | `false` | tonic TLS, optionally pin `ca_cert` and present `client_identity` | pub async fn build_channel(config: &ClientConfig) -> Result { let gateway = &config.gateway; @@ -131,10 +127,16 @@ pub async fn build_channel(config: &ClientConfig) -> Result { // is added by the gRPC interceptor at request time, not here — // `build_channel` only owns the TLS layer. - let tls_config = config.ca_cert.as_ref().map_or_else( + let mut tls_config = config.ca_cert.as_ref().map_or_else( || ClientTlsConfig::new().with_enabled_roots(), |ca_cert| ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_cert)), ); + if let Some(identity) = &config.client_identity { + tls_config = tls_config.identity(Identity::from_pem( + identity.certificate.clone(), + identity.private_key.clone(), + )); + } standard_endpoint(gateway.clone())? .tls_config(tls_config) .map_err(|e| SdkError::tls(format!("{e}")))?