Skip to content

Commit 1f3ee82

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 92508d5 commit 1f3ee82

File tree

4 files changed

+141
-61
lines changed

4 files changed

+141
-61
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
@@ -96,6 +96,7 @@ propolis-server-config = { path = "crates/propolis-server-config" }
9696
propolis_types = { path = "crates/propolis-types" }
9797
quote = "1.0"
9898
rand = "0.8"
99+
regex = "1.7.1"
99100
reqwest = "0.11.12"
100101
rfb = { git = "https://github.com/oxidecomputer/rfb", rev = "0cac8d9c25eb27acfa35df80f3b9d371de98ab3b" }
101102
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: 138 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
use std::ffi::OsString;
12
use std::fs::File;
23
use std::io::BufReader;
34
use std::path::{Path, PathBuf};
45
use std::{
56
net::{IpAddr, SocketAddr, ToSocketAddrs},
6-
os::unix::prelude::AsRawFd,
7+
os::unix::prelude::{AsRawFd, OsStringExt},
78
time::Duration,
89
};
910

@@ -17,6 +18,7 @@ use propolis_client::handmade::{
1718
},
1819
Client,
1920
};
21+
use regex::bytes::Regex;
2022
use slog::{o, Drain, Level, Logger};
2123
use tokio::io::{AsyncReadExt, AsyncWriteExt};
2224
use tokio_tungstenite::tungstenite::protocol::Role;
@@ -90,6 +92,29 @@ enum Command {
9092
/// Defaults to the most recent 16 KiB of console output (-16384).
9193
#[clap(long, short)]
9294
byte_offset: Option<i64>,
95+
96+
/// If this sequence of bytes is typed, the client will exit.
97+
/// Defaults to "^]^C" (Ctrl+], Ctrl+C). Note that the string passed
98+
/// for this argument is used verbatim without any parsing; in most
99+
/// shells, if you wish to include a special character (such as Enter
100+
/// or a Ctrl+letter combo), you can insert the character by preceding
101+
/// it with Ctrl+V at the command line.
102+
#[clap(long, short, default_value = "\x1d\x03")]
103+
escape_string: OsString,
104+
105+
/// The number of bytes from the beginning of the escape string to pass
106+
/// to the VM before beginning to buffer inputs until a mismatch.
107+
/// Defaults to 0, such that input matching the escape string does not
108+
/// get sent to the VM at all until a non-matching character is typed.
109+
/// To mimic the escape sequence for exiting SSH (Enter, tilde, dot),
110+
/// you may pass `-e '^M~.' --escape-prefix-length=1` such that normal
111+
/// Enter presses are sent to the VM immediately.
112+
#[clap(long, default_value = "0")]
113+
escape_prefix_length: usize,
114+
115+
/// Disable escape string altogether (to exit, use pkill or similar).
116+
#[clap(long, short = 'E')]
117+
no_escape: bool,
93118
},
94119

