@push.rocks/smartnftables 4.0.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/changelog.md +19 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +2 -1
  8. package/dist_ts/classes.manageddockerforwarding.js +3 -2
  9. package/dist_ts/forwardhooks.d.ts +8 -0
  10. package/dist_ts/forwardhooks.js +42 -0
  11. package/dist_ts/index.d.ts +2 -0
  12. package/dist_ts/index.js +2 -1
  13. package/dist_ts/managed.docker.types.d.ts +2 -1
  14. package/dist_ts/managed.egress.types.d.ts +5 -0
  15. package/dist_ts/managed.forwardhooks.types.d.ts +35 -0
  16. package/dist_ts/managed.forwardhooks.types.js +2 -0
  17. package/package.json +5 -4
  18. package/readme.md +139 -19
  19. package/rust/src/docker.frontend.rs +2 -2
  20. package/rust/src/docker.graph.rs +36 -11
  21. package/rust/src/docker.policy.rs +95 -32
  22. package/rust/src/docker.rs +37 -13
  23. package/rust/src/docker_tests.rs +164 -2
  24. package/rust/src/egress.host.rs +53 -45
  25. package/rust/src/egress.rs +64 -0
  26. package/rust/src/egress_exclusiveforwarding_tests.rs +256 -0
  27. package/rust/src/forwardhooks.rs +176 -0
  28. package/rust/src/forwardhooks_tests.rs +136 -0
  29. package/rust/src/main.rs +12 -8
  30. package/rust/src/owner_exclusiveforwarding_tests.rs +200 -0
  31. package/rust/src/owner_host_traffic_tests.rs +1 -1
  32. package/rust/src/owner_tests.rs +3 -0
  33. package/rust/src/wire.links.rs +28 -6
  34. package/rust/src/wire.rs +22 -2
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts/classes.manageddockerforwarding.ts +2 -1
  37. package/ts/forwardhooks.ts +39 -0
  38. package/ts/index.ts +2 -0
  39. package/ts/managed.docker.types.ts +2 -1
  40. package/ts/managed.egress.types.ts +5 -0
  41. package/ts/managed.forwardhooks.types.ts +26 -0
@@ -9,17 +9,36 @@ fn payload(base: u32, offset: u32, length: u32) -> Attr {
9
9
  expr("payload",vec![Attr::u32(1,1),Attr::u32(2,base),Attr::u32(3,offset),Attr::u32(4,length)])
10
10
  }
11
11
 
12
+ fn span(value: &str) -> Result<(u16, u16)> {
13
+ let (first,last)=value.split_once(':').unwrap_or((value,value));
14
+ Ok((first.parse::<u16>().map_err(|_|Error::Invalid)?,last.parse::<u16>().map_err(|_|Error::Invalid)?))
15
+ }
16
+ /// The one key of a rule among alternatives, with its value.
17
+ fn either<'a>(rule: &'a Rule, keys: [&'static str; 2]) -> Result<(usize, &'a str)> {
18
+ let mut found=keys.iter().enumerate().filter_map(|(index,key)|value(rule,key).ok().map(|value|(index,value)));
19
+ let result=found.next().ok_or(Error::Invalid)?;
20
+ if found.next().is_some() {return Err(Error::Invalid);}
21
+ Ok(result)
22
+ }
23
+
12
24
  /// Fixed Linux UAPI / xtables 1.8.10 and 1.8.11 constructors, not a raw rule API.
13
25
  pub(super) fn expected(rule: &Rule) -> Result<Vec<Attr>> {
14
26
  let reply=value(rule,"--ctdir")?=="REPLY";
15
27
  let new=value(rule,"--ctstate")?=="NEW";
16
28
  let protocol=match value(rule,"-p")? {"tcp"=>6_u16,"udp"=>17,_=>return Err(Error::Invalid)};
17
- let address=value(rule,"--ctorigsrc")?.parse::<std::net::Ipv4Addr>().map_err(|_|Error::Invalid)?.octets();
18
- let ports=value(rule,"--ctorigsrcport")?;
19
- let (first,last)=ports.split_once(':').unwrap_or((ports,ports));
20
- let first=first.parse::<u16>().map_err(|_|Error::Invalid)?;
21
- let last=last.parse::<u16>().map_err(|_|Error::Invalid)?;
22
- let mut result=vec![payload(1,if reply {16} else {12},4),compare(address.to_vec())];
29
+ // The packet's own source (0) or destination (1) address and port.
30
+ let (side,live)=either(rule,["-s","-d"])?;
31
+ let live=live.strip_suffix("/32").ok_or(Error::Invalid)?.parse::<std::net::Ipv4Addr>().map_err(|_|Error::Invalid)?.octets();
32
+ let (port_side,ports)=either(rule,["--sport","--dport"])?;
33
+ if port_side!=side {return Err(Error::Invalid);}
34
+ let (first,last)=span(ports)?;
35
+ // The originally tracked source (0) or destination (1) address and port.
36
+ let (tracked_side,tracked)=either(rule,["--ctorigsrc","--ctorigdst"])?;
37
+ let tracked=tracked.parse::<std::net::Ipv4Addr>().map_err(|_|Error::Invalid)?.octets();
38
+ let (tracked_port_side,tracked_ports)=either(rule,["--ctorigsrcport","--ctorigdstport"])?;
39
+ if tracked_port_side!=tracked_side {return Err(Error::Invalid);}
40
+ let (tracked_first,tracked_last)=span(tracked_ports)?;
41
+ let mut result=vec![payload(1,if side==0 {12} else {16},4),compare(live.to_vec())];
23
42
  for (key,argument) in [(6,"-i"),(7,"-o")] {
24
43
  result.extend(meta(key,[value(rule,argument)?.as_bytes(),&[0]].concat()));
25
44
  }
@@ -30,7 +49,7 @@ pub(super) fn expected(rule: &Rule) -> Result<Vec<Attr>> {
30
49
  Attr::nested(4,vec![Attr::bytes(1,vec![0x17])]),Attr::nested(5,vec![Attr::bytes(1,vec![0])]),
31
50
  ]),compare(vec![2])]);
