@@ -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! {
@@ -566,7 +552,19 @@ async fn main() -> anyhow::Result<()> {
566552 }
567553 Command :: Get => get_instance ( & client) . await ?,
568554 Command :: State { state } => put_instance ( & client, state) . await ?,
569- Command :: Serial { byte_offset } => serial ( addr, byte_offset) . await ?,
555+ Command :: Serial {
556+ byte_offset,
557+ escape_string,
558+ escape_prefix_length,
559+ } => {
560+ let escape = if escape_string. is_empty ( ) {
561+ None
562+ } else {
563+ let escape_vector = escape_string. into_bytes ( ) ;
564+ Some ( EscapeSequence :: new ( escape_vector, escape_prefix_length) ?)
565+ } ;
566+ serial ( addr, byte_offset, escape) . await ?
567+ }
570568 Command :: Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => {
571569 let dst_addr = SocketAddr :: new ( dst_server, dst_port) ;
572570 let dst_client = Client :: new ( dst_addr, log. clone ( ) ) ;
@@ -620,3 +618,109 @@ impl Drop for RawTermiosGuard {
620618 }
621619 }
622620}
621+
622+ struct EscapeSequence {
623+ bytes : Vec < u8 > ,
624+ prefix_length : usize ,
625+
626+ // the following are member variables because their values persist between
627+ // invocations of EscapeSequence::process, because the relevant bytes of
628+ // the things for which we're checking likely won't all arrive at once.
629+ // ---
630+ // position of next potential match in the escape sequence
631+ esc_pos : usize ,
632+ // buffer for accumulating characters that may be part of an ANSI Cursor
633+ // Position Report sent from xterm-likes that we should ignore (this will
634+ // otherwise render any escape sequence containing newlines before its
635+ // `prefix_length` unusable, if they're received by a shell that sends
636+ // requests for these reports for each newline received)
637+ ansi_curs_check : Vec < u8 > ,
638+ // pattern used for matching partial-to-complete versions of the above.
639+ // stored here such that it's only instantiated once at construction time.
640+ ansi_curs_pat : Regex ,
641+ }
642+
643+ impl EscapeSequence {
644+ fn new ( bytes : Vec < u8 > , prefix_length : usize ) -> anyhow:: Result < Self > {
645+ let escape_len = bytes. len ( ) ;
646+ if prefix_length > escape_len {
647+ anyhow:: bail!(
648+ "prefix length {} is greater than length of escape string ({})" ,
649+ prefix_length,
650+ escape_len
651+ ) ;
652+ }
653+ // matches partial prefixes of 'CSI row ; column R' (e.g. "\x1b[14;30R")
654+ let ansi_curs_pat = Regex :: new ( "^\x1b (\\ [([0-9]+(;([0-9]+R?)?)?)?)?$" ) ?;
655+
656+ Ok ( EscapeSequence {
657+ bytes,
658+ prefix_length,
659+ esc_pos : 0 ,
660+ ansi_curs_check : Vec :: new ( ) ,
661+ ansi_curs_pat,
662+ } )
663+ }
664+
665+ // return the bytes we can safely commit to sending to the serial port, and
666+ // determine if the user has entered the escape sequence completely.
667+ // returns true iff the program should exit.
668+ fn process ( & mut self , inbuf : Vec < u8 > ) -> ( Vec < u8 > , bool ) {
669+ // Put bytes from inbuf to outbuf, but don't send characters in the
670+ // escape string sequence unless we bail.
671+ let mut outbuf = Vec :: with_capacity ( inbuf. len ( ) ) ;
672+
673+ for c in inbuf {
674+ if !self . ignore_ansi_cpr_seq ( & mut outbuf, c) {
675+ // is this char a match for the next byte of the sequence?
676+ if c == self . bytes [ self . esc_pos ] {
677+ self . esc_pos += 1 ;
678+ if self . esc_pos == self . bytes . len ( ) {
679+ // Exit on completed escape string
680+ return ( outbuf, true ) ;
681+ } else if self . esc_pos <= self . prefix_length {
682+ // let through incomplete prefix up to the given limit
683+ outbuf. push ( c) ;
684+ }
685+ } else {
686+ // they bailed from the sequence,
687+ // feed everything that matched so far through
688+ if self . esc_pos != 0 {
689+ outbuf. extend (
690+ & self . bytes [ self . prefix_length ..self . esc_pos ] ,
691+ )
692+ }
693+ self . esc_pos = 0 ;
694+ outbuf. push ( c) ;
695+ }
696+ }
697+ }
698+ ( outbuf, false )
699+ }
700+
701+ // ignore ANSI escape sequence for the Cursor Position Report sent by
702+ // xterm-likes in response to shells requesting one after each newline.
703+ // returns true if further processing of character `c` shouldn't apply
704+ // (i.e. we find a partial or complete match of the ANSI CSR pattern)
705+ fn ignore_ansi_cpr_seq ( & mut self , outbuf : & mut Vec < u8 > , c : u8 ) -> bool {
706+ if self . esc_pos > 0
707+ && self . esc_pos <= self . prefix_length
708+ && b"\r \n " . contains ( & self . bytes [ self . esc_pos - 1 ] )
709+ {
710+ self . ansi_curs_check . push ( c) ;
711+ if self . ansi_curs_pat . is_match ( & self . ansi_curs_check ) {
712+ // end of the sequence?
713+ if c == b'R' {
714+ outbuf. extend ( & self . ansi_curs_check ) ;
715+ self . ansi_curs_check . clear ( ) ;
716+ }
717+ return true ;
718+ } else {
719+ self . ansi_curs_check . pop ( ) ; // we're not `continue`ing
720+ outbuf. extend ( & self . ansi_curs_check ) ;
721+ self . ansi_curs_check . clear ( ) ;
722+ }
723+ }
724+ false
725+ }
726+ }
0 commit comments