@push.rocks/smartnftables 1.5.0 → 1.7.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 (43) hide show
  1. package/changelog.md +18 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +17 -0
  8. package/dist_ts/classes.manageddockerforwarding.js +128 -0
  9. package/dist_ts/classes.managednftables.d.ts +2 -0
  10. package/dist_ts/classes.managednftables.js +2 -2
  11. package/dist_ts/index.d.ts +2 -0
  12. package/dist_ts/index.js +3 -1
  13. package/dist_ts/managed.docker.types.d.ts +78 -0
  14. package/dist_ts/managed.docker.types.js +2 -0
  15. package/dist_ts/managed.egress.types.d.ts +9 -1
  16. package/package.json +1 -1
  17. package/readme.md +100 -6
  18. package/rust/src/docker.frontend.rs +203 -0
  19. package/rust/src/docker.graph.rs +129 -0
  20. package/rust/src/docker.policy.rs +140 -0
  21. package/rust/src/docker.rs +191 -0
  22. package/rust/src/docker_process_tests.rs +29 -0
  23. package/rust/src/docker_tests.rs +148 -0
  24. package/rust/src/egress.compile.rs +3 -0
  25. package/rust/src/egress.poolguard.rs +30 -0
  26. package/rust/src/egress.rs +27 -0
  27. package/rust/src/egress_tests.rs +85 -0
  28. package/rust/src/main.rs +10 -5
  29. package/rust/src/managed.rs +4 -0
  30. package/rust/src/owner.rs +19 -1
  31. package/rust/src/owner_egress_tests.rs +1 -1
  32. package/rust/src/owner_egress_traffic_tests.rs +21 -10
  33. package/rust/src/owner_host_traffic_tests.rs +4 -10
  34. package/rust/src/owner_poolguard_tests.rs +265 -0
  35. package/rust/src/owner_tests.rs +3 -0
  36. package/rust/src/wire.links.rs +11 -0
  37. package/rust/src/wire.rs +14 -3
  38. package/ts/00_commitinfo_data.ts +1 -1
  39. package/ts/classes.manageddockerforwarding.ts +113 -0
  40. package/ts/classes.managednftables.ts +1 -1
  41. package/ts/index.ts +2 -0
  42. package/ts/managed.docker.types.ts +49 -0
  43. package/ts/managed.egress.types.ts +10 -1
