Replace external reachability with an owned canary

This commit is contained in:
ms2k 2026-07-19 22:39:31 +09:00
commit 31e9ab0f2d
6 changed files with 1409 additions and 1972 deletions

View file

@ -14,7 +14,6 @@ use lazaret_core::configuration::{
use lazaret_core::identity::HostPath;
use lazaret_core::records::{
ConfigurationFingerprintRecordV1, EnvironmentReference, ResolvedAutoApprovalRule,
ResolvedDnsProbeTargetV1, ResolvedNetworkProbeTargetV1, ResolvedNetworkReadinessV1,
ResolvedVolume, VolumeAccess, VolumeKind,
};
use static_assertions::assert_not_impl_any;
@ -113,11 +112,6 @@ volumes = [
{{ host = "{{profile_dir}}/agent-state", container = "/root/.local/state/agent", kind = "directory", access = "read-write", create = true }},
]
[profiles.default.readiness]
denied = [{{ address = "10.2.3.4", port = 443 }}]
permitted = [{{ address = "192.0.2.4", port = 443 }}]
dns = [{{ resolver = "198.51.100.53", query = "example.invalid" }}]
[profiles.default.escalation]
enabled = false
"#,
@ -283,6 +277,16 @@ allow_extra_args = true
}
}
#[test]
fn profiles_cannot_claim_the_session_identity_environment_name() {
let document = b"schema = \"lazaret-trusted-host-config-v1\"\n\
[profiles.default]\n\
image = \"example.invalid/default:v1\"\n\
env = { LAZARET_SESSION_ID = \"env:HOST_SESSION_ID\" }\n";
let error = parse_trusted_configuration(document).unwrap_err();
assert_eq!(error.kind(), ConfigurationErrorKind::InvalidProfile);
}
#[test]
fn selected_profile_loads_into_confined_nonserializable_authority() {
let tree = TestTree::new();
@ -761,20 +765,6 @@ fn digest_vectors_are_framed_and_order_independent() {
}],
effective_allow_prefixes: vec![([8, 8, 8, 8], 32), ([1, 1, 1, 1], 32)],
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
network_readiness: ResolvedNetworkReadinessV1 {
denied: vec![ResolvedNetworkProbeTargetV1 {
address: [10, 0, 0, 1],
port: 9,
}],
permitted: vec![ResolvedNetworkProbeTargetV1 {
address: [1, 1, 1, 1],
port: 443,
}],
dns: vec![ResolvedDnsProbeTargetV1 {
resolver: [8, 8, 8, 8],
query: "example.invalid".into(),
}],
},
escalation_enabled: true,
auto_approval_rules: vec![ResolvedAutoApprovalRule {
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
@ -786,9 +776,9 @@ fn digest_vectors_are_framed_and_order_independent() {
assert_eq!(
first.as_bytes(),
&[
0x22, 0x57, 0x11, 0xe7, 0xc9, 0x11, 0xba, 0x8e, 0x08, 0x11, 0xae, 0x56, 0x5b, 0x7d,
0x89, 0xb0, 0x73, 0x48, 0xaa, 0x73, 0x87, 0x19, 0x5e, 0xcd, 0xf8, 0xfa, 0xd1, 0x48,
0xc1, 0x5b, 0x90, 0x57,
0xd3, 0xba, 0xef, 0x91, 0x21, 0x0f, 0x98, 0x31, 0x6c, 0xea, 0x34, 0xfb, 0x41, 0xbd,
0x6c, 0xa5, 0x07, 0xf0, 0xb8, 0x9b, 0x90, 0x77, 0xaf, 0x0a, 0x0e, 0xd0, 0x6a, 0xd6,
0x46, 0xc7, 0x2d, 0xbf,
]
);
record.persistent_dirs.reverse();

View file

@ -6,21 +6,20 @@ use lazaret_core::identity::{
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, ProcessStartTimeTicks, ReadinessFingerprint,
};
use lazaret_core::network::{
DnsQueryExpectation, NetworkValidationErrorKind, ReachabilityExpectation, ReadinessExpectation,
accept_network_readiness, canonical_readiness_identity_bytes, derive_policy,
readiness_fingerprint, readiness_identity, validate_network_readiness_evidence,
NetworkValidationErrorKind, ReadinessExpectation, accept_network_readiness,
canonical_readiness_identity_bytes, derive_policy, readiness_fingerprint, readiness_identity,
validate_network_readiness_evidence,
};
use lazaret_core::records::{
AddressFamily, AddressFamilyDisposition, AddressFamilyDispositionValue, DefaultAction,
DeniedDestinationObservation, DerivedNetworkPolicyFields, DnsObservation, IpAddress, IpNetwork,
NetworkObservation, NetworkReachabilityProbe, NetworkReadinessAttestation,
NetworkReadinessAttestationFields, PermittedDestinationObservation, PolicyAction,
PolicyDelivery, PrefixRule, Resolver,
DerivedNetworkPolicyFields, IpAddress, IpNetwork, NetworkCanaryObservation, NetworkObservation,
NetworkReadinessAttestation, NetworkReadinessAttestationFields, PolicyAction, PolicyDelivery,
PrefixRule, Resolver,
};
type AttestationMutation = fn(&mut NetworkReadinessAttestation);
type EvidenceMutation = fn(&mut lazaret_core::records::NetworkReadinessEvidence);
type ProbeMutation = fn(&mut NetworkReachabilityProbe);
type CanaryMutation = fn(&mut NetworkCanaryObservation);
fn repeated(value: u8) -> [u8; 32] {
[value; 32]
@ -107,8 +106,7 @@ struct Fixture {
runtime_supervisor: PidfsProcessIdentityV1,
connectivity_helper: PidfsProcessIdentityV1,
attestation: NetworkReadinessAttestation,
probe: NetworkReachabilityProbe,
reachability: ReachabilityExpectation,
canary: NetworkCanaryObservation,
}
impl Fixture {
@ -135,39 +133,34 @@ impl Fixture {
routes_attested: true,
},
});
let probe = NetworkReachabilityProbe {
let canary = NetworkCanaryObservation {
domain_id: DomainId::new(repeated(1)),
generation_id: GenerationId::new(2),
enforcement_namespace: namespace(),
denied_destinations: vec![DeniedDestinationObservation {
destination: IpAddress::V4([10, 1, 2, 3]),
unreachable: true,
}],
permitted_destinations: vec![PermittedDestinationObservation {
destination: IpAddress::V4([10, 20, 1, 2]),
reachable: true,
}],
dns_observations: vec![DnsObservation {
resolver: IpAddress::V4([192, 0, 2, 53]),
query_name: "sandbox.invalid".into(),
resolved_addresses: vec![IpAddress::V4([192, 0, 2, 10])],
}],
};
let reachability = ReachabilityExpectation {
denied_destinations: vec![IpAddress::V4([10, 1, 2, 3])],
permitted_destinations: vec![IpAddress::V4([10, 20, 1, 2])],
dns_queries: vec![DnsQueryExpectation {
resolver: IpAddress::V4([192, 0, 2, 53]),
query_name: "sandbox.invalid".into(),
}],
linux_policy_matches: true,
endpoints_authenticated_before_policy: true,
allowed_endpoint_authenticated: true,
denied_endpoint_unreachable: true,
imported_interface_matches: true,
tap_present: true,
ipv6_absent: true,
network_capabilities_empty: true,
route_mutation_refused: true,
cleanup_shape_matched: true,
responder_closed: true,
peer_namespace_closed: true,
host_interface_deleted: true,
allowed_endpoint_unreachable_after_cleanup: true,
tap_present_after_cleanup: true,
ipv6_unchanged_after_cleanup: true,
network_admin_dropped: true,
};
Self {
policy,
runtime_supervisor,
connectivity_helper,
attestation,
probe,
reachability,
canary,
}
}
@ -188,8 +181,7 @@ impl Fixture {
&self.policy,
&self.expectation(),
self.attestation.clone(),
self.probe.clone(),
&self.reachability,
self.canary.clone(),
)
.unwrap()
}
@ -344,8 +336,7 @@ fn every_attestation_identity_mismatch_is_refused() {
&fixture.policy,
&fixture.expectation(),
attestation,
fixture.probe.clone(),
&fixture.reachability,
fixture.canary.clone(),
)
.unwrap_err();
assert_eq!(
@ -392,13 +383,13 @@ fn every_evidence_identity_mismatch_is_refused() {
value.readiness_fingerprint = ReadinessFingerprint::new(repeated(90));
}),
("probe domain", |value| {
value.reachability_probe.domain_id = DomainId::new(repeated(91));
value.canary_observation.domain_id = DomainId::new(repeated(91));
}),
("probe generation", |value| {
value.reachability_probe.generation_id = GenerationId::new(92);
value.canary_observation.generation_id = GenerationId::new(92);
}),
("probe namespace", |value| {
value.reachability_probe.enforcement_namespace.inode = InodeId::new(93);
value.canary_observation.enforcement_namespace.inode = InodeId::new(93);
}),
];
for (name, mutate) in mutations {
@ -409,7 +400,6 @@ fn every_evidence_identity_mismatch_is_refused() {
&fixture.policy,
&fixture.expectation(),
&evidence,
&fixture.reachability,
)
.is_err(),
"accepted {name}"
@ -418,34 +408,35 @@ fn every_evidence_identity_mismatch_is_refused() {
}
#[test]
fn incomplete_or_failed_reachability_is_refused() {
fn incomplete_or_failed_canary_is_refused() {
let fixture = Fixture::new();
let mutations: [(&str, ProbeMutation); 6] = [
let mutations: [(&str, CanaryMutation); 6] = [
("deny reached", |value| {
value.denied_destinations[0].unreachable = false;
value.denied_endpoint_unreachable = false;
}),
("permit unreachable", |value| {
value.permitted_destinations[0].reachable = false;
value.allowed_endpoint_authenticated = false;
}),
("missing deny", |value| value.denied_destinations.clear()),
("missing permit", |value| {
value.permitted_destinations.clear();
("policy mismatch", |value| {
value.linux_policy_matches = false;
}),
("missing DNS", |value| value.dns_observations.clear()),
("empty DNS result", |value| {
value.dns_observations[0].resolved_addresses.clear();
("cleanup mismatch", |value| {
value.cleanup_shape_matched = false;
}),
("capability retained", |value| {
value.network_admin_dropped = false;
}),
("IPv6 present", |value| value.ipv6_absent = false),
];
for (name, mutate) in mutations {
let mut probe = fixture.probe.clone();
mutate(&mut probe);
let mut canary = fixture.canary.clone();
mutate(&mut canary);
assert!(
accept_network_readiness(
&fixture.policy,
&fixture.expectation(),
fixture.attestation.clone(),
probe,
&fixture.reachability,
canary,
)
.is_err(),
"accepted {name}"
@ -475,8 +466,7 @@ fn incomplete_or_malformed_attestation_is_refused() {
&fixture.policy,
&fixture.expectation(),
attestation,
fixture.probe.clone(),
&fixture.reachability,
fixture.canary.clone(),
)
.is_err(),
"accepted {name}"

View file

@ -387,24 +387,28 @@ fn readiness_identity() -> NetworkReadinessIdentity {
})
}
fn reachability_probe() -> NetworkReachabilityProbe {
NetworkReachabilityProbe {
fn canary_observation() -> NetworkCanaryObservation {
NetworkCanaryObservation {
domain_id: domain_id(),
generation_id: generation_id(),
enforcement_namespace: namespace(),
denied_destinations: vec![DeniedDestinationObservation {
destination: IpAddress::V4([10, 1, 2, 3]),
unreachable: true,
}],
permitted_destinations: vec![PermittedDestinationObservation {
destination: IpAddress::V4([1, 1, 1, 1]),
reachable: true,
}],
dns_observations: vec![DnsObservation {
resolver: IpAddress::V4([9, 9, 9, 9]),
query_name: "example.invalid".into(),
resolved_addresses: vec![IpAddress::V6([1; 16])],
}],
linux_policy_matches: true,
endpoints_authenticated_before_policy: true,
allowed_endpoint_authenticated: true,
denied_endpoint_unreachable: true,
imported_interface_matches: true,
tap_present: true,
ipv6_absent: true,
network_capabilities_empty: true,
route_mutation_refused: true,
cleanup_shape_matched: true,
responder_closed: true,
peer_namespace_closed: true,
host_interface_deleted: true,
allowed_endpoint_unreachable_after_cleanup: true,
tap_present_after_cleanup: true,
ipv6_unchanged_after_cleanup: true,
network_admin_dropped: true,
}
}
@ -417,7 +421,7 @@ fn readiness_evidence() -> NetworkReadinessEvidence {
readiness_fingerprint: derive_readiness_fingerprint(&readiness_identity).unwrap(),
attestation: readiness_attestation(),
readiness_identity,
reachability_probe: reachability_probe(),
canary_observation: canary_observation(),
}
}

View file

@ -175,7 +175,6 @@ fn trusted_host_configuration_postcard_dispatch_rejects_unknown_schema() {
persistent_dirs: vec![],
allow_egress: vec![],
deny_egress: vec![],
readiness: NetworkReadinessConfigurationV1::default(),
env: BTreeMap::new(),
volumes: vec![],
escalation: EscalationConfigurationV1::default(),

View file

@ -531,29 +531,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
verify_shape(network_observation(), &mut seen);
verify_shape(PolicyDelivery::SealedMemfd, &mut seen);
verify_shape(readiness_identity(), &mut seen);
verify_shape(reachability_probe(), &mut seen);
verify_shape(
DeniedDestinationObservation {
destination: IpAddress::V4([10, 0, 0, 1]),
unreachable: true,
},
&mut seen,
);
verify_shape(
PermittedDestinationObservation {
destination: IpAddress::V4([1, 0, 0, 1]),
reachable: true,
},
&mut seen,
);
verify_shape(
DnsObservation {
resolver: IpAddress::V4([9, 9, 9, 9]),
query_name: "example.invalid".into(),
resolved_addresses: vec![IpAddress::V4([203, 0, 113, 1])],
},
&mut seen,
);
verify_shape(canary_observation(), &mut seen);
verify_shape(
DomainStore {
metadata: DomainStoreMetadata::v1(),
@ -606,20 +584,6 @@ image = "example.invalid/agent:v1"
}],
effective_allow_prefixes: vec![([1, 1, 1, 1], 32)],
effective_deny_prefixes: vec![([10, 0, 0, 0], 8)],
network_readiness: ResolvedNetworkReadinessV1 {
denied: vec![ResolvedNetworkProbeTargetV1 {
address: [10, 0, 0, 1],
port: 9,
}],
permitted: vec![ResolvedNetworkProbeTargetV1 {
address: [1, 1, 1, 1],
port: 443,
}],
dns: vec![ResolvedDnsProbeTargetV1 {
resolver: [8, 8, 8, 8],
query: "example.invalid".into(),
}],
},
escalation_enabled: true,
auto_approval_rules: vec![ResolvedAutoApprovalRule {
canonical_executable: HostPath::new(b"/usr/bin/true".to_vec()),
@ -654,23 +618,6 @@ image = "example.invalid/agent:v1"
},
&mut seen,
);
verify_shape(
ResolvedNetworkReadinessV1 {
denied: vec![ResolvedNetworkProbeTargetV1 {
address: [10, 0, 0, 1],
port: 9,
}],
permitted: vec![ResolvedNetworkProbeTargetV1 {
address: [1, 1, 1, 1],
port: 443,
}],
dns: vec![ResolvedDnsProbeTargetV1 {
resolver: [8, 8, 8, 8],
query: "example.invalid".into(),
}],
},
&mut seen,
);
verify_shape(
BootstrapWaitRequest {
protocol_version: ProtocolVersion::V1,
@ -693,8 +640,8 @@ image = "example.invalid/agent:v1"
assert_eq!(
seen.digest(),
[
255, 220, 6, 56, 49, 12, 147, 182, 148, 61, 218, 228, 124, 211, 136, 128, 186, 186,
138, 239, 246, 197, 75, 0, 75, 85, 27, 244, 21, 21, 246, 41,
245, 169, 157, 164, 67, 211, 165, 23, 129, 62, 119, 92, 247, 248, 131, 4, 252, 42, 34,
214, 207, 60, 126, 219, 81, 68, 159, 6, 26, 88, 233, 108,
]
);
}

View file

@ -370,14 +370,14 @@ fn active_network_rejects_every_mismatched_readiness_relation() {
}),
),
(
"reachability domain",
"canary domain",
generation_id(),
mutate_network_ownership(|ownership| {
ownership
.readiness_evidence
.as_mut()
.unwrap()
.reachability_probe
.canary_observation
.domain_id = other_domain;
}),
),
@ -406,14 +406,14 @@ fn active_network_rejects_every_mismatched_readiness_relation() {
}),
),
(
"reachability generation",
"canary generation",
generation_id(),
mutate_network_ownership(|ownership| {
ownership
.readiness_evidence
.as_mut()
.unwrap()
.reachability_probe
.canary_observation
.generation_id = other_generation;
}),
),
@ -502,14 +502,14 @@ fn active_network_rejects_every_mismatched_readiness_relation() {
}),
),
(
"reachability namespace",
"canary namespace",
generation_id(),
mutate_network_ownership(|ownership| {
ownership
.readiness_evidence
.as_mut()
.unwrap()
.reachability_probe
.canary_observation
.enforcement_namespace = other_namespace;
}),
),