32
51
  }
33
- result.push(payload(2,if reply {2} else {0},2));
52
+ result.push(payload(2,if side==0 {0} else {2},2));
34
53
  result.push(if first==last {compare(first.to_be_bytes().to_vec())} else {
35
54
  expr("range",vec![Attr::u32(1,1),Attr::u32(2,0),
36
55
  Attr::nested(3,vec![Attr::bytes(1,first.to_be_bytes().to_vec())]),
@@ -38,11 +57,17 @@ pub(super) fn expected(rule: &Rule) -> Result<Vec<Attr>> {
38
57
  });
39
58
  // xt_conntrack_mtinfo3 is 164 bytes, XT_ALIGN'ed to 168 on both shipped
40
59
  // 64-bit Linux targets. All fields and padding are initialized explicitly.
60
+ // The original source address/mask are at 0/16, its ports at 138/154; the
61
+ // original destination's at 32/48 and 140/156.
41
62
  let mut conntrack=vec![0;168];
42
- conntrack[..4].copy_from_slice(&address);
43
- conntrack[16..32].fill(0xff); // canonical bare-host nf_inet_addr mask
44
- for (offset,number) in [(136,protocol),(138,first),(146,0x1107),
45
- (148,if reply {0x1000} else {0}),(150,if new {8} else {2}),(154,last)] {
63
+ let address=32*tracked_side;
64
+ conntrack[address..address+4].copy_from_slice(&tracked);
65
+ conntrack[address+16..address+32].fill(0xff); // canonical bare-host nf_inet_addr mask
66
+ // STATE|PROTO|DIRECTION with ORIGSRC|ORIGSRC_PORT or ORIGDST|ORIGDST_PORT.
67
+ let flags=0x1003|if tracked_side==0 {0x0104} else {0x0208};
68
+ let port=2*tracked_side;
69
+ for (offset,number) in [(136,protocol),(138+port,tracked_first),(146,flags),
70
+ (148,if reply {0x1000} else {0}),(150,if new {8} else {2}),(154+port,tracked_last)] {
46
71
  conntrack[offset..offset+2].copy_from_slice(&number.to_ne_bytes());
47
72
  }
48
73
  result.push(expr("match",vec![Attr::string(1,"conntrack"),Attr::u32(2,3),Attr::bytes(3,conntrack)]));
@@ -63,7 +63,11 @@ impl Policy {
63
63
  return Err(Error::Invalid);
64
64
  }
65
65
  self.barrier.prepared.validate()?;
66
- self.scope()?;
66
+ // The contribution admits handoff flows through Docker's FORWARD path; an
67
+ // exclusive barrier would drop Docker's own container forwarding.
68
+ if self.scope()?.exclusive_forwarding {
69
+ return Err(Error::Invalid);
70
+ }
67
71
  let receipt = &self.barrier.receipt;
68
72
  let handle = receipt.table_handle.parse::<u64>().map_err(|_| Error::Invalid)?;
69
73
  if handle == 0 || handle.to_string() != receipt.table_handle
@@ -95,46 +99,105 @@ pub struct Rule {
95
99
  fn port_range(first: u16, last: u16) -> String {
96
100
  if first == last { first.to_string() } else { format!("{first}:{last}") }
97
101
  }
102
+ /// One side of a packet or of its originally tracked tuple: an exact address
103
+ /// and an inclusive port span.
104
+ #[derive(Clone, Copy)]
105
+ struct Tuple<'a> {
106
+ address: &'a str,
107
+ first: u16,
108
+ last: u16,
109
+ }
110
+ /// One flow class of the barrier's forward chain, in its original direction:
111
+ /// it enters on `input`, leaves on `output`, carries `live` as its source
112
+ /// (`source`) or destination, and conntrack tracked it with `tracked` as its
113
+ /// original source (`source`) or destination. A leased or symmetric flow is
114
+ /// keyed on its own source, before outer source NAT; an inbound publication on
115
+ /// its translated inside destination and its published outside destination.
116
+ struct Flow<'a> {
117
+ input: &'a str,
118
+ output: &'a str,
119
+ protocol: &'a str,
120
+ source: bool,
121
+ live: Tuple<'a>,
122
+ tracked: Tuple<'a>,
123
+ }
124
+ impl Flow<'_> {
125
+ /// The ESTABLISHED original direction, a NEW opening (TCP only with SYN and
126
+ /// FIN/RST/ACK clear) and the ESTABLISHED reply, as the barrier admits them.
127
+ fn rules(&self, mut push: impl FnMut(Vec<String>) -> Result<()>) -> Result<()> {
128
+ for state in ["NEW", "ESTABLISHED", "REPLY"] {
129
+ let reply = state == "REPLY";
130
+ // The reply swaps the interfaces and the packet's tuple sides; the
131
+ // originally tracked tuple stays the same.
132
+ let source = self.source != reply;
133
+ let mut args: Vec<String> = vec![
134
+ "-i".into(), if reply { self.output } else { self.input }.into(),
135
+ "-o".into(), if reply { self.input } else { self.output }.into(),
136
+ if source { "-s" } else { "-d" }.into(), format!("{}/32", self.live.address),
137
+ "-p".into(), self.protocol.into(), "-m".into(), self.protocol.into(),
138
+ if source { "--sport" } else { "--dport" }.into(), port_range(self.live.first, self.live.last),
139
+ ];
140
+ if state == "NEW" && self.protocol == "tcp" {
141
+ args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
142
+ }
143
+ let (address, port) = if self.source { ("--ctorigsrc", "--ctorigsrcport") } else { ("--ctorigdst", "--ctorigdstport") };
144
+ args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if self.protocol == "tcp" {"6"} else {"17"},address].map(String::from));
145
+ // Canonical xtables host form: explicit /32 has different unused
146
+ // conntrack mask bytes and does not survive a save/restore round trip.
147
+ args.push(self.tracked.address.into());
148
+ args.push(port.into());args.push(port_range(self.tracked.first,self.tracked.last));
149
+ args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"}].map(String::from));
150
+ push(args)?;
151
+ }
152
+ Ok(())
153
+ }
154
+ }
98
155
  impl Prepared {
99
156
  pub fn validate(&self) -> Result<()> {
100
157
  if self.policy.clone().prepare(self.backend.clone())? != *self { return Err(Error::Invalid); }
101
158
  Ok(())
102
159
  }
160
+ /// Every forwarded flow the barrier admits, from the scope's one forwarding
161
+ /// enumeration: leased ranges first, then each publication's inbound flows
162
+ /// and, when symmetric, the flows it opens from its target ports.
163
+ fn flows(&self) -> Result<Vec<Flow<'_>>> {
164
+ let scope = self.policy.scope()?;
165
+ let forwarding = scope.forwarding()?;
166
+ let uplink = scope.uplink.interface_name.as_str();
167
+ let mut flows = Vec::new();
168
+ for leased in &forwarding.leased {
169
+ let tuple = Tuple { address:&leased.allocation.transit_source_address, first:leased.range.first, last:leased.range.last };
170
+ flows.push(Flow { input:&leased.handoff.link.interface_name, output:uplink, protocol:&leased.range.protocol,
171
+ source:true, live:tuple, tracked:tuple });
172
+ }
173
+ for published in &forwarding.published {
174
+ let port = published.port;
175
+ let inside = Tuple { address:&port.target_address, first:port.target_port, last:port.target_last() };
176
+ let outside = Tuple { address:&port.host_ip, first:port.host_port, last:port.host_last() };
177
+ let link = published.link.interface_name.as_str();
178
+ flows.push(Flow { input:uplink, output:link, protocol:&port.protocol, source:false, live:inside, tracked:outside });
179
+ if port.symmetric {
180
+ flows.push(Flow { input:link, output:uplink, protocol:&port.protocol, source:true, live:inside, tracked:inside });
181
+ }
182
+ }
183
+ Ok(flows)
184
+ }
103
185
  pub fn rules(&self, options: &Options) -> Result<Vec<Rule>> {
104
186
  options.validate()?;
105
- let scope = self.policy.scope()?;
187
+ let digest = self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?;
106
188
  let mut result = Vec::new();
107
189
  let mut bytes = 0;
108
- for handoff in &scope.handoffs {
109
- for allocation in &handoff.allocations {
110
- for range in &allocation.source_port_ranges {
111
- for state in ["NEW", "ESTABLISHED", "REPLY"] {
112
- let reply = state == "REPLY";
113
- let mut args: Vec<String> = vec![
114
- "-i".into(), if reply { scope.uplink.interface_name.clone() } else { handoff.link.interface_name.clone() },
115
- "-o".into(), if reply { handoff.link.interface_name.clone() } else { scope.uplink.interface_name.clone() },
116
- if reply { "-d" } else { "-s" }.into(), format!("{}/32", allocation.transit_source_address),
117
- "-p".into(),range.protocol.clone(),"-m".into(),range.protocol.clone(),
118
- if reply { "--dport" } else { "--sport" }.into(),port_range(range.first,range.last),
119
- ];
120
- if state == "NEW" && range.protocol == "tcp" {
121
- args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
122
- }
123
- args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if range.protocol == "tcp" {"6"} else {"17"},"--ctorigsrc"].map(String::from));
124
- // Canonical xtables host form: explicit /32 has different unused
125
- // conntrack mask bytes and does not survive a save/restore round trip.
126
- args.push(allocation.transit_source_address.clone());
127
- args.push("--ctorigsrcport".into());args.push(port_range(range.first,range.last));
128
- args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"},"-m","comment","--comment"].map(String::from));
129
- let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?,result.len());
130
- args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
131
- bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
132
- crate::capacity("restoreRules", 192, result.len() + 1)?;
133
- crate::capacity("restoreBytes", 100_000, bytes)?;
134
- result.push(Rule {comment,args});
135
- }
136
- }
137
- }
190
+ for flow in self.flows()? {
191
+ flow.rules(|mut args| {
192
+ let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,digest,result.len());
193
+ args.extend(["-m","comment","--comment"].map(String::from));
194
+ args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
195
+ bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
196
+ crate::capacity("restoreRules", 192, result.len() + 1)?;
197
+ crate::capacity("restoreBytes", 100_000, bytes)?;
198
+ result.push(Rule {comment,args});
199
+ Ok(())
200
+ })?;
138
201
  }
