Add interactive client terminal transport

This commit is contained in:
ms2k 2026-07-17 06:59:39 +09:00
commit 6816f0632e
3 changed files with 1454 additions and 59 deletions

View file

@ -9,16 +9,21 @@ use std::sync::Mutex;
use ed25519_dalek::SigningKey;
use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId};
use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, ProtocolPublicationIdentity, ProtocolVersion, SessionBinding,
SessionExitOutcome, SessionRegistrationRequest, TlsSubjectId,
ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionRequest, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
SessionRegistrationRequest, TlsSubjectId,
};
use lazaret_host_coordinator::control::{
ControlError, PendingClientEnrollment, PublishedControlFace,
};
use sha2::{Digest, Sha256};
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
pub mod production;
pub mod terminal;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientCommand {
@ -300,6 +305,45 @@ impl ClientSessionIdentity {
pub fn session_anchor_id(&self) -> SessionAnchorId {
SessionAnchorId::new(Sha256::digest(self.anchor.verifying_key().as_bytes()).into())
}
#[must_use]
pub fn session_anchor_public_key(&self) -> [u8; 32] {
self.anchor.verifying_key().to_bytes()
}
/// Signs one exact prompt decision with this Client Session's host-only Session Anchor.
///
/// # Errors
/// Returns a protocol encoding failure.
pub fn sign_developer_decision<E>(
&self,
session_binding: SessionBinding,
prompt: &lazaret_core::records::TerminalPrompt,
decision: DeveloperDecisionKind,
) -> Result<Vec<u8>, ClientError<E>> {
if session_binding.session_id != self.session_id
|| session_binding.cli_id != self.cli_id
|| prompt.session_id != self.session_id
{
return Err(ClientError::Protocol("prompt identity differs"));
}
lazaret_core::protocol::encode_signed(
&DeveloperDecision {
protocol_version: ProtocolVersion::V1,
session_binding,
request_id: prompt.request_id,
request_sha256: prompt.request_sha256,
decision,
approved_command_argv: if decision == DeveloperDecisionKind::Approve {
prompt.command_argv.clone()
} else {
Vec::new()
},
},
&self.anchor,
)
.map_err(|_| ClientError::Protocol("developer decision encoding failed"))
}
}
/// # Errors
@ -425,6 +469,137 @@ pub fn execute_over_control_face_as<E>(
}
}
/// Executes one interactive Session through the exact identity that owns its Session Anchor.
///
/// # Errors
/// Returns enrollment, registration, terminal, framing, or runtime-loss failures.
pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
endpoint: &ControlEndpoint,
argv: &[Vec<u8>],
terminal: &mut H,
identity: &ClientSessionIdentity,
) -> Result<i32, ClientError<E>> {
if argv.is_empty() {
return Err(ClientError::EmptyCommand);
}
let generation = endpoint.publication.generation_identity.clone();
let cli_id = identity.cli_id;
let session_id = identity.session_id;
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
let anonymous = PublishedControlFace::client_config(&endpoint.publication, None)?;
let mut enrollment =
PublishedControlFace::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
PublishedControlFace::send(
&mut enrollment,
&ControlRequest::Enroll(pending.request().clone()),
)?;
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) =
PublishedControlFace::receive(&mut enrollment)?
else {
return Err(ClientError::Protocol(
"enrollment response was not Enrolled",
));
};
if credential.generation_identity != generation
|| credential.subject_id() != TlsSubjectId::ClientCli(cli_id)
{
return Err(ClientError::Protocol(
"enrollment credential identity differs",
));
}
drop(enrollment);
let binding = SessionBinding {
generation_identity: generation,
cli_id,
session_id,
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
};
let authenticated = PublishedControlFace::client_config(
&endpoint.publication,
Some((&credential, pending.private_key())),
)?;
let mut stream =
PublishedControlFace::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
PublishedControlFace::send(
&mut stream,
&ControlRequest::Register(SessionRegistrationRequest {
protocol_version: ProtocolVersion::V1,
session_binding: binding.clone(),
}),
)?;
let ControlResponse::Registered(registered) = PublishedControlFace::receive(&mut stream)?
else {
return Err(ClientError::Protocol(
"registration response was not Registered",
));
};
if registered.session_capability.session_binding != binding {
return Err(ClientError::Protocol(
"registration capability identity differs",
));
}
endpoint.acknowledge_waiter_transfer()?;
let mut transport = ControlFaceInteractiveTransport { stream };
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>,
}
impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
type Error = std::convert::Infallible;
fn start(
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
PublishedControlFace::send(
&mut self.stream,
&ControlRequest::ExecuteInteractive(request),
)?;
receive_interactive_batch(&mut self.stream)
}
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
PublishedControlFace::send(&mut self.stream, &event).map_err(map_control_connection)?;
receive_interactive_batch(&mut self.stream)
}
}
fn receive_interactive_batch(
stream: &mut impl std::io::Read,
) -> Result<InteractiveSessionBatch, ClientError<std::convert::Infallible>> {
PublishedControlFace::receive(stream).map_err(map_control_connection)
}
fn map_control_connection(error: ControlError) -> ClientError<std::convert::Infallible> {
match error {
ControlError::Io(_) => ClientError::LostConnection,
other => ClientError::Control(other),
}
}
fn widen_client_error<E>(error: ClientError<std::convert::Infallible>) -> ClientError<E> {
match error {
ClientError::Configuration(message) => ClientError::Configuration(message),
ClientError::UnsupportedHost(message) => ClientError::UnsupportedHost(message),
ClientError::StaleCoordinator => ClientError::StaleCoordinator,
ClientError::LostConnection => ClientError::LostConnection,
ClientError::BootstrapOwnerDisconnected => ClientError::BootstrapOwnerDisconnected,
ClientError::WaitingDomainFailed => ClientError::WaitingDomainFailed,
ClientError::EmptyCommand => ClientError::EmptyCommand,
ClientError::Backend(error) => match error {},
ClientError::Io(error) => ClientError::Io(error),
ClientError::Control(error) => ClientError::Control(error),
ClientError::Protocol(message) => ClientError::Protocol(message),
}
}
fn random_bytes<E>() -> Result<[u8; 32], ClientError<E>> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes)

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::fs::File;
use std::io::{self, Write};
use std::io::{self, IsTerminal, Write};
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
@ -27,7 +27,8 @@ use rustix::pipe::{PipeFlags, pipe_with};
use crate::{
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, ClientSessionIdentity,
ControlEndpoint, DomainStatus, execute_over_control_face_as,
ControlEndpoint, DomainStatus, execute_interactive_over_control_face_as,
execute_over_control_face_as,
};
#[derive(Debug)]
@ -335,7 +336,17 @@ impl ClientBackend for ProductionClientBackend {
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> Result<i32, ClientError<Self::Error>> {
let exit = execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)?;
let exit = if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
let mut terminal = crate::terminal::SystemTerminal::open()?;
execute_interactive_over_control_face_as(
&endpoint,
argv,
&mut terminal,
&self.identity,
)?
} else {
execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)?
};
if let Some(coordinator) = self.coordinator.take() {
let status = coordinator
.reap()

View file

@ -0,0 +1,389 @@
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
use std::num::NonZeroU16;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use lazaret_core::records::{
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionMessage, InteractiveSessionRequest, ProtocolVersion, SessionBinding,
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput,
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind,
};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr};
use signal_hook::SigId;
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGTSTP, SIGWINCH};
use crate::{ClientError, ClientSessionIdentity};
pub trait InteractiveSessionTransport {
type Error;
/// # Errors
/// Returns an authenticated session or framing failure.
fn start(
&mut self,
request: InteractiveSessionRequest,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
/// # Errors
/// Returns a lost connection, stale session, or framing failure.
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HostTerminalEvent {
Input(Vec<u8>),
Signal(TerminalSignalKind),
Resize,
LocalSuspend,
Terminate,
Idle,
}
pub trait HostTerminal {
/// # Errors
/// Returns an error when raw mode cannot be entered.
fn enter_raw(&mut self) -> io::Result<()>;
/// # Errors
/// Returns an error when the original terminal mode cannot be restored.
fn restore(&mut self) -> io::Result<()>;
/// # Errors
/// Returns an error when the current terminal size cannot be observed.
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize>;
/// # Errors
/// Returns an error while observing terminal input or signals.
fn next_event(&mut self) -> io::Result<HostTerminalEvent>;
/// # Errors
/// Returns an error while writing merged terminal output.
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
/// # Errors
/// Returns an error while collecting one explicit developer decision.
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind>;
/// # Errors
/// Returns an error restoring, stopping, or re-entering raw mode.
fn suspend_and_resume(&mut self) -> io::Result<()>;
}
pub struct SystemTerminal {
terminal: File,
original: Termios,
original_flags: OFlags,
raw: bool,
signals: SignalFlags,
}
impl SystemTerminal {
/// Opens the controlling terminal and captures its exact initial mode.
///
/// # Errors
/// Returns an error when the process has no usable controlling terminal.
pub fn open() -> io::Result<Self> {
let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
Ok(Self {
original: tcgetattr(&terminal)?,
original_flags: fcntl_getfl(&terminal)?,
terminal,
raw: false,
signals: SignalFlags::register()?,
})
}
}
impl HostTerminal for SystemTerminal {
fn enter_raw(&mut self) -> io::Result<()> {
if self.raw {
return Ok(());
}
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);
return Err(error.into());
}
self.raw = true;
Ok(())
}
fn restore(&mut self) -> io::Result<()> {
if !self.raw {
return Ok(());
}
fcntl_setfl(&self.terminal, self.original_flags)?;
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
self.raw = false;
Ok(())
}
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize> {
let size = tcgetwinsize(&self.terminal)?;
Ok(TerminalResize {
session_id,
rows: NonZeroU16::new(size.ws_row)
.ok_or_else(|| io::Error::other("terminal reported zero rows"))?,
columns: NonZeroU16::new(size.ws_col)
.ok_or_else(|| io::Error::other("terminal reported zero columns"))?,
pixel_width: size.ws_xpixel,
pixel_height: size.ws_ypixel,
})
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
if self.signals.terminate.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Terminate);
}
if self.signals.suspend.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::LocalSuspend);
}
if self.signals.resize.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Resize);
}
if self.signals.interrupt.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt));
}
if self.signals.quit.swap(false, Ordering::SeqCst) {
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Quit));
}
let mut bytes = vec![0_u8; 16 * 1024];
match self.terminal.read(&mut bytes) {
Ok(0) => Ok(HostTerminalEvent::Terminate),
Ok(length) => {
bytes.truncate(length);
Ok(HostTerminalEvent::Input(bytes))
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
Ok(HostTerminalEvent::Idle)
}
Err(error) => Err(error),
}
}
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()> {
self.terminal.write_all(bytes)?;
self.terminal.flush()
}
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
self.write_output(
format!(
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
prompt.agent_profile,
hex(prompt.session_id.as_bytes()),
display_argv(&prompt.command_argv),
prompt.justification
)
.as_bytes(),
)?;
loop {
match self.next_event()? {
HostTerminalEvent::Input(bytes) => {
let approve = bytes
.first()
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
return Ok(if approve {
DeveloperDecisionKind::Approve
} else {
DeveloperDecisionKind::Deny
});
}
HostTerminalEvent::LocalSuspend => self.suspend_and_resume()?,
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
HostTerminalEvent::Idle
| HostTerminalEvent::Resize
| HostTerminalEvent::Signal(_) => {}
}
}
}
fn suspend_and_resume(&mut self) -> io::Result<()> {
self.restore()?;
lazaret_linux::process::suspend_current_process()?;
self.enter_raw()
}
}
impl Drop for SystemTerminal {
fn drop(&mut self) {
let _ = self.restore();
}
}
struct SignalFlags {
interrupt: Arc<AtomicBool>,
quit: Arc<AtomicBool>,
suspend: Arc<AtomicBool>,
resize: Arc<AtomicBool>,
terminate: Arc<AtomicBool>,
registrations: Vec<SigId>,
}
impl SignalFlags {
fn register() -> io::Result<Self> {
let interrupt = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false));
let suspend = Arc::new(AtomicBool::new(false));
let resize = Arc::new(AtomicBool::new(false));
let terminate = Arc::new(AtomicBool::new(false));
let registrations = vec![
signal_hook::flag::register(SIGINT, Arc::clone(&interrupt))?,
signal_hook::flag::register(SIGQUIT, Arc::clone(&quit))?,
signal_hook::flag::register(SIGTSTP, Arc::clone(&suspend))?,
signal_hook::flag::register(SIGWINCH, Arc::clone(&resize))?,
signal_hook::flag::register(SIGTERM, Arc::clone(&terminate))?,
signal_hook::flag::register(SIGHUP, Arc::clone(&terminate))?,
];
Ok(Self {
interrupt,
quit,
suspend,
resize,
terminate,
registrations,
})
}
}
impl Drop for SignalFlags {
fn drop(&mut self) {
for registration in self.registrations.drain(..) {
signal_hook::low_level::unregister(registration);
}
}
}
/// Runs one interactive terminal route until its exact Session exit.
///
/// # Errors
/// Returns terminal, prompt-signing, stale-session, or lost-connection failures.
pub fn run_interactive_session<T: InteractiveSessionTransport, H: HostTerminal>(
transport: &mut T,
terminal: &mut H,
identity: &ClientSessionIdentity,
binding: &SessionBinding,
argv: &[Vec<u8>],
) -> Result<i32, ClientError<T::Error>> {
terminal.enter_raw()?;
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,
session_id,
argv: argv.to_vec(),
initial_size,
})?;
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)
}
};
}
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)?,
))
}
HostTerminalEvent::Terminate => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Close(SessionClose { session_id }),
),
HostTerminalEvent::Idle => InteractiveSessionEvent::Poll { session_id },
};
batch = transport.exchange(event)?;
}
})();
let restoration = terminal.restore().map_err(ClientError::Io);
restoration?;
result
}
fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
transport: &mut T,
terminal: &mut H,
identity: &ClientSessionIdentity,
binding: &SessionBinding,
batch: InteractiveSessionBatch,
) -> Result<Option<lazaret_core::records::SessionExit>, ClientError<T::Error>> {
if batch.session_id != identity.session_id() {
return Err(ClientError::Protocol("response Session ID differs"));
}
for message in batch.messages {
match message {
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(output)) => {
require_session(output.session_id, identity.session_id())?;
terminal.write_output(&output.bytes)?;
}
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(exit)) => {
require_session(exit.session_id(), identity.session_id())?;
return Ok(Some(exit));
}
InteractiveSessionMessage::Prompt(prompt) => {
require_session(prompt.session_id, identity.session_id())?;
let decision = terminal.prompt(&prompt)?;
let signed_decision =
identity.sign_developer_decision(binding.clone(), &prompt, decision)?;
let response = transport.exchange(InteractiveSessionEvent::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)
}
fn require_session<E>(
actual: lazaret_core::identity::SessionId,
expected: lazaret_core::identity::SessionId,
) -> Result<(), ClientError<E>> {
if actual == expected {
Ok(())
} else {
Err(ClientError::Protocol("response Session ID differs"))
}
}
fn display_argv(argv: &[Vec<u8>]) -> String {
argv.iter()
.map(|argument| String::from_utf8_lossy(argument))
.collect::<Vec<_>>()
.join(" ")
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes.iter().fold(String::new(), |mut output, byte| {
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
output
})
}