Files
OpenShell/crates/openshell-cli/src/edge_tunnel.rs
Jim Meyer 8c7dd148a9 perf(net): set TCP_NODELAY on latency-sensitive TCP hops (#2220)
* perf(net): set TCP_NODELAY on all tunnel and proxy TCP hops

The sandbox tunnel added ~44 ms of latency to every small
request/response because no socket in the path disabled Nagle's
algorithm, so sub-MSS writes waited on delayed ACKs at each hop.

Set TCP_NODELAY on every latency-sensitive TCP socket:

- gateway: accepted connections on the public listener (gRPC relay
  frames and WS tunnel writes)
- CLI: edge tunnel local accept + underlying WebSocket TCP stream,
  insecure TLS connector (tonic's default connector already does
  this), and service-forward accepted sockets
- supervisor: direct-tcpip connect into the sandbox netns, TCP relay
  target dials, egress proxy accepted connections, and all upstream
  CONNECT/HTTP dials (via a new connect_upstream helper)

Setting TCP_NODELAY on connect is best-effort: a failure only costs
latency, so we log and continue rather than fail the connection.

The sandbox SSH transport rides a unix domain socket and gRPC client
channels use tonic defaults (nodelay on), so no change is needed there.

Fixes #2219

Signed-off-by: Jim Meyer <jim@meyer4hire.com>

* refactor(net): house TCP_NODELAY helper in a shared net module

Address review feedback on the TCP_NODELAY change:

- Move the shared set-nodelay helper out of supervisor_session into a
  new crate-private `net` module in openshell-supervisor-process, so
  ssh and supervisor_session no longer reach across modules through a
  pub(crate) item.
- Make the best-effort comments at each call site terse and consistent.

No behavior change; the benchmark ladder reproduces the same numbers.

Signed-off-by: Jim Meyer <jim@meyer4hire.com>

* refactor(net): consolidate TCP_NODELAY helpers into openshell_core::net

Move the best-effort TCP_NODELAY helpers into the shared openshell_core::net
module so every crate dials and configures sockets the same way:

- Add set_tcp_nodelay_best_effort (accepted/existing streams) and
  connect_tcp_nodelay_best_effort (dial + set) with unit tests.
- Migrate all call sites in openshell-cli, openshell-server, and the
  supervisor crates to the shared helpers.
- Remove the crate-private net module from openshell-supervisor-process.
- Document socket guidance in AGENTS.md (Network Sockets).

Signed-off-by: Jim Meyer <jim@meyer4hire.com>

* perf(net): set TCP_NODELAY on exec bridge and metadata server

The gateway-side single-use SSH-over-relay loopback bridge and the
sandbox IMDS metadata server were missed latency-sensitive TCP hops.
Set TCP_NODELAY on the accepted client connection and both russh client
dials of the exec bridge — interactive keystrokes and line-buffered PTY
output are the most tinygram-heavy traffic in the system — and on the
metadata server's accepted connections.

Also log unrecognized MaybeTlsStream variants in the edge tunnel so a
future TLS-backend change surfaces a silent TCP_NODELAY miss instead of
skipping it quietly.

Signed-off-by: Jim Meyer <jim@meyer4hire.com>

* perf(net): set TCP_NODELAY on openshell-sdk socket paths

The openshell-sdk crate landed on main with its own copies of the CLI's
hand-rolled sockets, which the CLI and TUI are meant to consume. Give
them the same treatment as the CLI equivalents:

- edge_tunnel: the accepted local tunnel connection and the WebSocket's
  underlying TCP socket (plain and rustls variants).
- transport: the dial in InsecureTlsConnector, tonic's custom-connector
  path.

Only these hand-rolled sockets need it. Tonic's own connector defaults
tcp_nodelay to true and applies it itself, so plain Endpoint::connect
callers were already covered.

Signed-off-by: Jim Meyer <jim@meyer4hire.com>

---------

Signed-off-by: Jim Meyer <jim@meyer4hire.com>
2026-08-04 17:59:13 +00:00

262 lines
9.2 KiB
Rust

// 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;
}