139
202
  Ok(result)
140
203
  }
@@ -71,13 +71,38 @@ impl Owner {
71
71
  || expected.receipt.backend!=expected.prepared.backend {return Err(Error::Conflict);}
72
72
  Ok(())
73
73
  }
74
- fn fence(target: &Prepared) -> Result<()> {
75
- let scope=target.policy.scope()?;
76
- wire::verify_bound_interfaces_down(scope.handoffs.iter().map(|handoff| {
77
- let link=&handoff.link;
78
- wire::BoundInterface {index:link.interface_index,name:&link.interface_name,kind:&link.interface_kind,
79
- mac:link.mac_address.as_deref(),link_index:link.interface_link_index,addresses:&link.required_ipv4_addresses}
80
- }))
74
+ fn handoffs(prepared: &Prepared) -> Result<Vec<&crate::egress::LocalLink>> {
75
+ Ok(prepared.policy.scope()?.handoffs.iter().map(|handoff|&handoff.link).collect())
76
+ }
77
+ fn bound(link: &crate::egress::LocalLink) -> wire::BoundInterface<'_> {
78
+ wire::BoundInterface {index:link.interface_index,name:&link.interface_name,kind:&link.interface_kind,
79
+ mac:link.mac_address.as_deref(),link_index:link.interface_link_index,addresses:&link.required_ipv4_addresses}
80
+ }
81
+ /// The handoff links of a transition: those both generations bind exactly,
82
+ /// and those it adds or drops.
83
+ fn fenced<'a>(previous: Option<&'a Prepared>, target: &'a Prepared) -> Result<(Vec<&'a crate::egress::LocalLink>, Vec<&'a crate::egress::LocalLink>)> {
84
+ let previous=previous.map(Self::handoffs).transpose()?.unwrap_or_default();
85
+ let target=Self::handoffs(target)?;
86
+ let kept=target.iter().copied().filter(|link|previous.contains(link)).collect();
87
+ let changed=previous.iter().copied().filter(|link|!target.contains(link))
88
+ .chain(target.iter().copied().filter(|link|!previous.contains(link))).collect();
89
+ Ok((kept,changed))
90
+ }
91
+ /// Reconcile fence, before and after the single restore transaction. A handoff
92
+ /// link both generations bind exactly may carry packets: the transaction swaps
93
+ /// the owned block atomically, so a flow both contributions admit passes
94
+ /// throughout, and the referenced barrier already is the target. Every link
95
+ /// the transition adds or drops must be down.
96
+ fn fence(previous: Option<&Prepared>, target: &Prepared) -> Result<()> {
97
+ let (kept,changed)=Self::fenced(previous,target)?;
98
+ wire::verify_bound_interfaces(kept.into_iter().map(Self::bound))?;
99
+ wire::verify_bound_interfaces_down(changed.into_iter().map(Self::bound))
100
+ }
101
+ /// Cleanup fence: deletion only withdraws admission. A handoff link that no
102
+ /// longer exists, such as a veth that left with its router namespace, counts
103
+ /// as down; one that exists must still be the exact bound link, and down.
104
+ fn cleanup_fence(prepared: &Prepared) -> Result<()> {
105
+ wire::verify_bound_interfaces_down_or_absent(Self::handoffs(prepared)?.into_iter().map(Self::bound))
81
106
  }
82
107
  fn applied(&self, prepared: Prepared, backend: String) -> Applied {
83
108
  Applied {receipt:Receipt {identity:self.identity.clone(),backend,revision:prepared.policy.revision,digest:prepared.digest.clone()},prepared}
@@ -102,15 +127,14 @@ impl Owner {
102
127
  return Ok(self.applied(transition.target.clone(),snapshot.backend));
103
128
  }
104
129
  if !snapshot.verified_matches(&self.options,&previous_rules,deadline)? {return Err(Error::Conflict);}
105
- if let Some(previous)=&transition.previous {Self::fence(&previous.prepared)?;}
106
- Self::fence(&transition.target)?;
130
+ let previous=transition.previous.as_ref().map(|v|&v.prepared);
131
+ Self::fence(previous,&transition.target)?;
107
132
  frontend::replace(&previous_rules,&target_rules,deadline)?;
108
133
  self.context()?;
109
134
  let current=frontend::Snapshot::read(deadline)?;
110
135
  if current.backend!=snapshot.backend || !current.verified_matches(&self.options,&target_rules,deadline)? {return Err(Error::Conflict);}
111
136
  owner::Owner::verify_external(&transition.target.policy.barrier)?;
112
- if let Some(previous)=&transition.previous {Self::fence(&previous.prepared)?;}
113
- Self::fence(&transition.target)?;
137
+ Self::fence(previous,&transition.target)?;
114
138
  Ok(self.applied(transition.target.clone(),current.backend))
115
139
  })();
