refactor(cli): use Rust SDK transport (#4817)

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
This commit is contained in:
Taylor Mutch
2026-10-22 02:10:49 +00:00
committed by GitHub
parent 1d880c9acd
commit 029fda684d
10 changed files with 135 additions and 512 deletions
Generated
-3
View File
@@ -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",
-5
View File
@@ -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 }
+3 -16
View File
@@ -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<CompletionCandidate> {
@@ -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<OpenShellClient<InterceptedService<Channel, EdgeAuthInterceptor>>> {
async fn completion_grpc_client(server: &str, gateway_name: &str) -> Option<GrpcClient> {
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.
-261
View File
@@ -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:<local_port>`
//! (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<EdgeTunnelProxy> {
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<TunnelConfig>) {
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<WebSocketStream<MaybeTlsStream<TcpStream>>> {
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<TcpStream>,
mut ws_sink: SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, 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<WebSocketStream<MaybeTlsStream<TcpStream>>>,
mut tcp_write: tokio::io::WriteHalf<TcpStream>,
) {
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;
}
-1
View File
@@ -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;
+90 -207
View File
@@ -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<InterceptedService<Channel, EdgeAuthInterceptor>>;
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<rustls::ClientCon
.into_diagnostic()
}
pub fn build_tonic_tls_config(server: &str, materials: &TlsMaterials) -> Result<ClientTlsConfig> {
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<ClientTlsConfig> {
Ok(config.domain_name(tls_server_name(server)?))
}
fn tls_server_name(server: &str) -> Result<String> {
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<hyper::Uri> for InsecureTlsConnector {
type Response = hyper_util::rt::TokioIo<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Future =
std::pin::Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
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<rustls::ClientConfig> {
let config = openshell_crypto::tls::client_builder()
.dangerous()
@@ -332,136 +259,63 @@ pub fn build_insecure_rustls_config() -> Result<rustls::ClientConfig> {
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<Mutex<HashMap<(String, String), SocketAddr>>> = OnceLock::new();
fn sdk_client_config(server: &str, tls: &TlsOptions) -> Result<ClientConfig> {
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));
async fn edge_tunnel_addr(server: &str, token: &str) -> Result<SocketAddr> {
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);
}
}
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))
}
pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
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)?
// 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 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)?
let materials = require_tls_materials(server, tls)?;
config.ca_cert = Some(materials.ca);
config.client_identity = Some(ClientIdentity::new(materials.cert, materials.key));
}
} 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()
}
Ok(config)
}
/// Build a gRPC [`OpenShellClient`].
/// 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<Channel> {
let config = sdk_client_config(server, tls)?;
openshell_sdk::transport::build_channel(&config)
.await
.into_diagnostic()
}
/// 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<GrpcClient> {
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> {
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());
}
}
+2 -3
View File
@@ -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. |
+30 -8
View File
@@ -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<u8>,
pub private_key: Vec<u8>,
}
impl ClientIdentity {
pub fn new(certificate: impl Into<Vec<u8>>, private_key: impl Into<Vec<u8>>) -> 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<Vec<u8>>,
/// Client certificate and private key for mutual TLS. Ignored for
/// plaintext gateways, Cloudflare Access tunnels, and insecure TLS.
pub client_identity: Option<ClientIdentity>,
/// Bearer-token auth mode. `None` = anonymous TLS over HTTPS, or
/// plaintext when `gateway` is `http://`.
pub auth: Option<AuthConfig>,
+1 -1
View File
@@ -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};
+9 -7
View File
@@ -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<SocketAddr> {
/// | `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<Channel> {
let gateway = &config.gateway;
@@ -131,10 +127,16 @@ pub async fn build_channel(config: &ClientConfig) -> Result<Channel> {
// 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}")))?