Implement supervised rootless network readiness

This commit is contained in:
ms2k 2026-07-15 23:26:37 +09:00
commit ba6a075839
3 changed files with 2917 additions and 54 deletions

View file

@ -1,4 +1,5 @@
#![doc = "Runtime construction interfaces owned by the Lazaret Runtime Supervisor."]
pub mod network;
pub mod sandbox;
pub mod supervision;

View file

@ -0,0 +1,756 @@
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream, UdpSocket};
use std::os::fd::{AsFd, AsRawFd, OwnedFd};
use std::path::Path;
use std::time::{Duration, Instant};
use lazaret_core::codec::decode;
use lazaret_core::identity::{
ConfigurationFingerprint, DerivedPolicyDigest, DomainId, GenerationId, NetworkSidecarId,
PidfsProcessIdentityV1,
};
use lazaret_core::network::{CanonicalDerivedNetworkPolicy, derive_policy};
use lazaret_core::records::{
DeniedDestinationObservation, DerivedNetworkPolicy, DerivedNetworkPolicyFields, DnsObservation,
HostProcessOwnershipSlot, IpAddress, IpNetwork, NetworkObservation, NetworkReachabilityProbe,
NetworkReadinessAttestation, NetworkReadinessAttestationFields,
PermittedDestinationObservation, PolicyAction, PolicyDelivery, ProcessOwnershipCommitAck,
StagedProcessOwnership,
};
use lazaret_linux::filesystem::{namespace_identity, require_write_sealed};
use lazaret_linux::process::{
ChildStandardIo, ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker,
};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::io::{FdFlags, fcntl_setfd, read};
use rustix::mount::{MountPropagationFlags, mount_change};
use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::{Signal, getgid, getuid};
use rustix::thread::UnshareFlags;
use serde::{Deserialize, Serialize};
const MAX_POLICY_BYTES: u64 = 64 * 1024;
const HELPER_READY_TIMEOUT: Duration = Duration::from_secs(30);
const ROUTE_READY_TIMEOUT: Duration = Duration::from_secs(15);
const SLIRP4NETNS: &str = "/usr/bin/slirp4netns";
const IP: &str = "/usr/bin/ip";
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const MAX_DNS_PACKET: usize = 4096;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TcpProbeTarget {
pub address: IpAddress,
pub port: u16,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DnsProbeTarget {
pub resolver: IpAddress,
pub query_name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NetworkProbeRequest {
pub domain_id: DomainId,
pub generation_id: GenerationId,
pub enforcement_namespace: lazaret_core::identity::NamespaceIdentity,
pub denied: Vec<TcpProbeTarget>,
pub permitted: Vec<TcpProbeTarget>,
pub dns: Vec<DnsProbeTarget>,
}
#[derive(Clone, Copy, Debug)]
pub struct NetworkAttestationContext {
pub domain_id: DomainId,
pub generation_id: GenerationId,
pub configuration_fingerprint: ConfigurationFingerprint,
pub network_sidecar_id: NetworkSidecarId,
pub runtime_supervisor: PidfsProcessIdentityV1,
pub coordinator_id: lazaret_core::identity::CoordinatorId,
}
pub struct PreparedNetwork {
helper: Option<RetainedProcess>,
helper_exit: Option<OwnedFd>,
attestation: NetworkReadinessAttestation,
}
impl PreparedNetwork {
#[must_use]
pub const fn attestation(&self) -> &NetworkReadinessAttestation {
&self.attestation
}
#[must_use]
pub fn helper_identity(&self) -> Option<PidfsProcessIdentityV1> {
self.helper.as_ref().map(RetainedProcess::identity)
}
/// # Errors
/// Returns an error when exact helper liveness cannot be inspected.
pub fn helper_exited(&self) -> io::Result<bool> {
self.helper
.as_ref()
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?
.has_exited()
}
/// # Errors
/// Returns an error unless the exact helper has exited and can be reaped.
pub fn reap_exited(mut self) -> io::Result<()> {
self.helper_exit.take();
let helper = self
.helper
.take()
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?;
if !helper.has_exited()? {
return Err(io::Error::other("connectivity helper is still present"));
}
child_broker().reap(helper)
}
/// # Errors
/// Returns an error when the exact helper cannot be stopped and reaped.
pub fn stop(mut self) -> io::Result<()> {
self.helper_exit.take();
let helper = self
.helper
.take()
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?;
let terminate = if helper.has_exited()? {
None
} else {
helper.send_signal(Signal::TERM).err()
};
let reaped = child_broker().reap(helper);
match (terminate, reaped) {
(None, Ok(())) => Ok(()),
(Some(error), Ok(())) | (None, Err(error)) => Err(error),
(Some(error), Err(reap)) => Err(io::Error::new(
error.kind(),
format!("{error}; exact connectivity-helper reap failed: {reap}"),
)),
}
}
}
/// Verifies sealed policy delivery, supervises slirp4netns, installs routes, and attests state.
///
/// # Errors
/// Refuses mutable or mismatched policy, helper acquisition/readiness, route, or namespace state.
pub fn prepare_network<Stage, Acknowledge>(
policy_file: &File,
expected_digest: DerivedPolicyDigest,
context: NetworkAttestationContext,
stage: Stage,
acknowledge: Acknowledge,
) -> io::Result<PreparedNetwork>
where
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
{
let policy = read_sealed_policy(policy_file, expected_digest)?;
let (helper_ready, helper_ready_signal) = pipe_with(PipeFlags::CLOEXEC)?;
let (helper_exit, helper_exit_signal) = pipe_with(PipeFlags::CLOEXEC)?;
fcntl_setfd(helper_ready_signal.as_fd(), FdFlags::empty())?;
fcntl_setfd(helper_exit.as_fd(), FdFlags::empty())?;
let target = slirp_target(
context.runtime_supervisor,
helper_exit.as_raw_fd(),
helper_ready_signal.as_raw_fd(),
);
let helper = child_broker().acquire_with_stdio_after_ack(
context.runtime_supervisor,
ProcessOwnershipContext {
domain_id: context.domain_id,
generation_id: context.generation_id,
coordinator_id: context.coordinator_id,
slot: HostProcessOwnershipSlot::HelperConnectivityHelper,
},
&target,
ChildStandardIo::silenced(),
stage,
acknowledge,
enter_domain_namespaces,
)?;
drop(helper_ready_signal);
drop(helper_exit);
let enforcement_namespace = match namespace_identity(Path::new("/proc/self/ns/net")) {
Ok(namespace) => namespace,
Err(error) => return cleanup_helper(helper, helper_exit_signal, error),
};
if let Err(error) = wait_for_helper_ready(&helper_ready, &helper) {
return cleanup_helper(helper, helper_exit_signal, error);
}
let observed_routes = match install_policy_routes(&policy) {
Ok(routes) => routes,
Err(error) => return cleanup_helper(helper, helper_exit_signal, error),
};
let attestation = NetworkReadinessAttestation::v1(NetworkReadinessAttestationFields {
domain_id: context.domain_id,
generation_id: context.generation_id,
configuration_fingerprint: context.configuration_fingerprint,
network_sidecar_id: context.network_sidecar_id,
derived_policy_digest: policy.digest(),
enforcement_namespace,
runtime_supervisor: context.runtime_supervisor,
connectivity_helper: helper.identity(),
observation: NetworkObservation {
policy_delivery: PolicyDelivery::SealedMemfd,
helper_ready: true,
observed_routes,
routes_attested: true,
},
});
Ok(PreparedNetwork {
helper: Some(helper),
helper_exit: Some(helper_exit_signal),
attestation,
})
}
#[allow(deprecated)]
fn enter_domain_namespaces() -> io::Result<()> {
let uid = getuid().as_raw();
let gid = getgid().as_raw();
rustix::thread::unshare(UnshareFlags::NEWUSER)?;
std::fs::write("/proc/self/setgroups", "deny")?;
std::fs::write("/proc/self/uid_map", format!("0 {uid} 1\n"))?;
std::fs::write("/proc/self/gid_map", format!("0 {gid} 1\n"))?;
rustix::thread::unshare(UnshareFlags::NEWNS | UnshareFlags::NEWNET)?;
mount_change(
"/",
MountPropagationFlags::PRIVATE | MountPropagationFlags::REC,
)?;
Ok(())
}
/// Executes the generation-bound reachability probe in the current enforcement namespace.
///
/// # Errors
/// Refuses changed namespace identity, empty coverage, non-IPv4 targets, or malformed DNS names.
pub fn probe_current_network(
request: &NetworkProbeRequest,
) -> io::Result<NetworkReachabilityProbe> {
if namespace_identity(Path::new("/proc/self/ns/net"))? != request.enforcement_namespace {
return Err(io::Error::other("network probe namespace identity changed"));
}
if request.denied.is_empty() || request.permitted.is_empty() || request.dns.is_empty() {
return Err(io::Error::other("network probe coverage is incomplete"));
}
let denied_destinations = request
.denied
.iter()
.map(|target| {
Ok(DeniedDestinationObservation {
destination: target.address,
unreachable: TcpStream::connect_timeout(&socket_address(*target)?, PROBE_TIMEOUT)
.is_err(),
})
})
.collect::<io::Result<Vec<_>>>()?;
let permitted_destinations = request
.permitted
.iter()
.map(|target| {
Ok(PermittedDestinationObservation {
destination: target.address,
reachable: TcpStream::connect_timeout(&socket_address(*target)?, PROBE_TIMEOUT)
.is_ok(),
})
})
.collect::<io::Result<Vec<_>>>()?;
let dns_observations = request
.dns
.iter()
.enumerate()
.map(|(index, target)| dns_probe(target, u16::try_from(index).unwrap_or(u16::MAX)))
.collect::<io::Result<Vec<_>>>()?;
Ok(NetworkReachabilityProbe {
domain_id: request.domain_id,
generation_id: request.generation_id,
enforcement_namespace: request.enforcement_namespace,
denied_destinations,
permitted_destinations,
dns_observations,
})
}
fn socket_address(target: TcpProbeTarget) -> io::Result<std::net::SocketAddr> {
let IpAddress::V4(address) = target.address else {
return Err(io::Error::other("v1 network probes require IPv4"));
};
if target.port == 0 {
return Err(io::Error::other("network probe port is invalid"));
}
Ok(SocketAddrV4::new(Ipv4Addr::from(address), target.port).into())
}
fn dns_probe(target: &DnsProbeTarget, transaction_id: u16) -> io::Result<DnsObservation> {
let IpAddress::V4(resolver) = target.resolver else {
return Err(io::Error::other("v1 DNS probes require IPv4"));
};
let query = dns_query(transaction_id, &target.query_name)?;
let socket = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))?;
socket.set_read_timeout(Some(PROBE_TIMEOUT))?;
socket.set_write_timeout(Some(PROBE_TIMEOUT))?;
socket.send_to(&query, SocketAddrV4::new(Ipv4Addr::from(resolver), 53))?;
let mut response = [0_u8; MAX_DNS_PACKET];
let (length, source) = socket.recv_from(&mut response).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"DNS probe receive from {}.{}.{}.{}:53 for {} failed: {error}",
resolver[0], resolver[1], resolver[2], resolver[3], target.query_name
),
)
})?;
if source.ip() != Ipv4Addr::from(resolver) || source.port() != 53 {
return Err(io::Error::other(
"DNS probe response source mismatched resolver",
));
}
let resolved_addresses = dns_answers(&response[..length], transaction_id)?;
Ok(DnsObservation {
resolver: target.resolver,
query_name: target.query_name.clone(),
resolved_addresses,
})
}
fn dns_query(transaction_id: u16, name: &str) -> io::Result<Vec<u8>> {
if name.is_empty() || name.len() > 253 || !name.is_ascii() {
return Err(io::Error::other("DNS probe name is invalid"));
}
let mut query = Vec::with_capacity(name.len() + 18);
query.extend_from_slice(&transaction_id.to_be_bytes());
query.extend_from_slice(&[0x01, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
for label in name.trim_end_matches('.').split('.') {
if label.is_empty()
|| label.len() > 63
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(io::Error::other("DNS probe label is invalid"));
}
query.push(
u8::try_from(label.len()).map_err(|_| io::Error::other("DNS label is oversized"))?,
);
query.extend_from_slice(label.as_bytes());
}
query.extend_from_slice(&[0, 0, 1, 0, 1]);
Ok(query)
}
fn dns_answers(response: &[u8], transaction_id: u16) -> io::Result<Vec<IpAddress>> {
if response.len() < 12
|| response[..2] != transaction_id.to_be_bytes()
|| response[2] & 0x80 == 0
|| response[3] & 0x0f != 0
|| u16::from_be_bytes([response[4], response[5]]) != 1
{
return Err(io::Error::other("DNS probe response header is invalid"));
}
let answers = u16::from_be_bytes([response[6], response[7]]);
let mut offset = skip_dns_name(response, 12)?;
offset = offset
.checked_add(4)
.filter(|offset| *offset <= response.len())
.ok_or_else(|| io::Error::other("DNS probe question is truncated"))?;
let mut addresses = Vec::new();
for _ in 0..answers {
offset = skip_dns_name(response, offset)?;
let header_end = offset
.checked_add(10)
.filter(|end| *end <= response.len())
.ok_or_else(|| io::Error::other("DNS probe answer is truncated"))?;
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
let class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
let data_length = usize::from(u16::from_be_bytes([
response[offset + 8],
response[offset + 9],
]));
let data_end = header_end
.checked_add(data_length)
.filter(|end| *end <= response.len())
.ok_or_else(|| io::Error::other("DNS probe record data is truncated"))?;
if record_type == 1 && class == 1 && data_length == 4 {
addresses.push(IpAddress::V4(
response[header_end..data_end]
.try_into()
.map_err(|_| io::Error::other("DNS A record is malformed"))?,
));
}
offset = data_end;
}
if addresses.is_empty() {
return Err(io::Error::other("DNS probe returned no IPv4 addresses"));
}
Ok(addresses)
}
fn skip_dns_name(packet: &[u8], mut offset: usize) -> io::Result<usize> {
loop {
let length = *packet
.get(offset)
.ok_or_else(|| io::Error::other("DNS name is truncated"))?;
offset += 1;
if length == 0 {
return Ok(offset);
}
if length & 0xc0 == 0xc0 {
return offset
.checked_add(1)
.filter(|end| *end <= packet.len())
.ok_or_else(|| io::Error::other("DNS compression pointer is truncated"));
}
if length & 0xc0 != 0 || length > 63 {
return Err(io::Error::other("DNS name label is malformed"));
}
offset = offset
.checked_add(usize::from(length))
.filter(|end| *end <= packet.len())
.ok_or_else(|| io::Error::other("DNS name label is truncated"))?;
}
}
fn read_sealed_policy(
policy_file: &File,
expected_digest: DerivedPolicyDigest,
) -> io::Result<CanonicalDerivedNetworkPolicy> {
require_write_sealed(policy_file)?;
let length = policy_file.metadata()?.len();
if length == 0 || length > MAX_POLICY_BYTES {
return Err(io::Error::other("sealed network policy has invalid size"));
}
let mut policy_file = policy_file.try_clone()?;
policy_file.seek(SeekFrom::Start(0))?;
let mut bytes = Vec::with_capacity(
usize::try_from(length).map_err(|_| io::Error::other("network policy is oversized"))?,
);
policy_file
.take(MAX_POLICY_BYTES + 1)
.read_to_end(&mut bytes)?;
if u64::try_from(bytes.len()).ok() != Some(length) {
return Err(io::Error::other(
"sealed network policy read was incomplete",
));
}
let decoded: DerivedNetworkPolicy =
decode(&bytes).map_err(|error| io::Error::other(error.to_string()))?;
let canonical = derive_policy(DerivedNetworkPolicyFields {
address_families: decoded.address_families,
default_actions: decoded.default_actions,
prefix_rules: decoded.prefix_rules,
resolvers: decoded.resolvers,
})
.map_err(io::Error::other)?;
if canonical.digest() != expected_digest || canonical.canonical_bytes() != bytes {
return Err(io::Error::other(
"sealed network policy digest or canonical bytes mismatch",
));
}
Ok(canonical)
}
fn slirp_target(runtime: PidfsProcessIdentityV1, exit_fd: i32, ready_fd: i32) -> TargetCommand {
TargetCommand::new(SLIRP4NETNS)
.argument("--configure")
.argument("--mtu=65520")
.argument("--disable-host-loopback")
.argument("--enable-sandbox")
.argument("--enable-seccomp")
.argument(format!("--exit-fd={exit_fd}"))
.argument(format!("--ready-fd={ready_fd}"))
.argument(format!(
"--userns-path=/proc/{}/ns/user",
runtime.host_pid().get()
))
.argument(runtime.host_pid().get().to_string())
.argument("tap0")
.with_parent_death_kill_edge()
}
fn wait_for_helper_ready(ready: &OwnedFd, helper: &RetainedProcess) -> io::Result<()> {
let deadline = Instant::now() + HELPER_READY_TIMEOUT;
loop {
if helper.has_exited()? {
return Err(io::Error::other(
"slirp4netns exited before signaling readiness",
));
}
if poll_helper_ready(ready)? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(io::Error::other("slirp4netns readiness timed out"));
}
}
}
fn poll_helper_ready(ready: &OwnedFd) -> io::Result<bool> {
let mut descriptors = [PollFd::new(ready, PollFlags::IN)];
poll(
&mut descriptors,
Some(&Timespec {
tv_sec: 0,
tv_nsec: 10_000_000,
}),
)?;
let events = descriptors[0].revents();
if events.intersects(PollFlags::IN) {
let mut byte = [0_u8; 1];
return match read(ready.as_fd(), &mut byte) {
Ok(0) => Err(io::Error::other("slirp4netns readiness pipe closed")),
Ok(_) => Ok(true),
Err(rustix::io::Errno::INTR) => Ok(false),
Err(error) => Err(error.into()),
};
}
if events.intersects(PollFlags::HUP | PollFlags::ERR) {
return Err(io::Error::other("slirp4netns readiness pipe closed"));
}
Ok(false)
}
fn install_policy_routes(policy: &CanonicalDerivedNetworkPolicy) -> io::Result<Vec<IpNetwork>> {
run_ip(["link", "set", "lo", "up"])?;
wait_for_default_route()?;
let mut denied = Vec::new();
for rule in &policy.policy().prefix_rules {
let IpAddress::V4(address) = rule.network.address else {
continue;
};
let cidr = ipv4_cidr(address, rule.network.prefix_length);
match rule.action {
PolicyAction::Deny => {
run_ip(["-4", "route", "replace", &cidr, "dev", "lo"])?;
denied.push(rule.network);
}
PolicyAction::Allow => {
install_tap_route(&cidr, address, rule.network.prefix_length)?;
}
}
}
for resolver in &policy.policy().resolvers {
let IpAddress::V4(address) = resolver.address else {
continue;
};
let cidr = ipv4_cidr(address, 32);
install_tap_route(&cidr, address, 32)?;
}
install_tap_route("10.0.2.3/32", [10, 0, 2, 3], 32)?;
verify_loopback_routes(&denied)?;
Ok(denied)
}
fn install_tap_route(cidr: &str, address: [u8; 4], prefix_length: u8) -> io::Result<()> {
if ipv4_network_contains(address, prefix_length, [10, 0, 2, 2]) {
run_ip(["-4", "route", "replace", cidr, "dev", "tap0"])
} else {
run_ip([
"-4", "route", "replace", cidr, "via", "10.0.2.2", "dev", "tap0",
])
}
}
fn ipv4_network_contains(network: [u8; 4], prefix_length: u8, address: [u8; 4]) -> bool {
let mask = if prefix_length == 0 {
0
} else {
u32::MAX << (32 - prefix_length)
};
u32::from_be_bytes(network) & mask == u32::from_be_bytes(address) & mask
}
fn wait_for_default_route() -> io::Result<()> {
let deadline = Instant::now() + ROUTE_READY_TIMEOUT;
loop {
let routes = std::fs::read_to_string("/proc/self/net/route")?;
if routes.lines().skip(1).any(|line| {
let mut columns = line.split_whitespace();
columns.next() == Some("tap0") && columns.next() == Some("00000000")
}) {
return Ok(());
}
if Instant::now() >= deadline {
return Err(io::Error::other("slirp4netns default route timed out"));
}
std::thread::sleep(Duration::from_millis(5));
}
}
fn verify_loopback_routes(expected: &[IpNetwork]) -> io::Result<()> {
let routes = std::fs::read_to_string("/proc/self/net/route")?;
for network in expected {
let IpAddress::V4(address) = network.address else {
continue;
};
let destination = u32::from_le_bytes(address);
let mask = if network.prefix_length == 0 {
0
} else {
u32::MAX << (32 - network.prefix_length)
};
let route_table_mask = u32::from_le_bytes(mask.to_be_bytes());
let found = routes.lines().skip(1).any(|line| {
let columns = line.split_whitespace().collect::<Vec<_>>();
columns.len() >= 8
&& columns[0] == "lo"
&& u32::from_str_radix(columns[1], 16).ok() == Some(destination)
&& u32::from_str_radix(columns[7], 16).ok() == Some(route_table_mask)
});
if !found {
return Err(io::Error::other(format!(
"loopback-unicast route is absent for {}",
ipv4_cidr(address, network.prefix_length)
)));
}
}
Ok(())
}
fn run_ip<const N: usize>(arguments: [&str; N]) -> io::Result<()> {
let mut target = TargetCommand::new(IP);
for argument in arguments {
target = target.argument(argument);
}
child_broker()
.run_transient(&target.with_parent_death_kill_edge())
.map(|_| ())
}
fn cleanup_helper<T>(helper: RetainedProcess, exit: OwnedFd, error: io::Error) -> io::Result<T> {
drop(exit);
let _ = helper.send_signal(Signal::KILL);
match child_broker().reap(helper) {
Ok(()) => Err(error),
Err(cleanup) => Err(io::Error::new(
error.kind(),
format!("{error}; exact connectivity-helper cleanup failed: {cleanup}"),
)),
}
}
fn ipv4_cidr(address: [u8; 4], prefix_length: u8) -> String {
format!(
"{}.{}.{}.{}/{}",
address[0], address[1], address[2], address[3], prefix_length
)
}
#[cfg(test)]
mod tests {
use lazaret_core::network::derive_policy;
use lazaret_core::records::{
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
DerivedNetworkPolicyFields, PolicyAction, PrefixRule, Resolver,
};
use lazaret_linux::filesystem::sealed_memfd;
use super::*;
#[test]
fn sealed_policy_requires_exact_canonical_digest() {
let policy = policy();
let file = sealed_memfd("network-test", policy.canonical_bytes()).unwrap();
assert_eq!(read_sealed_policy(&file, policy.digest()).unwrap(), policy);
assert!(read_sealed_policy(&file, DerivedPolicyDigest::new([9; 32])).is_err());
}
#[test]
fn dns_codec_accepts_matching_ipv4_answer_and_refuses_mutation() {
let transaction = 17;
let query = dns_query(transaction, "example.com").unwrap();
let mut response = query.clone();
response[2] = 0x81;
response[3] = 0x80;
response[6..8].copy_from_slice(&1_u16.to_be_bytes());
response.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 93, 184, 216, 34]);
assert_eq!(
dns_answers(&response, transaction).unwrap(),
vec![IpAddress::V4([93, 184, 216, 34])]
);
assert!(dns_answers(&response, transaction + 1).is_err());
assert!(dns_query(transaction, "bad..name").is_err());
}
#[test]
fn probe_refuses_a_changed_enforcement_namespace_before_network_io() {
let mut namespace = namespace_identity(Path::new("/proc/self/ns/net")).unwrap();
namespace.inode = lazaret_core::identity::InodeId::new(namespace.inode.get() + 1);
let request = NetworkProbeRequest {
domain_id: DomainId::new([1; 32]),
generation_id: GenerationId::new(7),
enforcement_namespace: namespace,
denied: vec![TcpProbeTarget {
address: IpAddress::V4([10, 0, 0, 1]),
port: 1,
}],
permitted: vec![TcpProbeTarget {
address: IpAddress::V4([1, 1, 1, 1]),
port: 443,
}],
dns: vec![DnsProbeTarget {
resolver: IpAddress::V4([8, 8, 8, 8]),
query_name: "example.com".into(),
}],
};
assert!(probe_current_network(&request).is_err());
}
#[test]
fn readiness_consumes_a_pending_byte_before_observing_writer_close() {
let (reader, writer) = pipe_with(PipeFlags::empty()).unwrap();
rustix::io::write(&writer, &[1]).unwrap();
drop(writer);
assert!(poll_helper_ready(&reader).unwrap());
}
#[test]
fn proc_route_mask_uses_little_endian_network_bytes() {
let mask = u32::MAX << (32 - 8);
assert_eq!(u32::from_le_bytes(mask.to_be_bytes()), 0x0000_00ff);
}
fn policy() -> CanonicalDerivedNetworkPolicy {
derive_policy(DerivedNetworkPolicyFields {
address_families: vec![
AddressFamilyDisposition {
family: AddressFamily::Ipv4,
disposition: AddressFamilyDispositionValue::Enabled,
},
AddressFamilyDisposition {
family: AddressFamily::Ipv6,
disposition: AddressFamilyDispositionValue::Disabled,
},
],
default_actions: vec![DefaultAction {
family: AddressFamily::Ipv4,
action: PolicyAction::Allow,
}],
prefix_rules: [
([10, 0, 0, 0], 8),
([172, 16, 0, 0], 12),
([192, 168, 0, 0], 16),
]
.into_iter()
.map(|(address, prefix_length)| PrefixRule {
family: AddressFamily::Ipv4,
network: IpNetwork {
address: IpAddress::V4(address),
prefix_length,
},
action: PolicyAction::Deny,
})
.collect(),
resolvers: vec![Resolver {
family: AddressFamily::Ipv4,
address: IpAddress::V4([8, 8, 8, 8]),
}],
})
.unwrap()
}
}

