Add confined host escalation transport

This commit is contained in:
ms2k 2026-07-15 19:48:42 +09:00
commit 407b75b1ff

View file

@ -1,7 +1,7 @@
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
@ -13,7 +13,7 @@ use lazaret_core::records::{
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::fs::{fstat, fstatfs};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat, fstatfs};
use rustix::io::{FdFlags, fcntl_setfd};
use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::{Pid, PidfdFlags, Signal, WaitId, WaitIdOptions};
@ -25,11 +25,12 @@ const WRAPPER_ROLE: &str = "launch-wrapper";
const SIGCHLD_PROBE_ROLE: &str = "sigchld-probe";
static CHILD_BROKER: OnceLock<ChildBroker> = OnceLock::new();
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Debug)]
pub struct TargetCommand {
program: OsString,
arguments: Vec<OsString>,
parent_edge: ParentEdge,
executable: Option<OwnedFd>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -45,9 +46,27 @@ impl TargetCommand {
program: program.into(),
arguments: Vec::new(),
parent_edge: ParentEdge::ParentDeathKill,
executable: None,
}
}
/// Constructs an argv-only target bound to the exact inherited executable descriptor.
///
/// # Errors
/// Returns an error when the descriptor cannot be made inheritable by the launch wrapper.
pub fn from_executable(
executable: OwnedFd,
arguments: impl IntoIterator<Item = OsString>,
) -> io::Result<Self> {
fcntl_setfd(executable.as_fd(), FdFlags::empty())?;
Ok(Self {
program: OsString::from(format!("/proc/self/fd/{}", executable.as_raw_fd())),
arguments: arguments.into_iter().collect(),
parent_edge: ParentEdge::ParentDeathKill,
executable: Some(executable),
})
}
#[must_use]
pub fn argument(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(argument.into());
@ -73,6 +92,7 @@ impl TargetCommand {
#[allow(clippy::disallowed_types)]
fn append_to(&self, command: &mut std::process::Command) {
let _ = &self.executable;
command
.arg("--parent-action")
.arg(match self.parent_edge {
@ -149,6 +169,35 @@ pub struct RetainedProcess {
identity: PidfsProcessIdentityV1,
}
#[derive(Debug)]
pub struct CapturedProcess {
process: RetainedProcess,
stdout: File,
stderr: File,
}
impl CapturedProcess {
#[must_use]
pub const fn process(&self) -> &RetainedProcess {
&self.process
}
pub fn streams(&mut self) -> (&mut File, &mut File) {
(&mut self.stdout, &mut self.stderr)
}
#[must_use]
pub fn into_process(self) -> RetainedProcess {
self.process
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ProcessExit {
pub exit_code: Option<i32>,
pub terminating_signal: Option<i32>,
}
impl RetainedProcess {
#[must_use]
pub const fn identity(&self) -> PidfsProcessIdentityV1 {
@ -212,6 +261,16 @@ fn require_parent_edge_standard_io(
}
}
fn require_captured_parent_edge(parent_edge: ParentEdge) -> io::Result<()> {
if parent_edge == ParentEdge::ParentDeathKill {
Ok(())
} else {
Err(io::Error::other(
"captured output requires the parent-death edge",
))
}
}
impl ChildBroker {
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
/// and only then releases target execution.
@ -312,18 +371,144 @@ impl ChildBroker {
Ok(process)
}
/// Acquires an exact child with bounded-output pipe ownership retained by the caller.
///
/// # Errors
/// Returns an error when serialization or exact pidfd reaping fails.
#[allow(clippy::disallowed_methods)]
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
/// Refuses and reaps the blocked wrapper on every failed acquisition or callback.
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
pub fn acquire_captured<Stage, Acknowledge>(
&self,
expected_parent: PidfsProcessIdentityV1,
ownership: ProcessOwnershipContext,
target: &TargetCommand,
stage: Stage,
acknowledge: Acknowledge,
) -> io::Result<CapturedProcess>
where
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
{
let _guard = self
.exclusion
.lock()
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
drop(process);
Ok(())
require_captured_parent_edge(target.parent_edge())?;
require_default_sigchld()?;
require_reapable_sigchld()?;
let (release, wait) = launch_barrier()?;
let mut command = private_role_command(WRAPPER_ROLE);
append_parent_identity(&mut command, expected_parent);
target.append_to(&mut command);
command
.stdin(wait.into_stdio())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn()?;
let host_pid = child.id();
let process = match acquire_spawned_child(host_pid) {
Ok(process) => process,
Err(error) => return fail_before_pidfd(release, &mut child, error),
};
let staged = StagedProcessOwnership::v1(
ownership.domain_id,
ownership.generation_id,
ownership.coordinator_id,
ownership.slot,
process.identity,
);
if let Err(error) = stage(staged) {
return fail_after_pidfd(release, process, error);
}
let acknowledgment = match acknowledge(staged) {
Ok(acknowledgment) => acknowledgment,
Err(error) => return fail_after_pidfd(release, process, error),
};
if !acknowledgment.acknowledges(&staged) {
return fail_after_pidfd(
release,
process,
io::Error::other(
"process ownership acknowledgment does not match staged ownership",
),
);
}
if let Err(error) = release.release() {
return fail_after_pidfd_closed(process, error);
}
let Some(stdout) = child.stdout.take() else {
return fail_running_captured(
process,
child,
io::Error::other("captured child stdout is unavailable"),
);
};
let Some(stderr) = child.stderr.take() else {
return fail_running_captured(
process,
child,
io::Error::other("captured child stderr is unavailable"),
);
};
let stdout = match child_pipe_file(stdout.into()) {
Ok(stdout) => stdout,
Err(error) => return fail_running_captured(process, child, error),
};
let stderr = match child_pipe_file(stderr.into()) {
Ok(stderr) => stderr,
Err(error) => return fail_running_captured(process, child, error),
};
drop(child);
Ok(CapturedProcess {
process,
stdout,
stderr,
})
}
/// # Errors
/// Returns an error when serialization or exact pidfd reaping fails.
#[allow(clippy::disallowed_methods)]
pub fn reap(&self, process: RetainedProcess) -> io::Result<()> {
self.reap_status(process).map(|_| ())
}
/// # Errors
/// Returns an error when serialization or exact pidfd reaping fails.
#[allow(clippy::disallowed_methods)]
pub fn reap_status(&self, process: RetainedProcess) -> io::Result<ProcessExit> {
let _guard = self
.exclusion
.lock()
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
let status =
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?
.ok_or_else(|| io::Error::other("exact child exited without wait status"))?;
let exit = ProcessExit {
exit_code: status.exit_status(),
terminating_signal: status.terminating_signal(),
};
drop(process);
Ok(exit)
}
}
fn child_pipe_file(fd: OwnedFd) -> io::Result<File> {
let flags = fcntl_getfl(fd.as_fd())?;
fcntl_setfl(fd.as_fd(), flags | OFlags::NONBLOCK)?;
Ok(File::from(fd))
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn fail_running_captured<T>(
process: RetainedProcess,
child: std::process::Child,
error: io::Error,
) -> io::Result<T> {
let _ = process.send_signal(Signal::KILL);
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
drop(child);
drop(process);
Err(error)
}
#[allow(clippy::disallowed_types)]
@ -429,30 +614,27 @@ pub fn verify_direct_child_pidfd(pidfd: impl AsFd) -> io::Result<()> {
}
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
fn fail_before_pidfd(
fn fail_before_pidfd<T>(
release: BarrierRelease,
child: &mut std::process::Child,
error: io::Error,
) -> io::Result<RetainedProcess> {
) -> io::Result<T> {
drop(release);
child.wait()?;
Err(error)
}
fn fail_after_pidfd(
fn fail_after_pidfd<T>(
release: BarrierRelease,
process: RetainedProcess,
error: io::Error,
) -> io::Result<RetainedProcess> {
) -> io::Result<T> {
drop(release);
fail_after_pidfd_closed(process, error)
}
#[allow(clippy::disallowed_methods)]
fn fail_after_pidfd_closed(
process: RetainedProcess,
error: io::Error,
) -> io::Result<RetainedProcess> {
fn fail_after_pidfd_closed<T>(process: RetainedProcess, error: io::Error) -> io::Result<T> {
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?;
drop(process);
Err(error)
@ -848,6 +1030,7 @@ pub fn parse_wrapper_arguments(
program,
arguments: arguments.collect(),
parent_edge,
executable: None,
};
let boot = parse_boot_hex(&boot)?;
Ok((