Implement PTY-native session terminal control
This commit is contained in:
parent
058e58e816
commit
8cef037fa3
13 changed files with 1488 additions and 382 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
|
|
@ -102,7 +102,7 @@ fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"refused".to_vec(),
|
||||
}),
|
||||
|
|
@ -142,7 +142,7 @@ fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -183,7 +183,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -213,7 +213,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
|
|||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -236,9 +236,8 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(8),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
SessionTerminalEventV2::Break(TerminalBreak {
|
||||
session_id: session
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::StaleGeneration)
|
||||
|
|
@ -266,14 +265,14 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn resize_and_terminal_signals_use_the_exact_group_map() {
|
||||
fn resize_break_and_input_use_only_the_exact_pty() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Resize(TerminalResize {
|
||||
SessionTerminalEventV2::Resize(TerminalResize {
|
||||
session_id: session,
|
||||
rows: NonZeroU16::new(40).unwrap(),
|
||||
columns: NonZeroU16::new(120).unwrap(),
|
||||
|
|
@ -282,49 +281,49 @@ fn resize_and_terminal_signals_use_the_exact_group_map() {
|
|||
}),
|
||||
)
|
||||
.unwrap();
|
||||
for (kind, expected) in [
|
||||
(TerminalSignalKind::Interrupt, "signal:INT"),
|
||||
(TerminalSignalKind::Quit, "signal:QUIT"),
|
||||
(TerminalSignalKind::Suspend, "signal:TSTP"),
|
||||
] {
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
session_id: session,
|
||||
signal: kind,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(factory.log.lock().unwrap().last().unwrap(), expected);
|
||||
}
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Break(TerminalBreak {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: vec![3],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&factory.log.lock().unwrap()[..2],
|
||||
&["resize:40x120", "signal:WINCH"]
|
||||
&factory.log.lock().unwrap()[..3],
|
||||
&["resize:40x120", "break", "input:1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_guest_identity_refuses_numeric_group_action() {
|
||||
fn keyboard_input_does_not_target_the_recorded_group() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().guest.identity_mismatch = true;
|
||||
assert!(matches!(
|
||||
engine.handle(
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Signal(TerminalSignal {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
signal: TerminalSignalKind::Interrupt,
|
||||
bytes: vec![3],
|
||||
}),
|
||||
),
|
||||
Err(TerminalError::GuestIdentityMismatch)
|
||||
));
|
||||
assert!(factory.log.lock().unwrap().is_empty());
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(factory.log.lock().unwrap().as_slice(), ["input:1"]);
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -341,6 +340,47 @@ fn changed_guest_identity_refuses_numeric_group_action() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_leader_with_absent_group_closes_clean() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
{
|
||||
let state = factory.state(0);
|
||||
let mut state = state.lock().unwrap();
|
||||
state.guest.identity_mismatch = true;
|
||||
state.guest.group_present = false;
|
||||
}
|
||||
|
||||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
|
||||
);
|
||||
assert!(!engine.contains(session));
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
assert!(
|
||||
!factory
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.starts_with("signal:"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_leader_with_live_group_closes_with_release_intent() {
|
||||
let session = id(6);
|
||||
|
|
@ -355,7 +395,7 @@ fn absent_leader_with_live_group_closes_with_release_intent() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -412,7 +452,7 @@ fn persistent_guest_group_closes_session_with_release_intent() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -430,6 +470,40 @@ fn persistent_guest_group_closes_session_with_release_intent() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_absence_cleans_retained_terminal_after_exact_exec_absence() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().guest.clears_after_signal = "never";
|
||||
engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
engine
|
||||
.clean_after_sandbox_absence(
|
||||
GenerationId::new(7),
|
||||
ContainerId::new(bytes(9)),
|
||||
process(70),
|
||||
|_| {
|
||||
Err(io::Error::from_raw_os_error(
|
||||
rustix::io::Errno::SRCH.raw_os_error(),
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fixture.store.snapshot().unwrap().session_terminals[0].state(),
|
||||
SessionTerminalState::Clean
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn running_store_failures_release_every_live_partial_handle() {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
let messages = engine
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
|
||||
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
|
||||
session_id: session,
|
||||
}),
|
||||
)
|
||||
|
|
@ -91,7 +91,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
assert!(matches!(
|
||||
engine.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: session,
|
||||
bytes: b"reattach".to_vec(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
session_id: binding.session_id,
|
||||
};
|
||||
let batch = control
|
||||
.exchange(InteractiveSessionEvent::Poll {
|
||||
.exchange(InteractiveSessionEventV2::Poll {
|
||||
session_id: binding.session_id,
|
||||
})
|
||||
.unwrap();
|
||||
|
|
@ -111,8 +111,8 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
pixel_height: 900,
|
||||
};
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Resize(resumed_size),
|
||||
.exchange(InteractiveSessionEventV2::Terminal(
|
||||
SessionTerminalEventV2::Resize(resumed_size),
|
||||
))
|
||||
.unwrap();
|
||||
let signed_decision = encode_signed(
|
||||
|
|
@ -128,7 +128,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
)
|
||||
.unwrap();
|
||||
control
|
||||
.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
.exchange(InteractiveSessionEventV2::PromptDecision {
|
||||
session_id: binding.session_id,
|
||||
signed_decision: signed_decision.clone(),
|
||||
})
|
||||
|
|
@ -140,10 +140,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
assert_eq!(routed.signed_decision, signed_decision);
|
||||
assert_eq!(routed.decision.decision, DeveloperDecisionKind::Deny);
|
||||
assert_eq!(routed.resumed_size, Some(resumed_size));
|
||||
assert_eq!(
|
||||
factory.log.lock().unwrap().as_slice(),
|
||||
["resize:55x144", "signal:WINCH"]
|
||||
);
|
||||
assert_eq!(factory.log.lock().unwrap().as_slice(), ["resize:55x144"]);
|
||||
assert!(
|
||||
!factory
|
||||
.log
|
||||
|
|
@ -169,7 +166,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
|
|||
.unwrap()
|
||||
.handle(
|
||||
GenerationId::new(7),
|
||||
SessionTerminalEvent::Input(TerminalInput {
|
||||
SessionTerminalEventV2::Input(TerminalInput {
|
||||
session_id: binding.session_id,
|
||||
bytes: b"sandbox-input".to_vec(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue