@push.rocks/smartnftables 2.5.2 → 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 (41) hide show
  1. package/changelog.md +31 -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 +69 -3
  8. package/package.json +6 -6
  9. package/readme.md +342 -61
  10. package/rust/src/egress.compile.rs +157 -76
  11. package/rust/src/egress.host.rs +104 -44
  12. package/rust/src/egress.hostgrant.rs +14 -14
  13. package/rust/src/egress.keys.rs +305 -0
  14. package/rust/src/egress.leased.rs +399 -0
  15. package/rust/src/egress.poolguard.rs +45 -0
  16. package/rust/src/egress.private.rs +197 -0
  17. package/rust/src/egress.published.rs +370 -0
  18. package/rust/src/egress.router.rs +64 -295
  19. package/rust/src/egress.rs +248 -17
  20. package/rust/src/egress.workloadgrant.rs +88 -0
  21. package/rust/src/egress_hostgrant_tests.rs +196 -47
  22. package/rust/src/egress_localplatform_tests.rs +204 -0
  23. package/rust/src/egress_localport_tests.rs +266 -0
  24. package/rust/src/egress_publishedrange_tests.rs +812 -0
  25. package/rust/src/egress_routerequivalence_tests.rs +448 -0
  26. package/rust/src/egress_tests.rs +132 -230
  27. package/rust/src/egress_workloadgrant_tests.rs +313 -0
  28. package/rust/src/owner.rs +77 -15
  29. package/rust/src/owner_host_traffic_tests.rs +4 -0
  30. package/rust/src/owner_hostgrant_traffic_tests.rs +9 -4
  31. package/rust/src/owner_identity_tests.rs +228 -80
  32. package/rust/src/owner_localplatform_tests.rs +274 -0
  33. package/rust/src/owner_localport_tests.rs +241 -0
  34. package/rust/src/owner_publishedrange_traffic_tests.rs +306 -0
  35. package/rust/src/owner_router_traffic_tests.rs +2 -0
  36. package/rust/src/owner_scale_traffic_tests.rs +383 -0
  37. package/rust/src/owner_tests.rs +15 -0
  38. package/rust/src/owner_workloadgrant_tests.rs +278 -0
  39. package/rust/src/policy.rs +29 -25
  40. package/ts/00_commitinfo_data.ts +1 -1
  41. package/ts/managed.egress.types.ts +71 -3
@@ -15,15 +15,15 @@ pub(crate) fn granted(mut value: Value, grants: Value) -> Value {
15
15
  value["scope"]["hostGrants"] = grants;
16
16
  value
17
17
  }
