Implement supervised rootless network readiness

This commit is contained in:
ms2k 2026-07-15 23:26:37 +09:00
commit ba6a075839

View 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()
}