@@ -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,29 @@ 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+ #[ clap( long, short, default_value = "\x1d \x03 " ) ]
102+ escape_string : String ,
103+
104+ /// The number of bytes from the beginning of the escape string to pass
105+ /// to the VM before beginning to buffer inputs until a mismatch.
106+ /// Defaults to 0, such that input matching the escape string does not
107+ /// get sent to the VM at all until a non-matching character is typed.
108+ /// To mimic the escape sequence for exiting SSH ("\n~."),you may pass
109+ /// `-e '^M~.' --escape-prefix-length=1` such that newlines are sent to
110+ /// the VM immediately.
111+ #[ clap( long, default_value = "0" ) ]
112+ escape_prefix_length : usize ,
113+
114+ /// Disable escape string altogether (to exit, use pkill or similar).
115+ #[ clap( long, short = 'E' ) ]
116+ no_escape : bool ,
93117 } ,
94118
95119 /// Migrate instance to new propolis-server
@@ -225,60 +249,28 @@ async fn put_instance(
225249async fn stdin_to_websockets_task (
226250 mut stdinrx : tokio:: sync:: mpsc:: Receiver < Vec < u8 > > ,
227251 wstx : tokio:: sync:: mpsc:: Sender < Vec < u8 > > ,
252+ mut escape : Option < EscapeSequence > ,
228253) {
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- }
254+ if let Some ( esc_sequence) = & mut escape {
255+ loop {
256+ if let Some ( inbuf) = stdinrx. recv ( ) . await {
257+ // process potential matches of our escape sequence to determine
258+ // whether we should exit the loop
259+ let ( outbuf, exit) = esc_sequence. process ( inbuf) ;
260+
261+ // Send what we have, even if we're about to exit.
262+ if !outbuf. is_empty ( ) {
263+ wstx. send ( outbuf) . await . unwrap ( ) ;
267264 }
268- _ => {
269- outbuf . push ( c ) ;
270- next_raw = false ;
265+
266+ if exit {
267+ break ;
271268 }
272269 }
273270 }
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 ;
271+ } else {
272+ while let Some ( buf) = stdinrx. recv ( ) . await {
273+ wstx. send ( buf) . await . unwrap ( ) ;
282274 }
283275 }
284276}
@@ -290,7 +282,10 @@ async fn test_stdin_to_websockets_task() {
290282 let ( stdintx, stdinrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
291283 let ( wstx, mut wsrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
292284
293- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
285+ let escape = Some ( EscapeSequence :: new ( vec ! [ 0x1d , 0x03 ] , 0 ) . unwrap ( ) ) ;
286+ tokio:: spawn ( async move {
287+ stdin_to_websockets_task ( stdinrx, wstx, escape) . await
288+ } ) ;
294289
295290 // send characters, receive characters
296291 stdintx
@@ -300,33 +295,22 @@ async fn test_stdin_to_websockets_task() {
300295 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
301296 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test post please ignore" ) ;
302297
303- // don't send ctrl-a
304- stdintx. send ( "\x01 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
298+ // don't send a started escape sequence
299+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
305300 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
306301
307- // the "t" here is sent "raw" because of last ctrl-a but that doesn't change anything
302+ // since we didn't enter the \x03, the previous \x1d shows up here
308303 stdintx. send ( "test" . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
309304 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
310- assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test " ) ;
305+ assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x1d test " ) ;
311306
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 ( ) ;
307+ // \x03 gets sent if not preceded by \x1d
319308 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
320- assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
321309 let actual = wsrx. recv ( ) . await . unwrap ( ) ;
322310 assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x03 " ) ;
323311
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
312+ // \x1d followed by \x03 means exit, even if they're separate messages
313+ stdintx. send ( "\x1d " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
330314 stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
331315 assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
332316
@@ -337,6 +321,7 @@ async fn test_stdin_to_websockets_task() {
337321async fn serial (
338322 addr : SocketAddr ,
339323 byte_offset : Option < i64 > ,
324+ escape : Option < EscapeSequence > ,
340325) -> anyhow:: Result < ( ) > {
341326 let client = propolis_client:: Client :: new ( & format ! ( "http://{}" , addr) ) ;
342327 let mut req = client. instance_serial ( ) ;
@@ -379,7 +364,9 @@ async fn serial(
379364 }
380365 } ) ;
381366
382- tokio:: spawn ( async move { stdin_to_websockets_task ( stdinrx, wstx) . await } ) ;
367+ tokio:: spawn ( async move {
368+ stdin_to_websockets_task ( stdinrx, wstx, escape) . await
369+ } ) ;
383370
384371 loop {
385372 tokio:: select! {
@@ -574,7 +561,20 @@ async fn main() -> anyhow::Result<()> {
574561 }
575562 Command :: Get => get_instance ( & client) . await ?,
576563 Command :: State { state } => put_instance ( & client, state) . await ?,
577- Command :: Serial { byte_offset } => serial ( addr, byte_offset) . await ?,
564+ Command :: Serial {
565+ byte_offset,
566+ escape_string,
567+ escape_prefix_length,
568+ no_escape,
569+ } => {
570+ let escape = if no_escape || escape_string. is_empty ( ) {
571+ None
572+ } else {
573+ let escape_vector = escape_string. into_bytes ( ) ;
574+ Some ( EscapeSequence :: new ( escape_vector, escape_prefix_length) ?)
575+ } ;
576+ serial ( addr, byte_offset, escape) . await ?
577+ }
578578 Command :: Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => {
579579 let dst_addr = SocketAddr :: new ( dst_server, dst_port) ;
580580 let dst_client = Client :: new ( dst_addr, log. clone ( ) ) ;
@@ -628,3 +628,109 @@ impl Drop for RawTermiosGuard {
628628 }
629629 }
630630}
631+
632+ struct EscapeSequence {
633+ bytes : Vec < u8 > ,
634+ prefix_length : usize ,
635+
636+ // the following are member variables because their values persist between
637+ // invocations of EscapeSequence::process, because the relevant bytes of
638+ // the things for which we're checking likely won't all arrive at once.
639+ // ---
640+ // position of next potential match in the escape sequence
641+ esc_pos : usize ,
642+ // buffer for accumulating characters that may be part of an ANSI Cursor
643+ // Position Report sent from xterm-likes that we should ignore (this will
644+ // otherwise render any escape sequence containing newlines before its
645+ // `prefix_length` unusable, if they're received by a shell that sends
646+ // requests for these reports for each newline received)
647+ ansi_curs_check : Vec < u8 > ,
648+ // pattern used for matching partial-to-complete versions of the above.
649+ // stored here such that it's only instantiated once at construction time.
650+ ansi_curs_pat : Regex ,
651+ }
652+
653+ impl EscapeSequence {
654+ fn new ( bytes : Vec < u8 > , prefix_length : usize ) -> anyhow:: Result < Self > {
655+ let escape_len = bytes. len ( ) ;
656+ if prefix_length > escape_len {
657+ anyhow:: bail!(
658+ "prefix length {} is greater than length of escape string ({})" ,
659+ prefix_length,
660+ escape_len
661+ ) ;
662+ }
663+ // matches partial prefixes of 'CSI row ; column R' (e.g. "\x1b[14;30R")
664+ let ansi_curs_pat = Regex :: new ( "^\x1b (\\ [([0-9]+(;([0-9]+R?)?)?)?)?$" ) ?;
665+
666+ Ok ( EscapeSequence {
667+ bytes,
668+ prefix_length,
669+ esc_pos : 0 ,
670+ ansi_curs_check : Vec :: new ( ) ,
671+ ansi_curs_pat,
672+ } )
673+ }
674+
675+ // return the bytes we can safely commit to sending to the serial port, and
676+ // determine if the user has entered the escape sequence completely.
677+ // returns true iff the program should exit.
678+ fn process ( & mut self , inbuf : Vec < u8 > ) -> ( Vec < u8 > , bool ) {
679+ // Put bytes from inbuf to outbuf, but don't send characters in the
680+ // escape string sequence unless we bail.
681+ let mut outbuf = Vec :: with_capacity ( inbuf. len ( ) ) ;
682+
683+ for c in inbuf {
684+ if !self . ignore_ansi_cpr_seq ( & mut outbuf, c) {
685+ // is this char a match for the next byte of the sequence?
686+ if c == self . bytes [ self . esc_pos ] {
687+ self . esc_pos += 1 ;
688+ if self . esc_pos == self . bytes . len ( ) {
689+ // Exit on completed escape string
690+ return ( outbuf, true ) ;
691+ } else if self . esc_pos <= self . prefix_length {
692+ // let through incomplete prefix up to the given limit
693+ outbuf. push ( c) ;
694+ }
695+ } else {
696+ // they bailed from the sequence,
697+ // feed everything that matched so far through
698+ if self . esc_pos != 0 {
699+ outbuf. extend (
700+ & self . bytes [ self . prefix_length ..self . esc_pos ] ,
701+ )
702+ }
703+ self . esc_pos = 0 ;
704+ outbuf. push ( c) ;
705+ }
706+ }
707+ }
708+ ( outbuf, false )
709+ }
710+
711+ // ignore ANSI escape sequence for the Cursor Position Report sent by
712+ // xterm-likes in response to shells requesting one after each newline.
713+ // returns true if further processing of character `c` shouldn't apply
714+ // (i.e. we find a partial or complete match of the ANSI CSR pattern)
715+ fn ignore_ansi_cpr_seq ( & mut self , outbuf : & mut Vec < u8 > , c : u8 ) -> bool {
716+ if self . esc_pos > 0
717+ && self . esc_pos <= self . prefix_length
718+ && b"\r \n " . contains ( & self . bytes [ self . esc_pos - 1 ] )
719+ {
720+ self . ansi_curs_check . push ( c) ;
721+ if self . ansi_curs_pat . is_match ( & self . ansi_curs_check ) {
722+ // end of the sequence?
723+ if c == b'R' {
724+ outbuf. extend ( & self . ansi_curs_check ) ;
725+ self . ansi_curs_check . clear ( ) ;
726+ }
727+ return true ;
728+ } else {
729+ self . ansi_curs_check . pop ( ) ; // we're not `continue`ing
730+ outbuf. extend ( & self . ansi_curs_check ) ;
731+ self . ansi_curs_check . clear ( ) ;
732+ }
733+ }
734+ false
735+ }
736+ }
0 commit comments