Files
DatCaptainHorseandClaude Sonnet 5.5 d0ec87a139 feat: attach a shell to a running box
The host can now ask a box for a shell on a pseudo-terminal, the way a container
runtime attaches to a container. `HostToGuest::Attach` starts the process, and
the guest then dials the host on a vsock port of its own (7001) and carries the
terminal's bytes over a small framed stream: data both ways, a resize, a hangup,
and the exit status last. A screenful of output on the JSON-lines control channel
would hold every other message in the box behind it, which is why it is not
there. A failure to start is the exception, and is said on the control channel
because nothing has connected to the data port yet.

A shell is not a launch: it never ends the box, and the box ending ends it. It
is started through the same reaper as everything else, so it cannot become the
zombie that exists to be prevented. With a terminal on the other end the PTY is
ordinary; without one it is put in raw mode so a command run for its output gets
its bytes back unrewritten. Closing the stream hangs the shell up, process group
and all.

The frame codec is a pure bytes-in, frames-out module in nesprotocol with a
payload cap and no skipping of unknown tags, since a length read from a guest is
read across a trust boundary. No version bump: adding a message does not need
one, and a guest that predates it simply never dials.

The runtime stage gains strace, lsof and less, so a root shell in a box is not
one that cannot trace a process.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:09:15 +03:00

309 lines
9.9 KiB
Rust

// A shell on a real pseudo-terminal, over an in-memory stream.
//
// Its own test binary on purpose, for the reason `reaping.rs` is: the reaper waits
// on any child, so in a binary shared with other tests it would collect
// processes they were waiting for.
use std::time::{Duration, Instant};
use nesinit::attach::{serve, spawn};
use nesinit::reap::{Waiters, reap_exited};
use nesprotocol::attach::{ExitStatus, Frame, FrameReader};
use nesprotocol::lifecycle::{AttachId, Exec, Winsize};
use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
/// Reaping is process-wide, so these run one at a time.
static ONE_REAPER: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A reaper like the guest's: the only thing that waits, handing exits to
/// whoever asked.
fn start_reaper(waiters: Waiters) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
for (pid, exit) in reap_exited() {
waiters.deliver(pid, exit);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
}
fn me() -> (u32, u32) {
// SAFETY: two calls with no arguments.
unsafe { (libc::getuid(), libc::getgid()) }
}
fn exec(argv: &[&str]) -> Exec {
let (uid, gid) = me();
Exec {
argv: argv.iter().map(|s| s.to_string()).collect(),
env: [("TERM".to_string(), "dumb".to_string())].into(),
cwd: None,
uid,
gid,
}
}
/// The host's end of the stream.
struct Host {
stream: DuplexStream,
frames: FrameReader,
}
impl Host {
async fn say(&mut self, frame: Frame) {
self.stream
.write_all(&frame.encode().unwrap())
.await
.unwrap();
}
async fn next(&mut self) -> Option<Frame> {
let mut buf = [0u8; 4096];
loop {
if let Some(frame) = self.frames.next_frame().unwrap() {
return Some(frame);
}
let n = tokio::time::timeout(Duration::from_secs(10), self.stream.read(&mut buf))
.await
.expect("the guest went quiet")
.unwrap();
if n == 0 {
return None;
}
self.frames.push(&buf[..n]);
}
}
/// Everything until the exit, as (output, status).
async fn until_exit(&mut self) -> (Vec<u8>, ExitStatus) {
let mut out = Vec::new();
loop {
match self.next().await.expect("the stream ended before an exit") {
Frame::Data(bytes) => out.extend(bytes),
Frame::Exit(status) => return (out, status),
other => panic!("unexpected frame from the guest: {other:?}"),
}
}
}
}
/// Start a shell and serve it; return the host's end and the pid.
async fn attach(
waiters: &Waiters,
argv: &[&str],
size: Option<Winsize>,
) -> (Host, i32, tokio::task::JoinHandle<std::io::Result<()>>) {
let shell = spawn(waiters, &exec(argv), size).expect("the shell starts");
let pid = shell.pid();
let (guest_end, host_end) = tokio::io::duplex(1 << 16);
let id = AttachId::new("a-test");
let served = tokio::spawn(async move { serve(shell, &id, guest_end).await });
let mut host = Host {
stream: host_end,
frames: FrameReader::new(),
};
assert_eq!(
host.next().await,
Some(Frame::Hello("a-test".into())),
"the first frame must say which shell this is"
);
(host, pid, served)
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[tokio::test]
async fn a_person_at_a_terminal_types_and_sees_the_answer() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) =
attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await;
host.say(Frame::Data(b"echo hello-from-the-box\nexit 3\n".to_vec()))
.await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("hello-from-the-box"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(3));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A terminal that was resized tells the program on it, which is what makes a
/// full-screen tool fit the window it is in.
#[tokio::test]
async fn a_resize_reaches_the_program_on_the_terminal() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) =
attach(&waiters, &["/bin/sh"], Some(Winsize { cols: 80, rows: 24 })).await;
host.say(Frame::Resize {
cols: 100,
rows: 30,
})
.await;
host.say(Frame::Data(b"stty size\nexit 0\n".to_vec())).await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("30 100"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(0));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A command run for its output is a byte pipe: nothing rewrites its line
/// endings, and what it wrote to stderr is there too.
#[tokio::test]
async fn a_command_without_a_terminal_gets_its_bytes_back_unrewritten() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(
&waiters,
&["/bin/sh", "-c", "echo out; echo err >&2; exit 5"],
None,
)
.await;
let (out, status) = host.until_exit().await;
let out = text(&out);
assert!(out.contains("out\n") && out.contains("err\n"), "{out:?}");
assert!(
!out.contains('\r'),
"a terminal rewrote the line endings of a command's output: {out:?}"
);
assert_eq!(status, ExitStatus::Code(5));
served.await.unwrap().unwrap();
reaper.abort();
}
/// A kill is not an exit code.
#[tokio::test]
async fn a_shell_that_is_killed_says_which_signal() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(&waiters, &["/bin/sh", "-c", "kill -9 $$"], None).await;
let (_, status) = host.until_exit().await;
assert_eq!(status, ExitStatus::Signal(9));
served.await.unwrap().unwrap();
reaper.abort();
}
/// The last thing a program printed is what says why it stopped, so it must
/// not be lost to the exit arriving first.
#[tokio::test]
async fn output_written_just_before_the_exit_is_not_lost() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, _, served) = attach(
&waiters,
&["/bin/sh", "-c", "printf 'the-last-line\\n'; exit 1"],
None,
)
.await;
let (out, status) = host.until_exit().await;
assert!(text(&out).contains("the-last-line"), "{}", text(&out));
assert_eq!(status, ExitStatus::Code(1));
served.await.unwrap().unwrap();
reaper.abort();
}
/// The command line going away hangs the shell up, and what it started with it.
#[tokio::test]
async fn closing_the_stream_hangs_up_the_shell() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
drop(host);
served.await.unwrap().unwrap();
// Gone, and collected: signal zero asks whether the pid still exists.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
// SAFETY: an integer pid and the null signal.
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
break;
}
assert!(
Instant::now() < deadline,
"the shell outlived the connection that was its reason to exist"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}
/// An explicit hangup does the same.
#[tokio::test]
async fn a_hangup_frame_ends_the_shell() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
host.say(Frame::Hangup).await;
served.await.unwrap().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the shell outlived a hangup");
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}
/// Everything that can be refused before there is a connection is refused where
/// it can be sent up the control channel.
#[tokio::test]
async fn a_program_that_does_not_exist_is_refused_before_anything_connects() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let refused = spawn(&waiters, &exec(&["/nonexistent/program"]), None)
.err()
.expect("a missing program started");
assert!(
refused.reason.contains("/nonexistent/program"),
"{}",
refused.reason
);
assert!(spawn(&waiters, &exec(&[]), None).is_err());
reaper.abort();
}
/// A peer that speaks the wrong half of the protocol is not obeyed.
#[tokio::test]
async fn a_frame_only_the_guest_sends_is_refused_and_the_shell_hung_up() {
let _alone = ONE_REAPER.lock().await;
let waiters = Waiters::new();
let reaper = start_reaper(waiters.clone());
let (mut host, pid, served) = attach(&waiters, &["/bin/sh", "-c", "sleep 60"], None).await;
host.say(Frame::Exit(ExitStatus::Code(0))).await;
assert!(served.await.unwrap().is_err());
let deadline = Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(Instant::now() < deadline, "the shell outlived a bad peer");
tokio::time::sleep(Duration::from_millis(20)).await;
}
reaper.abort();
}