Implement supervised rootless network readiness
This commit is contained in:
parent
407b75b1ff
commit
ba6a075839
15 changed files with 2917 additions and 54 deletions
|
|
@ -275,6 +275,18 @@ pub fn accept_network_readiness(
|
|||
})
|
||||
}
|
||||
|
||||
/// Validates Runtime Supervisor attestation before the sandbox-view probe is available.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error for any identity, observation, policy, or process mismatch.
|
||||
pub fn validate_network_attestation(
|
||||
policy: &CanonicalDerivedNetworkPolicy,
|
||||
expectation: &ReadinessExpectation<'_>,
|
||||
attestation: &NetworkReadinessAttestation,
|
||||
) -> Result<(), NetworkValidationError> {
|
||||
validate_attestation(policy, expectation, attestation)
|
||||
}
|
||||
|
||||
/// Revalidates persisted readiness evidence against current generation authority.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@
|
|||
pub mod control;
|
||||
pub mod domain_store;
|
||||
pub mod escalation;
|
||||
pub mod network;
|
||||
pub mod runtime;
|
||||
pub mod state_anchor;
|
||||
|
|
|
|||
482
crates/lazaret-host-coordinator/src/network.rs
Normal file
482
crates/lazaret-host-coordinator/src/network.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lazaret_core::identity::{
|
||||
DerivedPolicyDigest, GenerationId, NamespaceIdentity, NetworkSidecarId, PidfsProcessIdentityV1,
|
||||
};
|
||||
use lazaret_core::network::{
|
||||
CanonicalDerivedNetworkPolicy, ReachabilityExpectation, ReadinessExpectation,
|
||||
accept_network_readiness, validate_network_attestation,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
HelperOwnership, HostProcessOwnershipSlot, NetworkOwnership, NetworkReachabilityProbe,
|
||||
NetworkReadinessAttestation, NetworkReadinessEvidence, Participant, ParticipantOwnership,
|
||||
ParticipantRole, ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck,
|
||||
StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::filesystem::namespace_identity;
|
||||
|
||||
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NetworkCoordinatorError {
|
||||
Store(DomainStoreError),
|
||||
Io(std::io::Error),
|
||||
InvalidState(&'static str),
|
||||
InvalidEvidence(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for NetworkCoordinatorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Store(error) => {
|
||||
write!(formatter, "Network Domain Store operation failed: {error}")
|
||||
}
|
||||
Self::Io(error) => write!(formatter, "Network host observation failed: {error}"),
|
||||
Self::InvalidState(detail) => write!(formatter, "Network state refused: {detail}"),
|
||||
Self::InvalidEvidence(detail) => {
|
||||
write!(formatter, "Network evidence refused: {detail}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NetworkCoordinatorError {}
|
||||
|
||||
impl From<DomainStoreError> for NetworkCoordinatorError {
|
||||
fn from(error: DomainStoreError) -> Self {
|
||||
Self::Store(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for NetworkCoordinatorError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NetworkCoordinator<'store> {
|
||||
domain_store: &'store HostCoordinatorDomainStore,
|
||||
}
|
||||
|
||||
impl<'store> NetworkCoordinator<'store> {
|
||||
#[must_use]
|
||||
pub const fn new(domain_store: &'store HostCoordinatorDomainStore) -> Self {
|
||||
Self { domain_store }
|
||||
}
|
||||
|
||||
/// Persists independent helper intent before launch and the observed Network RS authority.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a missing generation, changed identity, or an already-different role snapshot.
|
||||
pub fn begin_helper_intent(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
network_sidecar_id: NetworkSidecarId,
|
||||
runtime_supervisor: PidfsProcessIdentityV1,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let generation = snapshot
|
||||
.generations
|
||||
.iter()
|
||||
.find(|generation| generation.generation_id == generation_id)
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"helper generation is absent",
|
||||
))?;
|
||||
if generation.network_sidecar_id != network_sidecar_id {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper Network Sidecar identity mismatches generation",
|
||||
));
|
||||
}
|
||||
let intent = helper_participant(
|
||||
generation_id,
|
||||
ParticipantState::Intent,
|
||||
HelperOwnership {
|
||||
network_sidecar_id: Some(network_sidecar_id),
|
||||
connectivity_helper: PersistedHostProcessOwnership::unassigned(),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::committed(runtime_supervisor),
|
||||
},
|
||||
)?;
|
||||
put_initial_or_exact(self.domain_store, &snapshot.participants, intent)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses a non-helper tuple, absent intent, changed RS, or skipped acquisition phase.
|
||||
pub fn stage_helper(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
runtime_supervisor: PidfsProcessIdentityV1,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let current = self.participant(staged.generation_id(), ParticipantRole::Helper)?;
|
||||
if current.state() != ParticipantState::Intent {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper acquisition is not in Intent",
|
||||
));
|
||||
}
|
||||
let Some(ParticipantOwnership::Helper(mut ownership)) = current.ownership().cloned() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
if ownership.runtime_supervisor
|
||||
!= PersistedHostProcessOwnership::committed(runtime_supervisor)
|
||||
|| !ownership.connectivity_helper.is_unassigned()
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership changed before staging",
|
||||
));
|
||||
}
|
||||
ownership.connectivity_helper = PersistedHostProcessOwnership::staged(staged.identity());
|
||||
self.put(helper_participant(
|
||||
staged.generation_id(),
|
||||
ParticipantState::Intent,
|
||||
ownership,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses acknowledgment unless the exact helper tuple is durable.
|
||||
pub fn acknowledge_helper(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<ProcessOwnershipCommitAck, NetworkCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let current = self.participant(staged.generation_id(), ParticipantRole::Helper)?;
|
||||
let Some(ParticipantOwnership::Helper(ownership)) = current.ownership() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
if ownership.connectivity_helper != PersistedHostProcessOwnership::staged(staged.identity())
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"staged helper ownership is not durable",
|
||||
));
|
||||
}
|
||||
Ok(ProcessOwnershipCommitAck::v1(staged))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses a changed helper tuple or incomplete supervising ownership.
|
||||
pub fn commit_helper(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let current = self.participant(staged.generation_id(), ParticipantRole::Helper)?;
|
||||
let Some(ParticipantOwnership::Helper(mut ownership)) = current.ownership().cloned() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
if ownership.connectivity_helper != PersistedHostProcessOwnership::staged(staged.identity())
|
||||
|| !ownership.runtime_supervisor.is_current_committed()
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper readiness does not match committed ownership",
|
||||
));
|
||||
}
|
||||
ownership.connectivity_helper = PersistedHostProcessOwnership::committed(staged.identity());
|
||||
self.put(helper_participant(
|
||||
staged.generation_id(),
|
||||
ParticipantState::Committed,
|
||||
ownership,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Independently observes the exact RS network namespace and persists Network intent.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a missing generation, changed policy, namespace, or Runtime Supervisor identity.
|
||||
pub fn begin_network_intent(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
policy_digest: DerivedPolicyDigest,
|
||||
runtime_supervisor: PidfsProcessIdentityV1,
|
||||
) -> Result<NamespaceIdentity, NetworkCoordinatorError> {
|
||||
let namespace = observe_process_network_namespace(runtime_supervisor)?;
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
if !snapshot
|
||||
.generations
|
||||
.iter()
|
||||
.any(|generation| generation.generation_id == generation_id)
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"network generation is absent",
|
||||
));
|
||||
}
|
||||
let intent = network_participant(
|
||||
generation_id,
|
||||
ParticipantState::Intent,
|
||||
NetworkOwnership {
|
||||
derived_policy_digest: Some(policy_digest),
|
||||
readiness_fingerprint: None,
|
||||
readiness_evidence: None,
|
||||
namespace: Some(namespace),
|
||||
runtime_supervisor: PersistedHostProcessOwnership::committed(runtime_supervisor),
|
||||
},
|
||||
)?;
|
||||
put_initial_or_exact(self.domain_store, &snapshot.participants, intent)?;
|
||||
Ok(namespace)
|
||||
}
|
||||
|
||||
/// Independently validates RS attestation before allowing attached runsc acquisition.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses absent committed helper/RS ownership, namespace mutation, or identity mismatch.
|
||||
pub fn validate_attestation(
|
||||
&self,
|
||||
policy: &CanonicalDerivedNetworkPolicy,
|
||||
attestation: &NetworkReadinessAttestation,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let generation = snapshot
|
||||
.generations
|
||||
.iter()
|
||||
.find(|generation| generation.generation_id == attestation.generation_id)
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"attestation generation is absent",
|
||||
))?;
|
||||
let network = self.participant(attestation.generation_id, ParticipantRole::Network)?;
|
||||
let Some(ParticipantOwnership::Network(network)) = network.ownership() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"network ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
let namespace = network
|
||||
.namespace
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"network namespace evidence is absent",
|
||||
))?;
|
||||
if observe_process_network_namespace(attestation.runtime_supervisor)? != namespace {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor network namespace changed",
|
||||
));
|
||||
}
|
||||
let runtime_supervisor = *network
|
||||
.runtime_supervisor
|
||||
.serviceable_pidfs()
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("Network RS is not committed"))?;
|
||||
let helper = self.participant(attestation.generation_id, ParticipantRole::Helper)?;
|
||||
let Some(ParticipantOwnership::Helper(helper)) = helper.ownership() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
let connectivity_helper = *helper
|
||||
.connectivity_helper
|
||||
.serviceable_pidfs()
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("helper is not committed"))?;
|
||||
let expectation = ReadinessExpectation {
|
||||
domain_id: snapshot.domain.domain_id,
|
||||
generation_id: generation.generation_id,
|
||||
configuration_fingerprint: generation.configuration_fingerprint,
|
||||
network_sidecar_id: generation.network_sidecar_id,
|
||||
enforcement_namespace: namespace,
|
||||
runtime_supervisor: &runtime_supervisor,
|
||||
connectivity_helper: &connectivity_helper,
|
||||
};
|
||||
validate_network_attestation(policy, &expectation, attestation)
|
||||
.map_err(|error| NetworkCoordinatorError::InvalidEvidence(error.to_string()))
|
||||
}
|
||||
|
||||
/// Validates the exact attestation and sandbox-view probe before durable readiness.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses incomplete helper ownership, mutation, failed probe, or any generation mismatch.
|
||||
pub fn commit_readiness(
|
||||
&self,
|
||||
policy: &CanonicalDerivedNetworkPolicy,
|
||||
attestation: NetworkReadinessAttestation,
|
||||
probe: NetworkReachabilityProbe,
|
||||
expected_reachability: &ReachabilityExpectation,
|
||||
) -> Result<NetworkReadinessEvidence, NetworkCoordinatorError> {
|
||||
let generation_id = attestation.generation_id;
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let generation = snapshot
|
||||
.generations
|
||||
.iter()
|
||||
.find(|generation| generation.generation_id == generation_id)
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"readiness generation is absent",
|
||||
))?;
|
||||
let network = self.participant(generation_id, ParticipantRole::Network)?;
|
||||
let Some(ParticipantOwnership::Network(ownership)) = network.ownership().cloned() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"network ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
let mut ownership = *ownership;
|
||||
if network.state() != ParticipantState::Intent
|
||||
|| ownership.derived_policy_digest != Some(policy.digest())
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"network policy intent is absent or changed",
|
||||
));
|
||||
}
|
||||
let namespace = ownership
|
||||
.namespace
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"network namespace evidence is absent",
|
||||
))?;
|
||||
let runtime_supervisor = *ownership
|
||||
.runtime_supervisor
|
||||
.serviceable_pidfs()
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("Network RS is not committed"))?;
|
||||
let helper_participant = self.participant(generation_id, ParticipantRole::Helper)?;
|
||||
let Some(ParticipantOwnership::Helper(helper)) = helper_participant.ownership() else {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper ownership shape is invalid",
|
||||
));
|
||||
};
|
||||
if helper_participant.state() != ParticipantState::Committed
|
||||
|| helper.runtime_supervisor.serviceable_pidfs().ok() != Some(&runtime_supervisor)
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"helper and supervising RS are not committed",
|
||||
));
|
||||
}
|
||||
let connectivity_helper = *helper
|
||||
.connectivity_helper
|
||||
.serviceable_pidfs()
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("helper is not committed"))?;
|
||||
let expectation = ReadinessExpectation {
|
||||
domain_id: snapshot.domain.domain_id,
|
||||
generation_id,
|
||||
configuration_fingerprint: generation.configuration_fingerprint,
|
||||
network_sidecar_id: generation.network_sidecar_id,
|
||||
enforcement_namespace: namespace,
|
||||
runtime_supervisor: &runtime_supervisor,
|
||||
connectivity_helper: &connectivity_helper,
|
||||
};
|
||||
let evidence = accept_network_readiness(
|
||||
policy,
|
||||
&expectation,
|
||||
attestation,
|
||||
probe,
|
||||
expected_reachability,
|
||||
)
|
||||
.map_err(|error| NetworkCoordinatorError::InvalidEvidence(error.to_string()))?;
|
||||
ownership.readiness_fingerprint = Some(evidence.readiness_fingerprint);
|
||||
ownership.readiness_evidence = Some(evidence.clone());
|
||||
self.put(network_participant(
|
||||
generation_id,
|
||||
ParticipantState::Committed,
|
||||
ownership,
|
||||
)?)?;
|
||||
Ok(evidence)
|
||||
}
|
||||
|
||||
fn require_staged(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
let snapshot = self.domain_store.snapshot()?;
|
||||
let generation = snapshot
|
||||
.generations
|
||||
.iter()
|
||||
.find(|generation| generation.generation_id == staged.generation_id())
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"staged helper generation is absent",
|
||||
))?;
|
||||
if staged.domain_id() != snapshot.domain.domain_id
|
||||
|| staged.coordinator_id() != generation.coordinator_id
|
||||
|| staged.slot() != HostProcessOwnershipSlot::HelperConnectivityHelper
|
||||
{
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"staged helper tuple mismatches generation",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn participant(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
role: ParticipantRole,
|
||||
) -> Result<Participant, NetworkCoordinatorError> {
|
||||
self.domain_store
|
||||
.snapshot()?
|
||||
.participants
|
||||
.into_iter()
|
||||
.find(|participant| {
|
||||
participant.generation_id() == generation_id && participant.role() == role
|
||||
})
|
||||
.ok_or(NetworkCoordinatorError::InvalidState(
|
||||
"network participant is absent",
|
||||
))
|
||||
}
|
||||
|
||||
fn put(&self, participant: Participant) -> Result<(), NetworkCoordinatorError> {
|
||||
self.domain_store
|
||||
.apply(&[DomainStoreMutation::PutParticipant(participant)])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn put_initial_or_exact(
|
||||
store: &HostCoordinatorDomainStore,
|
||||
participants: &[Participant],
|
||||
intent: Participant,
|
||||
) -> Result<(), NetworkCoordinatorError> {
|
||||
match participants.iter().find(|participant| {
|
||||
participant.generation_id() == intent.generation_id() && participant.role() == intent.role()
|
||||
}) {
|
||||
None => store.apply(&[
|
||||
DomainStoreMutation::PutParticipant(Participant::bare_intent(
|
||||
intent.generation_id(),
|
||||
intent.role(),
|
||||
)),
|
||||
DomainStoreMutation::PutParticipant(intent),
|
||||
])?,
|
||||
Some(current) if current == &intent => {}
|
||||
Some(_) => {
|
||||
return Err(NetworkCoordinatorError::InvalidState(
|
||||
"network role intent already differs",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn network_participant(
|
||||
generation_id: GenerationId,
|
||||
state: ParticipantState,
|
||||
ownership: NetworkOwnership,
|
||||
) -> Result<Participant, NetworkCoordinatorError> {
|
||||
Participant::snapshot(
|
||||
generation_id,
|
||||
ParticipantRole::Network,
|
||||
state,
|
||||
Some(ParticipantOwnership::Network(Box::new(ownership))),
|
||||
)
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("network participant shape is invalid"))
|
||||
}
|
||||
|
||||
fn helper_participant(
|
||||
generation_id: GenerationId,
|
||||
state: ParticipantState,
|
||||
ownership: HelperOwnership,
|
||||
) -> Result<Participant, NetworkCoordinatorError> {
|
||||
Participant::snapshot(
|
||||
generation_id,
|
||||
ParticipantRole::Helper,
|
||||
state,
|
||||
Some(ParticipantOwnership::Helper(ownership)),
|
||||
)
|
||||
.map_err(|_| NetworkCoordinatorError::InvalidState("helper participant shape is invalid"))
|
||||
}
|
||||
|
||||
fn observe_process_network_namespace(
|
||||
runtime_supervisor: PidfsProcessIdentityV1,
|
||||
) -> Result<NamespaceIdentity, NetworkCoordinatorError> {
|
||||
let path = PathBuf::from(format!(
|
||||
"/proc/{}/ns/net",
|
||||
runtime_supervisor.host_pid().get()
|
||||
));
|
||||
Ok(namespace_identity(&path)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
244
crates/lazaret-host-coordinator/src/network/tests.rs
Normal file
244
crates/lazaret-host-coordinator/src/network/tests.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
use std::fs::{File, OpenOptions};
|
||||
|
||||
use lazaret_core::identity::{
|
||||
CoordinatorId, DerivedPolicyDigest, DomainId, GenerationId, HostPath, NamespaceIdentity,
|
||||
NetworkSidecarId, PidfsProcessIdentityV1, SandboxId,
|
||||
};
|
||||
use lazaret_core::network::{DnsQueryExpectation, ReachabilityExpectation, derive_policy};
|
||||
use lazaret_core::records::{
|
||||
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
|
||||
DeniedDestinationObservation, DerivedNetworkPolicyFields, DnsObservation, Domain,
|
||||
DurableDecision, Generation, HostProcessOwnershipSlot, IpAddress, IpNetwork,
|
||||
NetworkObservation, NetworkReachabilityProbe, NetworkReadinessAttestation,
|
||||
NetworkReadinessAttestationFields, ParticipantOwnership, ParticipantRole, ParticipantState,
|
||||
PermittedDestinationObservation, PolicyAction, PolicyDelivery, PrefixRule, Resolver,
|
||||
StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::filesystem::{namespace_identity, try_exclusive_flock};
|
||||
use lazaret_linux::process::current_process_identity;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Fixture {
|
||||
_tree: TempDir,
|
||||
store: HostCoordinatorDomainStore,
|
||||
generation: Generation,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
let domain = Domain {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
profile_name: "network-test".into(),
|
||||
canonical_workspace: HostPath::new(b"/workspace".to_vec()),
|
||||
};
|
||||
let generation = Generation {
|
||||
domain_id: domain.domain_id,
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: lazaret_core::identity::ConfigurationFingerprint::new(
|
||||
[2; 32],
|
||||
),
|
||||
coordinator_id: CoordinatorId::new([3; 32]),
|
||||
network_sidecar_id: NetworkSidecarId::new([4; 32]),
|
||||
sandbox_id: SandboxId::new([5; 32]),
|
||||
durable_decision: DurableDecision::None,
|
||||
};
|
||||
let lock = try_exclusive_flock(lock_file(&tree.path().join("domain.lock"))).unwrap();
|
||||
let store =
|
||||
HostCoordinatorDomainStore::open(lock, &tree.path().join("domain.redb"), &domain)
|
||||
.unwrap();
|
||||
store
|
||||
.apply(&[DomainStoreMutation::PutGeneration(generation.clone())])
|
||||
.unwrap();
|
||||
Self {
|
||||
_tree: tree,
|
||||
store,
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_roles(&self) -> (CanonicalDerivedNetworkPolicy, PidfsProcessIdentityV1) {
|
||||
let policy = policy();
|
||||
let runtime = current_process_identity().unwrap();
|
||||
let coordinator = NetworkCoordinator::new(&self.store);
|
||||
coordinator
|
||||
.begin_helper_intent(
|
||||
self.generation.generation_id,
|
||||
self.generation.network_sidecar_id,
|
||||
runtime,
|
||||
)
|
||||
.unwrap();
|
||||
let staged = StagedProcessOwnership::v1(
|
||||
self.generation.domain_id,
|
||||
self.generation.generation_id,
|
||||
self.generation.coordinator_id,
|
||||
HostProcessOwnershipSlot::HelperConnectivityHelper,
|
||||
runtime,
|
||||
);
|
||||
coordinator.stage_helper(staged, runtime).unwrap();
|
||||
assert!(
|
||||
coordinator
|
||||
.acknowledge_helper(staged)
|
||||
.unwrap()
|
||||
.acknowledges(&staged)
|
||||
);
|
||||
coordinator.commit_helper(staged).unwrap();
|
||||
coordinator
|
||||
.begin_network_intent(self.generation.generation_id, policy.digest(), runtime)
|
||||
.unwrap();
|
||||
(policy, runtime)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_probe_leaves_network_intent_uncommitted() {
|
||||
let fixture = Fixture::new();
|
||||
let (policy, runtime) = fixture.prepare_roles();
|
||||
let namespace = namespace_identity(std::path::Path::new("/proc/self/ns/net")).unwrap();
|
||||
let attestation = attestation(&fixture.generation, policy.digest(), namespace, runtime);
|
||||
let probe = NetworkReachabilityProbe {
|
||||
domain_id: fixture.generation.domain_id,
|
||||
generation_id: fixture.generation.generation_id,
|
||||
enforcement_namespace: namespace,
|
||||
denied_destinations: vec![DeniedDestinationObservation {
|
||||
destination: IpAddress::V4([10, 1, 2, 3]),
|
||||
unreachable: false,
|
||||
}],
|
||||
permitted_destinations: vec![PermittedDestinationObservation {
|
||||
destination: IpAddress::V4([1, 1, 1, 1]),
|
||||
reachable: true,
|
||||
}],
|
||||
dns_observations: vec![DnsObservation {
|
||||
resolver: IpAddress::V4([8, 8, 8, 8]),
|
||||
query_name: "example.com".into(),
|
||||
resolved_addresses: vec![IpAddress::V4([93, 184, 216, 34])],
|
||||
}],
|
||||
};
|
||||
let result = NetworkCoordinator::new(&fixture.store).commit_readiness(
|
||||
&policy,
|
||||
attestation,
|
||||
probe,
|
||||
&reachability(),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(NetworkCoordinatorError::InvalidEvidence(_))
|
||||
));
|
||||
let participant = fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.participants
|
||||
.into_iter()
|
||||
.find(|participant| participant.role() == ParticipantRole::Network)
|
||||
.unwrap();
|
||||
assert_eq!(participant.state(), ParticipantState::Intent);
|
||||
let Some(ParticipantOwnership::Network(ownership)) = participant.ownership() else {
|
||||
panic!("network ownership missing");
|
||||
};
|
||||
assert!(ownership.readiness_evidence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independently_observed_namespace_refuses_attestation_mutation() {
|
||||
let fixture = Fixture::new();
|
||||
let (policy, runtime) = fixture.prepare_roles();
|
||||
let namespace = namespace_identity(std::path::Path::new("/proc/self/ns/net")).unwrap();
|
||||
let mut attestation = attestation(&fixture.generation, policy.digest(), namespace, runtime);
|
||||
attestation.enforcement_namespace.inode =
|
||||
lazaret_core::identity::InodeId::new(attestation.enforcement_namespace.inode.get() + 1);
|
||||
assert!(matches!(
|
||||
NetworkCoordinator::new(&fixture.store).validate_attestation(&policy, &attestation),
|
||||
Err(NetworkCoordinatorError::InvalidState(_))
|
||||
| Err(NetworkCoordinatorError::InvalidEvidence(_))
|
||||
));
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn attestation(
|
||||
generation: &Generation,
|
||||
digest: DerivedPolicyDigest,
|
||||
namespace: NamespaceIdentity,
|
||||
runtime: PidfsProcessIdentityV1,
|
||||
) -> NetworkReadinessAttestation {
|
||||
NetworkReadinessAttestation::v1(NetworkReadinessAttestationFields {
|
||||
domain_id: generation.domain_id,
|
||||
generation_id: generation.generation_id,
|
||||
configuration_fingerprint: generation.configuration_fingerprint,
|
||||
network_sidecar_id: generation.network_sidecar_id,
|
||||
derived_policy_digest: digest,
|
||||
enforcement_namespace: namespace,
|
||||
runtime_supervisor: runtime,
|
||||
connectivity_helper: runtime,
|
||||
observation: NetworkObservation {
|
||||
policy_delivery: PolicyDelivery::SealedMemfd,
|
||||
helper_ready: true,
|
||||
observed_routes: vec![IpNetwork {
|
||||
address: IpAddress::V4([10, 0, 0, 0]),
|
||||
prefix_length: 8,
|
||||
}],
|
||||
routes_attested: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn reachability() -> ReachabilityExpectation {
|
||||
ReachabilityExpectation {
|
||||
denied_destinations: vec![IpAddress::V4([10, 1, 2, 3])],
|
||||
permitted_destinations: vec![IpAddress::V4([1, 1, 1, 1])],
|
||||
dns_queries: vec![DnsQueryExpectation {
|
||||
resolver: IpAddress::V4([8, 8, 8, 8]),
|
||||
query_name: "example.com".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_file(path: &std::path::Path) -> File {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
.unwrap()
|
||||
}
|
||||
|
|
@ -5,15 +5,17 @@ use std::path::Path;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::identity::{
|
||||
BundleDigest, ContainerId, GenerationId, HostPath, PidfsProcessIdentityV1,
|
||||
BundleDigest, ContainerId, GenerationId, HostPath, NamespaceIdentity, PidfsProcessIdentityV1,
|
||||
};
|
||||
use lazaret_core::network::{CanonicalDerivedNetworkPolicy, ReachabilityExpectation};
|
||||
use lazaret_core::records::{
|
||||
HostProcessOwnershipSlot, Participant, ParticipantOwnership, ParticipantRole, ParticipantState,
|
||||
PersistedHostProcessOwnership, ProcessOwnershipCommitAck, SandboxOwnership,
|
||||
StagedProcessOwnership,
|
||||
HostProcessOwnershipSlot, NetworkReadinessEvidence, Participant, ParticipantOwnership,
|
||||
ParticipantRole, ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck,
|
||||
SandboxOwnership, StagedProcessOwnership,
|
||||
};
|
||||
use lazaret_linux::ipc::private_stream_pair;
|
||||
use lazaret_linux::ipc::private_seqpacket_pair;
|
||||
use lazaret_linux::process::{RetainedProcess, child_broker, current_process_identity};
|
||||
use lazaret_runtime_supervisor::network::NetworkProbeRequest;
|
||||
use lazaret_runtime_supervisor::supervision::{
|
||||
AttachedRunscSignal, CoordinatorChannel, RuntimeSupervisorLaunch, SupervisorEvent,
|
||||
};
|
||||
|
|
@ -21,12 +23,14 @@ use rustix::pipe::{PipeFlags, pipe_with};
|
|||
use rustix::process::Signal;
|
||||
|
||||
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
use crate::network::{NetworkCoordinator, NetworkCoordinatorError};
|
||||
|
||||
const SUPERVISOR_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SandboxCoordinatorError {
|
||||
Store(DomainStoreError),
|
||||
Network(NetworkCoordinatorError),
|
||||
Io(io::Error),
|
||||
InvalidState(&'static str),
|
||||
}
|
||||
|
|
@ -37,6 +41,7 @@ impl fmt::Display for SandboxCoordinatorError {
|
|||
Self::Store(error) => {
|
||||
write!(formatter, "Sandbox Domain Store operation failed: {error}")
|
||||
}
|
||||
Self::Network(error) => write!(formatter, "Sandbox network operation failed: {error}"),
|
||||
Self::Io(error) => write!(formatter, "Sandbox process operation failed: {error}"),
|
||||
Self::InvalidState(detail) => write!(formatter, "Sandbox state refused: {detail}"),
|
||||
}
|
||||
|
|
@ -47,6 +52,7 @@ impl std::error::Error for SandboxCoordinatorError {
|
|||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Store(error) => Some(error),
|
||||
Self::Network(error) => Some(error),
|
||||
Self::Io(error) => Some(error),
|
||||
Self::InvalidState(_) => None,
|
||||
}
|
||||
|
|
@ -65,6 +71,12 @@ impl From<io::Error> for SandboxCoordinatorError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<NetworkCoordinatorError> for SandboxCoordinatorError {
|
||||
fn from(error: NetworkCoordinatorError) -> Self {
|
||||
Self::Network(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SandboxCoordinator<'store> {
|
||||
domain_store: &'store HostCoordinatorDomainStore,
|
||||
}
|
||||
|
|
@ -351,9 +363,16 @@ pub struct AwaitingAttachedRunsc {
|
|||
control: Option<CoordinatorChannel>,
|
||||
lifetime: Option<std::os::fd::OwnedFd>,
|
||||
staged: Option<StagedProcessOwnership>,
|
||||
helper_staged: Option<StagedProcessOwnership>,
|
||||
network_evidence: Option<NetworkReadinessEvidence>,
|
||||
}
|
||||
|
||||
impl AwaitingAttachedRunsc {
|
||||
#[must_use]
|
||||
pub fn runtime_supervisor_identity(&self) -> Option<PidfsProcessIdentityV1> {
|
||||
self.supervisor.as_ref().map(RetainedProcess::identity)
|
||||
}
|
||||
|
||||
/// Launches the RS through the HC broker and commits its staged ownership before execution.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -361,9 +380,39 @@ impl AwaitingAttachedRunsc {
|
|||
pub fn launch(
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
launch: &RuntimeSupervisorLaunch,
|
||||
) -> Result<Self, SandboxCoordinatorError> {
|
||||
if launch.network_sidecar_id().is_some() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"networked Runtime Supervisor requires helper intent",
|
||||
));
|
||||
}
|
||||
Self::launch_inner(coordinator, None, launch)
|
||||
}
|
||||
|
||||
/// Launches the RS only after independent durable helper intent is recorded.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a non-network launch or any sandbox/helper staging failure.
|
||||
pub fn launch_networked(
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
network: &NetworkCoordinator<'_>,
|
||||
launch: &RuntimeSupervisorLaunch,
|
||||
) -> Result<Self, SandboxCoordinatorError> {
|
||||
if launch.network_sidecar_id().is_none() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor launch has no network policy",
|
||||
));
|
||||
}
|
||||
Self::launch_inner(coordinator, Some(network), launch)
|
||||
}
|
||||
|
||||
fn launch_inner(
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
network: Option<&NetworkCoordinator<'_>>,
|
||||
launch: &RuntimeSupervisorLaunch,
|
||||
) -> Result<Self, SandboxCoordinatorError> {
|
||||
let parent = current_process_identity()?;
|
||||
let (coordinator_control, supervisor_control) = private_stream_pair()?;
|
||||
let (coordinator_control, supervisor_control) = private_seqpacket_pair()?;
|
||||
let (supervisor_lifetime, coordinator_lifetime) =
|
||||
pipe_with(PipeFlags::CLOEXEC).map_err(io::Error::from)?;
|
||||
let process = child_broker().acquire_with_stdio(
|
||||
|
|
@ -374,7 +423,19 @@ impl AwaitingAttachedRunsc {
|
|||
|staged| {
|
||||
coordinator
|
||||
.stage_runtime_supervisor(staged)
|
||||
.map_err(io::Error::other)
|
||||
.map_err(io::Error::other)?;
|
||||
if let Some(network) = network {
|
||||
network
|
||||
.begin_helper_intent(
|
||||
staged.generation_id(),
|
||||
launch.network_sidecar_id().ok_or_else(|| {
|
||||
io::Error::other("network sidecar identity is absent")
|
||||
})?,
|
||||
staged.identity(),
|
||||
)
|
||||
.map_err(io::Error::other)?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|staged| {
|
||||
coordinator
|
||||
|
|
@ -387,9 +448,131 @@ impl AwaitingAttachedRunsc {
|
|||
control: Some(CoordinatorChannel::new(coordinator_control)),
|
||||
lifetime: Some(coordinator_lifetime),
|
||||
staged: None,
|
||||
helper_staged: None,
|
||||
network_evidence: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Receives, persists, and acknowledges the exact helper child acquisition.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses unexpected events, changed ownership, or a missing network launch.
|
||||
pub fn receive_helper_staged(
|
||||
&mut self,
|
||||
network: &NetworkCoordinator<'_>,
|
||||
) -> Result<StagedProcessOwnership, SandboxCoordinatorError> {
|
||||
if self.helper_staged.is_some() || self.network_evidence.is_some() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"helper stage was already received",
|
||||
));
|
||||
}
|
||||
let control = self
|
||||
.control
|
||||
.as_ref()
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor control channel is absent",
|
||||
))?;
|
||||
let staged = match control.receive()? {
|
||||
SupervisorEvent::HelperStaged(staged) => staged,
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
return Err(SandboxCoordinatorError::Io(io::Error::other(detail)));
|
||||
}
|
||||
_ => {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not stage the helper first",
|
||||
));
|
||||
}
|
||||
};
|
||||
let runtime_supervisor = self
|
||||
.supervisor
|
||||
.as_ref()
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor handle is absent",
|
||||
))?
|
||||
.identity();
|
||||
network.stage_helper(staged, runtime_supervisor)?;
|
||||
let acknowledgment = network.acknowledge_helper(staged)?;
|
||||
control.acknowledge_helper(acknowledgment)?;
|
||||
self.helper_staged = Some(staged);
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Commits helper readiness, independently observes the namespace, and validates attestation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses event ordering, namespace mutation, or any attestation mismatch.
|
||||
pub fn receive_network_readiness<BuildProbeRequest>(
|
||||
&mut self,
|
||||
network: &NetworkCoordinator<'_>,
|
||||
policy: &CanonicalDerivedNetworkPolicy,
|
||||
build_probe_request: BuildProbeRequest,
|
||||
expected_reachability: &ReachabilityExpectation,
|
||||
) -> Result<NetworkReadinessEvidence, SandboxCoordinatorError>
|
||||
where
|
||||
BuildProbeRequest: FnOnce(NamespaceIdentity) -> NetworkProbeRequest,
|
||||
{
|
||||
let staged = self
|
||||
.helper_staged
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"helper ownership was not staged",
|
||||
))?;
|
||||
let control = self
|
||||
.control
|
||||
.as_ref()
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor control channel is absent",
|
||||
))?;
|
||||
if control.receive()? != SupervisorEvent::HelperReady(staged) {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor helper readiness mismatched ownership",
|
||||
));
|
||||
}
|
||||
network.commit_helper(staged)?;
|
||||
let attestation = match control.receive()? {
|
||||
SupervisorEvent::NetworkAttestation(attestation) => attestation,
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
return Err(SandboxCoordinatorError::Io(io::Error::other(detail)));
|
||||
}
|
||||
_ => {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not provide network attestation",
|
||||
));
|
||||
}
|
||||
};
|
||||
let namespace = network.begin_network_intent(
|
||||
attestation.generation_id,
|
||||
policy.digest(),
|
||||
attestation.runtime_supervisor,
|
||||
)?;
|
||||
network.validate_attestation(policy, &attestation)?;
|
||||
let probe_request = build_probe_request(namespace);
|
||||
if probe_request.domain_id != attestation.domain_id
|
||||
|| probe_request.generation_id != attestation.generation_id
|
||||
|| probe_request.enforcement_namespace != attestation.enforcement_namespace
|
||||
{
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"network probe request mismatches attestation",
|
||||
));
|
||||
}
|
||||
control.acknowledge_network_attestation(probe_request)?;
|
||||
let probe = match control.receive()? {
|
||||
SupervisorEvent::NetworkProbe(probe) => probe,
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
return Err(SandboxCoordinatorError::Io(io::Error::other(detail)));
|
||||
}
|
||||
_ => {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not return the network probe",
|
||||
));
|
||||
}
|
||||
};
|
||||
let evidence =
|
||||
network.commit_readiness(policy, attestation, probe, expected_reachability)?;
|
||||
control.acknowledge_network_readiness()?;
|
||||
self.network_evidence = Some(evidence.clone());
|
||||
Ok(evidence)
|
||||
}
|
||||
|
||||
/// Receives and durably stages the exact RS child without releasing its barrier.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -403,6 +586,11 @@ impl AwaitingAttachedRunsc {
|
|||
"attached runsc stage was already received",
|
||||
));
|
||||
}
|
||||
if self.helper_staged.is_some() && self.network_evidence.is_none() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"attached runsc cannot start before network attestation",
|
||||
));
|
||||
}
|
||||
let event = self
|
||||
.control
|
||||
.as_ref()
|
||||
|
|
@ -450,6 +638,7 @@ impl AwaitingAttachedRunsc {
|
|||
control: self.control.take(),
|
||||
lifetime: self.lifetime.take(),
|
||||
staged,
|
||||
network_evidence: self.network_evidence.take(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -474,6 +663,7 @@ pub struct AwaitingSandboxReadiness {
|
|||
control: Option<CoordinatorChannel>,
|
||||
lifetime: Option<std::os::fd::OwnedFd>,
|
||||
staged: StagedProcessOwnership,
|
||||
network_evidence: Option<NetworkReadinessEvidence>,
|
||||
}
|
||||
|
||||
impl AwaitingSandboxReadiness {
|
||||
|
|
@ -482,8 +672,39 @@ impl AwaitingSandboxReadiness {
|
|||
/// # Errors
|
||||
/// Refuses a mismatched readiness tuple, durable commit failure, or channel failure.
|
||||
pub fn commit(
|
||||
self,
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
) -> Result<CommittedSandboxRuntime, SandboxCoordinatorError> {
|
||||
if self.network_evidence.is_some() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"networked sandbox requires a successful sandbox-view probe",
|
||||
));
|
||||
}
|
||||
self.commit_after_probe(coordinator, None)
|
||||
}
|
||||
|
||||
/// Commits the Sandbox only after the enforcement-namespace probe is durably accepted.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses missing attestation, failed/mutated probe evidence, or any runtime mismatch.
|
||||
pub fn commit_networked(
|
||||
mut self,
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
) -> Result<(CommittedSandboxRuntime, NetworkReadinessEvidence), SandboxCoordinatorError> {
|
||||
let evidence =
|
||||
self.network_evidence
|
||||
.take()
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"network readiness evidence is absent",
|
||||
))?;
|
||||
let runtime = self.commit_after_probe(coordinator, Some(evidence.clone()))?;
|
||||
Ok((runtime, evidence))
|
||||
}
|
||||
|
||||
fn commit_after_probe(
|
||||
mut self,
|
||||
coordinator: &SandboxCoordinator<'_>,
|
||||
network_evidence: Option<NetworkReadinessEvidence>,
|
||||
) -> Result<CommittedSandboxRuntime, SandboxCoordinatorError> {
|
||||
let control = self
|
||||
.control
|
||||
|
|
@ -520,6 +741,7 @@ impl AwaitingSandboxReadiness {
|
|||
"Runtime Supervisor control channel is absent",
|
||||
))?,
|
||||
lifetime: self.lifetime.take(),
|
||||
network_evidence,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -536,9 +758,28 @@ pub struct CommittedSandboxRuntime {
|
|||
supervisor: Option<RetainedProcess>,
|
||||
control: CoordinatorChannel,
|
||||
lifetime: Option<std::os::fd::OwnedFd>,
|
||||
network_evidence: Option<NetworkReadinessEvidence>,
|
||||
}
|
||||
|
||||
impl CommittedSandboxRuntime {
|
||||
#[must_use]
|
||||
pub const fn network_evidence(&self) -> Option<&NetworkReadinessEvidence> {
|
||||
self.network_evidence.as_ref()
|
||||
}
|
||||
|
||||
/// Withdraws live readiness after the RS reports exact helper-only loss.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses any event other than connectivity-helper loss.
|
||||
pub fn wait_for_helper_exit(&mut self) -> Result<(), SandboxCoordinatorError> {
|
||||
if self.control.receive()? != SupervisorEvent::HelperExited {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not report helper-only loss",
|
||||
));
|
||||
}
|
||||
self.network_evidence = None;
|
||||
Ok(())
|
||||
}
|
||||
#[must_use]
|
||||
pub fn runtime_supervisor_identity(&self) -> Option<PidfsProcessIdentityV1> {
|
||||
self.supervisor.as_ref().map(RetainedProcess::identity)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::os::linux::net::SocketAddrExt;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::os::unix::net::{SocketAddr, UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::thread;
|
||||
|
|
@ -13,11 +15,13 @@ use lazaret_core::configuration::{
|
|||
use lazaret_core::identity::{
|
||||
BundleDigest, ContainerId, CoordinatorId, GenerationId, HostPath, NetworkSidecarId, SandboxId,
|
||||
};
|
||||
use lazaret_core::network::{DnsQueryExpectation, ReachabilityExpectation, derive_ipv4_policy};
|
||||
use lazaret_core::records::{
|
||||
Domain, DurableDecision, Generation, HostProcessOwnershipSlot, ParticipantRole,
|
||||
Domain, DurableDecision, Generation, HostProcessOwnershipSlot, IpAddress, ParticipantRole,
|
||||
ParticipantState, ProcessOwnershipCommitAck,
|
||||
};
|
||||
use lazaret_host_coordinator::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
use lazaret_host_coordinator::network::NetworkCoordinator;
|
||||
use lazaret_host_coordinator::runtime::{
|
||||
AwaitingAttachedRunsc, CommittedSandboxRuntime, SandboxCoordinator,
|
||||
};
|
||||
|
|
@ -26,18 +30,22 @@ use lazaret_linux::platform::{PlatformObservation, check_platform};
|
|||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker, current_process_identity,
|
||||
};
|
||||
use lazaret_runtime_supervisor::network::{DnsProbeTarget, NetworkProbeRequest, TcpProbeTarget};
|
||||
use lazaret_runtime_supervisor::sandbox::{
|
||||
NETWORK_NAMESPACE_PATH, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
production_sandbox_layout,
|
||||
};
|
||||
use lazaret_runtime_supervisor::supervision::{
|
||||
AttachedRunscSignal, RuntimeSupervisorLaunch, dispatch_private_runtime_role,
|
||||
AttachedRunscSignal, RuntimeNetworkLaunch, RuntimeSupervisorLaunch,
|
||||
dispatch_private_runtime_role,
|
||||
};
|
||||
use rustix::process::Signal;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const SKIP: u8 = 77;
|
||||
const HOST_COORDINATOR_DRIVER: &str = "--runtime-composition-host-coordinator";
|
||||
const SANDBOX_NETWORK_PROBE: &str = "--sandbox-network-probe";
|
||||
const NETWORK_MATRIX: &str = "--network-matrix";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
|
|
@ -50,6 +58,25 @@ fn main() -> ExitCode {
|
|||
if arguments.first().and_then(|argument| argument.to_str()) == Some(HOST_COORDINATOR_DRIVER) {
|
||||
return exit(run_host_coordinator_driver(&arguments[1..]));
|
||||
}
|
||||
if arguments.first().and_then(|argument| argument.to_str()) == Some(SANDBOX_NETWORK_PROBE) {
|
||||
return exit(run_sandbox_network_probe(&arguments[1..]));
|
||||
}
|
||||
if arguments.first().and_then(|argument| argument.to_str()) == Some(NETWORK_MATRIX) {
|
||||
return match run_network_matrix() {
|
||||
Ok(()) => {
|
||||
println!("PASS production network composition matrix");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(MatrixError::Skip(reason)) => {
|
||||
println!("SKIP network composition: {reason}");
|
||||
ExitCode::from(SKIP)
|
||||
}
|
||||
Err(MatrixError::Failure(error)) => {
|
||||
eprintln!("network composition failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
match run_matrix() {
|
||||
Ok(()) => {
|
||||
println!("PASS foreground runtime composition and causal-loss matrix");
|
||||
|
|
@ -88,6 +115,26 @@ impl From<std::io::Error> for MatrixError {
|
|||
}
|
||||
|
||||
fn run_matrix() -> Result<(), MatrixError> {
|
||||
require_matrix_prerequisites()?;
|
||||
lost_acknowledgment_refuses_target_work()?;
|
||||
normal_stop_and_stop_resume()?;
|
||||
attached_runsc_loss_stops_guest()?;
|
||||
runtime_supervisor_loss_stops_guest()?;
|
||||
host_coordinator_loss_stops_guest()?;
|
||||
run_network_cases()
|
||||
}
|
||||
|
||||
fn run_network_matrix() -> Result<(), MatrixError> {
|
||||
require_matrix_prerequisites()?;
|
||||
run_network_cases()
|
||||
}
|
||||
|
||||
fn run_network_cases() -> Result<(), MatrixError> {
|
||||
failed_probe_blocks_sandbox_start()?;
|
||||
networked_sandbox_view_isolated()
|
||||
}
|
||||
|
||||
fn require_matrix_prerequisites() -> Result<(), MatrixError> {
|
||||
let release = fs::read_to_string("/proc/sys/kernel/osrelease")?;
|
||||
check_platform(&PlatformObservation {
|
||||
operating_system: std::env::consts::OS,
|
||||
|
|
@ -95,12 +142,7 @@ fn run_matrix() -> Result<(), MatrixError> {
|
|||
kernel_release: release.trim(),
|
||||
})
|
||||
.map_err(|error| MatrixError::Skip(error.to_string()))?;
|
||||
require_runtime_prerequisites()?;
|
||||
lost_acknowledgment_refuses_target_work()?;
|
||||
normal_stop_and_stop_resume()?;
|
||||
attached_runsc_loss_stops_guest()?;
|
||||
runtime_supervisor_loss_stops_guest()?;
|
||||
host_coordinator_loss_stops_guest()
|
||||
require_runtime_prerequisites()
|
||||
}
|
||||
|
||||
fn require_runtime_prerequisites() -> Result<(), MatrixError> {
|
||||
|
|
@ -115,6 +157,8 @@ fn require_runtime_prerequisites() -> Result<(), MatrixError> {
|
|||
PathBuf::from("/bin/sh"),
|
||||
PathBuf::from("/bin/sleep"),
|
||||
PathBuf::from("/lib64/libc.so.6"),
|
||||
PathBuf::from("/lib64/libgcc_s.so.1"),
|
||||
PathBuf::from("/lib64/libm.so.6"),
|
||||
PathBuf::from("/lib64/libtinfo.so.6"),
|
||||
PathBuf::from("/lib64/ld-linux-x86-64.so.2"),
|
||||
] {
|
||||
|
|
@ -409,8 +453,8 @@ impl Fixture {
|
|||
})
|
||||
.map_err(failure)?;
|
||||
let runtime_domain = host_path(configuration.runtime_domain_root().canonical_label());
|
||||
stage_rootfs_and_control(&runtime_domain)?;
|
||||
let container_id = ContainerId::new([6; 32]);
|
||||
stage_rootfs_and_control(&runtime_domain, container_id)?;
|
||||
let root_process = RootProcess::new(
|
||||
"/bin/sh",
|
||||
[
|
||||
|
|
@ -502,7 +546,299 @@ impl Fixture {
|
|||
}
|
||||
}
|
||||
|
||||
fn stage_rootfs_and_control(runtime_domain: &Path) -> Result<(), MatrixError> {
|
||||
struct NetworkFixture {
|
||||
_tree: TempDir,
|
||||
_host_loopback: TcpListener,
|
||||
_host_abstract: UnixListener,
|
||||
configuration: ResolvedDomainConfiguration,
|
||||
store: HostCoordinatorDomainStore,
|
||||
bundle: SandboxBundle,
|
||||
policy: lazaret_core::network::CanonicalDerivedNetworkPolicy,
|
||||
probe_result: PathBuf,
|
||||
}
|
||||
|
||||
impl NetworkFixture {
|
||||
fn new() -> Result<Self, MatrixError> {
|
||||
let tree = tempfile::tempdir()?;
|
||||
let root = tree.path();
|
||||
let configuration_directory = root.join("configuration");
|
||||
let workspace = root.join("workspace");
|
||||
let runtime = root.join("runtime");
|
||||
fs::create_dir(&configuration_directory)?;
|
||||
fs::create_dir(&workspace)?;
|
||||
fs::create_dir(&runtime)?;
|
||||
let configuration_path = configuration_directory.join("lazaret.toml");
|
||||
fs::write(
|
||||
&configuration_path,
|
||||
format!(
|
||||
"schema = \"lazaret-trusted-host-config-v1\"\n\n[storage]\ndata_root = \"{}\"\n\n[profiles.default]\nimage = \"example.invalid/network-fixture:v1\"\npersistent_dirs = []\n",
|
||||
root.join("data").display()
|
||||
),
|
||||
)?;
|
||||
let configuration = load_trusted_configuration(ConfigurationLoadRequest {
|
||||
explicit_configuration_path: Some(configuration_path),
|
||||
selected_profile: "default".into(),
|
||||
workspace: workspace.clone(),
|
||||
home: root.join("home"),
|
||||
xdg_config_home: None,
|
||||
xdg_data_home: None,
|
||||
xdg_runtime_dir: runtime,
|
||||
environment: BTreeMap::new(),
|
||||
sandbox_layout: production_sandbox_layout().map_err(failure)?,
|
||||
})
|
||||
.map_err(failure)?;
|
||||
let runtime_domain = host_path(configuration.runtime_domain_root().canonical_label());
|
||||
let container_id = ContainerId::new([16; 32]);
|
||||
stage_rootfs_and_control(&runtime_domain, container_id)?;
|
||||
let rootfs = runtime_domain.join("state-anchor/merged");
|
||||
fs::copy(
|
||||
std::env::current_exe()?,
|
||||
rootfs.join("bin/lazaret-network-probe"),
|
||||
)?;
|
||||
fs::create_dir_all(rootfs.join("etc"))?;
|
||||
fs::write(rootfs.join("etc/resolv.conf"), b"nameserver 10.0.2.3\n")?;
|
||||
let host_loopback = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let host_loopback_port = host_loopback.local_addr()?.port();
|
||||
let abstract_name = format!("lazaret-network-v3-{}", std::process::id()).into_bytes();
|
||||
let host_abstract =
|
||||
UnixListener::bind_addr(&SocketAddr::from_abstract_name(&abstract_name)?)?;
|
||||
let root_process = RootProcess::new(
|
||||
"/bin/lazaret-network-probe",
|
||||
vec![
|
||||
SANDBOX_NETWORK_PROBE.into(),
|
||||
host_loopback_port.to_string(),
|
||||
String::from_utf8(abstract_name.clone()).map_err(failure)?,
|
||||
],
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let namespace =
|
||||
lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH))?;
|
||||
let bundle = build_sandbox_bundle(SandboxBuildRequest {
|
||||
configuration: &configuration,
|
||||
container_id,
|
||||
expected_network_namespace: namespace,
|
||||
root_process,
|
||||
terminal: None,
|
||||
})
|
||||
.map_err(failure)?;
|
||||
let bundle_path = runtime_domain
|
||||
.join("bundles")
|
||||
.join(hex(container_id.as_bytes()));
|
||||
fs::create_dir_all(&bundle_path)?;
|
||||
fs::write(bundle_path.join("config.json"), bundle.specification_json())?;
|
||||
fs::create_dir_all(runtime_domain.join("runsc"))?;
|
||||
let domain = Domain {
|
||||
domain_id: configuration.domain_id(),
|
||||
profile_name: configuration.selected_profile().to_owned(),
|
||||
canonical_workspace: configuration.workspace().canonical_label().clone(),
|
||||
};
|
||||
let persistent_domain = host_path(configuration.persistent_domain_root().canonical_label());
|
||||
let lock = try_exclusive_flock(lock_file(&persistent_domain.join("domain.lock")))?;
|
||||
let store =
|
||||
HostCoordinatorDomainStore::open(lock, &persistent_domain.join("domain.redb"), &domain)
|
||||
.map_err(failure)?;
|
||||
store
|
||||
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
||||
domain_id: configuration.domain_id(),
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: configuration.configuration_fingerprint(),
|
||||
coordinator_id: CoordinatorId::new([13; 32]),
|
||||
network_sidecar_id: NetworkSidecarId::new([14; 32]),
|
||||
sandbox_id: SandboxId::new([15; 32]),
|
||||
durable_decision: DurableDecision::None,
|
||||
})])
|
||||
.map_err(failure)?;
|
||||
SandboxCoordinator::new(&store)
|
||||
.begin_intent(
|
||||
GenerationId::new(7),
|
||||
container_id,
|
||||
&runtime_domain.join("runsc"),
|
||||
BundleDigest::new([17; 32]),
|
||||
current_process_identity()?,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let policy =
|
||||
derive_ipv4_policy(&configuration, [IpAddress::V4([10, 0, 2, 3])]).map_err(failure)?;
|
||||
Ok(Self {
|
||||
_tree: tree,
|
||||
_host_loopback: host_loopback,
|
||||
_host_abstract: host_abstract,
|
||||
configuration,
|
||||
store,
|
||||
bundle,
|
||||
policy,
|
||||
probe_result: workspace.join("network-probe"),
|
||||
})
|
||||
}
|
||||
|
||||
fn launch(&self) -> Result<CommittedSandboxRuntime, MatrixError> {
|
||||
self.launch_with_permitted([1, 1, 1, 1], 443)
|
||||
}
|
||||
|
||||
fn launch_with_permitted(
|
||||
&self,
|
||||
permitted_address: [u8; 4],
|
||||
permitted_port: u16,
|
||||
) -> Result<CommittedSandboxRuntime, MatrixError> {
|
||||
let launch = RuntimeSupervisorLaunch::from_networked_runsc_command(
|
||||
ProcessOwnershipContext {
|
||||
domain_id: self.configuration.domain_id(),
|
||||
generation_id: GenerationId::new(7),
|
||||
coordinator_id: CoordinatorId::new([13; 32]),
|
||||
slot: HostProcessOwnershipSlot::SandboxRuntimeSupervisor,
|
||||
},
|
||||
self.bundle.command(),
|
||||
RuntimeNetworkLaunch {
|
||||
policy: &self.policy,
|
||||
configuration_fingerprint: self.configuration.configuration_fingerprint(),
|
||||
network_sidecar_id: NetworkSidecarId::new([14; 32]),
|
||||
},
|
||||
)?;
|
||||
let sandbox = SandboxCoordinator::new(&self.store);
|
||||
let network = NetworkCoordinator::new(&self.store);
|
||||
let mut pending = AwaitingAttachedRunsc::launch_networked(&sandbox, &network, &launch)
|
||||
.map_err(failure)?;
|
||||
pending.receive_helper_staged(&network).map_err(failure)?;
|
||||
pending
|
||||
.receive_network_readiness(
|
||||
&network,
|
||||
&self.policy,
|
||||
|namespace| NetworkProbeRequest {
|
||||
domain_id: self.configuration.domain_id(),
|
||||
generation_id: GenerationId::new(7),
|
||||
enforcement_namespace: namespace,
|
||||
denied: vec![TcpProbeTarget {
|
||||
address: IpAddress::V4([10, 1, 2, 3]),
|
||||
port: 443,
|
||||
}],
|
||||
permitted: vec![TcpProbeTarget {
|
||||
address: IpAddress::V4(permitted_address),
|
||||
port: permitted_port,
|
||||
}],
|
||||
dns: vec![DnsProbeTarget {
|
||||
resolver: IpAddress::V4([10, 0, 2, 3]),
|
||||
query_name: "example.com".into(),
|
||||
}],
|
||||
},
|
||||
&ReachabilityExpectation {
|
||||
denied_destinations: vec![IpAddress::V4([10, 1, 2, 3])],
|
||||
permitted_destinations: vec![IpAddress::V4(permitted_address)],
|
||||
dns_queries: vec![DnsQueryExpectation {
|
||||
resolver: IpAddress::V4([10, 0, 2, 3]),
|
||||
query_name: "example.com".into(),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let staged = pending.receive_staged(&sandbox).map_err(failure)?;
|
||||
let (runtime, _) = pending
|
||||
.acknowledge(staged)
|
||||
.map_err(failure)?
|
||||
.commit_networked(&sandbox)
|
||||
.map_err(failure)?;
|
||||
Ok(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_probe_blocks_sandbox_start() -> Result<(), MatrixError> {
|
||||
let fixture = NetworkFixture::new()?;
|
||||
match fixture.launch_with_permitted([203, 0, 113, 1], 9) {
|
||||
Ok(runtime) => {
|
||||
runtime
|
||||
.stop(&SandboxCoordinator::new(&fixture.store))
|
||||
.map_err(failure)?;
|
||||
Err(failure("failed physical probe started the sandbox"))
|
||||
}
|
||||
Err(_) => {
|
||||
let sandbox = sandbox_participant(&fixture.store);
|
||||
if sandbox.state() != ParticipantState::Intent || fixture.probe_result.exists() {
|
||||
Err(failure(
|
||||
"failed physical probe advanced Sandbox state or guest work",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn networked_sandbox_view_isolated() -> Result<(), MatrixError> {
|
||||
let fixture = NetworkFixture::new()?;
|
||||
let runtime = fixture.launch()?;
|
||||
for _ in 0..6000 {
|
||||
if fixture.probe_result.is_file() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let result = fs::read_to_string(&fixture.probe_result)?;
|
||||
for required in [
|
||||
"denied=true",
|
||||
"public=true",
|
||||
"dns=true",
|
||||
"host_loopback=false",
|
||||
"abstract_socket=false",
|
||||
] {
|
||||
if !result.lines().any(|line| line == required) {
|
||||
return Err(failure(format!(
|
||||
"guest network assertion missing: {required}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
runtime
|
||||
.stop(&SandboxCoordinator::new(&fixture.store))
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
fn run_sandbox_network_probe(arguments: &[std::ffi::OsString]) -> std::io::Result<()> {
|
||||
let host_port = arguments
|
||||
.first()
|
||||
.and_then(|value| value.to_str())
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.filter(|port| *port != 0)
|
||||
.ok_or_else(|| std::io::Error::other("sandbox probe host port is invalid"))?;
|
||||
let abstract_name = arguments
|
||||
.get(1)
|
||||
.and_then(|value| value.to_str())
|
||||
.ok_or_else(|| std::io::Error::other("sandbox probe abstract name is invalid"))?;
|
||||
let timeout = Duration::from_secs(3);
|
||||
let denied = TcpStream::connect_timeout(
|
||||
&SocketAddrV4::new(Ipv4Addr::new(10, 1, 2, 3), 443).into(),
|
||||
timeout,
|
||||
)
|
||||
.is_err();
|
||||
let public = TcpStream::connect_timeout(
|
||||
&SocketAddrV4::new(Ipv4Addr::new(1, 1, 1, 1), 443).into(),
|
||||
timeout,
|
||||
)
|
||||
.is_ok();
|
||||
let dns = ("example.com", 443)
|
||||
.to_socket_addrs()
|
||||
.is_ok_and(|mut addresses| addresses.next().is_some());
|
||||
let host_loopback = TcpStream::connect_timeout(
|
||||
&SocketAddrV4::new(Ipv4Addr::new(10, 0, 2, 2), host_port).into(),
|
||||
timeout,
|
||||
)
|
||||
.is_ok();
|
||||
let abstract_socket =
|
||||
UnixStream::connect_addr(&SocketAddr::from_abstract_name(abstract_name.as_bytes())?)
|
||||
.is_ok();
|
||||
fs::write(
|
||||
"/workspace/.network-probe",
|
||||
format!(
|
||||
"denied={denied}\npublic={public}\ndns={dns}\nhost_loopback={host_loopback}\nabstract_socket={abstract_socket}\n"
|
||||
),
|
||||
)?;
|
||||
fs::rename("/workspace/.network-probe", "/workspace/network-probe")?;
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_rootfs_and_control(
|
||||
runtime_domain: &Path,
|
||||
container_id: ContainerId,
|
||||
) -> Result<(), MatrixError> {
|
||||
let rootfs = runtime_domain.join("state-anchor/merged");
|
||||
for directory in ["bin", "lib64"] {
|
||||
fs::create_dir_all(rootfs.join(directory))?;
|
||||
|
|
@ -511,12 +847,16 @@ fn stage_rootfs_and_control(runtime_domain: &Path) -> Result<(), MatrixError> {
|
|||
("/bin/sh", "bin/sh"),
|
||||
("/bin/sleep", "bin/sleep"),
|
||||
("/lib64/libc.so.6", "lib64/libc.so.6"),
|
||||
("/lib64/libgcc_s.so.1", "lib64/libgcc_s.so.1"),
|
||||
("/lib64/libm.so.6", "lib64/libm.so.6"),
|
||||
("/lib64/libtinfo.so.6", "lib64/libtinfo.so.6"),
|
||||
("/lib64/ld-linux-x86-64.so.2", "lib64/ld-linux-x86-64.so.2"),
|
||||
] {
|
||||
fs::copy(source, rootfs.join(destination))?;
|
||||
}
|
||||
let control = runtime_domain.join("sandbox-control").join(hex(&[6; 32]));
|
||||
let control = runtime_domain
|
||||
.join("sandbox-control")
|
||||
.join(hex(container_id.as_bytes()));
|
||||
for directory in ["credentials", "mailbox/requests", "mailbox/results", "bin"] {
|
||||
fs::create_dir_all(control.join(directory))?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ license.workspace = true
|
|||
[dependencies]
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
rustix.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "lazaret-launch-wrapper"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
use std::os::fd::OwnedFd;
|
||||
use std::io;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
|
||||
use rustix::net::{AddressFamily, SocketFlags, SocketType, socketpair};
|
||||
use lazaret_core::codec::{decode, encode};
|
||||
use rustix::net::{
|
||||
AddressFamily, RecvFlags, SendFlags, SocketFlags, SocketType, recv, send, socketpair,
|
||||
};
|
||||
|
||||
const MAX_PRIVATE_PACKET: usize = 64 * 1024;
|
||||
|
||||
/// # Errors
|
||||
/// Returns an error when the private sequenced-packet pair cannot be created.
|
||||
|
|
@ -26,6 +32,35 @@ pub fn private_stream_pair() -> std::io::Result<(OwnedFd, OwnedFd)> {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Returns an error when a typed private packet is oversized or cannot be sent atomically.
|
||||
pub fn send_packet<T: serde::Serialize>(descriptor: impl AsFd, message: &T) -> io::Result<()> {
|
||||
let encoded = encode(message).map_err(|error| io::Error::other(error.to_string()))?;
|
||||
if encoded.is_empty() || encoded.len() > MAX_PRIVATE_PACKET {
|
||||
return Err(io::Error::other("private packet has invalid size"));
|
||||
}
|
||||
let sent = send(descriptor.as_fd(), &encoded, SendFlags::empty())?;
|
||||
if sent == encoded.len() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other("private packet send was incomplete"))
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Returns an error when a private packet is empty, oversized, malformed, or unknown-version.
|
||||
pub fn receive_packet<T>(descriptor: impl AsFd) -> io::Result<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned + serde::Serialize,
|
||||
{
|
||||
let mut encoded = vec![0_u8; MAX_PRIVATE_PACKET + 1];
|
||||
let (_, received) = recv(descriptor.as_fd(), &mut encoded, RecvFlags::empty())?;
|
||||
if received == 0 || received > MAX_PRIVATE_PACKET {
|
||||
return Err(io::Error::other("private packet has invalid size"));
|
||||
}
|
||||
decode(&encoded[..received]).map_err(|error| io::Error::other(error.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::fd::AsFd;
|
||||
|
|
|
|||
|
|
@ -272,6 +272,55 @@ fn require_captured_parent_edge(parent_edge: ParentEdge) -> io::Result<()> {
|
|||
}
|
||||
|
||||
impl ChildBroker {
|
||||
/// Runs a non-durable direct child under the same spawn/reap exclusion as owned children.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when causal launch, exact waiting, or the command exit status fails.
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
pub fn run_transient(&self, target: &TargetCommand) -> io::Result<ProcessExit> {
|
||||
let _guard = self
|
||||
.exclusion
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("child broker lock poisoned"))?;
|
||||
require_parent_edge_standard_io(target.parent_edge(), ChildStandardIoPolicy::Silenced)?;
|
||||
require_default_sigchld()?;
|
||||
require_reapable_sigchld()?;
|
||||
let parent = current_process_identity()?;
|
||||
let (release, wait) = launch_barrier()?;
|
||||
let mut command = private_role_command(WRAPPER_ROLE);
|
||||
append_parent_identity(&mut command, parent);
|
||||
target.append_to(&mut command);
|
||||
command
|
||||
.stdin(wait.into_stdio())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = command.spawn()?;
|
||||
let process = match acquire_spawned_child(child.id()) {
|
||||
Ok(process) => process,
|
||||
Err(error) => return fail_before_pidfd(release, &mut child, error),
|
||||
};
|
||||
if let Err(error) = release.release() {
|
||||
return fail_after_pidfd_closed(process, error);
|
||||
}
|
||||
drop(child);
|
||||
let status =
|
||||
rustix::process::waitid(WaitId::PidFd(process.pidfd.as_fd()), WaitIdOptions::EXITED)?
|
||||
.ok_or_else(|| io::Error::other("transient child exited without wait status"))?;
|
||||
let exit = ProcessExit {
|
||||
exit_code: status.exit_status(),
|
||||
terminating_signal: status.terminating_signal(),
|
||||
};
|
||||
drop(process);
|
||||
if exit.exit_code == Some(0) && exit.terminating_signal.is_none() {
|
||||
Ok(exit)
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"transient child failed with exit {:?} and signal {:?}",
|
||||
exit.exit_code, exit.terminating_signal
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the repository wrapper, stages exact ownership, accepts its acknowledgment,
|
||||
/// and only then releases target execution.
|
||||
///
|
||||
|
|
@ -308,13 +357,46 @@ impl ChildBroker {
|
|||
expected_parent: PidfsProcessIdentityV1,
|
||||
ownership: ProcessOwnershipContext,
|
||||
target: &TargetCommand,
|
||||
mut standard_io: ChildStandardIo,
|
||||
standard_io: ChildStandardIo,
|
||||
stage: Stage,
|
||||
acknowledge: Acknowledge,
|
||||
) -> io::Result<RetainedProcess>
|
||||
where
|
||||
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||
{
|
||||
self.acquire_with_stdio_after_ack(
|
||||
expected_parent,
|
||||
ownership,
|
||||
target,
|
||||
standard_io,
|
||||
stage,
|
||||
acknowledge,
|
||||
|| Ok(()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Acquires an exact child while retaining its blocked wrapper across one acknowledged
|
||||
/// parent transition.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses and reaps the blocked wrapper when staging, acknowledgment, transition, or release
|
||||
/// fails.
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
pub fn acquire_with_stdio_after_ack<Stage, Acknowledge, Transition>(
|
||||
&self,
|
||||
expected_parent: PidfsProcessIdentityV1,
|
||||
ownership: ProcessOwnershipContext,
|
||||
target: &TargetCommand,
|
||||
mut standard_io: ChildStandardIo,
|
||||
stage: Stage,
|
||||
acknowledge: Acknowledge,
|
||||
transition: Transition,
|
||||
) -> io::Result<RetainedProcess>
|
||||
where
|
||||
Stage: FnOnce(StagedProcessOwnership) -> io::Result<()>,
|
||||
Acknowledge: FnOnce(StagedProcessOwnership) -> io::Result<ProcessOwnershipCommitAck>,
|
||||
Transition: FnOnce() -> io::Result<()>,
|
||||
{
|
||||
let _guard = self
|
||||
.exclusion
|
||||
|
|
@ -364,6 +446,9 @@ impl ChildBroker {
|
|||
),
|
||||
);
|
||||
}
|
||||
if let Err(error) = transition() {
|
||||
return fail_after_pidfd(release, process, error);
|
||||
}
|
||||
if let Err(error) = release.release() {
|
||||
return fail_after_pidfd_closed(process, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ fn exit(result: std::io::Result<()>) -> ExitCode {
|
|||
|
||||
fn run_all() -> std::io::Result<()> {
|
||||
successful_acquisition()?;
|
||||
acknowledged_transition_precedes_exact_wrapper_release()?;
|
||||
lifetime_pipe_descriptor_contract()?;
|
||||
failed_stages_and_acknowledgments()?;
|
||||
production_wrapper_refuses_former_harness_path()?;
|
||||
|
|
@ -53,6 +54,47 @@ fn run_all() -> std::io::Result<()> {
|
|||
concurrent_acquisitions_serialize()
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn acknowledged_transition_precedes_exact_wrapper_release() -> std::io::Result<()> {
|
||||
let target_marker = marker("transition-target");
|
||||
let parent = current_process_identity()?;
|
||||
let staged = std::cell::Cell::new(None);
|
||||
let acknowledged = std::cell::Cell::new(false);
|
||||
let target = TargetCommand::new("/usr/bin/touch").argument(&target_marker);
|
||||
let process = child_broker().acquire_with_stdio_after_ack(
|
||||
parent,
|
||||
context(),
|
||||
&target,
|
||||
ChildStandardIo::inherit(),
|
||||
|ownership| {
|
||||
staged.set(Some(ownership));
|
||||
Ok(())
|
||||
},
|
||||
|ownership| {
|
||||
if staged.get() != Some(ownership) {
|
||||
return Err(std::io::Error::other("transition staged ownership changed"));
|
||||
}
|
||||
acknowledged.set(true);
|
||||
Ok(ProcessOwnershipCommitAck::v1(ownership))
|
||||
},
|
||||
|| {
|
||||
if !acknowledged.get() || target_marker.exists() {
|
||||
return Err(std::io::Error::other(
|
||||
"wrapper was released outside the acknowledged transition boundary",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
child_broker().reap(process)?;
|
||||
if !target_marker.is_file() {
|
||||
return Err(std::io::Error::other(
|
||||
"acknowledged transition did not release the exact wrapper",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(target_marker)
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn lifetime_pipe_descriptor_contract() -> std::io::Result<()> {
|
||||
let held_pipe_marker = marker("lifetime-pipe");
|
||||
|
|
|
|||
|
|
@ -20,5 +20,10 @@ serde_json.workspace = true
|
|||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
||||
[[test]]
|
||||
name = "network-runtime"
|
||||
path = "tests/network-runtime.rs"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#![doc = "Runtime construction interfaces owned by the Lazaret Runtime Supervisor."]
|
||||
|
||||
pub mod network;
|
||||
pub mod sandbox;
|
||||
pub mod supervision;
|
||||
|
|
|
|||
756
crates/lazaret-runtime-supervisor/src/network.rs
Normal file
756
crates/lazaret-runtime-supervisor/src/network.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
330
crates/lazaret-runtime-supervisor/tests/network-runtime.rs
Normal file
330
crates/lazaret-runtime-supervisor/tests/network-runtime.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
use std::ffi::OsString;
|
||||
use std::fs::File;
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::codec::{decode, encode};
|
||||
use lazaret_core::identity::{
|
||||
ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, NetworkSidecarId,
|
||||
};
|
||||
use lazaret_core::network::{
|
||||
DnsQueryExpectation, ReachabilityExpectation, ReadinessExpectation, accept_network_readiness,
|
||||
derive_policy, validate_network_attestation,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
|
||||
DerivedNetworkPolicyFields, HostProcessOwnershipSlot, IpAddress, IpNetwork,
|
||||
NetworkReadinessAttestation, PolicyAction, PrefixRule, ProcessOwnershipCommitAck, Resolver,
|
||||
};
|
||||
use lazaret_linux::filesystem::{atomic_replace, require_write_sealed, sealed_memfd};
|
||||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, TargetCommand, child_broker, current_process_identity,
|
||||
dispatch_private_process_role, recover_process,
|
||||
};
|
||||
use lazaret_runtime_supervisor::network::{
|
||||
DnsProbeTarget, NetworkAttestationContext, NetworkProbeRequest, TcpProbeTarget,
|
||||
prepare_network, probe_current_network,
|
||||
};
|
||||
use rustix::io::{FdFlags, fcntl_setfd};
|
||||
use rustix::process::Signal;
|
||||
|
||||
const CHILD_ROLE: &str = "--network-runtime-child";
|
||||
const SKIP: u8 = 77;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if let Some(result) = dispatch_private_process_role(&arguments) {
|
||||
return exit(result);
|
||||
}
|
||||
if arguments.first().and_then(|value| value.to_str()) == Some(CHILD_ROLE) {
|
||||
return exit(run_child(&arguments[1..]));
|
||||
}
|
||||
match run_parent() {
|
||||
Ok(()) => {
|
||||
println!("PASS real rootless network namespace and supervised slirp4netns");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) if prerequisite_failure(&error) => {
|
||||
println!("SKIP real network runtime: {error}");
|
||||
ExitCode::from(SKIP)
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("network runtime failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_parent() -> std::io::Result<()> {
|
||||
for executable in ["/usr/bin/slirp4netns", "/usr/bin/ip"] {
|
||||
if !Path::new(executable).is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("required executable is absent: {executable}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let directory = tempfile::tempdir()?;
|
||||
let policy = policy()?;
|
||||
let policy_file = sealed_memfd("network-runtime-policy", policy.canonical_bytes())?;
|
||||
fcntl_setfd(policy_file.as_fd(), FdFlags::empty())?;
|
||||
let verifier_executable = std::env::current_exe()?;
|
||||
let target = TargetCommand::new(verifier_executable)
|
||||
.argument(CHILD_ROLE)
|
||||
.argument(directory.path())
|
||||
.argument(policy_file.as_raw_fd().to_string())
|
||||
.argument(hex(policy.digest().as_bytes()));
|
||||
let parent = current_process_identity()?;
|
||||
let runtime = child_broker().acquire(
|
||||
parent,
|
||||
ProcessOwnershipContext {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(7),
|
||||
coordinator_id: CoordinatorId::new([2; 32]),
|
||||
slot: HostProcessOwnershipSlot::SandboxRuntimeSupervisor,
|
||||
},
|
||||
&target,
|
||||
|_| Ok(()),
|
||||
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
|
||||
)?;
|
||||
let attestation_path = directory.path().join("attestation.postcard");
|
||||
wait_for_path(&attestation_path, &runtime)?;
|
||||
let attestation: NetworkReadinessAttestation = decode(&std::fs::read(&attestation_path)?)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
if attestation.runtime_supervisor != runtime.identity()
|
||||
|| !attestation.observation.helper_ready
|
||||
|| !attestation.observation.routes_attested
|
||||
|| attestation.observation.observed_routes.len() < 3
|
||||
{
|
||||
return cleanup_runtime(runtime, std::io::Error::other("attestation is incomplete"));
|
||||
}
|
||||
let probe_path = directory.path().join("probe.postcard");
|
||||
wait_for_path(&probe_path, &runtime)?;
|
||||
let probe: lazaret_core::records::NetworkReachabilityProbe =
|
||||
decode(&std::fs::read(&probe_path)?)
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
let expectation = ReadinessExpectation {
|
||||
domain_id: attestation.domain_id,
|
||||
generation_id: attestation.generation_id,
|
||||
configuration_fingerprint: attestation.configuration_fingerprint,
|
||||
network_sidecar_id: attestation.network_sidecar_id,
|
||||
enforcement_namespace: attestation.enforcement_namespace,
|
||||
runtime_supervisor: &attestation.runtime_supervisor,
|
||||
connectivity_helper: &attestation.connectivity_helper,
|
||||
};
|
||||
accept_network_readiness(
|
||||
&policy,
|
||||
&expectation,
|
||||
attestation.clone(),
|
||||
probe,
|
||||
&reachability(),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let mut mutated = attestation.clone();
|
||||
mutated.derived_policy_digest = lazaret_core::identity::DerivedPolicyDigest::new([9; 32]);
|
||||
if validate_network_attestation(&policy, &expectation, &mutated).is_ok() {
|
||||
return cleanup_runtime(
|
||||
runtime,
|
||||
std::io::Error::other("mutated attestation was accepted"),
|
||||
);
|
||||
}
|
||||
let helper = recover_process(attestation.connectivity_helper)?;
|
||||
helper.send_signal(Signal::KILL)?;
|
||||
wait_for_path(&directory.path().join("helper-lost"), &runtime)?;
|
||||
runtime.send_signal(Signal::KILL)?;
|
||||
child_broker().reap(runtime)
|
||||
}
|
||||
|
||||
fn run_child(arguments: &[OsString]) -> std::io::Result<()> {
|
||||
let directory = PathBuf::from(
|
||||
arguments
|
||||
.first()
|
||||
.ok_or_else(|| std::io::Error::other("network child directory is absent"))?,
|
||||
);
|
||||
let policy_fd = arguments
|
||||
.get(1)
|
||||
.and_then(|value| value.to_str())
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
.filter(|value| *value >= 0)
|
||||
.ok_or_else(|| std::io::Error::other("network child policy fd is invalid"))?;
|
||||
let digest = parse_digest(
|
||||
arguments
|
||||
.get(2)
|
||||
.ok_or_else(|| std::io::Error::other("network child digest is absent"))?,
|
||||
)?;
|
||||
let policy_file = File::open(format!("/proc/self/fd/{policy_fd}"))?;
|
||||
require_write_sealed(&policy_file)?;
|
||||
let runtime = current_process_identity()?;
|
||||
let prepared = prepare_network(
|
||||
&policy_file,
|
||||
digest,
|
||||
NetworkAttestationContext {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(7),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new([3; 32]),
|
||||
network_sidecar_id: NetworkSidecarId::new([4; 32]),
|
||||
runtime_supervisor: runtime,
|
||||
coordinator_id: CoordinatorId::new([2; 32]),
|
||||
},
|
||||
|_| Ok(()),
|
||||
|staged| Ok(ProcessOwnershipCommitAck::v1(staged)),
|
||||
)?;
|
||||
let attestation = prepared.attestation().clone();
|
||||
atomic_replace(
|
||||
&directory.join("attestation.postcard"),
|
||||
&encode(&attestation).map_err(std::io::Error::other)?,
|
||||
)?;
|
||||
let probe = probe_current_network(&probe_request(attestation.enforcement_namespace))?;
|
||||
atomic_replace(
|
||||
&directory.join("probe.postcard"),
|
||||
&encode(&probe).map_err(std::io::Error::other)?,
|
||||
)?;
|
||||
loop {
|
||||
if prepared.helper_exited()? {
|
||||
prepared.reap_exited()?;
|
||||
std::fs::write(directory.join("helper-lost"), b"lost")?;
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn policy() -> std::io::Result<lazaret_core::network::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([10, 0, 2, 3]),
|
||||
}],
|
||||
})
|
||||
.map_err(std::io::Error::other)
|
||||
}
|
||||
|
||||
fn probe_request(namespace: lazaret_core::identity::NamespaceIdentity) -> NetworkProbeRequest {
|
||||
NetworkProbeRequest {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(7),
|
||||
enforcement_namespace: namespace,
|
||||
denied: vec![TcpProbeTarget {
|
||||
address: IpAddress::V4([10, 1, 2, 3]),
|
||||
port: 443,
|
||||
}],
|
||||
permitted: vec![TcpProbeTarget {
|
||||
address: IpAddress::V4([1, 1, 1, 1]),
|
||||
port: 443,
|
||||
}],
|
||||
dns: vec![DnsProbeTarget {
|
||||
resolver: IpAddress::V4([10, 0, 2, 3]),
|
||||
query_name: "example.com".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn reachability() -> ReachabilityExpectation {
|
||||
ReachabilityExpectation {
|
||||
denied_destinations: vec![IpAddress::V4([10, 1, 2, 3])],
|
||||
permitted_destinations: vec![IpAddress::V4([1, 1, 1, 1])],
|
||||
dns_queries: vec![DnsQueryExpectation {
|
||||
resolver: IpAddress::V4([10, 0, 2, 3]),
|
||||
query_name: "example.com".into(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_path(
|
||||
path: &Path,
|
||||
process: &lazaret_linux::process::RetainedProcess,
|
||||
) -> std::io::Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while !path.exists() {
|
||||
if process.has_exited()? {
|
||||
return Err(std::io::Error::other(
|
||||
"network runtime exited before evidence",
|
||||
));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(std::io::Error::other("network runtime evidence timed out"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_runtime<T>(
|
||||
runtime: lazaret_linux::process::RetainedProcess,
|
||||
error: std::io::Error,
|
||||
) -> std::io::Result<T> {
|
||||
let _ = runtime.send_signal(Signal::KILL);
|
||||
let _ = child_broker().reap(runtime);
|
||||
Err(error)
|
||||
}
|
||||
|
||||
fn parse_digest(value: &OsString) -> std::io::Result<lazaret_core::identity::DerivedPolicyDigest> {
|
||||
let value = value
|
||||
.to_str()
|
||||
.filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.ok_or_else(|| std::io::Error::other("network child digest is invalid"))?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
|
||||
.map_err(|_| std::io::Error::other("network child digest is invalid"))?;
|
||||
}
|
||||
Ok(lazaret_core::identity::DerivedPolicyDigest::new(bytes))
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").unwrap();
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
fn prerequisite_failure(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied
|
||||
) || matches!(error.raw_os_error(), Some(1 | 13 | 95))
|
||||
}
|
||||
|
||||
fn exit(result: std::io::Result<()>) -> ExitCode {
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("network child failed: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue