Run non-interactive sessions through production coordination

This commit is contained in:
ms2k 2026-07-16 16:28:18 +09:00
commit 71b0266cf2

View file

@ -1,16 +1,19 @@
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::fd::AsFd;
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use ed25519_dalek::SigningKey;
use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::ConfinedPathPlan;
use lazaret_core::identity::{
CertificateDigest, ClientId, SessionAnchorId, SessionId, TrustManifestDigest,
CertificateDigest, ClientId, ContainerId, SessionAnchorId, SessionId, TrustManifestDigest,
};
use lazaret_core::protocol::{
ProtocolValidationError, issue_protocol_credential, issue_session_capability,
@ -18,10 +21,11 @@ use lazaret_core::protocol::{
};
use lazaret_core::records::{
ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse,
DurableDecision, GenerationIdentity, ProtocolCredential, ProtocolCredentialRole,
DurableDecision, GenerationIdentity, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, ProtocolCredential, ProtocolCredentialRole,
ProtocolPublicationIdentity, ProtocolSubjectId, ProtocolVersion, SandboxCredentialBundle,
Session, SessionBinding, SessionPhase, SessionRegistrationRequest, SessionRegistrationResponse,
TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
Session, SessionBinding, SessionExit, SessionPhase, SessionRegistrationRequest,
SessionRegistrationResponse, TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
};
use rcgen::{
BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer,
@ -42,14 +46,16 @@ use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinator
use crate::lifecycle::{
HostCoordinatorLifecycle, LifecycleAction, SessionAdmissionIdentity, SessionLifecycleAdapter,
};
use lazaret_linux::process::{ProcessExit, TargetCommand, child_broker};
use lazaret_runtime_supervisor::sandbox::RunscCommand;
pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1";
pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid";
pub const MAX_ENROLLMENT_BYTES: usize = 8_192;
const MAX_CONTROL_BYTES: usize = 65_536;
const CONTROL_SOCKET_NAME: &str = "control.sock";
const CONTROL_SOCKET_NAME: &str = "c.sock";
const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard";
const STAGED_CONTROL_SOCKET_NAME: &str = ".control.sock.staged";
const STAGED_CONTROL_SOCKET_NAME: &str = ".c.staged";
const STAGED_PUBLICATION_IDENTITY_NAME: &str = ".protocol-identity.staged";
#[derive(Debug)]
@ -824,6 +830,167 @@ pub struct PublishedControlFace {
anchor: TrustAnchor,
server_config: Arc<ServerConfig>,
registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
}
pub trait NonInteractiveSessionExecutor: Send + Sync {
/// # Errors
/// Returns a runtime, stream, or exact-session execution failure.
fn execute(
&self,
request: &NonInteractiveSessionRequest,
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<SessionExit, ControlError>;
}
struct ExecutionUnavailable;
impl NonInteractiveSessionExecutor for ExecutionUnavailable {
fn execute(
&self,
_request: &NonInteractiveSessionRequest,
_emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<SessionExit, ControlError> {
Err(ControlError::InvalidRequest)
}
}
pub struct RunscNonInteractiveExecutor {
program: OsString,
global_arguments: Vec<OsString>,
container: OsString,
runner: Arc<dyn NonInteractiveCommandRunner>,
}
trait NonInteractiveCommandRunner: Send + Sync {
fn run(
&self,
target: &TargetCommand,
session_id: SessionId,
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<ProcessExit, ControlError>;
}
struct ChildBrokerCommandRunner;
impl NonInteractiveCommandRunner for ChildBrokerCommandRunner {
fn run(
&self,
target: &TargetCommand,
session_id: SessionId,
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<ProcessExit, ControlError> {
let mut child = child_broker().run_transient_captured(target)?;
let mut stdout_done = false;
let mut stderr_done = false;
while !(stdout_done && stderr_done && child.process().has_exited()?) {
let (stdout, stderr) = child.streams();
stdout_done |= emit_available(stdout, session_id, true, emit)?;
stderr_done |= emit_available(stderr, session_id, false, emit)?;
if !(stdout_done && stderr_done) {
std::thread::yield_now();
}
}
child_broker()
.reap_status(child.into_process())
.map_err(Into::into)
}
}
impl RunscNonInteractiveExecutor {
/// Derives the exact `runsc exec` prefix from the generation's attached `runsc run` command.
///
/// # Errors
/// Refuses a non-runsc command or a command without one exact run/container suffix.
pub fn from_run_command(
command: &RunscCommand,
container_id: ContainerId,
) -> Result<Self, ControlError> {
if command.program() != OsStr::new("runsc") {
return Err(ControlError::InvalidRequest);
}
let run = command
.arguments()
.iter()
.position(|argument| argument == OsStr::new("run"))
.ok_or(ControlError::InvalidRequest)?;
if run + 3 != command.arguments().len()
|| !command.arguments()[run + 1]
.as_encoded_bytes()
.starts_with(b"-bundle=")
{
return Err(ControlError::InvalidRequest);
}
let container = OsString::from(hex_lower(container_id.as_bytes()));
if command.arguments()[run + 2] != container {
return Err(ControlError::InvalidRequest);
}
Ok(Self {
program: command.program().to_owned(),
global_arguments: command.arguments()[..run].to_vec(),
container,
runner: Arc::new(ChildBrokerCommandRunner),
})
}
}
impl NonInteractiveSessionExecutor for RunscNonInteractiveExecutor {
fn execute(
&self,
request: &NonInteractiveSessionRequest,
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<SessionExit, ControlError> {
let mut target = TargetCommand::new(self.program.clone());
for argument in &self.global_arguments {
target = target.argument(argument.clone());
}
target = target.argument("exec").argument(self.container.clone());
for argument in &request.argv {
target = target.argument(OsString::from_vec(argument.clone()));
}
let process_status = self.runner.run(&target, request.session_id, emit)?;
match (process_status.exit_code, process_status.terminating_signal) {
(Some(code), None) => Ok(SessionExit::exited(request.session_id, code)),
(None, Some(_)) => Ok(SessionExit::terminated(request.session_id)),
_ => Err(ControlError::InvalidRequest),
}
}
}
fn emit_available(
stream: &mut File,
session_id: SessionId,
stdout: bool,
emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<bool, ControlError> {
let mut bytes = [0_u8; 16 * 1024];
loop {
match stream.read(&mut bytes) {
Ok(0) => return Ok(true),
Ok(length) => {
let output = lazaret_core::records::NonInteractiveOutput {
session_id,
bytes: bytes[..length].to_vec(),
};
emit(if stdout {
NonInteractiveSessionMessage::Stdout(output)
} else {
NonInteractiveSessionMessage::Stderr(output)
})?;
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(false),
Err(error) => return Err(error.into()),
}
}
}
fn hex_lower(bytes: &[u8]) -> String {
use fmt::Write as _;
bytes.iter().fold(String::new(), |mut output, byte| {
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
output
})
}
pub struct StagedControlFace {
@ -838,6 +1005,7 @@ pub struct StagedControlFace {
anchor: Option<TrustAnchor>,
server_config: Arc<ServerConfig>,
registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
}
impl StagedControlFace {
@ -850,6 +1018,26 @@ impl StagedControlFace {
uid: u32,
anchor: TrustAnchor,
registry: Arc<SessionRegistry>,
) -> Result<Self, ControlError> {
Self::stage_with_executor(
runtime_domain_root,
uid,
anchor,
registry,
Arc::new(ExecutionUnavailable),
)
}
/// Stages a control face with its generation-bound non-interactive executor.
///
/// # Errors
/// Refuses the same invalid staging conditions as [`Self::stage`].
pub fn stage_with_executor(
runtime_domain_root: &Path,
uid: u32,
anchor: TrustAnchor,
registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
) -> Result<Self, ControlError> {
if registry.generation_identity != anchor.generation_identity {
return Err(ControlError::StaleGeneration);
@ -902,6 +1090,7 @@ impl StagedControlFace {
anchor: Some(anchor),
server_config,
registry,
executor,
})
}
@ -959,6 +1148,7 @@ impl StagedControlFace {
))?,
server_config: Arc::clone(&self.server_config),
registry: Arc::clone(&self.registry),
executor: Arc::clone(&self.executor),
})
}
}
@ -971,6 +1161,128 @@ impl Drop for StagedControlFace {
}
impl PublishedControlFace {
/// Selects blocking or polling admission for the publication listener.
///
/// # Errors
/// Returns an operating-system failure changing the listener mode.
pub fn set_nonblocking(&self, nonblocking: bool) -> Result<(), ControlError> {
self.listener.set_nonblocking(nonblocking)?;
Ok(())
}
/// Discovers one publication through the retained runtime Domain authority.
///
/// # Errors
/// Refuses changed root authority or absent, replaced, non-private, oversized, or malformed
/// publication objects.
pub fn discover_confined(
runtime_domain_root: &ConfinedPathPlan,
uid: u32,
) -> Result<(File, PathBuf, ProtocolPublicationIdentity), ControlError> {
let root = File::from(
runtime_domain_root
.open_authority()
.map_err(|_| ControlError::InvalidPath("runtime Domain authority changed"))?,
);
let root_metadata = root.metadata()?;
if !root_metadata.is_dir()
|| root_metadata.uid() != uid
|| root_metadata.mode() & 0o7777 != 0o700
{
return Err(ControlError::InvalidPath(
"runtime Domain root is not invoking-user-owned mode 0700",
));
}
let descriptor = openat(
root.as_fd(),
PUBLICATION_IDENTITY_NAME,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?;
let mut identity = File::from(descriptor);
let identity_metadata = identity.metadata()?;
if !identity_metadata.file_type().is_file()
|| identity_metadata.uid() != uid
|| identity_metadata.mode() & 0o7777 != 0o600
|| identity_metadata.nlink() != 1
|| identity_metadata.len() > MAX_CONTROL_BYTES as u64
{
return Err(ControlError::InvalidPublication(
"publication identity is not an exact private regular file",
));
}
let socket = File::from(
openat(
root.as_fd(),
CONTROL_SOCKET_NAME,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?,
);
let socket_metadata = socket.metadata()?;
if !socket_metadata.file_type().is_socket()
|| socket_metadata.uid() != uid
|| socket_metadata.mode() & 0o7777 != 0o600
{
return Err(ControlError::InvalidPublication(
"control socket is not an invoking-user-private socket",
));
}
let mut bytes = Vec::new();
identity.read_to_end(&mut bytes)?;
let socket_path = PathBuf::from(format!(
"/proc/self/fd/{}/{}",
root.as_raw_fd(),
CONTROL_SOCKET_NAME
));
Ok((root, socket_path, decode(&bytes)?))
}
/// Discovers one exact current control publication beneath a validated runtime Domain root.
///
/// # Errors
/// Refuses absent, replaced, non-private, oversized, or malformed publication objects.
pub fn discover(
runtime_domain_root: &Path,
uid: u32,
) -> Result<(PathBuf, ProtocolPublicationIdentity), ControlError> {
let paths = ControlPaths::open(runtime_domain_root, uid)?;
let descriptor = openat(
paths.root_file.as_fd(),
PUBLICATION_IDENTITY_NAME,
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(io::Error::from)?;
let mut identity = File::from(descriptor);
let descriptor_metadata = identity.metadata()?;
let path_metadata = std::fs::symlink_metadata(&paths.identity)?;
let expected = FileIdentity::from_metadata(&descriptor_metadata);
if FileIdentity::from_metadata(&path_metadata) != expected
|| !descriptor_metadata.file_type().is_file()
|| descriptor_metadata.uid() != uid
|| descriptor_metadata.mode() & 0o7777 != 0o600
|| descriptor_metadata.nlink() != 1
|| descriptor_metadata.len() > MAX_CONTROL_BYTES as u64
{
return Err(ControlError::InvalidPublication(
"publication identity is not an exact private regular file",
));
}
let socket = std::fs::symlink_metadata(&paths.socket)?;
if !socket.file_type().is_socket() || socket.uid() != uid || socket.mode() & 0o7777 != 0o600
{
return Err(ControlError::InvalidPublication(
"control socket is not an invoking-user-private socket",
));
}
let mut bytes = Vec::new();
identity.read_to_end(&mut bytes)?;
Ok((paths.socket, decode(&bytes)?))
}
/// Publishes one generation control face in the fixed host-private runtime path.
///
/// # Errors
@ -981,12 +1293,39 @@ impl PublishedControlFace {
uid: u32,
anchor: TrustAnchor,
store: Arc<HostCoordinatorDomainStore>,
) -> Result<Self, ControlError> {
Self::publish_with_executor(
runtime_domain_root,
uid,
anchor,
store,
Arc::new(ExecutionUnavailable),
)
}
/// Publishes one generation control face with non-interactive session execution.
///
/// # Errors
/// Refuses the same invalid publication conditions as [`Self::publish`].
pub fn publish_with_executor(
runtime_domain_root: &Path,
uid: u32,
anchor: TrustAnchor,
store: Arc<HostCoordinatorDomainStore>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
) -> Result<Self, ControlError> {
let registry = Arc::new(SessionRegistry::new(
store,
anchor.generation_identity.clone(),
));
StagedControlFace::stage(runtime_domain_root, uid, anchor, registry)?.expose()
StagedControlFace::stage_with_executor(
runtime_domain_root,
uid,
anchor,
registry,
executor,
)?
.expose()
}
#[must_use]
@ -1132,13 +1471,44 @@ impl PublishedControlFace {
session_capability: capability,
}),
)?;
let mut extra = [0_u8; 1];
match stream.read(&mut extra) {
Ok(0) => Ok(()),
Ok(_) => Err(ControlError::InvalidRequest),
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(()),
Err(error) => Err(error.into()),
let request: ControlRequest = match read_frame(stream, MAX_CONTROL_BYTES) {
Ok(request) => request,
Err(ControlError::Io(error))
if matches!(
error.kind(),
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset
) =>
{
return Ok(());
}
Err(error) => return Err(error),
};
let ControlRequest::Execute(request) = request else {
return Err(ControlError::InvalidRequest);
};
if request.session_id != session_id
|| request.argv.is_empty()
|| request.argv[0].is_empty()
|| request.argv.iter().any(|argument| argument.contains(&0))
{
return Err(ControlError::InvalidRequest);
}
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)?;
if outcome.session_id() != request.session_id {
return Err(ControlError::InvalidRequest);
}
require_non_interactive_session(
&NonInteractiveSessionMessage::Exit(outcome),
request.session_id,
)?;
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
})();
let disconnected = disconnect(session_id);
result.and(disconnected)
@ -1301,6 +1671,22 @@ fn remove_if_identity_matches(path: &Path, expected: FileIdentity) {
}
}
fn require_non_interactive_session(
message: &NonInteractiveSessionMessage,
expected: SessionId,
) -> Result<(), ControlError> {
let actual = match message {
NonInteractiveSessionMessage::Stdout(output)
| NonInteractiveSessionMessage::Stderr(output) => output.session_id,
NonInteractiveSessionMessage::Exit(exit) => exit.session_id(),
};
if actual == expected {
Ok(())
} else {
Err(ControlError::InvalidRequest)
}
}
fn leaf_parameters(
common_name: &str,
usage: ExtendedKeyUsagePurpose,
@ -1406,6 +1792,7 @@ fn write_frame<Record: serde::Serialize>(
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::sync::Arc;
use std::thread;
@ -1483,6 +1870,64 @@ mod tests {
));
}
struct RecordingRunner {
observed: Mutex<Option<(OsString, Vec<OsString>)>>,
exit: ProcessExit,
}
impl NonInteractiveCommandRunner for RecordingRunner {
fn run(
&self,
target: &TargetCommand,
_session_id: SessionId,
_emit: &mut dyn FnMut(NonInteractiveSessionMessage) -> Result<(), ControlError>,
) -> Result<ProcessExit, ControlError> {
*self.observed.lock().unwrap() =
Some((target.program().to_owned(), target.arguments().to_vec()));
Ok(self.exit)
}
}
#[test]
fn runsc_exec_uses_the_command_as_the_first_post_container_argument() {
let runner = Arc::new(RecordingRunner {
observed: Mutex::new(None),
exit: ProcessExit {
exit_code: Some(23),
terminating_signal: None,
},
});
let executor = RunscNonInteractiveExecutor {
program: "runsc".into(),
global_arguments: vec!["--root=/runtime".into()],
container: "container-id".into(),
runner: runner.clone(),
};
let session_id = SessionId::new([9; 32]);
let exit = executor
.execute(
&NonInteractiveSessionRequest {
protocol_version: ProtocolVersion::V1,
session_id,
argv: vec![b"printf".to_vec(), b"hello".to_vec()],
},
&mut |_| Ok(()),
)
.unwrap();
assert_eq!(exit.exit_code(), Some(23));
assert_eq!(
runner.observed.lock().unwrap().as_ref().unwrap(),
&(
OsString::from("runsc"),
["--root=/runtime", "exec", "container-id", "printf", "hello"]
.into_iter()
.map(OsString::from)
.collect()
)
);
}
#[test]
fn rustls_refuses_client_leaf_with_server_role() {
let anchor = TrustAnchor::create(generation()).unwrap();