mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-26 06:52:57 +08:00
* 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>
262 lines
9.2 KiB
Rust
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;
|
|
}
|