Skip to content

Commit 69f2578

Browse files
author
lif
committed
Make the mechanism for escaping discoverable and customizable
This changes the method of entering an escape sequence: - raw Ctrl+C gets sent to the VM unimpeded. - by default, the sequence Ctrl+], Ctrl+C is used to quit the program (`^]^C`) - this can be customized or removed via CLI flags, allowing the string be of arbitrary length. - i.e. if you `propolis-cli serial -e "beans"` and then type "bea", nothing gets sent to the VM after the "b" yet. and then if you type: 1. "k", the VM gets sent "beak" 2. '"ns", the VM doesn't get sent anything else, and the client exits. - the client can be configured to pass through an arbitrary prefix length of the escape string before it starts suppressing inputs, such that you can, for example, mimic ssh's Enter-tilde-dot sequence without temporarily suppressing Enter presses not intended to start an escape sequence, which would interfere with function: `-e '^M~.' --escape-prefix-length=1` (this also works around ANSI escape sequences being sent by xterm-like emulators when Enter is pressed in a shell that sends a request for such)
1 parent 4d4e5af commit 69f2578

File tree

4 files changed

+177
-70
lines changed

4 files changed

+177
-70
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ propolis-standalone-config = { path = "crates/propolis-standalone-config" }
101101
propolis_types = { path = "crates/propolis-types" }
102102
quote = "1.0"
103103
rand = "0.8"
104+
regex = "1.7.1"
104105
reqwest = { version = "0.11.12", default-features = false }
105106
rfb = { git = "https://github.com/oxidecomputer/rfb", rev = "0cac8d9c25eb27acfa35df80f3b9d371de98ab3b" }
106107
ring = "0.16"

bin/propolis-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ anyhow.workspace = true
1111
clap = { workspace = true, features = ["derive"] }
1212
futures.workspace = true
1313
libc.workspace = true
14+
regex.workspace = true
1415
propolis-client = { workspace = true, features = ["generated"] }
1516
slog.workspace = true
1617
slog-async.workspace = true

bin/propolis-cli/src/main.rs

Lines changed: 174 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use propolis_client::handmade::{
1717
},
1818
Client,
1919
};
20+
use regex::bytes::Regex;
2021
use slog::{o, Drain, Level, Logger};
2122
use tokio::io::{AsyncReadExt, AsyncWriteExt};
2223
use 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(
225248
async 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(), "\x1dtest");
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() {
337320
async 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

Comments
 (0)