use std::fmt; use std::fs::File; use std::io::{self, Write}; use std::net::Shutdown; use std::path::PathBuf; use std::sync::{Mutex, mpsc}; use std::thread::JoinHandle; use std::time::Duration; use ed25519_dalek::SigningKey; use lazaret_control_client::{ControlClient, ControlClientError, PendingClientEnrollment}; use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId}; use lazaret_core::records::{ ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision, DeveloperDecisionKind, InteractiveSessionBatchV2, InteractiveSessionEventV2, InteractiveSessionRequestV2, NonInteractiveSessionMessage, NonInteractiveSessionRequest, ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome, SessionRegistrationRequest, TlsSubjectId, }; use sha2::{Digest, Sha256}; use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session}; pub mod cli; pub mod coordinator_backend; mod domain_observation; pub mod private_roles; pub mod state; pub mod terminal; pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientCommand { pub profile: String, pub workspace: PathBuf, pub configuration: Option, pub startup_timeout: Duration, pub argv: Vec>, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum DomainStatus { Absent, Starting, Ready(E), Stale, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BootstrapAcquisition { Leader, Waiter, } #[derive(Debug)] pub enum ClientError { Configuration(String), UnsupportedHost(String), StaleCoordinator, LostConnection, BootstrapOwnerDisconnected, WaitingDomainFailed, EmptyCommand, Backend(E), Io(io::Error), Control(ControlClientError), Protocol(&'static str), } impl fmt::Display for ClientError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Configuration(message) => write!(formatter, "configuration is invalid: {message}"), Self::UnsupportedHost(message) => write!(formatter, "host is unsupported: {message}"), Self::StaleCoordinator => formatter.write_str( "the Domain coordinator is stale; remove no files manually and run Lazaret recovery", ), Self::LostConnection => formatter.write_str( "the Domain coordinator connection was lost before the command completed", ), Self::BootstrapOwnerDisconnected => formatter.write_str( "the Domain bootstrap owner disconnected before publication", ), Self::WaitingDomainFailed => formatter.write_str( "the Domain failed while this Client was waiting; recovery is required before retry", ), Self::EmptyCommand => formatter.write_str("a command is required after `--`"), Self::Backend(error) => error.fmt(formatter), Self::Io(error) => write!(formatter, "standard stream forwarding failed: {error}"), Self::Control(error) => error.fmt(formatter), Self::Protocol(message) => write!(formatter, "control protocol failed: {message}"), } } } impl std::error::Error for ClientError {} pub trait ClientBackend { type Endpoint; type Error; /// # Errors /// Returns actionable host or configuration validation failures. fn validate(&mut self, command: &ClientCommand) -> Result<(), ClientError>; /// # Errors /// Returns failures while inspecting current Domain coordination state. fn observe(&mut self) -> Result, Self::Error>; /// # Errors /// Returns failures while attempting exclusive bootstrap leadership. fn acquire_bootstrap(&mut self) -> Result; /// # Errors /// Returns startup failure or loss of the bootstrap owner. fn bootstrap(&mut self) -> Result>; /// # Errors /// Returns wait failure, stale publication, or bootstrap-owner loss. fn wait_until_ready(&mut self) -> Result>; /// # Errors /// Returns session registration, stream forwarding, runtime, or connection failure. fn execute( &mut self, endpoint: Self::Endpoint, argv: &[Vec], stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> Result>; } /// # Errors /// Returns validation, coordination, bootstrap, connection, or command execution failures. pub fn run( backend: &mut B, command: &ClientCommand, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> Result> { if command.argv.is_empty() { return Err(ClientError::EmptyCommand); } backend.validate(command)?; let endpoint = match backend.observe().map_err(ClientError::Backend)? { DomainStatus::Ready(endpoint) => endpoint, DomainStatus::Starting => backend.wait_until_ready()?, DomainStatus::Stale | DomainStatus::Absent => { match backend.acquire_bootstrap().map_err(ClientError::Backend)? { BootstrapAcquisition::Leader => match backend.bootstrap() { Ok(endpoint) => endpoint, Err(ClientError::BootstrapOwnerDisconnected) => { return Err(ClientError::BootstrapOwnerDisconnected); } Err(error) => return Err(error), }, BootstrapAcquisition::Waiter => backend.wait_until_ready()?, } } }; backend.execute(endpoint, &command.argv, stdout, stderr) } pub struct ControlEndpoint { pub socket_path: PathBuf, pub publication: ProtocolPublicationIdentity, socket_authority: Option, waiter_transfer: Mutex>, } impl ControlEndpoint { #[must_use] pub fn new(socket_path: PathBuf, publication: ProtocolPublicationIdentity) -> Self { Self { socket_path, publication, socket_authority: None, waiter_transfer: Mutex::new(None), } } pub(crate) fn confined( socket_path: PathBuf, publication: ProtocolPublicationIdentity, authority: File, ) -> Self { Self { socket_path, publication, socket_authority: Some(authority), waiter_transfer: Mutex::new(None), } } pub(crate) fn retain_waiter_transfer(mut self, waiter: std::os::unix::net::UnixStream) -> Self { self.waiter_transfer = Mutex::new(Some(waiter)); self } fn acknowledge_waiter_transfer(&self) -> Result<(), ClientError> { self.waiter_transfer .lock() .map_err(|_| ClientError::Protocol("waiter transfer lock is poisoned"))? .take(); let _ = &self.socket_authority; Ok(()) } } pub struct ClientSessionIdentity { cli_id: ClientId, session_id: SessionId, anchor: SigningKey, } impl ClientSessionIdentity { /// Creates one fresh CLI, Session, and host-private Session Anchor identity. /// /// # Errors /// Returns an error when operating-system randomness is unavailable. pub fn generate() -> Result> { Ok(Self { cli_id: ClientId::new(random_bytes()?), session_id: SessionId::new(random_bytes()?), anchor: SigningKey::from_bytes(&random_bytes()?), }) } #[must_use] pub const fn cli_id(&self) -> ClientId { self.cli_id } #[must_use] pub const fn session_id(&self) -> SessionId { self.session_id } #[must_use] 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( &self, session_binding: SessionBinding, prompt: &lazaret_core::records::TerminalPrompt, decision: DeveloperDecisionKind, ) -> Result, ClientError> { 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 /// Returns enrollment, registration, framing, stream forwarding, or runtime-loss failures. pub fn execute_over_control_face( endpoint: &ControlEndpoint, argv: &[Vec], stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> Result> { let identity = ClientSessionIdentity::generate()?; execute_over_control_face_as(endpoint, argv, stdout, stderr, &identity) } /// Executes through the exact identity that owns cold-start leadership. /// /// # Errors /// Returns enrollment, registration, framing, stream forwarding, or runtime-loss failures. pub fn execute_over_control_face_as( endpoint: &ControlEndpoint, argv: &[Vec], stdout: &mut dyn Write, stderr: &mut dyn Write, identity: &ClientSessionIdentity, ) -> Result> { 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 = ControlClient::config(&endpoint.publication, None)?; let mut enrollment = ControlClient::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?; ControlClient::send( &mut enrollment, &ControlRequest::Enroll(pending.request().clone()), )?; let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) = ControlClient::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 = ControlClient::config( &endpoint.publication, Some((&credential, pending.private_key())), )?; let mut stream = ControlClient::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?; ControlClient::send( &mut stream, &ControlRequest::Register(SessionRegistrationRequest { protocol_version: ProtocolVersion::V1, session_binding: binding.clone(), }), )?; let ControlResponse::Registered(registered) = ControlClient::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()?; ControlClient::send( &mut stream, &ControlRequest::Execute(NonInteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id, argv: argv.to_vec(), }), )?; loop { let message = ControlClient::receive(&mut stream).map_err(|error| match error { ControlClientError::Io(_) => ClientError::LostConnection, other => ClientError::Control(other), })?; match message { NonInteractiveSessionMessage::Stdout(output) => { require_session(output.session_id, session_id)?; stdout.write_all(&output.bytes)?; stdout.flush()?; } NonInteractiveSessionMessage::Stderr(output) => { require_session(output.session_id, session_id)?; stderr.write_all(&output.bytes)?; stderr.flush()?; } NonInteractiveSessionMessage::Exit(exit) => { require_session(exit.session_id(), session_id)?; 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) } }; } } } } /// 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( endpoint: &ControlEndpoint, argv: &[Vec], terminal: &mut H, identity: &ClientSessionIdentity, ) -> Result> { 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 = ControlClient::config_v2(&endpoint.publication, None)?; let mut enrollment = ControlClient::connect_v2(&endpoint.socket_path, anonymous, &endpoint.publication)?; ControlClient::send( &mut enrollment, &ControlRequest::Enroll(pending.request().clone()), )?; let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) = ControlClient::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 = ControlClient::config_v2( &endpoint.publication, Some((&credential, pending.private_key())), )?; let mut stream = ControlClient::connect_v2(&endpoint.socket_path, authenticated, &endpoint.publication)?; ControlClient::send( &mut stream, &ControlRequest::Register(SessionRegistrationRequest { protocol_version: ProtocolVersion::V1, session_binding: binding.clone(), }), )?; let ControlResponse::Registered(registered) = ControlClient::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::new(stream).map_err(widen_client_error)?; run_interactive_session(&mut transport, terminal, identity, &binding, argv) .map_err(widen_client_error) } struct ControlFaceInteractiveTransport { commands: Option>, batches: mpsc::Receiver>>, cancellation: std::os::unix::net::UnixStream, worker: Option>, } enum InteractiveTransportCommand { Start(InteractiveSessionRequestV2), Event(InteractiveSessionEventV2), } impl ControlFaceInteractiveTransport { fn new( mut stream: rustls::StreamOwned, ) -> Result> { 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> { self.commands .as_ref() .ok_or(ClientError::LostConnection)? .send(command) .map_err(|_| ClientError::LostConnection) } } impl InteractiveSessionTransport for ControlFaceInteractiveTransport { type Error = std::convert::Infallible; fn start( &mut self, request: InteractiveSessionRequestV2, ) -> Result<(), ClientError> { self.submit(InteractiveTransportCommand::Start(request)) } fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError> { self.submit(InteractiveTransportCommand::Event(event)) } fn try_receive( &mut self, ) -> Result, ClientError> { 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> { ControlClient::receive(stream).map_err(map_control_connection) } fn map_control_connection(error: ControlClientError) -> ClientError { match error { ControlClientError::Io(_) => ClientError::LostConnection, other => ClientError::Control(other), } } fn widen_client_error(error: ClientError) -> ClientError { 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() -> Result<[u8; 32], ClientError> { let mut bytes = [0_u8; 32]; getrandom::fill(&mut bytes) .map_err(|_| ClientError::Protocol("operating-system randomness is unavailable"))?; Ok(bytes) } fn require_session(actual: SessionId, expected: SessionId) -> Result<(), ClientError> { if actual == expected { Ok(()) } else { Err(ClientError::Protocol("response Session ID differs")) } } impl From for ClientError { fn from(error: io::Error) -> Self { Self::Io(error) } } impl From for ClientError { fn from(error: ControlClientError) -> Self { Self::Control(error) } }