Complete authenticated interactive prompt routing
This commit is contained in:
parent
6816f0632e
commit
46b52b4b43
1 changed files with 595 additions and 96 deletions
|
|
@ -1,8 +1,6 @@
|
|||
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::{
|
||||
|
|
@ -11,10 +9,10 @@ use lazaret_core::records::{
|
|||
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput,
|
||||
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind,
|
||||
};
|
||||
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 signal_hook::SigId;
|
||||
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGTSTP, SIGWINCH};
|
||||
|
||||
use crate::{ClientError, ClientSessionIdentity};
|
||||
|
||||
|
|
@ -64,18 +62,24 @@ pub trait HostTerminal {
|
|||
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>;
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome>;
|
||||
/// # Errors
|
||||
/// Returns an error restoring, stopping, or re-entering raw mode.
|
||||
fn suspend_and_resume(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PromptOutcome {
|
||||
pub decision: DeveloperDecisionKind,
|
||||
pub resumed_size: Option<TerminalResize>,
|
||||
}
|
||||
|
||||
pub struct SystemTerminal {
|
||||
terminal: File,
|
||||
original: Termios,
|
||||
original_flags: OFlags,
|
||||
raw: bool,
|
||||
signals: SignalFlags,
|
||||
signals: SignalQueue,
|
||||
}
|
||||
|
||||
impl SystemTerminal {
|
||||
|
|
@ -90,7 +94,7 @@ impl SystemTerminal {
|
|||
original_flags: fcntl_getfl(&terminal)?,
|
||||
terminal,
|
||||
raw: false,
|
||||
signals: SignalFlags::register()?,
|
||||
signals: SignalQueue::register()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -136,20 +140,8 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
|
||||
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));
|
||||
if let Some(event) = self.signals.next_event()? {
|
||||
return Ok(event);
|
||||
}
|
||||
let mut bytes = vec![0_u8; 16 * 1024];
|
||||
match self.terminal.read(&mut bytes) {
|
||||
|
|
@ -171,7 +163,7 @@ impl HostTerminal for SystemTerminal {
|
|||
self.terminal.flush()
|
||||
}
|
||||
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
||||
self.write_output(
|
||||
format!(
|
||||
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
|
||||
|
|
@ -182,6 +174,7 @@ impl HostTerminal for SystemTerminal {
|
|||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
let mut resumed_size = None;
|
||||
loop {
|
||||
match self.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => {
|
||||
|
|
@ -189,13 +182,19 @@ impl HostTerminal for SystemTerminal {
|
|||
.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
|
||||
return Ok(PromptOutcome {
|
||||
decision: if approve {
|
||||
DeveloperDecisionKind::Approve
|
||||
} else {
|
||||
DeveloperDecisionKind::Deny
|
||||
},
|
||||
resumed_size,
|
||||
});
|
||||
}
|
||||
HostTerminalEvent::LocalSuspend => self.suspend_and_resume()?,
|
||||
HostTerminalEvent::LocalSuspend => {
|
||||
self.suspend_and_resume()?;
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
}
|
||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||
HostTerminalEvent::Idle
|
||||
| HostTerminalEvent::Resize
|
||||
|
|
@ -214,52 +213,79 @@ impl HostTerminal for SystemTerminal {
|
|||
impl Drop for SystemTerminal {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.restore();
|
||||
self.signals.restore();
|
||||
}
|
||||
}
|
||||
|
||||
struct SignalFlags {
|
||||
interrupt: Arc<AtomicBool>,
|
||||
quit: Arc<AtomicBool>,
|
||||
suspend: Arc<AtomicBool>,
|
||||
resize: Arc<AtomicBool>,
|
||||
terminate: Arc<AtomicBool>,
|
||||
registrations: Vec<SigId>,
|
||||
struct SignalQueue {
|
||||
fd: SignalFd,
|
||||
original_mask: SigSet,
|
||||
restored: bool,
|
||||
}
|
||||
|
||||
impl SignalFlags {
|
||||
impl SignalQueue {
|
||||
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))?,
|
||||
];
|
||||
let mut mask = SigSet::empty();
|
||||
for signal in [
|
||||
Signal::SIGINT,
|
||||
Signal::SIGQUIT,
|
||||
Signal::SIGTSTP,
|
||||
Signal::SIGWINCH,
|
||||
Signal::SIGTERM,
|
||||
Signal::SIGHUP,
|
||||
] {
|
||||
mask.add(signal);
|
||||
}
|
||||
let original_mask = mask
|
||||
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
|
||||
.map_err(errno_to_io)?;
|
||||
let fd = match SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC) {
|
||||
Ok(fd) => fd,
|
||||
Err(error) => {
|
||||
let _ = original_mask.thread_swap_mask(SigmaskHow::SIG_SETMASK);
|
||||
return Err(errno_to_io(error));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
interrupt,
|
||||
quit,
|
||||
suspend,
|
||||
resize,
|
||||
terminate,
|
||||
registrations,
|
||||
fd,
|
||||
original_mask,
|
||||
restored: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SignalFlags {
|
||||
fn drop(&mut self) {
|
||||
for registration in self.registrations.drain(..) {
|
||||
signal_hook::low_level::unregister(registration);
|
||||
fn next_event(&mut self) -> io::Result<Option<HostTerminalEvent>> {
|
||||
let Some(info) = self.fd.read_signal().map_err(errno_to_io)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
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::SIGTSTP => Some(HostTerminalEvent::LocalSuspend),
|
||||
Signal::SIGWINCH => Some(HostTerminalEvent::Resize),
|
||||
Signal::SIGTERM | Signal::SIGHUP => Some(HostTerminalEvent::Terminate),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore(&mut self) {
|
||||
if !self.restored {
|
||||
let _ = self.original_mask.thread_swap_mask(SigmaskHow::SIG_SETMASK);
|
||||
self.restored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SignalQueue {
|
||||
fn drop(&mut self) {
|
||||
self.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn errno_to_io(error: nix::errno::Errno) -> io::Error {
|
||||
io::Error::from_raw_os_error(error as i32)
|
||||
}
|
||||
|
||||
/// Runs one interactive terminal route until its exact Session exit.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -344,9 +370,19 @@ fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
|
|||
}
|
||||
InteractiveSessionMessage::Prompt(prompt) => {
|
||||
require_session(prompt.session_id, identity.session_id())?;
|
||||
let decision = terminal.prompt(&prompt)?;
|
||||
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));
|
||||
}
|
||||
}
|
||||
let signed_decision =
|
||||
identity.sign_developer_decision(binding.clone(), &prompt, decision)?;
|
||||
identity.sign_developer_decision(binding.clone(), &prompt, outcome.decision)?;
|
||||
let response = transport.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
session_id: identity.session_id(),
|
||||
signed_decision,
|
||||
|
|
@ -387,3 +423,44 @@ fn hex(bytes: &[u8]) -> String {
|
|||
output
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
mod tests {
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
use std::process::Command;
|
||||
|
||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
||||
use super::SignalQueue;
|
||||
|
||||
#[test]
|
||||
fn signal_scope_restores_mask_and_default_termination() {
|
||||
if std::env::var_os("LAZARET_SIGNAL_SCOPE_CHILD").is_some() {
|
||||
let signals = SignalQueue::register().unwrap();
|
||||
drop(signals);
|
||||
kill(Pid::this(), Signal::SIGTERM).unwrap();
|
||||
panic!("restored SIGTERM did not terminate the process");
|
||||
}
|
||||
|
||||
let observed = SigSet::empty()
|
||||
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
|
||||
.unwrap();
|
||||
let signals = SignalQueue::register().unwrap();
|
||||
drop(signals);
|
||||
let restored = SigSet::empty()
|
||||
.thread_swap_mask(SigmaskHow::SIG_BLOCK)
|
||||
.unwrap();
|
||||
assert_eq!(restored, observed);
|
||||
|
||||
let status = Command::new(std::env::current_exe().unwrap())
|
||||
.arg("--exact")
|
||||
.arg("terminal::tests::signal_scope_restores_mask_and_default_termination")
|
||||
.arg("--nocapture")
|
||||
.env("LAZARET_SIGNAL_SCOPE_CHILD", "1")
|
||||
.status()
|
||||
.unwrap();
|
||||
assert_eq!(status.signal(), Some(nix::libc::SIGTERM));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue