@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,948 @@
1
+ //! Host-origin exact-port grants: compile-level exactness across the three
2
+ //! scopes a host-to-workload flow crosses (allocation-pool guard and host
3
+ //! transit in the host namespace, router egress in the router namespace).
4
+ use super::tests::{host, pool_guard, router};
5
+ use super::{Policy, Prepared};
6
+ use crate::wire::{self, Attr};
7
+ use serde_json::{json, Value};
8
+ use std::collections::BTreeMap;
9
+
10
+ pub(crate) fn host_grant(protocol: &str, source: &str, destination: &str, port: u16) -> Value {
11
+ json!({"protocol":protocol,"sourceAddress":source,"destinationAddress":destination,
12
+ "destinationPort":port})
13
+ }
14
+ pub(crate) fn granted(mut value: Value, grants: Value) -> Value {
15
+ value["scope"]["hostGrants"] = grants;
16
+ value
17
+ }
18
+ fn normalized(value: Value) -> crate::Result<Policy> {
19
+ serde_json::from_value::<Policy>(value)
20
+ .map_err(|_| crate::Error::Invalid)?
21
+ .normalize()
22
+ }
23
+ fn prepared(value: Value) -> Prepared {
24
+ normalized(value).unwrap().prepare().unwrap()
25
+ }
26
+ fn bytes(prepared: &Prepared) -> Vec<u8> {
27
+ let mut result = Vec::new();
28
+ for (kind, attributes) in prepared.program("snft_host_grant").unwrap() {
29
+ result.extend(kind.to_be_bytes());
30
+ result.extend(wire::encode_attrs(&attributes));
31
+ }
32
+ result
33
+ }
34
+ /// The fixture flow: the host's transit address on the handoff dials the workload.
35
+ fn exact() -> Value {
36
+ json!([host_grant("tcp", "10.240.0.1", "10.241.0.2", 8080)])
37
+ }
38
+
39
+ /// One packet with the conntrack facts a filter hook observes.
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,
53
+ /// Original-direction conntrack tuple: source, destination, ports.
54
+ original: ([u8; 4], [u8; 4], u16, u16),
55
+ }
56
+ const HOST: [u8; 4] = [10, 240, 0, 1];
57
+ const WORK: [u8; 4] = [10, 241, 0, 2];
58
+ const NEW: u32 = 8;
59
+ const ESTABLISHED: u32 = 2;
60
+ const SYN: u8 = 0x02;
61
+ const ACK: u8 = 0x10;
62
+ fn opening(input: Option<(u32, &'static str)>, output: Option<(u32, &'static str)>) -> Flow {
63
+ Flow {
64
+ input,
65
+ output,
66
+ protocol: 6,
67
+ source: HOST,
68
+ destination: WORK,
69
+ source_port: 443,
70
+ destination_port: 8080,
71
+ tcp_flags: SYN,
72
+ state: NEW,
73
+ reply: false,
74
+ zone: 0,
75
+ original: (HOST, WORK, 443, 8080),
76
+ }
77
+ }
78
+ /// The workload's answer to `flow`, arriving on the reverse links.
79
+ fn answer(flow: Flow) -> Flow {
80
+ Flow {
81
+ input: flow.output,
82
+ output: flow.input,
83
+ source: flow.destination,
84
+ destination: flow.source,
85
+ source_port: flow.destination_port,
86
+ destination_port: flow.source_port,
87
+ tcp_flags: ACK,
88
+ state: ESTABLISHED,
89
+ reply: true,
90
+ ..flow
91
+ }
92
+ }
93
+ /// Every variant of an admitted flow that the grant does not name.
94
+ fn foreign(flow: Flow) -> Vec<Flow> {
95
+ let (source, destination, source_port, _) = flow.original;
96
+ let original = |value: (_, _, _, _)| {
97
+ if flow.reply {
98
+ (value.1, value.0, value.3, value.2)
99
+ } else {
100
+ value
101
+ }
102
+ };
103
+ let tuple = |changed: Flow| Flow {
104
+ original: original((
105
+ changed.source,
106
+ changed.destination,
107
+ changed.source_port,
108
+ changed.destination_port,
109
+ )),
110
+ ..changed
111
+ };
112
+ let mut result = vec![
113
+ // Another port, protocol, workload address and host source address.
114
+ if flow.reply {
115
+ tuple(Flow {
116
+ source_port: 8081,
117
+ ..flow
118
+ })
119
+ } else {
120
+ tuple(Flow {
121
+ destination_port: 8081,
122
+ ..flow
123
+ })
124
+ },
125
+ Flow {
126
+ protocol: 17,
127
+ ..flow
128
+ },
129
+ if flow.reply {
130
+ tuple(Flow {
131
+ source: [10, 241, 0, 3],
132
+ ..flow
133
+ })
134
+ } else {
135
+ tuple(Flow {
136
+ destination: [10, 241, 0, 3],
137
+ ..flow
138
+ })
139
+ },
140
+ if flow.reply {
141
+ tuple(Flow {
142
+ destination: [192, 0, 2, 2],
143
+ ..flow
144
+ })
145
+ } else {
146
+ tuple(Flow {
147
+ source: [192, 0, 2, 2],
148
+ ..flow
149
+ })
150
+ },
151
+ // Foreign destination NAT onto the granted tuple.
152
+ Flow {
153
+ original: (source, [203, 0, 113, 9], source_port, 80),
154
+ ..flow
155
+ },
156
+ Flow {
157
+ original: (source, destination, source_port, 80),
158
+ ..flow
159
+ },
160
+ // Another conntrack zone, the wrong direction, a non-SYN opening and an
161
+ // unreplied flow on the reply path.
162
+ Flow { zone: 7, ..flow },
163
+ Flow {
164
+ reply: !flow.reply,
165
+ ..flow
166
+ },
167
+ ];
168
+ result.push(if flow.reply {
169
+ Flow { state: NEW, ..flow }
170
+ } else {
171
+ Flow {
172
+ state: NEW,
173
+ tcp_flags: ACK,
174
+ ..flow
175
+ }
176
+ });
177
+ result
178
+ }
179
+
180
+ /// What one hook decides for a flow, following jumps and returns exactly as the
181
+ /// kernel does: the terminal verdict, the chain that reached it (`policy` for the
182
+ /// base chain's accept policy) and the conntrack zone a raw rule stored.
183
+ #[derive(Debug, PartialEq, Eq)]
184
+ struct Decision {
185
+ verdict: i32,
186
+ chain: String,
187
+ zone: u16,
188
+ }
189
+ /// The compiled table: rules per chain and element keys per set.
190
+ struct Table {
191
+ chains: BTreeMap<String, Vec<Vec<Attr>>>,
192
+ sets: BTreeMap<String, Vec<Vec<u8>>>,
193
+ }
194
+ fn chains(prepared: &Prepared) -> BTreeMap<String, Vec<Vec<Attr>>> {
195
+ table(prepared).chains
196
+ }
197
+ fn table(prepared: &Prepared) -> Table {
198
+ let mut chains: BTreeMap<String, Vec<Vec<Attr>>> = BTreeMap::new();
199
+ let mut sets: BTreeMap<String, Vec<Vec<u8>>> = BTreeMap::new();
200
+ for (kind, attributes) in prepared.program("snft_host_grant").unwrap() {
201
+ match kind {
202
+ 3 => {
203
+ chains
204
+ .entry(wire::text(&attributes, 3).unwrap())
205
+ .or_default();
206
+ }
207
+ 6 => chains
208
+ .entry(wire::text(&attributes, 2).unwrap())
209
+ .or_default()
210
+ .push(wire::attrs(&wire::one(&attributes, 4).unwrap().value).unwrap()),
211
+ 9 => {
212
+ assert!(sets
213
+ .insert(wire::text(&attributes, 2).unwrap(), Vec::new())
214
+ .is_none());
215
+ }
216
+ 12 => {
217
+ let keys = sets.get_mut(&wire::text(&attributes, 2).unwrap()).unwrap();
218
+ for element in wire::attrs(&wire::one(&attributes, 3).unwrap().value).unwrap() {
219
+ let key = wire::attrs(&element.value).unwrap();
220
+ keys.push(data(&key, 1));
221
+ }
222
+ }
223
+ other => panic!("operation {other}"),
224
+ }
225
+ }
226
+ Table { chains, sets }
227
+ }
228
+ fn data(attributes: &[Attr], id: u16) -> Vec<u8> {
229
+ wire::attrs(&wire::one(attributes, id).unwrap().value).unwrap()[0]
230
+ .value
231
+ .clone()
232
+ }
233
+ /// A missing device loads as index 0 and an empty name, exactly like Linux.
234
+ fn interface(value: Option<(u32, &'static str)>, name: bool) -> Vec<u8> {
235
+ let (index, text) = value.unwrap_or((0, ""));
236
+ if name {
237
+ let mut padded = text.as_bytes().to_vec();
238
+ padded.resize(16, 0);
239
+ padded
240
+ } else {
241
+ index.to_ne_bytes().to_vec()
242
+ }
243
+ }
244
+ /// None when a load cannot produce a value, which breaks the rule like the kernel.
245
+ fn load(flow: &Flow, name: &str, fields: &[Attr]) -> Option<Vec<u8>> {
246
+ Some(match name {
247
+ "meta" => match wire::number(fields, 2).unwrap() {
248
+ 4 => interface(flow.input, false),
249
+ 5 => interface(flow.output, false),
250
+ 6 => interface(flow.input, true),
251
+ 7 => interface(flow.output, true),
252
+ 15 => vec![libc::NFPROTO_IPV4 as u8],
253
+ 16 => vec![flow.protocol],
254
+ key => panic!("meta {key}"),
255
+ },
256
+ "payload" => {
257
+ let loaded = match (
258
+ wire::number(fields, 2).unwrap(),
259
+ wire::number(fields, 3).unwrap(),
260
+ ) {
261
+ (1, 12) => flow.source.to_vec(),
262
+ (1, 16) => flow.destination.to_vec(),
263
+ (2, 0) => flow.source_port.to_be_bytes().to_vec(),
264
+ (2, 2) => flow.destination_port.to_be_bytes().to_vec(),
265
+ (2, 13) if flow.protocol == 6 => vec![flow.tcp_flags],
266
+ (2, 13) => return None,
267
+ load => panic!("payload {load:?}"),
268
+ };
269
+ assert_eq!(loaded.len() as u32, wire::number(fields, 4).unwrap());
270
+ loaded
271
+ }
272
+ "ct" => {
273
+ if let Ok(direction) = wire::one(fields, 3) {
274
+ assert_eq!(direction.value, vec![0]);
275
+ }
276
+ let (source, destination, source_port, destination_port) = flow.original;
277
+ match wire::number(fields, 2).unwrap() {
278
+ 0 => flow.state.to_ne_bytes().to_vec(),
279
+ 1 => vec![u8::from(flow.reply)],
280
+ 10 => vec![flow.protocol],
281
+ 11 => source_port.to_be_bytes().to_vec(),
282
+ 12 => destination_port.to_be_bytes().to_vec(),
283
+ 13 => vec![0; 16],
284
+ 17 => flow.zone.to_ne_bytes().to_vec(),
285
+ 19 => source.to_vec(),
286
+ 20 => destination.to_vec(),
287
+ key => panic!("ct {key}"),
288
+ }
289
+ }
290
+ other => panic!("{other}"),
291
+ })
292
+ }
293
+ /// Byte offset of a register operand, as the kernel parses it.
294
+ fn offset(fields: &[Attr], id: u16) -> usize {
295
+ let register = wire::number(fields, id).unwrap() as usize;
296
+ 4 * if register <= 4 {
297
+ register * 4
298
+ } else {
299
+ register - 4
300
+ }
301
+ }
302
+ enum Step {
303
+ Next,
304
+ Final(Decision),
305
+ }
306
+ /// A register machine over the compiled rules: loads store into their register
307
+ /// and zero the rest of a partially written 32-bit word, exactly as the kernel's
308
+ /// store helpers do, so concatenated set keys are checked byte for byte.
309
+ fn run(table: &Table, chain: &str, flow: &Flow, zone: &mut u16) -> Step {
310
+ for expressions in &table.chains[chain] {
311
+ let mut registers = [0_u8; 80];
312
+ let mut matched = true;
313
+ let mut verdict: Option<(i32, Option<String>)> = None;
314
+ for expression in expressions {
315
+ let parts = wire::attrs(&expression.value).unwrap();
316
+ let name = wire::text(&parts, 1).unwrap();
317
+ let fields = wire::attrs(&wire::one(&parts, 2).unwrap().value).unwrap();
318
+ match name.as_str() {
319
+ "ct" if wire::one(&fields, 4).is_ok() => {
320
+ if wire::number(&fields, 2).unwrap() == 17 {
321
+ let at = offset(&fields, 4);
322
+ *zone = u16::from_ne_bytes(registers[at..at + 2].try_into().unwrap());
323
+ }
324
+ }
325
+ "meta" | "payload" | "ct" => match load(flow, &name, &fields) {
326
+ Some(value) => {
327
+ let at = offset(&fields, 1);
328
+ let end = at + value.len().div_ceil(4) * 4;
329
+ registers[at..end].fill(0);
330
+ registers[at..at + value.len()].copy_from_slice(&value);
331
+ }
332
+ None => matched = false,
333
+ },
334
+ "bitwise" => {
335
+ let (from, to) = (offset(&fields, 1), offset(&fields, 2));
336
+ let length = wire::number(&fields, 3).unwrap() as usize;
337
+ let (mask, xor) = (data(&fields, 4), data(&fields, 5));
338
+ for index in 0..length {
339
+ registers[to + index] = registers[from + index] & mask[index] ^ xor[index];
340
+ }
341
+ }
342
+ "cmp" => {
343
+ assert_eq!(wire::number(&fields, 2).unwrap(), 0);
344
+ let expected = data(&fields, 3);
345
+ let at = offset(&fields, 1);
346
+ matched = registers[at..at + expected.len()] == expected[..];
347
+ }
348
+ "range" => {
349
+ assert_eq!(wire::number(&fields, 2).unwrap(), 0);
350
+ let (first, last) = (data(&fields, 3), data(&fields, 4));
351
+ let at = offset(&fields, 1);
352
+ let loaded = &registers[at..at + first.len()];
353
+ matched = loaded >= &first[..] && loaded <= &last[..];
354
+ }
355
+ "lookup" => {
356
+ assert_eq!(wire::number(&fields, 5).unwrap(), 0);
357
+ let keys = &table.sets[&wire::text(&fields, 1).unwrap()];
358
+ let at = offset(&fields, 2);
359
+ matched = keys
360
+ .iter()
361
+ .any(|key| registers[at..at + key.len()] == key[..]);
362
+ }
363
+ "immediate" if wire::number(&fields, 1).unwrap() == 0 => {
364
+ let code = wire::attrs(
365
+ &wire::one(
366
+ &wire::attrs(&wire::one(&fields, 2).unwrap().value).unwrap(),
367
+ 2,
368
+ )
369
+ .unwrap()
370
+ .value,
371
+ )
372
+ .unwrap();
373
+ verdict = Some((
374
+ wire::number(&code, 1).unwrap() as i32,
375
+ wire::text(&code, 2).ok(),
376
+ ));
377
+ }
378
+ "immediate" => {
379
+ let value = data(&fields, 2);
380
+ let at = offset(&fields, 1);
381
+ registers[at..at + value.len()].copy_from_slice(&value);
382
+ }
383
+ "nat" => {}
384
+ other => panic!("{other}"),
385
+ }
386
+ if !matched {
387
+ break;
388
+ }
389
+ }
390
+ if !matched {
391
+ continue;
392
+ }
393
+ match verdict {
394
+ Some((code @ (0 | 1), _)) => {
395
+ return Step::Final(Decision {
396
+ verdict: code,
397
+ chain: chain.into(),
398
+ zone: *zone,
399
+ })
400
+ }
401
+ Some((-5, _)) => return Step::Next,
402
+ Some((-3, Some(target))) => {
403
+ if let Step::Final(decision) = run(table, &target, flow, zone) {
404
+ return Step::Final(decision);
405
+ }
406
+ }
407
+ Some((-4, Some(target))) => return run(table, &target, flow, zone),
408
+ Some(other) => panic!("verdict {other:?}"),
409
+ None => {}
410
+ }
411
+ }
412
+ Step::Next
413
+ }
414
+ /// A base chain that ends without a terminal verdict accepts by policy.
415
+ fn decide(prepared: &Prepared, chain: &str, flow: &Flow) -> Decision {
416
+ let mut zone = 0;
417
+ match run(&table(prepared), chain, flow, &mut zone) {
418
+ Step::Final(decision) => decision,
419
+ Step::Next => Decision {
420
+ verdict: 1,
421
+ chain: "policy".into(),
422
+ zone,
423
+ },
424
+ }
425
+ }
426
+ /// The grant admits exactly its flow in both directions of every hook, and every
427
+ /// other tuple meets exactly the decision the scope without the grant makes.
428
+ fn assert_exact(baseline: &Prepared, granted: &Prepared, hooks: &[(&str, Flow)]) {
429
+ for (chain, flow) in hooks {
430
+ assert_ne!(decide(baseline, chain, flow).verdict, 1, "{chain} baseline");
431
+ assert_eq!(decide(granted, chain, flow).verdict, 1, "{chain} granted");
432
+ for (index, other) in foreign(*flow).iter().enumerate() {
433
+ assert_eq!(
434
+ decide(granted, chain, other),
435
+ decide(baseline, chain, other),
436
+ "{chain} variant {index}"
437
+ );
438
+ }
439
+ }
440
+ }
441
+
442
+ #[test]
443
+ fn host_grants_absent_and_empty_keep_every_scope_digest_and_compiled_bytes() {
444
+ for fixture in [router(), host(), pool_guard()] {
445
+ let baseline = prepared(fixture.clone());
446
+ assert!(!serde_json::to_string(&baseline.policy)
447
+ .unwrap()
448
+ .contains("hostGrants"));
449
+ let empty = prepared(granted(fixture.clone(), json!([])));
450
+ assert_eq!(empty, baseline);
451
+ assert_eq!(bytes(&empty), bytes(&baseline));
452
+ let with = prepared(granted(fixture, exact()));
453
+ assert_ne!(with.digest, baseline.digest);
454
+ assert!(serde_json::to_string(&with.policy)
455
+ .unwrap()
456
+ .contains("\"hostGrants\""));
457
+ }
458
+ }
459
+
460
+ #[test]
461
+ fn router_host_grant_classifies_and_admits_only_the_exact_handoff_flow() {
462
+ let baseline = prepared(router());
463
+ let with = prepared(granted(router(), exact()));
464
+ let handoff = Some((4, "handoff"));
465
+ let work = Some((2, "work"));
466
+ let original = opening(handoff, work);
467
+ // Forward admission of the opening, of the established flow and of the reply.
468
+ assert_exact(
469
+ &baseline,
470
+ &with,
471
+ &[
472
+ ("forward", original),
473
+ (
474
+ "forward",
475
+ Flow {
476
+ state: ESTABLISHED,
477
+ tcp_flags: ACK,
478
+ ..original
479
+ },
480
+ ),
481
+ ("forward", answer(original)),
482
+ ],
483
+ );
484
+ // Raw handoff ingress runs before conntrack: the exact arriving tuple is
485
+ // accepted in the default zone ahead of the protected-source denial that
486
+ // drops every host-origin packet without the grant.
487
+ let raw = |prepared: &Prepared, flow: &Flow| decide(prepared, "raw_in", flow);
488
+ assert_eq!(raw(&baseline, &original).verdict, 0);
489
+ let classified = raw(&with, &original);
490
+ assert_eq!(
491
+ (classified.verdict, classified.chain.as_str()),
492
+ (1, "raw_return")
493
+ );
494
+ for other in [
495
+ Flow {
496
+ destination_port: 8081,
497
+ ..original
498
+ },
499
+ Flow {
500
+ protocol: 17,
501
+ ..original
502
+ },
503
+ Flow {
504
+ destination: [10, 241, 0, 3],
505
+ ..original
506
+ },
507
+ Flow {
508
+ source: [10, 240, 0, 5],
509
+ ..original
510
+ },
511
+ Flow {
512
+ input: Some((3, "vpn")),
513
+ ..original
514
+ },
515
+ ] {
516
+ assert_eq!(raw(&with, &other), raw(&baseline, &other));
517
+ }
518
+ // The workload's answer stays in the default zone even when the host's source
519
+ // port equals the destination port of a leased public grant (tcp/443 here):
520
+ // protected destinations return before any leased classifier can zone it.
521
+ let reply = raw(&with, &answer(original));
522
+ assert_eq!(reply, raw(&baseline, &answer(original)));
523
+ assert_eq!((reply.verdict, reply.zone), (1, 0));
524
+ let leased = raw(
525
+ &with,
526
+ &Flow {
527
+ destination: [198, 51, 100, 7],
528
+ ..answer(original)
529
+ },
530
+ );
531
+ assert_eq!(
532
+ (leased.verdict, leased.chain.as_str(), leased.zone),
533
+ (1, "raw_in", 1)
534
+ );
535
+ // One raw classification and four forward admissions, whatever the number
536
+ // of grants.
537
+ let count = |prepared: &Prepared, chain: &str| chains(prepared)[chain].len();
538
+ assert_eq!(
539
+ count(&with, "raw_return"),
540
+ count(&baseline, "raw_return") + 1
541
+ );
542
+ assert_eq!(count(&with, "forward"), count(&baseline, "forward") + 4);
543
+ assert_eq!(chains(&with).len(), chains(&baseline).len());
544
+ }
545
+
546
+ #[test]
547
+ fn host_transit_host_grant_opens_only_the_exact_output_and_reply_input() {
548
+ let baseline = prepared(host());
549
+ let with = prepared(granted(host(), exact()));
550
+ let original = opening(None, Some((4, "handoff")));
551
+ assert_exact(
552
+ &baseline,
553
+ &with,
554
+ &[
555
+ ("output", original),
556
+ (
557
+ "output",
558
+ Flow {
559
+ state: ESTABLISHED,
560
+ tcp_flags: ACK,
561
+ ..original
562
+ },
563
+ ),
564
+ ("input", answer(original)),
565
+ ],
566
+ );
567
+ // No forwarding, no uplink path and no other handoff opens.
568
+ for (chain, flow) in [
569
+ (
570
+ "forward",
571
+ Flow {
572
+ input: Some((2, "ens18")),
573
+ ..original
574
+ },
575
+ ),
576
+ (
577
+ "output",
578
+ Flow {
579
+ output: Some((2, "ens18")),
580
+ ..original
581
+ },
582
+ ),
583
+ (
584
+ "input",
585
+ Flow {
586
+ input: Some((2, "ens18")),
587
+ ..answer(original)
588
+ },
589
+ ),
590
+ ] {
591
+ assert_eq!(decide(&with, chain, &flow), decide(&baseline, chain, &flow));
592
+ }
593
+ let count = |prepared: &Prepared, chain: &str| chains(prepared)[chain].len();
594
+ assert_eq!(count(&with, "output"), count(&baseline, "output") + 3);
595
+ assert_eq!(count(&with, "input"), count(&baseline, "input") + 1);
596
+ assert_eq!(count(&with, "forward"), count(&baseline, "forward"));
597
+ assert_eq!(chains(&with).len(), chains(&baseline).len());
598
+ }
599
+
600
+ #[test]
601
+ fn pool_guard_host_grant_is_the_only_exception_and_the_guard_keeps_every_other_pool_denial() {
602
+ let baseline = prepared(pool_guard());
603
+ let with = prepared(granted(pool_guard(), exact()));
604
+ // The guard has no link binding; the exception is the exact host-local tuple.
605
+ let original = opening(None, Some((4, "handoff")));
606
+ assert_exact(
607
+ &baseline,
608
+ &with,
609
+ &[
610
+ ("output", original),
611
+ (
612
+ "output",
613
+ Flow {
614
+ state: ESTABLISHED,
615
+ tcp_flags: ACK,
616
+ ..original
617
+ },
618
+ ),
619
+ ("input", answer(original)),
620
+ ],
621
+ );
622
+ // Forwarded pool traffic never matches a host grant.
623
+ let forwarded = Flow {
624
+ input: Some((2, "ens18")),
625
+ ..original
626
+ };
627
+ assert_eq!(
628
+ decide(&with, "forward", &forwarded),
629
+ decide(&baseline, "forward", &forwarded)
630
+ );
631
+ assert_eq!(decide(&with, "forward", &forwarded).verdict, 0);
632
+ let graph = chains(&with);
633
+ let reference = chains(&baseline);
634
+ for name in ["pool_guard", "pool_reply", "forward"] {
635
+ assert_eq!(graph[name], reference[name], "{name}");
636
+ }
637
+ assert_eq!(graph["output"].len(), reference["output"].len() + 3);
638
+ assert_eq!(graph["input"].len(), reference["input"].len() + 1);
639
+ }
640
+
641
+ #[test]
642
+ fn host_grant_withdrawal_compiles_the_exact_previous_graph() {
643
+ for fixture in [router(), host(), pool_guard()] {
644
+ let baseline = prepared(fixture.clone());
645
+ let two = prepared(granted(
646
+ fixture.clone(),
647
+ json!([
648
+ host_grant("tcp", "10.240.0.1", "10.241.0.2", 8080),
649
+ host_grant("udp", "10.240.0.1", "10.241.0.2", 5353)
650
+ ]),
651
+ ));
652
+ // Order is canonical, so a reordered list is the same policy.
653
+ let reordered = prepared(granted(
654
+ fixture.clone(),
655
+ json!([
656
+ host_grant("udp", "10.240.0.1", "10.241.0.2", 5353),
657
+ host_grant("tcp", "10.240.0.1", "10.241.0.2", 8080)
658
+ ]),
659
+ ));
660
+ assert_eq!(reordered, two);
661
+ // Withdrawing one grant is the single-grant policy; withdrawing all of them
662
+ // is byte-identical to the scope that never had one.
663
+ let one = prepared(granted(fixture.clone(), exact()));
664
+ let tcp = opening(None, Some((4, "handoff")));
665
+ assert_ne!(one.digest, two.digest);
666
+ assert_eq!(prepared(granted(fixture, json!([]))), baseline);
667
+ let (chain, flow) = if matches!(baseline.policy.scope, super::Scope::RouterEgress(_)) {
668
+ (
669
+ "forward",
670
+ Flow {
671
+ input: Some((4, "handoff")),
672
+ output: Some((2, "work")),
673
+ ..tcp
674
+ },
675
+ )
676
+ } else {
677
+ ("output", tcp)
678
+ };
679
+ let udp = Flow {
680
+ protocol: 17,
681
+ destination_port: 5353,
682
+ original: (HOST, WORK, 443, 5353),
683
+ ..flow
684
+ };
685
+ assert_eq!(decide(&two, chain, &udp).verdict, 1);
686
+ assert_eq!(decide(&one, chain, &udp), decide(&baseline, chain, &udp));
687
+ assert_eq!(decide(&one, chain, &flow).verdict, 1);
688
+ assert_ne!(decide(&baseline, chain, &flow).verdict, 1);
689
+ }
690
+ }
691
+
692
+ #[test]
693
+ fn host_grants_reject_ranges_wildcards_and_unbound_addresses() {
694
+ let valid = |fixture: Value| granted(fixture, exact());
695
+ for fixture in [router(), host(), pool_guard()] {
696
+ assert!(normalized(valid(fixture.clone())).is_ok());
697
+ let grant = host_grant("tcp", "10.240.0.1", "10.241.0.2", 8080);
698
+ let changed = |path: &str, value: Value| {
699
+ let mut changed = grant.clone();
700
+ *changed.pointer_mut(path).unwrap() = value;
701
+ granted(fixture.clone(), json!([changed]))
702
+ };
703
+ let mut unknown = grant.clone();
704
+ unknown["destinationPortLast"] = json!(8090);
705
+ let mut missing = grant.clone();
706
+ missing.as_object_mut().unwrap().remove("sourceAddress");
707
+ for invalid in [
708
+ granted(fixture.clone(), json!([unknown])),
709
+ granted(fixture.clone(), json!([missing])),
710
+ changed("/protocol", json!("any")),
711
+ changed("/protocol", json!("sctp")),
712
+ // Exact ports only: no zero, range, list, wildcard or null.
713
+ changed("/destinationPort", json!(0)),
714
+ changed("/destinationPort", json!(65536)),
715
+ changed("/destinationPort", json!(-1)),
716
+ changed("/destinationPort", json!("8080-8090")),
717
+ changed("/destinationPort", json!([8080, 8090])),
718
+ changed("/destinationPort", Value::Null),
719
+ // Exact unicast addresses only: no prefix, wildcard, broadcast or loopback.
720
+ changed("/sourceAddress", json!("10.240.0.1/32")),
721
+ changed("/sourceAddress", json!("0.0.0.0")),
722
+ changed("/sourceAddress", json!("10.240.0.01")),
723
+ changed("/destinationAddress", json!("10.241.0.0/24")),
724
+ changed("/destinationAddress", json!("0.0.0.0")),
725
+ changed("/destinationAddress", json!("255.255.255.255")),
726
+ changed("/destinationAddress", json!("127.0.0.1")),
727
+ changed("/destinationAddress", json!("fd00::2")),
728
+ changed("/destinationAddress", json!("10.240.0.1")),
729
+ // One entry per exact tuple, and a bounded list.
730
+ granted(fixture.clone(), json!([grant.clone(), grant.clone()])),
731
+ granted(
732
+ fixture.clone(),
733
+ json!((0..1025)
734
+ .map(|index| host_grant("tcp", "10.240.0.1", "10.241.0.2", 20000 + index))
735
+ .collect::<Vec<_>>()),
736
+ ),
737
+ ] {
738
+ assert!(normalized(invalid).is_err());
739
+ }
740
+ }
741
+ let bound = |fixture: Value, source: &str, destination: &str| {
742
+ normalized(granted(
743
+ fixture,
744
+ json!([host_grant("tcp", source, destination, 8080)]),
745
+ ))
746
+ };
747
+ // Router: the destination is a current veth workload endpoint address, and the
748
+ // source is protected handoff-side space that is neither router-local, a
749
+ // platform endpoint nor a workload.
750
+ for (source, destination) in [
751
+ ("10.240.0.1", "10.241.0.3"),
752
+ ("10.240.0.1", "10.241.0.1"),
753
+ ("10.240.0.1", "10.240.0.2"),
754
+ ("10.240.0.1", "10.242.0.2"),
755
+ ("10.240.0.1", "192.0.2.1"),
756
+ ("10.240.0.2", "10.241.0.2"),
757
+ ("10.241.0.1", "10.241.0.2"),
758
+ ("10.242.0.2", "10.241.0.2"),
759
+ ("192.0.2.1", "10.241.0.2"),
760
+ ("203.0.113.9", "10.241.0.2"),
761
+ ] {
762
+ assert!(
763
+ bound(router(), source, destination).is_err(),
764
+ "{source} {destination}"
765
+ );
766
+ }
767
+ // Host transit: the source is an exact current handoff address, and the
768
+ // destination is protected, non-local space that is no leased router address.
769
+ for (source, destination) in [
770
+ ("10.240.0.5", "10.241.0.2"),
771
+ ("192.0.2.2", "10.241.0.2"),
772
+ ("10.240.0.1", "10.240.0.2"),
773
+ ("10.240.0.1", "192.0.2.2"),
774
+ ("10.240.0.1", "192.0.2.1"),
775
+ ("10.240.0.1", "198.51.100.7"),
776
+ ] {
777
+ assert!(
778
+ bound(host(), source, destination).is_err(),
779
+ "{source} {destination}"
780
+ );
781
+ }
782
+ // Guard: the exception names an address inside a guarded pool.
783
+ for (source, destination) in [
784
+ ("10.240.0.1", "10.242.0.2"),
785
+ ("10.240.0.1", "192.168.1.1"),
786
+ ("10.240.0.1", "198.51.100.7"),
787
+ ] {
788
+ assert!(
789
+ bound(pool_guard(), source, destination).is_err(),
790
+ "{source} {destination}"
791
+ );
792
+ }
793
+ }
794
+
795
+ /// `count` distinct exact grants to the fixture workload, both protocols.
796
+ fn many(count: usize) -> Value {
797
+ json!((0..count)
798
+ .map(|index| host_grant(
799
+ if index % 2 == 0 { "tcp" } else { "udp" },
800
+ "10.240.0.1",
801
+ "10.241.0.2",
802
+ 20000 + (index / 2) as u16
803
+ ))
804
+ .collect::<Vec<_>>())
805
+ }
806
+
807
+ #[test]
808
+ fn host_grants_compile_a_constant_rule_graph_and_one_element_per_grant() {
809
+ for fixture in [router(), host(), pool_guard()] {
810
+ let one = table(&prepared(granted(fixture.clone(), many(1))));
811
+ let all = table(&prepared(granted(fixture.clone(), many(1024))));
812
+ // The rule graph is independent of the number of grants.
813
+ assert_eq!(one.chains, all.chains);
814
+ assert_eq!(all.sets["host_grant"].len(), 1024);
815
+ let keys: std::collections::BTreeSet<_> = all.sets["host_grant"].iter().collect();
816
+ assert_eq!(keys.len(), 1024);
817
+ if let Some(arrival) = all.sets.get("host_grant_arrival") {
818
+ assert_eq!(arrival.len(), 1024);
819
+ }
820
+ assert!(table(&prepared(fixture)).sets.is_empty());
821
+ }
822
+ }
823
+
824
+ #[test]
825
+ fn host_grants_admit_every_member_of_a_large_set_and_no_other_tuple() {
826
+ let flows = |fixture: &Value| -> Vec<(&'static str, Flow, bool)> {
827
+ let (chain, original) = if fixture["scope"]["kind"] == "routerEgress" {
828
+ ("forward", opening(Some((4, "handoff")), Some((2, "work"))))
829
+ } else {
830
+ ("output", opening(None, Some((4, "handoff"))))
831
+ };
832
+ let reply_chain = if chain == "forward" {
833
+ "forward"
834
+ } else {
835
+ "input"
836
+ };
837
+ let at = |protocol: u8, port: u16| Flow {
838
+ protocol,
839
+ destination_port: port,
840
+ original: (HOST, WORK, 443, port),
841
+ ..original
842
+ };
843
+ vec![
844
+ // First, middle and last members of both protocols.
845
+ (chain, at(6, 20000), true),
846
+ (chain, at(17, 20000), true),
847
+ (chain, at(6, 20255), true),
848
+ (chain, at(17, 20511), true),
849
+ (reply_chain, answer(at(6, 20511)), true),
850
+ (reply_chain, answer(at(17, 20300)), true),
851
+ // Just outside the member range, and a translated member.
852
+ (chain, at(6, 20512), false),
853
+ (chain, at(17, 19999), false),
854
+ (reply_chain, answer(at(6, 20512)), false),
855
+ (
856
+ chain,
857
+ Flow {
858
+ original: (HOST, WORK, 443, 20001),
859
+ ..at(6, 20000)
860
+ },
861
+ false,
862
+ ),
863
+ ]
864
+ };
865
+ for fixture in [router(), host(), pool_guard()] {
866
+ let with = prepared(granted(fixture.clone(), many(1024)));
867
+ let baseline = prepared(fixture.clone());
868
+ for (chain, flow, admitted) in flows(&fixture) {
869
+ let decision = decide(&with, chain, &flow);
870
+ if admitted {
871
+ assert_eq!(decision.verdict, 1, "{chain}");
872
+ } else {
873
+ assert_eq!(decision, decide(&baseline, chain, &flow), "{chain}");
874
+ }
875
+ }
876
+ }
877
+ }
878
+
879
+ #[test]
880
+ fn host_grants_meet_the_atomic_reserve_at_the_contract_maximum() {
881
+ // Worst-case handle-only deletion of a previous maximum graph: 768 messages
882
+ // naming the longest table, a chain name and a handle.
883
+ let deletion = 768 * (20 + 72 + 36 + 12);
884
+ for (name, fixture) in [
885
+ ("router", router()),
886
+ ("host", host()),
887
+ ("guard", pool_guard()),
888
+ ] {
889
+ let prepared = prepared(granted(fixture.clone(), many(1024)));
890
+ let program = prepared
891
+ .program(&format!("snft_{}", "x".repeat(59)))
892
+ .unwrap();
893
+ let size = |kinds: &[u16]| -> usize {
894
+ program
895
+ .iter()
896
+ .filter(|(kind, _)| kinds.contains(kind))
897
+ .map(|(_, attributes)| wire::encode_attrs(attributes).len() + 20)
898
+ .sum()
899
+ };
900
+ let (graph, elements) = (size(&[3, 6, 9]), size(&[12]));
901
+ assert!(
902
+ program.len() < 768 && graph <= 100_000 && elements <= super::compile::ELEMENT_BYTES
903
+ );
904
+ assert!(deletion + graph + elements + 64 <= crate::wire::BATCH_BYTES);
905
+ // The contract maximum is the validation bound.
906
+ assert!(normalized(granted(fixture, many(1025))).is_err());
907
+ println!(
908
+ "HOST_GRANT_MAX scope={name} grants=1024 operations={} rule_bytes={graph} element_bytes={elements} batch_bound={}",
909
+ program.len(),
910
+ deletion + graph + elements + 64
911
+ );
912
+ }
913
+ // 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);
915
+ }
916
+
917
+ /// A status carries up to three complete policies: the applied one and the
918
+ /// pending transition's previous and target. At the contract maximum in every
919
+ /// scope, that status still fits the private IPC bounds (1,000,000 JSON bytes
920
+ /// and 65,536 values in the facade, 1,048,576-byte lines).
921
+ #[test]
922
+ fn host_grant_policies_fit_the_ipc_bounds_three_at_a_time() {
923
+ fn values(value: &Value) -> usize {
924
+ 1 + match value {
925
+ Value::Array(items) => items.iter().map(values).sum(),
926
+ Value::Object(map) => map.values().map(values).sum(),
927
+ _ => 0,
928
+ }
929
+ }
930
+ for fixture in [router(), host(), pool_guard()] {
931
+ let prepared = serde_json::to_value(prepared(granted(fixture, many(1024)))).unwrap();
932
+ let applied = json!({"receipt":{"identity":{"ownerId":"o".repeat(64),"instanceId":"i".repeat(64),
933
+ "tableName":format!("snft_{}", "x".repeat(59)),"bootId":"0".repeat(36),
934
+ "namespaceDevice":"18446744073709551615","namespaceInode":"18446744073709551615"},
935
+ "tableHandle":"18446744073709551615","revision":9007199254740991_u64,"digest":prepared["digest"]},
936
+ "prepared":prepared});
937
+ let status = json!({"id":"x".repeat(128),"success":true,"result":{"identity":applied["receipt"]["identity"],
938
+ "state":"failed-owned","enforced":false,"applied":applied,
939
+ "pending":{"previous":applied,"target":prepared},"error":"CONFLICT"}});
940
+ let bytes = serde_json::to_vec(&status).unwrap().len();
941
+ assert!(bytes <= 1_000_000 && bytes < crate::IPC_BYTES, "{bytes}");
942
+ assert!(values(&status) <= 65_536);
943
+ println!(
944
+ "HOST_GRANT_IPC status_bytes={bytes} values={}",
945
+ values(&status)
946
+ );
947
+ }
948
+ }