18
- fn normalized(value: Value) -> crate::Result<Policy> {
18
+ pub(super) fn normalized(value: Value) -> crate::Result<Policy> {
19
19
  serde_json::from_value::<Policy>(value)
20
20
  .map_err(|_| crate::Error::Invalid)?
21
21
  .normalize()
22
22
  }
23
- fn prepared(value: Value) -> Prepared {
23
+ pub(super) fn prepared(value: Value) -> Prepared {
24
24
  normalized(value).unwrap().prepare().unwrap()
25
25
  }
26
- fn bytes(prepared: &Prepared) -> Vec<u8> {
26
+ pub(super) fn bytes(prepared: &Prepared) -> Vec<u8> {
27
27
  let mut result = Vec::new();
28
28
  for (kind, attributes) in prepared.program("snft_host_grant").unwrap() {
29
29
  result.extend(kind.to_be_bytes());
@@ -38,28 +38,38 @@ fn exact() -> Value {
38
38
 
39
39
  /// One packet with the conntrack facts a filter hook observes.
40
40
  #[derive(Clone, Copy)]
41
- struct Flow {
42
- input: Option<(u32, &'static str)>,
43
- output: Option<(u32, &'static str)>,
44
- protocol: u8,
45
- source: [u8; 4],
46
- destination: [u8; 4],
47
- source_port: u16,
48
- destination_port: u16,
49
- tcp_flags: u8,
50
- state: u32,
51
- reply: bool,
52
- zone: u16,
41
+ pub(super) struct Flow {
42
+ pub(super) input: Option<(u32, &'static str)>,
43
+ pub(super) output: Option<(u32, &'static str)>,
44
+ pub(super) protocol: u8,
45
+ pub(super) source: [u8; 4],
46
+ pub(super) destination: [u8; 4],
47
+ pub(super) source_port: u16,
48
+ pub(super) destination_port: u16,
49
+ pub(super) tcp_flags: u8,
50
+ pub(super) state: u32,
51
+ pub(super) reply: bool,
52
+ pub(super) zone: u16,
53
53
  /// Original-direction conntrack tuple: source, destination, ports.
54
- original: ([u8; 4], [u8; 4], u16, u16),
54
+ pub(super) original: ([u8; 4], [u8; 4], u16, u16),
55
+ /// The file system uid of the packet's user socket; None without one, when
56
+ /// `meta skuid` breaks the rule like the kernel.
57
+ pub(super) uid: Option<u32>,
58
+ /// The conntrack label of the flow's entry.
59
+ pub(super) label: [u8; 16],
55
60
  }
56
61
  const HOST: [u8; 4] = [10, 240, 0, 1];
57
62
  const WORK: [u8; 4] = [10, 241, 0, 2];
58
63
  const NEW: u32 = 8;
59
- const ESTABLISHED: u32 = 2;
64
+ pub(super) const ESTABLISHED: u32 = 2;
60
65
  const SYN: u8 = 0x02;
61
- const ACK: u8 = 0x10;
62
- fn opening(input: Option<(u32, &'static str)>, output: Option<(u32, &'static str)>) -> Flow {
66
+ pub(super) const ACK: u8 = 0x10;
67
+ /// The `reject with tcp reset` decision, distinct from a plain drop.
68
+ pub(super) const REJECT: i32 = i32::MIN;
69
+ pub(super) fn opening(
70
+ input: Option<(u32, &'static str)>,
71
+ output: Option<(u32, &'static str)>,
72
+ ) -> Flow {
63
73
  Flow {
64
74
  input,
65
75
  output,
@@ -73,6 +83,8 @@ fn opening(input: Option<(u32, &'static str)>, output: Option<(u32, &'static str
73
83
  reply: false,
74
84
  zone: 0,
75
85
  original: (HOST, WORK, 443, 8080),
86
+ uid: None,
87
+ label: [0; 16],
76
88
  }
77
89
  }
78
90
  /// The workload's answer to `flow`, arriving on the reverse links.
@@ -181,22 +193,59 @@ fn foreign(flow: Flow) -> Vec<Flow> {
181
193
  /// kernel does: the terminal verdict, the chain that reached it (`policy` for the
182
194
  /// base chain's accept policy) and the conntrack zone a raw rule stored.
183
195
  #[derive(Debug, PartialEq, Eq)]
184
- struct Decision {
185
- verdict: i32,
186
- chain: String,
187
- zone: u16,
196
+ pub(super) struct Decision {
197
+ pub(super) verdict: i32,
198
+ pub(super) chain: String,
199
+ pub(super) zone: u16,
200
+ /// The NAT a nat-chain rule performed, which also ends that rule with an
201
+ /// accept exactly as the kernel's nat expression does.
202
+ pub(super) translation: Option<Translation>,
188
203
  }
189
- /// The compiled table: rules per chain and element keys per set.
204
+ /// Source (0) or destination (1) NAT to an address and, when the rule names
205
+ /// them, an inclusive port range.
206
+ #[derive(Debug, PartialEq, Eq)]
207
+ pub(super) struct Translation {
208
+ pub(super) kind: u32,
209
+ pub(super) address: [u8; 4],
210
+ pub(super) ports: Option<(u16, u16)>,
211
+ }
212
+ /// The compiled table: rules per chain, element keys per exact set, and the
213
+ /// fields and elements of every interval set or map.
190
214
  struct Table {
191
215
  chains: BTreeMap<String, Vec<Vec<Attr>>>,
192
216
  sets: BTreeMap<String, Vec<Vec<u8>>>,
217
+ intervals: BTreeMap<String, Interval>,
193
218
  }
194
- fn chains(prepared: &Prepared) -> BTreeMap<String, Vec<Vec<Attr>>> {
219
+ /// Field byte lengths, then `(first, last, data)` per element.
220
+ #[derive(Default)]
221
+ struct Interval {
222
+ fields: Vec<usize>,
223
+ elements: Vec<(Vec<u8>, Vec<u8>, Option<Vec<u8>>)>,
224
+ }
225
+ impl Interval {
226
+ /// Every field of the key within its bounds, compared as the kernel's
227
+ /// pipapo backend does: byte strings of the field's own length.
228
+ fn find(&self, key: &[u8]) -> Option<&(Vec<u8>, Vec<u8>, Option<Vec<u8>>)> {
229
+ self.elements.iter().find(|(first, last, _)| {
230
+ let mut at = 0;
231
+ self.fields.iter().all(|length| {
232
+ let range = at..at + length;
233
+ at += length.div_ceil(4) * 4;
234
+ first[range.clone()] <= key[range.clone()] && key[range.clone()] <= last[range]
235
+ })
236
+ })
237
+ }
238
+ fn width(&self) -> usize {
239
+ self.fields.iter().map(|length| length.div_ceil(4) * 4).sum()
240
+ }
241
+ }
242
+ pub(super) fn chains(prepared: &Prepared) -> BTreeMap<String, Vec<Vec<Attr>>> {
195
243
  table(prepared).chains
196
244
  }
197
245
  fn table(prepared: &Prepared) -> Table {
198
246
  let mut chains: BTreeMap<String, Vec<Vec<Attr>>> = BTreeMap::new();
199
247
  let mut sets: BTreeMap<String, Vec<Vec<u8>>> = BTreeMap::new();
248
+ let mut intervals: BTreeMap<String, Interval> = BTreeMap::new();
200
249
  for (kind, attributes) in prepared.program("snft_host_grant").unwrap() {
201
250
  match kind {
202
251
  3 => {
@@ -209,21 +258,59 @@ fn table(prepared: &Prepared) -> Table {
209
258
  .or_default()
210
259
  .push(wire::attrs(&wire::one(&attributes, 4).unwrap().value).unwrap()),
211
260
  9 => {
212
- assert!(sets
213
- .insert(wire::text(&attributes, 2).unwrap(), Vec::new())
214
- .is_none());
261
+ let name = wire::text(&attributes, 2).unwrap();
262
+ if let Ok(description) = wire::one(&attributes, 9) {
263
+ let concatenation = wire::attrs(&description.value).unwrap();
264
+ let fields = wire::attrs(&wire::one(&concatenation, 2).unwrap().value)
265
+ .unwrap()
266
+ .iter()
267
+ .map(|field| {
268
+ wire::number(&wire::attrs(&field.value).unwrap(), 1).unwrap() as usize
269
+ })
270
+ .collect();
271
+ assert!(intervals
272
+ .insert(name, Interval { fields, elements: Vec::new() })
273
+ .is_none());
274
+ } else if wire::number(&attributes, 3).unwrap() & 8 != 0 {
275
+ // An exact map is one field of the whole key.
276
+ let fields = vec![wire::number(&attributes, 5).unwrap() as usize];
277
+ assert!(intervals
278
+ .insert(name, Interval { fields, elements: Vec::new() })
279
+ .is_none());
280
+ } else {
281
+ assert!(sets.insert(name, Vec::new()).is_none());
282
+ }
215
283
  }
216
284
  12 => {
217
- let keys = sets.get_mut(&wire::text(&attributes, 2).unwrap()).unwrap();
285
+ let name = wire::text(&attributes, 2).unwrap();
218
286
  for element in wire::attrs(&wire::one(&attributes, 3).unwrap().value).unwrap() {
219
287
  let key = wire::attrs(&element.value).unwrap();
220
- keys.push(data(&key, 1));
288
+ if let Some(interval) = intervals.get_mut(&name) {
289
+ // An element without a last key is one exact key.
290
+ let first = data(&key, 1);
291
+ let last = if wire::one(&key, 10).is_ok() {
292
+ data(&key, 10)
293
+ } else {
294
+ first.clone()
295
+ };
296
+ interval.elements.push((
297
+ first,
298
+ last,
299
+ wire::one(&key, 2).ok().map(|_| data(&key, 2)),
300
+ ));
301
+ } else {
302
+ sets.get_mut(&name).unwrap().push(data(&key, 1));
303
+ }
221
304
  }
222
305
  }
223
306
  other => panic!("operation {other}"),
224
307
  }
225
308
  }
226
- Table { chains, sets }
309
+ Table {
310
+ chains,
311
+ sets,
312
+ intervals,
313
+ }
227
314
  }
228
315
  fn data(attributes: &[Attr], id: u16) -> Vec<u8> {
229
316
  wire::attrs(&wire::one(attributes, id).unwrap().value).unwrap()[0]
@@ -246,6 +333,7 @@ fn load(flow: &Flow, name: &str, fields: &[Attr]) -> Option<Vec<u8>> {
246
333
  Some(match name {
247
334
  "meta" => match wire::number(fields, 2).unwrap() {
248
335
  4 => interface(flow.input, false),
336
+ 10 => flow.uid?.to_ne_bytes().to_vec(),
249
337
  5 => interface(flow.output, false),
250
338
  6 => interface(flow.input, true),
251
339
  7 => interface(flow.output, true),
@@ -280,7 +368,7 @@ fn load(flow: &Flow, name: &str, fields: &[Attr]) -> Option<Vec<u8>> {
280
368
  10 => vec![flow.protocol],
281
369
  11 => source_port.to_be_bytes().to_vec(),
282
370
  12 => destination_port.to_be_bytes().to_vec(),
283
- 13 => vec![0; 16],
371
+ 13 => flow.label.to_vec(),
284
372
  17 => flow.zone.to_ne_bytes().to_vec(),
285
373
  19 => source.to_vec(),
286
374
  20 => destination.to_vec(),
@@ -311,6 +399,7 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
311
399
  let mut registers = [0_u8; 80];
312
400
  let mut matched = true;
313
401
  let mut verdict: Option<(i32, Option<String>)> = None;
402
+ let mut translation = None;
314
403
  for expression in expressions {
315
404
  let parts = wire::attrs(&expression.value).unwrap();
316
405
  let name = wire::text(&parts, 1).unwrap();
@@ -340,10 +429,14 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
340
429
  }
341
430
  }
342
431
  "cmp" => {
343
- assert_eq!(wire::number(&fields, 2).unwrap(), 0);
344
432
  let expected = data(&fields, 3);
345
433
  let at = offset(&fields, 1);
346
- matched = registers[at..at + expected.len()] == expected[..];
434
+ let equal = registers[at..at + expected.len()] == expected[..];
435
+ matched = match wire::number(&fields, 2).unwrap() {
436
+ 0 => equal,
437
+ 1 => !equal,
438
+ operation => panic!("cmp {operation}"),
439
+ };
347
440
  }
348
441
  "range" => {
349
442
  assert_eq!(wire::number(&fields, 2).unwrap(), 0);
@@ -353,12 +446,29 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
353
446
  matched = loaded >= &first[..] && loaded <= &last[..];
354
447
  }
355
448
  "lookup" => {
356
- assert_eq!(wire::number(&fields, 5).unwrap(), 0);
357
- let keys = &table.sets[&wire::text(&fields, 1).unwrap()];
449
+ let inverted = wire::number(&fields, 5).unwrap() == 1;
450
+ let name = wire::text(&fields, 1).unwrap();
358
451
  let at = offset(&fields, 2);
359
- matched = keys
360
- .iter()
361
- .any(|key| registers[at..at + key.len()] == key[..]);
452
+ if let Some(interval) = table.intervals.get(&name) {
453
+ let key = registers[at..at + interval.width()].to_vec();
454
+ let found = interval.find(&key);
455
+ matched = found.is_some();
456
+ if let (Some((_, _, data)), Ok(_)) = (found, wire::one(&fields, 3)) {
457
+ // A map lookup stores the element's data.
458
+ let data = data.as_ref().unwrap();
459
+ let to = offset(&fields, 3);
460
+ registers[to..to + data.len()].copy_from_slice(data);
461
+ }
462
+ } else {
463
+ let keys = &table.sets[&name];
464
+ matched = keys
465
+ .iter()
466
+ .any(|key| registers[at..at + key.len()] == key[..]);
467
+ }
468
+ if inverted {
469
+ assert!(wire::one(&fields, 3).is_err());
470
+ matched = !matched;
471
+ }
362
472
  }
363
473
  "immediate" if wire::number(&fields, 1).unwrap() == 0 => {
364
474
  let code = wire::attrs(
@@ -380,7 +490,23 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
380
490
  let at = offset(&fields, 1);
381
491
  registers[at..at + value.len()].copy_from_slice(&value);
382
492
  }
383
- "nat" => {}
493
+ "nat" => {
494
+ let at = offset(&fields, 3);
495
+ let port = |id: u16| {
496
+ let at = offset(&fields, id);
497
+ u16::from_be_bytes(registers[at..at + 2].try_into().unwrap())
498
+ };
499
+ translation = Some(Translation {
500
+ kind: wire::number(&fields, 1).unwrap(),
501
+ address: registers[at..at + 4].try_into().unwrap(),
502
+ ports: wire::one(&fields, 5).ok().map(|_| (port(5), port(6))),
503
+ });
504
+ verdict = Some((1, None));
505
+ }
506
+ "reject" => {
507
+ assert_eq!(fields, vec![Attr::u32(1, 1)]);
508
+ verdict = Some((REJECT, None));
509
+ }
384
510
  other => panic!("{other}"),
385
511
  }
386
512
  if !matched {
@@ -391,11 +517,12 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
391
517
  continue;
392
518
  }
393
519
  match verdict {
394
- Some((code @ (0 | 1), _)) => {
520
+ Some((code @ (0 | 1 | REJECT), _)) => {
395
521
  return Step::Final(Decision {
396
522
  verdict: code,
397
523
  chain: chain.into(),
398
524
  zone: *zone,
525
+ translation,
399
526
  })
400
527
  }
401
528
  Some((-5, _)) => return Step::Next,
@@ -412,14 +539,35 @@ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
412
539
  Step::Next
413
540
  }
414
541
  /// A base chain that ends without a terminal verdict accepts by policy.
415
- fn decide(prepared: &Prepared, chain: &str, flow: &Flow) -> Decision {
542
+ pub(super) fn decide(prepared: &Prepared, chain: &str, flow: &Flow) -> Decision {
543
+ Simulator::new(prepared).decide(chain, flow)
544
+ }
545
+ /// One compiled table, parsed once for many decisions.
546
+ pub(super) struct Simulator {
547
+ table: Table,
548
+ }
549
+ impl Simulator {
550
+ pub(super) fn new(prepared: &Prepared) -> Self {
551
+ Self {
552
+ table: table(prepared),
553
+ }
554
+ }
555
+ pub(super) fn has(&self, chain: &str) -> bool {
556
+ self.table.chains.contains_key(chain)
557
+ }
558
+ pub(super) fn decide(&self, chain: &str, flow: &Flow) -> Decision {
559
+ decide_in(&self.table, chain, flow)
560
+ }
561
+ }
562
+ fn decide_in(table: &Table, chain: &str, flow: &Flow) -> Decision {
416
563
  let mut zone = 0;
417
- match run(&table(prepared), chain, flow, &mut zone) {
564
+ match run(table, chain, flow, &mut zone) {
418
565
  Step::Final(decision) => decision,
419
566
  Step::Next => Decision {
420
567
  verdict: 1,
421
568
  chain: "policy".into(),
422
569
  zone,
570
+ translation: None,
423
571
  },
424
572
  }
425
573
  }
@@ -793,7 +941,7 @@ fn host_grants_reject_ranges_wildcards_and_unbound_addresses() {
793
941
  }
794
942
 
795
943
  /// `count` distinct exact grants to the fixture workload, both protocols.
796
- fn many(count: usize) -> Value {
944
+ pub(super) fn many(count: usize) -> Value {
797
945
  json!((0..count)
798
946
  .map(|index| host_grant(
799
947
  if index % 2 == 0 { "tcp" } else { "udp" },
@@ -817,7 +965,8 @@ fn host_grants_compile_a_constant_rule_graph_and_one_element_per_grant() {
817
965
  if let Some(arrival) = all.sets.get("host_grant_arrival") {
818
966
  assert_eq!(arrival.len(), 1024);
819
967
  }
820
- assert!(table(&prepared(fixture)).sets.is_empty());
968
+ let without = table(&prepared(fixture));
969
+ assert!(without.sets.keys().all(|name| !name.starts_with("host_grant")));
821
970
  }
822
971
  }
823
972
 
@@ -899,7 +1048,7 @@ fn host_grants_meet_the_atomic_reserve_at_the_contract_maximum() {
899
1048
  };
900
1049
  let (graph, elements) = (size(&[3, 6, 9]), size(&[12]));
901
1050
  assert!(
902
- program.len() < 768 && graph <= 100_000 && elements <= super::compile::ELEMENT_BYTES
1051
+ program.len() < 768 && graph <= super::compile::RULE_BYTES && graph + elements <= super::compile::TARGET_BYTES
903
1052
  );
904
1053
  assert!(deletion + graph + elements + 64 <= crate::wire::BATCH_BYTES);
905
1054
  // The contract maximum is the validation bound.
@@ -911,7 +1060,7 @@ fn host_grants_meet_the_atomic_reserve_at_the_contract_maximum() {
911
1060
  );
912
1061
  }
913
1062
  // The reserve itself: a maximum previous graph beside a maximum target.
914
- assert!(deletion + 100_000 + super::compile::ELEMENT_BYTES + 64 <= crate::wire::BATCH_BYTES);
1063
+ assert!(deletion + super::compile::TARGET_BYTES + 64 <= crate::wire::BATCH_BYTES);
915
1064
  }
916
1065
 
917
1066
  /// A status carries up to three complete policies: the applied one and the
@@ -0,0 +1,204 @@
1
+ //! Host-local platform endpoints in host transit: leased flows from a handoff
2
+ //! into a platform endpoint served on a host address, and nothing else.
3
+ use super::hostgrant_tests::{
4
+ bytes, chains, decide, normalized, opening, prepared, Flow, ACK, ESTABLISHED,
5
+ };
6
+ use super::tests::host;
7
+ use serde_json::{json, Value};
8
+
9
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
10
+ const UPLINK: [u8; 4] = [192, 0, 2, 2];
11
+ const HANDOFF: [u8; 4] = [10, 240, 0, 1];
12
+ const LINK: Option<(u32, &str)> = Some((4, "handoff"));
13
+
14
+ /// The host fixture with two platform endpoints on host addresses: a TCP hub
15
+ /// on the uplink address and a UDP store on the handoff address.
16
+ pub(crate) fn served(mut value: Value) -> Value {
17
+ value["scope"]["protection"]["platformEndpoints"]
18
+ .as_array_mut()
19
+ .unwrap()
20
+ .extend([
21
+ json!({"id":"hub","address":"192.0.2.2","protocol":"tcp","port":8443}),
22
+ json!({"id":"store","address":"10.240.0.1","protocol":"udp","port":7000}),
23
+ ]);
24
+ value
25
+ }
26
+ pub(crate) fn local(mut value: Value, ids: Value) -> Value {
27
+ value["scope"]["localPlatformEndpoints"] = ids;
28
+ value
29
+ }
30
+ fn exact() -> Value {
31
+ local(served(host()), json!(["store", "hub"]))
32
+ }
33
+ /// A leased opening from the transit source address arriving on the handoff.
34
+ fn arrival(protocol: u8, source_port: u16, destination: [u8; 4], port: u16) -> Flow {
35
+ Flow {
36
+ input: LINK,
37
+ output: None,
38
+ protocol,
39
+ source: TRANSIT,
40
+ destination,
41
+ source_port,
42
+ destination_port: port,
43
+ original: (TRANSIT, destination, source_port, port),
44
+ ..opening(LINK, None)
45
+ }
46
+ }
47
+ /// The host's answer to `flow`, leaving on the handoff.
48
+ fn answer(flow: Flow) -> Flow {
49
+ Flow {
50
+ input: None,
51
+ output: LINK,
52
+ source: flow.destination,
53
+ destination: flow.source,
54
+ source_port: flow.destination_port,
55
+ destination_port: flow.source_port,
56
+ tcp_flags: ACK,
57
+ state: ESTABLISHED,
58
+ reply: true,
59
+ ..flow
60
+ }
61
+ }
62
+
63
+ #[test]
64
+ fn local_platform_endpoints_absent_and_empty_keep_the_host_digest_and_compiled_bytes() {
65
+ let baseline = prepared(host());
66
+ let empty = prepared(local(host(), json!([])));
67
+ assert_eq!(empty, baseline);
68
+ assert_eq!(bytes(&empty), bytes(&baseline));
69
+ let with = prepared(exact());
70
+ assert!(serde_json::to_string(&with.policy)
71
+ .unwrap()
72
+ .contains("\"localPlatformEndpoints\":[\"hub\",\"store\"]"));
73
+ // Canonical order.
74
+ assert_eq!(
75
+ prepared(local(served(host()), json!(["hub", "store"]))),
76
+ with
77
+ );
78
+ // The endpoints and the chains exist only when declared.
79
+ let graph = chains(&with);
80
+ assert!(
81
+ graph.contains_key("local_platform_in")
82
+ && !chains(&baseline).contains_key("local_platform_in")
83
+ );
84
+ }
85
+
86
+ #[test]
87
+ fn local_platform_endpoint_on_a_host_address_is_refused_unless_declared() {
88
+ // The refusal stays for every undeclared endpoint on a host address.
89
+ assert!(normalized(served(host())).is_err());
90
+ assert!(normalized(local(served(host()), json!(["hub"]))).is_err());
91
+ assert!(normalized(local(served(host()), json!(["store"]))).is_err());
92
+ assert!(normalized(exact()).is_ok());
93
+ for invalid in [
94
+ // Unknown, duplicate, or an endpoint the host does not hold.
95
+ json!(["hub", "store", "missing"]),
96
+ json!(["hub", "store", "hub"]),
97
+ json!(["hub", "store", "dns"]),
98
+ json!(["hub", "store", "relay"]),
99
+ json!(["hub", "store", ""]),
100
+ json!(["hub", "store", 1]),
101
+ json!("hub"),
102
+ json!({"hub": true}),
103
+ ] {
104
+ assert!(
105
+ normalized(local(served(host()), invalid.clone())).is_err(),
106
+ "{invalid}"
107
+ );
108
+ }
109
+ }
110
+
111
+ #[test]
112
+ fn local_platform_endpoint_admits_only_leased_exact_untranslated_flows_and_their_replies() {
113
+ let baseline = prepared(host());
114
+ let with = prepared(exact());
115
+ let tcp = arrival(6, 10500, UPLINK, 8443);
116
+ let udp = arrival(17, 10500, HANDOFF, 7000);
117
+ let decided = |flow: &Flow| {
118
+ let chain = if flow.input.is_some() {
119
+ "input"
120
+ } else {
121
+ "output"
122
+ };
123
+ (
124
+ decide(&baseline, chain, flow).verdict,
125
+ decide(&with, chain, flow).verdict,
126
+ )
127
+ };
128
+ // Every admitted packet: the TCP opening, established TCP, the UDP opening,
129
+ // and both replies. The baseline denies each at the handoff.
130
+ for flow in [
131
+ tcp,
132
+ Flow {
133
+ state: ESTABLISHED,
134
+ tcp_flags: ACK,
135
+ ..tcp
136
+ },
137
+ udp,
138
+ answer(tcp),
139
+ answer(udp),
140
+ ] {
141
+ assert_eq!(decided(&flow), (0, 1));
142
+ }
143
+ // Everything else keeps the baseline decision: a source port outside the
144
+ // leased range of the protocol, another source address, another port or
145
+ // protocol, a flow translated in transit, another zone, a TCP opening
146
+ // without SYN, and a host-origin opening toward the transit address.
147
+ for flow in [
148
+ arrival(6, 20000, UPLINK, 8443),
149
+ arrival(6, 9999, UPLINK, 8443),
150
+ arrival(6, 10500, UPLINK, 8444),
151
+ arrival(17, 10500, UPLINK, 8443),
152
+ arrival(6, 10500, HANDOFF, 7000),
153
+ arrival(17, 10500, HANDOFF, 7001),
154
+ Flow {
155
+ source: [10, 240, 0, 3],
156
+ original: ([10, 240, 0, 3], UPLINK, 10500, 8443),
157
+ ..tcp
158
+ },
159
+ Flow {
160
+ original: (TRANSIT, HANDOFF, 10500, 9000),
161
+ ..tcp
162
+ },
163
+ Flow { zone: 7, ..tcp },
164
+ Flow {
165
+ tcp_flags: ACK,
166
+ ..tcp
167
+ },
168
+ Flow {
169
+ reply: false,
170
+ state: 8,
171
+ tcp_flags: 0x02,
172
+ original: (UPLINK, TRANSIT, 8443, 10500),
173
+ ..answer(tcp)
174
+ },
175
+ Flow {
176
+ state: 8,
177
+ ..answer(tcp)
178
+ },
179
+ ] {
180
+ let (before, after) = decided(&flow);
181
+ assert_eq!(after, before, "{:?}", flow.original);
182
+ }
183
+ // A translated arrival that the source range admits is still refused: the
184
+ // original tuple is part of every check.
185
+ assert_eq!(
186
+ decided(&Flow {
187
+ original: (TRANSIT, HANDOFF, 10500, 9000),
188
+ ..tcp
189
+ }),
190
+ (0, 0)
191
+ );
192
+ // Forwarded traffic and the uplink keep their exact previous rules. (The
193
+ // protected chains list every platform endpoint, served here or not.)
194
+ let graph = chains(&with);
195
+ let reference = chains(&baseline);
196
+ for name in ["forward", "post"] {
197
+ assert_eq!(graph[name], reference[name], "{name}");
198
+ }
199
+ // One jump per leased range per direction, ahead of the handoff denials.
200
+ assert_eq!(graph["input"].len(), reference["input"].len() + 2);
201
+ assert_eq!(graph["output"].len(), reference["output"].len() + 2);
202
+ assert_eq!(graph["local_platform_in"].len(), 4);
203
+ assert_eq!(graph["local_platform_back"].len(), 2);
204
+ }