diff --git a/firecracker-pilot/guestvm-tools/sci/src/defaults.rs b/firecracker-pilot/guestvm-tools/sci/src/defaults.rs index c7868a4..2bd9754 100644 --- a/firecracker-pilot/guestvm-tools/sci/src/defaults.rs +++ b/firecracker-pilot/guestvm-tools/sci/src/defaults.rs @@ -34,6 +34,9 @@ pub const PROBE_MODULE: &str = "/sbin/modprobe"; pub const SYSTEMD_NETWORK_RESOLV_CONF: &str = "/run/systemd/resolve/resolv.conf"; pub const VM_QUIT: &str = "sci_quit"; pub const VHOST_TRANSPORT: &str = "vmw_vsock_virtio_transport"; +pub const TERM_TYPE: &str = "xterm"; +pub const TERM_LINES: u16 = 24; +pub const TERM_COLUMNS: u16 = 80; pub const VM_PORT: u32 = 52; pub const GUEST_CID: u32 = 3; pub const RETRIES: u32 = diff --git a/firecracker-pilot/guestvm-tools/sci/src/main.rs b/firecracker-pilot/guestvm-tools/sci/src/main.rs index 959bca8..9203be4 100644 --- a/firecracker-pilot/guestvm-tools/sci/src/main.rs +++ b/firecracker-pilot/guestvm-tools/sci/src/main.rs @@ -68,6 +68,9 @@ fn main() { env::set_var("PS1", "\\[\\]\\u@\\h: >\n"); + // provide a terminal type for the command call + setup_terminal_environment(); + // print user space env for (key, value) in env::vars() { debug(&format!("{key}: {value}")); @@ -430,6 +433,106 @@ fn redirect_command(command: &str, stream: vsock::VsockStream) { } } +fn setup_terminal_environment() { + /*! + Provide a terminal type in the environment + + The environment of sci is created from the kernel commandline + and therefore normally does not provide a TERM setting. Shells + like bash switch off their line editor (readline) if the + terminal type is unset or set to 'dumb'. Without the line + editor there is no tab completion and no history handling for + the caller. The terminal type of the caller can be handed over + through the sci_term=... boot parameter and defaults to + defaults::TERM_TYPE + !*/ + let term = env::var("TERM").unwrap_or_default(); + if term.is_empty() { + let mut term_type = env::var("sci_term").unwrap_or_default(); + if term_type.is_empty() { + term_type = defaults::TERM_TYPE.to_string() + } + debug(&format!("Setting terminal type to: {term_type}")); + env::set_var("TERM", term_type); + } +} + +fn set_interactive_terminal_flags(fd: i32) { + /*! + Setup the given terminal for interactive use + + Keep the standard line discipline of the terminal switched on + such that the line editor of an interactive command, e.g the + tab completion of a shell, stays in control of the input + handling and echoes back what it has read. The terminal of the + caller is switched to raw mode by the pilot, thus every single + key stroke, including TAB, arrives here unmodified + !*/ + match Termios::from_fd(fd) { + Ok(mut termios) => { + termios.c_lflag |= ECHO | ECHOE | ECHOK | ICANON | ISIG | IEXTEN; + termios.c_iflag |= ICRNL; + termios.c_oflag |= OPOST | ONLCR; + match tcsetattr(fd, TCSANOW, &termios) { + Ok(_) => {} + Err(error) => { + debug(&format!("tcsetattr failed with: {error}")); + } + } + }, + Err(error) => { + debug(&format!( + "Term I/O failed with: {error}" + )); + } + } + set_terminal_size(fd) +} + +fn set_terminal_size(fd: i32) { + /*! + Set the window size of the given terminal + + A newly allocated pseudo terminal comes with no window size + assigned. The line editor needs the size of the caller's + terminal to be able to redraw the input line and to arrange + the list of tab completion matches in columns. The size of the + caller's terminal can be handed over through the sci_lines=... + and sci_columns=... boot parameters and defaults to + defaults::TERM_LINES x defaults::TERM_COLUMNS + !*/ + let window_size = libc::winsize { + ws_row: get_terminal_size_value( + "sci_lines", defaults::TERM_LINES + ), + ws_col: get_terminal_size_value( + "sci_columns", defaults::TERM_COLUMNS + ), + ws_xpixel: 0, + ws_ypixel: 0 + }; + let result = unsafe { + libc::ioctl( + fd, libc::TIOCSWINSZ as _, &window_size as *const libc::winsize + ) + }; + if result == -1 { + debug(&format!( + "Failed to set terminal size: {}", + std::io::Error::last_os_error() + )); + } +} + +fn get_terminal_size_value(name: &str, default_value: u16) -> u16 { + // Read a terminal geometry value from the given environment + // variable and fall back to the given default value + match env::var(name).unwrap_or_default().parse::() { + Ok(value) if value > 0 => value, + _ => default_value + } +} + fn set_output_terminal_flags(fd: i32) { // Disable echo and canonical mode on stdout match Termios::from_fd(fd) { @@ -480,7 +583,7 @@ fn redirect_command_to_raw_channels( set_output_terminal_flags(stdout_fd); // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -549,6 +652,15 @@ fn redirect_command_to_raw_channels( debug("EOF detected on stream"); break; } + // On raw channels there is no terminal which + // could echo back the input. As the caller's + // terminal is in raw mode and no longer echoes + // locally, send the input back to make typing + // visible + if stream.write_all(&buffer[0..sz_r]).is_err() { + debug("write failure on stream"); + break; + } if stdin.write_all(&buffer[0..sz_r]).is_err() { debug("write failure on stdin"); break; @@ -576,10 +688,14 @@ fn redirect_command_to_pty( let stdout_fd = master.as_raw_fd(); let stream_fd = stream.as_raw_fd(); - set_output_terminal_flags(stdout_fd); + // Keep the line discipline of the pseudo terminal active. + // The command in the terminal, e.g a shell, takes care for + // reading and echoing the input which is the precondition + // for features like tab completion to work + set_interactive_terminal_flags(stdout_fd); // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -606,7 +722,7 @@ fn redirect_command_to_pty( // try to handle what happened on the file descriptors if unsafe { libc::FD_ISSET(stdout_fd, &fdset) } { // something new happened on master, - // try to receive some bytes an send them through the stream + // try to receive some bytes and send them through the stream if let Ok(sz_r) = master.read(&mut buffer) { if sz_r == 0 { debug("EOF detected on stdout"); diff --git a/firecracker-pilot/src/defaults.rs b/firecracker-pilot/src/defaults.rs index 791165c..1ca24c1 100644 --- a/firecracker-pilot/src/defaults.rs +++ b/firecracker-pilot/src/defaults.rs @@ -44,6 +44,7 @@ pub const FIRECRACKER_VSOCK_PREFIX: &str = "sci_cmd_"; pub const FIRECRACKER_VSOCK_PORT_START: u32 = 49200; pub const GC_THRESHOLD: usize = 20; +pub const TERM_NAME_MAX_LEN: usize = 32; pub const VM_CID: u32 = 3; pub const VM_PORT: u32 = 52; diff --git a/firecracker-pilot/src/firecracker.rs b/firecracker-pilot/src/firecracker.rs index 3a6f5d5..505abd7 100644 --- a/firecracker-pilot/src/firecracker.rs +++ b/firecracker-pilot/src/firecracker.rs @@ -604,6 +604,60 @@ pub fn execute_command_at_instance( Ok(()) } +pub fn get_terminal_boot_args() -> Vec { + /*! + Provide the terminal setup of the caller as kernel boot params + + The terminal type is required in the guest to switch on the + line editor of interactive commands, e.g the tab completion of + a shell. The window size is required to let that line editor + redraw the input line and to arrange the tab completion matches + in columns + !*/ + let mut terminal_boot_args: Vec = Vec::new(); + if let Ok(term) = env::var("TERM") { + // Only pass along a terminal name which cannot be used to + // smuggle further parameters into the kernel commandline + let is_terminal_name = ! term.is_empty() + && term.len() <= defaults::TERM_NAME_MAX_LEN + && term.chars().all( + |char| char.is_ascii_alphanumeric() || "-_.+".contains(char) + ); + if is_terminal_name { + terminal_boot_args.push(format!("sci_term={term}")) + } else if Lookup::is_debug() { + debug!("Unsupported TERM name, not passed to the instance"); + } + } + if let Some((lines, columns)) = get_terminal_size() { + terminal_boot_args.push(format!("sci_lines={lines}")); + terminal_boot_args.push(format!("sci_columns={columns}")); + } + terminal_boot_args +} + +pub fn get_terminal_size() -> Option<(u16, u16)> { + /*! + Provide the window size of the caller's terminal + !*/ + let mut window_size = std::mem::MaybeUninit::::uninit(); + let result = unsafe { + libc::ioctl( + io::stdin().as_raw_fd(), + libc::TIOCGWINSZ as _, + window_size.as_mut_ptr() + ) + }; + if result == -1 { + return None + } + let window_size = unsafe { window_size.assume_init() }; + if window_size.ws_row == 0 || window_size.ws_col == 0 { + return None + } + Some((window_size.ws_row, window_size.ws_col)) +} + pub fn create_firecracker_config( program_name: &String, config_file: &NamedTempFile @@ -642,6 +696,11 @@ pub fn create_firecracker_config( if engine_section.overlay_size.is_some() { boot_args.push("overlay_root=/dev/vdb".to_string()); } + // hand over the setup of the caller's terminal. sci uses this + // information to setup the pseudo terminal of the command such + // that interactive commands, e.g a shell, can provide features + // like tab completion + boot_args.append(&mut get_terminal_boot_args()); for boot_option in engine_section.boot_args { if (resume || force_vsock) @@ -1069,6 +1128,69 @@ pub fn umount_vm(sub_dir: &str, user: User) -> Result<(), CommandError> { x.into_iter().collect() } +pub struct TerminalMode { + fd: i32, + saved: Option +} + +impl TerminalMode { + pub fn raw(fd: i32) -> Self { + /*! + Switch the given terminal into raw mode + + In raw mode the terminal driver no longer buffers the input + until the line is complete and no longer echoes what was + typed. Every single key stroke is passed on unmodified and + the echo is left to the command on the other end of the + connection. Output post processing stays switched on such + that output which only provides a line feed is still + displayed correctly. The original terminal setup is + restored when the returned object is dropped. + + If the given file descriptor is not a terminal, e.g if the + input is piped, nothing is changed + !*/ + if unsafe { libc::isatty(fd) } != 1 { + return TerminalMode { fd, saved: None } + } + let mut terminal = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, terminal.as_mut_ptr()) } != 0 { + if Lookup::is_debug() { + debug!( + "tcgetattr failed with: {}", io::Error::last_os_error() + ); + } + return TerminalMode { fd, saved: None } + } + let saved = unsafe { terminal.assume_init() }; + let mut raw = saved; + raw.c_lflag &= !( + libc::ECHO | libc::ECHONL | libc::ICANON | + libc::ISIG | libc::IEXTEN + ); + raw.c_iflag &= !(libc::ICRNL | libc::IXON); + raw.c_cc[libc::VMIN] = 1; + raw.c_cc[libc::VTIME] = 0; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + if Lookup::is_debug() { + debug!( + "tcsetattr failed with: {}", io::Error::last_os_error() + ); + } + return TerminalMode { fd, saved: None } + } + TerminalMode { fd, saved: Some(saved) } + } +} + +impl Drop for TerminalMode { + fn drop(&mut self) { + if let Some(saved) = self.saved { + unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &saved) }; + } + } +} + pub fn stream_listener(socket_path: &str) -> thread::JoinHandle<()> { let mut socket = String::new(); socket.push_str(socket_path); @@ -1101,8 +1223,18 @@ pub fn stream_io(mut stream: UnixStream) { let stream_fd = stream.as_raw_fd(); let stdin_fd = stdin.as_raw_fd(); let stdout_fd = stdout.as_raw_fd(); + + // Switch the caller's terminal into raw mode for the time of + // the session. Only in raw mode a single key stroke, e.g the + // TAB key, is passed on to the guest instead of being buffered + // until the line is complete. This is the precondition for the + // line editor of an interactive command in the guest, e.g a + // shell, to provide tab completion. The original terminal + // setup is restored when this function returns + let _terminal_mode = TerminalMode::raw(stdin_fd); + // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -1148,6 +1280,7 @@ pub fn stream_io(mut stream: UnixStream) { } break; } + let _ = stdout.flush(); } else { if Lookup::is_debug() { debug!("read failure on stdin"); @@ -1157,7 +1290,7 @@ pub fn stream_io(mut stream: UnixStream) { } if unsafe { libc::FD_ISSET(stream_fd, &fdset) } { // something new happened on the stream - // try to receive some bytes an send them to stdout + // try to receive some bytes and send them to stdout if let Ok(sz_r) = stream.read(&mut buffer) { if sz_r == 0 { if Lookup::is_debug() { @@ -1171,6 +1304,7 @@ pub fn stream_io(mut stream: UnixStream) { } break; } + let _ = stdout.flush(); } else { if Lookup::is_debug() { debug!("read failure on stream");