116
140
  match result {
@@ -141,12 +165,12 @@ impl Owner {
141
165
  if current.verified_matches(&self.options,&[],deadline)? {return Ok(());}
142
166
  let rules=expected.prepared.rules(&self.options)?;
143
167
  if !current.verified_matches(&self.options,&rules,deadline)? {return Err(Error::Conflict);}
144
- Self::fence(&expected.prepared)?;
168
+ Self::cleanup_fence(&expected.prepared)?;
145
169
  frontend::replace(&rules,&[],deadline)?;
146
170
  self.context()?;
147
171
  let after=frontend::Snapshot::read(deadline)?;
148
172
  if after.backend!=current.backend || !after.verified_matches(&self.options,&[],deadline)? {return Err(Error::Conflict);}
149
- Self::fence(&expected.prepared)
173
+ Self::cleanup_fence(&expected.prepared)
150
174
  })();
151
175
  match result {Ok(())=>{self.applied=Some(expected);self.error=None;self.released=true;Ok(())},Err(error)=>{self.error=Some(error);Err(error)}}
152
176
  }
@@ -2,8 +2,9 @@ use super::*;
2
2
  use serde_json::json;
3
3
 
4
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();
5
+ fn policy() -> policy::Policy {policy_from(crate::egress::tests::host())}
6
+ fn policy_from(host: serde_json::Value) -> policy::Policy {
7
+ let prepared:crate::managed::Prepared=serde_json::from_value::<crate::egress::Policy>(host).unwrap().prepare().unwrap().into();
7
8
  policy::Policy {schema_version:1,revision:1,barrier:owner::Applied {
8
9
  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
  }
@@ -30,6 +31,9 @@ fn docker_prepare_rejects_router_receipts_bad_hashes_and_unbounded_restore() {
30
31
  let mut value=policy();value.schema_version=2;assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
31
32
  let mut value=policy();value.barrier.receipt.digest="sha256:wrong".into();assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
32
33
  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());
34
+ let mut exclusive=crate::egress::tests::host();exclusive["scope"]["exclusiveForwarding"]=json!(true);
35
+ let prepared:crate::managed::Prepared=serde_json::from_value::<crate::egress::Policy>(exclusive).unwrap().prepare().unwrap().into();
36
+ let mut value=policy();value.barrier.receipt.digest=prepared.digest.clone();value.barrier.prepared=prepared;assert!(value.prepare("v1.8.11 (nf_tables)".into()).is_err());
33
37
  let value=serde_json::to_value(policy()).unwrap();
34
38
  let mut extra=value.clone();extra["rules"]=json!([]);assert!(serde_json::from_value::<policy::Policy>(extra).is_err());
35
39
  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());
@@ -146,3 +150,161 @@ fn docker_forward_jumps_require_exact_unconditional_order() {
146
150
  assert!(!graph::matches_forward(&row(expected),if target=="DOCKER-USER" {"DOCKER-FORWARD"} else {"DOCKER-USER"}).unwrap());
147
151
  }
148
152
  }
