@push.rocks/smartnftables 2.4.0 → 2.5.0

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.
Files changed (33) hide show
  1. package/changelog.md +12 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.js +3 -3
  8. package/dist_ts/classes.managednftables.d.ts +5 -0
  9. package/dist_ts/classes.managednftables.js +13 -6
  10. package/dist_ts/managed.egress.types.d.ts +31 -1
  11. package/package.json +7 -7
  12. package/readme.md +91 -3
  13. package/rust/src/egress.compile.rs +49 -1
  14. package/rust/src/egress.host.rs +22 -0
  15. package/rust/src/egress.hostgrant.rs +187 -0
  16. package/rust/src/egress.poolguard.rs +7 -0
  17. package/rust/src/egress.router.rs +45 -4
  18. package/rust/src/egress.rs +150 -1
  19. package/rust/src/egress_hostgrant_tests.rs +948 -0
  20. package/rust/src/main.rs +6 -2
  21. package/rust/src/owner.rs +116 -4
  22. package/rust/src/owner_egress_tests.rs +1 -1
  23. package/rust/src/owner_hostgrant_traffic_tests.rs +280 -0
  24. package/rust/src/owner_identity_tests.rs +80 -1
  25. package/rust/src/owner_poolguard_tests.rs +69 -0
  26. package/rust/src/owner_router_traffic_tests.rs +1 -1
  27. package/rust/src/owner_tests.rs +3 -0
  28. package/rust/src/policy.rs +1 -1
  29. package/rust/src/wire.rs +15 -1
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/classes.manageddockerforwarding.ts +2 -2
  32. package/ts/classes.managednftables.ts +13 -5
  33. package/ts/managed.egress.types.ts +32 -1
