Files

1315 lines
43 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! Embedded SSH server for sandbox access.
use crate::child_env;
use crate::policy::SandboxPolicy;
use crate::process::drop_privileges;
use crate::sandbox;
#[cfg(target_os = "linux")]
use crate::{register_managed_child, unregister_managed_child};
use miette::{IntoDiagnostic, Result};
use nix::pty::{Winsize, openpty};
use nix::unistd::setsid;
use rand_core::OsRng;
use russh::keys::{Algorithm, PrivateKey};
use russh::server::{Auth, Handle, Session};
use russh::{ChannelId, CryptoVec};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::os::fd::{AsRawFd, RawFd};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tracing::{info, warn};
const PREFACE_MAGIC: &str = "NSSH1";
#[cfg(test)]
const SSH_HANDSHAKE_SECRET_ENV: &str = "NEMOCLAW_SSH_HANDSHAKE_SECRET";
/// A time-bounded set of nonces used to detect replayed NSSH1 handshakes.
/// Each entry records the `Instant` it was inserted; a background reaper task
/// periodically evicts entries older than the handshake skew window.
type NonceCache = Arc<Mutex<HashMap<String, Instant>>>;
/// Perform SSH server initialization: generate a host key, build the config,
/// and bind the TCP listener. Extracted so that startup errors can be forwarded
/// through the readiness channel rather than being silently logged.
async fn ssh_server_init(
listen_addr: SocketAddr,
ca_file_paths: &Option<(PathBuf, PathBuf)>,
) -> Result<(
TcpListener,
Arc<russh::server::Config>,
Option<Arc<(PathBuf, PathBuf)>>,
)> {
let mut rng = OsRng;
let host_key = PrivateKey::random(&mut rng, Algorithm::Ed25519).into_diagnostic()?;
let mut config = russh::server::Config {
auth_rejection_time: Duration::from_secs(1),
..Default::default()
};
config.keys.push(host_key);
let config = Arc::new(config);
let ca_paths = ca_file_paths.as_ref().map(|p| Arc::new(p.clone()));
let listener = TcpListener::bind(listen_addr).await.into_diagnostic()?;
info!(addr = %listen_addr, "SSH server listening");
Ok((listener, config, ca_paths))
}
#[allow(clippy::too_many_arguments)]
pub async fn run_ssh_server(
listen_addr: SocketAddr,
ready_tx: tokio::sync::oneshot::Sender<Result<()>>,
policy: SandboxPolicy,
workdir: Option<String>,
handshake_secret: String,
handshake_skew_secs: u64,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<(PathBuf, PathBuf)>,
provider_env: HashMap<String, String>,
) -> Result<()> {
let (listener, config, ca_paths) = match ssh_server_init(listen_addr, &ca_file_paths).await {
Ok(v) => {
// Signal that the SSH server has bound the socket and is ready to
// accept connections. The parent task awaits this before spawning
// the entrypoint process, ensuring exec requests won't race
// against server startup.
let _ = ready_tx.send(Ok(()));
v
}
Err(err) => {
let _ = ready_tx.send(Err(err));
return Ok(());
}
};
// Nonce cache for replay detection. Entries are evicted by a background
// reaper once they exceed the handshake skew window.
let nonce_cache: NonceCache = Arc::new(Mutex::new(HashMap::new()));
// Background task that periodically purges expired nonces.
let reaper_cache = nonce_cache.clone();
let ttl = Duration::from_secs(handshake_skew_secs);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
if let Ok(mut cache) = reaper_cache.lock() {
cache.retain(|_, inserted| inserted.elapsed() < ttl);
}
}
});
loop {
let (stream, peer) = listener.accept().await.into_diagnostic()?;
stream.set_nodelay(true).into_diagnostic()?;
let config = config.clone();
let policy = policy.clone();
let workdir = workdir.clone();
let secret = handshake_secret.clone();
let proxy_url = proxy_url.clone();
let ca_paths = ca_paths.clone();
let provider_env = provider_env.clone();
let nonce_cache = nonce_cache.clone();
tokio::spawn(async move {
if let Err(err) = handle_connection(
stream,
peer,
config,
policy,
workdir,
&secret,
handshake_skew_secs,
netns_fd,
proxy_url,
ca_paths,
provider_env,
&nonce_cache,
)
.await
{
warn!(error = %err, "SSH connection failed");
}
});
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
mut stream: tokio::net::TcpStream,
peer: SocketAddr,
config: Arc<russh::server::Config>,
policy: SandboxPolicy,
workdir: Option<String>,
secret: &str,
handshake_skew_secs: u64,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
provider_env: HashMap<String, String>,
nonce_cache: &NonceCache,
) -> Result<()> {
info!(peer = %peer, "SSH connection: reading handshake preface");
let mut line = String::new();
read_line(&mut stream, &mut line).await?;
info!(peer = %peer, preface_len = line.len(), "SSH connection: preface received, verifying");
if !verify_preface(&line, secret, handshake_skew_secs, nonce_cache)? {
warn!(peer = %peer, "SSH connection: handshake verification failed");
let _ = stream.write_all(b"ERR\n").await;
return Ok(());
}
stream.write_all(b"OK\n").await.into_diagnostic()?;
info!(peer = %peer, "SSH handshake accepted");
let handler = SshHandler::new(
policy,
workdir,
netns_fd,
proxy_url,
ca_file_paths,
provider_env,
);
russh::server::run_stream(config, stream, handler)
.await
.map_err(|err| miette::miette!("ssh stream error: {err}"))?;
Ok(())
}
async fn read_line(stream: &mut tokio::net::TcpStream, buf: &mut String) -> Result<()> {
let mut bytes = Vec::new();
loop {
let mut byte = [0u8; 1];
let n = stream.read(&mut byte).await.into_diagnostic()?;
if n == 0 {
break;
}
if byte[0] == b'\n' {
break;
}
bytes.push(byte[0]);
if bytes.len() > 1024 {
break;
}
}
*buf = String::from_utf8_lossy(&bytes).to_string();
Ok(())
}
fn verify_preface(
line: &str,
secret: &str,
handshake_skew_secs: u64,
nonce_cache: &NonceCache,
) -> Result<bool> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 5 || parts[0] != PREFACE_MAGIC {
return Ok(false);
}
let token = parts[1];
let timestamp: i64 = parts[2].parse().unwrap_or(0);
let nonce = parts[3];
let signature = parts[4];
let now = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.into_diagnostic()?
.as_secs(),
)
.into_diagnostic()?;
let skew = (now - timestamp).unsigned_abs();
if skew > handshake_skew_secs {
return Ok(false);
}
let payload = format!("{token}|{timestamp}|{nonce}");
let expected = hmac_sha256(secret.as_bytes(), payload.as_bytes());
if signature != expected {
return Ok(false);
}
// Reject replayed nonces. The cache is bounded by the reaper task which
// evicts entries older than `handshake_skew_secs`.
let mut cache = nonce_cache
.lock()
.map_err(|_| miette::miette!("nonce cache lock poisoned"))?;
if cache.contains_key(nonce) {
warn!(nonce = nonce, "NSSH1 nonce replay detected");
return Ok(false);
}
cache.insert(nonce.to_string(), Instant::now());
Ok(true)
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("hmac key");
mac.update(data);
let result = mac.finalize().into_bytes();
hex::encode(result)
}
struct SshHandler {
policy: SandboxPolicy,
workdir: Option<String>,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
provider_env: HashMap<String, String>,
input_sender: Option<mpsc::Sender<Vec<u8>>>,
pty_master: Option<std::fs::File>,
pty_request: Option<PtyRequest>,
}
impl SshHandler {
fn new(
policy: SandboxPolicy,
workdir: Option<String>,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
provider_env: HashMap<String, String>,
) -> Self {
Self {
policy,
workdir,
netns_fd,
proxy_url,
ca_file_paths,
provider_env,
input_sender: None,
pty_master: None,
pty_request: None,
}
}
}
impl russh::server::Handler for SshHandler {
type Error = anyhow::Error;
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
Ok(Auth::Accept)
}
async fn auth_publickey(
&mut self,
_user: &str,
_public_key: &russh::keys::PublicKey,
) -> Result<Auth, Self::Error> {
Ok(Auth::Accept)
}
async fn channel_open_session(
&mut self,
_channel: russh::Channel<russh::server::Msg>,
_session: &mut Session,
) -> Result<bool, Self::Error> {
Ok(true)
}
async fn channel_open_direct_tcpip(
&mut self,
channel: russh::Channel<russh::server::Msg>,
host_to_connect: &str,
port_to_connect: u32,
_originator_address: &str,
_originator_port: u32,
_session: &mut Session,
) -> Result<bool, Self::Error> {
// Only allow forwarding to loopback destinations to prevent the
// sandbox SSH server from being used as a generic proxy.
let is_loopback = host_to_connect == "127.0.0.1"
|| host_to_connect == "localhost"
|| host_to_connect == "::1";
if !is_loopback {
warn!(
host = host_to_connect,
port = port_to_connect,
"direct-tcpip rejected: non-loopback destination"
);
return Ok(false);
}
let host = host_to_connect.to_string();
#[allow(clippy::cast_possible_truncation)]
let port = port_to_connect as u16;
let netns_fd = self.netns_fd;
tokio::spawn(async move {
let addr = format!("{host}:{port}");
let tcp = match connect_in_netns(&addr, netns_fd).await {
Ok(stream) => stream,
Err(err) => {
warn!(addr = %addr, error = %err, "direct-tcpip: failed to connect");
let _ = channel.close().await;
return;
}
};
let mut channel_stream = channel.into_stream();
let mut tcp_stream = tcp;
let _ = tokio::io::copy_bidirectional(&mut channel_stream, &mut tcp_stream).await;
});
Ok(true)
}
async fn pty_request(
&mut self,
channel: ChannelId,
term: &str,
col_width: u32,
row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
self.pty_request = Some(PtyRequest {
term: term.to_string(),
col_width,
row_height,
pixel_width: 0,
pixel_height: 0,
});
session.channel_success(channel)?;
Ok(())
}
async fn window_change_request(
&mut self,
_channel: ChannelId,
col_width: u32,
row_height: u32,
pixel_width: u32,
pixel_height: u32,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(master) = self.pty_master.as_ref() {
let winsize = Winsize {
ws_row: to_u16(row_height.max(1)),
ws_col: to_u16(col_width.max(1)),
ws_xpixel: to_u16(pixel_width),
ws_ypixel: to_u16(pixel_height),
};
let _ = unsafe_pty::set_winsize(master.as_raw_fd(), winsize);
}
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?;
// Only allocate a PTY when the client explicitly requested one via
// pty_request. VS Code Remote-SSH sends shell_request *without* a
// preceding pty_request and expects pipe-based I/O with clean LF line
// endings. Forcing a PTY here caused CRLF translation which made
// VS Code misdetect the platform as Windows (and then try to run
// `powershell`).
self.start_shell(channel, session.handle(), None)?;
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?;
let command = String::from_utf8_lossy(data).trim().to_string();
if command.is_empty() {
return Ok(());
}
self.start_shell(channel, session.handle(), Some(command))?;
Ok(())
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
if name == "sftp" {
session.channel_success(channel)?;
// sftp-server speaks the SFTP binary protocol over stdin/stdout,
// which is exactly what spawn_pipe_exec wires up. This enables
// modern scp (SFTP-based, OpenSSH 9.0+) and SFTP clients to
// transfer files into and out of the sandbox.
let input_sender = spawn_pipe_exec(
&self.policy,
self.workdir.clone(),
Some("/usr/lib/openssh/sftp-server".to_string()),
session.handle(),
channel,
self.netns_fd,
self.proxy_url.clone(),
self.ca_file_paths.clone(),
&self.provider_env,
)?;
self.input_sender = Some(input_sender);
} else {
warn!(subsystem = name, "unsupported subsystem requested");
session.channel_failure(channel)?;
}
Ok(())
}
async fn env_request(
&mut self,
channel: ChannelId,
variable_name: &str,
variable_value: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
// Accept the env request so the client knows we handled it, but we
// don't actually propagate the variables — the sandbox environment is
// controlled via policy. We must reply so VSCode doesn't stall.
let _ = (variable_name, variable_value);
session.channel_success(channel)?;
Ok(())
}
async fn data(
&mut self,
_channel: ChannelId,
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(sender) = self.input_sender.as_ref() {
let _ = sender.send(data.to_vec());
}
Ok(())
}
async fn channel_eof(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
// Drop the input sender so the stdin writer thread sees a
// disconnected channel and closes the child's stdin pipe. This
// is essential for commands like `cat | tar xf -` which need
// stdin EOF to know the input stream is complete.
self.input_sender.take();
Ok(())
}
}
impl SshHandler {
fn start_shell(
&mut self,
channel: ChannelId,
handle: Handle,
command: Option<String>,
) -> anyhow::Result<()> {
if let Some(pty) = self.pty_request.take() {
// PTY was requested — allocate a real PTY (interactive shell or
// exec that explicitly asked for a terminal).
let (pty_master, input_sender) = spawn_pty_shell(
&self.policy,
self.workdir.clone(),
command,
&pty,
handle,
channel,
self.netns_fd,
self.proxy_url.clone(),
self.ca_file_paths.clone(),
&self.provider_env,
)?;
self.pty_master = Some(pty_master);
self.input_sender = Some(input_sender);
} else {
// No PTY requested — use plain pipes so stdout/stderr are
// separate and output has clean LF line endings. This is the
// path VSCode Remote-SSH exec commands take.
let input_sender = spawn_pipe_exec(
&self.policy,
self.workdir.clone(),
command,
handle,
channel,
self.netns_fd,
self.proxy_url.clone(),
self.ca_file_paths.clone(),
&self.provider_env,
)?;
self.input_sender = Some(input_sender);
}
Ok(())
}
}
/// Connect a TCP stream to `addr` inside the sandbox network namespace.
///
/// The SSH supervisor runs in the host network namespace while sandbox child
/// processes run in an isolated network namespace (with their own loopback).
/// A plain `TcpStream::connect("127.0.0.1:port")` from the supervisor would
/// hit the host loopback, not the sandbox loopback where services are listening.
///
/// On Linux, we spawn a dedicated OS thread, call `setns` to enter the sandbox
/// namespace, create the socket there, then convert it to a tokio `TcpStream`.
/// We use `std::thread::spawn` (not `spawn_blocking`) because `setns` changes
/// the calling thread's network namespace permanently — a tokio blocking-pool
/// thread could be reused for unrelated tasks and must not be contaminated.
/// On non-Linux platforms (no network namespace support), we connect directly.
async fn connect_in_netns(
addr: &str,
netns_fd: Option<RawFd>,
) -> std::io::Result<tokio::net::TcpStream> {
#[cfg(target_os = "linux")]
if let Some(fd) = netns_fd {
let addr = addr.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let result = (|| -> std::io::Result<std::net::TcpStream> {
// Enter the sandbox network namespace on this dedicated thread.
// SAFETY: setns is safe to call; this is a dedicated thread that
// will exit after the connection is established.
#[allow(unsafe_code)]
let rc = unsafe { libc::setns(fd, libc::CLONE_NEWNET) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
std::net::TcpStream::connect(&addr)
})();
let _ = tx.send(result);
});
let std_stream = rx
.await
.map_err(|_| std::io::Error::other("netns connect thread panicked"))??;
std_stream.set_nonblocking(true)?;
return tokio::net::TcpStream::from_std(std_stream);
}
#[cfg(not(target_os = "linux"))]
let _ = netns_fd;
tokio::net::TcpStream::connect(addr).await
}
#[derive(Clone)]
struct PtyRequest {
term: String,
col_width: u32,
row_height: u32,
pixel_width: u32,
pixel_height: u32,
}
impl Default for PtyRequest {
fn default() -> Self {
Self {
term: "xterm-256color".to_string(),
col_width: 80,
row_height: 24,
pixel_width: 0,
pixel_height: 0,
}
}
}
/// Derive the session USER and HOME from the policy's `run_as_user`.
///
/// Falls back to `("sandbox", "/sandbox")` when the policy has no explicit user,
/// preserving backward compatibility with images that use the default layout.
fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) {
match policy.process.run_as_user.as_deref() {
Some(user) if !user.is_empty() => {
// Look up the user's home directory from /etc/passwd.
let home = nix::unistd::User::from_name(user)
.ok()
.flatten()
.map(|u| u.dir.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("/home/{user}"));
(user.to_string(), home)
}
_ => ("sandbox".to_string(), "/sandbox".to_string()),
}
}
fn apply_child_env(
cmd: &mut Command,
session_home: &str,
session_user: &str,
term: &str,
proxy_url: Option<&str>,
ca_file_paths: Option<&(PathBuf, PathBuf)>,
provider_env: &HashMap<String, String>,
) {
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
cmd.env_clear()
.env("OPENSHELL_SANDBOX", "1")
.env("HOME", session_home)
.env("USER", session_user)
.env("SHELL", "/bin/bash")
.env("PATH", &path)
.env("TERM", term);
if let Some(url) = proxy_url {
for (key, value) in child_env::proxy_env_vars(url) {
cmd.env(key, value);
}
}
if let Some((ca_cert_path, combined_bundle_path)) = ca_file_paths {
for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) {
cmd.env(key, value);
}
}
for (key, value) in provider_env {
cmd.env(key, value);
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_pty_shell(
policy: &SandboxPolicy,
workdir: Option<String>,
command: Option<String>,
pty: &PtyRequest,
handle: Handle,
channel: ChannelId,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
provider_env: &HashMap<String, String>,
) -> anyhow::Result<(std::fs::File, mpsc::Sender<Vec<u8>>)> {
let winsize = Winsize {
ws_row: to_u16(pty.row_height.max(1)),
ws_col: to_u16(pty.col_width.max(1)),
ws_xpixel: to_u16(pty.pixel_width),
ws_ypixel: to_u16(pty.pixel_height),
};
let openpty = openpty(Some(&winsize), None)?;
let master = std::fs::File::from(openpty.master);
let slave = std::fs::File::from(openpty.slave);
let slave_fd = slave.as_raw_fd();
let stdin = slave.try_clone()?;
let stdout = slave.try_clone()?;
let stderr = slave;
let mut reader = master.try_clone()?;
let mut writer = master.try_clone()?;
let mut cmd = command.map_or_else(
|| {
let mut c = Command::new("/bin/bash");
c.arg("-i");
c
},
|command| {
let mut c = Command::new("/bin/bash");
c.arg("-lc").arg(command);
c
},
);
let term = if pty.term.is_empty() {
"xterm-256color"
} else {
pty.term.as_str()
};
// Derive USER and HOME from the policy's run_as_user when available,
// falling back to "sandbox" / "/sandbox" for backward compatibility.
let (session_user, session_home) = session_user_and_home(policy);
apply_child_env(
&mut cmd,
&session_home,
&session_user,
term,
proxy_url.as_deref(),
ca_file_paths.as_deref(),
provider_env,
);
cmd.stdin(stdin).stdout(stdout).stderr(stderr);
if let Some(dir) = workdir.as_deref() {
cmd.current_dir(dir);
}
#[cfg(unix)]
{
unsafe_pty::install_pre_exec(
&mut cmd,
policy.clone(),
workdir.clone(),
slave_fd,
netns_fd,
);
}
let mut child = cmd.spawn()?;
#[cfg(target_os = "linux")]
let child_pid = child.id();
#[cfg(target_os = "linux")]
register_managed_child(child_pid);
let master_file = master;
let (sender, receiver) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
while let Ok(bytes) = receiver.recv() {
if writer.write_all(&bytes).is_err() {
break;
}
let _ = writer.flush();
}
});
let runtime = tokio::runtime::Handle::current();
let runtime_reader = runtime.clone();
let handle_clone = handle.clone();
// Signal from the reader thread to the exit thread that all output has
// been forwarded. The exit thread waits for this before sending the
// exit-status and closing the channel, ensuring the correct SSH protocol
// ordering: data → EOF → exit-status → close.
let (reader_done_tx, reader_done_rx) = mpsc::channel::<()>();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
let handle_clone = handle_clone.clone();
let _ = runtime_reader
.block_on(async move { handle_clone.data(channel, data).await });
}
}
}
// Send EOF to indicate no more data will be sent on this channel.
let eof_handle = handle_clone.clone();
let _ = runtime_reader.block_on(async move { eof_handle.eof(channel).await });
// Notify the exit thread that all output has been forwarded.
let _ = reader_done_tx.send(());
});
let handle_exit = handle;
let runtime_exit = runtime;
std::thread::spawn(move || {
let status = child.wait().ok();
#[cfg(target_os = "linux")]
unregister_managed_child(child_pid);
let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs();
// Wait for the reader thread to finish forwarding all output before
// sending exit-status and closing the channel. This prevents the
// race where close() was called before exit_status_request().
//
// Use a timeout because a backgrounded grandchild process (e.g.
// `nohup daemon &`) may hold the PTY slave open indefinitely,
// preventing the reader from reaching EOF. Two seconds is enough
// for any remaining buffered data to drain.
let _ = reader_done_rx.recv_timeout(Duration::from_secs(2));
drop(runtime_exit.spawn(async move {
let _ = handle_exit.exit_status_request(channel, code).await;
let _ = handle_exit.close(channel).await;
}));
});
Ok((master_file, sender))
}
/// Spawn a command using plain pipes (no PTY).
///
/// stdout is forwarded as SSH channel data and stderr as SSH extended data
/// (type 1), preserving the separation that clients like `VSCode` Remote-SSH
/// expect. Output retains clean LF line endings (no CRLF translation).
#[allow(clippy::too_many_arguments)]
fn spawn_pipe_exec(
policy: &SandboxPolicy,
workdir: Option<String>,
command: Option<String>,
handle: Handle,
channel: ChannelId,
netns_fd: Option<RawFd>,
proxy_url: Option<String>,
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
provider_env: &HashMap<String, String>,
) -> anyhow::Result<mpsc::Sender<Vec<u8>>> {
let mut cmd = command.map_or_else(
|| {
// No command — read from stdin. Do *not* pass `-i`; interactive
// mode reads .bashrc, writes prompts to stderr, and can introduce
// just enough latency for VS Code Remote-SSH's platform detection
// to time out and fall back to "windows". Plain `bash` with piped
// stdin already reads commands line-by-line (script mode), which is
// exactly what VS Code's local server expects.
Command::new("/bin/bash")
},
|command| {
let mut c = Command::new("/bin/bash");
// Use login shell (-l) so that .profile/.bashrc are sourced and
// tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.)
// are available without hardcoding them here.
c.arg("-lc").arg(command);
c
},
);
let (session_user, session_home) = session_user_and_home(policy);
apply_child_env(
&mut cmd,
&session_home,
&session_user,
"dumb",
proxy_url.as_deref(),
ca_file_paths.as_deref(),
provider_env,
);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = workdir.as_deref() {
cmd.current_dir(dir);
}
#[cfg(unix)]
{
unsafe_pty::install_pre_exec_no_pty(&mut cmd, policy.clone(), workdir.clone(), netns_fd);
}
let mut child = cmd.spawn()?;
#[cfg(target_os = "linux")]
let child_pid = child.id();
#[cfg(target_os = "linux")]
register_managed_child(child_pid);
let child_stdin = child.stdin.take();
let child_stdout = child.stdout.take().expect("stdout must be piped");
let child_stderr = child.stderr.take().expect("stderr must be piped");
// stdin writer thread
let (sender, receiver) = mpsc::channel::<Vec<u8>>();
std::thread::spawn(move || {
let Some(mut stdin) = child_stdin else {
return;
};
while let Ok(bytes) = receiver.recv() {
if stdin.write_all(&bytes).is_err() {
break;
}
let _ = stdin.flush();
}
});
let runtime = tokio::runtime::Handle::current();
// Signal from the reader threads to the exit thread that all output has
// been forwarded.
let (reader_done_tx, reader_done_rx) = mpsc::channel::<()>();
// stdout reader
let stdout_handle = handle.clone();
let stdout_runtime = runtime.clone();
let reader_done_stdout = reader_done_tx.clone();
std::thread::spawn(move || {
let mut reader = child_stdout;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
let h = stdout_handle.clone();
let _ = stdout_runtime.block_on(async move { h.data(channel, data).await });
}
}
}
let _ = reader_done_stdout.send(());
});
// stderr reader — sends as extended data (type 1)
let stderr_handle = handle.clone();
let stderr_runtime = runtime.clone();
std::thread::spawn(move || {
let mut reader = child_stderr;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
let h = stderr_handle.clone();
let _ = stderr_runtime
.block_on(async move { h.extended_data(channel, 1, data).await });
}
}
}
let _ = reader_done_tx.send(());
});
// Exit waiter thread
let handle_exit = handle;
let runtime_exit = runtime;
std::thread::spawn(move || {
let status = child.wait().ok();
#[cfg(target_os = "linux")]
unregister_managed_child(child_pid);
let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs();
// Wait for both reader threads.
let _ = reader_done_rx.recv_timeout(Duration::from_secs(2));
let _ = reader_done_rx.recv_timeout(Duration::from_secs(1));
drop(runtime_exit.spawn(async move {
let _ = handle_exit.eof(channel).await;
let _ = handle_exit.exit_status_request(channel, code).await;
let _ = handle_exit.close(channel).await;
}));
});
Ok(sender)
}
mod unsafe_pty {
use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, sandbox, setsid};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[allow(unsafe_code)]
pub fn set_winsize(fd: RawFd, winsize: Winsize) -> std::io::Result<()> {
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, winsize) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[allow(unsafe_code)]
fn set_controlling_tty(fd: RawFd) -> std::io::Result<()> {
let rc = unsafe { libc::ioctl(fd, libc::TIOCSCTTY.into(), 0) };
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[allow(unsafe_code)]
pub fn install_pre_exec(
cmd: &mut Command,
policy: SandboxPolicy,
workdir: Option<String>,
slave_fd: RawFd,
netns_fd: Option<RawFd>,
) {
unsafe {
cmd.pre_exec(move || {
setsid().map_err(|err| std::io::Error::other(err.to_string()))?;
set_controlling_tty(slave_fd)?;
enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref())
});
}
}
/// Pre-exec hook for pipe-based (non-PTY) exec.
///
/// Skips `setsid` and `TIOCSCTTY` since there is no controlling terminal.
#[allow(unsafe_code)]
pub fn install_pre_exec_no_pty(
cmd: &mut Command,
policy: SandboxPolicy,
workdir: Option<String>,
netns_fd: Option<RawFd>,
) {
unsafe {
cmd.pre_exec(move || enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref()));
}
}
fn enter_netns_and_sandbox(
netns_fd: Option<RawFd>,
policy: &SandboxPolicy,
workdir: Option<&str>,
) -> std::io::Result<()> {
// Enter network namespace before dropping privileges.
// This ensures SSH shell processes are isolated to the same
// network namespace as the entrypoint, forcing all traffic
// through the veth pair and CONNECT proxy.
#[cfg(target_os = "linux")]
if let Some(fd) = netns_fd {
#[allow(unsafe_code)]
let result = unsafe { libc::setns(fd, libc::CLONE_NEWNET) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
#[cfg(not(target_os = "linux"))]
let _ = netns_fd;
// Drop privileges before applying sandbox restrictions.
// initgroups/setgid/setuid need access to /etc/group and /etc/passwd
// which may be blocked by Landlock.
drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?;
sandbox::apply(policy, workdir).map_err(|err| std::io::Error::other(err.to_string()))?;
Ok(())
}
}
fn to_u16(value: u32) -> u16 {
u16::try_from(value.min(u32::from(u16::MAX))).unwrap_or(u16::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Stdio;
/// Verify that dropping the input sender (the operation `channel_eof`
/// performs) causes the stdin writer loop to exit and close the child's
/// stdin pipe. Without this, commands like `cat | tar xf -` used by
/// `sync --up` hang forever waiting for EOF on stdin.
#[test]
fn dropping_input_sender_closes_child_stdin() {
let (sender, receiver) = mpsc::channel::<Vec<u8>>();
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn cat");
let child_stdin = child.stdin.take().expect("stdin must be piped");
// Replicate the stdin writer loop from spawn_pipe_exec.
std::thread::spawn(move || {
let mut stdin = child_stdin;
while let Ok(bytes) = receiver.recv() {
if stdin.write_all(&bytes).is_err() {
break;
}
let _ = stdin.flush();
}
});
sender.send(b"hello".to_vec()).unwrap();
// Simulate what channel_eof does: drop the sender.
drop(sender);
// cat should see EOF on stdin and exit. Use a timeout so the test
// fails fast instead of hanging if the mechanism is broken.
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(child.wait_with_output());
});
let output = done_rx
.recv_timeout(Duration::from_secs(5))
.expect("cat hung for 5s — stdin was not closed (channel_eof bug)")
.expect("failed to wait for cat");
assert!(
output.status.success(),
"cat exited with {:?}",
output.status
);
assert_eq!(output.stdout, b"hello");
}
/// Verify that the stdin writer delivers all buffered data before exiting
/// when the sender is dropped. This ensures channel_eof doesn't cause
/// data loss — only signals "no more data after this".
#[test]
fn stdin_writer_delivers_buffered_data_before_eof() {
let (sender, receiver) = mpsc::channel::<Vec<u8>>();
let mut child = Command::new("wc")
.arg("-c")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn wc");
let child_stdin = child.stdin.take().expect("stdin must be piped");
std::thread::spawn(move || {
let mut stdin = child_stdin;
while let Ok(bytes) = receiver.recv() {
if stdin.write_all(&bytes).is_err() {
break;
}
let _ = stdin.flush();
}
});
// Send multiple chunks, then drop the sender.
for _ in 0..100 {
sender.send(vec![0u8; 1024]).unwrap();
}
drop(sender);
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(child.wait_with_output());
});
let output = done_rx
.recv_timeout(Duration::from_secs(5))
.expect("wc hung for 5s — stdin was not closed")
.expect("failed to wait for wc");
let count: usize = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.expect("wc output was not a number");
assert_eq!(
count,
100 * 1024,
"expected all 100 KiB delivered before EOF"
);
}
// -----------------------------------------------------------------------
// verify_preface tests
// -----------------------------------------------------------------------
/// Build a valid NSSH1 preface line with the given parameters.
fn build_preface(token: &str, secret: &str, nonce: &str, timestamp: i64) -> String {
let payload = format!("{token}|{timestamp}|{nonce}");
let signature = hmac_sha256(secret.as_bytes(), payload.as_bytes());
format!("{PREFACE_MAGIC} {token} {timestamp} {nonce} {signature}")
}
fn fresh_nonce_cache() -> NonceCache {
Arc::new(Mutex::new(HashMap::new()))
}
fn current_timestamp() -> i64 {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
)
.unwrap()
}
#[test]
fn verify_preface_accepts_valid_preface() {
let secret = "test-secret-key";
let nonce = "unique-nonce-1";
let ts = current_timestamp();
let line = build_preface("tok1", secret, nonce, ts);
let cache = fresh_nonce_cache();
assert!(verify_preface(&line, secret, 300, &cache).unwrap());
}
#[test]
fn verify_preface_rejects_replayed_nonce() {
let secret = "test-secret-key";
let nonce = "replay-nonce";
let ts = current_timestamp();
let line = build_preface("tok1", secret, nonce, ts);
let cache = fresh_nonce_cache();
// First attempt should succeed.
assert!(verify_preface(&line, secret, 300, &cache).unwrap());
// Second attempt with the same nonce should be rejected.
assert!(!verify_preface(&line, secret, 300, &cache).unwrap());
}
#[test]
fn verify_preface_rejects_expired_timestamp() {
let secret = "test-secret-key";
let nonce = "expired-nonce";
// Timestamp 600 seconds in the past, with a 300-second skew window.
let ts = current_timestamp() - 600;
let line = build_preface("tok1", secret, nonce, ts);
let cache = fresh_nonce_cache();
assert!(!verify_preface(&line, secret, 300, &cache).unwrap());
}
#[test]
fn verify_preface_rejects_invalid_hmac() {
let secret = "test-secret-key";
let nonce = "hmac-nonce";
let ts = current_timestamp();
// Build with the correct secret, then verify with the wrong one.
let line = build_preface("tok1", secret, nonce, ts);
let cache = fresh_nonce_cache();
assert!(!verify_preface(&line, "wrong-secret", 300, &cache).unwrap());
}
#[test]
fn verify_preface_rejects_malformed_input() {
let cache = fresh_nonce_cache();
// Too few parts.
assert!(!verify_preface("NSSH1 tok1 123", "s", 300, &cache).unwrap());
// Wrong magic.
assert!(!verify_preface("NSSH2 tok1 123 nonce sig", "s", 300, &cache).unwrap());
// Empty string.
assert!(!verify_preface("", "s", 300, &cache).unwrap());
}
#[test]
fn verify_preface_distinct_nonces_both_accepted() {
let secret = "test-secret-key";
let ts = current_timestamp();
let cache = fresh_nonce_cache();
let line1 = build_preface("tok1", secret, "nonce-a", ts);
let line2 = build_preface("tok1", secret, "nonce-b", ts);
assert!(verify_preface(&line1, secret, 300, &cache).unwrap());
assert!(verify_preface(&line2, secret, 300, &cache).unwrap());
}
#[test]
fn apply_child_env_keeps_handshake_secret_out_of_ssh_children() {
let mut cmd = Command::new("/usr/bin/env");
cmd.env(SSH_HANDSHAKE_SECRET_ENV, "should-not-leak")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let provider_env = std::iter::once((
"ANTHROPIC_API_KEY".to_string(),
"openshell:resolve:env:ANTHROPIC_API_KEY".to_string(),
))
.collect();
apply_child_env(
&mut cmd,
"/sandbox",
"sandbox",
"dumb",
None,
None,
&provider_env,
);
let output = cmd.output().expect("spawn env");
let stdout = String::from_utf8(output.stdout).expect("utf8");
assert!(!stdout.contains(SSH_HANDSHAKE_SECRET_ENV));
assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY"));
}
}