95120
/// Migrate instance to new propolis-server
@@ -221,60 +246,89 @@ async fn put_instance(
221246
async fn stdin_to_websockets_task(
222247
mut stdinrx: tokio::sync::mpsc::Receiver<Vec<u8>>,
223248
wstx: tokio::sync::mpsc::Sender<Vec<u8>>,
249+
escape_vector: Option<Vec<u8>>,
250+
escape_prefix_length: usize,
224251
) {
225-
// next_raw must live outside loop, because Ctrl-A should work across
226-
// multiple inbuf reads.
227-
let mut next_raw = false;
252+
if let Some(esc_sequence) = &escape_vector {
253+
// esc_pos must live outside loop, because escape string should work
254+
// across multiple inbuf reads.
255+
let mut esc_pos = 0;
228256

229-
loop {
230-
let inbuf = if let Some(inbuf) = stdinrx.recv().await {
231-
inbuf
232-
} else {
233-
continue;
234-
};
257+
// matches partial increments of "\x1b[14;30R"
258+
let ansi_curs_pat =
259+
Regex::new("^\x1b(\\[([0-9]{1,2}(;([0-9]{1,2}R?)?)?)?)?$").unwrap();
260+
let mut ansi_curs_check = Vec::new();
235261

236-
// Put bytes from inbuf to outbuf, but don't send Ctrl-A unless
237-
// next_raw is true.
238-
let mut outbuf = Vec::with_capacity(inbuf.len());
239-
240-
let mut exit = false;
241-
for c in inbuf {
242-
match c {
243-
// Ctrl-A means send next one raw
244-
b'\x01' => {
245-
if next_raw {
246-
// Ctrl-A Ctrl-A should be sent as Ctrl-A
247-
outbuf.push(c);
248-
next_raw = false;
262+
loop {
263+
let inbuf = if let Some(inbuf) = stdinrx.recv().await {
264+
inbuf
265+
} else {
266+
continue;
267+
};
268+
269+
// Put bytes from inbuf to outbuf, but don't send characters in the
270+
// escape string sequence unless we bail.
271+
let mut outbuf = Vec::with_capacity(inbuf.len());
272+
273+
let mut exit = false;
274+
for c in inbuf {
275+
// ignore ANSI escape sequence for the cursor position
276+
// response sent by xterm-alikes in response to shells
277+
// requesting one after receiving a newline.
278+
if esc_pos > 0
279+
&& esc_pos <= escape_prefix_length
280+
&& b"\r\n".contains(&esc_sequence[esc_pos - 1])
281+
{
282+
ansi_curs_check.push(c);
283+
if ansi_curs_pat.is_match(&ansi_curs_check) {
284+
// end of the sequence?
285+
if c == b'R' {
286+
outbuf.extend(&ansi_curs_check);
287+
ansi_curs_check.clear();
288+
}
289+
continue;
249290
} else {
250-
next_raw = true;
291+
ansi_curs_check.pop(); // we're not `continue`ing
292+
outbuf.extend(&ansi_curs_check);
293+
ansi_curs_check.clear();
251294
}
252295
}
253-
b'\x03' => {
254-
if !next_raw {
255-
// Exit on non-raw Ctrl-C
296+
297+
if c == esc_sequence[esc_pos] {
298+
esc_pos += 1;
299+
if esc_pos == esc_sequence.len() {
300+
// Exit on completed escape string
256301
exit = true;
257302
break;
258-
} else {
259-
// Otherwise send Ctrl-C
303+
} else if esc_pos <= escape_prefix_length {
304+
// let through incomplete prefix up to the given limit
260305
outbuf.push(c);
261-
next_raw = false;
262306
}
263-
}
264-
_ => {
307+
} else {
308+
// they bailed from the sequence,
309+
// feed everything that matched so far through
310+
if esc_pos != 0 {
311+
outbuf.extend(
312+
&esc_sequence[escape_prefix_length..esc_pos],
313+
)
314+
}
315+
esc_pos = 0;
265316
outbuf.push(c);
266-
next_raw = false;
267317
}
268318
}
269-
}
270319

271-
// Send what we have, even if there's a Ctrl-C at the end.
272-
if !outbuf.is_empty() {
273-
wstx.send(outbuf).await.unwrap();
274-
}
320+
// Send what we have, even if we're about to exit.
321+
if !outbuf.is_empty() {
322+
wstx.send(outbuf).await.unwrap();
323+
}
275324

276-
if exit {
277-
break;
325+
if exit {
326+
break;
327+
}
328+
}
329+
} else {
330+
while let Some(buf) = stdinrx.recv().await {
331+
wstx.send(buf).await.unwrap();
278332
}
279333
}
280334
}
@@ -286,7 +340,10 @@ async fn test_stdin_to_websockets_task() {
286340
let (stdintx, stdinrx) = tokio::sync::mpsc::channel(16);
287341
let (wstx, mut wsrx) = tokio::sync::mpsc::channel(16);
288342

289-
tokio::spawn(async move { stdin_to_websockets_task(stdinrx, wstx).await });
343+
let escape_vector = Some(vec![0x1d, 0x03]);
344+
tokio::spawn(async move {
345+
stdin_to_websockets_task(stdinrx, wstx, escape_vector, 0).await
346+
});
290347

291348
// send characters, receive characters
292349
stdintx
@@ -296,33 +353,22 @@ async fn test_stdin_to_websockets_task() {
296353
let actual = wsrx.recv().await.unwrap();
297354
assert_eq!(String::from_utf8(actual).unwrap(), "test post please ignore");
298355

299-
// don't send ctrl-a
300-
stdintx.send("\x01".chars().map(|c| c as u8).collect()).await.unwrap();
356+
// don't send a started escape sequence
357+
stdintx.send("\x1d".chars().map(|c| c as u8).collect()).await.unwrap();
301358
assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty));
302359

303-
// the "t" here is sent "raw" because of last ctrl-a but that doesn't change anything
360+
// since we didn't enter the \x03, the previous \x1d shows up here
304361
stdintx.send("test".chars().map(|c| c as u8).collect()).await.unwrap();
305362
let actual = wsrx.recv().await.unwrap();
306-
assert_eq!(String::from_utf8(actual).unwrap(), "test");
307-
308-
// ctrl-a ctrl-c = only ctrl-c sent
309-
stdintx.send("\x01\x03".chars().map(|c| c as u8).collect()).await.unwrap();
310-
let actual = wsrx.recv().await.unwrap();
311-
assert_eq!(String::from_utf8(actual).unwrap(), "\x03");
363+
assert_eq!(String::from_utf8(actual).unwrap(), "\x1dtest");
312364

313-
// same as above, across two messages
314-
stdintx.send("\x01".chars().map(|c| c as u8).collect()).await.unwrap();
365+
// \x03 gets sent if not preceded by \x1d
315366
stdintx.send("\x03".chars().map(|c| c as u8).collect()).await.unwrap();
316-
assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty));
317367
let actual = wsrx.recv().await.unwrap();
318368
assert_eq!(String::from_utf8(actual).unwrap(), "\x03");
319369

320-
// ctrl-a ctrl-a = only ctrl-a sent
321-
stdintx.send("\x01\x01".chars().map(|c| c as u8).collect()).await.unwrap();
322-
let actual = wsrx.recv().await.unwrap();
323-
assert_eq!(String::from_utf8(actual).unwrap(), "\x01");
324-
325-
// ctrl-c on its own means exit
370+
// \x1d followed by \x03 means exit, even if they're separate messages
371+
stdintx.send("\x1d".chars().map(|c| c as u8).collect()).await.unwrap();
326372
stdintx.send("\x03".chars().map(|c| c as u8).collect()).await.unwrap();
327373
assert_eq!(wsrx.try_recv(), Err(TryRecvError::Empty));
328374

@@ -333,6 +379,8 @@ async fn test_stdin_to_websockets_task() {
333379
async fn serial(
334380
addr: SocketAddr,
335381
byte_offset: Option<i64>,
382+
escape_vector: Option<Vec<u8>>,
383+
escape_prefix_length: usize,
336384
) -> anyhow::Result<()> {
337385
let client = propolis_client::Client::new(&format!("http://{}", addr));
338386
let mut req = client.instance_serial();
@@ -375,7 +423,23 @@ async fn serial(
375423
}
376424
});
377425

378-
tokio::spawn(async move { stdin_to_websockets_task(stdinrx, wstx).await });
426+
let escape_len = escape_vector.as_ref().map(|x| x.len()).unwrap_or(0);
427+
if escape_prefix_length > escape_len {
428+
anyhow::bail!(
429+
"prefix length {} is greater than length of escape string ({})",
430+
escape_prefix_length,
431+
escape_len
432+
);
433+
}
434+
tokio::spawn(async move {
435+
stdin_to_websockets_task(
436+
stdinrx,
437+
wstx,
438+
escape_vector,
439+
escape_prefix_length,
440+
)
441+
.await
442+
});
379443

380444
loop {
381445
tokio::select! {
@@ -569,7 +633,20 @@ async fn main() -> anyhow::Result<()> {
569633
}
570634
Command::Get => get_instance(&client).await?,
571635
Command::State { state } => put_instance(&client, state).await?,
572-
Command::Serial { byte_offset } => serial(addr, byte_offset).await?,
636+
Command::Serial {
637+
byte_offset,
638+
escape_string,
639+
escape_prefix_length,
640+
no_escape,
641+
} => {
642+
let escape_vector = if no_escape || escape_string.is_empty() {
643+
None
644+
} else {
645+
Some(escape_string.into_vec())
646+
};
647+
serial(addr, byte_offset, escape_vector, escape_prefix_length)
648+
.await?
649+
}
573650
Command::Migrate { dst_server, dst_port, dst_uuid } => {
574651
let dst_addr = SocketAddr::new(dst_server, dst_port);
575652
let dst_client = Client::new(dst_addr, log.clone());

0 commit comments

Comments
 (0)