@@ -0,0 +1,29 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn frontend_subprocess_drains_large_input_and_output_with_a_clean_environment() {
5
+ let input="bounded-input\n".repeat(12_000);
6
+ assert_eq!(command("/usr/bin/cat",&[],input.as_bytes(),deadline()).unwrap(),input);
7
+ let environment=command("/usr/bin/env",&[],&[],deadline()).unwrap();
8
+ let values:BTreeSet<_>=environment.lines().collect();
9
+ assert_eq!(values,BTreeSet::from(["PATH=/usr/sbin:/usr/bin:/sbin:/bin","LC_ALL=C"]));
10
+ }
11
+ fn child_failure(kind: &str, source: &str, expected: Error) {
12
+ let directory=std::env::temp_dir().join(format!("smartnftables-command-{}-{kind}",std::process::id()));
13
+ std::fs::create_dir(&directory).unwrap();
14
+ let pidfile=directory.join("pid");
15
+ let source=format!("import os,pathlib,time;pathlib.Path({:?}).write_text(str(os.getpid()));{source}",pidfile.to_str().unwrap());
16
+ assert_eq!(command("/usr/bin/python3",&["-c",&source],&[],deadline()),Err(expected));
17
+ let pid=std::fs::read_to_string(&pidfile).unwrap().parse::<i32>().unwrap();
18
+ assert_eq!(unsafe{libc::kill(pid,0)},-1);
19
+ assert_eq!(std::io::Error::last_os_error().raw_os_error(),Some(libc::ESRCH));
20
+ std::fs::remove_file(pidfile).unwrap();std::fs::remove_dir(directory).unwrap();
21
+ }
22
+ #[test]
23
+ fn frontend_subprocess_output_overflow_stops_and_joins_the_exact_child() {
24
+ child_failure("output","os.write(1,b'x'*2097153);time.sleep(60)",Error::Protocol);
25
+ }
26
+ #[test]
27
+ fn frontend_subprocess_timeout_stops_and_joins_the_exact_child() {
28
+ child_failure("timeout","time.sleep(60)",Error::Unavailable);
29
+ }
@@ -0,0 +1,148 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+
4
+ fn options() -> Options {Options {owner_id:"dockerfixture".into(),instance_id:"qualification_20260913".into()}}
5
+ fn policy() -> policy::Policy {
6
+ let prepared:crate::managed::Prepared=serde_json::from_value::<crate::egress::Policy>(crate::egress::tests::host()).unwrap().prepare().unwrap().into();
7
+ policy::Policy {schema_version:1,revision:1,barrier:owner::Applied {
8
+ receipt:owner::Receipt {identity:owner::Identity {owner_id:"barrier".into(),instance_id:"qualification_20260913".into(),table_name:"snft_fixture".into(),boot_id:"00000000-0000-4000-8000-000000000000".into(),namespace_device:"4".into(),namespace_inode:"1".into()},table_handle:"1".into(),revision:1,digest:prepared.digest.clone()},prepared}}
9
+ }
10
+ fn text(rules: &[policy::Rule]) -> String {
11
+ let mut text="*filter\n:INPUT ACCEPT [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n:DOCKER-USER - [0:0]\n:DOCKER-FORWARD - [0:0]\n-A FORWARD -j DOCKER-USER\n-A FORWARD -j DOCKER-FORWARD\n".to_owned();
12
+ for rule in rules {text.push_str(&format!("-A DOCKER-USER {}\n",rule.args.join(" ")));}
13
+ text.push_str("-A DOCKER-USER -m comment --comment \"foreign unrelated rule\" -j RETURN\nCOMMIT\n");text
14
+ }
15
+ fn snapshot(text: &str) -> frontend::Snapshot {frontend::Snapshot::parse("v1.8.11 (nf_tables)".into(),text).unwrap()}
16
+
17
+ #[test]
18
+ fn docker_compiler_derives_only_scoped_directed_ranges_from_host_barrier() {
19
+ let prepared=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();let rules=prepared.rules(&options()).unwrap();
20
+ assert_eq!(rules.len(),6);
21
+ assert!(rules.iter().all(|r|r.args.contains(&"--ctorigsrcport".into()) && r.args.contains(&"--ctdir".into()) && !r.args.iter().any(|s|s.contains("RELATED"))));
22
+ let new_tcp=&rules[0].args;
23
+ assert!(new_tcp.windows(3).any(|w|w==["--tcp-flags","FIN,SYN,RST,ACK","SYN"]));
24
+ assert!(rules.iter().all(|r|r.comment.len()<256));
25
+ assert!(rules.iter().all(|r|r.args.windows(2).any(|w|w[0]=="--ctorigsrc" && !w[1].contains('/'))));
26
+ assert!(snapshot(&text(&rules)).matches(&options(),&rules).unwrap());
27
+ }
28
+ #[test]
29
+ fn docker_prepare_rejects_router_receipts_bad_hashes_and_unbounded_restore() {
30
+ let mut value=policy();value.schema_version=2;assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
31
+ let mut value=policy();value.barrier.receipt.digest="sha256:wrong".into();assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
32
+ let mut value=policy();value.barrier.prepared.policy=crate::managed::Policy::Egress(serde_json::from_value(crate::egress::tests::router()).unwrap());assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
33
+ let value=serde_json::to_value(policy()).unwrap();
34
+ let mut extra=value.clone();extra["rules"]=json!([]);assert!(serde_json::from_value::<policy::Policy>(extra).is_err());
35
+ let mut extra=value;extra["barrier"]["receipt"]["tableHandle"]=json!("01");assert!(serde_json::from_value::<policy::Policy>(extra).unwrap().prepare("v1.8.11 (nf_tables)".into()).is_err());
36
+ }
37
+ #[test]
38
+ fn docker_policy_digest_binds_barrier_handle_and_revision() {
39
+ let expected=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();
40
+ assert_ne!(policy().prepare("v1.8.10 (nf_tables)".into()).unwrap().digest,expected.digest);
41
+ for backend in ["v1.8. (nf_tables)","v1.8.9 (nf_tables)","v1.8.11 (legacy)","v1.8.011 (nf_tables)"] {
42
+ assert!(policy().prepare(backend.into()).is_err());
43
+ }
44
+ let mut value=policy();value.barrier.receipt.table_handle="2".into();assert_ne!(value.prepare("v1.8.11 (nf_tables)".into()).unwrap().digest,expected.digest);
45
+ let mut value=policy();value.revision=2;assert_ne!(value.prepare("v1.8.11 (nf_tables)".into()).unwrap().digest,expected.digest);
46
+ let mut forged=expected;forged.policy.revision=2;assert!(forged.validate().is_err());
47
+ }
48
+ #[test]
49
+ fn docker_snapshot_accepts_only_equivalent_frontend_rendering() {
50
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
51
+ let original=text(&rules);
52
+ let rendered=original.replace("--ctorigsrc 10.240.0.2/32","--ctorigsrc 10.240.0.2");
53
+ assert!(snapshot(&rendered).matches(&options(),&rules).unwrap());
54
+ for (before,after) in [("--ctdir ORIGINAL","--ctdir REPLY"),("--ctstate NEW","--ctstate RELATED"),("--sport 10000:10999","--sport 1:65535"),("-o ens18","-o foreign"),("-j ACCEPT","-j RETURN"),("FIN,SYN,RST,ACK SYN","FIN,SYN,RST,ACK ACK")] {
55
+ assert!(!snapshot(&original.replacen(before,after,1)).matches(&options(),&rules).unwrap());
56
+ }
57
+ assert!(snapshot(&original.replacen("-j ACCEPT","-m mark --mark 1 -j ACCEPT",1)).matches(&options(),&rules).is_err());
58
+ }
59
+ #[test]
60
+ fn docker_snapshot_rejects_duplicates_moved_owner_and_foreign_instance() {
61
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
62
+ let original=text(&rules);
63
+ let duplicate=original.replace("COMMIT",&format!("-A DOCKER-USER {}\nCOMMIT",rules[0].args.join(" ")));
64
+ assert!(!snapshot(&duplicate).matches(&options(),&rules).unwrap());
65
+ let moved=original.replacen("-A DOCKER-USER", "-A INPUT",1);
66
+ assert!(!snapshot(&moved).matches(&options(),&rules).unwrap());
67
+ assert!(!snapshot(&original.replace("qualification_20260913","foreign_instance_2026")).matches(&options(),&rules).unwrap());
68
+ assert_eq!(snapshot(&original.replace("snftd1:dockerfixture:","snftd1:anotherowner:")).matches(&options(),&[]),Err(Error::ForeignOwner));
69
+ let prepended=original.replacen("-A DOCKER-USER","-A DOCKER-USER -j RETURN\n-A DOCKER-USER",1);
70
+ assert!(!snapshot(&prepended).matches(&options(),&rules).unwrap());
71
+ assert!(!snapshot(&original).matches(&options(),&[]).unwrap());
72
+ }
73
+ #[test]
74
+ fn docker_snapshot_rejects_wrong_backend_path_policy_chain_order_and_table_framing() {
75
+ let original=text(&[]);
76
+ for changed in [original.replace(":FORWARD DROP",":FORWARD ACCEPT"),original.replace("-A FORWARD -j DOCKER-USER","-A FORWARD -j ACCEPT"),original.replace("*filter","*nat"),original.replace("COMMIT","COMMIT\n*filter"),original.replace(":DOCKER-USER - [0:0]\n","")] {
77
+ assert!(frontend::Snapshot::parse("v1.8.11 (nf_tables)".into(),&changed).is_err());
78
+ }
79
+ }
80
+
81
+ #[test]
82
+ fn docker_raw_graph_rejects_early_verdicts_reordered_matches_and_hidden_bytes() {
83
+ let rule=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap().remove(0);
84
+ let expressions=graph::expected(&rule).unwrap();
85
+ let row=|expressions|vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
86
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
87
+ assert!(graph::matches_rule(&row(expressions.clone()),&rule).unwrap());
88
+ let mut early=expressions.clone();let verdict=early.pop().unwrap();early.insert(0,verdict);
89
+ assert!(!graph::matches_rule(&row(early),&rule).unwrap());
90
+ let mut reordered=expressions.clone();reordered.swap(0,1);
91
+ assert!(!graph::matches_rule(&row(reordered),&rule).unwrap());
92
+ let mut moved_counter=expressions.clone();let length=moved_counter.len();moved_counter.swap(length-1,length-2);
93
+ assert!(!graph::matches_rule(&row(moved_counter),&rule).unwrap());
94
+ let mut hidden=expressions.clone();
95
+ for expression in &mut hidden {
96
+ let mut fields=crate::wire::attrs(&expression.value).unwrap();
97
+ if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
98
+ let mut data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
99
+ if crate::wire::text(&data,1).unwrap()!="conntrack" {continue;}
100
+ data.iter_mut().find(|a|a.id()==3).unwrap().value[20..32].fill(0);
101
+ *fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,data);
102
+ expression.value=crate::wire::encode_attrs(&fields);
103
+ }
104
+ assert!(!graph::matches_rule(&row(hidden),&rule).unwrap());
105
+ }
106
+
107
+ #[test]
108
+ fn docker_raw_graph_accepts_kernel_field_order_flags_and_changing_counters_only() {
109
+ let rule=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap().remove(0);
110
+ let mut expressions=graph::expected(&rule).unwrap();
111
+ for expression in &mut expressions {
112
+ let mut fields=crate::wire::attrs(&expression.value).unwrap();
113
+ if crate::wire::text(&fields,1).unwrap()=="counter" {
114
+ *fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,
115
+ vec![crate::wire::Attr::u64(2,123),crate::wire::Attr::u64(1,456)]);
116
+ }
117
+ fields.reverse();expression.value=crate::wire::encode_attrs(&fields);
118
+ }
119
+ fn kernel(attributes: &mut [crate::wire::Attr]) {
120
+ for attribute in attributes {
121
+ if attribute.kind&0x8000!=0 {
122
+ let mut children=crate::wire::attrs(&attribute.value).unwrap();kernel(&mut children);
123
+ attribute.value=crate::wire::encode_attrs(&children);
124
+ }
125
+ attribute.kind &= 0x3fff;
126
+ }
127
+ }
128
+ kernel(&mut expressions);
129
+ let row=vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
130
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
131
+ assert!(graph::matches_rule(&row,&rule).unwrap());
132
+ }
133
+
134
+ #[test]
135
+ fn docker_forward_jumps_require_exact_unconditional_order() {
136
+ use crate::{policy::{expr,verdict},wire::Attr};
137
+ let row=|expressions|vec![Attr::string(1,"filter"),Attr::string(2,"FORWARD"),
138
+ Attr::u64(3,42),Attr::nested(4,expressions)];
139
+ for target in ["DOCKER-USER","DOCKER-FORWARD"] {
140
+ let expected=vec![expr("counter",vec![Attr::u64(1,7),Attr::u64(2,11)]),verdict(-3,Some(target))];
141
+ assert!(graph::matches_forward(&row(expected.clone()),target).unwrap());
142
+ let mut early=expected.clone();early.insert(0,verdict(1,None));
143
+ assert!(!graph::matches_forward(&row(early),target).unwrap());
144
+ let mut reordered=expected.clone();reordered.reverse();
145
+ assert!(!graph::matches_forward(&row(reordered),target).unwrap());
146
+ assert!(!graph::matches_forward(&row(expected),if target=="DOCKER-USER" {"DOCKER-FORWARD"} else {"DOCKER-USER"}).unwrap());
147
+ }
148
+ }
@@ -4,6 +4,8 @@ use crate::wire::{self, Attr};
4
4
 
