mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-12 11:33:51 +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>
232 lines
7.7 KiB
Rust
232 lines
7.7 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Loopback HTTP server for cloud metadata emulators.
|
|
//!
|
|
//! Binds a TCP listener inside the sandbox network namespace so that
|
|
//! cloud SDKs that bypass `HTTP_PROXY` (e.g. Go's
|
|
//! `cloud.google.com/go/compute/metadata`) can reach the emulator via
|
|
//! direct TCP.
|
|
//!
|
|
//! The server is generic over [`MetadataHandler`] — any cloud provider
|
|
//! that needs an instance metadata emulator can implement the trait.
|
|
|
|
use miette::Result;
|
|
use openshell_core::net::set_tcp_nodelay_best_effort;
|
|
use std::future::Future;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::{Semaphore, oneshot};
|
|
use tracing::{debug, warn};
|
|
|
|
const MAX_REQUEST_BYTES: usize = 4096;
|
|
const MAX_CONCURRENT_CONNECTIONS: usize = 32;
|
|
|
|
/// Handler for cloud metadata HTTP requests.
|
|
///
|
|
/// Implementors receive the parsed HTTP method, path, raw request bytes,
|
|
/// and a bidirectional stream to write the response. The handler owns the
|
|
/// response format (status, headers, body) — the server only does TCP
|
|
/// accept and HTTP request-line parsing.
|
|
pub trait MetadataHandler: Send + Sync + 'static {
|
|
fn handle<S: AsyncRead + AsyncWrite + Unpin + Send>(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
request: &[u8],
|
|
stream: &mut S,
|
|
) -> impl Future<Output = Result<()>> + Send;
|
|
}
|
|
|
|
/// Bind a TCP listener inside the sandbox network namespace.
|
|
///
|
|
/// Run the metadata server accept loop.
|
|
///
|
|
/// Signals `ready_tx` with the bound address before entering the loop.
|
|
/// Returns when the listener encounters a fatal error or the runtime shuts down.
|
|
pub async fn run<H: MetadataHandler>(
|
|
listener: TcpListener,
|
|
handler: H,
|
|
ready_tx: oneshot::Sender<SocketAddr>,
|
|
) {
|
|
let local_addr = match listener.local_addr() {
|
|
Ok(addr) => addr,
|
|
Err(e) => {
|
|
warn!("metadata server failed to get local address: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let _ = ready_tx.send(local_addr);
|
|
|
|
let handler = Arc::new(handler);
|
|
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
|
|
|
loop {
|
|
let Ok(permit) = semaphore.clone().acquire_owned().await else {
|
|
break;
|
|
};
|
|
|
|
match listener.accept().await {
|
|
Ok((stream, _addr)) => {
|
|
// Small-request IMDS-style endpoint an agent polls for
|
|
// credentials/identity — disable Nagle to avoid delayed-ACK stalls.
|
|
set_tcp_nodelay_best_effort(&stream);
|
|
let handler = handler.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) = handle_connection(handler.as_ref(), stream).await {
|
|
debug!("metadata server connection error: {e}");
|
|
}
|
|
drop(permit);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
warn!("metadata server accept error: {e}");
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
|
|
async fn handle_connection<H: MetadataHandler>(
|
|
handler: &H,
|
|
mut stream: tokio::net::TcpStream,
|
|
) -> Result<()> {
|
|
let mut buf = vec![0u8; MAX_REQUEST_BYTES];
|
|
let mut used = 0;
|
|
let deadline = tokio::time::sleep(READ_TIMEOUT);
|
|
tokio::pin!(deadline);
|
|
loop {
|
|
tokio::select! {
|
|
result = stream.read(&mut buf[used..]) => {
|
|
let n = result.map_err(|e| miette::miette!("{e}"))?;
|
|
if n == 0 {
|
|
return Ok(());
|
|
}
|
|
used += n;
|
|
if buf[..used].windows(4).any(|w| w == b"\r\n\r\n") {
|
|
break;
|
|
}
|
|
if used >= buf.len() {
|
|
let _ = stream
|
|
.write_all(b"HTTP/1.1 413 Request Entity Too Large\r\nContent-Length: 0\r\n\r\n")
|
|
.await;
|
|
return Ok(());
|
|
}
|
|
}
|
|
() = &mut deadline => {
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
let request = String::from_utf8_lossy(&buf[..used]);
|
|
let request_line = request.split("\r\n").next().unwrap_or("");
|
|
let mut parts = request_line.split_whitespace();
|
|
let method = parts.next().unwrap_or("");
|
|
let path = parts.next().unwrap_or("/");
|
|
|
|
tokio::time::timeout(
|
|
READ_TIMEOUT,
|
|
handler.handle(method, path, &buf[..used], &mut stream),
|
|
)
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
debug!(method, path, "metadata handler timed out");
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::sync::mpsc;
|
|
|
|
struct RecordingHandler {
|
|
requests: mpsc::UnboundedSender<(String, String)>,
|
|
}
|
|
|
|
impl MetadataHandler for RecordingHandler {
|
|
async fn handle<S: AsyncRead + AsyncWrite + Unpin + Send>(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
_request: &[u8],
|
|
stream: &mut S,
|
|
) -> Result<()> {
|
|
self.requests
|
|
.send((method.to_string(), path.to_string()))
|
|
.unwrap();
|
|
stream
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
|
.await
|
|
.map_err(|error| miette::miette!("{error}"))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn connection_pair() -> (tokio::net::TcpStream, tokio::net::TcpStream) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let client = tokio::net::TcpStream::connect(listener.local_addr().unwrap())
|
|
.await
|
|
.unwrap();
|
|
let (server, _) = listener.accept().await.unwrap();
|
|
(client, server)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_loopback_dispatches_method_path_and_response() {
|
|
let (requests_tx, mut requests_rx) = mpsc::unbounded_channel();
|
|
let handler = RecordingHandler {
|
|
requests: requests_tx,
|
|
};
|
|
let (mut client, server) = connection_pair().await;
|
|
let server_task = tokio::spawn(async move { handle_connection(&handler, server).await });
|
|
|
|
client
|
|
.write_all(b"GET /computeMetadata/v1/instance HTTP/1.1\r\nHost: metadata\r\n\r\n")
|
|
.await
|
|
.unwrap();
|
|
let mut response = Vec::new();
|
|
client.read_to_end(&mut response).await.unwrap();
|
|
server_task.await.unwrap().unwrap();
|
|
|
|
assert_eq!(
|
|
requests_rx.try_recv().unwrap(),
|
|
(
|
|
"GET".to_string(),
|
|
"/computeMetadata/v1/instance".to_string()
|
|
)
|
|
);
|
|
assert_eq!(response, b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_loopback_rejects_oversized_headers_before_handler() {
|
|
let (requests_tx, mut requests_rx) = mpsc::unbounded_channel();
|
|
let handler = RecordingHandler {
|
|
requests: requests_tx,
|
|
};
|
|
let (mut client, server) = connection_pair().await;
|
|
let server_task = tokio::spawn(async move { handle_connection(&handler, server).await });
|
|
|
|
client
|
|
.write_all(&vec![b'x'; MAX_REQUEST_BYTES])
|
|
.await
|
|
.unwrap();
|
|
let mut response = Vec::new();
|
|
client.read_to_end(&mut response).await.unwrap();
|
|
server_task.await.unwrap().unwrap();
|
|
|
|
assert_eq!(
|
|
response,
|
|
b"HTTP/1.1 413 Request Entity Too Large\r\nContent-Length: 0\r\n\r\n"
|
|
);
|
|
assert!(requests_rx.try_recv().is_err());
|
|
}
|
|
}
|