153
+
154
+ /// The exact 4.1.0 contribution of a scope without publications: arguments,
155
+ /// order, comments and native graph bytes are unchanged.
156
+ #[test]
157
+ fn docker_leased_contribution_keeps_its_exact_previous_rules_and_graphs() {
158
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
159
+ let comment="snftd1:dockerfixture:qualification_20260913:2023fff251e6e68c6ec275e6b5e76f609b8bc0210b18ae3f3c88bfac3a54f5ee";
160
+ let expected=[
161
+ "-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 10000:10999 --tcp-flags FIN,SYN,RST,ACK SYN -m conntrack --ctstate NEW --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
162
+ "-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
163
+ "-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir REPLY",
164
+ "-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 10000:10999 -m conntrack --ctstate NEW --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
165
+ "-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir ORIGINAL",
166
+ "-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 10000:10999 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 10000:10999 --ctdir REPLY",
167
+ ];
168
+ assert_eq!(rules.len(),expected.len());
169
+ for (index,(rule,args)) in rules.iter().zip(expected).enumerate() {
170
+ assert_eq!(rule.args.join(" "),format!("{args} -m comment --comment {comment}:{index} -j ACCEPT"));
171
+ }
172
+ let mut digest=<sha2::Sha256 as sha2::Digest>::new();
173
+ for rule in &rules {sha2::Digest::update(&mut digest,crate::wire::encode_attrs(&graph::expected(rule).unwrap()));}
174
+ assert_eq!(format!("{:x}",sha2::Digest::finalize(digest)),"dc46825f57c6f0001c1a517760e9e5560e4a5ee90f00322075c4b96e41e24c79");
175
+ }
176
+
177
+ fn published() -> policy::Policy {
178
+ let mut range=crate::egress::tests::published_port("udp",20000,20000);
179
+ range["hostPortEnd"]=json!(20200);range["symmetric"]=json!(true);
180
+ policy_from(crate::egress::tests::published_host(json!([range,crate::egress::tests::published_port("tcp",443,8443)])))
181
+ }
182
+ fn conntrack(expressions: &[crate::wire::Attr]) -> Vec<u8> {
183
+ for expression in expressions {
184
+ let fields=crate::wire::attrs(&expression.value).unwrap();
185
+ if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
186
+ let data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
187
+ if crate::wire::text(&data,1).unwrap()=="conntrack" {return crate::wire::one(&data,3).unwrap().value.clone();}
188
+ }
189
+ panic!("no conntrack match")
190
+ }
191
+ fn word(bytes: &[u8], offset: usize) -> u16 {u16::from_ne_bytes([bytes[offset],bytes[offset+1]])}
192
+
193
+ /// Inbound publications and their replies, and the flows a symmetric
194
+ /// publication opens from its target ports, as the barrier's forward chain
195
+ /// admits them: leased ranges first, then publications in canonical order.
196
+ #[test]
197
+ fn docker_contribution_admits_every_published_and_symmetric_flow_of_the_barrier() {
198
+ let prepared=published().prepare("v1.8.11 (nf_tables)".into()).unwrap();
199
+ let rules=prepared.rules(&options()).unwrap();
200
+ // 2 leased ranges, 2 inbound publications and 1 symmetric publication, each
201
+ // NEW, ESTABLISHED and the ESTABLISHED reply.
202
+ assert_eq!(rules.len(),15);
203
+ let args=|index: usize|rules[index].args[..rules[index].args.len()-6].join(" ");
204
+ assert_eq!(args(6),"-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 8443 --tcp-flags FIN,SYN,RST,ACK SYN -m conntrack --ctstate NEW --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir ORIGINAL");
205
+ assert_eq!(args(7),"-i ens18 -o handoff -d 10.240.0.2/32 -p tcp -m tcp --dport 8443 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir ORIGINAL");
206
+ assert_eq!(args(8),"-i handoff -o ens18 -s 10.240.0.2/32 -p tcp -m tcp --sport 8443 -m conntrack --ctstate ESTABLISHED --ctproto 6 --ctorigdst 192.0.2.2 --ctorigdstport 443 --ctdir REPLY");
207
+ assert_eq!(args(9),"-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 20000:20200 -m conntrack --ctstate NEW --ctproto 17 --ctorigdst 192.0.2.2 --ctorigdstport 20000:20200 --ctdir ORIGINAL");
208
+ assert_eq!(args(11),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigdst 192.0.2.2 --ctorigdstport 20000:20200 --ctdir REPLY");
209
+ assert_eq!(args(12),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate NEW --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir ORIGINAL");
210
+ assert_eq!(args(13),"-i handoff -o ens18 -s 10.240.0.2/32 -p udp -m udp --sport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir ORIGINAL");
211
+ assert_eq!(args(14),"-i ens18 -o handoff -d 10.240.0.2/32 -p udp -m udp --dport 20000:20200 -m conntrack --ctstate ESTABLISHED --ctproto 17 --ctorigsrc 10.240.0.2 --ctorigsrcport 20000:20200 --ctdir REPLY");
212
+ // Without publications only the leased rules remain; a non-symmetric
213
+ // publication opens nothing from its target ports.
214
+ let mut plain=crate::egress::tests::published_port("udp",20000,20000);plain["hostPortEnd"]=json!(20200);
215
+ let plain=policy_from(crate::egress::tests::published_host(json!([plain]))).prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
216
+ assert_eq!(plain.len(),9);
217
+ assert!(plain.iter().all(|rule|!rule.args.windows(2).any(|w|w==["--ctorigsrcport","20000:20200"])));
218
+ assert!(snapshot(&text(&rules)).matches(&options(),&rules).unwrap());
219
+ }
220
+
221
+ #[test]
222
+ fn docker_inbound_graph_binds_the_original_destination_tuple() {
223
+ let rules=published().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
224
+ for (index,reply) in [(6,false),(8,true)] {
225
+ let expressions=graph::expected(&rules[index]).unwrap();
226
+ let data=conntrack(&expressions);
227
+ assert_eq!(data.len(),168);
228
+ assert!(data[..32].iter().all(|byte|*byte==0));
229
+ assert_eq!(&data[32..36],&[192,0,2,2]);
230
+ assert!(data[36..48].iter().all(|byte|*byte==0) && data[48..64].iter().all(|byte|*byte==0xff));
231
+ assert_eq!((word(&data,136),word(&data,138),word(&data,140),word(&data,146),word(&data,148),word(&data,150)),
232
+ (6,0,443,0x120b,if reply {0x1000} else {0},if reply {2} else {8}));
233
+ assert_eq!((word(&data,154),word(&data,156)),(0,443));
234
+ // The live tuple: the translated inside destination, or its reply source.
235
+ let payload=crate::wire::attrs(&expressions[0].value).unwrap();
236
+ let fields=crate::wire::attrs(&crate::wire::one(&payload,2).unwrap().value).unwrap();
237
+ assert_eq!(crate::wire::one(&fields,3).unwrap().value,(if reply {12_u32} else {16}).to_be_bytes());
238
+ let row=|expressions|vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
239
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,expressions)];
240
+ assert!(graph::matches_rule(&row(expressions.clone()),&rules[index]).unwrap());
241
+ let mut hidden=expressions.clone();
242
+ for expression in &mut hidden {
243
+ let mut fields=crate::wire::attrs(&expression.value).unwrap();
244
+ if crate::wire::text(&fields,1).unwrap()!="match" {continue;}
245
+ let mut data=crate::wire::attrs(&crate::wire::one(&fields,2).unwrap().value).unwrap();
246
+ if crate::wire::text(&data,1).unwrap()!="conntrack" {continue;}
247
+ data.iter_mut().find(|a|a.id()==3).unwrap().value[52..64].fill(0);
248
+ *fields.iter_mut().find(|a|a.id()==2).unwrap()=crate::wire::Attr::nested(2,data);
249
+ expression.value=crate::wire::encode_attrs(&fields);
250
+ }
251
+ assert!(!graph::matches_rule(&row(hidden),&rules[index]).unwrap());
252
+ }
253
+ // A ranged inbound publication binds its whole outside span.
254
+ let data=conntrack(&graph::expected(&rules[9]).unwrap());
255
+ assert_eq!((word(&data,140),word(&data,156)),(20000,20200));
256
+ }
257
+
258
+ #[test]
259
+ fn docker_snapshot_accepts_rendered_destination_tuples_and_rejects_changed_ones() {
260
+ let rules=published().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
261
+ let original=text(&rules);
262
+ assert!(snapshot(&original.replace("--ctorigdst 192.0.2.2 ","--ctorigdst 192.0.2.2/32 ")).matches(&options(),&rules).unwrap());
263
+ for (before,after) in [("--ctorigdstport 443","--ctorigdstport 444"),("--ctorigdst 192.0.2.2","--ctorigsrc 192.0.2.2"),
264
+ ("--dport 8443","--dport 8444"),("--ctorigdst 192.0.2.2","--ctorigdst 192.0.2.3")] {
265
+ assert!(!snapshot(&original.replacen(before,after,1)).matches(&options(),&rules).unwrap());
266
+ }
267
+ }
268
+
269
+ #[test]
270
+ fn docker_contribution_keeps_its_restore_bound() {
271
+ let ports:Vec<_>=(0..64_u16).map(|index|crate::egress::tests::published_port("tcp",443+index,443+index)).collect();
272
+ let error=policy_from(crate::egress::tests::published_host(json!(ports))).prepare("v1.8.11 (nf_tables)".into()).unwrap_err();
273
+ assert_eq!(error.code(),"EXHAUSTED");
274
+ }
275
+
276
+ fn absent(mut host: serde_json::Value) -> serde_json::Value {
277
+ // No link holds this index on a test host.
278
+ host["scope"]["handoffs"][0]["link"]["interfaceIndex"]=json!(2_147_483_000_u32);host
279
+ }
280
+
281
+ #[test]
282
+ fn docker_reconcile_fence_lets_only_links_both_generations_bind_carry_packets() {
283
+ let target=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap();
284
+ let amended=published().prepare("v1.8.11 (nf_tables)".into()).unwrap();
285
+ let moved=policy_from(absent(crate::egress::tests::host())).prepare("v1.8.11 (nf_tables)".into()).unwrap();
286
+ let names=|links: Vec<&crate::egress::LocalLink>|links.iter().map(|link|link.interface_index).collect::<Vec<_>>();
287
+ let (kept,changed)=Owner::fenced(None,&target).unwrap();
288
+ assert_eq!((names(kept),names(changed)),(vec![],vec![4]));
289
+ let (kept,changed)=Owner::fenced(Some(&target),&amended).unwrap();
290
+ assert_eq!((names(kept),names(changed)),(vec![4],vec![]));
291
+ let (kept,changed)=Owner::fenced(Some(&target),&moved).unwrap();
292
+ assert_eq!((names(kept),names(changed)),(vec![],vec![4,2_147_483_000]));
293
+ // A link the transition keeps must still exist exactly.
294
+ assert!(Owner::fence(Some(&moved),&moved).is_err());
295
+ assert!(Owner::fence(None,&moved).is_err());
296
+ }
297
+
298
+ #[test]
299
+ fn docker_cleanup_fence_counts_a_vanished_handoff_as_down() {
300
+ let gone=policy_from(absent(crate::egress::tests::host())).prepare("v1.8.11 (nf_tables)".into()).unwrap();
301
+ Owner::cleanup_fence(&gone).unwrap();
302
+ // The mutation fence still requires the exact link.
303
+ let link=&gone.policy.scope().unwrap().handoffs[0].link;
304
+ assert!(crate::wire::verify_bound_interfaces_down(std::iter::once(Owner::bound(link))).is_err());
305
+ // A different link at a bound index is not the bound link.
306
+ let mut loopback=crate::egress::tests::host();
307
+ loopback["scope"]["handoffs"][0]["link"]["interfaceIndex"]=json!(1);
308
+ let loopback=policy_from(loopback).prepare("v1.8.11 (nf_tables)".into()).unwrap();
309
+ assert_eq!(Owner::cleanup_fence(&loopback),Err(Error::Conflict));
310
+ }
@@ -84,34 +84,31 @@ fn leased(
84
84
  expressions.extend(original_range(range.first, range.last));
85
85
  Ok(expressions)
86
86
  }