5
5
  #[path = "egress.host.rs"]
6
6
  mod host;
7
+ #[path = "egress.poolguard.rs"]
8
+ mod poolguard;
7
9
  #[path = "egress.router.rs"]
8
10
  mod router;
9
11
 
@@ -283,6 +285,7 @@ pub(super) fn compile(prepared: &Prepared, table: &str) -> Result<Vec<(u16, Vec<
283
285
  router::compile(&mut program, scope, prepared.policy.revision)?
284
286
  }
285
287
  Scope::HostTransit(scope) => host::compile(&mut program, scope)?,
288
+ Scope::AllocationPoolGuard(scope) => poolguard::compile(&mut program, scope)?,
286
289
  }
287
290
  Ok(program.operations)
288
291
  }
@@ -0,0 +1,30 @@
1
+ use super::*;
2
+
3
+ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
4
+ for (name, hook) in [("input", 1), ("forward", 2), ("output", 3)] {
5
+ program.chain(name, Some((hook, 0, "filter")))?;
6
+ }
7
+ for name in ["pool_guard", "pool_reply"] {
8
+ program.chain(name, None)?;
9
+ }
10
+ for name in ["input", "forward", "output"] {
11
+ program.jump(name, ipv4(), "pool_guard")?;
12
+ }
13
+ // An earlier owner's DNAT must not conceal the original protected target.
14
+ for prefix in &scope.prefixes {
15
+ program.end("pool_guard", original_address(prefix, false)?, 0)?;
16
+ }
17
+ // GOTO, not JUMP: RETURN from pool_reply must return to the base chain,
18
+ // skipping current-destination denial for legitimate reverse-SNAT replies.
19
+ let mut reply = ct(1, None, vec![1]);
20
+ reply.push(verdict(-4, Some("pool_reply")));
21
+ program.rule("pool_guard", reply)?;
22
+ // A missing conntrack tuple/direction breaks only its rule. Untracked or
23
+ // invalid packets still reach this unconditional current-destination guard.
24
+ for prefix in &scope.prefixes {
25
+ program.end("pool_guard", current_address(prefix, false)?, 0)?;
26
+ program.end("pool_reply", current_address(prefix, true)?, 0)?;
27
+ }
28
+ program.end("pool_guard", vec![], -5)?;
29
+ program.end("pool_reply", vec![], -5)
30
+ }
@@ -119,10 +119,17 @@ pub struct HostScope {
119
119
  pub snat_address: String,
120
120
  }
