@push.rocks/smartnftables 2.6.0 → 4.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 (44) hide show
  1. package/changelog.md +33 -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 -4
  8. package/dist_ts/classes.managednftables.d.ts +14 -2
  9. package/dist_ts/classes.managednftables.js +48 -8
  10. package/dist_ts/managed.egress.types.d.ts +25 -3
  11. package/dist_ts/managed.types.d.ts +14 -0
  12. package/package.json +7 -7
  13. package/readme.md +262 -64
  14. package/rust/src/docker.policy.rs +2 -1
  15. package/rust/src/egress.compile.rs +153 -76
  16. package/rust/src/egress.host.rs +24 -51
  17. package/rust/src/egress.hostgrant.rs +1 -1
  18. package/rust/src/egress.keys.rs +305 -0
  19. package/rust/src/egress.leased.rs +399 -0
  20. package/rust/src/egress.private.rs +197 -0
  21. package/rust/src/egress.published.rs +370 -0
  22. package/rust/src/egress.router.rs +61 -294
  23. package/rust/src/egress.rs +140 -19
  24. package/rust/src/egress_hostgrant_tests.rs +158 -20
  25. package/rust/src/egress_localport_tests.rs +11 -2
  26. package/rust/src/egress_publishedrange_tests.rs +812 -0
  27. package/rust/src/egress_routerequivalence_tests.rs +448 -0
  28. package/rust/src/egress_tests.rs +289 -230
  29. package/rust/src/egress_workloadgrant_tests.rs +21 -13
  30. package/rust/src/main.rs +57 -3
  31. package/rust/src/owner.rs +69 -14
  32. package/rust/src/owner_host_traffic_tests.rs +4 -0
  33. package/rust/src/owner_identity_tests.rs +166 -80
  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 +6 -0
  38. package/rust/src/policy.rs +30 -29
  39. package/rust/src/tests.rs +100 -0
  40. package/ts/00_commitinfo_data.ts +1 -1
  41. package/ts/classes.manageddockerforwarding.ts +2 -3
  42. package/ts/classes.managednftables.ts +46 -8
  43. package/ts/managed.egress.types.ts +25 -3
  44. package/ts/managed.types.ts +17 -0
@@ -0,0 +1,812 @@
1
+ //! Published port ranges and symmetric publications on both hops: a range is one
2
+ //! interval match that keeps every port, and a symmetric publication lets its
3
+ //! workload open flows from its published ports toward unprotected space only.
4
+ use super::hostgrant_tests::{
5
+ bytes, decide, normalized, prepared, Flow, Translation, ACK, ESTABLISHED,
6
+ };
7
+ use super::tests::{published_host, published_port, published_router, router_published_port};
8
+ use super::Prepared;
9
+ use serde_json::{json, Value};
10
+
11
+ const NEW: u32 = 8;
12
+ const SYN: u8 = 0x02;
13
+ const CLIENT: [u8; 4] = [198, 51, 100, 7];
14
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
15
+ const UPLINK: [u8; 4] = [192, 0, 2, 2];
16
+ const WORK: [u8; 4] = [10, 241, 0, 2];
17
+ const HANDOFF: Option<(u32, &str)> = Some((4, "handoff"));
18
+ const ENS18: Option<(u32, &str)> = Some((2, "ens18"));
19
+ const WORK_LINK: Option<(u32, &str)> = Some((2, "work"));
20
+ /// The fixture ranges: first, middle and last port, then the ports just outside.
21
+ const INSIDE: [u16; 3] = [20000, 20100, 20200];
22
+ const OUTSIDE: [u16; 2] = [19999, 20201];
23
+
24
+ /// A published uplink range `first..=end` that keeps every port.
25
+ fn host_range(protocol: &str, first: u16, end: u16) -> Value {
26
+ let mut value = published_port(protocol, first, first);
27
+ value["hostPortEnd"] = json!(end);
28
+ value
29
+ }
30
+ /// A published transit range `first..=end` that keeps every port.
31
+ fn router_range(protocol: &str, first: u16, end: u16) -> Value {
32
+ let mut value = router_published_port(protocol, first, first);
33
+ value["transitPortEnd"] = json!(end);
34
+ value
35
+ }
36
+ fn symmetric(mut value: Value) -> Value {
37
+ value["symmetric"] = json!(true);
38
+ value
39
+ }
40
+ fn operations(prepared: &Prepared) -> usize {
41
+ prepared.program("snft_published_range").unwrap().len()
42
+ }
43
+ use super::tests::cost;
44
+ /// One packet of a default-zone flow; `original` is the tracked original tuple.
45
+ fn packet(
46
+ input: Option<(u32, &'static str)>,
47
+ output: Option<(u32, &'static str)>,
48
+ protocol: u8,
49
+ (source, source_port): ([u8; 4], u16),
50
+ (destination, destination_port): ([u8; 4], u16),
51
+ original: ([u8; 4], [u8; 4], u16, u16),
52
+ ) -> Flow {
53
+ Flow {
54
+ input,
55
+ output,
56
+ protocol,
57
+ source,
58
+ destination,
59
+ source_port,
60
+ destination_port,
61
+ tcp_flags: SYN,
62
+ state: NEW,
63
+ reply: false,
64
+ zone: 0,
65
+ original,
66
+ uid: None,
67
+ label: [0; 16],
68
+ }
69
+ }
70
+ /// The answer to an original-direction packet on the reverse links.
71
+ fn reply(flow: Flow) -> Flow {
72
+ Flow {
73
+ input: flow.output,
74
+ output: flow.input,
75
+ source: flow.destination,
76
+ destination: flow.source,
77
+ source_port: flow.destination_port,
78
+ destination_port: flow.source_port,
79
+ tcp_flags: ACK,
80
+ state: ESTABLISHED,
81
+ reply: true,
82
+ ..flow
83
+ }
84
+ }
85
+ fn snat(address: [u8; 4], first: u16, last: u16) -> Option<Translation> {
86
+ Some(Translation {
87
+ kind: 0,
88
+ address,
89
+ ports: Some((first, last)),
90
+ })
91
+ }
92
+
93
+ #[test]
94
+ fn absent_range_and_symmetric_fields_keep_the_exact_policy_digest_and_bytes() {
95
+ // The single-port goldens of the host and router publication tests were
96
+ // frozen before these fields existed and still hold; here the explicit
97
+ // `symmetric: false` is the same canonical policy as the absent field.
98
+ for (fixture, entry) in [
99
+ (
100
+ published_host as fn(Value) -> Value,
101
+ published_port("udp", 8125, 8125),
102
+ ),
103
+ (published_router, router_published_port("udp", 8125, 9125)),
104
+ ] {
105
+ let baseline = prepared(fixture(json!([entry.clone()])));
106
+ let mut explicit = entry.clone();
107
+ explicit["symmetric"] = json!(false);
108
+ let same = prepared(fixture(json!([explicit])));
109
+ assert_eq!(same, baseline);
110
+ assert_eq!(bytes(&same), bytes(&baseline));
111
+ let serialized = serde_json::to_string(&baseline.policy).unwrap();
112
+ for field in ["symmetric", "hostPortEnd", "transitPortEnd"] {
113
+ assert!(!serialized.contains(field), "{field}");
114
+ }
115
+ let changed = prepared(fixture(json!([symmetric(entry)])));
116
+ assert_ne!(changed.digest, baseline.digest);
117
+ assert!(serde_json::to_string(&changed.policy)
118
+ .unwrap()
119
+ .contains("\"symmetric\":true"));
120
+ }
121
+ }
122
+
123
+ #[test]
124
+ fn host_range_translates_every_port_by_address_and_costs_one_port() {
125
+ let single = prepared(published_host(json!([published_port("udp", 20000, 20000)])));
126
+ let ranged = prepared(published_host(json!([host_range("udp", 20000, 20200)])));
127
+ assert_eq!(operations(&ranged), operations(&single));
128
+ assert!(serde_json::to_string(&ranged.policy)
129
+ .unwrap()
130
+ .contains("\"hostPortEnd\":20200"));
131
+ for port in INSIDE {
132
+ let arrival = packet(
133
+ ENS18,
134
+ None,
135
+ 17,
136
+ (CLIENT, 40000),
137
+ (UPLINK, port),
138
+ (CLIENT, UPLINK, 40000, port),
139
+ );
140
+ // Address-only destination NAT keeps the port, and the client address.
141
+ assert_eq!(
142
+ decide(&ranged, "pre", &arrival).translation,
143
+ Some(Translation {
144
+ kind: 1,
145
+ address: TRANSIT,
146
+ ports: None,
147
+ }),
148
+ "{port}"
149
+ );
150
+ let translated = Flow {
151
+ output: HANDOFF,
152
+ destination: TRANSIT,
153
+ ..arrival
154
+ };
155
+ assert_eq!(decide(&ranged, "forward", &translated).verdict, 1, "{port}");
156
+ assert_eq!(
157
+ decide(&ranged, "forward", &reply(translated)).verdict,
158
+ 1,
159
+ "{port}"
160
+ );
161
+ }
162
+ for port in OUTSIDE {
163
+ let arrival = packet(
164
+ ENS18,
165
+ None,
166
+ 17,
167
+ (CLIENT, 40000),
168
+ (UPLINK, port),
169
+ (CLIENT, UPLINK, 40000, port),
170
+ );
171
+ assert_eq!(decide(&ranged, "pre", &arrival).translation, None, "{port}");
172
+ // Even a flow translated by another owner meets the capture barrier.
173
+ let translated = Flow {
174
+ output: HANDOFF,
175
+ destination: TRANSIT,
176
+ ..arrival
177
+ };
178
+ assert_eq!(decide(&ranged, "forward", &translated).verdict, 0, "{port}");
179
+ }
180
+ }
181
+
182
+ #[test]
183
+ fn router_range_classifies_translates_and_admits_every_port_and_costs_one_port() {
184
+ let single = prepared(published_router(json!([router_published_port(
185
+ "tcp", 20000, 20000
186
+ )])));
187
+ let ranged = prepared(published_router(json!([router_range("tcp", 20000, 20200)])));
188
+ assert_eq!(operations(&ranged), operations(&single));
189
+ for port in INSIDE {
190
+ let arrival = packet(
191
+ HANDOFF,
192
+ None,
193
+ 6,
194
+ (CLIENT, 40000),
195
+ (TRANSIT, port),
196
+ (CLIENT, TRANSIT, 40000, port),
197
+ );
198
+ let classified = decide(&ranged, "raw_in", &arrival);
199
+ assert_eq!((classified.verdict, classified.zone), (1, 0), "{port}");
200
+ assert_eq!(
201
+ decide(&ranged, "pre", &arrival).translation,
202
+ Some(Translation {
203
+ kind: 1,
204
+ address: WORK,
205
+ ports: None,
206
+ }),
207
+ "{port}"
208
+ );
209
+ let translated = Flow {
210
+ output: WORK_LINK,
211
+ destination: WORK,
212
+ ..arrival
213
+ };
214
+ assert_eq!(decide(&ranged, "forward", &translated).verdict, 1, "{port}");
215
+ let answer = reply(translated);
216
+ assert_eq!(decide(&ranged, "forward", &answer).verdict, 1, "{port}");
217
+ // The workload's published packets stay in the default zone ahead of the
218
+ // leased grant to any address on destination port 443.
219
+ let endpoint = decide(
220
+ &ranged,
221
+ "raw_in",
222
+ &Flow {
223
+ destination_port: 443,
224
+ ..answer
225
+ },
226
+ );
227
+ assert_eq!((endpoint.verdict, endpoint.zone), (1, 0), "{port}");
228
+ }
229
+ for port in OUTSIDE {
230
+ let arrival = packet(
231
+ HANDOFF,
232
+ None,
233
+ 6,
234
+ (CLIENT, 40000),
235
+ (TRANSIT, port),
236
+ (CLIENT, TRANSIT, 40000, port),
237
+ );
238
+ assert_eq!(decide(&ranged, "raw_in", &arrival).verdict, 0, "{port}");
239
+ assert_eq!(decide(&ranged, "pre", &arrival).translation, None, "{port}");
240
+ // Just outside the range the leased classifier still claims the workload.
241
+ let leased = decide(
242
+ &ranged,
243
+ "raw_in",
244
+ &packet(
245
+ WORK_LINK,
246
+ None,
247
+ 6,
248
+ (WORK, port),
249
+ (CLIENT, 443),
250
+ (WORK, CLIENT, port, 443),
251
+ ),
252
+ );
253
+ assert_eq!((leased.verdict, leased.zone), (1, 1), "{port}");
254
+ }
255
+ }
256
+
257
+ #[test]
258
+ fn router_symmetric_publications_open_from_their_ports_toward_unprotected_space_only() {
259
+ let ports = json!([
260
+ symmetric(router_range("udp", 20000, 20200)),
261
+ symmetric(router_published_port("udp", 5070, 5070)),
262
+ symmetric(router_published_port("tcp", 5070, 5070))
263
+ ]);
264
+ let published = prepared(published_router(ports.clone()));
265
+ let mut plain = ports;
266
+ for entry in plain.as_array_mut().unwrap() {
267
+ entry.as_object_mut().unwrap().remove("symmetric");
268
+ }
269
+ let plain = prepared(published_router(plain));
270
+ // An admission jump, a reply admission and a translation per symmetric
271
+ // publication, and one shared state chain: ESTABLISHED and a NEW opening
272
+ // per protocol.
273
+ // One map with its elements, four admissions and one translation, whatever
274
+ // the number of symmetric publications.
275
+ assert_eq!(operations(&published), operations(&plain) + 7);
276
+ let outbound = |protocol: u8, port: u16, destination: [u8; 4]| {
277
+ packet(
278
+ WORK_LINK,
279
+ HANDOFF,
280
+ protocol,
281
+ (WORK, port),
282
+ (destination, 5060),
283
+ (WORK, destination, port, 5060),
284
+ )
285
+ };
286
+ for (protocol, port, span) in [
287
+ (17, 20000, (20000, 20200)),
288
+ (17, 20100, (20000, 20200)),
289
+ (17, 20200, (20000, 20200)),
290
+ (17, 5070, (5070, 5070)),
291
+ (6, 5070, (5070, 5070)),
292
+ ] {
293
+ let flow = outbound(protocol, port, CLIENT);
294
+ // Without `symmetric` the flow from a published port is denied.
295
+ assert_eq!(decide(&plain, "forward", &flow).verdict, 0, "{port}");
296
+ assert_eq!(decide(&published, "forward", &flow).verdict, 1, "{port}");
297
+ let established = Flow {
298
+ state: ESTABLISHED,
299
+ tcp_flags: ACK,
300
+ ..flow
301
+ };
302
+ assert_eq!(decide(&published, "forward", &established).verdict, 1);
303
+ assert_eq!(decide(&published, "forward", &reply(flow)).verdict, 1);
304
+ // Source NAT to the transit address and the published port span, which
305
+ // keeps the workload's port.
306
+ assert_eq!(
307
+ decide(&published, "post", &flow).translation,
308
+ snat(TRANSIT, span.0, span.1),
309
+ "{port}"
310
+ );
311
+ // Both directions stay in the default zone in raw classification.
312
+ assert_eq!(decide(&published, "raw_in", &flow).zone, 0);
313
+ let answer = Flow {
314
+ destination: TRANSIT,
315
+ ..reply(flow)
316
+ };
317
+ let classified = decide(&published, "raw_in", &answer);
318
+ assert_eq!((classified.verdict, classified.zone), (1, 0), "{port}");
319
+ // The protected union stays denied: a protected current destination, a
320
+ // protected platform endpoint, and a protected original destination
321
+ // behind foreign destination NAT.
322
+ for denied in [
323
+ outbound(protocol, port, [192, 0, 2, 3]),
324
+ outbound(protocol, port, [203, 0, 113, 10]),
325
+ outbound(protocol, port, [10, 250, 0, 2]),
326
+ Flow {
327
+ original: (WORK, [192, 0, 2, 3], port, 5060),
328
+ ..flow
329
+ },
330
+ ] {
331
+ assert_eq!(decide(&published, "forward", &denied).verdict, 0, "{port}");
332
+ }
333
+ // A foreign zone, the reply direction and a changed original source never
334
+ // borrow the admission.
335
+ for foreign in [
336
+ Flow { zone: 7, ..flow },
337
+ Flow {
338
+ reply: true,
339
+ state: ESTABLISHED,
340
+ ..flow
341
+ },
342
+ Flow {
343
+ original: (WORK, CLIENT, port + 1000, 5060),
344
+ ..flow
345
+ },
346
+ ] {
347
+ assert_ne!(decide(&published, "forward", &foreign).verdict, 1, "{port}");
348
+ }
349
+ }
350
+ // A TCP opening needs SYN with FIN/RST/ACK clear.
351
+ let tcp = outbound(6, 5070, CLIENT);
352
+ assert_eq!(
353
+ decide(
354
+ &published,
355
+ "forward",
356
+ &Flow {
357
+ tcp_flags: ACK,
358
+ ..tcp
359
+ }
360
+ )
361
+ .verdict,
362
+ 0
363
+ );
364
+ // Ports just outside the range and beside the single port stay denied.
365
+ for (protocol, port) in [(17, 20201), (17, 19999), (6, 5071)] {
366
+ assert_eq!(
367
+ decide(&published, "forward", &outbound(protocol, port, CLIENT)).verdict,
368
+ 0,
369
+ "{port}"
370
+ );
371
+ }
372
+ }
373
+
374
+ #[test]
375
+ fn host_symmetric_publications_open_from_their_target_ports_toward_unprotected_space_only() {
376
+ let ports = json!([
377
+ symmetric(host_range("udp", 20000, 20200)),
378
+ symmetric(published_port("udp", 5070, 5070)),
379
+ symmetric(published_port("tcp", 5080, 6080)),
380
+ published_port("udp", 8125, 8125)
381
+ ]);
382
+ let published = prepared(published_host(ports.clone()));
383
+ let mut plain = ports;
384
+ for entry in plain.as_array_mut().unwrap() {
385
+ entry.as_object_mut().unwrap().remove("symmetric");
386
+ }
387
+ let plain = prepared(published_host(plain));
388
+ // One map with its elements, four admissions and one translation, whatever
389
+ // the number of symmetric publications.
390
+ assert_eq!(operations(&published), operations(&plain) + 7);
391
+ let outbound = |protocol: u8, port: u16, destination: [u8; 4]| {
392
+ packet(
393
+ HANDOFF,
394
+ ENS18,
395
+ protocol,
396
+ (TRANSIT, port),
397
+ (destination, 5060),
398
+ (TRANSIT, destination, port, 5060),
399
+ )
400
+ };
401
+ for (protocol, port, span) in [
402
+ (17, 20000, (20000, 20200)),
403
+ (17, 20100, (20000, 20200)),
404
+ (17, 20200, (20000, 20200)),
405
+ (17, 5070, (5070, 5070)),
406
+ // One port maps its target port to the published uplink port.
407
+ (6, 6080, (5080, 5080)),
408
+ ] {
409
+ let flow = outbound(protocol, port, CLIENT);
410
+ assert_eq!(decide(&plain, "forward", &flow).verdict, 0, "{port}");
411
+ assert_eq!(decide(&published, "forward", &flow).verdict, 1, "{port}");
412
+ assert_eq!(
413
+ decide(&published, "forward", &reply(flow)).verdict,
414
+ 1,
415
+ "{port}"
416
+ );
417
+ assert_eq!(
418
+ decide(&published, "post", &flow).translation,
419
+ snat(UPLINK, span.0, span.1),
420
+ "{port}"
421
+ );
422
+ for denied in [
423
+ outbound(protocol, port, [192, 0, 2, 3]),
424
+ outbound(protocol, port, [203, 0, 113, 10]),
425
+ Flow {
426
+ original: (TRANSIT, [192, 0, 2, 3], port, 5060),
427
+ ..flow
428
+ },
429
+ ] {
430
+ assert_eq!(decide(&published, "forward", &denied).verdict, 0, "{port}");
431
+ }
432
+ assert_eq!(
433
+ decide(&published, "forward", &Flow { zone: 7, ..flow }).verdict,
434
+ 0
435
+ );
436
+ }
437
+ for (protocol, port) in [(17, 8125), (17, 20201), (6, 5080)] {
438
+ assert_eq!(
439
+ decide(&published, "forward", &outbound(protocol, port, CLIENT)).verdict,
440
+ 0,
441
+ "{port}"
442
+ );
443
+ }
444
+ // Leased flows keep their own outer translation, without a port.
445
+ let leased = outbound(17, 10500, CLIENT);
446
+ assert_eq!(decide(&published, "forward", &leased).verdict, 1);
447
+ assert_eq!(
448
+ decide(&published, "post", &leased).translation,
449
+ Some(Translation {
450
+ kind: 0,
451
+ address: UPLINK,
452
+ ports: None,
453
+ })
454
+ );
455
+ }
456
+
457
+ #[test]
458
+ fn host_publications_refuse_ambiguous_ranges_and_symmetric_conflicts() {
459
+ let accepted = |ports: Value| normalized(published_host(ports)).is_ok();
460
+ assert!(accepted(json!([host_range("udp", 20000, 20200)])));
461
+ // Adjacent to the leased range and to another publication, and the same span
462
+ // for another protocol.
463
+ assert!(accepted(json!([
464
+ host_range("udp", 11000, 11100),
465
+ host_range("udp", 11101, 11200),
466
+ host_range("tcp", 11000, 11100)
467
+ ])));
468
+ // Two publications may share one target port while neither is symmetric.
469
+ assert!(accepted(json!([
470
+ published_port("tcp", 443, 8443),
471
+ published_port("tcp", 444, 8443)
472
+ ])));
473
+ // A target port inside a leased range stays valid for a plain publication.
474
+ assert!(accepted(json!([published_port("tcp", 443, 10500)])));
475
+ for (name, ports) in [
476
+ (
477
+ "range with a differing inside port",
478
+ json!([changed(
479
+ host_range("udp", 20000, 20200),
480
+ "/targetPort",
481
+ 21000
482
+ )]),
483
+ ),
484
+ ("inverted range", json!([host_range("udp", 20000, 19999)])),
485
+ ("one-port range", json!([host_range("udp", 20000, 20000)])),
486
+ (
487
+ "range end beyond 65535",
488
+ json!([beyond(host_range("udp", 20000, 20001), "/hostPortEnd")]),
489
+ ),
490
+ (
491
+ "overlap with the leased range start",
492
+ json!([host_range("udp", 9990, 10000)]),
493
+ ),
494
+ (
495
+ "overlap with the leased range end",
496
+ json!([host_range("tcp", 10999, 11005)]),
497
+ ),
498
+ (
499
+ "range overlapping a single port",
500
+ json!([
501
+ host_range("udp", 20000, 20200),
502
+ published_port("udp", 20100, 20100)
503
+ ]),
504
+ ),
505
+ (
506
+ "ranges sharing their boundary port",
507
+ json!([
508
+ host_range("udp", 20000, 20200),
509
+ host_range("udp", 20200, 20300)
510
+ ]),
511
+ ),
512
+ (
513
+ "symmetric target port inside a leased range",
514
+ json!([symmetric(published_port("tcp", 443, 10500))]),
515
+ ),
516
+ (
517
+ "symmetric target port shared with another publication",
518
+ json!([
519
+ symmetric(published_port("tcp", 443, 8443)),
520
+ published_port("tcp", 444, 8443)
521
+ ]),
522
+ ),
523
+ ] {
524
+ assert!(normalized(published_host(ports)).is_err(), "{name}");
525
+ }
526
+ }
527
+
528
+ #[test]
529
+ fn router_publications_refuse_ambiguous_ranges_and_symmetric_conflicts() {
530
+ let accepted = |ports: Value| normalized(published_router(ports)).is_ok();
531
+ assert!(accepted(json!([router_range("udp", 20000, 20200)])));
532
+ assert!(accepted(json!([
533
+ router_range("udp", 11000, 11100),
534
+ router_range("udp", 11101, 11200),
535
+ router_range("tcp", 11000, 11100)
536
+ ])));
537
+ assert!(accepted(json!([
538
+ router_published_port("tcp", 443, 8443),
539
+ router_published_port("tcp", 444, 8443)
540
+ ])));
541
+ for (name, ports) in [
542
+ (
543
+ "range with a differing inside port",
544
+ json!([changed(
545
+ router_range("udp", 20000, 20200),
546
+ "/endpointPort",
547
+ 21000
548
+ )]),
549
+ ),
550
+ ("inverted range", json!([router_range("udp", 20000, 19999)])),
551
+ ("one-port range", json!([router_range("udp", 20000, 20000)])),
552
+ (
553
+ "range end beyond 65535",
554
+ json!([beyond(router_range("udp", 20000, 20001), "/transitPortEnd")]),
555
+ ),
556
+ (
557
+ "overlap with the leased range start",
558
+ json!([router_range("udp", 9990, 10000)]),
559
+ ),
560
+ (
561
+ "overlap with the leased range end",
562
+ json!([router_range("tcp", 10999, 11005)]),
563
+ ),
564
+ (
565
+ "range overlapping a single port",
566
+ json!([
567
+ router_range("udp", 20000, 20200),
568
+ router_published_port("udp", 20100, 20100)
569
+ ]),
570
+ ),
571
+ (
572
+ "ranges sharing their boundary port",
573
+ json!([
574
+ router_range("udp", 20000, 20200),
575
+ router_range("udp", 20200, 20300)
576
+ ]),
577
+ ),
578
+ (
579
+ "symmetric endpoint port shared with another publication",
580
+ json!([
581
+ symmetric(router_published_port("tcp", 443, 8443)),
582
+ router_published_port("tcp", 444, 8443)
583
+ ]),
584
+ ),
585
+ (
586
+ "symmetric endpoint range overlapping another publication",
587
+ json!([
588
+ symmetric(router_range("udp", 20000, 20200)),
589
+ router_published_port("udp", 8125, 20150)
590
+ ]),
591
+ ),
592
+ ] {
593
+ assert!(normalized(published_router(ports)).is_err(), "{name}");
594
+ }
595
+ }
596
+
597
+ fn link_json(index: u32, name: &str, kind: &str, address: &str) -> Value {
598
+ json!({"interfaceIndex":index,"interfaceName":name,"interfaceKind":kind,
599
+ "macAddress":format!("02:00:00:00:{:02x}:{:02x}", index / 256, index % 256),
600
+ "interfaceLinkIndex":index + 1000,"requiredIpv4Addresses":[address]})
601
+ }
602
+ /// A Pallet-shaped node: `workloads` veth workloads behind one router, each with
603
+ /// local DNS on its gateway and public TCP and UDP egress when `leased`, grants
604
+ /// to the first `platforms` of four platform endpoints, and `published` of them
605
+ /// with one to three publications; the first carries the SIP shape.
606
+ pub(super) fn node(
607
+ workloads: u32,
608
+ leased: bool,
609
+ platforms: usize,
610
+ published: u32,
611
+ symmetric_all: bool,
612
+ ) -> (Value, Value) {
613
+ let catalog = [
614
+ ("dns", "192.0.2.1", "udp", 53),
615
+ ("registry", "192.0.2.10", "tcp", 443),
616
+ ("relay", "192.0.2.11", "tcp", 443),
617
+ ("metrics", "192.0.2.12", "tcp", 9100),
618
+ ];
619
+ let mut endpoints = Vec::new();
620
+ let mut links = Vec::new();
621
+ let mut rules = Vec::new();
622
+ let mut grants = Vec::new();
623
+ let mut router_ports = Vec::new();
624
+ let mut host_ports = Vec::new();
625
+ let mut next_port = 30000;
626
+ for index in 0..workloads {
627
+ let name = format!("w{index}");
628
+ let gateway = format!("10.241.{index}.1");
629
+ let address = format!("10.241.{index}.2");
630
+ endpoints.push(
631
+ json!({"id":name,"interfaceIndex":10 + index,"interfaceName":name,
632
+ "interfaceKind":"veth","sourcePrefixes":[format!("{address}/32")]}),
633
+ );
634
+ links.push(link_json(10 + index, &name, "veth", &gateway));
635
+ for protocol in ["tcp", "udp"].into_iter().filter(|_| leased) {
636
+ rules.push(json!({"sourceEndpoint":name,"destinationEndpoint":null,
637
+ "sourcePrefix":format!("{address}/32"),"destinationPrefix":format!("{gateway}/32"),
638
+ "protocol":protocol,"sourcePort":null,"destinationPort":53}));
639
+ rules.push(json!({"sourceEndpoint":null,"destinationEndpoint":name,
640
+ "sourcePrefix":format!("{gateway}/32"),"destinationPrefix":format!("{address}/32"),
641
+ "protocol":protocol,"sourcePort":53,"destinationPort":null}));
642
+ grants.push(json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
643
+ "destination":{"kind":"public","prefix":"0.0.0.0/0"},"protocol":protocol,
644
+ "sourcePort":null,"destinationPort":null,"sourcePortRange":{"first":10000,"last":10999}}));
645
+ }
646
+ for (id, _, protocol, port) in &catalog[..platforms] {
647
+ grants.push(json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
648
+ "destination":{"kind":"platform","endpointId":id},"protocol":protocol,
649
+ "sourcePort":null,"destinationPort":port,"sourcePortRange":{"first":10000,"last":10999}}));
650
+ }
651
+ if index >= published {
652
+ continue;
653
+ }
654
+ let shape: Vec<(&str, u16, Option<u16>)> = if index == 0 {
655
+ vec![
656
+ ("udp", 5070, None),
657
+ ("tcp", 5070, None),
658
+ ("udp", 20000, Some(20200)),
659
+ ]
660
+ } else {
661
+ (0..=(index % 3))
662
+ .map(|_| {
663
+ next_port += 1;
664
+ ("tcp", next_port, None)
665
+ })
666
+ .collect()
667
+ };
668
+ for (protocol, port, end) in shape {
669
+ let symmetric = symmetric_all || index == 0;
670
+ let mut routed = json!({"protocol":protocol,"transitPort":port,"endpointPort":port,
671
+ "endpointAddress":address,"transitSourceAddress":"10.240.0.2","symmetric":symmetric});
672
+ let mut hosted = json!({"protocol":protocol,"hostPort":port,"targetPort":port,
673
+ "targetAddress":"10.240.0.2","hostIp":"192.0.2.2","symmetric":symmetric});
674
+ if let Some(end) = end {
675
+ routed["transitPortEnd"] = json!(end);
676
+ hosted["hostPortEnd"] = json!(end);
677
+ }
678
+ router_ports.push(routed);
679
+ host_ports.push(hosted);
680
+ }
681
+ }
682
+ let protection = json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),
683
+ "prefixes":["10.0.0.0/8","192.0.2.0/24"],
684
+ "platformEndpoints":catalog[..platforms.max(3)]
685
+ .iter()
686
+ .map(|(id, address, protocol, port)| json!({"id":id,"address":address,"protocol":protocol,"port":port}))
687
+ .collect::<Vec<_>>()});
688
+ let ranges = json!([{"protocol":"tcp","first":10000,"last":10999},{"protocol":"udp","first":10000,"last":10999}]);
689
+ let lease = json!({"id":"lease-a","generation":1,"digest":format!("sha256:{}","a".repeat(64))});
690
+ let router = json!({"schemaVersion":2,"revision":1,"scope":{"kind":"routerEgress",
691
+ "endpoints":endpoints,"rules":rules,"links":links,
692
+ "handoff":link_json(4,"handoff","veth","10.240.0.2"),"protection":protection,
693
+ "generations":[{"lease":lease,"transitSourceAddress":"10.240.0.2","sourcePortRanges":ranges,
694
+ "conntrackZone":1,"conntrackLabel":"a".repeat(32),"grants":grants}],
695
+ "publishedPorts":router_ports}});
696
+ let host = json!({"schemaVersion":2,"revision":1,"scope":{"kind":"hostTransit","protection":protection,
697
+ "handoffs":[{"link":link_json(4,"handoff","veth","10.240.0.1"),
698
+ "allocations":[{"lease":lease,"transitSourceAddress":"10.240.0.2","sourcePortRanges":ranges}]}],
699
+ "uplink":link_json(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2",
700
+ "publishedPorts":host_ports}});
701
+ (router, host)
702
+ }
703
+ fn without_publications(mut value: Value) -> Value {
704
+ value["scope"]
705
+ .as_object_mut()
706
+ .unwrap()
707
+ .remove("publishedPorts");
708
+ value
709
+ }
710
+
711
+ /// The rule expressions of a prepared policy, without the digest stamps.
712
+ fn rule_expressions(value: Value) -> Vec<Vec<u8>> {
713
+ prepared(value)
714
+ .program("snft_published_range")
715
+ .unwrap()
716
+ .into_iter()
717
+ .filter(|(kind, _)| *kind == 6)
718
+ .map(|(_, attributes)| crate::wire::one(&attributes, 4).unwrap().value.clone())
719
+ .collect()
720
+ }
721
+
722
+ #[test]
723
+ fn a_realistic_node_costs_constant_rules_and_bounded_elements() {
724
+ // Every workload with local DNS, public TCP and UDP egress, grants to four
725
+ // platform endpoints and one to three publications, the first with the SIP
726
+ // shape. The router's rules are the same for every size; its elements, and
727
+ // the host's publications, grow with the node.
728
+ for workloads in [1, 8, 32, 64] {
729
+ let (router, host) = node(workloads, true, 4, workloads, false);
730
+ for (hop, value) in [("router", router), ("host", host)] {
731
+ let (operations, rule_bytes, element_bytes) = cost(&prepared(value));
732
+ assert!(operations < 768 && rule_bytes <= 100_000 && rule_bytes + element_bytes <= 212_000);
733
+ println!(
734
+ "NODE_COST workloads={workloads} {hop} operations={operations} rule_bytes={rule_bytes} element_bytes={element_bytes} target={}",
735
+ rule_bytes + element_bytes
736
+ );
737
+ }
738
+ }
739
+ let (eight, _) = node(8, true, 4, 8, false);
740
+ let (sixty_four, _) = node(64, true, 4, 64, false);
741
+ assert_eq!(rule_expressions(eight), rule_expressions(sixty_four));
742
+ // The largest such node the target budget holds; beyond it the policy is
743
+ // refused before any kernel work.
744
+ let fits = |workloads: u32| {
745
+ let (router, _) = node(workloads, true, 4, workloads, false);
746
+ serde_json::from_value::<super::Policy>(router)
747
+ .unwrap()
748
+ .prepare()
749
+ .is_ok()
750
+ };
751
+ let capacity = (64..=128).take_while(|workloads| fits(*workloads)).last().unwrap();
752
+ assert!(!fits(capacity + 1));
753
+ println!("NODE_COST capacity workloads={capacity}");
754
+ }
755
+
756
+ #[test]
757
+ fn publications_cost_constant_rules_on_a_node() {
758
+ // One publishing workload and eight compile exactly the same rules on both
759
+ // hops, whatever their publications.
760
+ let rules = |published: u32| {
761
+ let (router, host) = node(8, false, 0, published, true);
762
+ [rule_expressions(router), rule_expressions(host)]
763
+ };
764
+ assert_eq!(rules(1), rules(8));
765
+ }
766
+
767
+ #[test]
768
+ fn the_publication_maximum_fits_both_budgets() {
769
+ // The input maximum of 64 publications per scope, every one a symmetric
770
+ // range of 100 ports, spread over eight workloads.
771
+ let (mut router, mut host) = node(8, false, 0, 0, false);
772
+ router["scope"]["publishedPorts"] = json!((0..64u16)
773
+ .map(|index| {
774
+ let first = 20000 + index * 100;
775
+ symmetric(json!({"protocol":if index % 2 == 0 { "udp" } else { "tcp" },
776
+ "transitPort":first,"transitPortEnd":first + 99,"endpointPort":first,
777
+ "endpointAddress":format!("10.241.{}.2", index % 8),"transitSourceAddress":"10.240.0.2"}))
778
+ })
779
+ .collect::<Vec<_>>());
780
+ host["scope"]["publishedPorts"] = json!((0..64u16)
781
+ .map(|index| {
782
+ let first = 20000 + index * 100;
783
+ symmetric(
784
+ json!({"protocol":if index % 2 == 0 { "udp" } else { "tcp" },
785
+ "hostPort":first,"hostPortEnd":first + 99,"targetPort":first,
786
+ "targetAddress":"10.240.0.2","hostIp":"192.0.2.2"}),
787
+ )
788
+ })
789
+ .collect::<Vec<_>>());
790
+ for (hop, value) in [("router", router), ("host", host)] {
791
+ let bare = cost(&prepared(without_publications(value.clone())));
792
+ let (operations, rule_bytes, element_bytes) = cost(&prepared(value));
793
+ assert!(
794
+ operations <= 768 && rule_bytes <= 100_000 && element_bytes <= 100_000,
795
+ "{hop}"
796
+ );
797
+ println!(
798
+ "PUBLISHED_WORST {hop} publications=64 operations={operations} (+{}) rule_bytes={rule_bytes} (+{}) element_bytes={element_bytes}",
799
+ operations - bare.0,
800
+ rule_bytes - bare.1
801
+ );
802
+ }
803
+ }
804
+
805
+ fn changed(mut value: Value, path: &str, replacement: u16) -> Value {
806
+ *value.pointer_mut(path).unwrap() = json!(replacement);
807
+ value
808
+ }
809
+ fn beyond(mut value: Value, path: &str) -> Value {
810
+ *value.pointer_mut(path).unwrap() = json!(65536);
811
+ value
812
+ }