Implement PTY-native session terminal control

This commit is contained in:
ms2k 2026-08-02 17:36:40 +09:00
commit 8cef037fa3
25 changed files with 1488 additions and 382 deletions

View file

@ -1,8 +1,10 @@
use std::fmt;
use std::fs::File;
use std::io::{self, Write};
use std::net::Shutdown;
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::{Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration;
use ed25519_dalek::SigningKey;
@ -10,8 +12,8 @@ use lazaret_control_client::{ControlClient, ControlClientError, PendingClientEnr
use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId};
use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionRequest, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
DeveloperDecisionKind, InteractiveSessionBatchV2, InteractiveSessionEventV2,
InteractiveSessionRequestV2, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
SessionRegistrationRequest, TlsSubjectId,
};
@ -414,9 +416,9 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
let cli_id = identity.cli_id;
let session_id = identity.session_id;
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
let anonymous = ControlClient::config(&endpoint.publication, None)?;
let anonymous = ControlClient::config_v2(&endpoint.publication, None)?;
let mut enrollment =
ControlClient::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
ControlClient::connect_v2(&endpoint.socket_path, anonymous, &endpoint.publication)?;
ControlClient::send(
&mut enrollment,
&ControlRequest::Enroll(pending.request().clone()),
@ -442,12 +444,12 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
session_id,
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
};
let authenticated = ControlClient::config(
let authenticated = ControlClient::config_v2(
&endpoint.publication,
Some((&credential, pending.private_key())),
)?;
let mut stream =
ControlClient::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
ControlClient::connect_v2(&endpoint.socket_path, authenticated, &endpoint.publication)?;
ControlClient::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
@ -466,13 +468,70 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
));
}
endpoint.acknowledge_waiter_transfer()?;
let mut transport = ControlFaceInteractiveTransport { stream };
let mut transport = ControlFaceInteractiveTransport::new(stream).map_err(widen_client_error)?;
run_interactive_session(&mut transport, terminal, identity, &binding, argv)
.map_err(widen_client_error)
}
struct ControlFaceInteractiveTransport {
stream: rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
commands: Option<mpsc::Sender<InteractiveTransportCommand>>,
batches:
mpsc::Receiver<Result<InteractiveSessionBatchV2, ClientError<std::convert::Infallible>>>,
cancellation: std::os::unix::net::UnixStream,
worker: Option<JoinHandle<()>>,
}
enum InteractiveTransportCommand {
Start(InteractiveSessionRequestV2),
Event(InteractiveSessionEventV2),
}
impl ControlFaceInteractiveTransport {
fn new(
mut stream: rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
) -> Result<Self, ClientError<std::convert::Infallible>> {
let cancellation = stream.sock.try_clone()?;
let (commands, command_rx) = mpsc::channel();
let (batch_tx, batches) = mpsc::channel();
let worker = std::thread::spawn(move || {
while let Ok(command) = command_rx.recv() {
let result = match command {
InteractiveTransportCommand::Start(request) => ControlClient::send(
&mut stream,
&ControlRequest::ExecuteInteractiveV2(request),
)
.map_err(map_control_connection)
.and_then(|()| receive_interactive_batch(&mut stream)),
InteractiveTransportCommand::Event(event) => {
ControlClient::send(&mut stream, &event)
.map_err(map_control_connection)
.and_then(|()| receive_interactive_batch(&mut stream))
}
};
let failed = result.is_err();
if batch_tx.send(result).is_err() || failed {
break;
}
}
});
Ok(Self {
commands: Some(commands),
batches,
cancellation,
worker: Some(worker),
})
}
fn submit(
&self,
command: InteractiveTransportCommand,
) -> Result<(), ClientError<std::convert::Infallible>> {
self.commands
.as_ref()
.ok_or(ClientError::LostConnection)?
.send(command)
.map_err(|_| ClientError::LostConnection)
}
}
impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
@ -480,27 +539,43 @@ impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
fn start(
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
ControlClient::send(
&mut self.stream,
&ControlRequest::ExecuteInteractive(request),
)?;
receive_interactive_batch(&mut self.stream)
request: InteractiveSessionRequestV2,
) -> Result<(), ClientError<Self::Error>> {
self.submit(InteractiveTransportCommand::Start(request))
}
fn exchange(
fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>> {
self.submit(InteractiveTransportCommand::Event(event))
}
fn try_receive(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
ControlClient::send(&mut self.stream, &event).map_err(map_control_connection)?;
receive_interactive_batch(&mut self.stream)
) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>> {
match self.batches.try_recv() {
Ok(batch) => batch.map(Some),
Err(mpsc::TryRecvError::Empty) => Ok(None),
Err(mpsc::TryRecvError::Disconnected) => Err(ClientError::LostConnection),
}
}
fn cancel(&mut self) {
self.commands.take();
let _ = self.cancellation.shutdown(Shutdown::Both);
}
}
impl Drop for ControlFaceInteractiveTransport {
fn drop(&mut self) {
self.cancel();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
fn receive_interactive_batch(
stream: &mut impl std::io::Read,
) -> Result<InteractiveSessionBatch, ClientError<std::convert::Infallible>> {
) -> Result<InteractiveSessionBatchV2, ClientError<std::convert::Infallible>> {
ControlClient::receive(stream).map_err(map_control_connection)
}

View file

@ -1,18 +1,21 @@
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
use std::num::NonZeroU16;
use std::time::Duration;
use lazaret_core::records::{
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionMessage, InteractiveSessionRequest, ProtocolVersion, SessionBinding,
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput,
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind,
DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequestV2,
SessionBinding, SessionExitOutcome, SessionTerminalEventV2, SessionTerminalMessage,
TerminalBreak, TerminalInput, TerminalModesV2, TerminalPrompt, TerminalResize,
};
use nix::sys::signal::{SigSet, SigmaskHow, Signal};
use nix::sys::signalfd::{SfdFlags, SignalFd};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr};
use rustix::termios::{
OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcgetwinsize, tcsetattr,
};
use crate::{ClientError, ClientSessionIdentity};
@ -23,21 +26,26 @@ pub trait InteractiveSessionTransport {
/// Returns an authenticated session or framing failure.
fn start(
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
request: InteractiveSessionRequestV2,
) -> Result<(), ClientError<Self::Error>>;
/// # Errors
/// Returns a lost connection, stale session, or framing failure.
fn exchange(
fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>>;
/// # Errors
/// Returns a lost connection, stale session, or framing failure.
fn try_receive(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>>;
fn cancel(&mut self);
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HostTerminalEvent {
Input(Vec<u8>),
Signal(TerminalSignalKind),
Break,
Resize,
LocalSuspend,
Terminate,
@ -54,6 +62,7 @@ pub trait HostTerminal {
/// # Errors
/// Returns an error when the current terminal size cannot be observed.
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize>;
fn modes(&self) -> TerminalModesV2;
/// # Errors
/// Returns an error while observing terminal input or signals.
fn next_event(&mut self) -> io::Result<HostTerminalEvent>;
@ -80,6 +89,9 @@ pub struct SystemTerminal {
original_flags: OFlags,
raw: bool,
signals: SignalQueue,
events: VecDeque<HostTerminalEvent>,
line_start: bool,
escape_pending: bool,
}
impl SystemTerminal {
@ -95,8 +107,59 @@ impl SystemTerminal {
terminal,
raw: false,
signals: SignalQueue::register()?,
events: VecDeque::new(),
line_start: true,
escape_pending: false,
})
}
fn queue_input(&mut self, bytes: &[u8]) {
let mut forwarded = Vec::new();
let mut events = VecDeque::new();
for &byte in bytes {
if self.escape_pending {
self.escape_pending = false;
match byte {
b'.' | 0x1a | b'B' => {
if !forwarded.is_empty() {
events.push_back(HostTerminalEvent::Input(std::mem::take(
&mut forwarded,
)));
}
events.push_back(match byte {
b'.' => HostTerminalEvent::Terminate,
0x1a => HostTerminalEvent::LocalSuspend,
b'B' => HostTerminalEvent::Break,
_ => unreachable!(),
});
self.line_start = true;
continue;
}
b'~' => {
forwarded.push(b'~');
self.line_start = false;
continue;
}
_ => {
forwarded.push(b'~');
forwarded.push(byte);
}
}
self.line_start = matches!(byte, b'\r' | b'\n');
continue;
}
if self.line_start && byte == b'~' {
self.escape_pending = true;
continue;
}
forwarded.push(byte);
self.line_start = matches!(byte, b'\r' | b'\n');
}
if !forwarded.is_empty() {
events.push_back(HostTerminalEvent::Input(forwarded));
}
self.events.extend(events);
}
}
impl HostTerminal for SystemTerminal {
@ -106,7 +169,6 @@ impl HostTerminal for SystemTerminal {
}
let mut raw = self.original.clone();
raw.make_raw();
raw.local_modes.insert(LocalModes::ISIG);
tcsetattr(&self.terminal, OptionalActions::Flush, &raw)?;
if let Err(error) = fcntl_setfl(&self.terminal, self.original_flags | OFlags::NONBLOCK) {
let _ = tcsetattr(&self.terminal, OptionalActions::Flush, &self.original);
@ -139,7 +201,14 @@ impl HostTerminal for SystemTerminal {
})
}
fn modes(&self) -> TerminalModesV2 {
terminal_modes_v2(&self.original)
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
if let Some(event) = self.events.pop_front() {
return Ok(event);
}
if let Some(event) = self.signals.next_event()? {
return Ok(event);
}
@ -148,7 +217,8 @@ impl HostTerminal for SystemTerminal {
Ok(0) => Ok(HostTerminalEvent::Terminate),
Ok(length) => {
bytes.truncate(length);
Ok(HostTerminalEvent::Input(bytes))
self.queue_input(&bytes);
Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle))
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
@ -196,9 +266,7 @@ impl HostTerminal for SystemTerminal {
resumed_size = Some(self.size(prompt.session_id)?);
}
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
HostTerminalEvent::Idle
| HostTerminalEvent::Resize
| HostTerminalEvent::Signal(_) => {}
HostTerminalEvent::Idle | HostTerminalEvent::Resize | HostTerminalEvent::Break => {}
}
}
}
@ -259,11 +327,11 @@ impl SignalQueue {
};
let signal = Signal::try_from(info.ssi_signo.cast_signed()).map_err(errno_to_io)?;
Ok(match signal {
Signal::SIGINT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt)),
Signal::SIGQUIT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Quit)),
Signal::SIGINT | Signal::SIGQUIT | Signal::SIGTERM | Signal::SIGHUP => {
Some(HostTerminalEvent::Terminate)
}
Signal::SIGTSTP => Some(HostTerminalEvent::LocalSuspend),
Signal::SIGWINCH => Some(HostTerminalEvent::Resize),
Signal::SIGTERM | Signal::SIGHUP => Some(HostTerminalEvent::Terminate),
_ => None,
})
}
@ -282,6 +350,35 @@ impl Drop for SignalQueue {
}
}
fn terminal_modes_v2(modes: &Termios) -> TerminalModesV2 {
TerminalModesV2 {
input_modes: modes.input_modes.bits(),
output_modes: modes.output_modes.bits(),
control_modes: modes.control_modes.bits(),
local_modes: modes.local_modes.bits(),
line_discipline: modes.line_discipline,
input_speed: modes.input_speed(),
output_speed: modes.output_speed(),
interrupt: modes.special_codes[SpecialCodeIndex::VINTR],
quit: modes.special_codes[SpecialCodeIndex::VQUIT],
erase: modes.special_codes[SpecialCodeIndex::VERASE],
kill: modes.special_codes[SpecialCodeIndex::VKILL],
eof: modes.special_codes[SpecialCodeIndex::VEOF],
time: modes.special_codes[SpecialCodeIndex::VTIME],
minimum: modes.special_codes[SpecialCodeIndex::VMIN],
switch: modes.special_codes[SpecialCodeIndex::VSWTC],
start: modes.special_codes[SpecialCodeIndex::VSTART],
stop: modes.special_codes[SpecialCodeIndex::VSTOP],
suspend: modes.special_codes[SpecialCodeIndex::VSUSP],
end_of_line: modes.special_codes[SpecialCodeIndex::VEOL],
reprint: modes.special_codes[SpecialCodeIndex::VREPRINT],
discard: modes.special_codes[SpecialCodeIndex::VDISCARD],
word_erase: modes.special_codes[SpecialCodeIndex::VWERASE],
literal_next: modes.special_codes[SpecialCodeIndex::VLNEXT],
end_of_line_2: modes.special_codes[SpecialCodeIndex::VEOL2],
}
}
fn errno_to_io(error: nix::errno::Errno) -> io::Error {
io::Error::from_raw_os_error(error as i32)
}
@ -301,63 +398,81 @@ pub fn run_interactive_session<T: InteractiveSessionTransport, H: HostTerminal>(
let result = (|| {
let session_id = identity.session_id();
let initial_size = terminal.size(session_id)?;
let mut batch = transport.start(InteractiveSessionRequest {
protocol_version: ProtocolVersion::V1,
transport.start(InteractiveSessionRequestV2 {
protocol_version: InteractiveProtocolVersion::V2,
session_id,
argv: argv.to_vec(),
initial_size,
initial_modes: terminal.modes(),
})?;
let mut pending = VecDeque::new();
loop {
if let Some(exit) = handle_batch(transport, terminal, identity, binding, batch)? {
return match exit.outcome() {
SessionExitOutcome::Exited => exit
.exit_code()
.ok_or(ClientError::Protocol("Exited omitted its exit code")),
SessionExitOutcome::Terminated | SessionExitOutcome::RuntimeLost => {
Err(ClientError::LostConnection)
if let Some(batch) = transport.try_receive()? {
let handled = handle_batch(terminal, identity, binding, batch)?;
if let Some(exit) = handled.exit {
return match exit.outcome() {
SessionExitOutcome::Exited => exit
.exit_code()
.ok_or(ClientError::Protocol("Exited omitted its exit code")),
SessionExitOutcome::Terminated | SessionExitOutcome::RuntimeLost => {
Err(ClientError::LostConnection)
}
};
}
for event in handled.events {
enqueue_event(&mut pending, event)?;
}
} else {
let event = terminal.next_event()?;
match host_event(terminal, session_id, event)? {
HostEvent::Remote(event) => enqueue_event(&mut pending, event)?,
HostEvent::Continue => {}
HostEvent::Disconnect => {
transport.cancel();
return Err(ClientError::LostConnection);
}
};
}
continue;
}
let event =
match terminal.next_event()? {
HostTerminalEvent::Input(bytes) => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Input(TerminalInput { session_id, bytes }),
),
HostTerminalEvent::Signal(signal) => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Signal(TerminalSignal { session_id, signal }),
),
HostTerminalEvent::Resize => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Resize(terminal.size(session_id)?),
),
HostTerminalEvent::LocalSuspend => {
terminal.suspend_and_resume()?;
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(
terminal.size(session_id)?,
))
let event = if let Some(event) = pending.pop_front() {
event
} else {
let event = terminal.next_event()?;
match host_event(terminal, session_id, event)? {
HostEvent::Remote(event) => event,
HostEvent::Continue => InteractiveSessionEventV2::Poll { session_id },
HostEvent::Disconnect => {
transport.cancel();
return Err(ClientError::LostConnection);
}
HostTerminalEvent::Terminate => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Close(SessionClose { session_id }),
),
HostTerminalEvent::Idle => InteractiveSessionEvent::Poll { session_id },
};
batch = transport.exchange(event)?;
}
};
transport.send(event)?;
}
})();
if result.is_err() {
transport.cancel();
}
let restoration = terminal.restore().map_err(ClientError::Io);
restoration?;
result
}
fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
transport: &mut T,
struct HandledBatch {
exit: Option<lazaret_core::records::SessionExit>,
events: Vec<InteractiveSessionEventV2>,
}
fn handle_batch<H: HostTerminal, E>(
terminal: &mut H,
identity: &ClientSessionIdentity,
binding: &SessionBinding,
batch: InteractiveSessionBatch,
) -> Result<Option<lazaret_core::records::SessionExit>, ClientError<T::Error>> {
batch: InteractiveSessionBatchV2,
) -> Result<HandledBatch, ClientError<E>> {
if batch.session_id != identity.session_id() {
return Err(ClientError::Protocol("response Session ID differs"));
}
let mut events = Vec::new();
for message in batch.messages {
match message {
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(output)) => {
@ -366,35 +481,92 @@ fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
}
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(exit)) => {
require_session(exit.session_id(), identity.session_id())?;
return Ok(Some(exit));
return Ok(HandledBatch {
exit: Some(exit),
events,
});
}
InteractiveSessionMessage::Prompt(prompt) => {
require_session(prompt.session_id, identity.session_id())?;
let outcome = terminal.prompt(&prompt)?;
if let Some(resize) = outcome.resumed_size {
let response = transport.exchange(InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Resize(resize),
))?;
if let Some(exit) =
handle_batch(transport, terminal, identity, binding, response)?
{
return Ok(Some(exit));
}
events.push(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(resize),
));
}
let signed_decision =
identity.sign_developer_decision(binding.clone(), &prompt, outcome.decision)?;
let response = transport.exchange(InteractiveSessionEvent::PromptDecision {
events.push(InteractiveSessionEventV2::PromptDecision {
session_id: identity.session_id(),
signed_decision,
})?;
if let Some(exit) = handle_batch(transport, terminal, identity, binding, response)?
{
return Ok(Some(exit));
}
});
}
}
}
Ok(None)
Ok(HandledBatch { exit: None, events })
}
enum HostEvent {
Remote(InteractiveSessionEventV2),
Continue,
Disconnect,
}
fn host_event<H: HostTerminal, E>(
terminal: &mut H,
session_id: lazaret_core::identity::SessionId,
event: HostTerminalEvent,
) -> Result<HostEvent, ClientError<E>> {
Ok(match event {
HostTerminalEvent::Input(bytes) => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Input(TerminalInput { session_id, bytes }),
)),
HostTerminalEvent::Break => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Break(TerminalBreak { session_id }),
)),
HostTerminalEvent::Resize => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
)),
HostTerminalEvent::LocalSuspend => {
terminal.suspend_and_resume()?;
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
))
}
HostTerminalEvent::Terminate => HostEvent::Disconnect,
HostTerminalEvent::Idle => HostEvent::Continue,
})
}
fn enqueue_event<E>(
pending: &mut VecDeque<InteractiveSessionEventV2>,
event: InteractiveSessionEventV2,
) -> Result<(), ClientError<E>> {
const MAX_PENDING_EVENTS: usize = 256;
const MAX_PENDING_INPUT_BYTES: usize = 64 * 1024;
let bytes = pending
.iter()
.filter_map(|event| match event {
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input)) => {
Some(input.bytes.len())
}
_ => None,
})
.sum::<usize>()
+ match &event {
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input)) => {
input.bytes.len()
}
_ => 0,
};
if pending.len() >= MAX_PENDING_EVENTS || bytes > MAX_PENDING_INPUT_BYTES {
return Err(ClientError::Io(io::Error::other(
"interactive terminal transport backpressure",
)));
}
pending.push_back(event);
Ok(())
}
fn require_session<E>(
@ -426,10 +598,81 @@ fn hex(bytes: &[u8]) -> String {
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write as _;
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
use nix::unistd::Pid;
use rustix::fs::fcntl_getfl;
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{LocalModes, tcgetattr};
use super::SignalQueue;
use super::{HostTerminal, HostTerminalEvent, SignalQueue, SystemTerminal};
fn terminal() -> (SystemTerminal, File) {
let master =
openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).unwrap();
grantpt(&master).unwrap();
unlockpt(&master).unwrap();
let slave = File::from(
ioctl_tiocgptpeer(
&master,
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
)
.unwrap(),
);
let original = tcgetattr(&slave).unwrap();
let original_flags = fcntl_getfl(&slave).unwrap();
(
SystemTerminal {
terminal: slave,
original,
original_flags,
raw: false,
signals: SignalQueue::register().unwrap(),
events: VecDeque::new(),
line_start: true,
escape_pending: false,
},
File::from(master),
)
}
#[test]
fn raw_mode_forwards_control_bytes_without_local_signals() {
let (mut terminal, mut master) = terminal();
terminal.enter_raw().unwrap();
assert!(
!tcgetattr(&terminal.terminal)
.unwrap()
.local_modes
.contains(LocalModes::ISIG)
);
master.write_all(&[3, 28, 26]).unwrap();
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(vec![3, 28, 26])
);
}
#[test]
fn ssh_style_escapes_survive_read_boundaries_and_preserve_literals() {
let (mut terminal, mut master) = terminal();
terminal.enter_raw().unwrap();
master.write_all(b"line\n~").unwrap();
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(b"line\n".to_vec())
);
master.write_all(b"B~.~~literal\n").unwrap();
assert_eq!(terminal.next_event().unwrap(), HostTerminalEvent::Break);
assert_eq!(terminal.next_event().unwrap(), HostTerminalEvent::Terminate);
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(b"~literal\n".to_vec())
);
}
#[test]
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]

