Enable pty console

Add the ability for cloud-hypervisor to create, manage and monitor a
pty for serial and/or console I/O from a user. The reasoning for
having cloud-hypervisor create the ptys is so that clients, libvirt
for example, could exit and later re-open the pty without causing I/O
issues. If the clients were responsible for creating the pty, when
they exit the main pty fd would close and cause cloud-hypervisor to
get I/O errors on writes.

Ideally the main and subordinate pty fds would be kept in the main
vmm's Vm structure. However, because the device manager owns parsing
the configuration for the serial and console devices, the information
is instead stored in new fields under the DeviceManager structure
directly.

From there hooking up the main fd is intended to look as close to
handling stdin and stdout on the tty as possible (there is some future
work ahead for perhaps moving support for the pty into the
vmm_sys_utils crate).

The main fd is used for reading user input and writing to output of
the Vm device. The subordinate fd is used to setup raw mode and it is
kept open in order to avoid I/O errors when clients open and close the
pty device.

The ability to handle multiple inputs as part of this change is
intentional. The current code allows serial and console ptys to be
created and both be used as input. There was an implementation gap
though with the queue_input_bytes needing to be modified so the pty
handlers for serial and console could access the methods on the serial
and console structures directly. Without this change only a single
input source could be processed as the console would switch based on
its input type (this is still valid for tty and isn't otherwise
modified).

Signed-off-by: William Douglas <william.r.douglas@gmail.com>
This commit is contained in:
William Douglas 2021-01-14 03:03:53 +00:00 committed by Rob Bradford
parent a59fbf0e37
commit 48963e322a
8 changed files with 419 additions and 24 deletions

View File

