@push.rocks/smartnftables 3.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -484,7 +484,7 @@ pub fn destination(grant: &Grant, protection: &Protection) -> Result<String> {
484
484
  /// with the leased outbound source ports translated to that same transit address.
485
485
  fn normalize_router_published(value: &mut RouterScope) -> Result<()> {
486
486
  let mut ports = std::mem::take(&mut value.published_ports);
487
- require(ports.len() <= PUBLISHED_PORTS)?;
487
+ crate::capacity("publishedPorts", PUBLISHED_PORTS, ports.len())?;
488
488
  ports.sort();
489
489
  for (index, port) in ports.iter().enumerate() {
490
490
  require(
@@ -599,11 +599,12 @@ fn exclusive_symmetric<'a>(
599
599
  /// the enclosing scope.
600
600
  fn normalize_host_grants(
601
601
  grants: &mut [HostGrant],
602
+ name: &'static str,
602
603
  bound: impl Fn(&HostGrant) -> Result<()>,
603
604
  ) -> Result<()> {
604
605
  // The contract maximum per projection; set lookups keep the rule graph
605
606
  // constant, and the element budget holds this many in every scope.
606
- require(grants.len() <= 1024)?;
607
+ crate::capacity(name, HOST_GRANTS, grants.len())?;
607
608
  grants.sort();
608
609
  for (index, grant) in grants.iter().enumerate() {
609
610
  require(
@@ -648,7 +649,7 @@ fn normalize_router_host_grants(value: &mut RouterScope) -> Result<()> {
648
649
  .any(|source| covers(source, &target) == Ok(true))
649
650
  })
650
651
  };
651
- normalize_host_grants(&mut grants, |grant| {
652
+ normalize_host_grants(&mut grants, "hostGrants", |grant| {
652
653
  let endpoint = workload(&grant.destination_address).ok_or(Error::Invalid)?;
653
654
  require(
654
655
  endpoint.interface_kind == "veth"
@@ -695,7 +696,7 @@ fn normalize_router_workload_grants(value: &mut RouterScope) -> Result<()> {
695
696
  require(endpoint.interface_kind == "veth" && !local && !platform)?;
696
697
  Ok(endpoint)
697
698
  };
698
- normalize_host_grants(&mut grants, |grant| {
699
+ normalize_host_grants(&mut grants, "workloadGrants", |grant| {
699
700
  let source = workload(&grant.source_address)?;
700
701
  let destination = workload(&grant.destination_address)?;
701
702
  require(source.id != destination.id)
@@ -711,8 +712,11 @@ pub const ROUTER_RULES: usize = 1024;
711
712
  pub const ROUTER_GRANTS: usize = 1024;
712
713
  /// Publications per scope; they are set elements on both hops.
713
714
  pub const PUBLISHED_PORTS: usize = 1024;
715
+ /// Host grants per scope and workload grants per router, each a set element.
716
+ pub const HOST_GRANTS: usize = 1024;
714
717
  fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
715
- require(value.links.len() <= ROUTER_LINKS && value.generations.len() <= 32)?;
718
+ crate::capacity("links", ROUTER_LINKS, value.links.len())?;
719
+ require(value.generations.len() <= 32)?;
716
720
  let private = policy::Policy {
717
721
  schema_version: 1,
718
722
  revision,
@@ -779,7 +783,11 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
779
783
  let mut zones = BTreeSet::new();
780
784
  let mut labels = BTreeSet::new();
781
785
  let mut classifiers: Vec<(&Grant, String)> = Vec::new();
782
- let mut count = 0;
786
+ crate::capacity(
787
+ "grants",
788
+ ROUTER_GRANTS,
789
+ value.generations.iter().map(|item| item.grants.len()).sum(),
790
+ )?;
783
791
  value
784
792
  .generations
785
793
  .sort_by(|a, b| (&a.lease.id, a.lease.generation).cmp(&(&b.lease.id, b.lease.generation)));
@@ -805,8 +813,6 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
805
813
  && generation.conntrack_label.bytes().any(|item| item != b'0')
806
814
  && labels.insert(generation.conntrack_label.clone()),
807
815
  )?;
808
- count += generation.grants.len();
809
- require(count <= ROUTER_GRANTS)?;
810
816
  generation.grants.sort();
811
817
  for grant in &generation.grants {
812
818
  require(
@@ -903,7 +909,7 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
903
909
  }
904
910
  // An exception is only ever an exact flow into a guarded pool.
905
911
  let mut grants = std::mem::take(&mut value.host_grants);
906
- normalize_host_grants(&mut grants, |grant| {
912
+ normalize_host_grants(&mut grants, "hostGrants", |grant| {
907
913
  require(covers_address(&value.prefixes, &grant.destination_address)?)
908
914
  })?;
909
915
  value.host_grants = grants;
@@ -913,7 +919,7 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
913
919
  /// wildcard or range, each with exactly one owning uid. `(uid_t)-1` is not a
914
920
  /// user; it is the kernel's "unchanged" sentinel.
915
921
  fn normalize_local_tcp_port_owners(owners: &mut [LocalTcpPortOwner]) -> Result<()> {
916
- require(owners.len() <= LOCAL_TCP_PORT_OWNERS)?;
922
+ crate::capacity("localTcpPortOwners", LOCAL_TCP_PORT_OWNERS, owners.len())?;
917
923
  owners.sort();
918
924
  for (index, owner) in owners.iter().enumerate() {
919
925
  let ip = address(&owner.address)?;
@@ -939,7 +945,7 @@ const LOCAL_TCP_PORT_OWNERS: usize = 8;
939
945
  fn normalize_host_host_grants(value: &mut HostScope) -> Result<()> {
940
946
  let mut grants = std::mem::take(&mut value.host_grants);
941
947
  let scope = &*value;
942
- normalize_host_grants(&mut grants, |grant| {
948
+ normalize_host_grants(&mut grants, "hostGrants", |grant| {
943
949
  let destination = &grant.destination_address;
944
950
  require(
945
951
  scope
@@ -984,7 +990,7 @@ fn normalize_host_host_grants(value: &mut HostScope) -> Result<()> {
984
990
  /// overlap with the leased outbound source ports translated to that same address.
985
991
  fn normalize_published(value: &mut HostScope) -> Result<()> {
986
992
  let mut ports = std::mem::take(&mut value.published_ports);
987
- require(ports.len() <= PUBLISHED_PORTS)?;
993
+ crate::capacity("publishedPorts", PUBLISHED_PORTS, ports.len())?;
988
994
  ports.sort();
989
995
  for (index, port) in ports.iter().enumerate() {
990
996
  require(
@@ -1096,13 +1102,14 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
1096
1102
  .ok_or(Error::Invalid)?;
1097
1103
  require(local_addresses.contains(&endpoint.address))?;
1098
1104
  }
1105
+ // Handoff addresses lie in protected space, checked per handoff below. An
1106
+ // uplink address need not: the uplink lease and SNAT address is the host's
1107
+ // public side, usually outside the protected authority, and the compiler
1108
+ // adds each uncovered one to the host barrier as a protected destination.
1099
1109
  for ip in &local_addresses {
1100
- require(
1101
- covers_address(&value.protection.prefixes, ip)?
1102
- && value.protection.platform_endpoints.iter().all(|endpoint| {
1103
- endpoint.address != *ip || value.local_platform_endpoints.contains(&endpoint.id)
1104
- }),
1105
- )?;
1110
+ require(value.protection.platform_endpoints.iter().all(|endpoint| {
1111
+ endpoint.address != *ip || value.local_platform_endpoints.contains(&endpoint.id)
1112
+ }))?;
1106
1113
  }
1107
1114
  for handoff in &mut value.handoffs {
1108
1115
  normalize_link(&mut handoff.link)?;
@@ -1180,6 +1187,9 @@ mod routerequivalence_tests;
1180
1187
  #[path = "egress_tests.rs"]
1181
1188
  pub(crate) mod tests;
1182
1189
  #[cfg(test)]
1190
+ #[path = "egress_uplink_tests.rs"]
1191
+ mod uplink_tests;
1192
+ #[cfg(test)]
1183
1193
  #[path = "egress_workloadgrant_tests.rs"]
1184
1194
  pub(crate) mod workloadgrant_tests;
1185
1195
  impl Policy {
@@ -1051,8 +1051,15 @@ fn host_grants_meet_the_atomic_reserve_at_the_contract_maximum() {
1051
1051
  program.len() < 768 && graph <= super::compile::RULE_BYTES && graph + elements <= super::compile::TARGET_BYTES
1052
1052
  );
1053
1053
  assert!(deletion + graph + elements + 64 <= crate::wire::BATCH_BYTES);
1054
- // The contract maximum is the validation bound.
1055
- assert!(normalized(granted(fixture, many(1025))).is_err());
1054
+ // The contract maximum is a capacity bound.
1055
+ assert_eq!(
1056
+ normalized(granted(fixture, many(1025))).unwrap_err(),
1057
+ crate::Error::Exhausted(crate::Bound {
1058
+ name: "hostGrants",
1059
+ limit: 1024,
1060
+ actual: 1025
1061
+ })
1062
+ );
1056
1063
  println!(
1057
1064
  "HOST_GRANT_MAX scope={name} grants=1024 operations={} rule_bytes={graph} element_bytes={elements} batch_bound={}",
1058
1065
  program.len(),
@@ -262,5 +262,12 @@ fn local_tcp_port_owners_meet_the_atomic_reserve_at_the_contract_maximum() {
262
262
  .as_array_mut()
263
263
  .unwrap()
264
264
  .push(owner("127.0.0.1", 10008, 0));
265
- assert!(normalized(value).is_err());
265
+ assert_eq!(
266
+ normalized(value).unwrap_err(),
267
+ crate::Error::Exhausted(crate::Bound {
268
+ name: "localTcpPortOwners",
269
+ limit: 8,
270
+ actual: 9
271
+ })
272
+ );
266
273
  }
@@ -992,3 +992,160 @@ fn router_v2_published_endpoint_traffic_is_classified_before_the_leased_grants()
992
992
  );
993
993
  }
994
994
  }
995
+
996
+ fn exhausted(value: super::Result<Prepared>) -> crate::Bound {
997
+ match value {
998
+ Err(crate::Error::Exhausted(bound)) => bound,
999
+ other => panic!("expected a capacity refusal, got {other:?}"),
1000
+ }
1001
+ }
1002
+ fn bound(name: &'static str, limit: usize, actual: usize) -> crate::Bound {
1003
+ crate::Bound {
1004
+ name,
1005
+ limit,
1006
+ actual,
1007
+ }
1008
+ }
1009
+ fn prepare(value: Value) -> super::Result<Prepared> {
1010
+ normalized(value)?.prepare()
1011
+ }
1012
+
1013
+ /// The compiled budget and the input counts that exist to hold it refuse with
1014
+ /// EXHAUSTED and their bound; bounds of the topology's shape stay INVALID.
1015
+ #[test]
1016
+ fn v2_capacity_refusals_name_their_bound_and_shape_bounds_stay_invalid() {
1017
+ // Router rules grow with protected prefixes: 128 exceed the rule reserve.
1018
+ let mut value = router();
1019
+ let mut prefixes = vec![
1020
+ json!("10.0.0.0/8"),
1021
+ json!("192.0.2.0/24"),
1022
+ json!("203.0.113.10/32"),
1023
+ ];
1024
+ prefixes.extend((0..125).map(|index| json!(format!("172.16.{index}.0/24"))));
1025
+ value["scope"]["protection"]["prefixes"] = json!(prefixes);
1026
+ let refused = exhausted(prepare(value.clone()));
1027
+ assert_eq!((refused.name, refused.limit), ("ruleBytes", 100_000));
1028
+ assert!(refused.actual > refused.limit);
1029
+ // 129 protected prefixes are outside the schema, not over capacity.
1030
+ value["scope"]["protection"]["prefixes"]
1031
+ .as_array_mut()
1032
+ .unwrap()
1033
+ .push(json!("172.17.0.0/24"));
1034
+ assert_eq!(prepare(value).unwrap_err(), crate::Error::Invalid);
1035
+
1036
+ // Host transit rules grow with its handoffs and their leased ranges.
1037
+ let handoff = |index: u32, ranges: u32| {
1038
+ let mut item = allocation();
1039
+ item["lease"]["id"] = json!(format!("lease-{index}"));
1040
+ item["transitSourceAddress"] = json!(format!("10.240.{index}.2"));
1041
+ item["sourcePortRanges"] = json!((0..ranges)
1042
+ .map(
1043
+ |range| json!({"protocol":if range % 2 == 0 { "tcp" } else { "udp" },
1044
+ "first":10000 + range * 100,"last":10099 + range * 100})
1045
+ )
1046
+ .collect::<Vec<_>>());
1047
+ json!({"link":link(10 + index, &format!("h{index}"), "veth", &format!("10.240.{index}.1")),
1048
+ "allocations":[item]})
1049
+ };
1050
+ let handoffs = |count: u32, ranges: u32| {
1051
+ json!((0..count)
1052
+ .map(|index| handoff(index, ranges))
1053
+ .collect::<Vec<_>>())
1054
+ };
1055
+ let mut value = host();
1056
+ value["scope"]["handoffs"] = handoffs(4, 2);
1057
+ assert_eq!(prepare(value.clone()).map(|_| ()), Ok(()));
1058
+ value["scope"]["handoffs"] = handoffs(32, 16);
1059
+ let refused = exhausted(prepare(value.clone()));
1060
+ assert_eq!((refused.name, refused.limit), ("ruleBytes", 100_000));
1061
+ assert!(refused.actual > refused.limit);
1062
+ // 33 handoffs are outside the schema, not over capacity.
1063
+ value["scope"]["handoffs"] = handoffs(33, 2);
1064
+ assert_eq!(prepare(value).unwrap_err(), crate::Error::Invalid);
1065
+
1066
+ // Router input counts.
1067
+ let mut value = router();
1068
+ let template = value["scope"]["links"][0].clone();
1069
+ value["scope"]["links"] = json!((0..129u32)
1070
+ .map(|index| {
1071
+ let mut item = template.clone();
1072
+ item["interfaceIndex"] = json!(10 + index);
1073
+ item["interfaceName"] = json!(format!("l{index}"));
1074
+ item
1075
+ })
1076
+ .collect::<Vec<_>>());
1077
+ assert_eq!(exhausted(prepare(value)), bound("links", 128, 129));
1078
+ let mut value = router();
1079
+ let template = value["scope"]["endpoints"][0].clone();
1080
+ value["scope"]["endpoints"] = json!((0..129u32)
1081
+ .map(|index| {
1082
+ let mut item = template.clone();
1083
+ item["id"] = json!(format!("e{index}"));
1084
+ item
1085
+ })
1086
+ .collect::<Vec<_>>());
1087
+ assert_eq!(exhausted(prepare(value)), bound("endpoints", 128, 129));
1088
+ let mut value = router();
1089
+ let template = value["scope"]["rules"][0].clone();
1090
+ value["scope"]["rules"] = json!((0..1025u16)
1091
+ .map(|index| {
1092
+ let mut item = template.clone();
1093
+ item["destinationPort"] = json!(1 + index);
1094
+ item
1095
+ })
1096
+ .collect::<Vec<_>>());
1097
+ assert_eq!(exhausted(prepare(value)), bound("rules", 1024, 1025));
1098
+ // Egress grants count across every generation.
1099
+ let mut value = router();
1100
+ let generation = value["scope"]["generations"][0].clone();
1101
+ let template = generation["grants"][0].clone();
1102
+ value["scope"]["generations"] = json!((0..2u16)
1103
+ .map(|part| {
1104
+ let mut item = generation.clone();
1105
+ item["grants"] = json!((0..(512 + part))
1106
+ .map(|index| {
1107
+ let mut grant = template.clone();
1108
+ grant["destinationPort"] = json!(1 + index);
1109
+ grant
1110
+ })
1111
+ .collect::<Vec<_>>());
1112
+ item
1113
+ })
1114
+ .collect::<Vec<_>>());
1115
+ assert_eq!(exhausted(prepare(value)), bound("grants", 1024, 1025));
1116
+ // Active generations are lease lifecycle, not capacity.
1117
+ let mut value = router();
1118
+ let generation = value["scope"]["generations"][0].clone();
1119
+ value["scope"]["generations"] = json!(vec![generation; 33]);
1120
+ assert_eq!(prepare(value).unwrap_err(), crate::Error::Invalid);
1121
+
1122
+ // Publications on both hops.
1123
+ let ports = json!((0..1025u16)
1124
+ .map(|index| published_port("tcp", 20000 + index, 20000 + index))
1125
+ .collect::<Vec<_>>());
1126
+ assert_eq!(
1127
+ exhausted(prepare(published_host(ports.clone()))),
1128
+ bound("publishedPorts", 1024, 1025)
1129
+ );
1130
+ let ports = json!((0..1025u16)
1131
+ .map(|index| router_published_port("tcp", 20000 + index, 20000 + index))
1132
+ .collect::<Vec<_>>());
1133
+ assert_eq!(
1134
+ exhausted(prepare(published_router(ports))),
1135
+ bound("publishedPorts", 1024, 1025)
1136
+ );
1137
+
1138
+ // Within the input bound, the grant elements exceed the target budget.
1139
+ let mut value = router();
1140
+ let template = value["scope"]["generations"][0]["grants"][0].clone();
1141
+ value["scope"]["generations"][0]["grants"] = json!((0..1024u16)
1142
+ .map(|index| {
1143
+ let mut grant = template.clone();
1144
+ grant["destinationPort"] = json!(1000 + index);
1145
+ grant
1146
+ })
1147
+ .collect::<Vec<_>>());
1148
+ let refused = exhausted(prepare(value));
1149
+ assert_eq!((refused.name, refused.limit), ("targetBytes", 212_000));
1150
+ assert!(refused.actual > refused.limit);
1151
+ }
@@ -0,0 +1,159 @@
1
+ //! Host transit on an uplink whose own addresses lie outside the protected
2
+ //! authority: the cluster-wide authority covers pools, resolvers, platform
3
+ //! endpoints and the VPN, not a node's uplink lease, which is usually public.
4
+ //! The scope prepares, and the host barrier protects the uplink addresses itself.
5
+ use super::hostgrant_tests::{
6
+ chains, decide, normalized, opening, prepared, Flow, ACK, ESTABLISHED,
7
+ };
8
+ use super::tests::host;
9
+ use serde_json::{json, Value};
10
+
11
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
12
+ const UPLINK: [u8; 4] = [198, 51, 100, 7];
13
+ const PUBLIC: [u8; 4] = [198, 51, 100, 200];
14
+ const HANDOFF_LINK: Option<(u32, &str)> = Some((4, "handoff"));
15
+ const UPLINK_LINK: Option<(u32, &str)> = Some((2, "ens18"));
16
+
17
+ /// The `preparePolicy` request Pallet 35.1.0 sent pallet-guard on a lab node,
18
+ /// captured on the native IPC (lab-only private addresses). Its uplink lease and
19
+ /// SNAT address 10.92.26.30 lies outside the protected prefix 10.240.0.0/12; the
20
+ /// two other uplink addresses hold host-local platform endpoints inside it.
21
+ fn captured() -> Value {
22
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"hostTransit",
23
+ "protection":{
24
+ "authorityDigest":"sha256:f436a15c8c8f4d4f0f23bc9fc69f45a8c9137a9586db1d2c2be8c09dac3467c9",
25
+ "prefixes":["10.240.0.0/12"],
26
+ "platformEndpoints":[
27
+ {"id":"5aaa706266a3c4e9b0cc99eb354496a732f88cdf3d36fd5911ab6fbb98ef7489","address":"10.244.0.30","port":8443,"protocol":"udp"},
28
+ {"id":"0298aa0b89b4248e058b7a279abc9d9c1793d2d012752432e95881367b43d6ac","address":"10.245.0.30","port":27017,"protocol":"tcp"},
29
+ {"id":"f0808a59e876a51c1a8a5b8cf38e0fc323dd5309dec3a8890468a1afc69dc9ff","address":"10.245.0.30","port":9000,"protocol":"tcp"},
30
+ {"id":"f4744ddb8e7d63883a7fbd4635f3ee4a031650effaaf5c13a1c664182b2b0501","address":"10.245.0.30","port":8443,"protocol":"tcp"},
31
+ {"id":"8933bdd0997dc2b230b0be7960c1708a57d72a0fa9481ec4aa56b9311ae68751","address":"10.243.0.1","port":53,"protocol":"tcp"},
32
+ {"id":"6e88da7a803f69d77d8d8fd86c5952ae87c26c3172b2f9a751662793e0ee8a7e","address":"10.243.0.1","port":53,"protocol":"udp"}]},
33
+ "handoffs":[{
34
+ "link":{"interfaceIndex":11,"interfaceName":"phc05f15afde9d","interfaceKind":"veth",
35
+ "macAddress":"4a:d5:5b:95:91:fa","interfaceLinkIndex":2,"requiredIpv4Addresses":["10.240.0.0"]},
36
+ "allocations":[{
37
+ "lease":{"id":"049eda6820b222e7e371bb4d1e24a325ac92422cff1900443c1e74a9efbb5d5e","generation":1,
38
+ "digest":"sha256:9a5635b213263796ac3b883cce942a0ca45161d9412ec6c6c9a5d748c22a3a7c"},
39
+ "transitSourceAddress":"10.240.0.1",
40
+ "sourcePortRanges":[{"protocol":"tcp","first":1024,"last":65535},{"protocol":"udp","first":1024,"last":65535}]}]}],
41
+ "uplink":{"interfaceIndex":2,"interfaceName":"enp1s0","interfaceKind":"ethernet",
42
+ "macAddress":"52:54:00:92:26:30","interfaceLinkIndex":2,
43
+ "requiredIpv4Addresses":["10.244.0.30","10.245.0.30","10.92.26.30"]},
44
+ "snatAddress":"10.92.26.30",
45
+ "localPlatformEndpoints":[
46
+ "0298aa0b89b4248e058b7a279abc9d9c1793d2d012752432e95881367b43d6ac",
47
+ "5aaa706266a3c4e9b0cc99eb354496a732f88cdf3d36fd5911ab6fbb98ef7489",
48
+ "f0808a59e876a51c1a8a5b8cf38e0fc323dd5309dec3a8890468a1afc69dc9ff",
49
+ "f4744ddb8e7d63883a7fbd4635f3ee4a031650effaaf5c13a1c664182b2b0501"]}})
50
+ }
51
+ /// The host fixture on a public uplink lease outside every protected prefix.
52
+ fn public() -> Value {
53
+ let mut value = host();
54
+ value["scope"]["uplink"]["requiredIpv4Addresses"] = json!(["198.51.100.7"]);
55
+ value["scope"]["snatAddress"] = json!("198.51.100.7");
56
+ value
57
+ }
58
+ /// A leased opening from the handoff out through the uplink, to `destination`
59
+ /// in the live tuple and `original` in the originally tracked one.
60
+ fn egress(destination: [u8; 4], original: [u8; 4]) -> Flow {
61
+ Flow {
62
+ source: TRANSIT,
63
+ destination,
64
+ source_port: 10500,
65
+ destination_port: 443,
66
+ original: (TRANSIT, original, 10500, 443),
67
+ ..opening(HANDOFF_LINK, UPLINK_LINK)
68
+ }
69
+ }
70
+
71
+ #[test]
72
+ fn host_transit_prepares_the_captured_lab_request_with_an_unprotected_uplink_lease() {
73
+ // Refused INVALID by 4.0.0: the uplink lease was required to be protected.
74
+ let lab = prepared(captured());
75
+ // The lease joins the barrier as a /32, exactly as if the authority had
76
+ // named it; the covered uplink addresses meet their prefix already.
77
+ let mut value = captured();
78
+ value["scope"]["protection"]["prefixes"] = json!(["10.240.0.0/12", "10.92.26.30/32"]);
79
+ assert_eq!(chains(&lab), chains(&prepared(value)));
80
+ }
81
+
82
+ #[test]
83
+ fn host_transit_uplink_addresses_need_no_protected_prefix_but_handoff_addresses_do() {
84
+ assert!(normalized(public()).is_ok());
85
+ // A handoff address outside the authority still refuses; the control
86
+ // covering it prepares.
87
+ let handoff = |prefix: &str| {
88
+ let mut value = public();
89
+ value["scope"]["protection"]["prefixes"] =
90
+ json!([prefix, "192.0.2.0/24", "203.0.113.10/32"]);
91
+ normalized(value)
92
+ };
93
+ assert!(handoff("10.240.0.2/32").is_err());
94
+ assert!(handoff("10.240.0.0/30").is_ok());
95
+ // A platform endpoint on the uplink lease lies in protected space, and it
96
+ // still refuses unless declared host-local.
97
+ let mut value = public();
98
+ value["scope"]["protection"]["prefixes"] =
99
+ json!(["10.0.0.0/8", "192.0.2.0/24", "198.51.100.7/32"]);
100
+ value["scope"]["protection"]["platformEndpoints"] = json!([
101
+ {"id":"hub","address":"198.51.100.7","protocol":"tcp","port":8443}]);
102
+ assert!(normalized(value.clone()).is_err());
103
+ value["scope"]["localPlatformEndpoints"] = json!(["hub"]);
104
+ assert!(normalized(value).is_ok());
105
+ }
106
+
107
+ #[test]
108
+ fn host_transit_barrier_protects_an_unprotected_uplink_lease_as_a_destination() {
109
+ let with = prepared(public());
110
+ // Positive control: leased egress to public space passes, including a flow
111
+ // another owner translated between two public destinations.
112
+ for flow in [egress(PUBLIC, PUBLIC), egress(PUBLIC, [198, 51, 100, 201])] {
113
+ assert_eq!(decide(&with, "forward", &flow).verdict, 1);
114
+ }
115
+ // A flow whose live or originally tracked destination is the host's own
116
+ // uplink lease is a translation into the host: the barrier refuses it.
117
+ for flow in [egress(PUBLIC, UPLINK), egress(UPLINK, PUBLIC)] {
118
+ let decision = decide(&with, "forward", &flow);
119
+ assert_eq!(
120
+ (decision.verdict, decision.chain.as_str()),
121
+ (0, "protected_out")
122
+ );
123
+ }
124
+ // A packet forwarded toward the handoff from the uplink lease is a spoof.
125
+ let reply = Flow {
126
+ input: UPLINK_LINK,
127
+ output: HANDOFF_LINK,
128
+ source: UPLINK,
129
+ destination: TRANSIT,
130
+ source_port: 443,
131
+ destination_port: 10500,
132
+ tcp_flags: ACK,
133
+ state: ESTABLISHED,
134
+ reply: true,
135
+ ..egress(PUBLIC, PUBLIC)
136
+ };
137
+ let decision = decide(&with, "forward", &reply);
138
+ assert_eq!(
139
+ (decision.verdict, decision.chain.as_str()),
140
+ (0, "protected_back")
141
+ );
142
+ let reply = Flow {
143
+ source: PUBLIC,
144
+ ..reply
145
+ };
146
+ assert_eq!(decide(&with, "forward", &reply).verdict, 1);
147
+ }
148
+
149
+ #[test]
150
+ fn host_transit_covered_uplink_compiles_exactly_the_authority_barrier() {
151
+ // The fixture's uplink lies inside the authority: no address joins the
152
+ // barrier, and its compiled bytes stay frozen by the golden host test.
153
+ let covered = chains(&prepared(host()));
154
+ let public = chains(&prepared(public()));
155
+ for name in ["protected_out", "protected_back"] {
156
+ // One /32 adds a live and an originally tracked destination check.
157
+ assert_eq!(public[name].len(), covered[name].len() + 2, "{name}");
158
+ }
159
+ }
@@ -241,12 +241,6 @@ fn workload_grants_reject_unbound_same_link_local_platform_and_tun_ends() {
241
241
  json!([{"protocol":"tcp","sourceAddress":"10.241.0.2","destinationAddress":"10.241.1.2",
242
242
  "destinationPort":80,"bidirectional":true}]),
243
243
  ),
244
- granted(
245
- two_workloads(),
246
- json!((0..1025_u16)
247
- .map(|port| workload_grant("tcp", "10.241.0.2", "10.241.1.2", 1 + port))
248
- .collect::<Vec<_>>()),
249
- ),
250
244
  ] {
251
245
  assert!(
252
246
  normalized(invalid.clone()).is_err(),
@@ -254,6 +248,21 @@ fn workload_grants_reject_unbound_same_link_local_platform_and_tun_ends() {
254
248
  invalid["scope"]["workloadGrants"]
255
249
  );
256
250
  }
251
+ // The contract maximum is a capacity bound.
252
+ assert_eq!(
253
+ normalized(granted(
254
+ two_workloads(),
255
+ json!((0..1025_u16)
256
+ .map(|port| workload_grant("tcp", "10.241.0.2", "10.241.1.2", 1 + port))
257
+ .collect::<Vec<_>>()),
258
+ ))
259
+ .unwrap_err(),
260
+ crate::Error::Exhausted(crate::Bound {
261
+ name: "workloadGrants",
262
+ limit: 1024,
263
+ actual: 1025
264
+ })
265
+ );
257
266
  // Both directions between two workloads are two separate grants.
258
267
  assert!(normalized(granted(
259
268
  two_workloads(),
package/rust/src/main.rs CHANGED
@@ -15,9 +15,22 @@ use std::io::{BufRead, Read, Write};
15
15
  /// same lines. A transition carries two complete policies.
16
16
  pub(crate) const IPC_BYTES: usize = 1_048_576;
17
17
 
18
+ /// A capacity bound of the atomic replacement budget that a policy exceeded: the
19
+ /// stable bound name, its limit and the size the policy reached. Input counts and
20
+ /// the schema-v1 budget are exact; the other compiled budgets stop at the first
21
+ /// message or rule past the limit, so the complete policy needs at least `actual`.
22
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
23
+ pub struct Bound {
24
+ pub name: &'static str,
25
+ pub limit: usize,
26
+ pub actual: usize,
27
+ }
28
+
18
29
  #[derive(Clone, Copy, Debug, PartialEq, Eq)]
19
30
  pub enum Error {
20
31
  Invalid,
32
+ /// The policy does not fit a capacity bound; a smaller policy may fit.
33
+ Exhausted(Bound),
21
34
  Unsupported,
22
35
  UnsupportedBackend,
23
36
  Permission,
@@ -28,10 +41,23 @@ pub enum Error {
28
41
  Unavailable,
29
42
  }
30
43
  pub type Result<T> = std::result::Result<T, Error>;
44
+ /// Refuses `actual` above the capacity bound `name`.
45
+ pub fn capacity(name: &'static str, limit: usize, actual: usize) -> Result<()> {
46
+ if actual > limit {
47
+ Err(Error::Exhausted(Bound {
48
+ name,
49
+ limit,
50
+ actual,
51
+ }))
52
+ } else {
53
+ Ok(())
54
+ }
55
+ }
31
56
  impl Error {
32
57
  pub fn code(self) -> &'static str {
33
58
  match self {
34
59
  Self::Invalid => "INVALID",
60
+ Self::Exhausted(_) => "EXHAUSTED",
35
61
  Self::Unsupported => "UNSUPPORTED_KERNEL",
36
62
  Self::UnsupportedBackend => "UNSUPPORTED_BACKEND",
37
63
  Self::Permission => "PERMISSION",
@@ -42,6 +68,26 @@ impl Error {
42
68
  Self::Unavailable => "UNAVAILABLE",
43
69
  }
44
70
  }
71
+ /// The bounded, non-secret refusal text of the response.
72
+ pub fn message(self) -> String {
73
+ match self {
74
+ Self::Invalid => "Managed policy input is invalid.".into(),
75
+ Self::Exhausted(bound) => format!(
76
+ "Managed policy exceeds the {} capacity of {}: it reached {}.",
77
+ bound.name, bound.limit, bound.actual
78
+ ),
79
+ _ => "Managed policy operation failed.".into(),
80
+ }
81
+ }
82
+ /// The structured refusal details of the response, if any.
83
+ pub fn data(self) -> Option<serde_json::Value> {
84
+ match self {
85
+ Self::Exhausted(bound) => Some(
86
+ serde_json::json!({"bound":bound.name,"limit":bound.limit,"actual":bound.actual}),
87
+ ),
88
+ _ => None,
89
+ }
90
+ }
45
91
  pub fn kernel(code: i32) -> Self {
46
92
  match -code {
47
93
  libc::ENOENT => Self::Absent,
@@ -114,6 +160,16 @@ fn dispatch(owner: &mut Option<owner::Owner>, request: Request) -> Result<serde_
114
160
  }
115
161
  }
116
162
 
163
+ /// The failure response line: the refusal text, the stable code and, for a
164
+ /// capacity refusal, its bound.
165
+ fn failure(id: &str, error: Error) -> serde_json::Value {
166
+ let mut value = serde_json::json!({"id":id,"success":false,"error":error.message(),"errorCode":error.code()});
167
+ if let Some(data) = error.data() {
168
+ value["errorData"] = data;
169
+ }
170
+ value
171
+ }
172
+
117
173
  fn main() {
118
174
  let arguments = std::env::args().skip(1).collect::<Vec<_>>();
119
175
  let (docker_mode, descriptor) = match arguments.as_slice() {
@@ -163,9 +219,7 @@ fn main() {
163
219
  let id = request.id.clone();
164
220
  let response = match if docker_mode { docker::dispatch(&mut docker, request) } else { dispatch(&mut owner, request) } {
165
221
  Ok(result) => serde_json::json!({"id":id,"success":true,"result":result}),
166
- Err(error) => {
167
- serde_json::json!({"id":id,"success":false,"error":"Managed policy operation failed.","errorCode":error.code()})
168
- }
222
+ Err(error) => failure(&id, error),
169
223
  };
170
224
  if writeln!(output, "{response}")
171
225
  .and_then(|_| output.flush())
@@ -101,29 +101,26 @@ impl Policy {
101
101
  let program = prepared.program(&format!("snft_{}", "x".repeat(59)))?;
102
102
  // Reserve enough of the batch for deleting a previous maximum
103
103
  // graph, then installing this complete graph in the same transaction.
104
- if program.len() > 768
105
- || program
104
+ crate::capacity("operations", 768, program.len())?;
105
+ crate::capacity(
106
+ "ruleBytes",
107
+ 100_000,
108
+ program
106
109
  .iter()
107
110
  .map(|(_, attrs)| wire::encode_attrs(attrs).len() + 20)
108
- .sum::<usize>()
109
- > 100_000
110
- {
111
- return Err(Error::Invalid);
112
- }
111
+ .sum(),
112
+ )?;
113
113
  Ok(prepared)
114
114
  }
115
115
  /// Validates and orders the endpoints and rules. Schema v1 compiles one rule
116
116
  /// per endpoint and grant, so it admits 32 endpoints and 128 rules; the
117
117
  /// set-backed router scope of schema v2 admits more.
118
118
  pub(crate) fn normalize(mut self, endpoints: usize, rules: usize) -> Result<Self> {
119
- if self.schema_version != 1
120
- || self.revision == 0
121
- || self.revision > 9_007_199_254_740_991
122
- || self.endpoints.len() > endpoints
123
- || self.rules.len() > rules
124
- {
119
+ if self.schema_version != 1 || self.revision == 0 || self.revision > 9_007_199_254_740_991 {
125
120
  return Err(Error::Invalid);
126
121
  }
122
+ crate::capacity("endpoints", endpoints, self.endpoints.len())?;
123
+ crate::capacity("rules", rules, self.rules.len())?;
127
124
  let mut ids = BTreeSet::new();
128
125
  let mut indices = BTreeSet::new();
129
126
  let mut names = BTreeSet::new();