Isolate interactive terminal session ownership

This commit is contained in:
ms2k 2026-08-01 00:00:47 +09:00
commit 690738d846
8 changed files with 280 additions and 24 deletions

View file

@ -422,7 +422,13 @@ impl ClientBackend for CoordinatorClientBackend {
} else { } else {
execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)? execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)?
}; };
if let Some(coordinator) = self.coordinator.take() { self.bootstrap_owner.take();
if let Some(coordinator) = self.coordinator.take()
&& coordinator
.has_exited()
.map_err(failure)
.map_err(ClientError::Backend)?
{
let status = coordinator let status = coordinator
.reap() .reap()
.map_err(failure) .map_err(failure)
@ -433,7 +439,6 @@ impl ClientBackend for CoordinatorClientBackend {
)))); ))));
} }
} }
self.bootstrap_owner.take();
Ok(exit) Ok(exit)
} }
} }

View file

@ -147,7 +147,6 @@ impl SessionTerminalEngine {
session_id: SessionId, session_id: SessionId,
generation_id: GenerationId, generation_id: GenerationId,
) -> Result<Vec<SessionTerminalMessage>, TerminalError> { ) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
let authoritative = self.refresh_route(session_id, generation_id)?;
let terminal = self.active_mut(session_id, generation_id)?; let terminal = self.active_mut(session_id, generation_id)?;
if terminal.process.exec_exited()? { if terminal.process.exec_exited()? {
return self.close( return self.close(
@ -158,6 +157,8 @@ impl SessionTerminalEngine {
GuestReleaseAuthority::Validate, GuestReleaseAuthority::Validate,
); );
} }
let authoritative = self.refresh_route(session_id, generation_id)?;
let terminal = self.active_mut(session_id, generation_id)?;
if authoritative != TerminalRoute::Sandbox || terminal.releasing { if authoritative != TerminalRoute::Sandbox || terminal.releasing {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -446,6 +447,13 @@ pub(super) fn release_physical(
} else { } else {
guest_authorized = false; guest_authorized = false;
} }
if matches!(
failure.as_ref(),
Some(TerminalError::GuestIdentityMismatch | TerminalError::GuestGroupStillPresent)
) {
failure = None;
guest_authorized = false;
}
if let Some(error) = failure { if let Some(error) = failure {
return Err(error); return Err(error);
} }

View file

@ -77,6 +77,7 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
target = match slave target = match slave
.try_clone() .try_clone()
.and_then(|stdin| target.with_target_stdin(stdin)) .and_then(|stdin| target.with_target_stdin(stdin))
.map(lazaret_linux::process::TargetCommand::with_controlling_terminal)
{ {
Ok(target) => target, Ok(target) => target,
Err(error) => { Err(error) => {

View file

@ -321,15 +321,20 @@ fn changed_guest_identity_refuses_numeric_group_action() {
Err(TerminalError::GuestIdentityMismatch) Err(TerminalError::GuestIdentityMismatch)
)); ));
assert!(factory.log.lock().unwrap().is_empty()); assert!(factory.log.lock().unwrap().is_empty());
assert!(matches!( let messages = engine
engine.handle( .handle(
GenerationId::new(7), GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose { SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
session_id: session, session_id: session,
}), }),
), )
Err(TerminalError::GuestIdentityMismatch) .unwrap();
)); assert!(
messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
);
assert!(!engine.contains(session));
assert_eq!( assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(), fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::ReleaseIntent SessionTerminalState::ReleaseIntent
@ -337,7 +342,7 @@ fn changed_guest_identity_refuses_numeric_group_action() {
} }
#[test] #[test]
fn absent_leader_with_live_group_retains_release_until_group_is_empty() { fn absent_leader_with_live_group_closes_with_release_intent() {
let session = id(6); let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]); let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap(); engine.acquire(&acquisition(session)).unwrap();
@ -347,25 +352,7 @@ fn absent_leader_with_live_group_retains_release_until_group_is_empty() {
state.guest.leader_present = false; state.guest.leader_present = false;
state.guest.group_present = true; state.guest.group_present = true;
} }
assert!(matches!( let messages = engine
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
),
Err(TerminalError::GuestIdentityMismatch)
));
let state = factory.state(0);
assert!(state.lock().unwrap().resources.exec_reaped);
assert!(!state.lock().unwrap().resources.pty_open);
assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::ReleaseIntent
);
state.lock().unwrap().guest.group_present = false;
engine
.handle( .handle(
GenerationId::new(7), GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose { SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
@ -373,9 +360,73 @@ fn absent_leader_with_live_group_retains_release_until_group_is_empty() {
}), }),
) )
.unwrap(); .unwrap();
let state = factory.state(0);
assert!(state.lock().unwrap().resources.exec_reaped);
assert!(!state.lock().unwrap().resources.pty_open);
assert!(
messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
);
assert!(!engine.contains(session));
assert_eq!( assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(), fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::Clean SessionTerminalState::ReleaseIntent
);
}
#[test]
fn exited_exec_with_absent_leader_emits_exit_and_retains_group_release() {
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.leader_present = false;
state.guest.group_present = true;
state.resources.exec_exited = true;
}
let messages = engine.poll(session, GenerationId::new(7)).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::ReleaseIntent
);
}
#[test]
fn persistent_guest_group_closes_session_with_release_intent() {
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";
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::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::ReleaseIntent
); );
} }

