From d0ec87a1392d6601a5d19cab075cdd72bc1bf686 Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Fri, 2 Oct 2026 16:52:01 +0300 Subject: [PATCH] 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 --- apps/nesinit/src/attach.rs | 420 ++++++++++++++++++++++++++++ apps/nesinit/src/lib.rs | 1 + apps/nesinit/src/session.rs | 105 +++++++ apps/nesinit/src/workload.rs | 56 +++- apps/nesinit/tests/attach.rs | 308 ++++++++++++++++++++ build/Containerfile | 6 + crates/nesprotocol/src/attach.rs | 327 ++++++++++++++++++++++ crates/nesprotocol/src/lib.rs | 1 + crates/nesprotocol/src/lifecycle.rs | 118 ++++++++ 9 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 apps/nesinit/src/attach.rs create mode 100644 apps/nesinit/tests/attach.rs create mode 100644 crates/nesprotocol/src/attach.rs diff --git a/apps/nesinit/src/attach.rs b/apps/nesinit/src/attach.rs new file mode 100644 index 00000000..dae141fd --- /dev/null +++ b/apps/nesinit/src/attach.rs @@ -0,0 +1,420 @@ +// A shell in the box, for whoever is operating it. +// +// The host asks for one on the control channel and this end starts the process +// on a pseudo-terminal, then dials the host on a port of its own and carries +// the terminal's bytes over it. See `nesprotocol::attach` for the frames and +// why they are not on the control channel. +// +// # It is not a launch +// +// A launch is the box's workload and its exit means something to the session. +// A shell has no such meaning: it comes and goes while a game plays, it never +// ends the box, and the box ending ends it. So it takes no part in the +// session's one-running-launch bookkeeping. +// +// # Reaping goes through the same place as everything else +// +// This is PID 1, and the reaper is the only thing allowed to wait on a child +// (see `reap`). The shell is started through `Waiters` like a workload is, and +// its exit arrives the same way, so a shell that dies does not become the one +// zombie the reaper was written to prevent. +// +// # Two modes, one mechanism +// +// With a terminal on the other end the PTY is an ordinary one. Without, it is +// put in raw mode so it is a byte pipe: no echo, and no rewriting of line +// endings, which a command run for its output would otherwise get "\r\n" for +// every "\n". Stderr shares the PTY with stdout, which a terminal does anyway. + +use std::collections::BTreeMap; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use nesprotocol::attach::{ExitStatus, Frame, FrameReader}; +use nesprotocol::lifecycle::{AttachId, Exec, Exit, Winsize}; +use tokio::io::unix::AsyncFd; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::oneshot; + +use crate::reap::{Waiters, Watched}; +use crate::workload::Failure; + +/// What a shell finds on `PATH` when the host named none. +/// +/// The guest's, because only the guest knows its own filesystem. The environment +/// is cleared, as a launch's is, so without this a shell would have no `PATH` +/// at all and find nothing. +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/bin"; + +/// How long to keep collecting a shell's last output after it has exited. +/// +/// Its output may still be queued in the terminal when its exit arrives, and +/// dropping it would lose the last line -- usually the one that says why. Bounded, +/// because something it started may still hold the terminal open and nothing +/// more is coming. +const DRAIN: Duration = Duration::from_millis(300); + +/// A shell that has been started and not yet connected to anything. +/// +/// Dropping one hangs it up, so a shell whose connection could not be made does +/// not sit in the box with nobody at the other end. +pub struct Shell { + master: Option, + watched: Watched, + exit: Option>, +} + +impl Shell { + pub fn pid(&self) -> i32 { + self.watched.pid + } +} + +impl Drop for Shell { + fn drop(&mut self) { + hang_up(&self.watched); + } +} + +/// Tell the shell its terminal is gone, if it is still ours to tell. +/// +/// The process group, because the shell's children are in it and a `sleep` +/// left holding the terminal is what a hung-up shell is for getting rid of. +/// Asked of the registry first: after the reaper has collected the shell the +/// number may belong to something else. +fn hang_up(watched: &Watched) { + if !watched.running() { + return; + } + // SAFETY: two integers, nothing borrowed. A group that has gone fails with + // ESRCH, which is what hanging up twice should do. + unsafe { + libc::kill(-watched.pid, libc::SIGHUP); + libc::kill(watched.pid, libc::SIGHUP); + } +} + +fn pty(size: Option) -> io::Result<(OwnedFd, OwnedFd)> { + let mut master: libc::c_int = -1; + let mut slave: libc::c_int = -1; + let window = size.map(|s| libc::winsize { + ws_row: s.rows, + ws_col: s.cols, + ws_xpixel: 0, + ws_ypixel: 0, + }); + // SAFETY: two out-pointers to integers and a pointer to a winsize that + // lives for the call; no name buffer and no termios are asked for. + let opened = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + window.as_ref().map_or(std::ptr::null_mut(), |w| { + w as *const libc::winsize as *mut libc::winsize + }), + ) + }; + if opened != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openpty returned two descriptors this process now owns. + let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + + for fd in [&master, &slave] { + // SAFETY: fcntl on a descriptor this function owns. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } != 0 { + return Err(io::Error::last_os_error()); + } + } + // The master is read through the runtime, so it must not block. + // SAFETY: as above. + if unsafe { libc::fcntl(master.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK) } != 0 { + return Err(io::Error::last_os_error()); + } + if size.is_none() { + raw_mode(&slave)?; + } + Ok((master, slave)) +} + +/// Make a terminal a byte pipe: no echo, no line editing, no rewriting. +fn raw_mode(slave: &OwnedFd) -> io::Result<()> { + // SAFETY: a zeroed termios is filled by tcgetattr before it is used, and + // every call is on a descriptor this function was handed. + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(slave.as_raw_fd(), &mut termios) != 0 { + return Err(io::Error::last_os_error()); + } + libc::cfmakeraw(&mut termios); + if libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios) != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +/// The environment a shell starts with: what a launch gets, plus a `PATH`. +fn environment(exec: &Exec) -> BTreeMap { + // Made here for the same reason a launch's is: the environment is cleared, + // so nothing a shell inherits points at it, and a tool that wants a runtime + // directory reads `XDG_RUNTIME_DIR`. + let runtime = crate::system::runtime_dir(exec.uid, exec.gid).ok(); + let mut env: BTreeMap = crate::workload::environment(exec, runtime.as_deref()) + .into_iter() + .collect(); + env.entry("PATH".into()) + .or_insert_with(|| DEFAULT_PATH.into()); + env +} + +/// Start the process on a terminal. +/// +/// Synchronous on purpose: everything that can fail before there is anything to +/// connect to fails here, where it can be returned and sent up the control +/// channel. A host that heard nothing would wait out a timeout to learn the +/// program does not exist. +pub fn spawn(waiters: &Waiters, exec: &Exec, size: Option) -> Result { + let Some((program, args)) = exec.argv.split_first() else { + return Err(Failure::new("the command is empty")); + }; + let (master, slave) = pty(size).map_err(|e| Failure::new(format!("no terminal: {e}")))?; + let again = |slave: &OwnedFd| { + slave + .try_clone() + .map_err(|e| Failure::new(format!("no terminal: {e}"))) + }; + + let mut command = Command::new(program); + command + .args(args) + .env_clear() + .envs(environment(exec)) + .current_dir(exec.cwd.as_deref().unwrap_or("/")) + .stdin(Stdio::from(again(&slave)?)) + .stdout(Stdio::from(again(&slave)?)) + .stderr(Stdio::from(slave)); + + let (uid, gid) = (exec.uid, exec.gid); + // SAFETY: the closure runs between fork and exec, where only + // async-signal-safe calls are allowed. These are, and it allocates nothing. + unsafe { + command.pre_exec(move || { + // A session of its own, with the terminal as its controlling one: + // that is what makes ^C reach the foreground job instead of the + // shell's whole group, and what lets job control work at all. + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(io::Error::last_os_error()); + } + // gid first: dropping the uid first would lose the privilege needed + // to set the gid at all. + if libc::setgid(gid) != 0 { + return Err(io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + + let mut watched = waiters + .watch(|| Ok(command.spawn()?.id() as i32)) + .map_err(|error| Failure::new(format!("{program} as {uid}:{gid}: {error}")))?; + let exit = watched.take_exit().expect("a new watch has its exit"); + Ok(Shell { + master: Some(master), + watched, + exit: Some(exit), + }) +} + +fn read_fd(fd: RawFd, buf: &mut [u8]) -> io::Result { + // SAFETY: a pointer and length into a buffer that outlives the call. + let n = unsafe { libc::read(fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +fn write_fd(fd: RawFd, buf: &[u8]) -> io::Result { + // SAFETY: a pointer and length into a buffer that outlives the call. + let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(n as usize) + } +} + +fn resize(fd: RawFd, cols: u16, rows: u16) { + let window = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + // SAFETY: a pointer to a winsize that lives for the call. The kernel sends + // the foreground job SIGWINCH itself. + unsafe { libc::ioctl(fd, libc::TIOCSWINSZ as _, &window) }; +} + +/// Whether a read failure means the terminal has no process on it any more. +/// +/// A pseudo-terminal's master reports `EIO` once every holder of the slave has +/// gone, and that is its end of file. +fn terminal_gone(error: &io::Error) -> bool { + error.raw_os_error() == Some(libc::EIO) +} + +async fn send(out: &mut W, frame: &Frame) -> io::Result<()> { + let bytes = frame + .encode() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + out.write_all(&bytes).await +} + +async fn send_data(out: &mut W, bytes: &[u8]) -> io::Result<()> { + for frame in Frame::data(bytes) { + send(out, &frame).await?; + } + Ok(()) +} + +/// Write to the terminal, waiting for room. +async fn write_terminal(master: &AsyncFd, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut ready = master.writable().await?; + match ready.try_io(|fd| write_fd(fd.as_raw_fd(), bytes)) { + Ok(Ok(n)) => bytes = &bytes[n..], + // The shell is gone; what was typed at it has nowhere to go. + Ok(Err(e)) if terminal_gone(&e) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_would_block) => {} + } + } + Ok(()) +} + +/// How the process ended, as the stream says it. +fn status_of(exit: Option) -> ExitStatus { + match exit { + Some(Exit { + signal: Some(signal), + .. + }) => ExitStatus::Signal(signal), + Some(Exit { + exit_code: Some(code), + .. + }) => ExitStatus::Code(code), + // The reaper never delivered an exit, which it only fails to do when this + // end is being torn down. Said as a code no shell returns on its own. + _ => ExitStatus::Code(-1), + } +} + +/// Carry a shell's terminal over `stream` until one side is done. +/// +/// Generic over the stream so it can be driven by a real pseudo-terminal and an +/// in-memory pipe, which is the only way to test the part that matters. +pub async fn serve(mut shell: Shell, id: &AttachId, stream: S) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let master = AsyncFd::new( + shell + .master + .take() + .expect("a shell has its terminal until served"), + )?; + let mut exit = shell + .exit + .take() + .expect("a shell has its exit until served"); + let (mut from_host, mut to_host) = tokio::io::split(stream); + + send(&mut to_host, &Frame::Hello(id.to_string())).await?; + + let mut frames = FrameReader::new(); + let mut input = vec![0u8; 8192]; + let mut output = vec![0u8; 16384]; + let mut terminal_open = true; + + let ended = loop { + tokio::select! { + ready = master.readable(), if terminal_open => { + let mut ready = ready?; + match ready.try_io(|fd| read_fd(fd.as_raw_fd(), &mut output)) { + Ok(Ok(0)) => terminal_open = false, + Ok(Ok(n)) => send_data(&mut to_host, &output[..n]).await?, + Ok(Err(e)) if terminal_gone(&e) => terminal_open = false, + Ok(Err(e)) => return Err(e), + Err(_would_block) => {} + } + } + read = from_host.read(&mut input) => { + let n = read?; + if n == 0 { + // The command line went away. Dropping `shell` hangs it up. + return Ok(()); + } + frames.push(&input[..n]); + loop { + match frames.next_frame() { + Ok(Some(Frame::Data(bytes))) => { + if terminal_open { + write_terminal(&master, &bytes).await?; + } + } + Ok(Some(Frame::Resize { cols, rows })) => resize(master.as_raw_fd(), cols, rows), + Ok(Some(Frame::Hangup)) => return Ok(()), + // Hello and Exit are ours to send. A peer that sends them + // has lost track of which end it is. + Ok(Some(other)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("the host sent a frame only the guest sends: {other:?}"), + )); + } + Ok(None) => break, + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e)), + } + } + } + exit = &mut exit => break exit.ok(), + } + }; + + // Collect what it said last, then say how it ended. + if terminal_open { + let drained = tokio::time::timeout(DRAIN, async { + loop { + let mut ready = master.readable().await?; + match ready.try_io(|fd| read_fd(fd.as_raw_fd(), &mut output)) { + Ok(Ok(0)) => return Ok::<(), io::Error>(()), + Ok(Ok(n)) => send_data(&mut to_host, &output[..n]).await?, + Ok(Err(e)) if terminal_gone(&e) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_would_block) => return Ok(()), + } + } + }) + .await; + if let Ok(Err(e)) = drained { + return Err(e); + } + } + send(&mut to_host, &Frame::Exit(status_of(ended))).await?; + to_host.flush().await?; + to_host.shutdown().await +} diff --git a/apps/nesinit/src/lib.rs b/apps/nesinit/src/lib.rs index a52936ef..f2b49730 100644 --- a/apps/nesinit/src/lib.rs +++ b/apps/nesinit/src/lib.rs @@ -12,6 +12,7 @@ // It is also the box's only init: there is no service manager in the image, so // the box's own services come up from a table in this binary. ref(d-0064) +pub mod attach; pub mod filesystems; pub mod payload; pub mod platform; diff --git a/apps/nesinit/src/session.rs b/apps/nesinit/src/session.rs index 2958350d..f1493546 100644 --- a/apps/nesinit/src/session.rs +++ b/apps/nesinit/src/session.rs @@ -353,6 +353,24 @@ where running = start(&mut writer, workload, untrusted, id, exec, on_exit).await?; } + HostToGuest::Attach { id, exec, size } => { + // A shell is not a launch: it does not touch `running`, takes no + // part in what an exit means, and a failure to start one is said + // about the shell and never about the session. The control + // channel is where it is said because nothing has connected to + // the data port yet. + if let Err(failure) = workload.attach(id.clone(), &exec, size) { + tracing::warn!(attach = %id, reason = %failure.reason, "a shell would not start"); + send( + &mut writer, + &GuestToHost::AttachFailed { + id, + reason: failure.reason, + }, + ) + .await?; + } + } HostToGuest::Stop { id } => match &running { // Idempotent, and never ends the session by itself. Some(current) if current.id == id => workload.signal_stop(), @@ -1182,6 +1200,93 @@ mod tests { assert!(workload.started.is_empty()); } + /// A shell is handed to the workload layer with everything it was asked for, + /// and it is not a launch: nothing starts, nothing exits, and the session + /// carries on exactly as it was. + #[tokio::test] + async fn an_attach_goes_to_the_workload_layer_and_is_not_a_launch() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + + let wanted = Exec { + argv: vec!["/bin/bash".into()], + env: [("TERM".to_string(), "xterm-256color".to_string())].into(), + cwd: None, + uid: 0, + gid: 0, + }; + let size = Some(nesprotocol::lifecycle::Winsize { + cols: 132, + rows: 43, + }); + caller + .say(&HostToGuest::Attach { + id: nesprotocol::lifecycle::AttachId::new("a-1"), + exec: wanted.clone(), + size, + }) + .await; + caller.say(&HostToGuest::Shutdown).await; + + let (outcome, workload, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown); + assert_eq!( + workload.attached, + vec![(nesprotocol::lifecycle::AttachId::new("a-1"), wanted, size)] + ); + assert!( + workload.started.is_empty(), + "a shell was started as though it were the workload" + ); + } + + /// Said about the shell, by its id, on the channel the host is already + /// reading -- nothing has connected to the data port yet, and a host that + /// heard nothing would wait out a timeout to find out. + #[tokio::test] + async fn a_shell_that_will_not_start_is_reported_by_id_and_the_box_carries_on() { + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let mut workload = Double::exits_when_stopped(Exit::code(0)); + workload.attach_failure = Some(Failure::new("/bin/nope as 0:0: No such file or directory")); + let session = spawn(guest, Given::new(workload)); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + caller + .say(&HostToGuest::Attach { + id: nesprotocol::lifecycle::AttachId::new("a-2"), + exec: exec(), + size: None, + }) + .await; + + assert_eq!( + caller.expect().await, + GuestToHost::AttachFailed { + id: nesprotocol::lifecycle::AttachId::new("a-2"), + reason: "/bin/nope as 0:0: No such file or directory".into() + } + ); + caller.say(&HostToGuest::Shutdown).await; + let (outcome, _, _) = session.await.unwrap(); + assert_eq!(outcome, Outcome::Shutdown, "a failed shell ended the box"); + } + /// The compatibility layer is the base the rest of a box is built on, so it /// is mounted before anything that could sit inside it, and a box whose /// image will not mount is refused rather than carried on with. diff --git a/apps/nesinit/src/workload.rs b/apps/nesinit/src/workload.rs index 08be10ca..47ac774f 100644 --- a/apps/nesinit/src/workload.rs +++ b/apps/nesinit/src/workload.rs @@ -11,7 +11,7 @@ use std::future::Future; use std::io; use std::pin::Pin; -use nesprotocol::lifecycle::{Exec, Exit, Image, Mount, Overlay}; +use nesprotocol::lifecycle::{AttachId, Exec, Exit, Image, Mount, Overlay, Winsize}; use std::os::unix::process::CommandExt; @@ -51,6 +51,15 @@ pub trait Workload { /// layer, and the two together where it says. fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>; + /// Start a process on a terminal and connect it to the host on a data port + /// of its own. + /// + /// Returns once the process is started or has failed to be, and no later: + /// everything that can fail before there is anything to connect to fails + /// here, where it can be sent up the control channel. The connection itself + /// is made in the background. + fn attach(&mut self, id: AttachId, exec: &Exec, size: Option) -> Result<(), Failure>; + /// Start the command the descriptor names. /// /// Returning the exit as a future, rather than a `wait` method, is what @@ -146,6 +155,32 @@ impl Workload for Process { Ok(()) } + fn attach(&mut self, id: AttachId, exec: &Exec, size: Option) -> Result<(), Failure> { + use nesprotocol::lifecycle::ATTACH_PORT; + use tokio_vsock::{VMADDR_CID_HOST, VsockAddr, VsockStream}; + + let shell = crate::attach::spawn(&self.waiters, exec, size)?; + tracing::info!(attach = %id, pid = shell.pid(), uid = exec.uid, "a shell was started"); + tokio::spawn(async move { + // Dialled once. The host is listening before it asks, so a refused + // connection means it stopped wanting the shell, and `shell` + // dropping at the end of this task is what hangs it up. + let address = VsockAddr::new(VMADDR_CID_HOST, ATTACH_PORT); + let stream = match VsockStream::connect(address).await { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(attach = %id, %error, "could not reach the host for a shell"); + return; + } + }; + match crate::attach::serve(shell, &id, stream).await { + Ok(()) => tracing::info!(attach = %id, "a shell ended"), + Err(error) => tracing::warn!(attach = %id, %error, "a shell's connection failed"), + } + }); + Ok(()) + } + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> { for overlay in overlays { mount_overlay(overlay)?; @@ -263,7 +298,7 @@ impl Workload for Process { /// finds the variable unset does not fail loudly -- the compositor here falls /// back to `/tmp` -- so the sockets land somewhere world-writable and shared /// with every other user, and everything reports success. ref(d-0065) -fn environment(exec: &Exec, runtime: Option<&str>) -> Vec<(String, String)> { +pub(crate) fn environment(exec: &Exec, runtime: Option<&str>) -> Vec<(String, String)> { GRAPHICS .iter() .map(|(k, v)| (k.to_string(), v.to_string())) @@ -950,6 +985,8 @@ pub mod double { pub images: Vec>, /// Which kind of mount was asked for, in the order it was asked. pub order: Vec<&'static str>, + pub attached: Vec<(AttachId, Exec, Option)>, + pub attach_failure: Option, pub started: Vec, pub stops: usize, pub mount_failure: Option, @@ -977,6 +1014,8 @@ pub mod double { overlays: Vec::new(), images: Vec::new(), order: Vec::new(), + attached: Vec::new(), + attach_failure: None, started: Vec::new(), stops: 0, mount_failure: None, @@ -999,6 +1038,19 @@ pub mod double { } } + fn attach( + &mut self, + id: AttachId, + exec: &Exec, + size: Option, + ) -> Result<(), Failure> { + self.attached.push((id, exec.clone(), size)); + match &self.attach_failure { + Some(failure) => Err(failure.clone()), + None => Ok(()), + } + } + fn mount_images(&mut self, images: &[Image]) -> Result<(), Failure> { self.order.push("images"); self.images.push(images.to_vec()); diff --git a/apps/nesinit/tests/attach.rs b/apps/nesinit/tests/attach.rs new file mode 100644 index 00000000..ef8f8d10 --- /dev/null +++ b/apps/nesinit/tests/attach.rs @@ -0,0 +1,308 @@ +// 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 { + 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, 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, +) -> (Host, i32, tokio::task::JoinHandle>) { + 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(); +} diff --git a/build/Containerfile b/build/Containerfile index 45537e2e..f87397f4 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -322,10 +322,16 @@ RUN pacman -Syu --noconfirm --needed \ pipewire pipewire-audio wireplumber opus \ python libunwind sdl2-compat \ python-xlib python-urllib3 python-pyzstd \ + strace lsof less \ && rm -f /usr/share/libalpm/hooks/dbus-reload.hook \ && pacman -Rdd --noconfirm systemd systemd-sysvcompat openssh \ && pacman -Scc --noconfirm +# `strace`, `lsof` and `less` are for the operator's shell (`neslet box exec`): +# a root shell in a box that cannot trace a process, list what it has open or +# page through a log is the situation that feature exists to get out of. They +# are a few hundred kilobytes between them. +# # `libunwind` is Wine's, not ours. `ntdll.so` links it, so without it every # process Wine starts dies at `could not load ntdll.so`, which is the first # thing it loads and reads like Wine itself being broken. Found 2026-09-12, diff --git a/crates/nesprotocol/src/attach.rs b/crates/nesprotocol/src/attach.rs new file mode 100644 index 00000000..199d7a06 --- /dev/null +++ b/crates/nesprotocol/src/attach.rs @@ -0,0 +1,327 @@ +//! The byte stream of an attached shell. +//! +//! A shell in a box is a terminal's worth of bytes in both directions, plus the +//! few things a terminal says out of band: it has been resized, the other end +//! has gone, the process has ended. Those ride a connection of their own (see +//! [`crate::lifecycle::ATTACH_PORT`]) rather than the JSON-lines control +//! channel, because a screenful of output on a channel shared with everything +//! else would hold every other message in the box behind it. +//! +//! # A codec and nothing else +//! +//! No I/O here: [`Frame::encode`] makes bytes and [`FrameReader`] is fed bytes +//! and hands back frames. Three programs speak this -- the guest, the agent and +//! its command line -- each with its own idea of how to read a socket, and none +//! of them should have to agree with the others about anything but this. +//! +//! # Hostile enough to be careful +//! +//! Both ends are ours, but one of them is a guest, and a length prefix read from +//! a guest is a length prefix read from the other side of a trust boundary. A +//! frame longer than [`MAX_PAYLOAD`] is an error and not an allocation, and an +//! unknown tag is an error and not a skip: skipping needs a length this end has +//! no reason to believe. + +/// The longest payload a frame may carry. +pub const MAX_PAYLOAD: usize = 1 << 20; + +const HEADER: usize = 5; + +const TAG_HELLO: u8 = 1; +const TAG_DATA: u8 = 2; +const TAG_RESIZE: u8 = 3; +const TAG_HANGUP: u8 = 4; +const TAG_EXIT: u8 = 5; + +/// How the process ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitStatus { + Code(i32), + /// Killed by this signal. Kept apart from a code: a signalled process has + /// no exit code, and `0` for one would make a kill look like a clean run. + Signal(i32), +} + +/// One message on the stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Frame { + /// First frame from the guest: which attach this connection is for. + Hello(String), + /// Terminal bytes, in either direction. + Data(Vec), + /// The terminal changed size. Host to guest. + Resize { cols: u16, rows: u16 }, + /// The host is done with the shell. Host to guest. + Hangup, + /// The process ended. Last frame, guest to host. + Exit(ExitStatus), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + UnknownTag(u8), + TooLarge(usize), + Malformed(&'static str), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::UnknownTag(tag) => write!(f, "an unknown frame tag, {tag}"), + FrameError::TooLarge(len) => { + write!(f, "a frame of {len} bytes, past the {MAX_PAYLOAD} allowed") + } + FrameError::Malformed(what) => write!(f, "a malformed frame: {what}"), + } + } +} + +impl std::error::Error for FrameError {} + +impl Frame { + /// This frame as bytes. Refuses a payload it could not be read back from. + pub fn encode(&self) -> Result, FrameError> { + let (tag, payload): (u8, Vec) = match self { + Frame::Hello(id) => (TAG_HELLO, id.as_bytes().to_vec()), + Frame::Data(bytes) => (TAG_DATA, bytes.clone()), + Frame::Resize { cols, rows } => { + let mut p = Vec::with_capacity(4); + p.extend_from_slice(&cols.to_le_bytes()); + p.extend_from_slice(&rows.to_le_bytes()); + (TAG_RESIZE, p) + } + Frame::Hangup => (TAG_HANGUP, Vec::new()), + Frame::Exit(status) => { + let (kind, value) = match status { + ExitStatus::Code(code) => (0u8, *code), + ExitStatus::Signal(signal) => (1u8, *signal), + }; + let mut p = vec![kind]; + p.extend_from_slice(&value.to_le_bytes()); + (TAG_EXIT, p) + } + }; + if payload.len() > MAX_PAYLOAD { + return Err(FrameError::TooLarge(payload.len())); + } + let mut out = Vec::with_capacity(HEADER + payload.len()); + out.push(tag); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&payload); + Ok(out) + } + + /// Terminal bytes as frames no larger than a frame may be. + pub fn data(bytes: &[u8]) -> impl Iterator + '_ { + bytes + .chunks(MAX_PAYLOAD) + .map(|chunk| Frame::Data(chunk.to_vec())) + } +} + +/// Frames out of bytes that arrive in whatever pieces the socket gives. +#[derive(Debug, Default)] +pub struct FrameReader { + buf: Vec, +} + +impl FrameReader { + pub fn new() -> Self { + Self::default() + } + + /// Bytes that have arrived. + pub fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// The next whole frame, if one has arrived. + /// + /// `Ok(None)` is "not yet". After an error the reader is poisoned in the + /// sense that matters: the stream has lost its framing, nothing after this + /// point can be trusted, and the caller closes the connection. + pub fn next_frame(&mut self) -> Result, FrameError> { + if self.buf.len() < HEADER { + return Ok(None); + } + let tag = self.buf[0]; + let len = u32::from_le_bytes([self.buf[1], self.buf[2], self.buf[3], self.buf[4]]) as usize; + if !(TAG_HELLO..=TAG_EXIT).contains(&tag) { + return Err(FrameError::UnknownTag(tag)); + } + if len > MAX_PAYLOAD { + return Err(FrameError::TooLarge(len)); + } + if self.buf.len() < HEADER + len { + return Ok(None); + } + let payload: Vec = self.buf[HEADER..HEADER + len].to_vec(); + self.buf.drain(..HEADER + len); + + let frame = match tag { + TAG_HELLO => Frame::Hello( + String::from_utf8(payload).map_err(|_| FrameError::Malformed("a non-UTF-8 id"))?, + ), + TAG_DATA => Frame::Data(payload), + TAG_RESIZE => { + let [c0, c1, r0, r1] = payload[..] else { + return Err(FrameError::Malformed("a resize that is not four bytes")); + }; + Frame::Resize { + cols: u16::from_le_bytes([c0, c1]), + rows: u16::from_le_bytes([r0, r1]), + } + } + TAG_HANGUP => { + if !payload.is_empty() { + return Err(FrameError::Malformed("a hangup with a payload")); + } + Frame::Hangup + } + TAG_EXIT => { + let [kind, a, b, c, d] = payload[..] else { + return Err(FrameError::Malformed("an exit that is not five bytes")); + }; + let value = i32::from_le_bytes([a, b, c, d]); + match kind { + 0 => Frame::Exit(ExitStatus::Code(value)), + 1 => Frame::Exit(ExitStatus::Signal(value)), + _ => return Err(FrameError::Malformed("an exit of an unknown kind")), + } + } + _ => unreachable!("the tag range was checked above"), + }; + Ok(Some(frame)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(frame: Frame) { + let bytes = frame.encode().unwrap(); + let mut reader = FrameReader::new(); + reader.push(&bytes); + assert_eq!(reader.next_frame().unwrap(), Some(frame)); + assert_eq!(reader.next_frame().unwrap(), None); + } + + #[test] + fn every_frame_survives_the_round_trip() { + round_trip(Frame::Hello("a-1".into())); + round_trip(Frame::Data(b"ls -l\r".to_vec())); + round_trip(Frame::Data(Vec::new())); + round_trip(Frame::Resize { + cols: 220, + rows: 61, + }); + round_trip(Frame::Hangup); + round_trip(Frame::Exit(ExitStatus::Code(0))); + round_trip(Frame::Exit(ExitStatus::Code(127))); + round_trip(Frame::Exit(ExitStatus::Signal(9))); + } + + /// A signal is not a code, and the two must not read back as each other. + #[test] + fn a_kill_is_not_an_exit_code() { + let mut reader = FrameReader::new(); + reader.push(&Frame::Exit(ExitStatus::Signal(9)).encode().unwrap()); + assert_ne!( + reader.next_frame().unwrap(), + Some(Frame::Exit(ExitStatus::Code(9))) + ); + } + + /// A socket hands over bytes in whatever pieces it likes. + #[test] + fn frames_are_reassembled_from_any_split() { + let frames = vec![ + Frame::Hello("x".into()), + Frame::Data(vec![7; 3000]), + Frame::Resize { cols: 80, rows: 24 }, + Frame::Exit(ExitStatus::Code(3)), + ]; + let wire: Vec = frames.iter().flat_map(|f| f.encode().unwrap()).collect(); + for step in [1, 2, 5, 7, 4096] { + let mut reader = FrameReader::new(); + let mut got = Vec::new(); + for piece in wire.chunks(step) { + reader.push(piece); + while let Some(frame) = reader.next_frame().unwrap() { + got.push(frame); + } + } + assert_eq!(got, frames, "split into {step}-byte pieces"); + } + } + + #[test] + fn two_frames_in_one_read_come_out_as_two() { + let mut reader = FrameReader::new(); + let mut wire = Frame::Data(b"a".to_vec()).encode().unwrap(); + wire.extend(Frame::Hangup.encode().unwrap()); + reader.push(&wire); + assert_eq!( + reader.next_frame().unwrap(), + Some(Frame::Data(b"a".to_vec())) + ); + assert_eq!(reader.next_frame().unwrap(), Some(Frame::Hangup)); + assert_eq!(reader.next_frame().unwrap(), None); + } + + /// The length is the other side's to claim, and is not believed past the cap. + #[test] + fn a_length_past_the_cap_is_refused_before_anything_is_waited_for() { + let mut reader = FrameReader::new(); + let mut header = vec![TAG_DATA]; + header.extend_from_slice(&((MAX_PAYLOAD as u32) + 1).to_le_bytes()); + reader.push(&header); + assert_eq!( + reader.next_frame(), + Err(FrameError::TooLarge(MAX_PAYLOAD + 1)) + ); + assert!(Frame::Data(vec![0; MAX_PAYLOAD + 1]).encode().is_err()); + assert!(Frame::Data(vec![0; MAX_PAYLOAD]).encode().is_ok()); + } + + #[test] + fn an_unknown_tag_is_an_error_and_not_a_skip() { + for tag in [0u8, 6, 255] { + let mut reader = FrameReader::new(); + reader.push(&[tag, 0, 0, 0, 0]); + assert_eq!(reader.next_frame(), Err(FrameError::UnknownTag(tag))); + } + } + + #[test] + fn a_frame_whose_payload_is_the_wrong_shape_is_refused() { + for (tag, payload) in [ + (TAG_RESIZE, vec![1, 2, 3]), + (TAG_HANGUP, vec![1]), + (TAG_EXIT, vec![0, 1, 2]), + (TAG_EXIT, vec![9, 0, 0, 0, 0]), + (TAG_HELLO, vec![0xff, 0xfe]), + ] { + let mut wire = vec![tag]; + wire.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + wire.extend_from_slice(&payload); + let mut reader = FrameReader::new(); + reader.push(&wire); + assert!( + matches!(reader.next_frame(), Err(FrameError::Malformed(_))), + "tag {tag} with {payload:?}" + ); + } + } + + #[test] + fn large_output_is_split_into_frames_a_frame_may_be() { + let bytes = vec![1u8; MAX_PAYLOAD * 2 + 10]; + let frames: Vec = Frame::data(&bytes).collect(); + assert_eq!(frames.len(), 3); + for frame in &frames { + assert!(frame.encode().is_ok()); + } + } +} diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index a0176bf1..44241656 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -2,6 +2,7 @@ // media: frames, audio, cursor, input and stats. One definition, so no two ends // can drift from each other silently. +pub mod attach; pub mod datagram; pub mod delay; pub mod gamepad; diff --git a/crates/nesprotocol/src/lifecycle.rs b/crates/nesprotocol/src/lifecycle.rs index bc974ec1..a6cefe39 100644 --- a/crates/nesprotocol/src/lifecycle.rs +++ b/crates/nesprotocol/src/lifecycle.rs @@ -47,6 +47,15 @@ use serde::{Deserialize, Serialize}; /// slow boot from a dead one. pub const CONTROL_PORT: u32 = 7000; +/// The vsock port an attached shell's bytes travel on. +/// +/// The host sends [`HostToGuest::Attach`] on the control channel and the guest +/// answers by dialling this, the same direction the control channel itself goes, +/// so nothing in the box has to listen. The stream is the frames in +/// [`crate::attach`]. It is a port of its own because a screenful of output on +/// the control channel would hold every other message in the box behind it. +pub const ATTACH_PORT: u32 = 7001; + /// Version of this layer. Both ends compare it during the handshake and refuse /// on mismatch, so a guest built against one version meeting a caller built /// against another fails immediately and legibly, rather than later on a field @@ -183,6 +192,39 @@ impl std::fmt::Display for LaunchId { } } +/// Names one attached shell, for as long as the connection that carries it. +/// +/// Minted by the host and echoed by the guest in the first frame of the stream, +/// which is how the host knows which waiting command line a connection belongs +/// to. Not a [`LaunchId`]: a shell is not a launch and has no exit that means +/// anything to the session. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AttachId(pub String); + +impl AttachId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AttachId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A terminal's size, in character cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Winsize { + pub cols: u16, + pub rows: u16, +} + /// What the workload exiting means for the session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct OnExit { @@ -338,6 +380,11 @@ pub enum GuestToHost { #[serde(flatten)] exit: Exit, }, + /// The shell with this id could not be started, in the words the operating + /// system used. Said on the control channel because nothing has connected to + /// the data port yet, and a host that heard nothing would wait out a timeout + /// to find out. + AttachFailed { id: AttachId, reason: String }, /// The launch with this id is running. Started { id: LaunchId }, /// The launch with this id could not be run, in the words the operating @@ -388,6 +435,21 @@ pub enum HostToGuest { /// exists as one message only because a caller sending two has the same /// effect with a worse race in it. The relaunch keeps the id. ref(d-0064) Restart { id: LaunchId }, + /// Run something on a terminal and connect it to the host on + /// [`ATTACH_PORT`]. + /// + /// **Not a launch.** It has no `on_exit`, because nothing it does ends the + /// session, and it is not the running launch, so a shell can come and go + /// while a game plays. `size` present means a person is at a terminal and the + /// PTY is an ordinary one; absent means a command is being run for its + /// output, and the PTY is put in raw mode so it is a byte pipe and not a + /// terminal that rewrites line endings. + Attach { + id: AttachId, + exec: Exec, + #[serde(default)] + size: Option, + }, /// Shut the guest down. Shutdown, /// Bytes for the workload, relayed. See [`Payload`]. @@ -807,3 +869,59 @@ mod image_tests { assert_eq!(back, d); } } + +#[cfg(test)] +mod attach_tests { + use super::*; + + #[test] + fn an_attach_round_trips_with_and_without_a_terminal() { + for size in [ + Some(Winsize { + cols: 120, + rows: 40, + }), + None, + ] { + let attach = HostToGuest::Attach { + id: AttachId::new("a-1"), + exec: Exec { + argv: vec!["/bin/bash".into()], + env: Default::default(), + cwd: None, + uid: 0, + gid: 0, + }, + size, + }; + let back: HostToGuest = from_line(&to_line(&attach).unwrap()).unwrap(); + assert_eq!(back, attach); + } + } + + /// A host that leaves `size` out is asking for a command, not a terminal. + #[test] + fn an_attach_with_no_size_is_not_a_terminal() { + let line = r#"{"type":"attach","id":"a-1","exec":{"argv":["/bin/true"],"uid":0,"gid":0}}"#; + let HostToGuest::Attach { size, .. } = from_line(line).unwrap() else { + panic!("not an attach") + }; + assert_eq!(size, None); + } + + #[test] + fn a_failed_attach_says_which() { + let failed = GuestToHost::AttachFailed { + id: AttachId::new("a-1"), + reason: "no such file".into(), + }; + let back: GuestToHost = from_line(&to_line(&failed).unwrap()).unwrap(); + assert_eq!(back, failed); + } + + /// The attach port is not the control port. + #[test] + fn the_data_port_is_its_own() { + assert_ne!(ATTACH_PORT, CONTROL_PORT); + } +}