mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-26 06:52:57 +08:00
refactor(cli): use Rust SDK transport (#4817)
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
This commit is contained in:
Generated
-3
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}")))?
|
||||
|
||||
Reference in New Issue
Block a user