87
- /// The publications of this hop. Validation admits only leased transit source
88
- /// addresses as targets, and one address never spans two active handoff links,
89
- /// so each publication has exactly one handoff.
90
- fn hop(scope: &HostScope) -> Result<published::Hop<'_>> {
91
- let mut publications = Vec::new();
92
- for port in &scope.published_ports {
93
- let handoff = scope
94
- .handoffs
95
- .iter()
96
- .find(|handoff| {
97
- handoff
98
- .allocations
99
- .iter()
100
- .any(|allocation| allocation.transit_source_address == port.target_address)
101
- })
102
- .ok_or(crate::Error::Invalid)?;
103
- publications.push(published::Publication {
104
- protocol: &port.protocol,
105
- outside: (&port.host_ip, port.host_port, port.host_last()),
106
- inside: (&port.target_address, port.target_port, port.target_last()),
107
- link: &handoff.link,
108
- symmetric: port.symmetric,
109
- });
110
- }
111
- Ok(published::Hop {
87
+ /// The publications of this hop, from the scope's forwarding enumeration.
88
+ fn hop<'a>(scope: &'a HostScope, forwarding: &Forwarding<'a>) -> published::Hop<'a> {
89
+ let publications = forwarding
90
+ .published
91
+ .iter()
92
+ .map(|item| published::Publication {
93
+ protocol: &item.port.protocol,
94
+ outside: (
95
+ &item.port.host_ip,
96
+ item.port.host_port,
97
+ item.port.host_last(),
98
+ ),
99
+ inside: (
100
+ &item.port.target_address,
101
+ item.port.target_port,
102
+ item.port.target_last(),
103
+ ),
104
+ link: item.link,
105
+ symmetric: item.port.symmetric,
106
+ })
107
+ .collect();
108
+ published::Hop {
112
109
  publications,
113
110
  fixed: &scope.uplink,
114
- })
111
+ }
115
112
  }
