@@ -17,6 +17,7 @@ use propolis_client::handmade::{
1717 } ,
1818 Client ,
1919} ;
20+ use regex:: bytes:: Regex ;
2021use slog:: { o, Drain , Level , Logger } ;
2122use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
2223use tokio_tungstenite:: tungstenite:: protocol:: Role ;
@@ -90,6 +91,28 @@ enum Command {
9091 /// Defaults to the most recent 16 KiB of console output (-16384).
9192 #[ clap( long, short) ]
9293 byte_offset : Option < i64 > ,
94+
95+ /// If this sequence of bytes is typed, the client will exit.
96+ /// Defaults to "^]^C" (Ctrl+], Ctrl+C). Note that the string passed
97+ /// for this argument must be valid UTF-8, and is used verbatim without
98+ /// any parsing; in most shells, if you wish to include a special
99+ /// character (such as Enter or a Ctrl+letter combo), you can insert
100+ /// the character by preceding it with Ctrl+V at the command line.
101+ /// To disable the escape string altogether, provide an empty string to
102+ /// this flag (and to exit in such a case, use pkill or similar).
103+ #[ clap( long, short, default_value = "\x1d \x03 " ) ]
104+ escape_string : String ,
105+
106+ /// The number of bytes from the beginning of the escape string to pass
107+ /// to the VM before beginning to buffer inputs until a mismatch.
108+ /// Defaults to 0, such that input matching the escape string does not
109+ /// get sent to the VM at all until a non-matching character is typed.
110+ /// For example, to mimic the escape sequence for exiting SSH ("\n~."),
111+ /// you may pass `-e '^M~.' --escape-prefix-length=1` such that newline
112+ /// gets sent to the VM immediately while still continuing to match the
113+ /// rest of the sequence.
114+ #[ clap( long, default_value = "0" ) ]
115+ escape_prefix_length : usize ,
93116 } ,
94117
95118 /// Migrate instance to new propolis-server
@@ -225,60 +248,28 @@ async fn put_instance(
225248async fn stdin_to_websockets_task (
226249 mut stdinrx : tokio:: sync:: mpsc:: Receiver < Vec < u8 > > ,
227250 wstx : tokio:: sync:: mpsc:: Sender < Vec < u8 > > ,
251+ mut escape : Option < EscapeSequence > ,
228252) {
229- // next_raw must live outside loop, because Ctrl-A should work across
230- // multiple inbuf reads.
231- let mut next_raw = false ;
232-
233- loop {
234- let inbuf = if let Some ( inbuf) = stdinrx. recv ( ) . await {
235- inbuf
236- } else {
237- continue ;
238- } ;
239-
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 ;
253- } else {
254- next_raw = true ;
255- }
256- }
257- b'\x03' => {
258- if !next_raw {
259- // Exit on non-raw Ctrl-C
260- exit = true ;
261- break ;
262- } else {
263- // Otherwise send Ctrl-C
264- outbuf. push ( c) ;
265- next_raw = false ;
266- }
253+ if let Some ( esc_sequence) = & mut escape {
254+ loop {
255+ if let Some ( inbuf) = stdinrx. recv ( ) . await {
256+ // process potential matches of our escape sequence to determine
257+ // whether we should exit the loop
258+ let ( outbuf, exit) = esc_sequence. process ( inbuf) ;
259+
260+ // Send what we have, even if we're about to exit.
261+ if !outbuf. is_empty ( ) {
262+ wstx. send ( outbuf) . await . unwrap ( ) ;
267263 }
268- _ => {
269- outbuf . push ( c ) ;
270- next_raw = false ;
264+
265+ if exit {
266+ break ;
271267 }
272268 }
273269 }
274-
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- }
279-
280- if exit {
281- break ;
270+ } else {
271+ while let Some ( buf) = stdinrx. recv ( ) . await {
272+ wstx. send ( buf) . await . unwrap ( ) ;
282273 }
283274 }
284275}
@@ -290,7 +281,10 @@ async fn test_stdin_to_websockets_task() {
290281 let ( stdintx, stdinrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
291282 let ( wstx, mut wsrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
292283
293- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
284+ let escape = Some ( EscapeSequence :: new ( vec ! [ 0x1d , 0x03 ] , 0 ) . unwrap ( ) ) ;
285+ tokio:: spawn ( async move {
286+ stdin_to_websockets_task ( stdinrx, wstx, escape) . await
287+ } ) ;
294288
295289 // send characters, receive characters
296290 stdintx
@@ -300,33 +294,22 @@ async fn test_stdin_to_websockets_task() {
300294 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
301295 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test post please ignore" ) ;
302296
303- // don't send ctrl-a
304- stdintx. send ( "\x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
297+ // don't send a started escape sequence
298+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
305299 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
306300
307- // the "t" here is sent "raw" because of last ctrl-a but that doesn't change anything
301+ // since we didn't enter the \x03, the previous \x1d shows up here
308302 stdintx. send ( "test" . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
309303 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
310- assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test " ) ;
304+ assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x1d test " ) ;
311305
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 " ) ;
316-
317- // same as above, across two messages
318- stdintx. send ( "\x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
306+ // \x03 gets sent if not preceded by \x1d
319307 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
320- assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
321308 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
322309 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x03 " ) ;
323310
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
311+ // \x1d followed by \x03 means exit, even if they're separate messages
312+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
330313 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
331314 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
332315
@@ -337,6 +320,7 @@ async fn test_stdin_to_websockets_task() {
337320async fn serial (
338321 addr : SocketAddr ,
339322 byte_offset : Option < i64 > ,
323+ escape : Option < EscapeSequence > ,
340324) -> anyhow:: Result < ( ) > {
341325 let client = propolis_client:: Client :: new ( & format ! ( "http://{}" , addr) ) ;
342326 let mut req = client. instance_serial ( ) ;
@@ -379,7 +363,9 @@ async fn serial(
379363 }
380364 } ) ;
381365
382- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
366+ tokio:: spawn ( async move {
367+ stdin_to_websockets_task ( stdinrx, wstx, escape) . await
368+ } ) ;
383369
384370 loop {
385371 tokio:: select! {
@@ -574,7 +560,19 @@ async fn main() -> anyhow::Result<()> {
574560 }
575561 Command :: Get => get_instance ( & client) . await ?,
576562 Command :: State { state } => put_instance ( & client, state) . await ?,
577- Command :: Serial { byte_offset } => serial ( addr, byte_offset) . await ?,
563+ Command :: Serial {
564+ byte_offset,
565+ escape_string,
566+ escape_prefix_length,
567+ } => {
568+ let escape = if escape_string. is_empty ( ) {
569+ None
570+ } else {
571+ let escape_vector = escape_string. into_bytes ( ) ;
572+ Some ( EscapeSequence :: new ( escape_vector, escape_prefix_length) ?)
573+ } ;
574+ serial ( addr, byte_offset, escape) . await ?
575+ }
578576 Command :: Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => {
579577 let dst_addr = SocketAddr :: new ( dst_server, dst_port) ;
580578 let dst_client = Client :: new ( dst_addr, log. clone ( ) ) ;
@@ -628,3 +626,109 @@ impl Drop for RawTermiosGuard {
628626 }
629627 }
630628}
629+
630+ struct EscapeSequence {
631+ bytes : Vec < u8 > ,
632+ prefix_length : usize ,
633+
634+ // the following are member variables because their values persist between
635+ // invocations of EscapeSequence::process, because the relevant bytes of
636+ // the things for which we're checking likely won't all arrive at once.
637+ // ---
638+ // position of next potential match in the escape sequence
639+ esc_pos : usize ,
640+ // buffer for accumulating characters that may be part of an ANSI Cursor
641+ // Position Report sent from xterm-likes that we should ignore (this will
642+ // otherwise render any escape sequence containing newlines before its
643+ // `prefix_length` unusable, if they're received by a shell that sends
644+ // requests for these reports for each newline received)
645+ ansi_curs_check : Vec < u8 > ,
646+ // pattern used for matching partial-to-complete versions of the above.
647+ // stored here such that it's only instantiated once at construction time.
648+ ansi_curs_pat : Regex ,
649+ }
650+
651+ impl EscapeSequence {
652+ fn new ( bytes : Vec < u8 > , prefix_length : usize ) -> anyhow:: Result < Self > {
653+ let escape_len = bytes. len ( ) ;
654+ if prefix_length > escape_len {
655+ anyhow:: bail!(
656+ "prefix length {} is greater than length of escape string ({})" ,
657+ prefix_length,
658+ escape_len
659+ ) ;
660+ }
661+ // matches partial prefixes of 'CSI row ; column R' (e.g. "\x1b[14;30R")
662+ let ansi_curs_pat = Regex :: new ( "^\x1b (\\ [([0-9]+(;([0-9]+R?)?)?)?)?$" ) ?;
663+
664+ Ok ( EscapeSequence {
665+ bytes,
666+ prefix_length,
667+ esc_pos : 0 ,
668+ ansi_curs_check : Vec :: new ( ) ,
669+ ansi_curs_pat,
670+ } )
671+ }
672+
673+ // return the bytes we can safely commit to sending to the serial port, and
674+ // determine if the user has entered the escape sequence completely.
675+ // returns true iff the program should exit.
676+ fn process ( & mut self , inbuf : Vec < u8 > ) -> ( Vec < u8 > , bool ) {
677+ // Put bytes from inbuf to outbuf, but don't send characters in the
678+ // escape string sequence unless we bail.
679+ let mut outbuf = Vec :: with_capacity ( inbuf. len ( ) ) ;
680+
681+ for c in inbuf {
682+ if !self . ignore_ansi_cpr_seq ( & mut outbuf, c) {
683+ // is this char a match for the next byte of the sequence?
684+ if c == self . bytes [ self . esc_pos ] {
685+ self . esc_pos += 1 ;
686+ if self . esc_pos == self . bytes . len ( ) {
687+ // Exit on completed escape string
688+ return ( outbuf, true ) ;
689+ } else if self . esc_pos <= self . prefix_length {
690+ // let through incomplete prefix up to the given limit
691+ outbuf. push ( c) ;
692+ }
693+ } else {
694+ // they bailed from the sequence,
695+ // feed everything that matched so far through
696+ if self . esc_pos != 0 {
697+ outbuf. extend (
698+ & self . bytes [ self . prefix_length ..self . esc_pos ] ,
699+ )
700+ }
701+ self . esc_pos = 0 ;
702+ outbuf. push ( c) ;
703+ }
704+ }
705+ }
706+ ( outbuf, false )
707+ }
708+
709+ // ignore ANSI escape sequence for the Cursor Position Report sent by
710+ // xterm-likes in response to shells requesting one after each newline.
711+ // returns true if further processing of character `c` shouldn't apply
712+ // (i.e. we find a partial or complete match of the ANSI CSR pattern)
713+ fn ignore_ansi_cpr_seq ( & mut self , outbuf : & mut Vec < u8 > , c : u8 ) -> bool {
714+ if self . esc_pos > 0
715+ && self . esc_pos <= self . prefix_length
716+ && b"\r \n " . contains ( & self . bytes [ self . esc_pos - 1 ] )
717+ {
718+ self . ansi_curs_check . push ( c) ;
719+ if self . ansi_curs_pat . is_match ( & self . ansi_curs_check ) {
720+ // end of the sequence?
721+ if c == b'R' {
722+ outbuf. extend ( & self . ansi_curs_check ) ;
723+ self . ansi_curs_check . clear ( ) ;
724+ }
725+ return true ;
726+ } else {
727+ self . ansi_curs_check . pop ( ) ; // we're not `continue`ing
728+ outbuf. extend ( & self . ansi_curs_check ) ;
729+ self . ansi_curs_check . clear ( ) ;
730+ }
731+ }
732+ false
733+ }
734+ }
0 commit comments