View file

@ -12,14 +12,14 @@ use lazaret_core::identity::{
SandboxId, SessionId,
};
use lazaret_core::records::{
DeveloperDecision, DeveloperDecisionKind, GenerationIdentity, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, SessionBinding,
SessionExit, SessionTerminalMessage, SignedProtocolRecord, TerminalOutput, TerminalPrompt,
TerminalResize, TerminalSignalKind,
DeveloperDecision, DeveloperDecisionKind, GenerationIdentity, InteractiveSessionBatchV2,
InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequestV2,
SessionBinding, SessionExit, SessionTerminalEventV2, SessionTerminalMessage,
SignedProtocolRecord, TerminalModesV2, TerminalOutput, TerminalPrompt, TerminalResize,
};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcsetattr};
use rustix::termios::{OptionalActions, Termios, tcgetattr, tcsetattr};
struct PtyTerminal {
_master: File,
@ -74,7 +74,6 @@ impl HostTerminal for PtyTerminal {
fn enter_raw(&mut self) -> io::Result<()> {
let mut raw = self.original.clone();
raw.make_raw();
raw.local_modes.insert(LocalModes::ISIG);
tcsetattr(&self.slave, OptionalActions::Now, &raw)?;
fcntl_setfl(&self.slave, self.original_flags | OFlags::NONBLOCK)?;
self.raw = true;
@ -98,6 +97,10 @@ impl HostTerminal for PtyTerminal {
})
}
fn modes(&self) -> TerminalModesV2 {
test_modes()
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle))
}
@ -139,11 +142,14 @@ enum TransportFailure {
struct FixtureTransport {
session_id: SessionId,
events: Vec<InteractiveSessionEvent>,
events: Vec<InteractiveSessionEventV2>,
prompt_sent: bool,
failure: Option<TransportFailure>,
signed_decision: Option<Vec<u8>>,
prompt_after_events: usize,
batches: VecDeque<InteractiveSessionBatchV2>,
cancelled: bool,
blocked: bool,
}
impl FixtureTransport {
@ -155,6 +161,9 @@ impl FixtureTransport {
failure: None,
signed_decision: None,
prompt_after_events: 5,
batches: VecDeque::new(),
cancelled: false,
blocked: false,
}
}
}
@ -164,13 +173,17 @@ impl InteractiveSessionTransport for FixtureTransport {
fn start(
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
request: InteractiveSessionRequestV2,
) -> Result<(), ClientError<Self::Error>> {
if matches!(self.failure, Some(TransportFailure::Stale)) {
return Err(ClientError::StaleCoordinator);
}
assert_eq!(request.session_id, self.session_id);
Ok(batch(
assert_eq!(
request.protocol_version,
lazaret_core::records::InteractiveProtocolVersion::V2
);
self.batches.push_back(batch(
self.session_id,
vec![InteractiveSessionMessage::Terminal(
SessionTerminalMessage::Output(TerminalOutput {
@ -178,32 +191,31 @@ impl InteractiveSessionTransport for FixtureTransport {
bytes: b"guest-output".to_vec(),
}),
)],
))
));
Ok(())
}
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>> {
if matches!(self.failure, Some(TransportFailure::Lost)) {
return Err(ClientError::LostConnection);
}
self.events.push(event.clone());
if let InteractiveSessionEvent::PromptDecision {
if let InteractiveSessionEventV2::PromptDecision {
signed_decision, ..
} = event
{
self.signed_decision = Some(signed_decision);
return Ok(batch(
self.batches.push_back(batch(
self.session_id,
vec![InteractiveSessionMessage::Terminal(
SessionTerminalMessage::Exit(SessionExit::exited(self.session_id, 23)),
)],
));
return Ok(());
}
if !self.prompt_sent && self.events.len() >= self.prompt_after_events {
self.prompt_sent = true;
return Ok(batch(
self.batches.push_back(batch(
self.session_id,
vec![InteractiveSessionMessage::Prompt(TerminalPrompt {
session_id: self.session_id,
@ -214,8 +226,23 @@ impl InteractiveSessionTransport for FixtureTransport {
justification: "update host metadata".into(),
})],
));
return Ok(());
}
Ok(batch(self.session_id, Vec::new()))
self.batches.push_back(batch(self.session_id, Vec::new()));
Ok(())
}
fn try_receive(
&mut self,
) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>> {
if self.blocked {
return Ok(None);
}
Ok(self.batches.pop_front())
}
fn cancel(&mut self) {
self.cancelled = true;
}
}
@ -243,23 +270,21 @@ fn prompt_suspend_sends_resumed_size_before_decision() {
assert!(matches!(
transport.events.as_slice(),
[
InteractiveSessionEvent::Poll { .. },
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize(
_
)),
InteractiveSessionEvent::PromptDecision { .. }
InteractiveSessionEventV2::Poll { .. },
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(_)),
InteractiveSessionEventV2::PromptDecision { .. }
]
));
}
#[test]
fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() {
fn terminal_fixture_routes_resize_input_break_suspend_prompt_and_nonzero_exit() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
let binding = binding(&identity);
let mut terminal = PtyTerminal::new([
HostTerminalEvent::Resize,
HostTerminalEvent::Signal(TerminalSignalKind::Interrupt),
HostTerminalEvent::Signal(TerminalSignalKind::Quit),
HostTerminalEvent::Input(vec![3]),
HostTerminalEvent::Break,
HostTerminalEvent::LocalSuspend,
HostTerminalEvent::Input(b"input".to_vec()),
]);
@ -289,21 +314,16 @@ fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() {
assert_eq!(decision.approved_command_argv, [b"/usr/bin/tool".to_vec()]);
assert!(transport.events.iter().any(|event| matches!(
event,
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize(_))
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(_))
)));
for signal in [TerminalSignalKind::Interrupt, TerminalSignalKind::Quit] {
assert!(transport.events.iter().any(|event| matches!(
event,
InteractiveSessionEvent::Terminal(
lazaret_core::records::SessionTerminalEvent::Signal(sent)
) if sent.signal == signal
)));
}
assert!(!transport.events.iter().any(|event| matches!(
assert!(transport.events.iter().any(|event| matches!(
event,
InteractiveSessionEvent::Terminal(
lazaret_core::records::SessionTerminalEvent::Signal(sent)
) if sent.signal == TerminalSignalKind::Suspend
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input))
if input.bytes == [3]
)));
assert!(transport.events.iter().any(|event| matches!(
event,
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Break(_))
)));
}
@ -335,6 +355,34 @@ fn terminal_fixture_restores_raw_mode_for_stale_and_lost_coordinator() {
}
}
#[test]
fn local_disconnect_cancels_blocked_transport_and_restores_terminal() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
let binding = binding(&identity);
let mut terminal = PtyTerminal::new([
HostTerminalEvent::LocalSuspend,
HostTerminalEvent::Terminate,
]);
let mut transport = FixtureTransport {
blocked: true,
..FixtureTransport::successful(identity.session_id())
};
assert!(matches!(
run_interactive_session(
&mut transport,
&mut terminal,
&identity,
&binding,
&[b"agent".to_vec()],
),
Err(ClientError::LostConnection)
));
assert!(transport.cancelled);
assert_eq!(terminal.suspend_count, 1);
assert!(terminal.restored());
}
fn binding(identity: &ClientSessionIdentity) -> SessionBinding {
SessionBinding {
generation_identity: GenerationIdentity {
@ -354,9 +402,38 @@ fn binding(identity: &ClientSessionIdentity) -> SessionBinding {
fn batch(
session_id: SessionId,
messages: Vec<InteractiveSessionMessage>,
) -> InteractiveSessionBatch {
InteractiveSessionBatch {
) -> InteractiveSessionBatchV2 {
InteractiveSessionBatchV2 {
session_id,
messages,
}
}
fn test_modes() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 0,
output_modes: 0,
control_modes: 0,
local_modes: 0,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
}
}

View file

@ -28,6 +28,7 @@ use rustls::{ClientConfig, ClientConnection, RootCertStore};
use sha2::{Digest, Sha256};
pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1";
pub const CONTROL_ALPN_V2: &[u8] = b"lazaret-control/2";
pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid";
pub const CONTROL_SOCKET_NAME: &str = "c.sock";
pub const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard";
@ -237,6 +238,25 @@ impl ControlClient {
pub fn config(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
Self::config_with_alpn(publication, client, CONTROL_ALPN)
}
/// Creates a TLS client configuration for the interactive v2 protocol.
///
/// # Errors
/// Returns malformed trust or credential failures.
pub fn config_v2(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
Self::config_with_alpn(publication, client, CONTROL_ALPN_V2)
}
fn config_with_alpn(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
alpn: &[u8],
) -> Result<ClientConfig, ControlClientError> {
let mut roots = RootCertStore::empty();
roots.add(CertificateDer::from(
@ -258,7 +278,7 @@ impl ControlClient {
} else {
builder.with_no_client_auth()
};
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
config.alpn_protocols = vec![alpn.to_vec()];
config.enable_early_data = false;
config.resumption = rustls::client::Resumption::disabled();
Ok(config)
@ -275,6 +295,33 @@ impl ControlClient {
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
Self::connect_with_alpn(socket_path, config, publication, CONTROL_ALPN)
}
/// Connects a pinned TLS stream using the interactive v2 ALPN.
///
/// # Errors
/// Returns socket, TLS, or certificate-profile failures.
pub fn connect_v2(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
Self::connect_with_alpn(socket_path, config, publication, CONTROL_ALPN_V2)
}
fn connect_with_alpn(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
alpn: &[u8],
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
let socket = std::os::unix::net::UnixStream::connect(socket_path)?;
let server_name = ServerName::try_from(CONTROL_SERVER_NAME)
@ -288,7 +335,7 @@ impl ControlClient {
return Err(ControlClientError::InvalidCertificateProfile);
};
if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3)
|| connection.alpn_protocol() != Some(CONTROL_ALPN)
|| connection.alpn_protocol() != Some(alpn)
|| certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256
{
return Err(ControlClientError::InvalidCertificateProfile);

View file

@ -230,6 +230,7 @@ pub enum ControlRequest {
Register(SessionRegistrationRequest),
Execute(NonInteractiveSessionRequest),
ExecuteInteractive(super::InteractiveSessionRequest),
ExecuteInteractiveV2(super::InteractiveSessionRequestV2),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]

View file

@ -4,25 +4,28 @@ use super::{
DefaultAction, DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, Domain,
DomainStore, DomainStoreMetadata, EnvironmentReference, EscalationOutcome, Generation,
GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity, HelperOwnership,
HostProcessIdentity, HostProcessOwnershipSlot, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, Mailbox,
MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk,
MountIdentity, NamespaceIdentity, NetworkCanaryObservation, NetworkObservation,
NetworkOwnership, NetworkReadinessAttestation, NetworkReadinessEvidence,
NetworkReadinessIdentity, NonInteractiveOutput, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, Participant, ParticipantOwnership, ParticipantState,
PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction,
PolicyDelivery, PrefixRule, PrepareOperation, PrepareStageState, PreparedArtifact,
PreparedLowerOwnership, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion,
ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity,
PublicationOwnership, ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream,
RunscProcessOwnership, SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding,
SessionClose, SessionExit, SessionExitOutcome, SessionRegistrationRequest,
SessionRegistrationResponse, SessionRequestCapability, SessionTerminal, SessionTerminalEvent,
SessionTerminalMessage, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership,
StateAnchorOwnership, StateAnchorReleaseEvidence, StorageCatalog, StorageCatalogMetadata,
StoreMetadata, TerminalInput, TerminalOutput, TerminalPrompt, TerminalResize, TerminalSignal,
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
HostProcessIdentity, HostProcessOwnershipSlot, InteractiveProtocolVersion,
InteractiveSessionBatch, InteractiveSessionBatchV2, InteractiveSessionEvent,
InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequest,
InteractiveSessionRequestV2, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult,
MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity,
NetworkCanaryObservation, NetworkObservation, NetworkOwnership, NetworkReadinessAttestation,
NetworkReadinessEvidence, NetworkReadinessIdentity, NonInteractiveOutput,
NonInteractiveSessionMessage, NonInteractiveSessionRequest, Participant, ParticipantOwnership,
ParticipantState, PersistedHostProcessOwnership, PidfsProcessAuthorityV1,
PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PrepareOperation,
PrepareStageState, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
ResolvedVolume, Resolver, ResultStream, RunscProcessOwnership, SandboxCredentialBundle,
SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
SessionRegistrationRequest, SessionRegistrationResponse, SessionRequestCapability,
SessionTerminal, SessionTerminalEvent, SessionTerminalEventV2, SessionTerminalMessage,
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
StateAnchorReleaseEvidence, StorageCatalog, StorageCatalogMetadata, StoreMetadata,
TerminalBreak, TerminalInput, TerminalModesV2, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest,
TrustedHostConfigurationV1,
};
use crate::protocol::{BootstrapWaitOutcome, BootstrapWaitRequest};
@ -67,16 +70,23 @@ schema_types!(
"ProcessOwnershipCommitAck" => ProcessOwnershipCommitAck,
"GuestProcessIdentity" => GuestProcessIdentity,
"SessionTerminalEvent" => SessionTerminalEvent,
"SessionTerminalEventV2" => SessionTerminalEventV2,
"TerminalInput" => TerminalInput,
"TerminalResize" => TerminalResize,
"TerminalModesV2" => TerminalModesV2,
"TerminalBreak" => TerminalBreak,
"TerminalSignal" => TerminalSignal,
"TerminalSignalKind" => TerminalSignalKind,
"SessionClose" => SessionClose,
"SessionTerminalMessage" => SessionTerminalMessage,
"InteractiveSessionRequest" => InteractiveSessionRequest,
"InteractiveProtocolVersion" => InteractiveProtocolVersion,
"InteractiveSessionRequestV2" => InteractiveSessionRequestV2,
"InteractiveSessionEvent" => InteractiveSessionEvent,
"InteractiveSessionEventV2" => InteractiveSessionEventV2,
"InteractiveSessionMessage" => InteractiveSessionMessage,
"InteractiveSessionBatch" => InteractiveSessionBatch,
"InteractiveSessionBatchV2" => InteractiveSessionBatchV2,
"TerminalPrompt" => TerminalPrompt,
"NonInteractiveSessionRequest" => NonInteractiveSessionRequest,
"NonInteractiveOutput" => NonInteractiveOutput,

View file

@ -2,6 +2,11 @@ use crate::identity::{RequestDigest, RequestId};
use super::{Deserialize, NonZeroU16, ProtocolVersion, Serialize, SessionId};
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveProtocolVersion {
V2,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalInput {
pub session_id: SessionId,
@ -17,6 +22,34 @@ pub struct TerminalResize {
pub pixel_height: u16,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalModesV2 {
pub input_modes: u32,
pub output_modes: u32,
pub control_modes: u32,
pub local_modes: u32,
pub line_discipline: u8,
pub input_speed: u32,
pub output_speed: u32,
pub interrupt: u8,
pub quit: u8,
pub erase: u8,
pub kill: u8,
pub eof: u8,
pub time: u8,
pub minimum: u8,
pub switch: u8,
pub start: u8,
pub stop: u8,
pub suspend: u8,
pub end_of_line: u8,
pub reprint: u8,
pub discard: u8,
pub word_erase: u8,
pub literal_next: u8,
pub end_of_line_2: u8,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum TerminalSignalKind {
Interrupt,
@ -35,6 +68,11 @@ pub struct SessionClose {
pub session_id: SessionId,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalBreak {
pub session_id: SessionId,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SessionTerminalEvent {
Input(TerminalInput),
@ -43,6 +81,14 @@ pub enum SessionTerminalEvent {
Close(SessionClose),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SessionTerminalEventV2 {
Input(TerminalInput),
Resize(TerminalResize),
Break(TerminalBreak),
Close(SessionClose),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalOutput {
pub session_id: SessionId,
@ -147,6 +193,15 @@ pub struct InteractiveSessionRequest {
pub initial_size: TerminalResize,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionRequestV2 {
pub protocol_version: InteractiveProtocolVersion,
pub session_id: SessionId,
pub argv: Vec<Vec<u8>>,
pub initial_size: TerminalResize,
pub initial_modes: TerminalModesV2,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalPrompt {
pub session_id: SessionId,
@ -169,6 +224,18 @@ pub enum InteractiveSessionEvent {
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionEventV2 {
Terminal(SessionTerminalEventV2),
PromptDecision {
session_id: SessionId,
signed_decision: Vec<u8>,
},
Poll {
session_id: SessionId,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionMessage {
Terminal(SessionTerminalMessage),
@ -181,6 +248,12 @@ pub struct InteractiveSessionBatch {
pub messages: Vec<InteractiveSessionMessage>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionBatchV2 {
pub session_id: SessionId,
pub messages: Vec<InteractiveSessionMessage>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NonInteractiveSessionRequest {
pub protocol_version: super::ProtocolVersion,

View file

@ -288,6 +288,35 @@ fn session_id() -> SessionId {
SessionId::new(opaque(5))
}
fn terminal_modes_v2() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 1,
output_modes: 2,
control_modes: 3,
local_modes: 4,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
}
}
fn process(seed: u32) -> PidfsProcessIdentityV1 {
PidfsProcessIdentityV1::new(
PidfsProcessAuthorityV1 {

View file

@ -145,6 +145,12 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}),
&mut seen,
);
verify_shape(
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session_id(),
}),
&mut seen,
);
verify_shape(
TerminalInput {
session_id: session_id(),
@ -162,6 +168,13 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
},
&mut seen,
);
verify_shape(terminal_modes_v2(), &mut seen);
verify_shape(
TerminalBreak {
session_id: session_id(),
},
&mut seen,
);
verify_shape(
TerminalSignal {
session_id: session_id(),
@ -227,12 +240,29 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
},
&mut seen,
);
verify_shape(InteractiveProtocolVersion::V2, &mut seen);
verify_shape(
InteractiveSessionRequestV2 {
protocol_version: InteractiveProtocolVersion::V2,
session_id: session_id(),
argv: vec![b"tool".to_vec()],
initial_size: interactive_resize,
initial_modes: terminal_modes_v2(),
},
&mut seen,
);
verify_shape(
InteractiveSessionEvent::Poll {
session_id: session_id(),
},
&mut seen,
);
verify_shape(
InteractiveSessionEventV2::Poll {
session_id: session_id(),
},
&mut seen,
);
let prompt = TerminalPrompt {
session_id: session_id(),
request_id: RequestId::new(opaque(54)),
@ -251,6 +281,13 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
},
&mut seen,
);
verify_shape(
InteractiveSessionBatchV2 {
session_id: session_id(),
messages: Vec::new(),
},
&mut seen,
);
verify_shape(generation_identity(), &mut seen);
verify_shape(trust_manifest(), &mut seen);
verify_shape(tls_credential.clone(), &mut seen);
@ -649,8 +686,8 @@ persistent_dirs = []
assert_eq!(
seen.digest(),
[
54, 187, 13, 149, 227, 157, 1, 54, 234, 66, 127, 9, 34, 65, 173, 20, 214, 200, 126, 67,
1, 216, 152, 76, 167, 32, 177, 218, 0, 192, 175, 28,
201, 9, 82, 185, 208, 17, 32, 181, 237, 9, 161, 239, 26, 109, 45, 148, 192, 138, 104,
114, 203, 147, 61, 180, 162, 184, 139, 77, 227, 8, 27, 128,
]
);
}

View file

@ -220,6 +220,7 @@ fn control_corpus(digest: &mut Sha256) {
ControlRequest::Register(_) => ControlRequest::Register(registration_request()),
ControlRequest::Execute(_) => ControlRequest::Execute(NonInteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()] }),
ControlRequest::ExecuteInteractive(_) => ControlRequest::ExecuteInteractive(InteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()], initial_size: resize }),
ControlRequest::ExecuteInteractiveV2(_) => ControlRequest::ExecuteInteractiveV2(InteractiveSessionRequestV2 { protocol_version: InteractiveProtocolVersion::V2, session_id: session_id(), argv: vec![b"tool".to_vec()], initial_size: resize, initial_modes: terminal_modes_v2() }),
});
enum_corpus!(digest, ControlResponse, {
ControlResponse::Enrolled(_) => ControlResponse::Enrolled(enrollment_response()),
@ -231,6 +232,9 @@ fn credential_corpus(digest: &mut Sha256) {
enum_corpus!(digest, ProtocolVersion, {
ProtocolVersion::V1 => ProtocolVersion::V1,
});
enum_corpus!(digest, InteractiveProtocolVersion, {
InteractiveProtocolVersion::V2 => InteractiveProtocolVersion::V2,
});
enum_corpus!(digest, TlsRole, {
TlsRole::SessionCoordinator => TlsRole::SessionCoordinator,
TlsRole::ClientCli => TlsRole::ClientCli,
@ -325,8 +329,8 @@ fn every_serialized_enum_variant_has_fixed_shape_evidence() {
assert_eq!(
digest.finalize().as_slice(),
[
63, 126, 242, 211, 164, 165, 11, 185, 180, 135, 109, 135, 92, 142, 111, 169, 142, 215,
49, 4, 170, 169, 119, 237, 235, 161, 192, 89, 92, 93, 88, 35,
16, 136, 82, 10, 228, 231, 132, 63, 189, 59, 106, 16, 164, 103, 232, 11, 245, 31, 144,
53, 91, 201, 91, 74, 66, 14, 174, 78, 110, 17, 181, 21,
]
);
}

View file

@ -553,6 +553,10 @@ impl<'a> CoordinatorLifecycleAdapter<'a> {
.ok_or_else(|| LifecycleServiceError("committed runtime is absent".into()))?
.stop_sandbox(&sandbox)
.map_err(failure)?;
self.plan
.interactive_executor
.clean_after_sandbox_absence()
.map_err(failure)?;
if self.plan.git_routing.is_some() {
let git_routing = GitRoutingCoordinator::new(&store);
self.runtime

View file

@ -10,7 +10,9 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use ed25519_dalek::SigningKey;
pub use lazaret_control_client::{CONTROL_ALPN, CONTROL_SERVER_NAME, PendingClientEnrollment};
pub use lazaret_control_client::{
CONTROL_ALPN, CONTROL_ALPN_V2, CONTROL_SERVER_NAME, PendingClientEnrollment,
};
use lazaret_control_client::{ControlClient, ControlClientError};
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::{ConfinedPathPlan, EnvironmentValue};
@ -24,13 +26,13 @@ use lazaret_core::protocol::{
};
use lazaret_core::records::{
ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse,
DurableDecision, GenerationIdentity, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionMessage, InteractiveSessionRequest, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, ProtocolCredential, ProtocolCredentialRole,
ProtocolPublicationIdentity, ProtocolSubjectId, ProtocolVersion, PublicationOwnership,
SandboxCredentialBundle, Session, SessionBinding, SessionExit, SessionPhase,
SessionRegistrationRequest, SessionRegistrationResponse, TlsCredential, TlsRole, TlsSubjectId,
TrustManifest,
DurableDecision, GenerationIdentity, InteractiveProtocolVersion, InteractiveSessionBatch,
InteractiveSessionBatchV2, InteractiveSessionEventV2, InteractiveSessionMessage,
InteractiveSessionRequestV2, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, ProtocolSubjectId,
ProtocolVersion, PublicationOwnership, SandboxCredentialBundle, Session, SessionBinding,
SessionExit, SessionPhase, SessionRegistrationRequest, SessionRegistrationResponse,
SessionTerminalMessage, TerminalOutput, TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
};
use rcgen::{
BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer,
@ -354,7 +356,7 @@ impl TrustAnchor {
)],
PrivateKeyDer::Pkcs8(self.coordinator_tls_private_key.clone_key()),
)?;
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
config.alpn_protocols = vec![CONTROL_ALPN_V2.to_vec(), CONTROL_ALPN.to_vec()];
config.max_early_data_size = 0;
config.send_tls13_tickets = 0;
config.session_storage = Arc::new(NoServerSessionStorage {});

View file

@ -1,12 +1,12 @@
use super::{
Arc, AsFd, BTreeMap, CONTROL_SOCKET_NAME, ContainerId, ControlError, EnvironmentValue, File,
FileIdentity, InteractiveSessionBatch, InteractiveSessionEvent, InteractiveSessionRequest,
MetadataExt, Mode, NonInteractiveSessionMessage, NonInteractiveSessionRequest, OFlags,
OpenOptions, OsStr, OsString, OsStringExt, PUBLICATION_IDENTITY_NAME, Path, PathBuf,
ProcessExit, ProtocolPublicationIdentity, Read, RunscCommand, STAGED_CONTROL_SOCKET_NAME,
STAGED_PUBLICATION_IDENTITY_NAME, ServerConfig, SessionExit, SessionId, SessionRegistry,
TargetCommand, TrustAnchor, Write, child_broker, fchmod, fmt, io, openat,
remove_if_identity_matches,
FileIdentity, InteractiveSessionBatchV2, InteractiveSessionEventV2,
InteractiveSessionRequestV2, MetadataExt, Mode, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, OFlags, OpenOptions, OsStr, OsString, OsStringExt,
PUBLICATION_IDENTITY_NAME, Path, PathBuf, ProcessExit, ProtocolPublicationIdentity, Read,
RunscCommand, STAGED_CONTROL_SOCKET_NAME, STAGED_PUBLICATION_IDENTITY_NAME, ServerConfig,
SessionExit, SessionId, SessionRegistry, TargetCommand, TrustAnchor, Write, child_broker,
fchmod, fmt, io, openat, remove_if_identity_matches,
};
pub(super) struct ControlPaths {
@ -194,8 +194,8 @@ pub trait InteractiveControlSession: Send {
/// Returns an exact-session terminal, prompt-routing, or transport failure.
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ControlError>;
event: InteractiveSessionEventV2,
) -> Result<InteractiveSessionBatchV2, ControlError>;
}
pub trait InteractiveSessionExecutor: Send + Sync {
@ -203,8 +203,12 @@ pub trait InteractiveSessionExecutor: Send + Sync {
/// Returns an acquisition or exact-session identity failure.
fn start(
&self,
request: &InteractiveSessionRequest,
request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError>;
/// # Errors
/// Refuses cleanup unless every terminal resource is absent after sandbox teardown.
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError>;
}
pub(super) struct InteractiveExecutionUnavailable;
@ -212,10 +216,14 @@ pub(super) struct InteractiveExecutionUnavailable;
impl InteractiveSessionExecutor for InteractiveExecutionUnavailable {
fn start(
&self,
_request: &InteractiveSessionRequest,
_request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
Err(ControlError::InvalidRequest)
}
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError> {
Ok(())
}
}
pub struct RunscNonInteractiveExecutor {

View file

@ -1,18 +1,20 @@
use super::{
AcceptedControlConnection, Arc, AsFd, AsRawFd, CONTROL_ALPN, CONTROL_SOCKET_NAME,
CertificateDigest, CertificateParams, ClientConfig, ClientConnection, ClientEnrollmentResponse,
ClientId, ConfinedPathPlan, ControlClient, ControlError, ControlPaths, ControlRequest,
ControlResponse, ControlSessionAdmission, Digest, DistinguishedName, DnType, Duration,
ExecutionUnavailable, ExtendedKeyUsagePurpose, File, FileExt, FileIdentity, FileTypeExt,
GenerationIdentity, HostCoordinatorDomainStore, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionExecutor, InteractiveSessionMessage, IsCa,
AcceptedControlConnection, Arc, AsFd, AsRawFd, CONTROL_ALPN, CONTROL_ALPN_V2,
CONTROL_SOCKET_NAME, CertificateDigest, CertificateParams, ClientConfig, ClientConnection,
ClientEnrollmentResponse, ClientId, ConfinedPathPlan, ControlClient, ControlError,
ControlPaths, ControlRequest, ControlResponse, ControlSessionAdmission, Digest,
DistinguishedName, DnType, Duration, ExecutionUnavailable, ExtendedKeyUsagePurpose, File,
FileExt, FileIdentity, FileTypeExt, GenerationIdentity, HostCoordinatorDomainStore,
InteractiveProtocolVersion, InteractiveSessionBatch, InteractiveSessionBatchV2,
InteractiveSessionEventV2, InteractiveSessionExecutor, InteractiveSessionMessage, IsCa,
KeyUsagePurpose, MAX_CONTROL_BYTES, MAX_ENROLLMENT_BYTES, MetadataExt, Mode,
NonInteractiveSessionExecutor, NonInteractiveSessionMessage, OFlags, OffsetDateTime,
PUBLICATION_IDENTITY_NAME, Path, PathBuf, PrivateKeyDer, ProtocolPublicationIdentity,
ProtocolVersion, PublicationOwnership, PublishedControlFace, Read, ServerConnection,
SessionBinding, SessionId, SessionPhase, SessionRegistrationResponse, SessionRegistry, Sha256,
SigningKey, StagedControlFace, TlsCredential, TrustAnchor, Write, decode, encode, fmt, io,
issue_session_capability, openat, publication_recovery_ownership, socket_peercred,
SessionBinding, SessionExit, SessionId, SessionPhase, SessionRegistrationResponse,
SessionRegistry, SessionTerminalMessage, Sha256, SigningKey, StagedControlFace, TerminalOutput,
TlsCredential, TrustAnchor, Write, decode, encode, fmt, io, issue_session_capability, openat,
publication_recovery_ownership, socket_peercred,
};
impl PublishedControlFace {
@ -317,9 +319,11 @@ impl PublishedControlFace {
while connection.is_handshaking() {
connection.complete_io(&mut socket)?;
}
if connection.alpn_protocol() != Some(CONTROL_ALPN) {
return Err(ControlError::InvalidRequest);
}
let interactive_v2 = match connection.alpn_protocol() {
Some(protocol) if protocol == CONTROL_ALPN_V2 => true,
Some(protocol) if protocol == CONTROL_ALPN => false,
_ => return Err(ControlError::InvalidRequest),
};
let peer_certificate = connection.peer_certificates().map(|certificates| {
certificates
.iter()
@ -332,6 +336,7 @@ impl PublishedControlFace {
Some(certificates) => self.serve_registration(
&mut stream,
&certificates,
interactive_v2,
&mut admit,
&mut registered,
&mut disconnect,
@ -362,6 +367,7 @@ impl PublishedControlFace {
&self,
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
certificates: &[Vec<u8>],
interactive_v2: bool,
admit: &mut Admit,
registered: &mut Registered,
disconnect: &mut Disconnect,
@ -410,55 +416,91 @@ impl PublishedControlFace {
}
Err(error) => return Err(error),
};
match request {
ControlRequest::Execute(request) => {
validate_command(request.session_id, session_id, &request.argv)?;
let mut send_message = |message: NonInteractiveSessionMessage| {
if matches!(message, NonInteractiveSessionMessage::Exit(_)) {
return Err(ControlError::InvalidRequest);
}
require_non_interactive_session(&message, request.session_id)?;
write_frame(stream, &message)
};
let outcome = self.executor.execute(&request, &mut send_message)?;
require_session_id(outcome.session_id(), request.session_id)?;
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
}
ControlRequest::ExecuteInteractive(request) => {
validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id {
return Err(ControlError::InvalidRequest);
}
let mut terminal = self.interactive_executor.start(&request)?;
let mut event = InteractiveSessionEvent::Poll { session_id };
loop {
let batch = terminal.exchange(event)?;
require_interactive_batch(&batch, session_id)?;
let complete = batch.messages.iter().any(|message| {
matches!(
message,
InteractiveSessionMessage::Terminal(
lazaret_core::records::SessionTerminalMessage::Exit(_)
)
)
});
write_frame(stream, &batch)?;
if complete {
break Ok(());
}
event = read_frame(stream, MAX_CONTROL_BYTES)?;
require_interactive_event(&event, session_id)?;
}
}
ControlRequest::Enroll(_) | ControlRequest::Register(_) => {
Err(ControlError::InvalidRequest)
}
}
self.serve_execution(stream, request, session_id, interactive_v2)
})();
let disconnected = disconnect(session_id);
result.and(disconnected)
}
fn serve_execution(
&self,
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
request: ControlRequest,
session_id: SessionId,
interactive_v2: bool,
) -> Result<(), ControlError> {
match request {
ControlRequest::Execute(request) if !interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?;
let mut send_message = |message: NonInteractiveSessionMessage| {
if matches!(message, NonInteractiveSessionMessage::Exit(_)) {
return Err(ControlError::InvalidRequest);
}
require_non_interactive_session(&message, request.session_id)?;
write_frame(stream, &message)
};
let outcome = self.executor.execute(&request, &mut send_message)?;
require_session_id(outcome.session_id(), request.session_id)?;
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
}
ControlRequest::ExecuteInteractive(request) if !interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id {
return Err(ControlError::InvalidRequest);
}
write_frame(
stream,
&InteractiveSessionBatch {
session_id,
messages: vec![
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(
TerminalOutput {
session_id,
bytes: b"Lazaret interactive protocol upgrade required\r\n"
.to_vec(),
},
)),
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(
SessionExit::runtime_lost(session_id),
)),
],
},
)
}
ControlRequest::ExecuteInteractiveV2(request) if interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id
|| request.protocol_version != InteractiveProtocolVersion::V2
{
return Err(ControlError::InvalidRequest);
}
let mut terminal = self.interactive_executor.start(&request)?;
let mut event = InteractiveSessionEventV2::Poll { session_id };
loop {
let batch: InteractiveSessionBatchV2 = terminal.exchange(event)?;
require_interactive_batch(&batch, session_id)?;
let complete = batch.messages.iter().any(|message| {
matches!(
message,
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(_))
)
});
write_frame(stream, &batch)?;
if complete {
break Ok(());
}
event = read_frame(stream, MAX_CONTROL_BYTES)?;
require_interactive_event(&event, session_id)?;
}
}
ControlRequest::Execute(_)
| ControlRequest::ExecuteInteractive(_)
| ControlRequest::ExecuteInteractiveV2(_)
| ControlRequest::Enroll(_)
| ControlRequest::Register(_) => Err(ControlError::InvalidRequest),
}
}
fn validate_publication(&self) -> Result<(), ControlError> {
self.paths.validate_root(self.uid)?;
let socket = std::fs::symlink_metadata(&self.paths.socket)?;
@ -613,24 +655,24 @@ fn require_session_id(actual: SessionId, expected: SessionId) -> Result<(), Cont
}
fn require_interactive_event(
event: &InteractiveSessionEvent,
event: &InteractiveSessionEventV2,
expected: SessionId,
) -> Result<(), ControlError> {
let actual = match event {
InteractiveSessionEvent::Terminal(event) => match event {
lazaret_core::records::SessionTerminalEvent::Input(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Resize(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Signal(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Close(event) => event.session_id,
InteractiveSessionEventV2::Terminal(event) => match event {
lazaret_core::records::SessionTerminalEventV2::Input(event) => event.session_id,
lazaret_core::records::SessionTerminalEventV2::Resize(event) => event.session_id,
lazaret_core::records::SessionTerminalEventV2::Break(event) => event.session_id,
lazaret_core::records::SessionTerminalEventV2::Close(event) => event.session_id,
},
InteractiveSessionEvent::PromptDecision { session_id, .. }
| InteractiveSessionEvent::Poll { session_id } => *session_id,
InteractiveSessionEventV2::PromptDecision { session_id, .. }
| InteractiveSessionEventV2::Poll { session_id } => *session_id,
};
require_session_id(actual, expected)
}
fn require_interactive_batch(
batch: &InteractiveSessionBatch,
batch: &InteractiveSessionBatchV2,
expected: SessionId,
) -> Result<(), ControlError> {
require_session_id(batch.session_id, expected)?;

View file

@ -167,6 +167,31 @@ fn rustls_refuses_client_leaf_with_server_role() {
assert!(server.is_err() || client.is_err());
}
#[test]
fn v2_client_fails_the_tls_handshake_with_a_v1_only_server() {
let anchor = TrustAnchor::create(generation()).unwrap();
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let certificate = leaf_parameters(
"client.lazaret.invalid",
ExtendedKeyUsagePurpose::ClientAuth,
OffsetDateTime::now_utc(),
)
.unwrap()
.signed_by(&key, &anchor.tls_ca)
.unwrap();
let mut client_config = client_config_with_certificate(
&anchor,
certificate.der().clone(),
PrivatePkcs8KeyDer::from(key.serialize_der()),
);
client_config.alpn_protocols = vec![CONTROL_ALPN_V2.to_vec()];
let mut server_config = anchor.server_config().unwrap();
server_config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
let (server, client) = handshake_pair(server_config, client_config);
assert!(server.is_err() || client.is_err());
}
#[test]
fn rustls_refuses_server_leaf_with_client_role() {
let anchor = TrustAnchor::create(generation()).unwrap();

View file

@ -14,13 +14,13 @@ use lazaret_core::identity::{
};
use lazaret_core::protocol::VerifiedSession;
use lazaret_core::records::{
DeveloperDecision, DomainStore, DurableDecision, GuestProcessIdentity, InteractiveSessionBatch,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest,
ParticipantOwnership, ParticipantRole, ParticipantState, PersistedHostProcessOwnership,
ProcessOwnershipCommitAck, ProtocolVersion, PtyIdentity, SessionExit, SessionPhase,
SessionTerminal, SessionTerminalEvent, SessionTerminalFields, SessionTerminalMessage,
SessionTerminalState, StagedProcessOwnership, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignalKind,
DeveloperDecision, DomainStore, DurableDecision, GuestProcessIdentity,
InteractiveProtocolVersion, InteractiveSessionBatchV2, InteractiveSessionEventV2,
InteractiveSessionMessage, InteractiveSessionRequestV2, ParticipantOwnership, ParticipantRole,
ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck, PtyIdentity,
SessionExit, SessionPhase, SessionTerminal, SessionTerminalEventV2, SessionTerminalFields,
SessionTerminalMessage, SessionTerminalState, StagedProcessOwnership, TerminalModesV2,
TerminalOutput, TerminalPrompt, TerminalResize,
};
use lazaret_linux::process::{
ChildStandardIo, ProcessExit, ProcessOwnershipContext, RetainedProcess, TargetCommand,
@ -29,7 +29,10 @@ use lazaret_linux::process::{
use lazaret_sandbox_contract::SANDBOX_INIT_DESTINATION;
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcsetwinsize};
use rustix::termios::{
ControlModes, InputModes, LocalModes, OptionalActions, OutputModes, SpecialCodeIndex, Winsize,
tcgetattr, tcsendbreak, tcsetattr, tcsetwinsize,
};
use sha2::{Digest, Sha256};
use crate::control::{ControlError, InteractiveControlSession, InteractiveSessionExecutor};
@ -440,6 +443,7 @@ impl PromptRouteCancellation for SessionPromptTransport {
pub trait SessionTerminalProcess: Send {
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize>;
fn resize(&self, resize: TerminalResize) -> io::Result<()>;
fn send_break(&self) -> io::Result<()>;
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize>;
fn exec_exited(&self) -> io::Result<bool>;
fn signal_group(&self, signal: &str) -> Result<(), TerminalError>;
@ -471,6 +475,8 @@ pub struct TerminalAcquisition {
pub environment: BTreeMap<String, EnvironmentValue>,
pub internal_pid_file: PathBuf,
pub argv: Vec<Vec<u8>>,
pub initial_size: TerminalResize,
pub initial_modes: TerminalModesV2,
}
pub struct HostCoordinatorTerminalRecorder {

View file

@ -3,12 +3,41 @@ use super::{
HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder, Instant, MAX_DRAIN_BYTES,
MAX_EVENT_BYTES, MAX_INPUT_QUEUE_BYTES, MAX_OUTPUT_BYTES, POLL_INTERVAL,
PersistedHostProcessOwnership, ProcessExit, PromptRouteCancellation, SIGNAL_GRACE, SessionExit,
SessionId, SessionTerminalEngine, SessionTerminalEvent, SessionTerminalFactory,
SessionId, SessionTerminalEngine, SessionTerminalEventV2, SessionTerminalFactory,
SessionTerminalMessage, SessionTerminalProcess, SessionTerminalState, TerminalAcquisition,
TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, TerminalSignalKind, VecDeque,
clean_terminal, copy_terminal_state, io, terminal_snapshot,
TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, VecDeque, clean_terminal,
copy_terminal_state, io, terminal_snapshot,
};
fn require_exec_absent<Open>(
ownership: &PersistedHostProcessOwnership,
open: &mut Open,
) -> Result<(), TerminalError>
where
Open: FnMut(
super::PidfsProcessIdentityV1,
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
{
let Ok(identity) = ownership.authoritative_pidfs() else {
return Ok(());
};
match open(*identity) {
Ok(lazaret_linux::process::PidfsRecoveryMatch::BootMismatch) => Ok(()),
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(process))
if process.has_exited()? =>
{
Ok(())
}
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(_)) => {
Err(TerminalError::AuthorityMismatch)
}
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error()) => {
Ok(())
}
Err(error) => Err(error.into()),
}
}
impl SessionTerminalEngine {
#[must_use]
pub fn new(
@ -58,6 +87,56 @@ impl SessionTerminalEngine {
Ok(())
}
pub(super) fn clean_after_sandbox_absence<Open>(
&self,
generation_id: GenerationId,
container_id: super::ContainerId,
host_coordinator: super::PidfsProcessIdentityV1,
mut open: Open,
) -> Result<(), TerminalError>
where
Open: FnMut(
super::PidfsProcessIdentityV1,
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
{
if self
.active
.values()
.any(|terminal| terminal.generation_id == generation_id)
{
return Err(TerminalError::AuthorityMismatch);
}
let snapshot = self.recorder.store.snapshot()?;
for terminal in snapshot
.session_terminals
.iter()
.filter(|terminal| terminal.generation_id() == generation_id)
{
if terminal.state() == SessionTerminalState::Clean {
continue;
}
let closed = snapshot.sessions.iter().any(|session| {
session.generation_id == generation_id
&& session.session_id == terminal.session_id()
&& matches!(
session.session_phase,
super::SessionPhase::Closed | super::SessionPhase::Failed
)
});
if terminal.state() != SessionTerminalState::ReleaseIntent
|| terminal.container_id() != Some(container_id)
|| terminal.host_coordinator().authoritative_pidfs().ok() != Some(&host_coordinator)
|| !closed
{
return Err(TerminalError::AuthorityMismatch);
}
require_exec_absent(terminal.exec_client(), &mut open)?;
self.recorder
.clean(clean_terminal(generation_id, terminal.session_id())?)?;
}
Ok(())
}
/// # Errors
/// Refuses stale, unknown, detached, or releasing Session Terminals.
pub fn set_route(
@ -83,15 +162,15 @@ impl SessionTerminalEngine {
pub fn handle(
&mut self,
generation_id: GenerationId,
event: SessionTerminalEvent,
event: SessionTerminalEventV2,
) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
let session_id = match &event {
SessionTerminalEvent::Input(event) => event.session_id,
SessionTerminalEvent::Resize(event) => event.session_id,
SessionTerminalEvent::Signal(event) => event.session_id,
SessionTerminalEvent::Close(event) => event.session_id,
SessionTerminalEventV2::Input(event) => event.session_id,
SessionTerminalEventV2::Resize(event) => event.session_id,
SessionTerminalEventV2::Break(event) => event.session_id,
SessionTerminalEventV2::Close(event) => event.session_id,
};
if matches!(event, SessionTerminalEvent::Close(_)) {
if matches!(event, SessionTerminalEventV2::Close(_)) {
return self.close(
session_id,
generation_id,
@ -112,7 +191,7 @@ impl SessionTerminalEngine {
return Err(TerminalError::InactiveRoute);
}
match event {
SessionTerminalEvent::Input(input) => {
SessionTerminalEventV2::Input(input) => {
if input.bytes.len() > MAX_EVENT_BYTES {
return Err(TerminalError::OversizedInput);
}
@ -123,20 +202,15 @@ impl SessionTerminalEngine {
flush_input(terminal)?;
Ok(Vec::new())
}
SessionTerminalEvent::Resize(resize) => {
SessionTerminalEventV2::Resize(resize) => {
terminal.process.resize(resize)?;
terminal.process.signal_group("WINCH")?;
Ok(Vec::new())
}
SessionTerminalEvent::Signal(signal) => {
terminal.process.signal_group(match signal.signal {
TerminalSignalKind::Interrupt => "INT",
TerminalSignalKind::Quit => "QUIT",
TerminalSignalKind::Suspend => "TSTP",
})?;
SessionTerminalEventV2::Break(_) => {
terminal.process.send_break()?;
Ok(Vec::new())
}
SessionTerminalEvent::Close(_) => unreachable!(),
SessionTerminalEventV2::Close(_) => unreachable!(),
}
}

View file

@ -1,12 +1,13 @@
use super::{
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
InteractiveControlSession, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionExecutor, InteractiveSessionMessage, InteractiveSessionRequest, Mutex,
OsString, PathBuf, PidfsProcessIdentityV1, PromptRouteCancellation, ProtocolVersion,
RunscSessionTerminalFactory, SessionId, SessionPromptTransport, SessionTerminal,
SessionTerminalEvent, SessionTerminalFactory, SessionTerminalMessage, SessionTerminalProcess,
TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute, VecDeque,
InteractiveControlSession, InteractiveProtocolVersion, InteractiveSessionBatchV2,
InteractiveSessionEventV2, InteractiveSessionExecutor, InteractiveSessionMessage,
InteractiveSessionRequestV2, Mutex, OsString, PathBuf, PidfsProcessIdentityV1,
PromptRouteCancellation, RunscSessionTerminalFactory, SessionId, SessionPromptTransport,
SessionTerminal, SessionTerminalEventV2, SessionTerminalFactory, SessionTerminalMessage,
SessionTerminalProcess, TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute,
VecDeque,
};
pub(super) struct ActiveTerminal {
@ -91,9 +92,9 @@ impl RunscInteractiveSessionExecutor {
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
fn start(
&self,
request: &InteractiveSessionRequest,
request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
if request.protocol_version != ProtocolVersion::V1
if request.protocol_version != InteractiveProtocolVersion::V2
|| request.initial_size.session_id != request.session_id
{
return Err(ControlError::InvalidRequest);
@ -113,22 +114,13 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
hex_session(request.session_id.as_bytes())
)),
argv: request.argv.clone(),
initial_size: request.initial_size,
initial_modes: request.initial_modes.clone(),
};
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
engine
.acquire(&acquisition)
.map_err(|error| terminal_control_error(&error))?;
if let Err(error) = engine.handle(
self.generation_id,
SessionTerminalEvent::Resize(request.initial_size),
) {
let _ = engine.lose(
request.session_id,
self.generation_id,
TerminalLoss::ClientDisconnected,
);
return Err(terminal_control_error(&error));
}
drop(engine);
Ok(Box::new(RunscInteractiveControlSession {
engine: Arc::clone(&self.engine),
@ -137,6 +129,19 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
session_id: request.session_id,
}))
}
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError> {
self.engine
.lock()
.map_err(|_| ControlError::LockPoisoned)?
.clean_after_sandbox_absence(
self.generation_id,
self.container_id,
self.host_coordinator,
lazaret_linux::process::match_recovery_process,
)
.map_err(|error| terminal_control_error(&error))
}
}
pub(super) struct RunscInteractiveControlSession {
@ -149,11 +154,11 @@ pub(super) struct RunscInteractiveControlSession {
impl InteractiveControlSession for RunscInteractiveControlSession {
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ControlError> {
event: InteractiveSessionEventV2,
) -> Result<InteractiveSessionBatchV2, ControlError> {
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
let mut messages = match event {
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(resize)) => {
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(resize)) => {
if self
.prompt_transport
.retain_prompt_resize(resize)
@ -162,17 +167,17 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
Vec::new()
} else {
engine
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
.handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
.map_err(|error| terminal_control_error(&error))?
}
}
InteractiveSessionEvent::Terminal(event) => engine
InteractiveSessionEventV2::Terminal(event) => engine
.handle(self.generation_id, event)
.map_err(|error| terminal_control_error(&error))?,
InteractiveSessionEvent::Poll { session_id } if session_id == self.session_id => {
InteractiveSessionEventV2::Poll { session_id } if session_id == self.session_id => {
Vec::new()
}
InteractiveSessionEvent::PromptDecision {
InteractiveSessionEventV2::PromptDecision {
session_id,
signed_decision,
} if session_id == self.session_id => {
@ -190,7 +195,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?;
if let Some(resize) = decision.resumed_size {
engine
.handle(self.generation_id, SessionTerminalEvent::Resize(resize))
.handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
.map_err(|error| terminal_control_error(&error))?;
}
}
@ -199,8 +204,8 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?;
Vec::new()
}
InteractiveSessionEvent::PromptDecision { .. }
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest),
InteractiveSessionEventV2::PromptDecision { .. }
| InteractiveSessionEventV2::Poll { .. } => return Err(ControlError::InvalidRequest),
};
if let Some(prompt) = self
.prompt_transport
@ -210,7 +215,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
engine
.set_route(self.session_id, self.generation_id, TerminalRoute::Prompt)
.map_err(|error| terminal_control_error(&error))?;
return Ok(InteractiveSessionBatch {
return Ok(InteractiveSessionBatchV2 {
session_id: self.session_id,
messages: vec![InteractiveSessionMessage::Prompt(prompt)],
});
@ -225,7 +230,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?,
);
}
Ok(InteractiveSessionBatch {
Ok(InteractiveSessionBatchV2 {
session_id: self.session_id,
messages: messages
.into_iter()

View file

@ -1,15 +1,16 @@
use super::{
Arc, ChildStandardIo, DeviceId, File, FileDescriptor, GenerationId, GuestProcessGroupId,
GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks,
HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, Instant, Mutex, OFlags,
OpenptFlags, OsString, POLL_INTERVAL, PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE,
PersistedHostProcessOwnership, PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck,
ProcessOwnershipContext, PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId,
SessionTerminal, SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess,
SessionTerminalState, StagedProcessOwnership, TargetCommand, TerminalAcquisition,
TerminalError, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl,
fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcsetwinsize,
unlockpt,
Arc, ChildStandardIo, ControlModes, DeviceId, File, FileDescriptor, GenerationId,
GuestProcessGroupId, GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks,
HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, InputModes, Instant, LocalModes,
Mutex, OFlags, OpenptFlags, OptionalActions, OsString, OutputModes, POLL_INTERVAL,
PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE, PersistedHostProcessOwnership,
PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck, ProcessOwnershipContext,
PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId, SessionTerminal,
SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess, SessionTerminalState,
SpecialCodeIndex, StagedProcessOwnership, TargetCommand, TerminalAcquisition, TerminalError,
TerminalModesV2, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl,
fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcgetattr,
tcsendbreak, tcsetattr, tcsetwinsize, unlockpt,
};
use std::io::{Read, Write};
use std::os::fd::AsRawFd;
@ -40,6 +41,12 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
return Err(TerminalError::AuthorityMismatch);
}
let (master, slave, pty) = allocate_pty()?;
if let Err(error) = configure_pty(&slave, &request.initial_modes, request.initial_size) {
drop(master);
drop(slave);
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)?;
return Err(error.into());
}
remove_pid_file(&request.internal_pid_file)?;
let mut target = TargetCommand::new(request.runsc_program.clone());
for argument in &request.runsc_global_arguments {
@ -308,6 +315,15 @@ impl SessionTerminalProcess for RunscSessionTerminal {
Ok(())
}
fn send_break(&self) -> io::Result<()> {
tcsendbreak(
self.master
.as_ref()
.ok_or_else(|| io::Error::other("PTY is closed"))?,
)?;
Ok(())
}
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
self.master
.as_mut()
@ -338,12 +354,13 @@ impl SessionTerminalProcess for RunscSessionTerminal {
}
fn group_absent(&self) -> Result<bool, TerminalError> {
if observe_guest_identity(&self.request, self.guest.leader_pid.get())?
.is_some_and(|observed| observed != self.guest)
{
let leader_matches = observe_guest_identity(&self.request, self.guest.leader_pid.get())?
.is_none_or(|observed| observed == self.guest);
let group_absent = observe_group_absent(&self.request, self.guest.process_group_id.get())?;
if !leader_matches && !group_absent {
return Err(TerminalError::GuestIdentityMismatch);
}
observe_group_absent(&self.request, self.guest.process_group_id.get())
Ok(group_absent)
}
fn terminate_exec(&self) -> io::Result<()> {
@ -393,6 +410,60 @@ fn allocate_pty() -> io::Result<(File, File, PtyIdentity)> {
Ok((File::from(master), File::from(slave), pty))
}
fn configure_pty(slave: &File, modes: &TerminalModesV2, size: TerminalResize) -> io::Result<()> {
if modes.line_discipline != 0 {
return Err(io::Error::other("unsupported terminal line discipline"));
}
let mut configured = tcgetattr(slave)?;
configured.input_modes = InputModes::from_bits(modes.input_modes)
.ok_or_else(|| io::Error::other("unsupported terminal input modes"))?;
configured.output_modes = OutputModes::from_bits(modes.output_modes)
.ok_or_else(|| io::Error::other("unsupported terminal output modes"))?;
configured.control_modes = ControlModes::from_bits(modes.control_modes)
.ok_or_else(|| io::Error::other("unsupported terminal control modes"))?;
configured.local_modes = LocalModes::from_bits(modes.local_modes)
.ok_or_else(|| io::Error::other("unsupported terminal local modes"))?;
configured.line_discipline = modes.line_discipline;
configured.set_input_speed(modes.input_speed)?;
configured.set_output_speed(modes.output_speed)?;
for (index, value) in terminal_control_codes(modes) {
configured.special_codes[index] = value;
}
tcsetattr(slave, OptionalActions::Now, &configured)?;
tcsetwinsize(
slave,
Winsize {
ws_row: size.rows.get(),
ws_col: size.columns.get(),
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
},
)?;
Ok(())
}
fn terminal_control_codes(modes: &TerminalModesV2) -> [(SpecialCodeIndex, u8); 17] {
[
(SpecialCodeIndex::VINTR, modes.interrupt),
(SpecialCodeIndex::VQUIT, modes.quit),
(SpecialCodeIndex::VERASE, modes.erase),
(SpecialCodeIndex::VKILL, modes.kill),
(SpecialCodeIndex::VEOF, modes.eof),
(SpecialCodeIndex::VTIME, modes.time),
(SpecialCodeIndex::VMIN, modes.minimum),
(SpecialCodeIndex::VSWTC, modes.switch),
(SpecialCodeIndex::VSTART, modes.start),
(SpecialCodeIndex::VSTOP, modes.stop),
(SpecialCodeIndex::VSUSP, modes.suspend),
(SpecialCodeIndex::VEOL, modes.end_of_line),
(SpecialCodeIndex::VREPRINT, modes.reprint),
(SpecialCodeIndex::VDISCARD, modes.discard),
(SpecialCodeIndex::VWERASE, modes.word_erase),
(SpecialCodeIndex::VLNEXT, modes.literal_next),
(SpecialCodeIndex::VEOL2, modes.end_of_line_2),
]
}
pub(super) fn staged_terminal(
request: &TerminalAcquisition,
pty: PtyIdentity,
@ -619,3 +690,71 @@ fn hex_lower(bytes: &[u8]) -> String {
output
})
}
#[cfg(test)]
mod configuration_tests {
use super::*;
use rustix::termios::tcgetwinsize;
#[test]
fn pty_modes_and_size_are_applied_before_guest_start() {
let (_master, slave, _) = allocate_pty().unwrap();
let mut original = tcgetattr(&slave).unwrap();
original.set_input_speed(original.input_speed()).unwrap();
original.set_output_speed(original.output_speed()).unwrap();
let mut modes = modes_from(&original);
modes.local_modes ^= LocalModes::ECHO.bits();
modes.interrupt = 7;
modes.erase = 8;
let size = TerminalResize {
session_id: SessionId::new([1; 32]),
rows: 55.try_into().unwrap(),
columns: 144.try_into().unwrap(),
pixel_width: 901,
pixel_height: 502,
};
configure_pty(&slave, &modes, size).unwrap();
let configured = tcgetattr(&slave).unwrap();
assert_eq!(modes_from(&configured), modes);
assert_eq!(
tcgetwinsize(&slave).unwrap(),
Winsize {
ws_row: 55,
ws_col: 144,
ws_xpixel: 901,
ws_ypixel: 502,
}
);
}
fn modes_from(modes: &rustix::termios::Termios) -> TerminalModesV2 {
TerminalModesV2 {
input_modes: modes.input_modes.bits(),
output_modes: modes.output_modes.bits(),
control_modes: modes.control_modes.bits(),
local_modes: modes.local_modes.bits(),
line_discipline: modes.line_discipline,
input_speed: modes.input_speed(),
output_speed: modes.output_speed(),
interrupt: modes.special_codes[SpecialCodeIndex::VINTR],
quit: modes.special_codes[SpecialCodeIndex::VQUIT],
erase: modes.special_codes[SpecialCodeIndex::VERASE],
kill: modes.special_codes[SpecialCodeIndex::VKILL],
eof: modes.special_codes[SpecialCodeIndex::VEOF],
time: modes.special_codes[SpecialCodeIndex::VTIME],
minimum: modes.special_codes[SpecialCodeIndex::VMIN],
switch: modes.special_codes[SpecialCodeIndex::VSWTC],
start: modes.special_codes[SpecialCodeIndex::VSTART],
stop: modes.special_codes[SpecialCodeIndex::VSTOP],
suspend: modes.special_codes[SpecialCodeIndex::VSUSP],
end_of_line: modes.special_codes[SpecialCodeIndex::VEOL],
reprint: modes.special_codes[SpecialCodeIndex::VREPRINT],
discard: modes.special_codes[SpecialCodeIndex::VDISCARD],
word_erase: modes.special_codes[SpecialCodeIndex::VWERASE],
literal_next: modes.special_codes[SpecialCodeIndex::VLNEXT],
end_of_line_2: modes.special_codes[SpecialCodeIndex::VEOL2],
}
}
}

View file

@ -15,7 +15,8 @@ use lazaret_core::identity::{
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
use lazaret_core::records::{
DeveloperDecisionKind, Domain, DurableDecision, Generation, GenerationIdentity, MailboxRequest,
Participant, SandboxOwnership, Session, SessionBinding, TerminalInput, TerminalSignal,
Participant, ProtocolVersion, SandboxOwnership, Session, SessionBinding, TerminalBreak,
TerminalInput,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use tempfile::TempDir;
@ -352,6 +353,17 @@ impl SessionTerminalProcess for MockProcess {
Ok(())
}
fn send_break(&self) -> io::Result<()> {
self.state
.lock()
.unwrap()
.log
.lock()
.unwrap()
.push("break".into());
Ok(())
}
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
let mut state = self.state.lock().unwrap();
let Some(output) = state.output.pop_front() else {
@ -399,7 +411,7 @@ impl SessionTerminalProcess for MockProcess {
if let Some(hook) = hook {
hook();
}
if identity_mismatch {
if identity_mismatch && !group_absent {
return Err(TerminalError::GuestIdentityMismatch);
}
Ok(group_absent)
@ -529,6 +541,43 @@ fn acquisition(session_id: SessionId) -> TerminalAcquisition {
environment: BTreeMap::new(),
internal_pid_file: "/tmp/not-used-by-mock".into(),
argv: vec![b"/bin/agent".to_vec()],
initial_size: TerminalResize {
session_id,
rows: NonZeroU16::new(24).unwrap(),
columns: NonZeroU16::new(80).unwrap(),
pixel_width: 0,
pixel_height: 0,
},
initial_modes: terminal_modes(),
}
}
fn terminal_modes() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 0,
output_modes: 0,
control_modes: 0,
local_modes: 0,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
}
}

Some files were not shown because too many files have changed in this diff Show more