116
113
  /// The exact platform endpoints served on host addresses. Every packet is
117
114
  /// checked against the live and the originally tracked destination tuple, so a
@@ -219,7 +216,8 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
219
216
  .ok_or(crate::Error::Invalid)
220
217
  })?;
221
218
  }
222
- let hop = hop(scope)?;
219
+ let forwarding = scope.forwarding()?;
220
+ let hop = hop(scope, &forwarding);
223
221
  published::sets(program, &hop, false)?;
224
222
  protection(program, scope, false)?;
225
223
  protection(program, scope, true)?;
@@ -241,26 +239,27 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
241
239
  program.jump("forward", link(&handoff.link, true), "protected_out")?;
242
240
  program.jump("forward", link(&handoff.link, false), "protected_back")?;
243
241
  }
244
- for handoff in &scope.handoffs {
245
- for allocation in &handoff.allocations {
246
- for range in &allocation.source_port_ranges {
247
- let original = envelope(scope, handoff, allocation, range, false)?;
248
- for connection_state in [2, 8] {
249
- let mut expressions = original.clone();
250
- expressions.extend(state(connection_state));
251
- if connection_state == 8 && range.protocol == "tcp" {
252
- expressions.extend(opening_tcp());
253
- }
254
- program.end("forward", expressions, 1)?;
255
- }
256
- let mut reply = envelope(scope, handoff, allocation, range, true)?;
257
- reply.extend(state(2));
258
- program.end("forward", reply, 1)?;
259
- let mut nat = original;
260
- nat.extend(snat(&scope.snat_address, None)?);
261
- program.rule("post", nat)?;
242
+ for LeasedForward {
243
+ handoff,
244
+ allocation,
245
+ range,
246
+ } in &forwarding.leased
247
+ {
248
+ let original = envelope(scope, handoff, allocation, range, false)?;
249
+ for connection_state in [2, 8] {
250
+ let mut expressions = original.clone();
251
+ expressions.extend(state(connection_state));
252
+ if connection_state == 8 && range.protocol == "tcp" {
253
+ expressions.extend(opening_tcp());
262
254
  }
255
+ program.end("forward", expressions, 1)?;
263
256
  }
257
+ let mut reply = envelope(scope, handoff, allocation, range, true)?;
258
+ reply.extend(state(2));
259
+ program.end("forward", reply, 1)?;
260
+ let mut nat = original;
261
+ nat.extend(snat(&scope.snat_address, None)?);
262
+ program.rule("post", nat)?;
264
263
  }
265
264
  // Symmetric flows leave from ports outside every leased range of their
266
265
  // address, after the capture barrier and before the terminal handoff denials.
@@ -272,5 +271,14 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
272
271
  program.deny_link("forward", &handoff.link, true)?;
273
272
  program.deny_link("forward", &handoff.link, false)?;
274
273
  }
274
+ // Exclusive forwarding: everything the forward chain has not accepted by now
275
+ // drops, of every family and on every pair of interfaces, so the host
276
+ // forwards exactly this scope's leased, published and symmetric handoff
277
+ // flows. nftables runs every table's forward base chain, and a drop in any
278
+ // one is final: this denies forwarding that other owners would accept, and
279
+ // accepting here still cannot override their drops.
280
+ if scope.exclusive_forwarding {
281
+ program.end("forward", vec![], 0)?;
282
+ }
275
283
  Ok(())
276
284
  }