@push.rocks/smartnftables 2.6.0 → 3.0.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 (34) hide show
  1. package/changelog.md +19 -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/managed.egress.types.d.ts +25 -3
  8. package/package.json +6 -6
  9. package/readme.md +205 -63
  10. package/rust/src/egress.compile.rs +155 -76
  11. package/rust/src/egress.host.rs +24 -51
  12. package/rust/src/egress.hostgrant.rs +1 -1
  13. package/rust/src/egress.keys.rs +305 -0
  14. package/rust/src/egress.leased.rs +399 -0
  15. package/rust/src/egress.private.rs +197 -0
  16. package/rust/src/egress.published.rs +370 -0
  17. package/rust/src/egress.router.rs +61 -294
  18. package/rust/src/egress.rs +126 -11
  19. package/rust/src/egress_hostgrant_tests.rs +149 -18
  20. package/rust/src/egress_localport_tests.rs +3 -1
  21. package/rust/src/egress_publishedrange_tests.rs +812 -0
  22. package/rust/src/egress_routerequivalence_tests.rs +448 -0
  23. package/rust/src/egress_tests.rs +132 -230
  24. package/rust/src/egress_workloadgrant_tests.rs +6 -7
  25. package/rust/src/owner.rs +69 -14
  26. package/rust/src/owner_host_traffic_tests.rs +4 -0
  27. package/rust/src/owner_identity_tests.rs +166 -80
  28. package/rust/src/owner_publishedrange_traffic_tests.rs +306 -0
  29. package/rust/src/owner_router_traffic_tests.rs +2 -0
  30. package/rust/src/owner_scale_traffic_tests.rs +383 -0
  31. package/rust/src/owner_tests.rs +6 -0
  32. package/rust/src/policy.rs +29 -25
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/managed.egress.types.ts +25 -3
@@ -389,6 +389,8 @@ fn v2_host_publishes_exact_uplink_ports_and_removes_them_with_the_generation() {
389
389
  target_port: 8443,
390
390
  target_address: "10.240.0.2".into(),
391
391
  host_ip: "192.0.2.2".into(),
392
+ host_port_end: None,
393
+ symmetric: false,
392
394
  },
393
395
  egress::PublishedPort {
394
396
  protocol: "udp".into(),
@@ -396,6 +398,8 @@ fn v2_host_publishes_exact_uplink_ports_and_removes_them_with_the_generation() {
396
398
  target_port: 8125,
397
399
  target_address: "10.240.0.2".into(),
398
400
  host_ip: "192.0.2.2".into(),
401
+ host_port_end: None,
402
+ symmetric: false,
399
403
  },
400
404
  ];
401
405
  let mut owner = Owner::new(options("host_published")).unwrap();
@@ -168,94 +168,162 @@ fn egress_identity_rejects_unknown_duplicate_or_incomplete_operands() {
168
168
  }
169
169
  }
170
170
 
171
+ /// The compiled chains, rules, and sets with their element identities of one
172
+ /// prepared policy, as the owner's graph comparison sees them.
173
+ type Compiled = (
174
+ Vec<Vec<u8>>,
175
+ Vec<(String, Vec<u8>)>,
176
+ std::collections::BTreeMap<String, (Vec<u8>, Vec<Vec<u8>>)>,
177
+ );
178
+ fn compiled(value: serde_json::Value) -> Compiled {
179
+ let prepared = serde_json::from_value::<crate::egress::Policy>(value)
180
+ .unwrap()
181
+ .prepare()
182
+ .unwrap();
183
+ let (mut chains, mut rules) = (Vec::new(), Vec::new());
184
+ let mut sets = std::collections::BTreeMap::new();
185
+ for (kind, attributes) in prepared.program("snft_identity").unwrap() {
186
+ match kind {
187
+ 3 => chains.push(super::chain_identity(&attributes).unwrap()),
188
+ 6 => {
189
+ // The stamp carries the policy digest; compare the rule itself.
190
+ let unstamped: Vec<Attr> = attributes
191
+ .iter()
192
+ .map(|attribute| match attribute.id() {
193
+ 7 => Attr::bytes(7, b"stamp".to_vec()),
194
+ _ => attribute.clone(),
195
+ })
196
+ .collect();
197
+ rules.push(rule_identity(&unstamped).unwrap());
198
+ }
199
+ 9 => {
200
+ // The kernel's dump of the same set has the same identity.
201
+ let identity = set_identity(&attributes).unwrap();
202
+ assert_eq!(set_identity(&dumped_set(&attributes)).unwrap(), identity);
203
+ sets.insert(wire::text(&attributes, 2).unwrap(), (identity, Vec::new()));
204
+ }
205
+ 12 => {
206
+ let list = wire::attrs(&wire::one(&attributes, 3).unwrap().value).unwrap();
207
+ sets.get_mut(&wire::text(&attributes, 2).unwrap())
208
+ .unwrap()
209
+ .1
210
+ .extend(element_keys(&list).unwrap());
211
+ }
212
+ other => panic!("operation {other}"),
213
+ }
214
+ }
215
+ (chains, rules, sets)
216
+ }
217
+
171
218
  /// A published host-transit generation must survive the same apply-time graph