View file

@ -33,6 +33,7 @@ pub struct TargetCommand {
parent_edge: ParentEdge, parent_edge: ParentEdge,
executable: Option<OwnedFd>, executable: Option<OwnedFd>,
stdin: Option<File>, stdin: Option<File>,
terminal: bool,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -106,6 +107,7 @@ impl TargetCommand {
parent_edge: ParentEdge::ParentDeathKill, parent_edge: ParentEdge::ParentDeathKill,
executable: None, executable: None,
stdin: None, stdin: None,
terminal: false,
} }
} }
@ -124,6 +126,7 @@ impl TargetCommand {
parent_edge: ParentEdge::ParentDeathKill, parent_edge: ParentEdge::ParentDeathKill,
executable: Some(executable), executable: Some(executable),
stdin: None, stdin: None,
terminal: false,
}) })
} }
@ -156,6 +159,12 @@ impl TargetCommand {
Ok(self) Ok(self)
} }
#[must_use]
pub fn with_controlling_terminal(mut self) -> Self {
self.terminal = true;
self
}
#[must_use] #[must_use]
pub const fn parent_edge(&self) -> ParentEdge { pub const fn parent_edge(&self) -> ParentEdge {
self.parent_edge self.parent_edge
@ -180,6 +189,7 @@ impl TargetCommand {
ParentEdge::ParentDeathKill => "kill", ParentEdge::ParentDeathKill => "kill",
ParentEdge::LifetimePipe => "lifetime-pipe", ParentEdge::LifetimePipe => "lifetime-pipe",
}) })
.args(self.terminal.then_some("--target-terminal"))
.args( .args(
self.stdin self.stdin
.as_ref() .as_ref()

View file

@ -16,6 +16,7 @@ use super::{
use crate::ipc::private_stream_pair; use crate::ipc::private_stream_pair;
use crate::user_namespace::UserNamespaceMap; use crate::user_namespace::UserNamespaceMap;
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
use std::os::unix::process::CommandExt as _;
impl ChildBroker { impl ChildBroker {
/// Spawns the private Host Coordinator bootstrap with exact pipe ownership and pidfd reaping. /// Spawns the private Host Coordinator bootstrap with exact pipe ownership and pidfd reaping.
@ -44,7 +45,8 @@ impl ChildBroker {
.args(&target.arguments) .args(&target.arguments)
.stdin(Stdio::from(lifetime_reader)) .stdin(Stdio::from(lifetime_reader))
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()); .stderr(Stdio::inherit())
.process_group(0);
let mut child = command.spawn()?; let mut child = command.spawn()?;
let status = child let status = child
.stdout .stdout
@ -743,6 +745,7 @@ fn run_mapped_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
rustix::process::Uid::ROOT, rustix::process::Uid::ROOT,
rustix::process::Uid::ROOT, rustix::process::Uid::ROOT,
)?; )?;
establish_controlling_terminal(&target)?;
if let Some(stdin) = target.stdin.as_ref() { if let Some(stdin) = target.stdin.as_ref() {
rustix::stdio::dup2_stdin(stdin)?; rustix::stdio::dup2_stdin(stdin)?;
} }
@ -761,12 +764,27 @@ fn run_wrapper_role(arguments: Vec<OsString>) -> io::Result<()> {
require_lifetime_pipe_and_recheck(expected_parent)?; require_lifetime_pipe_and_recheck(expected_parent)?;
} }
} }
establish_controlling_terminal(&target)?;
if let Some(stdin) = target.stdin.as_ref() { if let Some(stdin) = target.stdin.as_ref() {
rustix::stdio::dup2_stdin(stdin)?; rustix::stdio::dup2_stdin(stdin)?;
} }
Err(exec_target(target)) Err(exec_target(target))
} }
fn establish_controlling_terminal(target: &TargetCommand) -> io::Result<()> {
if !target.terminal {
return Ok(());
}
let stdin = target
.stdin
.as_ref()
.ok_or_else(|| io::Error::other("terminal target stdin is missing"))?;
rustix::process::setsid()?;
rustix::process::ioctl_tiocsctty(stdin)?;
rustix::termios::tcsetpgrp(stdin, rustix::process::getpgrp())?;
Ok(())
}
fn require_lifetime_pipe_and_recheck( fn require_lifetime_pipe_and_recheck(
expected_parent: PidfsProcessIdentityV1, expected_parent: PidfsProcessIdentityV1,
) -> io::Result<RetainedProcess> { ) -> io::Result<RetainedProcess> {

View file

@ -91,7 +91,11 @@ pub fn parse_wrapper_arguments(
Some("lifetime-pipe") => ParentEdge::LifetimePipe, Some("lifetime-pipe") => ParentEdge::LifetimePipe,
_ => return Err(io::Error::other("wrapper parent action is invalid")), _ => return Err(io::Error::other("wrapper parent action is invalid")),
}; };
let next = arguments.next(); let mut next = arguments.next();
let terminal = next.as_deref() == Some(OsStr::new("--target-terminal"));
if terminal {
next = arguments.next();
}
let stdin = if next.as_deref() == Some(OsStr::new("--target-stdin-fd")) { let stdin = if next.as_deref() == Some(OsStr::new("--target-stdin-fd")) {
let descriptor = arguments let descriptor = arguments
.next() .next()
@ -117,6 +121,7 @@ pub fn parse_wrapper_arguments(
parent_edge, parent_edge,
executable: None, executable: None,
stdin, stdin,
terminal,
}; };
let boot = parse_boot_hex(&boot)?; let boot = parse_boot_hex(&boot)?;
Ok(( Ok((

View file

@ -5,6 +5,7 @@ use std::os::fd::AsFd;
use std::process::{ExitCode, Stdio}; use std::process::{ExitCode, Stdio};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier}; use std::sync::{Arc, Barrier};
use std::time::{Duration, Instant};
use lazaret_core::identity::{ use lazaret_core::identity::{
CoordinatorId, DomainId, GenerationId, HostPidLocator, PidfsProcessIdentityV1, CoordinatorId, DomainId, GenerationId, HostPidLocator, PidfsProcessIdentityV1,
@ -13,14 +14,14 @@ use lazaret_core::records::{
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership, HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
}; };
use lazaret_linux::process::{ use lazaret_linux::process::{
AcknowledgedChildAcquisition, ChildStandardIo, ProcessOwnershipContext, TargetCommand, AcknowledgedChildAcquisition, ChildStandardIo, ProcessOwnershipContext, RetainedProcess,
append_parent_identity, child_broker, current_process_identity, dispatch_private_process_role, TargetCommand, append_parent_identity, child_broker, current_process_identity,
verify_direct_child_pidfd, dispatch_private_process_role, verify_direct_child_pidfd,
}; };
use lazaret_linux::user_namespace::UserNamespaceMap; use lazaret_linux::user_namespace::UserNamespaceMap;
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::pipe::{PipeFlags, pipe_with}; use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::{PidfdFlags, pidfd_open}; use rustix::process::{PidfdFlags, Signal, pidfd_open};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt}; use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcgetwinsize, tcsetwinsize}; use rustix::termios::{Winsize, tcgetwinsize, tcsetwinsize};
@ -52,6 +53,8 @@ fn run_all() -> std::io::Result<()> {
mapped_transient_uses_configured_subordinates()?; mapped_transient_uses_configured_subordinates()?;
transient_captured_child_preserves_streams_and_exit()?; transient_captured_child_preserves_streams_and_exit()?;
terminal_child_restores_slave_stdin_and_merges_output()?; terminal_child_restores_slave_stdin_and_merges_output()?;
terminal_children_isolate_window_signals()?;
bootstrap_child_uses_a_distinct_process_group()?;
target_stdin_does_not_gain_write_authority()?; target_stdin_does_not_gain_write_authority()?;
acknowledged_transition_precedes_exact_wrapper_release()?; acknowledged_transition_precedes_exact_wrapper_release()?;
failed_acknowledged_transition_refuses_exact_wrapper_release()?; failed_acknowledged_transition_refuses_exact_wrapper_release()?;
@ -64,6 +67,33 @@ fn run_all() -> std::io::Result<()> {
concurrent_acquisitions_serialize() concurrent_acquisitions_serialize()
} }
fn bootstrap_child_uses_a_distinct_process_group() -> std::io::Result<()> {
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC)?;
let target = TargetCommand::new("/bin/sh")
.argument("-c")
.argument(
"if [ \"$(ps -o pgid= -p $$)\" = \"$(ps -o pgid= -p $PPID)\" ]; then printf E; else printf R; fi; read value || exit 0",
)
.with_lifetime_pipe_parent_edge();
let mut child = child_broker().spawn_bootstrap(&target, File::from(reader))?;
if child.bootstrap_status()? != b'R' {
return Err(std::io::Error::other(
"bootstrap child shared the client process group",
));
}
drop(writer);
while !child.has_exited()? {
std::thread::yield_now();
}
let exit = child.reap()?;
if exit.exit_code != Some(0) || exit.terminating_signal.is_some() {
return Err(std::io::Error::other(format!(
"bootstrap process-group probe failed: {exit:?}"
)));
}
Ok(())
}
fn mapped_transient_uses_configured_subordinates() -> std::io::Result<()> { fn mapped_transient_uses_configured_subordinates() -> std::io::Result<()> {
let map = UserNamespaceMap::resolve_current()?; let map = UserNamespaceMap::resolve_current()?;
child_broker() child_broker()
@ -149,7 +179,8 @@ fn terminal_child_restores_slave_stdin_and_merges_output() -> std::io::Result<()
.argument( .argument(
"read value; printf 'stdin-fd\\n' >&0; printf 'out:%s\\n' \"$value\"; printf 'err\\n' >&2", "read value; printf 'stdin-fd\\n' >&0; printf 'out:%s\\n' \"$value\"; printf 'err\\n' >&2",
) )
.with_target_stdin(slave.try_clone()?)?; .with_target_stdin(slave.try_clone()?)?
.with_controlling_terminal();
let expected_parent = current_process_identity()?; let expected_parent = current_process_identity()?;
let ownership = ProcessOwnershipContext { let ownership = ProcessOwnershipContext {
domain_id: DomainId::new([41; 32]), domain_id: DomainId::new([41; 32]),
@ -193,6 +224,133 @@ fn terminal_child_restores_slave_stdin_and_merges_output() -> std::io::Result<()
Ok(()) Ok(())
} }
fn terminal_children_isolate_window_signals() -> std::io::Result<()> {
let (mut first_master, first) = terminal_child("S1", 51)?;
let (mut second_master, second) = terminal_child("S2", 52)?;
let mut first_output = Vec::new();
let mut second_output = Vec::new();
wait_for_terminal_marker(&mut first_master, &mut first_output, b"S1_READY")?;
wait_for_terminal_marker(&mut second_master, &mut second_output, b"S2_READY")?;
let first_identity = terminal_identity(&first_output, "S1_READY")?;
let second_identity = terminal_identity(&second_output, "S2_READY")?;
if first_identity
.iter()
.any(|value| *value != first_identity[0])
|| second_identity
.iter()
.any(|value| *value != second_identity[0])
|| first_identity[0] == second_identity[0]
{
return Err(std::io::Error::other(
"terminal targets did not receive distinct controlling sessions",
));
}
first.send_signal(Signal::WINCH)?;
wait_for_terminal_marker(&mut first_master, &mut first_output, b"S1_WINCH")?;
second_master.write_all(b"after\n")?;
wait_for_terminal_marker(&mut second_master, &mut second_output, b"S2:after")?;
if second_output
.windows(b"S2_WINCH".len())
.any(|part| part == b"S2_WINCH")
{
return Err(std::io::Error::other(
"window signal crossed terminal process groups",
));
}
while !first.has_exited()? {
std::thread::yield_now();
}
let first_exit = child_broker().reap_status(first)?;
second.send_signal(Signal::KILL)?;
let second_exit = child_broker().reap_status(second)?;
if first_exit.exit_code != Some(0)
|| first_exit.terminating_signal.is_some()
|| second_exit.terminating_signal.is_none()
{
return Err(std::io::Error::other(
"terminal signal isolation produced unexpected exits",
));
}
Ok(())
}
fn terminal_child(marker: &str, seed: u8) -> std::io::Result<(File, RetainedProcess)> {
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?;
grantpt(&master)?;
unlockpt(&master)?;
let slave = File::from(ioctl_tiocgptpeer(
&master,
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
)?);
let target = TargetCommand::new("/usr/bin/python3")
.argument("-c")
.argument(format!(
"import os, signal, sys\nmarker = {marker:?}\ndef winch(*_):\n print(marker + '_WINCH', flush=True)\n sys.exit(0)\nsignal.signal(signal.SIGWINCH, winch)\nprint(f'{{marker}}_READY pid={{os.getpid()}} sid={{os.getsid(0)}} pgrp={{os.getpgrp()}} tty={{os.tcgetpgrp(0)}}', flush=True)\nfor line in sys.stdin:\n print(marker + ':' + line.strip(), flush=True)"
))
.with_target_stdin(slave.try_clone()?)?
.with_controlling_terminal();
let process = child_broker().acquire_with_stdio(
current_process_identity()?,
ProcessOwnershipContext {
domain_id: DomainId::new([seed; 32]),
generation_id: GenerationId::new(u64::from(seed)),
coordinator_id: CoordinatorId::new([seed.wrapping_add(1); 32]),
slot: HostProcessOwnershipSlot::SessionTerminalExecClient(
lazaret_core::identity::SessionId::new([seed.wrapping_add(2); 32]),
),
},
&target,
ChildStandardIo::terminal(slave)?,
|_| Ok(()),
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
)?;
let master = File::from(master);
let flags = fcntl_getfl(&master)?;
fcntl_setfl(&master, flags | OFlags::NONBLOCK)?;
Ok((master, process))
}
fn terminal_identity(output: &[u8], marker: &str) -> std::io::Result<[u32; 4]> {
let text = String::from_utf8_lossy(output);
let line = text
.lines()
.find(|line| line.starts_with(marker))
.ok_or_else(|| std::io::Error::other("terminal identity marker is missing"))?;
let values = line
.split_whitespace()
.skip(1)
.map(|field| {
field
.split_once('=')
.and_then(|(_, value)| value.parse::<u32>().ok())
.ok_or_else(|| std::io::Error::other("terminal identity field is invalid"))
})
.collect::<std::io::Result<Vec<_>>>()?;
values
.try_into()
.map_err(|_| std::io::Error::other("terminal identity field count is invalid"))
}
fn wait_for_terminal_marker(
master: &mut File,
output: &mut Vec<u8>,
marker: &[u8],
) -> std::io::Result<()> {
let deadline = Instant::now() + Duration::from_secs(10);
while !output.windows(marker.len()).any(|part| part == marker) {
read_available(master, output)?;
if Instant::now() >= deadline {
return Err(std::io::Error::other(format!(
"terminal marker {:?} deadline expired with {:?}",
String::from_utf8_lossy(marker),
String::from_utf8_lossy(output)
)));
}
std::thread::yield_now();
}
Ok(())
}
fn transient_captured_child_preserves_streams_and_exit() -> std::io::Result<()> { fn transient_captured_child_preserves_streams_and_exit() -> std::io::Result<()> {
let target = TargetCommand::new("/bin/sh") let target = TargetCommand::new("/bin/sh")
.argument("-c") .argument("-c")