From 5e59379013f89df44b1e93006e77459725435a66 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Fri, 28 Aug 2026 17:09:14 -0600 Subject: [PATCH 1/3] Tunnel to the rack through Nexus Given --nexus, the client carries its connections over authenticated websockets to Nexus's support-shell endpoint, which pipes them to a sush proxy. The platform TLS and signed requests inside cross Nexus untouched, so the tech port and the tunnel carry identical traffic. Co-Authored-By: Claude Mythos 5 --- Cargo.lock | 1 + client/Cargo.toml | 1 + client/src/commands.rs | 53 ++++- client/src/lib.rs | 1 + client/src/repl.rs | 4 + client/src/tunnel.rs | 358 +++++++++++++++++++++++++++++++++ tests/src/integration_tests.rs | 108 ++++++++++ 7 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 client/src/tunnel.rs diff --git a/Cargo.lock b/Cargo.lock index f3d4a8a1..4f83cb0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4880,6 +4880,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-fd", + "tokio-rustls", "tokio-tungstenite", "x509-cert", "xdg 3.0.0", diff --git a/client/Cargo.toml b/client/Cargo.toml index 844ae66d..44bf21bc 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -57,6 +57,7 @@ sush-common = { path = "../common" } thiserror.workspace = true tokio.workspace = true tokio-fd.workspace = true +tokio-rustls.workspace = true tokio-tungstenite.workspace = true x509-cert.workspace = true xdg.workspace = true diff --git a/client/src/commands.rs b/client/src/commands.rs index d1b3c3e5..b5e74349 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -66,6 +66,7 @@ use crate::interactive::interactive_job; use crate::permslip::{PermslipError, PermslipSigner}; use crate::repl::Repl; use crate::tls; +use crate::tunnel::{Tunnel, TunnelError}; use crate::types::{Error as ApiError, SessionStartBody}; use crate::{Client, Error as ClientError}; @@ -82,7 +83,11 @@ pub const SUSH_MAX_FSIZE: &str = "SUSH_MAX_FSIZE"; #[cfg(feature = "permslip")] pub const SUSH_PERMSLIP_KEY: &str = "SUSH_PERMSLIP_KEY"; pub const SUSH_OUTPUT_FORMAT: &str = "SUSH_OUTPUT_FORMAT"; +pub const SUSH_NEXUS: &str = "SUSH_NEXUS"; +pub const SUSH_NEXUS_ROOT: &str = "SUSH_NEXUS_ROOT"; +pub const SUSH_NEXUS_TOKEN: &str = "SUSH_NEXUS_TOKEN"; pub const SUSH_PROXY_ROOT: &str = "SUSH_PROXY_ROOT"; +pub const SUSH_RACK: &str = "SUSH_RACK"; pub const SUSH_URL: &str = "SUSH_URL"; /// Default chunk size for parallel downloads of large output. @@ -158,6 +163,28 @@ pub struct GlobalArgs { #[clap(global = true)] pub url: Option, + /// Nexus URL to tunnel through instead of a tech port URL + // Not env OXIDE_HOST: clap applies conflict rules to env-sourced + // values, so the oxide CLI's variable would poison --url. + #[arg(long, env = SUSH_NEXUS, conflicts_with_all = ["url", "offline"])] + #[clap(global = true)] + pub nexus: Option, + + /// Token authenticating the tunnel to Nexus + #[arg(long, env = SUSH_NEXUS_TOKEN, hide_env_values = true)] + #[clap(global = true)] + pub nexus_token: Option, + + /// PEM roots that Nexus's TLS certificate must chain to + #[arg(long = "nexus-root", env = SUSH_NEXUS_ROOT, value_name = "PEM")] + #[clap(global = true)] + pub nexus_roots: Vec, + + /// ID of the rack to tunnel to (try `oxide system hardware rack list`) + #[arg(long, env = SUSH_RACK, value_name = "UUID")] + #[clap(global = true)] + pub rack: Option, + /// PEM roots that a proxy's TLS certificate must chain to, /// replacing the baked-in platform identity roots. #[arg(long = "proxy-root", env = SUSH_PROXY_ROOT, value_name = "PEM")] @@ -747,11 +774,29 @@ impl ClientCommand { } async fn run(self, ctx: &mut impl CommandContext) -> Result<(), CommandError> { - let args = ctx.get_globals().to_owned(); + let mut args = ctx.get_globals().to_owned(); if let Some(output) = args.output_format() { ctx.set_output_format(output); } + // The tunnel stands in for the proxy until the command ends. + let _tunnel = match args.nexus.as_ref() { + Some(nexus) => { + let rack = args.rack.as_ref().ok_or(CommandError::MissingRack)?; + let token = args + .nexus_token + .as_ref() + .ok_or(CommandError::MissingNexusToken)?; + let tunnel = Tunnel::start(nexus, rack, token, &args.nexus_roots).await?; + args.url = Some(tunnel.url.clone()); + // The repl re-enters run per command; commands must + // reuse this tunnel, not build their own. + args.nexus = None; + Some(tunnel) + } + None => None, + }; + let client = match args.url.as_ref() { Some(url) => { let roots = if args.proxy_roots.is_empty() { @@ -2448,6 +2493,10 @@ pub enum CommandError { #[cfg(feature = "permslip")] #[error("❌ Missing permslip URL, try `--permslip-url` or setting `PERMSLIP_URL`")] MissingPermslipUrl, + #[error("❌ Missing rack ID, try `--rack` (IDs from `oxide system hardware rack list`)")] + MissingRack, + #[error("❌ Missing Nexus token, try `--nexus-token` or setting `SUSH_NEXUS_TOKEN`")] + MissingNexusToken, #[error("❌ Missing session, try `session start`")] MissingSession, #[error("❌ Missing SSH agent socket, try `--ssh-auth-sock`")] @@ -2501,6 +2550,8 @@ pub enum CommandError { TimedOut, #[error("❌ Too much output to display on terminal, try `--file`")] TooMuchOutput, + #[error("❌ Tunnel error: {0}")] + Tunnel(#[from] TunnelError), #[error("❌ No sled with serial `{serial}` has a status for job `{job_id}`")] UnknownSerial { serial: String, job_id: JobId }, #[error("❌ Serial `{0}` matches no sled in the rack inventory")] diff --git a/client/src/lib.rs b/client/src/lib.rs index 98e8f342..1f9aab9a 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -19,6 +19,7 @@ pub mod interactive; pub mod permslip; pub mod repl; pub mod tls; +pub mod tunnel; /// Authorization state shared between the command context and the /// client's pre-send hook, which signs every request with the current diff --git a/client/src/repl.rs b/client/src/repl.rs index 82fa7268..7bdead6e 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -172,6 +172,10 @@ impl CommandContext for Repl { ssh_auth_sock, ssh_key_id, proxy_roots: _, + nexus: _, + nexus_token: _, + nexus_roots: _, + rack: _, } = args; if json { output = Some(OutputFormat::Json); diff --git a/client/src/tunnel.rs b/client/src/tunnel.rs new file mode 100644 index 00000000..f2dcca46 --- /dev/null +++ b/client/src/tunnel.rs @@ -0,0 +1,358 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Transparent tunneling through Nexus. +//! +//! Given `--nexus`, the client listens on an ephemeral loopback port, +//! carries each accepted connection over an authenticated websocket +//! to Nexus, and points itself at the listener. The sprockets-TLS +//! and signed requests inside are forwarded untouched, so the tech +//! port and the tunnel carry the same traffic. The far end of each +//! WebSocket is the sush proxy in a switch zone. The Nexus half lives +//! in Omicron's `nexus/src/app/support_shell.rs`. + +use std::fs::read; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use futures::{SinkExt, StreamExt}; +use http::Uri; +use http::header::{AUTHORIZATION, HeaderValue}; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{ClientConfig, RootCertStore}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::select; +use tokio::spawn; +use tokio::task::{JoinHandle, JoinSet}; +use tokio::time::{interval, sleep, timeout}; +use tokio_rustls::TlsConnector; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use x509_cert::Certificate; +use x509_cert::der::Encode; + +/// How long to wait for TCP toward Nexus. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to back off a failed accept (say, EMFILE) rather than +/// retrying it hot. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); + +/// How often to ping, so NAT and LB idle timers see a live flow +/// while the operator thinks. The far side auto-pongs. +const PING_INTERVAL: Duration = Duration::from_secs(30); + +/// What went wrong starting or probing the tunnel. +#[derive(Debug, Error)] +pub enum TunnelError { + #[error("can't listen on loopback: {0}")] + Listen(io::Error), + #[error("invalid Nexus URL `{url}`: {reason}")] + NexusUrl { url: String, reason: &'static str }, + #[error("can't read root certificate `{path}`: {error}")] + Root { path: PathBuf, error: io::Error }, + #[error("can't parse root certificate `{0}`")] + RootPem(PathBuf), + #[error("an https Nexus URL needs `--nexus-root`")] + MissingRoots, + #[error("tunnel probe failed: {0}")] + Probe(String), +} + +/// Where the websockets go and how they authenticate. +struct Target { + host: String, + port: u16, + uri: Uri, + token: String, + /// None speaks plain websockets, for tests and dev Nexus. + tls: Option, +} + +/// A running tunnel. Dropping it closes the listener and every +/// connection in flight. +pub struct Tunnel { + /// The loopback URL standing in for the sush proxy. + pub url: String, + listener_task: JoinHandle<()>, +} + +impl Drop for Tunnel { + fn drop(&mut self) { + self.listener_task.abort(); + } +} + +impl Tunnel { + /// Start forwarding loopback connections to the rack's sush proxy + /// by way of Nexus, probing the path once before accepting any. + pub async fn start( + nexus: &str, + rack_id: &str, + token: &str, + roots: &[PathBuf], + ) -> Result { + let target = Arc::new(target(nexus, rack_id, token, roots)?); + + // A dead path should fail the command now, not as a + // connection reset from the listener later. + probe(&target).await.map_err(TunnelError::Probe)?; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(TunnelError::Listen)?; + let url = format!( + "https://{}", + listener.local_addr().map_err(TunnelError::Listen)? + ); + let listener_task = spawn(async move { + // Connections outlive neither this task nor the JoinSet. + let mut connections = JoinSet::new(); + loop { + match listener.accept().await { + Ok((stream, _peer)) => { + let target = Arc::clone(&target); + connections.spawn(async move { + if let Err(error) = forward(&target, stream).await { + eprintln!("⚠️ Tunnel connection failed: {error}"); + } + }); + } + Err(_) => sleep(ACCEPT_RETRY).await, + } + while connections.try_join_next().is_some() {} + } + }); + Ok(Tunnel { url, listener_task }) + } +} + +/// Resolve the flags into a connection target. +fn target( + nexus: &str, + rack_id: &str, + token: &str, + roots: &[PathBuf], +) -> Result { + let invalid = |reason| TunnelError::NexusUrl { + url: nexus.to_string(), + reason, + }; + let uri = nexus.parse::().map_err(|_| invalid("unparseable"))?; + let secure = match uri.scheme_str() { + None | Some("https") => true, + Some("http") => false, + Some(_) => return Err(invalid("scheme must be http or https")), + }; + // The bare host connects and names the server; the authority form + // keeps its brackets for the request URI. + let raw = uri.host().ok_or_else(|| invalid("no host"))?; + let host = raw + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + let authority = if host.contains(':') { + format!("[{host}]") + } else { + host.clone() + }; + let port = uri.port_u16().unwrap_or(if secure { 443 } else { 80 }); + let scheme = if secure { "wss" } else { "ws" }; + if !rack_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(invalid("bad rack id")); + } + let uri = format!( + "{scheme}://{authority}:{port}/v1/system/hardware/racks/{rack_id}/support-shell/tunnel" + ) + .parse::() + .map_err(|_| invalid("bad rack id"))?; + + let tls = if secure { + if roots.is_empty() { + return Err(TunnelError::MissingRoots); + } + let mut store = RootCertStore::empty(); + for path in roots { + let pem = read(path).map_err(|error| TunnelError::Root { + path: path.clone(), + error, + })?; + // Roots often arrive bundled; take every certificate. + let chain = Certificate::load_pem_chain(&pem) + .map_err(|_| TunnelError::RootPem(path.clone()))?; + for cert in chain { + let der = cert + .to_der() + .map_err(|_| TunnelError::RootPem(path.clone()))?; + store + .add(CertificateDer::from(der)) + .map_err(|_| TunnelError::RootPem(path.clone()))?; + } + } + let config = ClientConfig::builder() + .with_root_certificates(store) + .with_no_client_auth(); + Some(TlsConnector::from(Arc::new(config))) + } else { + None + }; + Ok(Target { + host, + port, + uri, + token: token.to_string(), + tls, + }) +} + +/// Upgrade an established stream into an authenticated WebSocket. +async fn handshake(target: &Target, stream: S) -> Result, String> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut request = target + .uri + .clone() + .into_client_request() + .map_err(|e| e.to_string())?; + request.headers_mut().insert( + AUTHORIZATION, + format!("Bearer {}", target.token) + .parse::() + .map_err(|e| e.to_string())?, + ); + let (ws, _response) = client_async(request, stream).await.map_err(describe)?; + Ok(ws) +} + +/// Keep a refusal's response body: Nexus says why in it. +fn describe(error: WsError) -> String { + match error { + WsError::Http(response) => { + let status = response.status(); + match response.into_body() { + Some(body) if !body.is_empty() => { + format!("{status}: {}", String::from_utf8_lossy(&body)) + } + _ => status.to_string(), + } + } + other => other.to_string(), + } +} + +/// Connect TCP toward Nexus, impatiently. +async fn dial(target: &Target) -> Result { + let connect = TcpStream::connect((target.host.as_str(), target.port)); + match timeout(CONNECT_TIMEOUT, connect).await { + Ok(Ok(tcp)) => { + let _ = tcp.set_nodelay(true); + Ok(tcp) + } + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err(String::from("connect timed out")), + } +} + +/// Prove the path to Nexus once, before standing behind it. +async fn probe(target: &Target) -> Result<(), String> { + let tcp = dial(target).await?; + match &target.tls { + Some(tls) => { + let name = ServerName::try_from(target.host.clone()).map_err(|e| e.to_string())?; + let stream = tls.connect(name, tcp).await.map_err(|e| e.to_string())?; + let _ = handshake(target, stream).await?.close(None).await; + } + None => { + let _ = handshake(target, tcp).await?.close(None).await; + } + } + Ok(()) +} + +/// Carry one loopback connection over a fresh WebSocket to Nexus. +async fn forward(target: &Target, conn: TcpStream) -> Result<(), String> { + let tcp = dial(target).await?; + match &target.tls { + Some(tls) => { + let name = ServerName::try_from(target.host.clone()).map_err(|e| e.to_string())?; + let stream = tls.connect(name, tcp).await.map_err(|e| e.to_string())?; + pipe(conn, handshake(target, stream).await?).await; + } + None => pipe(conn, handshake(target, tcp).await?).await, + } + Ok(()) +} + +/// Copy bytes both ways until either side finishes. The mirror of the +/// Nexus half's pipe: when one direction ends, both are torn down. +pub async fn pipe(tcp: TcpStream, ws: WebSocketStream) +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let _ = tcp.set_nodelay(true); + let (mut ws_sink, mut ws_source) = ws.split(); + let (mut tcp_read, mut tcp_write) = tcp.into_split(); + + let inbound = async { + while let Some(message) = ws_source.next().await { + match message { + Ok(Message::Binary(data)) => { + if tcp_write.write_all(&data).await.is_err() { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + // As on the Nexus side, teardown drops the other direction: + // no half-close support, by design. + let _ = tcp_write.shutdown().await; + }; + + let outbound = async { + let mut buf = [0; 0x2000]; + let mut keepalive = interval(PING_INTERVAL); + keepalive.tick().await; + loop { + select! { + _ = keepalive.tick() => { + if ws_sink.send(Message::Ping(Bytes::new())).await.is_err() { + break; + } + } + read = tcp_read.read(&mut buf) => match read { + Ok(0) | Err(_) => break, + Ok(n) => { + if ws_sink + .send(Message::binary(buf[..n].to_vec())) + .await + .is_err() + { + break; + } + } + }, + } + } + let _ = ws_sink.send(Message::Close(None)).await; + }; + + select! { + _ = inbound => {} + _ = outbound => {} + } +} diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index f19bf1a7..7fb06e48 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -16,10 +16,15 @@ use dropshot::{ConfigDropshot, ServerBuilder}; use function_name::named; use futures::{SinkExt as _, StreamExt as _}; use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tokio::test; use tokio::time::{sleep, timeout}; use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_hdr_async; +use tokio_tungstenite::tungstenite::handshake::server::{ + Request as WsRequest, Response as WsResponse, +}; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::sync::CancellationToken; @@ -39,6 +44,7 @@ use x509_cert::time::Validity; use sush_api::JobWait; use sush_api::sush_api_mod::api_description; use sush_client::tls::client as tls_client; +use sush_client::tunnel::{Tunnel, pipe}; use sush_client::{AuthzSigner, Client, Error as ClientError}; use sush_common::hash::hash; use sush_common::interactive::{InteractiveJobControl, InteractiveJobMessage}; @@ -1157,3 +1163,105 @@ async fn streaming_job_linger() { assert_eq!(stdout_len, streamed.len() as u64); assert_eq!(stdout_hash, hash(&streamed).into()); } + +/// A client tunneling through a stub Nexus authenticates and works, +/// with the platform TLS crossing the websocket untouched. +// The large Err is tungstenite's callback signature, not ours. +#[allow(clippy::result_large_err)] +#[named] +#[test] +async fn client_tunnels_through_nexus() { + // The rack side: a sush server behind the platform-TLS proxy. + let log = test_logger(function_name!()); + let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log.clone()).await; + let api = api_description::().unwrap(); + let server = ServerBuilder::new(api, Arc::new(mgr), log.clone()) + .config(ConfigDropshot { + bind_address: local_addr(), + ..Default::default() + }) + .start() + .expect("failed to start server"); + let (_pki_dir, pki) = test_pki("sush-tunnel-"); + let resolve = ResolveSetting::Local { + priv_key: private_key_path(pki.clone(), &sprockets_auth_prefix(1)), + cert_chain: certlist_path(pki.clone(), &sprockets_auth_prefix(1)), + }; + let tls = platform_tls(&log, resolve).expect("can't build TLS config"); + let (_tx_targets, rx_targets) = watch::channel(Targets { + sleds: BTreeMap::from([(test_baseboard_id(), server.local_addr())]), + cubbies: Cubbies::new(), + }); + let shutdown_proxy = CancellationToken::new(); + let proxy = ProxyServer::start( + &log, + local_addr(), + Some(tls), + rx_targets, + None, + shutdown_proxy.clone(), + ) + .await + .expect("can't start TLS proxy server"); + let proxy_addr = proxy.local_addr(); + + // A stub Nexus: admit the expected bearer and rack, then pipe + // each websocket to the proxy, as the real one does. + const RACK_ID: &str = "9ec7a284-b040-4bec-a2f7-cf06e1f10f76"; + const TOKEN: &str = "sesame"; + let stub = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let stub_addr = stub.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let (conn, _peer) = stub.accept().await.unwrap(); + tokio::spawn(async move { + let admit = |request: &WsRequest, response: WsResponse| { + assert_eq!( + request.uri().path(), + format!("/v1/system/hardware/racks/{RACK_ID}/support-shell/tunnel"), + ); + assert_eq!( + request.headers()["authorization"], + format!("Bearer {TOKEN}") + ); + Ok(response) + }; + let ws = accept_hdr_async(conn, admit).await.expect("stub upgrade"); + let far = TcpStream::connect(proxy_addr) + .await + .expect("stub can't reach the proxy"); + pipe(far, ws).await; + }); + } + }); + + // The tunnel probes the path, then stands in for the proxy. + let tunnel = Tunnel::start(&format!("http://{stub_addr}"), RACK_ID, TOKEN, &[]) + .await + .expect("can't start tunnel"); + + // The usual client, aimed through the tunnel, authenticates and works. + let pem = read(cert_path(pki.clone(), &root_prefix())).unwrap(); + let roots = vec![Certificate::from_pem(&pem).unwrap()]; + let signer = AuthzSigner::default(); + let client = Client::new_with_client( + &tunnel.url, + tls_client(roots, None).unwrap(), + signer.clone(), + ); + let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + else { + panic!("expected error response") + }; + assert_eq!(unauthz.status(), 401, "expected 401 Unauthorized"); + let (identity, credentials) = authz(&client, unauthz, &mut root).await; + signer.set(Some(credentials)); + let iam = client + .iam() + .body(None) + .send() + .await + .expect("can't authenticate") + .into_inner(); + assert_eq!(iam, identity, "who am I?"); +} From b6097e47464438363d0b105008fd0751dfaea857 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sat, 29 Aug 2026 08:04:34 -0600 Subject: [PATCH 2/3] Dial Nexus at an explicit address Like curl --resolve: --nexus-resolve supplies the socket address while the URL's host still names the server for TLS, standing in for rack DNS that is not yet populated. Co-Authored-By: Claude Mythos 5 --- client/src/commands.rs | 13 ++++++++++++- client/src/repl.rs | 1 + client/src/tunnel.rs | 15 ++++++++++++--- tests/src/integration_tests.rs | 30 +++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index b5e74349..7c3f1036 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -10,6 +10,7 @@ use std::fs::{File, OpenOptions, read}; #[cfg(feature = "permslip")] use std::io::ErrorKind; use std::io::{Read as _, Seek as _, SeekFrom, Write as _, stdin}; +use std::net::SocketAddr; use std::num::{NonZeroU8, NonZeroU64}; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -84,6 +85,7 @@ pub const SUSH_MAX_FSIZE: &str = "SUSH_MAX_FSIZE"; pub const SUSH_PERMSLIP_KEY: &str = "SUSH_PERMSLIP_KEY"; pub const SUSH_OUTPUT_FORMAT: &str = "SUSH_OUTPUT_FORMAT"; pub const SUSH_NEXUS: &str = "SUSH_NEXUS"; +pub const SUSH_NEXUS_RESOLVE: &str = "SUSH_NEXUS_RESOLVE"; pub const SUSH_NEXUS_ROOT: &str = "SUSH_NEXUS_ROOT"; pub const SUSH_NEXUS_TOKEN: &str = "SUSH_NEXUS_TOKEN"; pub const SUSH_PROXY_ROOT: &str = "SUSH_PROXY_ROOT"; @@ -180,6 +182,13 @@ pub struct GlobalArgs { #[clap(global = true)] pub nexus_roots: Vec, + /// Dial Nexus at this address instead of resolving its URL's + /// host, which still names the server for TLS (like curl + /// --resolve, for racks whose DNS is not yet populated) + #[arg(long, env = SUSH_NEXUS_RESOLVE, value_name = "IP:PORT")] + #[clap(global = true)] + pub nexus_resolve: Option, + /// ID of the rack to tunnel to (try `oxide system hardware rack list`) #[arg(long, env = SUSH_RACK, value_name = "UUID")] #[clap(global = true)] @@ -787,7 +796,9 @@ impl ClientCommand { .nexus_token .as_ref() .ok_or(CommandError::MissingNexusToken)?; - let tunnel = Tunnel::start(nexus, rack, token, &args.nexus_roots).await?; + let tunnel = + Tunnel::start(nexus, rack, token, &args.nexus_roots, args.nexus_resolve) + .await?; args.url = Some(tunnel.url.clone()); // The repl re-enters run per command; commands must // reuse this tunnel, not build their own. diff --git a/client/src/repl.rs b/client/src/repl.rs index 7bdead6e..578a5627 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -175,6 +175,7 @@ impl CommandContext for Repl { nexus: _, nexus_token: _, nexus_roots: _, + nexus_resolve: _, rack: _, } = args; if json { diff --git a/client/src/tunnel.rs b/client/src/tunnel.rs index f2dcca46..73c937ab 100644 --- a/client/src/tunnel.rs +++ b/client/src/tunnel.rs @@ -14,6 +14,7 @@ use std::fs::read; use std::io; +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -72,9 +73,9 @@ pub enum TunnelError { struct Target { host: String, port: u16, + resolve: Option, uri: Uri, token: String, - /// None speaks plain websockets, for tests and dev Nexus. tls: Option, } @@ -100,8 +101,9 @@ impl Tunnel { rack_id: &str, token: &str, roots: &[PathBuf], + resolve: Option, ) -> Result { - let target = Arc::new(target(nexus, rack_id, token, roots)?); + let target = Arc::new(target(nexus, rack_id, token, roots, resolve)?); // A dead path should fail the command now, not as a // connection reset from the listener later. @@ -142,6 +144,7 @@ fn target( rack_id: &str, token: &str, roots: &[PathBuf], + resolve: Option, ) -> Result { let invalid = |reason| TunnelError::NexusUrl { url: nexus.to_string(), @@ -211,6 +214,7 @@ fn target( Ok(Target { host, port, + resolve, uri, token: token.to_string(), tls, @@ -255,7 +259,12 @@ fn describe(error: WsError) -> String { /// Connect TCP toward Nexus, impatiently. async fn dial(target: &Target) -> Result { - let connect = TcpStream::connect((target.host.as_str(), target.port)); + let connect = async { + match target.resolve { + Some(addr) => TcpStream::connect(addr).await, + None => TcpStream::connect((target.host.as_str(), target.port)).await, + } + }; match timeout(CONNECT_TIMEOUT, connect).await { Ok(Ok(tcp)) => { let _ = tcp.set_nodelay(true); diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index 7fb06e48..7f401b8e 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -1236,7 +1236,7 @@ async fn client_tunnels_through_nexus() { }); // The tunnel probes the path, then stands in for the proxy. - let tunnel = Tunnel::start(&format!("http://{stub_addr}"), RACK_ID, TOKEN, &[]) + let tunnel = Tunnel::start(&format!("http://{stub_addr}"), RACK_ID, TOKEN, &[], None) .await .expect("can't start tunnel"); @@ -1264,4 +1264,32 @@ async fn client_tunnels_through_nexus() { .expect("can't authenticate") .into_inner(); assert_eq!(iam, identity, "who am I?"); + + // A tunnel whose Nexus URL names an unresolvable host still works + // when --nexus-resolve supplies the address. + let resolved = Tunnel::start("http://nexus.invalid", RACK_ID, TOKEN, &[], Some(stub_addr)) + .await + .expect("can't start resolved tunnel"); + let signer = AuthzSigner::default(); + let pem = read(cert_path(pki.clone(), &root_prefix())).unwrap(); + let roots = vec![Certificate::from_pem(&pem).unwrap()]; + let client = Client::new_with_client( + &resolved.url, + tls_client(roots, None).unwrap(), + signer.clone(), + ); + let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + else { + panic!("expected error response") + }; + let (identity, credentials) = authz(&client, unauthz, &mut root).await; + signer.set(Some(credentials)); + let iam = client + .iam() + .body(None) + .send() + .await + .expect("can't authenticate via the resolved tunnel") + .into_inner(); + assert_eq!(iam, identity, "who am I, resolved?"); } From 2445faf3f3e023d439c560a2abf5ed4f88aec845 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sat, 29 Aug 2026 09:28:34 -0600 Subject: [PATCH 3/3] Announce a combined session start once Creation already shows the session; the start's echo of the same line was noise. The explicit three-step start still announces. Co-Authored-By: Claude Mythos 5 --- client/src/commands.rs | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index 7c3f1036..4af83637 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -1151,22 +1151,25 @@ async fn session( }, Some(client), ) => { - let (session_id, nonce) = if let (Some(session_id), Some(nonce)) = (session_id, nonce) { - (session_id, nonce) - } else { - let (baseboard_id, nonce) = with_login(ctx, client, async || { - Ok(( - client.target().send().await?.into_inner(), - client.session_start_nonce().send().await?.into_inner(), - )) - }) - .await?; - let (session_id, nonce) = - session_create(permslip, permslip_url, &baseboard_id, nonce.nonce).await?; - ctx.session_created(Session::new(session_id), nonce); - (session_id, nonce) - }; - session_start(ctx, client, session_id, nonce, wait).await + // Creation already announces the session; don't echo it + // when the start succeeds. + let (session_id, nonce, show) = + if let (Some(session_id), Some(nonce)) = (session_id, nonce) { + (session_id, nonce, true) + } else { + let (baseboard_id, nonce) = with_login(ctx, client, async || { + Ok(( + client.target().send().await?.into_inner(), + client.session_start_nonce().send().await?.into_inner(), + )) + }) + .await?; + let (session_id, nonce) = + session_create(permslip, permslip_url, &baseboard_id, nonce.nonce).await?; + ctx.session_created(Session::new(session_id), nonce); + (session_id, nonce, false) + }; + session_start(ctx, client, session_id, nonce, wait, show).await } #[cfg(feature = "permslip")] @@ -1199,7 +1202,7 @@ async fn session( } else { return Err(CommandError::SigningUnavailable); }; - session_start(ctx, client, session_id, nonce, wait).await + session_start(ctx, client, session_id, nonce, wait, true).await } (SessionCommand::Allow { key_id, write }, Some(client)) => { @@ -1758,6 +1761,7 @@ async fn session_start( session_id: SessionId, signer_nonce: SessionSignerNonce, wait: bool, + show: bool, ) -> Result<(), CommandError> { let session = Session::new(session_id); with_login(ctx, client, async || { @@ -1771,7 +1775,9 @@ async fn session_start( }) .await? .into_inner(); - ctx.session_started(session, true); + if show { + ctx.session_started(session, true); + } Ok(()) }