172
- /// identity as the rest of the table: the kernel dump is compared operand by
173
- /// operand, so destination NAT and its admissions cannot be silently rewritten.
219
+ /// identity as the rest of the table: its rules and every set element are
220
+ /// compared operand by operand, so destination NAT and its admissions cannot be
221
+ /// silently rewritten, while the rules stay the same for any publication.
174
222
  #[test]
175
223
  fn published_host_graph_identity_preserves_destination_nat_and_admission_operands() {
176
- let compiled = |target_port: u16| {
177
- let value = crate::egress::tests::published_host(serde_json::json!([
224
+ let host = |target_port: u16| {
225
+ compiled(crate::egress::tests::published_host(serde_json::json!([
178
226
  crate::egress::tests::published_port("tcp", 443, target_port)
179
- ]));
180
- let prepared = serde_json::from_value::<crate::egress::Policy>(value)
181
- .unwrap()
182
- .prepare()
183
- .unwrap();
184
- let mut chains = Vec::new();
185
- let mut rules = Vec::new();
186
- for (kind, attributes) in prepared.program("snft_identity").unwrap() {
187
- if kind == 3 {
188
- chains.push(super::chain_identity(&attributes).unwrap());
189
- } else {
190
- rules.push(rule_identity(&attributes).unwrap());
191
- }
192
- }
193
- (chains, rules)
227
+ ])))
194
228
  };
195
- let (chains, rules) = compiled(8443);
229
+ let (chains, rules, sets) = host(8443);
196
230
  assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
197
231
  assert_eq!(chains.len(), 7);
198
- let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
199
- let (_, changed) = compiled(9443);
200
- let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
201
- assert_ne!(translation.1, other.1);
202
- assert_eq!(
203
- rules.iter().filter(|(chain, _)| chain == "forward").count(),
204
- changed.iter().filter(|(chain, _)| chain == "forward").count()
205
- );
232
+ let (_, changed_rules, changed_sets) = host(9443);
233
+ assert_eq!(rules, changed_rules);
234
+ // The translation's port is map data and the admission's port is a key.
235
+ for name in ["published_port", "published_forward"] {
236
+ assert_eq!(sets[name].0, changed_sets[name].0);
237
+ assert_ne!(sets[name].1, changed_sets[name].1, "{name}");
238
+ }
206
239
  }
207
240
 
208
241
  /// A published router generation must survive the same apply-time graph identity
209
- /// as the rest of the table: the kernel dump is compared operand by operand, so
210
- /// the workload translation and its admissions cannot be silently rewritten.
242
+ /// as the rest of the table: its rules and every set element are compared operand
243
+ /// by operand, so the workload translation, both classifications and the
244
+ /// admissions cannot be silently rewritten.
211
245
  #[test]
212
246
  fn published_router_graph_identity_preserves_destination_nat_and_admission_operands() {
213
- let compiled = |endpoint_port: u16| {
214
- let value = crate::egress::tests::published_router(serde_json::json!([
247
+ let router = |endpoint_port: u16| {
248
+ compiled(crate::egress::tests::published_router(serde_json::json!([
215
249
  crate::egress::tests::router_published_port("tcp", 443, endpoint_port)
216
- ]));
217
- let prepared = serde_json::from_value::<crate::egress::Policy>(value)
218
- .unwrap()
219
- .prepare()
220
- .unwrap();
221
- let mut chains = Vec::new();
222
- let mut rules = Vec::new();
223
- for (kind, attributes) in prepared.program("snft_identity").unwrap() {
224
- if kind == 3 {
225
- chains.push(super::chain_identity(&attributes).unwrap());
226
- } else {
227
- rules.push(rule_identity(&attributes).unwrap());
228
- }
229
- }
230
- (chains, rules)
250
+ ])))
231
251
  };
232
- let (chains, rules) = compiled(8443);
252
+ let (chains, rules, sets) = router(8443);
233
253
  assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
234
- assert_eq!(chains.len(), 16);
235
- let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
236
- let (_, changed) = compiled(9443);
237
- let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
238
- assert_ne!(translation.1, other.1);
239
- assert_eq!(
240
- rules.iter().filter(|(chain, _)| chain == "forward").count(),
241
- changed.iter().filter(|(chain, _)| chain == "forward").count()
242
- );
243
- // The handoff ingress classification is part of the same identity.
254
+ // The router's base chains, the pre chain and its three regular chains.
255
+ assert_eq!(chains.len(), 10);
256
+ let (_, changed_rules, changed_sets) = router(9443);
257
+ assert_eq!(rules, changed_rules);
258
+ for name in ["published_port", "published_forward", "published_endpoint"] {
259
+ assert_ne!(sets[name].1, changed_sets[name].1, "{name}");
260
+ }
261
+ assert_eq!(sets["published_arrival"], changed_sets["published_arrival"]);
262
+ }
263
+
264
+ /// Interval elements carry their last key and a map's data in their identity;
265
+ /// a changed bound or datum is a changed element, and a set description is only
266
+ /// ever a concatenation.
267
+ #[test]
268
+ fn interval_sets_and_elements_have_exact_graph_identity() {
269
+ let (_, _, sets) = compiled(crate::egress::tests::published_router(serde_json::json!([
270
+ crate::egress::tests::router_published_port("tcp", 443, 8443)
271
+ ])));
244
272
  assert_eq!(
245
- rules.iter().filter(|(chain, _)| chain == "raw_return").count(),
246
- changed
247
- .iter()
248
- .filter(|(chain, _)| chain == "raw_return")
249
- .count()
273
+ sets.keys().filter(|name| name.starts_with("published_")).count(),
274
+ 4
250
275
  );
276
+ let element = |last: u8, data: Option<u8>| {
277
+ let value = |kind: u16, byte: u8| Attr::nested(kind, vec![Attr::bytes(1, vec![byte; 8])]);
278
+ let mut fields = vec![value(1, 1), value(10, last)];
279
+ if let Some(data) = data {
280
+ fields.push(value(2, data));
281
+ }
282
+ Attr::nested(1, fields)
283
+ };
284
+ let identity = |attribute: Attr| element_keys(&[attribute]).unwrap();
285
+ let exact = identity(element(1, Some(4)));
286
+ assert_ne!(identity(element(2, Some(4))), exact);
287
+ assert_ne!(identity(element(1, Some(5))), exact);
288
+ assert_ne!(identity(element(1, None)), exact);
289
+ for extension in [
290
+ Attr::u32(3, 1),
291
+ Attr::nested(10, vec![Attr::bytes(1, vec![1; 8])]),
292
+ ] {
293
+ let Attr { value, .. } = element(1, Some(4));
294
+ let mut fields = wire::attrs(&value).unwrap();
295
+ fields.push(extension);
296
+ assert!(element_keys(&[Attr::nested(1, fields)]).is_err());
297
+ }
251
298
  }
252
299
 
253
- /// A host-grant set as Linux dumps it: the compiled creation plus the kernel's
254
- /// handle, an empty description, the backend type and the element count.
300
+ /// A set as Linux dumps it: the compiled creation plus the kernel's handle, its
301
+ /// description (empty for a plain key, the concatenation's field lengths without
302
+ /// nesting flags otherwise), the backend type and the element count.
255
303
  fn dumped_set(created: &[Attr]) -> Vec<Attr> {
256
- let mut dump: Vec<Attr> = created.iter().filter(|attribute| attribute.id() != 10).cloned().collect();
304
+ let mut dump: Vec<Attr> = created
305
+ .iter()
306
+ .filter(|attribute| ![9, 10].contains(&attribute.id()))
307
+ .cloned()
308
+ .collect();
257
309
  dump.push(Attr::u64(16, 7));
258
- dump.push(Attr::nested(9, vec![]));
310
+ let fields = created
311
+ .iter()
312
+ .find(|attribute| attribute.id() == 9)
313
+ .map(|description| {
314
+ let concatenation = wire::attrs(&description.value).unwrap();
315
+ wire::attrs(&wire::one(&concatenation, 2).unwrap().value)
316
+ .unwrap()
317
+ .iter()
318
+ .map(|field| {
319
+ Attr::bytes(1, wire::encode_attrs(&[Attr::u32(1, wire::number(&wire::attrs(&field.value).unwrap(), 1).unwrap())]))
320
+ })
321
+ .collect::<Vec<_>>()
322
+ });
323
+ dump.push(match fields {
324
+ Some(fields) => Attr::bytes(9, wire::encode_attrs(&[Attr::bytes(2, wire::encode_attrs(&fields))])),
325
+ None => Attr::nested(9, vec![]),
326
+ });
259
327
  dump.push(Attr::string(19, "nft_rhash_type"));
260
328
  dump.push(Attr::u32(20, 1024));
261
329
  dump
@@ -273,7 +341,11 @@ fn host_grant_sets_elements_and_lookups_have_exact_graph_identity() {
273
341
  .prepare()
274
342
  .unwrap();
275
343
  let program = prepared.program("snft_identity").unwrap();
276
- let sets: Vec<_> = program.iter().filter(|(kind, _)| *kind == 9).map(|(_, attributes)| attributes.clone()).collect();
344
+ let sets: Vec<_> = program
345
+ .iter()
346
+ .filter(|(kind, attributes)| *kind == 9 && wire::text(attributes, 2).unwrap().starts_with("host_grant"))
347
+ .map(|(_, attributes)| attributes.clone())
348
+ .collect();
277
349
  assert_eq!(sets.len(), 2);
278
350
  for created in &sets {
279
351
  // The kernel's informational attributes are not identity.
@@ -294,21 +366,30 @@ fn host_grant_sets_elements_and_lookups_have_exact_graph_identity() {
294
366
  assert!(set_identity(&changed).is_err());
295
367
  }
296
368
  }
297
- // Elements are plain keys; any extension rejects.
298
- let (_, elements) = program.iter().find(|(kind, _)| *kind == 12).unwrap();
369
+ // Elements are plain keys; flags reject, and data is a different element.
370
+ let (_, elements) = program
371
+ .iter()
372
+ .find(|(kind, attributes)| *kind == 12 && wire::text(attributes, 2).unwrap() == "host_grant")
373
+ .unwrap();
299
374
  let list = wire::attrs(&wire::one(elements, 3).unwrap().value).unwrap();
300
375
  assert_eq!(element_keys(&list).unwrap().len(), 1);
301
376
  let key = wire::attrs(&list[0].value).unwrap();
302
- for extension in [Attr::u32(3, 1), Attr::nested(2, vec![Attr::bytes(1, vec![1; 4])])] {
303
- let mut changed = key.clone();
304
- changed.push(extension);
305
- assert!(element_keys(&[Attr::nested(1, changed)]).is_err());
306
- }
307
- // Every lookup names its set with a plain, uninverted membership test, and
308
- // the key registers are part of identity.
377
+ let mut flagged = key.clone();
378
+ flagged.push(Attr::u32(3, 1));
379
+ assert!(element_keys(&[Attr::nested(1, flagged)]).is_err());
380
+ let mut mapped = key.clone();
381
+ mapped.push(Attr::nested(2, vec![Attr::bytes(1, vec![1; 4])]));
382
+ assert_ne!(
383
+ element_keys(&[Attr::nested(1, mapped)]).unwrap(),
384
+ element_keys(&list[..1]).unwrap()
385
+ );
386
+ // Five rules look up the host grant sets (the router's private and leased
387
+ // sets have their own lookups); the set name and key registers are identity.
309
388
  let lookups: Vec<_> = program
310
389
  .iter()
311
- .filter(|(kind, attributes)| *kind == 6 && rule_identity(attributes).unwrap().1.windows(6).any(|window| window == b"lookup"))
390
+ .filter(|(kind, attributes)| {
391
+ *kind == 6 && wire::encode_attrs(attributes).windows(10).any(|window| window == b"host_grant")
392
+ })
312
393
  .collect();
313
394
  assert_eq!(lookups.len(), 5);
314
395
  let lookup = |data: Vec<Attr>| rule(vec![expression("lookup", data)]);
@@ -317,13 +398,18 @@ fn host_grant_sets_elements_and_lookups_have_exact_graph_identity() {
317
398
  for changed in [
318
399
  vec![Attr::string(1, "host_grant_arrival"), Attr::u32(2, 1), Attr::u32(5, 0)],
319
400
  vec![Attr::string(1, "host_grant"), Attr::u32(2, 9), Attr::u32(5, 0)],
401
+ // A map lookup's data register and an absence test are identity.
402
+ vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(3, 2), Attr::u32(5, 0)],
403
+ vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(5, 1)],
320
404
  ] {
321
405
  assert_ne!(rule_identity(&lookup(changed)).unwrap(), identity);
322
406
  }
323
407
  for invalid in [
324
- vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(5, 1)],
408
+ vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(5, 2)],
325
409
  vec![Attr::string(1, "host_grant"), Attr::u32(2, 1)],
326
- vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(3, 2), Attr::u32(5, 0)],
410
+ vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(3, 2)],
411
+ // A map lookup is never inverted.
412
+ vec![Attr::string(1, "host_grant"), Attr::u32(2, 1), Attr::u32(3, 2), Attr::u32(5, 1)],
327
413
  ] {
328
414
  assert!(rule_identity(&lookup(invalid)).is_err());
329
415
  }
