@@ -17,6 +17,7 @@ use propolis_client::handmade::{
17
17
} ,
18
18
Client ,
19
19
} ;
20
+ use regex:: bytes:: Regex ;
20
21
use slog:: { o, Drain , Level , Logger } ;
21
22
use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
22
23
use tokio_tungstenite:: tungstenite:: protocol:: Role ;
@@ -90,6 +91,28 @@ enum Command {
90
91
/// Defaults to the most recent 16 KiB of console output (-16384).
91
92
#[ clap( long, short) ]
92
93
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 ,
93
116
} ,
94
117
95
118
/// Migrate instance to new propolis-server
@@ -225,60 +248,28 @@ async fn put_instance(
225
248
async fn stdin_to_websockets_task (
226
249
mut stdinrx : tokio:: sync:: mpsc:: Receiver < Vec < u8 > > ,
227
250
wstx : tokio:: sync:: mpsc:: Sender < Vec < u8 > > ,
251
+ mut escape : Option < EscapeSequence > ,
228
252
) {
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 ( ) ;
267
263
}
268
- _ => {
269
- outbuf . push ( c ) ;
270
- next_raw = false ;
264
+
265
+ if exit {
266
+ break ;
271
267
}
272
268
}
273
269
}
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 ( ) ;
282
273
}
283
274
}
284
275
}
@@ -290,7 +281,10 @@ async fn test_stdin_to_websockets_task() {
290
281
let ( stdintx, stdinrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
291
282
let ( wstx, mut wsrx) = tokio:: sync:: mpsc:: channel ( 16 ) ;
292
283
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
+ } ) ;
294
288
295
289
// send characters, receive characters
296
290
stdintx
@@ -300,33 +294,22 @@ async fn test_stdin_to_websockets_task() {
300
294
let actual = wsrx. recv ( ) . await . unwrap ( ) ;
301
295
assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "test post please ignore" ) ;
302
296
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 ( ) ;
305
299
assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
306
300
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
308
302
stdintx. send ( "test" . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
309
303
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 " ) ;
311
305
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
319
307
stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
320
- assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
321
308
let actual = wsrx. recv ( ) . await . unwrap ( ) ;
322
309
assert_eq ! ( String :: from_utf8( actual) . unwrap( ) , "\x03 " ) ;
323
310
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 ( ) ;
330
313
stdintx. send ( "\x03 " . chars ( ) . map ( |c| c as u8 ) . collect ( ) ) . await . unwrap ( ) ;
331
314
assert_eq ! ( wsrx. try_recv( ) , Err ( TryRecvError :: Empty ) ) ;
332
315
@@ -337,6 +320,7 @@ async fn test_stdin_to_websockets_task() {
337
320
async fn serial (
338
321
addr : SocketAddr ,
339
322
byte_offset : Option < i64 > ,
323
+ escape : Option < EscapeSequence > ,
340
324
) -> anyhow:: Result < ( ) > {
341
325
let client = propolis_client:: Client :: new ( & format ! ( "http://{}" , addr) ) ;
342
326
let mut req = client. instance_serial ( ) ;
@@ -379,7 +363,9 @@ async fn serial(
379
363
}
380
364
} ) ;
381
365
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
+ } ) ;
383
369
384
370
loop {
385
371
tokio:: select! {
@@ -574,7 +560,19 @@ async fn main() -> anyhow::Result<()> {
574
560
}
575
561
Command :: Get => get_instance ( & client) . await ?,
576
562
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
+ }
578
576
Command :: Migrate { dst_server, dst_port, dst_uuid, crucible_disks } => {
579
577
let dst_addr = SocketAddr :: new ( dst_server, dst_port) ;
580
578
let dst_client = Client :: new ( dst_addr, log. clone ( ) ) ;
@@ -628,3 +626,109 @@ impl Drop for RawTermiosGuard {
628
626
}
629
627
}
630
628
}
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