@ -247,7 +247,7 @@ fn create_app<'a, 'b>(
.arg(
Arg::with_name("serial")
.long("serial")
.help("Control serial port: off|null|tty|file=/path/to/a/file")
.help("Control serial port: off|null|pty|tty|file=/path/to/a/file")
.default_value("null")
.group("vm-config"),
)
@ -255,7 +255,7 @@ fn create_app<'a, 'b>(
Arg::with_name("console")
.long("console")
.help(
"Control (virtio) console: \"off|null|tty|file=/path/to/a/file,iommu=on|off\"",
"Control (virtio) console: \"off|null|pty|tty|file=/path/to/a/file,iommu=on|off\"",
)
.default_value("tty")
.group("vm-config"),
@ -1412,6 +1412,57 @@ mod unit_tests {
});
}
#[test]
fn test_valid_vm_config_serial_pty_console_pty() {
vec![
(
vec!["cloud-hypervisor", "--kernel", "/path/to/kernel"],
r#"{
"kernel": {"path": "/path/to/kernel"},
"serial": {"mode": "Null"},
"console": {"mode": "Tty"}
}"#,
true,
),
(
vec![
"cloud-hypervisor",
"--kernel",
"/path/to/kernel",
"--serial",
"null",
"--console",
"tty",
],
r#"{
"kernel": {"path": "/path/to/kernel"}
}"#,
true,
),
(
vec![
"cloud-hypervisor",
"--kernel",
"/path/to/kernel",
"--serial",
"pty",
"--console",
"pty",
],
r#"{
"kernel": {"path": "/path/to/kernel"},
"serial": {"mode": "Pty"},
"console": {"mode": "Pty"}
}"#,
true,
),
]
.iter()
.for_each(|(cli, openapi, equal)| {
compare_vm_config_cli_vs_json(cli, openapi, *equal);
});
}
#[test]
#[cfg(target_arch = "x86_64")]
fn test_valid_vm_config_devices() {

View File

@ -27,7 +27,8 @@ mod tests {
use std::process::{Child, Command, Stdio};
use std::str::FromStr;
use std::string::String;
use std::sync::Mutex;
use std::sync::mpsc::Receiver;
use std::sync::{mpsc, Mutex};
use std::thread;
use tempdir::TempDir;
use tempfile::NamedTempFile;
@ -2363,6 +2364,37 @@ mod tests {
}
}
fn pty_read(mut pty: std::fs::File) -> Receiver<String> {
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || loop {
thread::sleep(std::time::Duration::new(1, 0));
let mut buf = [0; 512];
match pty.read(&mut buf) {
Ok(_) => {
let output = std::str::from_utf8(&buf).unwrap().to_string();
match tx.send(output) {
Ok(_) => (),
Err(_) => break,
}
}
Err(_) => break,
}
});
rx
}
fn get_pty_path(api_socket: &str, pty_type: &str) -> PathBuf {
let (cmd_success, cmd_output) = remote_command_w_output(&api_socket, "info", None);
assert!(cmd_success);
let info: serde_json::Value = serde_json::from_slice(&cmd_output).unwrap_or_default();
assert_eq!("Pty", info["config"][pty_type]["mode"]);
PathBuf::from(
info["config"][pty_type]["file"]
.as_str()
.expect("Missing pty path"),
)
}
mod parallel {
use crate::tests::*;
@ -3654,6 +3686,94 @@ mod tests {
handle_child_output(r, &output);
}
#[test]
#[cfg(target_arch = "x86_64")]
fn test_pty_interaction() {
let mut focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());
let guest = Guest::new(&mut focal);
let api_socket = temp_api_path(&guest.tmp_dir);
let cmdline = DIRECT_KERNEL_BOOT_CMDLINE.to_owned() + " console=ttyS0";
let mut child = GuestCommand::new(&guest)
.args(&["--cpus", "boot=1"])
.args(&["--memory", "size=512M"])
.args(&[
"--kernel",
direct_kernel_boot_path().unwrap().to_str().unwrap(),
])
.args(&["--cmdline", &cmdline])
.default_disks()
.default_net()
.args(&["--serial", "null"])
.args(&["--console", "pty"])
.args(&["--api-socket", &api_socket])
.spawn()
.unwrap();
let r = std::panic::catch_unwind(|| {
guest.wait_vm_boot(None).unwrap();
// Get pty fd for console
let console_path = get_pty_path(&api_socket, "console");
// TODO: Get serial pty test working
let mut cf = std::fs::OpenOptions::new()
.write(true)
.read(true)
.open(console_path)
.unwrap();
// Some dumb sleeps but we don't want to write
// before the console is up and we don't want
// to try and write the next line before the
// login process is ready.
thread::sleep(std::time::Duration::new(5, 0));
assert_eq!(cf.write(b"cloud\n").unwrap(), 6);
thread::sleep(std::time::Duration::new(2, 0));
assert_eq!(cf.write(b"cloud123\n").unwrap(), 9);
thread::sleep(std::time::Duration::new(2, 0));
assert_eq!(cf.write(b"echo test_pty_console\n").unwrap(), 22);
thread::sleep(std::time::Duration::new(2, 0));
// read pty and ensure they have a login shell
// some fairly hacky workarounds to avoid looping
// forever in case the channel is blocked getting output
let ptyc = pty_read(cf);
let mut empty = 0;
let mut prev = String::new();
loop {
thread::sleep(std::time::Duration::new(2, 0));
match ptyc.try_recv() {
Ok(line) => {
empty = 0;
prev = prev + &line;
if prev.contains("test_pty_console") {
break;
}
}
Err(mpsc::TryRecvError::Empty) => {
empty += 1;
if empty > 5 {
panic!("No login on pty");
}
}
_ => panic!("No login on pty"),
}
}
guest.ssh_command("sudo shutdown -h now").unwrap();
});
let _ = child.wait_timeout(std::time::Duration::from_secs(20));
let _ = child.kill();
let output = child.wait_with_output().unwrap();
handle_child_output(r, &output);
let r = std::panic::catch_unwind(|| {
// Check that the cloud-hypervisor binary actually terminated
assert_eq!(output.status.success(), true);
});
handle_child_output(r, &output);
}
#[test]
fn test_virtio_console() {
let mut focal = UbuntuDiskConfig::new(FOCAL_IMAGE_NAME.to_string());

View File

@ -758,7 +758,7 @@ components:
type: string
mode:
type: string
enum: [Off, Tty, File, Null]
enum: [Off, Pty, Tty, File, Null]
iommu:
type: boolean
default: false

View File

@ -1170,6 +1170,7 @@ impl PmemConfig {
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum ConsoleOutputMode {
Off,
Pty,
Tty,
File,
Null,
@ -1177,7 +1178,7 @@ pub enum ConsoleOutputMode {
impl ConsoleOutputMode {
pub fn input_enabled(&self) -> bool {
matches!(self, ConsoleOutputMode::Tty)
matches!(self, ConsoleOutputMode::Tty | ConsoleOutputMode::Pty)
}
}
@ -1199,6 +1200,7 @@ impl ConsoleConfig {
let mut parser = OptionParser::new();
parser
.add_valueless("off")
.add_valueless("pty")
.add_valueless("tty")
.add_valueless("null")
.add("file")
@ -1209,6 +1211,8 @@ impl ConsoleConfig {
let mut mode: ConsoleOutputMode = ConsoleOutputMode::Off;
if parser.is_set("off") {
} else if parser.is_set("pty") {
mode = ConsoleOutputMode::Pty
} else if parser.is_set("tty") {
mode = ConsoleOutputMode::Tty
} else if parser.is_set("null") {
@ -2156,6 +2160,14 @@ mod tests {
file: None,
}
);
assert_eq!(
ConsoleConfig::parse("pty")?,
ConsoleConfig {
mode: ConsoleOutputMode::Pty,
iommu: false,
file: None,
}
);
assert_eq!(
ConsoleConfig::parse("tty")?,
ConsoleConfig {

View File

@ -57,8 +57,10 @@ use hypervisor::kvm_ioctls::*;
use hypervisor::CpuState;
#[cfg(feature = "mshv")]
use hypervisor::IoEventAddress;
use libc::TIOCGWINSZ;
use libc::{MAP_NORESERVE, MAP_PRIVATE, MAP_SHARED, O_TMPFILE, PROT_READ, PROT_WRITE};
use libc::{
isatty, tcgetattr, tcsetattr, termios, ECHO, ICANON, ISIG, MAP_NORESERVE, MAP_PRIVATE,
MAP_SHARED, O_TMPFILE, PROT_READ, PROT_WRITE, TCSANOW, TIOCGWINSZ,
};
use pci::{
DeviceRelocation, PciBarRegionType, PciBus, PciConfigIo, PciConfigMmio, PciDevice, PciRoot,
VfioPciDevice,
@ -66,12 +68,14 @@ use pci::{
use seccomp::SeccompAction;
use std::any::Any;
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::convert::TryInto;
use std::fs::{read_link, File, OpenOptions};
use std::io::{self, sink, stdout, Seek, SeekFrom};
use std::mem::zeroed;
use std::num::Wrapping;
use std::os::unix::fs::OpenOptionsExt;
#[cfg(feature = "kvm")]
use std::os::unix::io::FromRawFd;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::PathBuf;
use std::result;
use std::sync::{Arc, Barrier, Mutex};
#[cfg(feature = "kvm")]
@ -228,6 +232,18 @@ pub enum DeviceManagerError {
/// Error creating console output file
ConsoleOutputFileOpen(io::Error),
/// Error creating serial pty
SerialPtyOpen(io::Error),
/// Error creating console pty
ConsolePtyOpen(io::Error),
/// Error setting pty raw mode
SetPtyRaw(vmm_sys_util::errno::Error),
/// Error getting pty peer
GetPtyPeer(vmm_sys_util::errno::Error),
/// Cannot create a VFIO device
VfioCreate(vfio_ioctls::VfioError),
@ -406,6 +422,56 @@ pub fn get_win_size() -> (u16, u16) {
(ws.cols, ws.rows)
}
const TIOCSPTLCK: libc::c_int = 0x4004_5431;
const TIOCGTPEER: libc::c_int = 0x5441;
pub fn create_pty() -> io::Result<(File, File, PathBuf)> {
// Try to use /dev/pts/ptmx first then fall back to /dev/ptmx
// This is done to try and use the devpts filesystem that
// could be available for use in the process's namespace first.
// Ideally these are all the same file though but different
// kernels could have things setup differently.
// See https://www.kernel.org/doc/Documentation/filesystems/devpts.txt
// for further details.
let main = match OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY)
.open("/dev/pts/ptmx")
{
Ok(f) => f,
_ => OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY)
.open("/dev/ptmx")?,
};
let mut unlock: libc::c_ulong = 0;
unsafe {
libc::ioctl(
main.as_raw_fd(),
TIOCSPTLCK.try_into().unwrap(),
&mut unlock,
)
};
let sub_fd = unsafe {
libc::ioctl(
main.as_raw_fd(),
TIOCGTPEER.try_into().unwrap(),
libc::O_NOCTTY | libc::O_RDWR,
)
};
if sub_fd == -1 {
return vmm_sys_util::errno::errno_result().map_err(|e| e.into());
}
let proc_path = PathBuf::from(format!("/proc/self/fd/{}", sub_fd));
let path = read_link(proc_path)?;
Ok((main, unsafe { File::from_raw_fd(sub_fd) }, path))
}
enum ConsoleInput {
Serial,
VirtioConsole,
@ -422,23 +488,11 @@ impl Console {
pub fn queue_input_bytes(&self, out: &[u8]) -> vmm_sys_util::errno::Result<()> {
match self.input {
Some(ConsoleInput::Serial) => {
if self.serial.is_some() {
self.serial
.as_ref()
.unwrap()
.lock()
.expect("Failed to process stdin event due to poisoned lock")
.queue_input_bytes(out)?;
}
self.queue_input_bytes_serial(out)?;
}
Some(ConsoleInput::VirtioConsole) => {
if self.virtio_console_input.is_some() {
self.virtio_console_input
.as_ref()
.unwrap()
.queue_input_bytes(out);
}
self.queue_input_bytes_console(out);
}
None => {}
}
@ -446,6 +500,27 @@ impl Console {
Ok(())
}
pub fn queue_input_bytes_serial(&self, out: &[u8]) -> vmm_sys_util::errno::Result<()> {
if self.serial.is_some() {
self.serial
.as_ref()
.unwrap()
.lock()
.unwrap()
.queue_input_bytes(out)?;
}
Ok(())
}
pub fn queue_input_bytes_console(&self, out: &[u8]) {
if self.virtio_console_input.is_some() {
self.virtio_console_input
.as_ref()
.unwrap()
.queue_input_bytes(out);
}
}
pub fn update_console_size(&self, cols: u16, rows: u16) {
if self.virtio_console_input.is_some() {
self.virtio_console_input
@ -712,6 +787,12 @@ pub struct DeviceManager {
// Console abstraction
console: Arc<Console>,
// console PTY
console_pty: Option<Arc<Mutex<(File, File)>>>,
// serial PTY
serial_pty: Option<Arc<Mutex<(File, File)>>>,
// Interrupt controller
#[cfg(target_arch = "x86_64")]
interrupt_controller: Option<Arc<Mutex<ioapic::Ioapic>>>,
@ -881,6 +962,8 @@ impl DeviceManager {
.map_err(DeviceManagerError::EventFd)?,
#[cfg(feature = "acpi")]
acpi_address,
serial_pty: None,
console_pty: None,
};
let device_manager = Arc::new(Mutex::new(device_manager));
@ -898,6 +981,18 @@ impl DeviceManager {
Ok(device_manager)
}
pub fn serial_pty(&self) -> Option<File> {
self.serial_pty
.as_ref()
.map(|pty| pty.lock().unwrap().0.try_clone().unwrap())
}
pub fn console_pty(&self) -> Option<File> {
self.console_pty
.as_ref()
.map(|pty| pty.lock().unwrap().0.try_clone().unwrap())
}
pub fn create_devices(&mut self) -> DeviceManagerResult<()> {
let mut virtio_devices: Vec<(VirtioDeviceArc, bool, String)> = Vec::new();
@ -1488,6 +1583,37 @@ impl DeviceManager {
Ok(serial)
}
fn modify_mode<F: FnOnce(&mut termios)>(
&self,
fd: RawFd,
f: F,
) -> vmm_sys_util::errno::Result<()> {
// Safe because we check the return value of isatty.
if unsafe { isatty(fd) } != 1 {
return Ok(());
}
// The following pair are safe because termios gets totally overwritten by tcgetattr and we
// check the return result.
let mut termios: termios = unsafe { zeroed() };
let ret = unsafe { tcgetattr(fd, &mut termios as *mut _) };
if ret < 0 {
return vmm_sys_util::errno::errno_result();
}
f(&mut termios);
// Safe because the syscall will only read the extent of termios and we check the return result.
let ret = unsafe { tcsetattr(fd, TCSANOW, &termios as *const _) };
if ret < 0 {
return vmm_sys_util::errno::errno_result();
}
Ok(())
}
fn set_raw_mode(&self, f: &mut File) -> vmm_sys_util::errno::Result<()> {
self.modify_mode(f.as_raw_fd(), |t| t.c_lflag &= !(ICANON | ECHO | ISIG))
}
fn add_console_device(
&mut self,
interrupt_manager: &Arc<dyn InterruptManager<GroupConfig = LegacyIrqGroupConfig>>,
@ -1499,6 +1625,18 @@ impl DeviceManager {
File::create(serial_config.file.as_ref().unwrap())
.map_err(DeviceManagerError::SerialOutputFileOpen)?,
)),
ConsoleOutputMode::Pty => {
let (main, mut sub, path) =
create_pty().map_err(DeviceManagerError::SerialPtyOpen)?;
self.set_raw_mode(&mut sub)
.map_err(DeviceManagerError::SetPtyRaw)?;
self.serial_pty = Some(Arc::new(Mutex::new((
main.try_clone().unwrap(),
sub.try_clone().unwrap(),
))));
self.config.lock().unwrap().serial.file = Some(path);
Some(Box::new(main.try_clone().unwrap()))
}
ConsoleOutputMode::Tty => Some(Box::new(stdout())),
ConsoleOutputMode::Off | ConsoleOutputMode::Null => None,
};
@ -1515,6 +1653,18 @@ impl DeviceManager {
File::create(console_config.file.as_ref().unwrap())
.map_err(DeviceManagerError::ConsoleOutputFileOpen)?,
)),
ConsoleOutputMode::Pty => {
let (main, mut sub, path) =
create_pty().map_err(DeviceManagerError::SerialPtyOpen)?;
self.set_raw_mode(&mut sub)
.map_err(DeviceManagerError::SetPtyRaw)?;
self.console_pty = Some(Arc::new(Mutex::new((
main.try_clone().unwrap(),
sub.try_clone().unwrap(),
))));
self.config.lock().unwrap().console.file = Some(path);
Some(Box::new(main.try_clone().unwrap()))
}
ConsoleOutputMode::Tty => Some(Box::new(stdout())),
ConsoleOutputMode::Null => Some(Box::new(sink())),
ConsoleOutputMode::Off => None,

View File

@ -104,6 +104,10 @@ pub enum Error {
#[error("Error handling VM stdin: {0:?}")]
Stdin(VmError),
/// Cannot handle the VM pty stream
#[error("Error handling VM pty: {0:?}")]
Pty(VmError),
/// Cannot reboot the VM
#[error("Error rebooting VM: {0:?}")]
VmReboot(VmError),
@ -145,6 +149,7 @@ pub enum EpollDispatch {
Stdin,
Api,
ActivateVirtioDevices,
Pty,
}
pub struct EpollContext {
@ -354,6 +359,16 @@ impl Vmm {
self.hypervisor.clone(),
activate_evt,
)?;
if let Some(ref serial_pty) = vm.serial_pty() {
self.epoll
.add_event(serial_pty, EpollDispatch::Pty)
.map_err(VmError::EventfdError)?;
};
if let Some(ref console_pty) = vm.console_pty() {
self.epoll
.add_event(console_pty, EpollDispatch::Pty)
.map_err(VmError::EventfdError)?;
};
self.vm = Some(vm);
}
}
@ -1116,6 +1131,11 @@ impl Vmm {
.map_err(Error::ActivateVirtioDevices)?;
}
}
EpollDispatch::Pty => {
if let Some(ref vm) = self.vm {
vm.handle_pty().map_err(Error::Pty)?;
}
}
EpollDispatch::Api => {
// Consume the event.
self.api_evt.read().map_err(Error::EventFdRead)?;

View File

@ -47,6 +47,8 @@ const SYS_IO_URING_REGISTER: i64 = 427;
const TCGETS: u64 = 0x5401;
const TCSETS: u64 = 0x5402;
const TIOCGWINSZ: u64 = 0x5413;
const TIOCSPTLCK: u64 = 0x4004_5431;
const TIOCGTPEER: u64 = 0x5441;
const FIOCLEX: u64 = 0x5451;
const FIONBIO: u64 = 0x5421;
@ -155,6 +157,8 @@ fn create_vmm_ioctl_seccomp_rule_common() -> Result<Vec<SeccompRule>, Error> {
and![Cond::new(1, ArgLen::DWORD, Eq, TCSETS)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TCGETS)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TIOCGWINSZ)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TIOCSPTLCK)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TIOCGTPEER)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TUNGETFEATURES)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TUNGETIFF)?],
and![Cond::new(1, ArgLen::DWORD, Eq, TUNSETIFF)?],

View File

@ -130,6 +130,9 @@ pub enum Error {
/// Write to the console failed.
Console(vmm_sys_util::errno::Error),
/// Write to the pty console failed.
PtyConsole(io::Error),
/// Cannot setup terminal in raw mode.
SetTerminalRaw(vmm_sys_util::errno::Error),
@ -1073,6 +1076,14 @@ impl Vm {
Ok(())
}
pub fn serial_pty(&self) -> Option<File> {
self.device_manager.lock().unwrap().serial_pty()
}
pub fn console_pty(&self) -> Option<File> {
self.device_manager.lock().unwrap().console_pty()
}
pub fn shutdown(&mut self) -> Result<()> {
let mut state = self.state.try_write().map_err(|_| Error::PoisonedState)?;
let new_state = VmState::Shutdown;
@ -1557,6 +1568,33 @@ impl Vm {
Ok(())
}
pub fn handle_pty(&self) -> Result<()> {
// Could be a little dangerous, picks up a lock on device_manager
// and goes into a blocking read. If the epoll loops starts to be
// services by multiple threads likely need to revist this.
let dm = self.device_manager.lock().unwrap();
let mut out = [0u8; 64];
if let Some(mut pty) = dm.serial_pty() {
let count = pty.read(&mut out).map_err(Error::PtyConsole)?;
let console = dm.console();
if console.input_enabled() {
console
.queue_input_bytes_serial(&out[..count])
.map_err(Error::Console)?;
}
};
let count = match dm.console_pty() {
Some(mut pty) => pty.read(&mut out).map_err(Error::PtyConsole)?,
None => return Ok(()),
};
let console = dm.console();
if console.input_enabled() {
console.queue_input_bytes_console(&out[..count])
}
Ok(())
}
pub fn handle_stdin(&self) -> Result<()> {
let mut out = [0u8; 64];
let count = io::stdin()