Skip to content

Commit ed74a19

Browse files
authored
fix(sandbox): track PTY state per SSH channel to fix terminal resize (#687)
Replace flat pty_master/input_sender/pty_request fields in SshHandler with a HashMap<ChannelId, ChannelState> so each channel tracks its own PTY resources independently. This fixes window_change_request resizing the wrong PTY when multiple channels are open simultaneously. Also fixes ioctl UB in set_winsize (pass &winsize not winsize by value) and adds warn! logging for unknown channels across all handlers. Resolves #543
1 parent 36329a1 commit ed74a19

File tree

1 file changed

+177
-21
lines changed
  • crates/openshell-sandbox/src

1 file changed

+177
-21
lines changed

crates/openshell-sandbox/src/ssh.rs

Lines changed: 177 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,27 @@ fn hmac_sha256(key: &[u8], data: &[u8]) -> String {
263263
hex::encode(result)
264264
}
265265

266+
/// Per-channel state for tracking PTY resources and I/O senders.
267+
///
268+
/// Each SSH channel gets its own PTY master (if a PTY was requested) and input
269+
/// sender. This allows `window_change_request` to resize the correct PTY when
270+
/// multiple channels are open simultaneously (e.g. parallel shells, shell +
271+
/// sftp, etc.).
272+
#[derive(Default)]
273+
struct ChannelState {
274+
input_sender: Option<mpsc::Sender<Vec<u8>>>,
275+
pty_master: Option<std::fs::File>,
276+
pty_request: Option<PtyRequest>,
277+
}
278+
266279
struct SshHandler {
267280
policy: SandboxPolicy,
268281
workdir: Option<String>,
269282
netns_fd: Option<RawFd>,
270283
proxy_url: Option<String>,
271284
ca_file_paths: Option<Arc<(PathBuf, PathBuf)>>,
272285
provider_env: HashMap<String, String>,
273-
input_sender: Option<mpsc::Sender<Vec<u8>>>,
274-
pty_master: Option<std::fs::File>,
275-
pty_request: Option<PtyRequest>,
286+
channels: HashMap<ChannelId, ChannelState>,
276287
}
277288

278289
impl SshHandler {
@@ -291,9 +302,7 @@ impl SshHandler {
291302
proxy_url,
292303
ca_file_paths,
293304
provider_env,
294-
input_sender: None,
295-
pty_master: None,
296-
pty_request: None,
305+
channels: HashMap::new(),
297306
}
298307
}
299308
}
@@ -315,12 +324,27 @@ impl russh::server::Handler for SshHandler {
315324

316325
async fn channel_open_session(
317326
&mut self,
318-
_channel: russh::Channel<russh::server::Msg>,
327+
channel: russh::Channel<russh::server::Msg>,
319328
_session: &mut Session,
320329
) -> Result<bool, Self::Error> {
330+
self.channels.insert(channel.id(), ChannelState::default());
321331
Ok(true)
322332
}
323333

334+
/// Clean up per-channel state when the channel is closed.
335+
///
336+
/// This is the final cleanup and subsumes `channel_eof` — if `channel_close`
337+
/// fires without a preceding `channel_eof`, all resources (pty_master File,
338+
/// input_sender) are dropped here.
339+
async fn channel_close(
340+
&mut self,
341+
channel: ChannelId,
342+
_session: &mut Session,
343+
) -> Result<(), Self::Error> {
344+
self.channels.remove(&channel);
345+
Ok(())
346+
}
347+
324348
async fn channel_open_direct_tcpip(
325349
&mut self,
326350
channel: russh::Channel<russh::server::Msg>,
@@ -388,7 +412,11 @@ impl russh::server::Handler for SshHandler {
388412
_modes: &[(russh::Pty, u32)],
389413
session: &mut Session,
390414
) -> Result<(), Self::Error> {
391-
self.pty_request = Some(PtyRequest {
415+
let state = self
416+
.channels
417+
.get_mut(&channel)
418+
.ok_or_else(|| anyhow::anyhow!("pty_request on unknown channel {channel:?}"))?;
419+
state.pty_request = Some(PtyRequest {
392420
term: term.to_string(),
393421
col_width,
394422
row_height,
@@ -401,21 +429,27 @@ impl russh::server::Handler for SshHandler {
401429

402430
async fn window_change_request(
403431
&mut self,
404-
_channel: ChannelId,
432+
channel: ChannelId,
405433
col_width: u32,
406434
row_height: u32,
407435
pixel_width: u32,
408436
pixel_height: u32,
409437
_session: &mut Session,
410438
) -> Result<(), Self::Error> {
411-
if let Some(master) = self.pty_master.as_ref() {
439+
let Some(state) = self.channels.get(&channel) else {
440+
warn!("window_change_request on unknown channel {channel:?}");
441+
return Ok(());
442+
};
443+
if let Some(master) = state.pty_master.as_ref() {
412444
let winsize = Winsize {
413445
ws_row: to_u16(row_height.max(1)),
414446
ws_col: to_u16(col_width.max(1)),
415447
ws_xpixel: to_u16(pixel_width),
416448
ws_ypixel: to_u16(pixel_height),
417449
};
418-
let _ = unsafe_pty::set_winsize(master.as_raw_fd(), winsize);
450+
if let Err(e) = unsafe_pty::set_winsize(master.as_raw_fd(), winsize) {
451+
warn!("failed to resize PTY for channel {channel:?}: {e}");
452+
}
419453
}
420454
Ok(())
421455
}
@@ -474,7 +508,10 @@ impl russh::server::Handler for SshHandler {
474508
self.ca_file_paths.clone(),
475509
&self.provider_env,
476510
)?;
477-
self.input_sender = Some(input_sender);
511+
let state = self.channels.get_mut(&channel).ok_or_else(|| {
512+
anyhow::anyhow!("subsystem_request on unknown channel {channel:?}")
513+
})?;
514+
state.input_sender = Some(input_sender);
478515
} else {
479516
warn!(subsystem = name, "unsupported subsystem requested");
480517
session.channel_failure(channel)?;
@@ -499,26 +536,34 @@ impl russh::server::Handler for SshHandler {
499536

500537
async fn data(
501538
&mut self,
502-
_channel: ChannelId,
539+
channel: ChannelId,
503540
data: &[u8],
504541
_session: &mut Session,
505542
) -> Result<(), Self::Error> {
506-
if let Some(sender) = self.input_sender.as_ref() {
543+
let Some(state) = self.channels.get(&channel) else {
544+
warn!("data on unknown channel {channel:?}");
545+
return Ok(());
546+
};
547+
if let Some(sender) = state.input_sender.as_ref() {
507548
let _ = sender.send(data.to_vec());
508549
}
509550
Ok(())
510551
}
511552

512553
async fn channel_eof(
513554
&mut self,
514-
_channel: ChannelId,
555+
channel: ChannelId,
515556
_session: &mut Session,
516557
) -> Result<(), Self::Error> {
517558
// Drop the input sender so the stdin writer thread sees a
518559
// disconnected channel and closes the child's stdin pipe. This
519560
// is essential for commands like `cat | tar xf -` which need
520561
// stdin EOF to know the input stream is complete.
521-
self.input_sender.take();
562+
if let Some(state) = self.channels.get_mut(&channel) {
563+
state.input_sender.take();
564+
} else {
565+
warn!("channel_eof on unknown channel {channel:?}");
566+
}
522567
Ok(())
523568
}
524569
}
@@ -530,7 +575,11 @@ impl SshHandler {
530575
handle: Handle,
531576
command: Option<String>,
532577
) -> anyhow::Result<()> {
533-
if let Some(pty) = self.pty_request.take() {
578+
let state = self
579+
.channels
580+
.get_mut(&channel)
581+
.ok_or_else(|| anyhow::anyhow!("start_shell on unknown channel {channel:?}"))?;
582+
if let Some(pty) = state.pty_request.take() {
534583
// PTY was requested — allocate a real PTY (interactive shell or
535584
// exec that explicitly asked for a terminal).
536585
let (pty_master, input_sender) = spawn_pty_shell(
@@ -545,8 +594,8 @@ impl SshHandler {
545594
self.ca_file_paths.clone(),
546595
&self.provider_env,
547596
)?;
548-
self.pty_master = Some(pty_master);
549-
self.input_sender = Some(input_sender);
597+
state.pty_master = Some(pty_master);
598+
state.input_sender = Some(input_sender);
550599
} else {
551600
// No PTY requested — use plain pipes so stdout/stderr are
552601
// separate and output has clean LF line endings. This is the
@@ -562,7 +611,7 @@ impl SshHandler {
562611
self.ca_file_paths.clone(),
563612
&self.provider_env,
564613
)?;
565-
self.input_sender = Some(input_sender);
614+
state.input_sender = Some(input_sender);
566615
}
567616
Ok(())
568617
}
@@ -999,7 +1048,7 @@ mod unsafe_pty {
9991048

10001049
#[allow(unsafe_code)]
10011050
pub fn set_winsize(fd: RawFd, winsize: Winsize) -> std::io::Result<()> {
1002-
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, winsize) };
1051+
let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
10031052
if rc != 0 {
10041053
return Err(std::io::Error::last_os_error());
10051054
}
@@ -1404,4 +1453,111 @@ mod tests {
14041453
assert!(!is_loopback_host("not-an-ip"));
14051454
assert!(!is_loopback_host("[]"));
14061455
}
1456+
1457+
// -----------------------------------------------------------------------
1458+
// Per-channel PTY state tests (#543)
1459+
// -----------------------------------------------------------------------
1460+
1461+
#[test]
1462+
fn set_winsize_applies_to_correct_pty() {
1463+
// Verify that set_winsize applies to a specific PTY master FD,
1464+
// which is the mechanism that per-channel tracking relies on.
1465+
// With the old single-pty_master design, a window_change_request
1466+
// for channel N would resize whatever PTY was stored last —
1467+
// potentially belonging to a different channel.
1468+
let pty_a = openpty(None, None).expect("openpty a");
1469+
let pty_b = openpty(None, None).expect("openpty b");
1470+
let master_a = std::fs::File::from(pty_a.master);
1471+
let master_b = std::fs::File::from(pty_b.master);
1472+
let fd_a = master_a.as_raw_fd();
1473+
let fd_b = master_b.as_raw_fd();
1474+
assert_ne!(fd_a, fd_b, "two PTYs must have distinct FDs");
1475+
1476+
// Close the slave ends to avoid leaking FDs in the test.
1477+
drop(std::fs::File::from(pty_a.slave));
1478+
drop(std::fs::File::from(pty_b.slave));
1479+
1480+
// Resize only PTY B.
1481+
let winsize_b = Winsize {
1482+
ws_row: 50,
1483+
ws_col: 120,
1484+
ws_xpixel: 0,
1485+
ws_ypixel: 0,
1486+
};
1487+
unsafe_pty::set_winsize(fd_b, winsize_b).expect("set_winsize on PTY B");
1488+
1489+
// Resize PTY A to a different size.
1490+
let winsize_a = Winsize {
1491+
ws_row: 24,
1492+
ws_col: 80,
1493+
ws_xpixel: 0,
1494+
ws_ypixel: 0,
1495+
};
1496+
unsafe_pty::set_winsize(fd_a, winsize_a).expect("set_winsize on PTY A");
1497+
1498+
// Read back sizes via ioctl to verify independence.
1499+
let mut actual_a: libc::winsize = unsafe { std::mem::zeroed() };
1500+
let mut actual_b: libc::winsize = unsafe { std::mem::zeroed() };
1501+
#[allow(unsafe_code)]
1502+
unsafe {
1503+
libc::ioctl(fd_a, libc::TIOCGWINSZ, &mut actual_a);
1504+
libc::ioctl(fd_b, libc::TIOCGWINSZ, &mut actual_b);
1505+
}
1506+
1507+
assert_eq!(actual_a.ws_row, 24, "PTY A should be 24 rows");
1508+
assert_eq!(actual_a.ws_col, 80, "PTY A should be 80 cols");
1509+
assert_eq!(actual_b.ws_row, 50, "PTY B should be 50 rows");
1510+
assert_eq!(actual_b.ws_col, 120, "PTY B should be 120 cols");
1511+
}
1512+
1513+
#[test]
1514+
fn channel_state_independent_input_senders() {
1515+
// Verify that each channel gets its own input sender so that
1516+
// data() and channel_eof() affect only the targeted channel.
1517+
let (tx_a, rx_a) = mpsc::channel::<Vec<u8>>();
1518+
let (tx_b, rx_b) = mpsc::channel::<Vec<u8>>();
1519+
1520+
let mut state_a = ChannelState {
1521+
input_sender: Some(tx_a),
1522+
..Default::default()
1523+
};
1524+
let state_b = ChannelState {
1525+
input_sender: Some(tx_b),
1526+
..Default::default()
1527+
};
1528+
1529+
// Send data to channel A only.
1530+
state_a
1531+
.input_sender
1532+
.as_ref()
1533+
.unwrap()
1534+
.send(b"hello-a".to_vec())
1535+
.unwrap();
1536+
// Send data to channel B only.
1537+
state_b
1538+
.input_sender
1539+
.as_ref()
1540+
.unwrap()
1541+
.send(b"hello-b".to_vec())
1542+
.unwrap();
1543+
1544+
assert_eq!(rx_a.recv().unwrap(), b"hello-a");
1545+
assert_eq!(rx_b.recv().unwrap(), b"hello-b");
1546+
1547+
// EOF on channel A (drop sender) should not affect channel B.
1548+
state_a.input_sender.take();
1549+
assert!(
1550+
rx_a.recv().is_err(),
1551+
"channel A sender dropped, recv should fail"
1552+
);
1553+
1554+
// Channel B should still be functional.
1555+
state_b
1556+
.input_sender
1557+
.as_ref()
1558+
.unwrap()
1559+
.send(b"still-alive".to_vec())
1560+
.unwrap();
1561+
assert_eq!(rx_b.recv().unwrap(), b"still-alive");
1562+
}
14071563
}

0 commit comments

Comments
 (0)