@@ -0,0 +1,187 @@
1
+ //! Host grants compile to a constant number of rules around exact-tuple set
2
+ //! lookups, so the rule graph does not grow with the number of grants. The key
3
+ //! carries every operand the rules would otherwise compare one by one: the
4
+ //! variable interface (index and name), the packet and conntrack protocols, the
5
+ //! live tuple and the originally tracked tuple. An element is one grant with its
6
+ //! live tuple equal to its original tuple, so translated flows never match.
7
+ use super::*;
8
+
9
+ /// Every exact host-origin flow of the scope, keyed on its full tuple.
10
+ pub(super) const TUPLE_SET: &str = "host_grant";
11
+ /// Router raw ingress runs before conntrack: exact arriving tuples only.
12
+ pub(super) const ARRIVAL_SET: &str = "host_grant_arrival";
13
+
14
+ /// Register numbers as Linux dumps them: 16-byte aligned registers use the
15
+ /// legacy NFT_REG_1..4 names, every other 4-byte register NFT_REG32_xx.
16
+ fn register(index: u32) -> u32 {
17
+ if index % 4 == 0 {
18
+ index / 4
19
+ } else {
20
+ index - 4 + 8
21
+ }
22
+ }
23
+ fn store(name: &str, mut data: Vec<Attr>, index: u32) -> Attr {
24
+ data.insert(0, Attr::u32(1, register(index)));
25
+ expr(name, data)
26
+ }
27
+ fn meta_to(key: u32, index: u32) -> Attr {
28
+ store("meta", vec![Attr::u32(2, key)], index)
29
+ }
30
+ fn payload_to(base: u32, offset: u32, length: u32, index: u32) -> Attr {
31
+ store(
32
+ "payload",
33
+ vec![
34
+ Attr::u32(2, base),
35
+ Attr::u32(3, offset),
36
+ Attr::u32(4, length),
37
+ ],
38
+ index,
39
+ )
40
+ }
41
+ fn ct_to(key: u32, original: bool, index: u32) -> Attr {
42
+ let mut data = vec![Attr::u32(2, key)];
43
+ if original {
44
+ data.push(Attr::bytes(3, vec![0]));
45
+ }
46
+ store("ct", data, index)
47
+ }
48
+ fn lookup(set: &str) -> Attr {
49
+ expr(
50
+ "lookup",
51
+ vec![
52
+ Attr::string(1, set),
53
+ Attr::u32(2, register(4)),
54
+ Attr::u32(5, 0),
55
+ ],
56
+ )
57
+ }
58
+ fn word(bytes: &[u8]) -> Vec<u8> {
59
+ let mut result = bytes.to_vec();
60
+ result.resize(4, 0);
61
+ result
62
+ }
63
+
64
+ /// Key loads for one direction, then the lookup. `linked` adds the variable
65
+ /// interface: the outgoing interface of the original direction, the incoming
66
+ /// interface of the reply.
67
+ fn tuple_lookup(linked: bool, reply: bool) -> Vec<Attr> {
68
+ let mut result = Vec::new();
69
+ let mut index = 4;
70
+ if linked {
71
+ result.push(meta_to(if reply { 4 } else { 5 }, index));
72
+ result.push(meta_to(if reply { 6 } else { 7 }, index + 1));
73
+ index += 5;
74
+ }
75
+ result.push(meta_to(16, index));
76
+ result.push(ct_to(10, false, index + 1));
77
+ // The grant's source and destination are the packet's own addresses in the
78
+ // original direction and swapped on the reply; the port is the workload's.
79
+ result.push(payload_to(1, if reply { 16 } else { 12 }, 4, index + 2));
80
+ result.push(payload_to(1, if reply { 12 } else { 16 }, 4, index + 3));
81
+ result.push(payload_to(2, if reply { 0 } else { 2 }, 2, index + 4));
82
+ result.push(ct_to(19, true, index + 5));
83
+ result.push(ct_to(20, true, index + 6));
84
+ result.push(ct_to(12, true, index + 7));
85
+ result.push(lookup(TUPLE_SET));
86
+ result
87
+ }
88
+ fn tuple_key(grant: &HostGrant, link: Option<&LocalLink>) -> Result<Vec<u8>> {
89
+ let protocol = word(&[protocol(&grant.protocol)]);
90
+ let source = address(&grant.source_address)?.to_be_bytes();
91
+ let destination = address(&grant.destination_address)?.to_be_bytes();
92
+ let port = word(&grant.destination_port.to_be_bytes());
93
+ let mut key = Vec::new();
94
+ if let Some(link) = link {
95
+ key.extend(link.interface_index.to_ne_bytes());
96
+ let mut name = link.interface_name.as_bytes().to_vec();
97
+ name.resize(16, 0);
98
+ key.extend(name);
99
+ }
100
+ for field in [
101
+ &protocol[..],
102
+ &protocol,
103
+ &source,
104
+ &destination,
105
+ &port,
106
+ &source,
107
+ &destination,
108
+ &port,
109
+ ] {
110
+ key.extend(field);
111
+ }
112
+ Ok(key)
113
+ }
114
+
115
+ /// The scope's exact tuple set. `link` returns the variable interface of a
116
+ /// grant, or None for a link-independent scope.
117
+ pub(super) fn tuple_set<'s>(
118
+ program: &mut Program<'_>,
119
+ grants: &'s [HostGrant],
120
+ link: impl Fn(&HostGrant) -> Result<Option<&'s LocalLink>>,
121
+ ) -> Result<()> {
122
+ let mut keys = Vec::new();
123
+ for grant in grants {
124
+ keys.push(tuple_key(grant, link(grant)?)?);
125
+ }
126
+ let length = keys.first().map(Vec::len).ok_or(crate::Error::Invalid)?;
127
+ program.set(TUPLE_SET, 1, length)?;
128
+ program.elements(TUPLE_SET, keys)
129
+ }
130
+ pub(super) fn arrival_set(program: &mut Program<'_>, grants: &[HostGrant]) -> Result<()> {
131
+ let mut keys = Vec::new();
132
+ for grant in grants {
133
+ let mut key = word(&[protocol(&grant.protocol)]);
134
+ key.extend(address(&grant.source_address)?.to_be_bytes());
135
+ key.extend(address(&grant.destination_address)?.to_be_bytes());
136
+ key.extend(word(&grant.destination_port.to_be_bytes()));
137
+ keys.push(key);
138
+ }
139
+ program.set(ARRIVAL_SET, 2, 16)?;
140
+ program.elements(ARRIVAL_SET, keys)
141
+ }
142
+ /// Exact arriving tuple, before conntrack: protocol, addresses and port.
143
+ pub(super) fn arrival_lookup() -> Vec<Attr> {
144
+ vec![
145
+ meta_to(16, 4),
146
+ payload_to(1, 12, 4, 5),
147
+ payload_to(1, 16, 4, 6),
148
+ payload_to(2, 2, 2, 7),
149
+ lookup(ARRIVAL_SET),
150
+ ]
151
+ }
152
+
153
+ /// Four admissions whatever the number of grants: the ESTABLISHED original
154
+ /// direction, a NEW UDP opening, a NEW TCP opening restricted to SYN with
155
+ /// FIN/RST/ACK clear, and the ESTABLISHED reply. Each checks the default
156
+ /// conntrack zone and the direction before one exact-tuple lookup. `fixed`
157
+ /// adds the scope's fixed interface for each direction.
158
+ pub(super) fn admissions(
159
+ program: &mut Program<'_>,
160
+ chains: [&str; 2],
161
+ linked: bool,
162
+ fixed: impl Fn(bool) -> Vec<Attr>,
163
+ ) -> Result<()> {
164
+ let envelope = |reply: bool, connection_state: u32| {
165
+ let mut result = ipv4();
166
+ result.extend(fixed(reply));
167
+ result.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
168
+ result.extend(ct(1, None, vec![u8::from(reply)]));
169
+ result.extend(state(connection_state));
170
+ result
171
+ };
172
+ let mut established = envelope(false, 2);
173
+ established.extend(tuple_lookup(linked, false));
174
+ program.end(chains[0], established, 1)?;
175
+ for transport in ["udp", "tcp"] {
176
+ let mut opening = envelope(false, 8);
177
+ opening.extend(meta(16, vec![protocol(transport)]));
178
+ if transport == "tcp" {
179
+ opening.extend(opening_tcp());
180
+ }
181
+ opening.extend(tuple_lookup(linked, false));
182
+ program.end(chains[0], opening, 1)?;
183
+ }
184
+ let mut reply = envelope(true, 2);
185
+ reply.extend(tuple_lookup(linked, true));
186
+ program.end(chains[1], reply, 1)
187
+ }
@@ -7,6 +7,13 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScop
7
7
  for name in ["pool_guard", "pool_reply"] {
8
8
  program.chain(name, None)?;
9
9
  }