@@ -0,0 +1,306 @@
1
+ //! Published port ranges and symmetric publications across both hops, in the
2
+ //! four-namespace topology of the router publication qualification.
3
+ use super::egress_traffic_tests::{allocation, binding, connect, denied, host_policy, ns_ip};
4
+ use super::egress_traffic_tests::{protection, roundtrip, socket};
5
+ use super::host_traffic_tests::{control, dark_udp, published_udp};
6
+ use super::router_traffic_tests::connect_from;
7
+ use super::*;
8
+ use crate::egress;
9
+ use serde_json::json;
10
+ use std::io::{Read, Write};
11
+ use std::net::TcpListener;
12
+ use std::time::Duration;
13
+
14
+ const WORK: &str = "pr_work";
15
+ const ROUTER: &str = "pr_router";
16
+ const HOST: &str = "pr_host";
17
+ const PEER: &str = "pr_peer";
18
+
19
+ /// The SIP/RTP shape on both hops: UDP and TCP signalling on one port and one UDP
20
+ /// RTP range, all symmetric, beside a plain publication and leased egress.
21
+ fn host_ranges() -> egress::Policy {
22
+ let mut policy = host_policy();
23
+ let egress::Scope::HostTransit(scope) = &mut policy.scope else {
24
+ panic!()
25
+ };
26
+ scope.published_ports = [
27
+ ("udp", 20000, Some(20200), 20000, true),
28
+ ("udp", 5070, None, 5070, true),
29
+ ("tcp", 5070, None, 5070, true),
30
+ ("udp", 8125, None, 8125, false),
31
+ ]
32
+ .into_iter()
33
+ .map(
34
+ |(protocol, host_port, host_port_end, target_port, symmetric)| egress::PublishedPort {
35
+ protocol: protocol.into(),
36
+ host_port,
37
+ host_port_end,
38
+ target_port,
39
+ target_address: "10.240.0.2".into(),
40
+ host_ip: "192.0.2.2".into(),
41
+ symmetric,
42
+ },
43
+ )
44
+ .collect();
45
+ policy
46
+ }
47
+ fn router_ranges() -> egress::Policy {
48
+ let link = binding("work", "10.241.0.1", "veth");
49
+ let mut generation = allocation();
50
+ generation["conntrackZone"] = json!(17);
51
+ generation["conntrackLabel"] = json!("a".repeat(32));
52
+ generation["grants"] = json!([
53
+ {"sourceEndpoint":"work","sourcePrefix":"10.241.0.2/32","destination":{"kind":"public","prefix":"203.0.113.2/32"},
54
+ "protocol":"udp","sourcePort":null,"destinationPort":42000,"sourcePortRange":{"first":10000,"last":10015}}
55
+ ]);
56
+ let publication = |protocol: &str,
57
+ port: u16,
58
+ end: Option<u16>,
59
+ endpoint: u16,
60
+ symmetric: bool| {
61
+ let mut value = json!({"protocol":protocol,"transitPort":port,"endpointPort":endpoint,
62
+ "endpointAddress":"10.241.0.2","transitSourceAddress":"10.240.0.2","symmetric":symmetric});
63
+ if let Some(end) = end {
64
+ value["transitPortEnd"] = json!(end);
65
+ }
66
+ value
67
+ };
68
+ serde_json::from_value(
69
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"routerEgress",
70
+ "endpoints":[{"id":"work","interfaceIndex":link["interfaceIndex"],"interfaceName":"work",
71
+ "interfaceKind":"veth","sourcePrefixes":["10.241.0.2/32"]}],
72
+ "rules":[],"links":[link],"handoff":binding("handoff","10.240.0.2","veth"),
73
+ "protection":protection(),"generations":[generation],"publishedPorts":[
74
+ publication("udp", 20000, Some(20200), 20000, true),
75
+ publication("udp", 5070, None, 5070, true),
76
+ publication("tcp", 5070, None, 5070, true),
77
+ publication("udp", 8125, None, 9125, false)
78
+ ]}}),
79
+ )
80
+ .unwrap()
81
+ }
82
+ /// Binds and applies a policy inside its namespace, where its links exist, and
83
+ /// returns the owned sets with their element counts as the kernel dumps them.
84
+ /// Enforcement means the dump, every interval element and map datum included,
85
+ /// matched the compiled graph.
86
+ fn apply(
87
+ namespace: &str,
88
+ name: &'static str,
89
+ policy: fn() -> egress::Policy,
90
+ ) -> (Owner, Applied, Vec<(String, usize)>) {
91
+ in_namespace(namespace, move || {
92
+ let mut owner = Owner::new(options(name)).unwrap();
93
+ let target: Prepared = policy().prepare().unwrap().into();
94
+ target
95
+ .validate_interfaces()
96
+ .expect("published range fixture binding");
97
+ let applied = owner
98
+ .reconcile(Transition {
99
+ previous: None,
100
+ target,
101
+ })
102
+ .unwrap();
103
+ assert!(owner.inspect().enforced);
104
+ let sets = owner
105
+ .graph()
106
+ .unwrap()
107
+ .expect("owned table present")
108
+ .sets
109
+ .iter()
110
+ .map(|(attributes, elements)| (wire::text(attributes, 2).unwrap(), elements.len()))
111
+ .collect();
112
+ (owner, applied, sets)
113
+ })
114
+ }
115
+
116
+ #[test]
117
+ #[ignore = "requires disposable isolated native qualification guest"]
118
+ fn v2_published_ranges_and_symmetric_publications_cross_both_hops() {
119
+ isolated();
120
+ for name in [WORK, ROUTER, HOST, PEER] {
121
+ ip(&["netns", "add", name]);
122
+ ns_ip(name, &["link", "set", "lo", "up"]);
123
+ in_namespace(name, || {
124
+ std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
125
+ });
126
+ }
127
+ connect(
128
+ WORK,
129
+ "client",
130
+ "10.241.0.2/24",
131
+ ROUTER,
132
+ "work",
133
+ "10.241.0.1/24",
134
+ );
135
+ connect(
136
+ ROUTER,
137
+ "handoff",
138
+ "10.240.0.2/30",
139
+ HOST,
140
+ "router",
141
+ "10.240.0.1/30",
142
+ );
143
+ connect(
144
+ HOST,
145
+ "uplink",
146
+ "192.0.2.2/24",
147
+ PEER,
148
+ "underlay",
149
+ "192.0.2.3/24",
150
+ );
151
+ ns_ip(WORK, &["route", "add", "default", "via", "10.241.0.1"]);
152
+ ns_ip(ROUTER, &["route", "add", "default", "via", "10.240.0.1"]);
153
+ // The workload route exists throughout, so every later denial is caused by
154
+ // policy and not by a missing route.
155
+ ns_ip(
156
+ HOST,
157
+ &["route", "add", "10.241.0.0/24", "via", "10.240.0.2"],
158
+ );
159
+ ns_ip(
160
+ HOST,
161
+ &["route", "add", "203.0.113.2/32", "via", "192.0.2.3"],
162
+ );
163
+ ns_ip(PEER, &["address", "add", "203.0.113.2/32", "dev", "lo"]);
164
+ ns_ip(PEER, &["route", "add", "default", "via", "192.0.2.2"]);
165
+ let first = socket(WORK, "10.241.0.2:20000");
166
+ let middle = socket(WORK, "10.241.0.2:20100");
167
+ let last = socket(WORK, "10.241.0.2:20200");
168
+ let above = socket(WORK, "10.241.0.2:20201");
169
+ let below = socket(WORK, "10.241.0.2:19999");
170
+ let rtp = socket(WORK, "10.241.0.2:20150");
171
+ let sip = socket(WORK, "10.241.0.2:5070");
172
+ let plain = socket(WORK, "10.241.0.2:9125");
173
+ let work_egress = socket(WORK, "10.241.0.2:40001");
174
+ let public = socket(PEER, "203.0.113.2:42000");
175
+ let rtp_peer = socket(PEER, "203.0.113.2:42001");
176
+ let sip_peer = socket(PEER, "203.0.113.2:42002");
177
+ let plain_peer = socket(PEER, "203.0.113.2:42003");
178
+ let protected_peer = socket(PEER, "192.0.2.3:40300");
179
+ // Positive controls: without policy the workload is routed directly and its
180
+ // packets reach the protected peer address, so every later result is caused
181
+ // by policy. Each probe uses its own tuple, so no unreplied conntrack entry
182
+ // carries a null translation across the policy change.
183
+ control(
184
+ &socket(PEER, "192.0.2.3:40010"),
185
+ "10.241.0.2:20201",
186
+ &above,
187
+ true,
188
+ );
189
+ control(
190
+ &socket(PEER, "192.0.2.3:40011"),
191
+ "10.241.0.2:19999",
192
+ &below,
193
+ true,
194
+ );
195
+ control(
196
+ &socket(WORK, "10.241.0.2:5071"),
197
+ "192.0.2.3:40300",
198
+ &protected_peer,
199
+ true,
200
+ );
201
+ dark_udp(&socket(PEER, "192.0.2.3:40012"), &middle, "192.0.2.2:20100");
202
+ let (host_owner, host_applied, host_sets) = apply(HOST, "published_range_host", host_ranges);
203
+ let (router_owner, router_applied, router_sets) =
204
+ apply(ROUTER, "published_range", router_ranges);
205
+ // Publications are set elements: one-port translations, range translations,
206
+ // the inbound admissions and the symmetric map on both hops, and the two raw
207
+ // classifications in the router.
208
+ let named = |sets: &[(String, usize)], name: &str| {
209
+ sets.iter()
210
+ .find(|(set, _)| set == name)
211
+ .map(|(_, count)| *count)
212
+ };
213
+ for (name, host, router) in [
214
+ ("published_port", Some(3), Some(3)),
215
+ ("published_address", Some(1), Some(1)),
216
+ ("published_forward", Some(4), Some(4)),
217
+ ("published_symmetric", Some(3), Some(3)),
218
+ ("published_arrival", None, Some(4)),
219
+ // 9125 and the 5070 ports of both protocols and the 20000 range.
220
+ ("published_endpoint", None, Some(4)),
221
+ ] {
222
+ assert_eq!(named(&host_sets, name), host, "host {name}");
223
+ assert_eq!(named(&router_sets, name), router, "router {name}");
224
+ }
225
+ // First, middle and last port of the range reach the listener behind both
226
+ // hops with the client address preserved, and the reply comes back from the
227
+ // published address and port.
228
+ for (port, client_port, workload) in [
229
+ (20000, 40100, &first),
230
+ (20100, 40101, &middle),
231
+ (20200, 40102, &last),
232
+ ] {
233
+ let client = in_namespace(PEER, move || {
234
+ std::net::UdpSocket::bind(format!("192.0.2.3:{client_port}")).unwrap()
235
+ });
236
+ client
237
+ .set_read_timeout(Some(Duration::from_millis(250)))
238
+ .unwrap();
239
+ published_udp(
240
+ &client,
241
+ workload,
242
+ &format!("192.0.2.2:{port}"),
243
+ &format!("192.0.2.3:{client_port}"),
244
+ );
245
+ }
246
+ // The ports just outside the range stay dark.
247
+ dark_udp(&socket(PEER, "192.0.2.3:40103"), &above, "192.0.2.2:20201");
248
+ dark_udp(&socket(PEER, "192.0.2.3:40104"), &below, "192.0.2.2:19999");
249
+ // Symmetric outbound flows reach the uplink with their own port, from a range
250
+ // port and from the single signalling port, and their replies return.
251
+ let observed = roundtrip(&rtp, &rtp_peer);
252
+ assert_eq!(observed.to_string(), "192.0.2.2:20150");
253
+ let observed = roundtrip(&sip, &sip_peer);
254
+ assert_eq!(observed.to_string(), "192.0.2.2:5070");
255
+ // A later request from the same peer tuple toward the published port is the
256
+ // flow's own reply direction and reaches the workload from that peer.
257
+ assert_eq!(sip_peer.send_to(b"invite", "192.0.2.2:5070").unwrap(), 6);
258
+ let mut bytes = [0; 64];
259
+ let (size, from) = sip
260
+ .recv_from(&mut bytes)
261
+ .expect("request on the symmetric flow");
262
+ assert_eq!(
263
+ (&bytes[..size], from.to_string().as_str()),
264
+ (&b"invite"[..], "203.0.113.2:42002")
265
+ );
266
+ // An unrelated client still reaches the publication inbound.
267
+ let unrelated = socket(PEER, "192.0.2.3:40105");
268
+ published_udp(&unrelated, &sip, "192.0.2.2:5070", "192.0.2.3:40105");
269
+ let listener = in_namespace(PEER, || TcpListener::bind("203.0.113.2:5060").unwrap());
270
+ let mut outbound = in_namespace(WORK, || connect_from("10.241.0.2:5070", "203.0.113.2:5060"))
271
+ .expect("symmetric tcp opening");
272
+ let (mut served, peer) = listener.accept().unwrap();
273
+ assert_eq!(peer.to_string(), "192.0.2.2:5070");
274
+ for stream in [&outbound, &served] {
275
+ stream
276
+ .set_read_timeout(Some(Duration::from_secs(1)))
277
+ .unwrap();
278
+ }
279
+ outbound.write_all(b"ping").unwrap();
280
+ served.read_exact(&mut [0; 4]).unwrap();
281
+ served.write_all(b"pong").unwrap();
282
+ outbound.read_exact(&mut [0; 4]).unwrap();
283
+ drop(outbound);
284
+ drop(served);
285
+ // Negative controls: a plain publication's workload port opens nothing, and a
286
+ // symmetric flow into the protected union stays denied from either port kind.
287
+ denied(&plain, &plain_peer, false);
288
+ denied(&sip, &protected_peer, false);
289
+ denied(&rtp, &protected_peer, false);
290
+ // Leased egress keeps its own leased source range.
291
+ let outer = roundtrip(&work_egress, &public);
292
+ assert_eq!(outer.ip().to_string(), "192.0.2.2");
293
+ assert!((10000..=10015).contains(&outer.port()));
294
+ in_namespace(ROUTER, move || {
295
+ let mut owner = router_owner;
296
+ assert!(owner.inspect().enforced);
297
+ owner.release(router_applied).unwrap();
298
+ });
299
+ in_namespace(HOST, move || {
300
+ let mut owner = host_owner;
301
+ assert!(owner.inspect().enforced);
302
+ owner.release(host_applied).unwrap();
303
+ });
304
+ dark_udp(&socket(PEER, "192.0.2.3:40106"), &middle, "192.0.2.2:20100");
305
+ println!("V2_PUBLISHED_RANGE_PROOF set_backed_graph_verified=true range_first_delivered=true range_middle_delivered=true range_last_delivered=true client_address_preserved=true range_outside_dark=true symmetric_range_source_port_kept=true symmetric_udp_source_port_kept=true symmetric_tcp_source_port_kept=true peer_request_on_symmetric_flow=true inbound_beside_symmetric=true plain_publication_outbound_denied=true symmetric_protected_denied=true leased_egress_unaffected=true protected_positive_control=true released_range_dark=true");
306
+ }
@@ -135,6 +135,8 @@ fn published_host_policy() -> egress::Policy {
135
135
  target_port,
136
136
  target_address: "10.240.0.2".into(),
137
137
  host_ip: "192.0.2.2".into(),
138
+ host_port_end: None,
139
+ symmetric: false,
138
140
  })
139
141
  .collect();
140
142
  policy