1+ use std:: ffi:: OsString ;
12use std:: fs:: File ;
23use std:: io:: BufReader ;
34use std:: path:: { Path , PathBuf } ;
45use std:: {
56 net:: { IpAddr , SocketAddr , ToSocketAddrs } ,
6- os:: unix:: prelude:: AsRawFd ,
7+ os:: unix:: prelude:: { AsRawFd , OsStringExt } ,
78 time:: Duration ,
89} ;
910
@@ -17,6 +18,7 @@ use propolis_client::handmade::{
1718 } ,
1819 Client ,
1920} ;
21+ use regex:: bytes:: Regex ;
2022use slog:: { o, Drain , Level , Logger } ;
2123use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
2224use tokio_tungstenite:: tungstenite:: protocol:: Role ;
@@ -90,6 +92,29 @@ enum Command {
9092 /// Defaults to the most recent 16 KiB of console output (-16384).
9193 #[ clap( long, short) ]
9294 byte_offset : Option < i64 > ,
95+
96+ /// If this sequence of bytes is typed, the client will exit.
97+ /// Defaults to "^]^C" (Ctrl+], Ctrl+C). Note that the string passed
98+ /// for this argument is used verbatim without any parsing; in most
99+ /// shells, if you wish to include a special character (such as Enter
100+ /// or a Ctrl+letter combo), you can insert the character by preceding
101+ /// it with Ctrl+V at the command line.
102+ #[ clap( long, short, default_value = "\x1d \x03 " ) ]
103+ escape_string : OsString ,
104+
105+ /// The number of bytes from the beginning of the escape string to pass
106+ /// to the VM before beginning to buffer inputs until a mismatch.
107+ /// Defaults to 0, such that input matching the escape string does not
108+ /// get sent to the VM at all until a non-matching character is typed.
109+ /// To mimic the escape sequence for exiting SSH (Enter, tilde, dot),
110+ /// you may pass `-e '^M~.' --escape-prefix-length=1` such that normal
111+ /// Enter presses are sent to the VM immediately.
112+ #[ clap( long, default_value = "0" ) ]
113+ escape_prefix_length : usize ,
114+
115+ /// Disable escape string altogether (to exit, use pkill or similar).
116+ #[ clap( long, short = 'E' ) ]
117+ no_escape : bool ,
93118 } ,
94119
95120 /// Migrate instance to new propolis-server
@@ -225,60 +250,89 @@ async fn put_instance(
225250async fn stdin_to_websockets_task (
226251 mut stdinrx : tokio:: sync:: mpsc:: Receiver < Vec < u8 > > ,
227252 wstx : tokio:: sync:: mpsc:: Sender < Vec < u8 > > ,
253+ escape_vector : Option < Vec < u8 > > ,
254+ escape_prefix_length : usize ,
228255) {
229- // next_raw must live outside loop, because Ctrl-A should work across
230- // multiple inbuf reads.
231- let mut next_raw = false ;
256+ if let Some ( esc_sequence) = & escape_vector {
257+ // esc_pos must live outside loop, because escape string should work
258+ // across multiple inbuf reads.
259+ let mut esc_pos = 0 ;
232260
233- loop {
234- let inbuf = if let Some ( inbuf) = stdinrx. recv ( ) . await {
235- inbuf
236- } else {
237- continue ;
238- } ;
261+ // matches partial increments of "\x1b[14;30R"
262+ let ansi_curs_pat =
263+ Regex :: new ( "^\x1b (\\ [([0-9]+(;([0-9]+R?)?)?)?)?$" ) . unwrap ( ) ;
264+ let mut ansi_curs_check = Vec :: new ( ) ;
239265
240- // Put bytes from inbuf to outbuf, but don't send Ctrl-A unless
241- // next_raw is true.
242- let mut outbuf = Vec :: with_capacity ( inbuf. len ( ) ) ;
243-
244- let mut exit = false ;
245- for c in inbuf {
246- match c {
247- // Ctrl-A means send next one raw
248- b'\x01' => {
249- if next_raw {
250- // Ctrl-A Ctrl-A should be sent as Ctrl-A
251- outbuf. push ( c) ;
252- next_raw = false ;
266+ loop {
267+ let inbuf = if let Some ( inbuf) = stdinrx. recv ( ) . await {
268+ inbuf
269+ } else {
270+ continue ;
271+ } ;
272+
273+ // Put bytes from inbuf to outbuf, but don't send characters in the
274+ // escape string sequence unless we bail.
275+ let mut outbuf = Vec :: with_capacity ( inbuf. len ( ) ) ;
276+
277+ let mut exit = false ;
278+ for c in inbuf {
279+ // ignore ANSI escape sequence for the cursor position
280+ // response sent by xterm-alikes in response to shells
281+ // requesting one after receiving a newline.
282+ if esc_pos > 0
283+ && esc_pos <= escape_prefix_length
284+ && b"\r \n " . contains ( & esc_sequence[ esc_pos - 1 ] )
285+ {
286+ ansi_curs_check. push ( c) ;
287+ if ansi_curs_pat. is_match ( & ansi_curs_check) {
288+ // end of the sequence?
289+ if c == b'R' {
290+ outbuf. extend ( & ansi_curs_check) ;
291+ ansi_curs_check. clear ( ) ;
292+ }
293+ continue ;
253294 } else {
254- next_raw = true ;
295+ ansi_curs_check. pop ( ) ; // we're not `continue`ing
296+ outbuf. extend ( & ansi_curs_check) ;
297+ ansi_curs_check. clear ( ) ;
255298 }
256299 }
257- b'\x03' => {
258- if !next_raw {
259- // Exit on non-raw Ctrl-C
300+
301+ if c == esc_sequence[ esc_pos] {
302+ esc_pos += 1 ;
303+ if esc_pos == esc_sequence. len ( ) {
304+ // Exit on completed escape string
260305 exit = true ;
261306 break ;
262- } else {
263- // Otherwise send Ctrl-C
307+ } else if esc_pos <= escape_prefix_length {
308+ // let through incomplete prefix up to the given limit
264309 outbuf. push ( c) ;
265- next_raw = false ;
266310 }
267- }
268- _ => {
311+ } else {
312+ // they bailed from the sequence,
313+ // feed everything that matched so far through
314+ if esc_pos != 0 {
315+ outbuf. extend (
316+ & esc_sequence[ escape_prefix_length..esc_pos] ,
317+ )
318+ }
319+ esc_pos = 0 ;
269320 outbuf. push ( c) ;
270- next_raw = false ;
271321 }
272322 }
273- }
274323
275- // Send what we have, even if there's a Ctrl-C at the end .
276- if !outbuf. is_empty ( ) {
277- wstx. send ( outbuf) . await . unwrap ( ) ;
278- }
324+ // Send what we have, even if we're about to exit .
325+ if !outbuf. is_empty ( ) {
326+ wstx. send ( outbuf) . await . unwrap ( ) ;
327+ }
279328
280- if exit {
281- break ;
329+ if exit {
330+ break ;
331+ }
332+ }
333+ } else {
334+ while let Some ( buf) = stdinrx. recv ( ) . await {
335+ wstx. send ( buf) . await . unwrap ( ) ;
282336 }
283337 }
284338}
@@ -290,7 +344,10 @@ async fn test_stdin_to_websockets_task() {
290344 let ( stdintx, stdinrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
291345 let ( wstx, mut wsrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
292346
293- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
347+ let escape_vector = Some ( vec ! [ 0x1d , 0x03 ] ) ;
348+ tokio:: spawn ( async move {
349+ stdin_to_websockets_task ( stdinrx, wstx, escape_vector, 0 ) . await
350+ } ) ;
294351
295352 // send characters, receive characters
296353 stdintx
@@ -300,33 +357,22 @@ async fn test_stdin_to_websockets_task() {
300357 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
301358 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test post please ignore" ) ;
302359
303- // don't send ctrl-a
304- stdintx. send ( "\x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
360+ // don't send a started escape sequence
361+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
305362 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
306363
307- // the "t" here is sent "raw" because of last ctrl-a but that doesn't change anything
364+ // since we didn't enter the \x03, the previous \x1d shows up here
308365 stdintx. send ( "test" . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
309366 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
310- assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test" ) ;
311-
312- // ctrl-a ctrl-c = only ctrl-c sent
313- stdintx. send ( "\x01 \x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
314- let actual = wsrx. recv ( ) . await . unwrap ( ) ;
315- assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x03 " ) ;
367+ assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x1d test" ) ;
316368
317- // same as above, across two messages
318- stdintx. send ( "\x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
369+ // \x03 gets sent if not preceded by \x1d
319370 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
320- assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
321371 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
322372 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x03 " ) ;
323373
324- // ctrl-a ctrl-a = only ctrl-a sent
325- stdintx. send ( "\x01 \x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
326- let actual = wsrx. recv ( ) . await . unwrap ( ) ;
327- assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x01 " ) ;
328-
329- // ctrl-c on its own means exit
374+ // \x1d followed by \x03 means exit, even if they're separate messages
375+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
330376 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
331377 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
332378
@@ -337,6 +383,8 @@ async fn test_stdin_to_websockets_task() {
337383async fn serial (
338384 addr : SocketAddr ,
339385 byte_offset : Option < i64 > ,
386+ escape_vector : Option < Vec < u8 > > ,
387+ escape_prefix_length : usize ,
340388) -> anyhow:: Result < ( ) > {
341389 let client = propolis_client:: Client :: new ( & format ! ( "http://{}" , addr) ) ;
342390 let mut req = client. instance_serial ( ) ;
@@ -379,7 +427,23 @@ async fn serial(
379427 }
380428 } ) ;
381429
382- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
430+ let escape_len = escape_vector. as_ref ( ) . map ( |x| x. len ( ) ) . unwrap_or ( 0 ) ;
431+ if escape_prefix_length > escape_len {
432+ anyhow:: bail!(
433+ "prefix length {} is greater than length of escape string ({})" ,
434+ escape_prefix_length,
435+ escape_len
436+ ) ;
437+ }
438+ tokio:: spawn ( async move {
439+ stdin_to_websockets_task (
440+ stdinrx,
441+ wstx,
442+ escape_vector,
443+ escape_prefix_length,
444+ )
445+ . await
446+ } ) ;
383447
384448 loop {
385449 tokio:: select! {
@@ -574,7 +638,20 @@ async fn main() -> anyhow::Result<()> {
574638 }
575639 Command :: Get => get_instance ( & client) . await ?,
576640 Command :: State { state } => put_instance ( & client, state) . await ?,
577- Command :: Serial { byte_offset } => serial ( addr, byte_offset) . await ?,
641+ Command :: Serial {
642+ byte_offset,
643+ escape_string,
644+ escape_prefix_length,
645+ no_escape,
646+ } => {
647+ let escape_vector = if no_escape || escape_string. is_empty ( ) {
648+ None
649+ } else {
650+ Some ( escape_string. into_vec ( ) )
651+ } ;
652+ serial ( addr, byte_offset, escape_vector, escape_prefix_length)
653+ . await ?
654+ }
578655 Command :: Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => {
579656 let dst_addr = SocketAddr :: new ( dst_server, dst_port) ;
580657 let dst_client = Client :: new ( dst_addr, log. clone ( ) ) ;
0 commit comments