10
+ // The only exceptions: an exact host-origin flow into a pool and its reply,
11
+ // ahead of the guard. Everything else, including a translated flow that merely
12
+ // arrives at a granted tuple, still meets the unconditional guard below.
13
+ if !scope.host_grants.is_empty() {
14
+ hostgrant::tuple_set(program, &scope.host_grants, |_| Ok(None))?;
15
+ hostgrant::admissions(program, ["output", "input"], false, |_| vec![])?;
16
+ }
10
17
  for name in ["input", "forward", "output"] {
11
18
  program.jump(name, ipv4(), "pool_guard")?;
12
19
  }
@@ -47,11 +47,11 @@ fn raw_return(scope: &RouterScope, grant: &Grant, generation: &Generation) -> Re
47
47
  ));
48
48
  Ok(result)
49
49
  }
50
- /// The exact veth link of the workload endpoint that holds a published address.
50
+ /// The exact veth link of the workload endpoint that holds an endpoint address.
51
51
  /// Validation admits only a current endpoint address, and endpoint prefixes are
52
- /// pairwise disjoint, so one publication never spans two endpoint links.
53
- fn published_link<'a>(scope: &'a RouterScope, port: &RouterPublishedPort) -> Result<&'a LocalLink> {
54
- let target = format!("{}/32", port.endpoint_address);
52
+ /// pairwise disjoint, so one publication or host grant never spans two links.
53
+ fn endpoint_link<'a>(scope: &'a RouterScope, address: &str) -> Result<&'a LocalLink> {
54
+ let target = format!("{address}/32");
55
55
  let endpoint = scope
56
56
  .endpoints
57
57
  .iter()
@@ -68,6 +68,9 @@ fn published_link<'a>(scope: &'a RouterScope, port: &RouterPublishedPort) -> Res
68
68
  .find(|link| link.interface_index == endpoint.interface_index)
