diff --git a/Cargo.lock b/Cargo.lock index 712fd1c04..305613e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1223,9 +1223,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e2792b0ff0340399d58445b88fd9770e3489eff258a4cbc1523418f12abf84" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" dependencies = [ "futures-channel", "futures-core", @@ -1238,9 +1238,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5317663a9089767a1ec00a487df42e0ca174b61b4483213ac24448e4664df5" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" dependencies = [ "futures-core", "futures-sink", @@ -1248,15 +1248,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec90ff4d0fe1f57d600049061dc6bb68ed03c7d2fbd697274c41805dcb3f8608" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" [[package]] name = "futures-executor" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8de0a35a6ab97ec8869e32a2473f4b1324459e14c29275d14b10cb1fd19b50e" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" dependencies = [ "futures-core", "futures-task", @@ -1265,38 +1265,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfb8371b6fb2aeb2d280374607aeabfc99d95c72edfe51692e42d3d7f0d08531" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" [[package]] name = "futures-macro" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a73af87da33b5acf53acfebdc339fe592ecf5357ac7c0a7734ab9d8c876a70" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ "proc-macro2", "quote", - "syn 1.0.107", + "syn 2.0.13", ] [[package]] name = "futures-sink" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f310820bb3e8cfd46c80db4d7fb8353e15dfff853a127158425f31e0be6c8364" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" [[package]] name = "futures-task" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf79a1bf610b10f42aea489289c5a2c478a786509693b80cd39c44ccd936366" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" [[package]] name = "futures-util" -version = "0.3.26" +version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c1d6de3acfef38d2be4b1f543f553131788603495be83da675e180c8d6b7bd1" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" dependencies = [ "futures-channel", "futures-core", @@ -2545,7 +2545,7 @@ dependencies = [ "slog-term", "thiserror", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.17.2", "toml 0.5.11", "tracing", "uuid", @@ -2905,8 +2905,9 @@ dependencies = [ "slog", "slog-async", "slog-term", + "thouart", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.17.2", "uuid", ] @@ -2928,7 +2929,7 @@ dependencies = [ "slog", "thiserror", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.17.2", "uuid", ] @@ -2981,7 +2982,7 @@ dependencies = [ "slog", "thiserror", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.17.2", "tokio-util", "toml 0.5.11", "usdt", @@ -4039,6 +4040,19 @@ dependencies = [ "syn 2.0.13", ] +[[package]] +name = "thouart" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/thouart?rev=656d2a77dd22f7110f513aed2401ebd740b9ef79#656d2a77dd22f7110f513aed2401ebd740b9ef79" +dependencies = [ + "futures", + "libc", + "regex", + "thiserror", + "tokio", + "tokio-tungstenite 0.18.0", +] + [[package]] name = "thread-id" version = "4.0.0" @@ -4198,7 +4212,19 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.17.3", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54319c93411147bced34cb5609a80e0a8e44c5999c93903a81cd866630ec0bfd" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.18.0", ] [[package]] @@ -4394,6 +4420,25 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ee6ab729cd4cf0fd55218530c4522ed30b7b6081752839b68fcec8d0960788" +dependencies = [ + "base64 0.13.1", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" diff --git a/Cargo.toml b/Cargo.toml index 3269cb7dc..0e7a155b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ softnpu-lib = { git = "https://github.com/oxidecomputer/softnpu", branch = "main syn = "1.0" tempfile = "3.2" thiserror = "1.0" +thouart = { git = "https://github.com/oxidecomputer/thouart", rev = "656d2a77dd22f7110f513aed2401ebd740b9ef79" } tokio = "1" tokio-tungstenite = "0.17" tokio-util = "0.7" diff --git a/bin/propolis-cli/Cargo.toml b/bin/propolis-cli/Cargo.toml index a00d9d3e7..55a5b2dd0 100644 --- a/bin/propolis-cli/Cargo.toml +++ b/bin/propolis-cli/Cargo.toml @@ -15,6 +15,7 @@ propolis-client = { workspace = true, features = ["generated"] } slog.workspace = true slog-async.workspace = true slog-term.workspace = true +thouart.workspace = true tokio = { workspace = true, features = ["full"] } tokio-tungstenite.workspace = true uuid.workspace = true diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index 17aad781c..05eaa38d4 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -3,7 +3,6 @@ use std::io::BufReader; use std::path::{Path, PathBuf}; use std::{ net::{IpAddr, SocketAddr, ToSocketAddrs}, - os::unix::prelude::AsRawFd, time::Duration, }; @@ -19,7 +18,7 @@ use propolis_client::handmade::{ }; use propolis_client::support::InstanceSerialConsoleHelper; use slog::{o, Drain, Level, Logger}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use thouart::EscapeSequence; use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; @@ -89,6 +88,28 @@ enum Command { /// Defaults to the most recent 16 KiB of console output (-16384). #[clap(long, short)] byte_offset: Option, + + /// If this sequence of bytes is typed, the client will exit. + /// Defaults to "^]^C" (Ctrl+], Ctrl+C). Note that the string passed + /// for this argument must be valid UTF-8, and is used verbatim without + /// any parsing; in most shells, if you wish to include a special + /// character (such as Enter or a Ctrl+letter combo), you can insert + /// the character by preceding it with Ctrl+V at the command line. + /// To disable the escape string altogether, provide an empty string to + /// this flag (and to exit in such a case, use pkill or similar). + #[clap(long, short, default_value = "\x1d\x03")] + escape_string: String, + + /// The number of bytes from the beginning of the escape string to pass + /// to the VM before beginning to buffer inputs until a mismatch. + /// Defaults to 0, such that input matching the escape string does not + /// get sent to the VM at all until a non-matching character is typed. + /// For example, to mimic the escape sequence for exiting SSH ("\n~."), + /// you may pass `-e '^M~.' --escape-prefix-length=1` such that newline + /// gets sent to the VM immediately while still continuing to match the + /// rest of the sequence. + #[clap(long, default_value = "0")] + escape_prefix_length: usize, }, /// Migrate instance to new propolis-server @@ -221,160 +242,24 @@ async fn put_instance( Ok(()) } -async fn stdin_to_websockets_task( - mut stdinrx: tokio::sync::mpsc::Receiver>, - wstx: tokio::sync::mpsc::Sender>, -) { - // next_raw must live outside loop, because Ctrl-A should work across - // multiple inbuf reads. - let mut next_raw = false; - - loop { - let inbuf = if let Some(inbuf) = stdinrx.recv().await { - inbuf - } else { - continue; - }; - - // Put bytes from inbuf to outbuf, but don't send Ctrl-A unless - // next_raw is true. - let mut outbuf = Vec::with_capacity(inbuf.len()); - - let mut exit = false; - for c in inbuf { - match c { - // Ctrl-A means send next one raw - b'\x01' => { - if next_raw { - // Ctrl-A Ctrl-A should be sent as Ctrl-A - outbuf.push(c); - next_raw = false; - } else { - next_raw = true; - } - } - b'\x03' => { - if !next_raw { - // Exit on non-raw Ctrl-C - exit = true; - break; - } else { - // Otherwise send Ctrl-C - outbuf.push(c); - next_raw = false; - } - } - _ => { - outbuf.push(c); - next_raw = false; - } - } - } - - // Send what we have, even if there's a Ctrl-C at the end. - if !outbuf.is_empty() { - wstx.send(outbuf).await.unwrap(); - } - - if exit { - break; - } - } -} - -#[tokio::test] -async fn test_stdin_to_websockets_task() { - use tokio::sync::mpsc::error::TryRecvError; - - let (stdintx, stdinrx) = tokio::sync::mpsc::channel(16); - let (wstx, mut wsrx) = tokio::sync::mpsc::channel(16); - - tokio::spawn(async move { stdin_to_websockets_task(stdinrx, wstx).await }); - - // send characters, receive characters - stdintx - .send("test post please ignore".chars().map(|c| c as u8).collect()) - .await - .unwrap(); - let actual = wsrx.recv().await.unwrap(); - assert_eq!(String::from_utf8(actual).unwrap(), "test post please ignore"); - - // don't send ctrl-a - stdintx.send("\x01".chars().map(|c| c as u8).collect()).await.unwrap(); - assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty)); - - // the "t" here is sent "raw" because of last ctrl-a but that doesn't change anything - stdintx.send("test".chars().map(|c| c as u8).collect()).await.unwrap(); - let actual = wsrx.recv().await.unwrap(); - assert_eq!(String::from_utf8(actual).unwrap(), "test"); - - // ctrl-a ctrl-c = only ctrl-c sent - stdintx.send("\x01\x03".chars().map(|c| c as u8).collect()).await.unwrap(); - let actual = wsrx.recv().await.unwrap(); - assert_eq!(String::from_utf8(actual).unwrap(), "\x03"); - - // same as above, across two messages - stdintx.send("\x01".chars().map(|c| c as u8).collect()).await.unwrap(); - stdintx.send("\x03".chars().map(|c| c as u8).collect()).await.unwrap(); - assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty)); - let actual = wsrx.recv().await.unwrap(); - assert_eq!(String::from_utf8(actual).unwrap(), "\x03"); - - // ctrl-a ctrl-a = only ctrl-a sent - stdintx.send("\x01\x01".chars().map(|c| c as u8).collect()).await.unwrap(); - let actual = wsrx.recv().await.unwrap(); - assert_eq!(String::from_utf8(actual).unwrap(), "\x01"); - - // ctrl-c on its own means exit - stdintx.send("\x03".chars().map(|c| c as u8).collect()).await.unwrap(); - assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty)); - - // channel is closed - assert!(wsrx.recv().await.is_none()); -} - async fn serial( addr: SocketAddr, byte_offset: Option, log: Logger, + escape: Option, ) -> anyhow::Result<()> { + // handles following live migrations let mut ws_console = serial_connect(addr, byte_offset, log).await?; - let _raw_guard = RawTermiosGuard::stdio_guard() - .with_context(|| anyhow!("failed to set raw mode"))?; - - let mut stdout = tokio::io::stdout(); - - // https://docs.rs/tokio/latest/tokio/io/trait.AsyncReadExt.html#method.read_exact - // is not cancel safe! Meaning reads from tokio::io::stdin are not cancel - // safe. Spawn a separate task to read and put bytes onto this channel. - let (stdintx, stdinrx) = tokio::sync::mpsc::channel(16); - let (wstx, mut wsrx) = tokio::sync::mpsc::channel(16); - - tokio::spawn(async move { - let mut stdin = tokio::io::stdin(); - let mut inbuf = [0u8; 1024]; - - loop { - let n = match stdin.read(&mut inbuf).await { - Err(_) | Ok(0) => break, - Ok(n) => n, - }; - - stdintx.send(inbuf[0..n].to_vec()).await.unwrap(); - } - }); - - tokio::spawn(async move { stdin_to_websockets_task(stdinrx, wstx).await }); + // handles raw mode and escape sequence state tracking + let mut tty_console = thouart::Console::new_stdio(escape).await?; loop { tokio::select! { - c = wsrx.recv() => { + c = tty_console.read_stdin() => { match c { - None => { - // channel is closed - break; - } + // channel is closed + None => break, Some(c) => { ws_console.send(Message::Binary(c)).await?; }, @@ -383,8 +268,7 @@ async fn serial( msg = ws_console.recv() => { match msg { Some(Ok(Message::Binary(input))) => { - stdout.write_all(&input).await?; - stdout.flush().await?; + tty_console.write_stdout(&input).await?; } Some(Ok(Message::Close(..))) | None => break, _ => continue, @@ -392,6 +276,7 @@ async fn serial( } } } + ws_console.send(Message::Close(None)).await?; Ok(()) } @@ -573,8 +458,18 @@ async fn main() -> anyhow::Result<()> { } Command::Get => get_instance(&client).await?, Command::State { state } => put_instance(&client, state).await?, - Command::Serial { byte_offset } => { - serial(addr, byte_offset, log).await? + Command::Serial { + byte_offset, + escape_string, + escape_prefix_length, + } => { + let escape = if escape_string.is_empty() { + None + } else { + let escape_vector = escape_string.into_bytes(); + Some(EscapeSequence::new(escape_vector, escape_prefix_length)?) + }; + serial(addr, byte_offset, log, escape).await? } Command::Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => { let dst_addr = SocketAddr::new(dst_server, dst_port); @@ -593,39 +488,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -/// Guard object that will set the terminal to raw mode and restore it -/// to its previous state when it's dropped -struct RawTermiosGuard(libc::c_int, libc::termios); - -impl RawTermiosGuard { - fn stdio_guard() -> Result { - let fd = std::io::stdout().as_raw_fd(); - let termios = unsafe { - let mut curr_termios = std::mem::zeroed(); - let r = libc::tcgetattr(fd, &mut curr_termios); - if r == -1 { - return Err(std::io::Error::last_os_error()); - } - curr_termios - }; - let guard = RawTermiosGuard(fd, termios); - unsafe { - let mut raw_termios = termios; - libc::cfmakeraw(&mut raw_termios); - let r = libc::tcsetattr(fd, libc::TCSAFLUSH, &raw_termios); - if r == -1 { - return Err(std::io::Error::last_os_error()); - } - } - Ok(guard) - } -} -impl Drop for RawTermiosGuard { - fn drop(&mut self) { - let r = unsafe { libc::tcsetattr(self.0, libc::TCSADRAIN, &self.1) }; - if r == -1 { - Err::<(), _>(std::io::Error::last_os_error()).unwrap(); - } - } -}