View file

@ -6,11 +6,17 @@ use std::os::unix::fs::FileExt;
use std::time::{Duration, Instant};
use lazaret_core::codec::{decode, encode};
use lazaret_core::identity::{CoordinatorId, DomainId, GenerationId};
use lazaret_core::identity::{
ConfigurationFingerprint, CoordinatorId, DerivedPolicyDigest, DomainId, GenerationId,
NetworkSidecarId,
};
use lazaret_core::network::CanonicalDerivedNetworkPolicy;
use lazaret_core::records::{
HostProcessOwnershipSlot, ProcessOwnershipCommitAck, StagedProcessOwnership,
HostProcessOwnershipSlot, NetworkReadinessAttestation, ProcessOwnershipCommitAck,
StagedProcessOwnership,
};
use lazaret_linux::capability;
use lazaret_linux::filesystem::sealed_memfd;
use lazaret_linux::process::{
ChildStandardIo, ParentEdge, ProcessOwnershipContext, RetainedProcess, TargetCommand,
child_broker, current_process_identity,
@ -22,6 +28,10 @@ use rustix::process::Signal;
use rustix::thread::CapabilitySet;
use serde::{Deserialize, Serialize};
use crate::network::{
NetworkAttestationContext, NetworkProbeRequest, PreparedNetwork, prepare_network,
probe_current_network,
};
use crate::sandbox::RunscCommand;
const PRIVATE_ROLE_ARGUMENT: &str = "--lazaret-runtime-supervisor-role";
@ -48,6 +58,11 @@ impl AttachedRunscSignal {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SupervisorEvent {
HelperStaged(StagedProcessOwnership),
HelperReady(StagedProcessOwnership),
NetworkAttestation(NetworkReadinessAttestation),
NetworkProbe(lazaret_core::records::NetworkReachabilityProbe),
HelperExited,
Staged(StagedProcessOwnership),
Ready {
staged: StagedProcessOwnership,
@ -59,8 +74,11 @@ pub enum SupervisorEvent {
Refused(String),
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
enum CoordinatorMessage {
HelperStagedAck(ProcessOwnershipCommitAck),
NetworkAttestationAccepted(NetworkProbeRequest),
NetworkReadinessCommitted,
StagedAck(ProcessOwnershipCommitAck),
SandboxCommitted,
SignalAttachedRunsc(AttachedRunscSignal),
@ -92,6 +110,33 @@ impl CoordinatorChannel {
)
}
/// # Errors
/// Returns an error when the exact helper acknowledgment cannot be delivered.
pub fn acknowledge_helper(&self, acknowledgment: ProcessOwnershipCommitAck) -> io::Result<()> {
send_message(
&self.descriptor,
&CoordinatorMessage::HelperStagedAck(acknowledgment),
)
}
/// # Errors
/// Returns an error when accepted attestation cannot release sandbox acquisition.
pub fn acknowledge_network_attestation(&self, request: NetworkProbeRequest) -> io::Result<()> {
send_message(
&self.descriptor,
&CoordinatorMessage::NetworkAttestationAccepted(request),
)
}
/// # Errors
/// Returns an error when durable network readiness cannot release sandbox acquisition.
pub fn acknowledge_network_readiness(&self) -> io::Result<()> {
send_message(
&self.descriptor,
&CoordinatorMessage::NetworkReadinessCommitted,
)
}
/// # Errors
/// Returns an error when the durable sandbox commit cannot be acknowledged.
pub fn acknowledge_sandbox_committed(&self) -> io::Result<()> {
@ -117,6 +162,14 @@ impl CoordinatorChannel {
pub struct RuntimeSupervisorLaunch {
target: TargetCommand,
ownership: ProcessOwnershipContext,
_policy_file: Option<File>,
network_sidecar_id: Option<NetworkSidecarId>,
}
pub struct RuntimeNetworkLaunch<'policy> {
pub policy: &'policy CanonicalDerivedNetworkPolicy,
pub configuration_fingerprint: ConfigurationFingerprint,
pub network_sidecar_id: NetworkSidecarId,
}
impl RuntimeSupervisorLaunch {
@ -157,9 +210,57 @@ impl RuntimeSupervisorLaunch {
Ok(Self {
target: target.with_lifetime_pipe_parent_edge(),
ownership,
_policy_file: None,
network_sidecar_id: None,
})
}
/// Builds the RS-owned network path with one inherited sealed policy document.
///
/// # Errors
/// Refuses an invalid runsc form or a policy descriptor that cannot be sealed and inherited.
pub fn from_networked_runsc_command(
ownership: ProcessOwnershipContext,
command: &RunscCommand,
network: RuntimeNetworkLaunch<'_>,
) -> io::Result<Self> {
let runtime_supervisor_executable = std::env::current_exe()?;
let mut launch = Self::from_runsc_command(ownership, command)?;
let policy_file = sealed_memfd("lazaret-network-policy", network.policy.canonical_bytes())?;
fcntl_setfd(policy_file.as_fd(), FdFlags::empty())?;
let policy_fd = policy_file.as_raw_fd();
let target = std::mem::replace(
&mut launch.target,
TargetCommand::new("/usr/bin/false").with_lifetime_pipe_parent_edge(),
);
launch.target = TargetCommand::new(runtime_supervisor_executable)
.argument(PRIVATE_ROLE_ARGUMENT)
.argument("--domain")
.argument(hex(ownership.domain_id.as_bytes()))
.argument("--generation")
.argument(ownership.generation_id.get().to_string())
.argument("--coordinator")
.argument(hex(ownership.coordinator_id.as_bytes()))
.argument("--network-policy-fd")
.argument(policy_fd.to_string())
.argument("--network-policy-digest")
.argument(hex(network.policy.digest().as_bytes()))
.argument("--configuration-fingerprint")
.argument(hex(network.configuration_fingerprint.as_bytes()))
.argument("--network-sidecar")
.argument(hex(network.network_sidecar_id.as_bytes()))
.argument("--runsc")
.argument(command.program());
for argument in command.arguments() {
launch.target = launch.target.argument(argument);
}
launch.target = launch.target.with_lifetime_pipe_parent_edge();
drop(target);
launch._policy_file = Some(policy_file);
launch.network_sidecar_id = Some(network.network_sidecar_id);
Ok(launch)
}
#[must_use]
pub const fn target(&self) -> &TargetCommand {
&self.target
@ -170,6 +271,11 @@ impl RuntimeSupervisorLaunch {
self.ownership
}
#[must_use]
pub const fn network_sidecar_id(&self) -> Option<NetworkSidecarId> {
self.network_sidecar_id
}
/// # Errors
/// Refuses a Runtime Supervisor target without the lifetime-pipe parent edge.
pub fn child_standard_io(
@ -214,18 +320,34 @@ pub fn dispatch_private_runtime_role(arguments: &[OsString]) -> Option<io::Resul
struct RuntimeArguments {
ownership: ProcessOwnershipContext,
network: Option<RuntimeNetworkArguments>,
runsc_program: OsString,
runsc_arguments: Vec<OsString>,
}
struct RuntimeNetworkArguments {
policy_fd: i32,
policy_digest: DerivedPolicyDigest,
configuration_fingerprint: ConfigurationFingerprint,
network_sidecar_id: NetworkSidecarId,
}
fn parse_runtime_arguments(arguments: &[OsString]) -> io::Result<RuntimeArguments> {
let mut arguments = arguments.iter();
let mut arguments = arguments.iter().peekable();
let domain = parse_fixed_hex(named(&mut arguments, "--domain")?)?;
let generation = named(&mut arguments, "--generation")?
.to_str()
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::other("Runtime Supervisor generation is invalid"))?;
let coordinator = parse_fixed_hex(named(&mut arguments, "--coordinator")?)?;
let network = if arguments
.peek()
.is_some_and(|value| *value == "--network-policy-fd")
{
Some(parse_network_arguments(&mut arguments)?)
} else {
None
};
if arguments.next().is_none_or(|value| value != "--runsc") {
return Err(io::Error::other(
"Runtime Supervisor runsc separator is missing",
@ -244,11 +366,38 @@ fn parse_runtime_arguments(arguments: &[OsString]) -> io::Result<RuntimeArgument
coordinator_id: CoordinatorId::new(coordinator),
slot: HostProcessOwnershipSlot::SandboxAttachedRunsc,
},
network,
runsc_program: (*program).clone(),
runsc_arguments: arguments.cloned().collect(),
})
}
fn parse_network_arguments<'a>(
arguments: &mut impl Iterator<Item = &'a OsString>,
) -> io::Result<RuntimeNetworkArguments> {
let policy_fd = named(arguments, "--network-policy-fd")?
.to_str()
.and_then(|value| value.parse().ok())
.filter(|value| *value >= 0)
.ok_or_else(|| io::Error::other("Runtime Supervisor policy descriptor is invalid"))?;
let policy_digest = DerivedPolicyDigest::new(parse_fixed_hex(named(
arguments,
"--network-policy-digest",
)?)?);
let configuration_fingerprint = ConfigurationFingerprint::new(parse_fixed_hex(named(
arguments,
"--configuration-fingerprint",
)?)?);
let network_sidecar_id =
NetworkSidecarId::new(parse_fixed_hex(named(arguments, "--network-sidecar")?)?);
Ok(RuntimeNetworkArguments {
policy_fd,
policy_digest,
configuration_fingerprint,
network_sidecar_id,
})
}
fn named<'a>(
arguments: &mut impl Iterator<Item = &'a OsString>,
name: &str,
@ -282,11 +431,17 @@ fn run_supervisor(arguments: &RuntimeArguments) -> io::Result<()> {
require_lifetime_held()?;
let control = std::io::stdout();
let parent = current_process_identity()?;
let network = match &arguments.network {
Some(network) => Some(prepare_runtime_network(
arguments, network, parent, &control,
)?),
None => None,
};
let pid_file = File::from(memfd_create("lazaret-runsc-pid", MemfdFlags::empty())?);
fcntl_setfd(pid_file.as_fd(), FdFlags::empty())?;
let runsc = runsc_target(arguments, pid_file.as_raw_fd())?;
let staged = std::cell::Cell::new(None);
let process = child_broker().acquire_with_stdio(
let process = match child_broker().acquire_with_stdio(
parent,
arguments.ownership,
&runsc,
@ -305,25 +460,29 @@ fn run_supervisor(arguments: &RuntimeArguments) -> io::Result<()> {
"Runtime Supervisor received a mismatched staged acknowledgment",
)),
},
)?;
) {
Ok(process) => process,
Err(error) => return fail_network(network, error),
};
if let Err(error) = fcntl_setfd(pid_file.as_fd(), FdFlags::CLOEXEC) {
return fail_with_exact_cleanup(process, error.into());
return fail_with_network_cleanup(process, network, error.into());
}
let Some(staged) = staged.get() else {
return fail_with_exact_cleanup(
return fail_with_network_cleanup(
process,
network,
io::Error::other("attached runsc ownership was not staged"),
);
};
let sentry_pid_inventory = match wait_for_runsc_ready(&pid_file, &process) {
Ok(pid) => pid,
Err(error) => return fail_with_exact_cleanup(process, error),
Err(error) => return fail_with_network_cleanup(process, network, error),
};
if let Err(error) = capability::irreversibly_drop(CapabilitySet::NET_ADMIN) {
return fail_with_exact_cleanup(process, error);
return fail_with_network_cleanup(process, network, error);
}
if let Err(error) = capability::require_regain_refused(CapabilitySet::NET_ADMIN) {
return fail_with_exact_cleanup(process, error);
return fail_with_network_cleanup(process, network, error);
}
if let Err(error) = send_message(
&control,
@ -332,19 +491,99 @@ fn run_supervisor(arguments: &RuntimeArguments) -> io::Result<()> {
sentry_pid_inventory,
},
) {
return fail_with_exact_cleanup(process, error);
return fail_with_network_cleanup(process, network, error);
}
let committed: CoordinatorMessage = match receive_message(&control) {
Ok(committed) => committed,
Err(error) => return fail_with_exact_cleanup(process, error),
Err(error) => return fail_with_network_cleanup(process, network, error),
};
if committed != CoordinatorMessage::SandboxCommitted {
return fail_with_exact_cleanup(
return fail_with_network_cleanup(
process,
network,
io::Error::other("sandbox commit acknowledgment was not received"),
);
}
supervise(&control, process)
supervise(&control, process, network)
}
fn prepare_runtime_network(
arguments: &RuntimeArguments,
network: &RuntimeNetworkArguments,
runtime_supervisor: lazaret_core::identity::PidfsProcessIdentityV1,
control: &std::io::Stdout,
) -> io::Result<PreparedNetwork> {
let policy_file = File::open(format!("/proc/self/fd/{}", network.policy_fd))?;
let staged = std::cell::Cell::new(None);
let prepared = prepare_network(
&policy_file,
network.policy_digest,
NetworkAttestationContext {
domain_id: arguments.ownership.domain_id,
generation_id: arguments.ownership.generation_id,
configuration_fingerprint: network.configuration_fingerprint,
network_sidecar_id: network.network_sidecar_id,
runtime_supervisor,
coordinator_id: arguments.ownership.coordinator_id,
},
|ownership| {
staged.set(Some(ownership));
send_message(control, &SupervisorEvent::HelperStaged(ownership))
},
|ownership| match receive_message(control)? {
CoordinatorMessage::HelperStagedAck(acknowledgment)
if acknowledgment.acknowledges(&ownership) =>
{
Ok(acknowledgment)
}
_ => Err(io::Error::other(
"Runtime Supervisor received a mismatched helper acknowledgment",
)),
},
)?;
let staged = staged
.get()
.ok_or_else(|| io::Error::other("connectivity helper ownership was not staged"))?;
if let Err(error) = send_message(control, &SupervisorEvent::HelperReady(staged)) {
return fail_network(Some(prepared), error);
}
if let Err(error) = send_message(
control,
&SupervisorEvent::NetworkAttestation(prepared.attestation().clone()),
) {
return fail_network(Some(prepared), error);
}
let message: CoordinatorMessage = match receive_message(control) {
Ok(message) => message,
Err(error) => return fail_network(Some(prepared), error),
};
let request = match message {
CoordinatorMessage::NetworkAttestationAccepted(request) => request,
_ => {
return fail_network(
Some(prepared),
io::Error::other("network attestation acceptance was not received"),
);
}
};
let probe = match probe_current_network(&request) {
Ok(probe) => probe,
Err(error) => return fail_network(Some(prepared), error),
};
if let Err(error) = send_message(control, &SupervisorEvent::NetworkProbe(probe)) {
return fail_network(Some(prepared), error);
}
let committed: CoordinatorMessage = match receive_message(control) {
Ok(message) => message,
Err(error) => return fail_network(Some(prepared), error),
};
if committed != CoordinatorMessage::NetworkReadinessCommitted {
return fail_network(
Some(prepared),
io::Error::other("durable network readiness was not acknowledged"),
);
}
Ok(prepared)
}
fn runsc_target(arguments: &RuntimeArguments, pid_file: i32) -> io::Result<TargetCommand> {
@ -397,23 +636,39 @@ fn wait_for_runsc_ready(pid_file: &File, process: &RetainedProcess) -> io::Resul
}
}
fn supervise(control: &std::io::Stdout, process: RetainedProcess) -> io::Result<()> {
fn supervise(
control: &std::io::Stdout,
process: RetainedProcess,
mut network: Option<PreparedNetwork>,
) -> io::Result<()> {
loop {
let exited = match process.has_exited() {
Ok(exited) => exited,
Err(error) => return fail_with_exact_cleanup(process, error),
Err(error) => return fail_with_network_cleanup(process, network, error),
};
if exited {
child_broker().reap(process)?;
stop_network(network)?;
send_message(control, &SupervisorEvent::AttachedRunscExited)?;
return Ok(());
}
if network
.as_ref()
.is_some_and(|network| network.helper_exited().unwrap_or(true))
{
network
.take()
.ok_or_else(|| io::Error::other("connectivity helper handle is absent"))?
.reap_exited()?;
send_message(control, &SupervisorEvent::HelperExited)?;
}
let lease_lost = match lifetime_lost() {
Ok(lost) => lost,
Err(error) => return fail_with_exact_cleanup(process, error),
Err(error) => return fail_with_network_cleanup(process, network, error),
};
if lease_lost {
terminate_and_reap(process)?;
stop_network(network)?;
return Ok(());
}
let mut descriptors = [PollFd::new(control, PollFlags::IN)];
@ -424,12 +679,13 @@ fn supervise(control: &std::io::Stdout, process: RetainedProcess) -> io::Result<
tv_nsec: 10_000_000,
}),
) {
return fail_with_exact_cleanup(process, error.into());
return fail_with_network_cleanup(process, network, error.into());
}
let events = descriptors[0].revents();
if events.intersects(PollFlags::HUP | PollFlags::ERR) {
return fail_with_exact_cleanup(
return fail_with_network_cleanup(
process,
network,
io::Error::new(
io::ErrorKind::UnexpectedEof,
"runtime control channel closed",
@ -439,27 +695,33 @@ fn supervise(control: &std::io::Stdout, process: RetainedProcess) -> io::Result<
if events.intersects(PollFlags::IN) {
let message = match receive_message(control) {
Ok(message) => message,
Err(error) => return fail_with_exact_cleanup(process, error),
Err(error) => return fail_with_network_cleanup(process, network, error),
};
match message {
CoordinatorMessage::SignalAttachedRunsc(signal) => {
if let Err(error) = process.send_signal(signal.signal()) {
return fail_with_exact_cleanup(process, error);
return fail_with_network_cleanup(process, network, error);
}
if let Err(error) =
send_message(control, &SupervisorEvent::SignalApplied(signal))
{
return fail_with_exact_cleanup(process, error);
return fail_with_network_cleanup(process, network, error);
}
}
CoordinatorMessage::Stop => {
terminate_and_reap(process)?;
stop_network(network)?;
send_message(control, &SupervisorEvent::Stopped)?;
return Ok(());
}
CoordinatorMessage::StagedAck(_) | CoordinatorMessage::SandboxCommitted => {
return fail_with_exact_cleanup(
CoordinatorMessage::HelperStagedAck(_)
| CoordinatorMessage::NetworkAttestationAccepted(_)
| CoordinatorMessage::NetworkReadinessCommitted
| CoordinatorMessage::StagedAck(_)
| CoordinatorMessage::SandboxCommitted => {
return fail_with_network_cleanup(
process,
network,
io::Error::other("unexpected duplicate coordinator message"),
);
}
@ -485,17 +747,39 @@ fn terminate_and_reap(process: RetainedProcess) -> io::Result<()> {
}
}
fn fail_with_exact_cleanup(process: RetainedProcess, error: io::Error) -> io::Result<()> {
let cleanup = terminate_and_reap(process);
match cleanup {
fn fail_with_network_cleanup(
process: RetainedProcess,
network: Option<PreparedNetwork>,
error: io::Error,
) -> io::Result<()> {
let runsc_cleanup = terminate_and_reap(process);
let network_cleanup = stop_network(network);
match (runsc_cleanup, network_cleanup) {
(Ok(()), Ok(())) => Err(error),
(runsc, network) => Err(io::Error::new(
error.kind(),
format!("{error}; exact runtime cleanup failed: runsc={runsc:?}, network={network:?}"),
)),
}
}
fn fail_network<T>(network: Option<PreparedNetwork>, error: io::Error) -> io::Result<T> {
match stop_network(network) {
Ok(()) => Err(error),
Err(cleanup) => Err(io::Error::new(
error.kind(),
format!("{error}; exact attached-runsc cleanup failed: {cleanup}"),
format!("{error}; exact connectivity-helper cleanup failed: {cleanup}"),
)),
}
}
fn stop_network(network: Option<PreparedNetwork>) -> io::Result<()> {
match network {
Some(network) => network.stop(),
None => Ok(()),
}
}
fn require_lifetime_held() -> io::Result<()> {
if lifetime_lost()? {
Err(io::Error::other("Host Coordinator lifetime lease is lost"))
@ -584,7 +868,7 @@ mod tests {
use lazaret_core::identity::{CoordinatorId, DomainId, GenerationId};
use lazaret_core::records::HostProcessOwnershipSlot;
use lazaret_linux::ipc::private_stream_pair;
use lazaret_linux::ipc::private_seqpacket_pair;
use lazaret_linux::process::ChildStandardIoPolicy;
use rustix::pipe::{PipeFlags, pipe_with};
@ -592,7 +876,7 @@ mod tests {
#[test]
fn private_stream_framing_round_trips_typed_events() {
let (coordinator, supervisor) = private_stream_pair().unwrap();
let (coordinator, supervisor) = private_seqpacket_pair().unwrap();
let expected = SupervisorEvent::Refused("refused".into());
send_message(&supervisor, &expected).unwrap();
assert_eq!(
@ -612,8 +896,10 @@ mod tests {
let launch = RuntimeSupervisorLaunch {
target: TargetCommand::new("/proc/self/exe").with_lifetime_pipe_parent_edge(),
ownership,
_policy_file: None,
network_sidecar_id: None,
};
let (_coordinator_control, supervisor_control) = private_stream_pair().unwrap();
let (_coordinator_control, supervisor_control) = private_seqpacket_pair().unwrap();
let (supervisor_lifetime, _coordinator_lifetime) = pipe_with(PipeFlags::CLOEXEC).unwrap();
assert_eq!(launch.target().parent_edge(), ParentEdge::LifetimePipe);
assert_eq!(
@ -629,6 +915,7 @@ mod tests {
slot: HostProcessOwnershipSlot::SandboxAttachedRunsc,
..ownership
},
network: None,
runsc_program: "runsc".into(),
runsc_arguments: ["-root=/runtime", "run", "container"]
.map(OsString::from)