121
121
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
122
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
123
+ pub struct AllocationPoolGuardScope {
124
+ pub authority_digest: String,
125
+ pub prefixes: Vec<String>,
126
+ }
127
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
122
128
  #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
123
129
  pub enum Scope {
124
130
  RouterEgress(RouterScope),
125
131
  HostTransit(HostScope),
132
+ AllocationPoolGuard(AllocationPoolGuardScope),
126
133
  }
127
134
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
128
135
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -532,6 +539,23 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
532
539
  )
533
540
  }))
534
541
  }
542
+ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
543
+ require(
544
+ digest(&value.authority_digest) && !value.prefixes.is_empty() && value.prefixes.len() <= 64,
545
+ )?;
546
+ value.prefixes.sort();
547
+ for (index, item) in value.prefixes.iter().enumerate() {
548
+ require(
549
+ covers("10.0.0.0/8", item)?
550
+ || covers("172.16.0.0/12", item)?
551
+ || covers("192.168.0.0/16", item)?,
552
+ )?;
553
+ for previous in &value.prefixes[..index] {
554
+ require(!overlaps(item, previous)?)?;
555
+ }
556
+ }
557
+ Ok(())
558
+ }
535
559
  fn normalize_host(value: &mut HostScope) -> Result<()> {
536
560
  normalize_protection(&mut value.protection)?;
537
561
  normalize_link(&mut value.uplink)?;
@@ -642,6 +666,7 @@ impl Policy {
642
666
  match &mut self.scope {
643
667
  Scope::RouterEgress(scope) => normalize_router(scope, self.revision)?,
644
668
  Scope::HostTransit(scope) => normalize_host(scope)?,
669
+ Scope::AllocationPoolGuard(scope) => normalize_pool_guard(scope)?,
645
670
  }
646
671
  Ok(self)
647
672
  }
@@ -667,6 +692,8 @@ impl Prepared {
667
692
  .map(|item| &item.link)
668
693
  .chain(std::iter::once(&scope.uplink))
669
694
  .collect(),
695
+ // No link/route dependency: this policy must apply before allocation.
696
+ Scope::AllocationPoolGuard(_) => return Ok(()),
670
697
  };
671
698
  crate::wire::verify_bound_interfaces(links.into_iter().map(|link| {
672
699
  crate::wire::BoundInterface {
@@ -45,6 +45,11 @@ pub(crate) fn host() -> Value {
45
45
  "handoffs":[{"link":link(4,"handoff","veth","10.240.0.1"),"allocations":[allocation()]}],
46
46
  "uplink":link(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2"}})
47
47
  }
48
+ pub(crate) fn pool_guard() -> Value {
49
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"allocationPoolGuard",
50
+ "authorityDigest":format!("sha256:{}", "b".repeat(64)),
51
+ "prefixes":["10.241.0.0/16", "10.240.0.0/16"]}})
52
+ }
48
53
  fn normalized(value: Value) -> super::Result<Policy> {
49
54
  serde_json::from_value::<Policy>(value)
50
55
  .map_err(|_| crate::Error::Invalid)?
@@ -330,3 +335,83 @@ fn managed_union_keeps_strict_duplicate_and_unknown_field_rejection() {
330
335
  )
331
336
  .is_err());
332
337
  }
338
+
339
+ #[test]
340
+ fn allocation_pool_guard_is_canonical_authority_bound_and_private_only() {
341
+ let prepared = normalized(pool_guard()).unwrap().prepare().unwrap();
342
+ let mut reordered = pool_guard();
343
+ reordered["scope"]["prefixes"]
344
+ .as_array_mut()
345
+ .unwrap()
346
+ .reverse();
347
+ assert_eq!(normalized(reordered).unwrap().prepare().unwrap(), prepared);
348
+ let mut changed_authority = pool_guard();
349
+ changed_authority["scope"]["authorityDigest"] = json!(format!("sha256:{}", "c".repeat(64)));
350
+ assert_ne!(
351
+ normalized(changed_authority)
352
+ .unwrap()
353
+ .prepare()
354
+ .unwrap()
355
+ .digest,
356
+ prepared.digest
357
+ );
358
+ for prefixes in [
359
+ json!([]),
360
+ json!(["10.0.0.1/8"]),
361
+ json!(["10.0.0.0/8", "10.1.0.0/16"]),
362
+ json!(["10.0.0.0/8", "10.0.0.0/8"]),
363
+ json!(["0.0.0.0/0"]),
364
+ json!(["192.0.2.0/24"]),
365
+ json!(["127.0.0.0/8"]),
366
+ json!(["172.0.0.0/8"]),
367
+ json!(["192.168.0.0/15"]),
368
+ json!(["10.0.0.0/08"]),
369
+ json!(["::/0"]),
370
+ ] {
371
+ assert!(normalized(changed(pool_guard(), "/scope/prefixes", prefixes)).is_err());
372
+ }
373
+ let mut extra = pool_guard();
374
+ extra["scope"]["handoffs"] = json!([]);
375
+ assert!(normalized(extra).is_err());
376
+ assert!(normalized(changed(
377
+ pool_guard(),
378
+ "/scope/authorityDigest",
379
+ json!("sha256:bad")
380
+ ))
381
+ .is_err());
382
+ assert!(normalized(changed(
383
+ pool_guard(),
384
+ "/scope/prefixes",
385
+ json!(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"])
386
+ ))
387
+ .is_ok());
388
+ let duplicate = serde_json::to_string(&pool_guard())
389
+ .unwrap()
390
+ .replace("\"prefixes\":", "\"prefixes\":[],\"prefixes\":");
391
+ assert!(serde_json::from_str::<crate::managed::Policy>(&duplicate).is_err());
392
+ }
393
+
394
+ #[test]
395
+ fn allocation_pool_guard_maximum_fits_the_atomic_graph_without_link_bindings() {
396
+ let prefixes: Vec<_> = (0..64).map(|part| format!("10.{part}.0.0/16")).collect();
397
+ let prepared = normalized(changed(pool_guard(), "/scope/prefixes", json!(prefixes)))
398
+ .unwrap()
399
+ .prepare()
400
+ .unwrap();
401
+ prepared.validate().unwrap();
402
+ prepared.validate_interfaces().unwrap();
403
+ let program = prepared
404
+ .program(&format!("snft_{}", "x".repeat(59)))
405
+ .unwrap();
406
+ let bytes: usize = program
407
+ .iter()
408
+ .map(|(_, attrs)| crate::wire::encode_attrs(attrs).len() + 20)
409
+ .sum();
410
+ assert!(program.len() <= 768 && bytes <= 100_000);
411
+ println!(
412
+ "POOL_GUARD_MAX prefixes=64 operations={} bytes={bytes}",
413
+ program.len()
414
+ );
415
+ let excessive: Vec<_> = (0..65).map(|part| format!("10.{part}.0.0/16")).collect();
416
+ assert!(normalized(changed(pool_guard(), "/scope/prefixes", json!(excessive))).is_err());
417
+ }
package/rust/src/main.rs CHANGED
@@ -1,4 +1,5 @@
1
1
  mod egress;
2
+ mod docker;
2
3
  mod managed;
3
4
  mod namespace;
4
5
  mod owner;
@@ -14,6 +15,7 @@ use std::io::{BufRead, Read, Write};
14
15
  pub enum Error {
15
16
  Invalid,
16
17
  Unsupported,
18
+ UnsupportedBackend,
17
19
  Permission,
18
20
  ForeignOwner,
19
21
  Conflict,
@@ -27,6 +29,7 @@ impl Error {
27
29
  match self {
28
30
  Self::Invalid => "INVALID",
29
31
  Self::Unsupported => "UNSUPPORTED_KERNEL",
32
+ Self::UnsupportedBackend => "UNSUPPORTED_BACKEND",
30
33
  Self::Permission => "PERMISSION",
31
34
  Self::ForeignOwner => "FOREIGN_OWNER",
32
35
  Self::Conflict => "CONFLICT",
@@ -101,13 +104,14 @@ fn dispatch(owner: &mut Option<owner::Owner>, request: Request) -> Result<serde_
101
104
 
102
105
  fn main() {
103
106
  let arguments = std::env::args().skip(1).collect::<Vec<_>>();
104
- let descriptor = match arguments.as_slice() {
105
- [management] if management == "--management" => None,
107
+ let (docker_mode, descriptor) = match arguments.as_slice() {
108
+ [management] if management == "--management" => (false, None),
109
+ [management] if management == "--docker-forwarding" => (true, None),
106
110
  [management, option, fd]
107
- if management == "--management" && option == "--network-namespace-fd" =>
111
+ if (management == "--management" || management == "--docker-forwarding") && option == "--network-namespace-fd" =>
108
112
  {
109
113
  match fd.parse::<i32>() {
110
- Ok(value) => Some(value),
114
+ Ok(value) => (management == "--docker-forwarding", Some(value)),
111
115
  Err(_) => std::process::exit(2),
112
116
  }
113
117
  }
@@ -125,6 +129,7 @@ fn main() {
125
129
  output.flush().unwrap();
126
130
  let mut input = std::io::stdin().lock();
127
131
  let mut owner = None;
132
+ let mut docker = None;
128
133
  loop {
129
134
  let mut bytes = Vec::new();
130
135
  let read = std::io::Read::by_ref(&mut input)
@@ -144,7 +149,7 @@ fn main() {
144
149
  std::process::exit(2);
145
150
  }
146
151
  let id = request.id.clone();
147
- let response = match dispatch(&mut owner, request) {
152
+ let response = match if docker_mode { docker::dispatch(&mut docker, request) } else { dispatch(&mut owner, request) } {
148
153
  Ok(result) => serde_json::json!({"id":id,"success":true,"result":result}),
149
154
  Err(error) => {
150
155
  serde_json::json!({"id":id,"success":false,"error":"Managed policy operation failed.","errorCode":error.code()})
@@ -34,6 +34,10 @@ impl Policy {
34
34
  scope: egress::Scope::HostTransit(_),
35
35
  ..
36
36
  }) => "hostTransitV2",
37
+ Self::Egress(egress::Policy {
38
+ scope: egress::Scope::AllocationPoolGuard(_),
39
+ ..
40
+ }) => "allocationPoolGuardV2",
37
41
  }
38
42
  }
39
43
  pub fn prepare(self) -> Result<Prepared> {
package/rust/src/owner.rs CHANGED
@@ -70,7 +70,7 @@ pub struct Owner {
70
70
  released: bool,
71
71
  }
72
72
 
73
- fn context() -> Result<(String, String, String)> {
73
+ pub(crate) fn context() -> Result<(String, String, String)> {
74
74
  let boot = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
75
75
  .map_err(|_| Error::Unavailable)?
76
76
  .trim()
@@ -112,6 +112,24 @@ fn kernel_floor() -> Result<()> {
112
112
  }
113
113
 
114
114
  impl Owner {
115
+ /// Read another dedicated owner's exact receipt without claiming its table.
116
+ /// PERSIST may retain enforcement after that owner's socket closes.
117
+ pub(crate) fn verify_external(expected: &Applied) -> Result<()> {
118
+ let identity = &expected.receipt.identity;
119
+ let mut reader = Self::new(Options {
120
+ owner_id: identity.owner_id.clone(),
121
+ instance_id: identity.instance_id.clone(),
122
+ table_name: identity.table_name.clone(),
123
+ })?;
124
+ reader.validate_receipt(expected)?;
125
+ let graph = reader.graph()?.ok_or(Error::Conflict)?;
126
+ reader.binding(&graph, Some(expected))?;
127
+ if !reader.matches(&graph, &expected.prepared)? {
128
+ return Err(Error::Conflict);
129
+ }
130
+ expected.prepared.validate_interfaces()
131
+ }
132
+
115
133
  pub fn new(options: Options) -> Result<Self> {
116
134
  if !identifier(&options.owner_id)
117
135
  || !identifier(&options.instance_id)
@@ -62,7 +62,7 @@ fn host() -> egress::Policy {
62
62
  value["scope"]["uplink"] = binding("uplink", "192.0.2.2", "veth");
63
63
  serde_json::from_value(value).unwrap()
64
64
  }
65
- fn recover(name: &str, policy: egress::Policy) {
65
+ pub(super) fn recover(name: &str, policy: egress::Policy) {
66
66
  let prepared: Prepared = policy.clone().prepare().unwrap().into();
67
67
  prepared.validate_interfaces().expect("fixture bindings");
68
68
  let mut owner = Owner::new(options(name)).unwrap();
@@ -205,6 +205,18 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
205
205
  ns_ip(ROUTER, &["address", "add", "10.242.0.1/24", "dev", "vpn"]);
206
206
  ns_ip(ROUTER, &["link", "set", "vpn", "up"]);
207
207
  let mut device = in_namespace(ROUTER, tun);
208
+ let (pool_owner, pool_applied) = in_namespace(HOST, || {
209
+ let mut owner = Owner::new(options("pool_coexistence")).unwrap();
210
+ let policy: egress::Policy = serde_json::from_value(egress::tests::pool_guard()).unwrap();
211
+ let applied = owner
212
+ .reconcile(Transition {
213
+ previous: None,
214
+ target: policy.prepare().unwrap().into(),
215
+ })
216
+ .unwrap();
217
+ assert!(owner.inspect().enforced);
218
+ (owner, applied)
219
+ });
208
220
  let (host_owner, host_applied) = in_namespace(HOST, || {
209
221
  let mut owner = Owner::new(options("host_packets")).unwrap();
210
222
  let applied = owner
@@ -260,11 +272,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
260
272
  });
261
273
  let listener = in_namespace(PEER, || TcpListener::bind("192.0.2.3:3000").unwrap());
262
274
  let mut client = in_namespace(WORK, || {
263
- TcpStream::connect_timeout(
264
- &"192.0.2.3:3000".parse().unwrap(),
265
- Duration::from_secs(1),
266
- )
267
- .unwrap()
275
+ TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
276
+ .unwrap()
268
277
  });
269
278
  let (mut server, peer) = listener.accept().unwrap();
270
279
  assert_eq!(peer.ip().to_string(), "192.0.2.2");
@@ -290,11 +299,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
290
299
  assert_eq!(syn[33] & 0xc0, 0xc0);
291
300
  assert!((10016..=10031).contains(&u16::from_be_bytes(syn[20..22].try_into().unwrap())));
292
301
  let mut local_tcp = in_namespace(ROUTER, || {
293
- TcpStream::connect_timeout(
294
- &"192.0.2.3:3000".parse().unwrap(),
295
- Duration::from_secs(1),
296
- )
297
- .unwrap()
302
+ TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
303
+ .unwrap()
298
304
  });
299
305
  let (mut local_peer, local_outer) = listener.accept().unwrap();
300
306
  assert!((10016..=10031).contains(&local_outer.port()));
@@ -466,5 +472,10 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
466
472
  let mut owner = host_owner;
467
473
  owner.release(host_applied).unwrap();
468
474
  });
475
+ in_namespace(HOST, move || {
476
+ let mut owner = pool_owner;
477
+ assert!(owner.inspect().enforced);
478
+ owner.release(pool_applied).unwrap();
479
+ });
469
480
  println!("V2_PACKET_PROOF private_dns=true tun_private=true tun_public_denied=true workload_public=true local_dns_and_tcp=true handoff_udp_tcp_ranges=true ecn_syn=true opening_ack_rst_denied=true fragments_checked=true range_exhaustion_denied=true protected_grant_denied=true ungranted_local_denied=true retired_replies_denied=true revoked_egress_denied=true disjoint_generation_restored=true flows_drained=false reusable=false");
470
481
  }
@@ -7,7 +7,7 @@ const SOURCE: &str = "hs_source";
7
7
  const HOST: &str = "hs_host";
8
8
  const PEER: &str = "hs_peer";
9
9
 
10
- fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
10
+ pub(super) fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
11
11
  vec![
12
12
  expr(
13
13
  "payload",
@@ -21,7 +21,7 @@ fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
21
21
  compare(bytes),
22
22
  ]
23
23
  }
24
- fn immediate(value: Vec<u8>) -> Attr {
24
+ pub(super) fn immediate(value: Vec<u8>) -> Attr {
25
25
  expr(
26
26
  "immediate",
27
27
  vec![
@@ -196,10 +196,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
196
196
  HOST,
197
197
  &["route", "add", "203.0.113.2/32", "via", "192.0.2.3"],
198
198
  );
199
- ns_ip(
200
- HOST,
201
- &["route", "add", "10.250.0.2/32", "via", "192.0.2.3"],
202
- );
199
+ ns_ip(HOST, &["route", "add", "10.250.0.2/32", "via", "192.0.2.3"]);
203
200
  for address in ["203.0.113.2/32", "10.250.0.2/32"] {
204
201
  ns_ip(PEER, &["address", "add", address, "dev", "lo"]);
205
202
  }
@@ -285,10 +282,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
285
282
  control(source, destination, target, false);
286
283
  }
287
284
  let allowed = socket(SOURCE, "10.240.0.2:10000");
288
- assert_eq!(
289
- roundtrip(&allowed, &public).ip().to_string(),
290
- "192.0.2.2"
291
- );
285
+ assert_eq!(roundtrip(&allowed, &public).ip().to_string(), "192.0.2.2");
292
286
  let platform = socket(PEER, "192.0.2.3:5300");
293
287
  roundtrip(&allowed, &platform);
294
288
  let unleased = socket(SOURCE, "10.240.0.2:9999");