69
69
  .ok_or(crate::Error::Invalid)
70
70
  }
71
+ fn published_link<'a>(scope: &'a RouterScope, port: &RouterPublishedPort) -> Result<&'a LocalLink> {
72
+ endpoint_link(scope, &port.endpoint_address)
73
+ }
71
74
  /// Inbound publication envelope. Both directions check the exact links, the
72
75
  /// translated current tuple and the original conntrack destination, so foreign
73
76
  /// NAT cannot borrow this admission for another flow.
@@ -130,6 +133,39 @@ fn published_endpoint(program: &mut Program<'_>, scope: &RouterScope) -> Result<
130
133
  }
131
134
  Ok(())
132
135
  }
136
+ /// Handoff ingress classification for an exact host grant. Like a publication it
137
+ /// is the explicit authority for its tuple, so it leads the protected-source
138
+ /// denial that otherwise drops every packet from the host's transit address, and
139
+ /// it never sets a generation zone: a host grant is an ordinary default-zone flow.
140
+ /// The workload's replies need no classifier of their own: their destination is
141
+ /// protected, so they return to the default zone ahead of every leased classifier.
142
+ fn host_grant_return(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
143
+ if !scope.host_grants.is_empty() {
144
+ let mut expressions = ipv4();
145
+ expressions.extend(link(&scope.handoff, true));
146
+ expressions.extend(hostgrant::arrival_lookup());
147
+ program.end("raw_return", expressions, 1)?;
148
+ }
149
+ Ok(())
150
+ }
151
+ /// The workload link of each grant is part of its key; the handoff is fixed.
152
+ fn host_grant_admissions(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
153
+ if !scope.host_grants.is_empty() {
154
+ hostgrant::admissions(program, ["forward", "forward"], true, |reply| {
155
+ link(&scope.handoff, !reply)
156
+ })?;
157
+ }
158
+ Ok(())
159
+ }
160
+ fn host_grant_sets(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
161
+ if !scope.host_grants.is_empty() {
162
+ hostgrant::tuple_set(program, &scope.host_grants, |grant| {
163
+ endpoint_link(scope, &grant.destination_address).map(Some)
164
+ })?;
165
+ hostgrant::arrival_set(program, &scope.host_grants)?;
166
+ }
167
+ Ok(())
168
+ }
133
169
  fn published_translation(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
134
170
  for port in &scope.published_ports {
135
171
  let mut expressions = ipv4();
@@ -196,6 +232,7 @@ fn raw(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
196
232
  // platform return envelope is. The host publishes into this address, so its
197
233
  // clients are not confined to unprotected space.
198
234
  published_return(program, scope)?;
235
+ host_grant_return(program, scope)?;
199
236
  // Both sides of a publication lead the leased classifiers of every generation,
200
237
  // so neither direction depends on the client's choice of source port.
201
238
  published_endpoint(program, scope)?;
@@ -327,6 +364,7 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
327
364
  for name in ["raw_return", "protected_out", "protected_back"] {
328
365
  program.chain(name, None)?;
329
366
  }
367
+ host_grant_sets(program, scope)?;
330
368
  let private = policy::Policy {
331
369
  schema_version: 1,
332
370
  revision,
@@ -354,8 +392,11 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
354
392
  // precedes the capture barrier: the barrier denies every protected original
355
393
  // destination, which includes this transit address, and an exact inbound
356
394
  // publication is the explicit authority for that exception.
395
+ // Host grants take the same position for the same reason: the barrier
396
+ // denies the host's protected transit address in both directions.
357
397
  if chain == "forward" {
358
398
  published_admissions(program, scope)?;
399
+ host_grant_admissions(program, scope)?;
359
400
  }
360
401
  for expressions in parts.grants {
361
402
  program.rule(chain, expressions)?;
@@ -103,6 +103,18 @@ pub struct RouterPublishedPort {
103
103
  pub endpoint_address: String,
104
104
  pub transit_source_address: String,
105
105
  }
106
+ /// One exact host-origin flow: the node's own host network namespace dials one
107
+ /// port of one local workload at its lease address, from the transit host
108
+ /// address of the current handoff. The same grant is compiled in every scope the
109
+ /// flow crosses; each scope binds it to the authority that scope proves.
110
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
111
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
112
+ pub struct HostGrant {
113
+ pub protocol: String,
114
+ pub source_address: String,
115
+ pub destination_address: String,
116
+ pub destination_port: u16,
117
+ }
106
118
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
107
119
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
108
120
  pub struct RouterScope {
@@ -116,6 +128,9 @@ pub struct RouterScope {
116
128
  /// keeps its exact previous digest and compiled graph.
117
129
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
118
130
  pub published_ports: Vec<RouterPublishedPort>,
131
+ /// Absent and empty are the same canonical policy, like publications.
132
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
133
+ pub host_grants: Vec<HostGrant>,
119
134
  }
120
135
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
121
136
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -143,12 +158,19 @@ pub struct HostScope {
143
158
  /// keeps its exact previous digest and compiled graph.
144
159
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
145
160
  pub published_ports: Vec<PublishedPort>,
161
+ /// Absent and empty are the same canonical policy, like publications.
162
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
163
+ pub host_grants: Vec<HostGrant>,
146
164
  }
147
165
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
148
166
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
149
167
  pub struct AllocationPoolGuardScope {
150
168
  pub authority_digest: String,
151
169
  pub prefixes: Vec<String>,
170
+ /// The only exceptions to the guard: exact host-origin flows into a pool.
171
+ /// Absent and empty are the same canonical policy.
172
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
173
+ pub host_grants: Vec<HostGrant>,
152
174
  }
153
175
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
154
176
  #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
@@ -473,6 +495,76 @@ fn normalize_router_published(value: &mut RouterScope) -> Result<()> {
473
495
  value.published_ports = ports;
474
496
  Ok(())
475
497
  }
498
+ /// Exact tuples only: one protocol, one unicast source and destination address
499
+ /// and one destination port. Addresses never carry a prefix and ports never a
500
+ /// range, so a grant cannot widen past its flow. `bound` checks the authority of
501
+ /// the enclosing scope.
502
+ fn normalize_host_grants(
503
+ grants: &mut [HostGrant],
504
+ bound: impl Fn(&HostGrant) -> Result<()>,
505
+ ) -> Result<()> {
506
+ // The contract maximum per projection; set lookups keep the rule graph
507
+ // constant, and the element budget holds this many in every scope.
508
+ require(grants.len() <= 1024)?;
509
+ grants.sort();
510
+ for (index, grant) in grants.iter().enumerate() {
511
+ require(
512
+ protocol(&grant.protocol)
513
+ && grant.destination_port > 0
514
+ && (index == 0 || grants[index - 1] != *grant),
515
+ )?;
516
+ unicast(&grant.source_address)?;
517
+ unicast(&grant.destination_address)?;
518
+ require(grant.source_address != grant.destination_address)?;
519
+ bound(grant)?;
520
+ }
521
+ Ok(())
522
+ }
523
+ /// The router forwards a host grant from its handoff to one workload: the
524
+ /// destination is an exact current veth endpoint address of this scope, and the
525
+ /// source is protected handoff-side space that is neither router-local, a
526
+ /// platform endpoint nor any workload's address.
527
+ fn normalize_router_host_grants(value: &mut RouterScope) -> Result<()> {
528
+ let mut grants = std::mem::take(&mut value.host_grants);
529
+ let scope = &*value;
530
+ let local = |ip: &str| {
531
+ scope
532
+ .links
533
+ .iter()
534
+ .chain(std::iter::once(&scope.handoff))
535
+ .any(|link| link.required_ipv4_addresses.iter().any(|item| item == ip))
536
+ };
537
+ let platform = |ip: &str| {
538
+ scope
539
+ .protection
540
+ .platform_endpoints
541
+ .iter()
542
+ .any(|endpoint| endpoint.address == ip)
543
+ };
544
+ let workload = |ip: &str| {
545
+ let target = format!("{ip}/32");
546
+ scope.endpoints.iter().find(|endpoint| {
547
+ endpoint
548
+ .source_prefixes
549
+ .iter()
550
+ .any(|source| covers(source, &target) == Ok(true))
551
+ })
552
+ };
553
+ normalize_host_grants(&mut grants, |grant| {
554
+ let endpoint = workload(&grant.destination_address).ok_or(Error::Invalid)?;
555
+ require(
556
+ endpoint.interface_kind == "veth"
557
+ && !local(&grant.destination_address)
558
+ && !platform(&grant.destination_address)
559
+ && covers_address(&scope.protection.prefixes, &grant.source_address)?
560
+ && !local(&grant.source_address)
561
+ && !platform(&grant.source_address)
562
+ && workload(&grant.source_address).is_none(),
563
+ )
564
+ })?;
565
+ value.host_grants = grants;
566
+ Ok(())
567
+ }
476
568
  fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
477
569
  require(value.links.len() <= 32 && value.generations.len() <= 32)?;
478
570
  let private = policy::Policy {
@@ -644,7 +736,8 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
644
736
  item.source_port_ranges.as_slice(),
645
737
  )
646
738
  }))?;
647
- normalize_router_published(value)
739
+ normalize_router_published(value)?;
740
+ normalize_router_host_grants(value)
648
741
  }
649
742
  fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
650
743
  require(
@@ -661,6 +754,58 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
661
754
  require(!overlaps(item, previous)?)?;
662
755
  }
663
756
  }
757
+ // An exception is only ever an exact flow into a guarded pool.
758
+ let mut grants = std::mem::take(&mut value.host_grants);
759
+ normalize_host_grants(&mut grants, |grant| {
760
+ require(covers_address(&value.prefixes, &grant.destination_address)?)
761
+ })?;
762
+ value.host_grants = grants;
763
+ Ok(())
764
+ }
765
+ /// The host dials from an exact current address of exactly one handoff link, into
766
+ /// protected space that is neither host-local, a platform endpoint nor a leased
767
+ /// router transit address: only a workload behind that handoff remains.
768
+ fn normalize_host_host_grants(value: &mut HostScope) -> Result<()> {
769
+ let mut grants = std::mem::take(&mut value.host_grants);
770
+ let scope = &*value;
771
+ normalize_host_grants(&mut grants, |grant| {
772
+ let destination = &grant.destination_address;
773
+ require(
774
+ scope
775
+ .handoffs
776
+ .iter()
777
+ .filter(|handoff| {
778
+ handoff
779
+ .link
780
+ .required_ipv4_addresses
781
+ .contains(&grant.source_address)
782
+ })
783
+ .count()
784
+ == 1
785
+ && !scope
786
+ .uplink
787
+ .required_ipv4_addresses
788
+ .contains(&grant.source_address)
789
+ && covers_address(&scope.protection.prefixes, destination)?
790
+ && scope
791
+ .handoffs
792
+ .iter()
793
+ .map(|handoff| &handoff.link)
794
+ .chain(std::iter::once(&scope.uplink))
795
+ .all(|link| !link.required_ipv4_addresses.contains(destination))
796
+ && scope
797
+ .protection
798
+ .platform_endpoints
799
+ .iter()
800
+ .all(|endpoint| endpoint.address != *destination)
801
+ && scope
802
+ .handoffs
803
+ .iter()
804
+ .flat_map(|handoff| handoff.allocations.iter())
805
+ .all(|allocation| allocation.transit_source_address != *destination),
806
+ )
807
+ })?;
808
+ value.host_grants = grants;
664
809
  Ok(())
665
810
  }
666
811
  /// Inbound publication is bounded by the same authority the scope already proves:
@@ -777,6 +922,7 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
777
922
  }
778
923
  }
779
924
  normalize_published(value)?;
925
+ normalize_host_host_grants(value)?;
780
926
  unique_links(
781
927
  value
782
928
  .handoffs
@@ -799,6 +945,9 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
799
945
  )
800
946
  }
801
947
 
948
+ #[cfg(test)]
949
+ #[path = "egress_hostgrant_tests.rs"]
950
+ pub(crate) mod hostgrant_tests;
802
951
  #[cfg(test)]
803
952
  #[path = "egress_tests.rs"]
804
953
  pub(crate) mod tests;