@push.rocks/smartnftables 4.1.0 → 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 (35) hide show
  1. package/changelog.md +9 -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 +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.forwardhooks.types.d.ts +35 -0
  15. package/dist_ts/managed.forwardhooks.types.js +2 -0
  16. package/package.json +1 -1
  17. package/readme.md +69 -16
  18. package/rust/src/docker.frontend.rs +2 -2
  19. package/rust/src/docker.graph.rs +36 -11
  20. package/rust/src/docker.policy.rs +90 -31
  21. package/rust/src/docker.rs +37 -13
  22. package/rust/src/docker_tests.rs +161 -2
  23. package/rust/src/egress.host.rs +44 -45
  24. package/rust/src/egress.rs +56 -0
  25. package/rust/src/forwardhooks.rs +176 -0
  26. package/rust/src/forwardhooks_tests.rs +136 -0
  27. package/rust/src/main.rs +12 -8
  28. package/rust/src/wire.links.rs +28 -6
  29. package/rust/src/wire.rs +22 -2
  30. package/ts/00_commitinfo_data.ts +1 -1
  31. package/ts/classes.manageddockerforwarding.ts +2 -1
  32. package/ts/forwardhooks.ts +39 -0
  33. package/ts/index.ts +2 -0
  34. package/ts/managed.docker.types.ts +2 -1
  35. package/ts/managed.forwardhooks.types.ts +26 -0
@@ -99,46 +99,105 @@ pub struct Rule {
99
99
  fn port_range(first: u16, last: u16) -> String {
100
100
  if first == last { first.to_string() } else { format!("{first}:{last}") }
101
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
+ }
102
155
  impl Prepared {
103
156
  pub fn validate(&self) -> Result<()> {
104
157
  if self.policy.clone().prepare(self.backend.clone())? != *self { return Err(Error::Invalid); }
105
158
  Ok(())
106
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
+ }
107
185
  pub fn rules(&self, options: &Options) -> Result<Vec<Rule>> {
108
186
  options.validate()?;
109
- let scope = self.policy.scope()?;
187
+ let digest = self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?;
110
188
  let mut result = Vec::new();
111
189
  let mut bytes = 0;
112
- for handoff in &scope.handoffs {
113
- for allocation in &handoff.allocations {
114
- for range in &allocation.source_port_ranges {
115
- for state in ["NEW", "ESTABLISHED", "REPLY"] {
116
- let reply = state == "REPLY";
117
- let mut args: Vec<String> = vec![
118
- "-i".into(), if reply { scope.uplink.interface_name.clone() } else { handoff.link.interface_name.clone() },
119
- "-o".into(), if reply { handoff.link.interface_name.clone() } else { scope.uplink.interface_name.clone() },
120
- if reply { "-d" } else { "-s" }.into(), format!("{}/32", allocation.transit_source_address),
121
- "-p".into(),range.protocol.clone(),"-m".into(),range.protocol.clone(),
122
- if reply { "--dport" } else { "--sport" }.into(),port_range(range.first,range.last),
123
- ];
124
- if state == "NEW" && range.protocol == "tcp" {
125
- args.extend(["--tcp-flags","FIN,SYN,RST,ACK","SYN"].map(String::from));
126
- }
127
- args.extend(["-m","conntrack","--ctstate",if reply {"ESTABLISHED"} else {state},"--ctproto",if range.protocol == "tcp" {"6"} else {"17"},"--ctorigsrc"].map(String::from));
128
- // Canonical xtables host form: explicit /32 has different unused
129
- // conntrack mask bytes and does not survive a save/restore round trip.
130
- args.push(allocation.transit_source_address.clone());
131
- args.push("--ctorigsrcport".into());args.push(port_range(range.first,range.last));
132
- args.extend(["--ctdir",if reply {"REPLY"} else {"ORIGINAL"},"-m","comment","--comment"].map(String::from));
133
- let comment = format!("snftd1:{}:{}:{}:{}",options.owner_id,options.instance_id,self.digest.strip_prefix("sha256:").ok_or(Error::Invalid)?,result.len());
134
- args.push(comment.clone());args.extend(["-j","ACCEPT"].map(String::from));
135
- bytes += args.iter().map(|arg|arg.len()+1).sum::<usize>()+32;
136
- crate::capacity("restoreRules", 192, result.len() + 1)?;
137
- crate::capacity("restoreBytes", 100_000, bytes)?;
138
- result.push(Rule {comment,args});
139
- }
140
- }
141
- }
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
+ })?;
142
201
  }
143
202
  Ok(result)
144
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
  }
@@ -149,3 +150,161 @@ fn docker_forward_jumps_require_exact_unconditional_order() {
149
150
  assert!(!graph::matches_forward(&row(expected),if target=="DOCKER-USER" {"DOCKER-FORWARD"} else {"DOCKER-USER"}).unwrap());
150
151
  }
151
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.
@@ -216,6 +216,62 @@ pub struct HostScope {
216
216
  #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217
217
  pub exclusive_forwarding: bool,
218
218
  }
219
+ /// One leased source port range of one allocation, on the handoff that holds it.
220
+ pub struct LeasedForward<'a> {
221
+ pub handoff: &'a HostHandoff,
222
+ pub allocation: &'a Allocation,
223
+ pub range: &'a PortRange,
224
+ }
225
+ /// One publication, on the handoff link that holds its leased target address.
226
+ pub struct PublishedForward<'a> {
227
+ pub port: &'a PublishedPort,
228
+ pub link: &'a LocalLink,
229
+ }
230
+ /// The forwarded flows a host-transit scope admits, in canonical scope order:
231
+ /// leased flows from a handoff to the uplink, and per publication its inbound
232
+ /// flows from the uplink to the handoff and, when symmetric, the flows it opens
233
+ /// from its target ports to the uplink; each with its replies. The scope's nft
234
+ /// forward chain and the Docker contribution both derive from this one list.
235
+ pub struct Forwarding<'a> {
236
+ pub leased: Vec<LeasedForward<'a>>,
237
+ pub published: Vec<PublishedForward<'a>>,
238
+ }
239
+ impl HostScope {
240
+ pub fn forwarding(&self) -> Result<Forwarding<'_>> {
241
+ let mut leased = Vec::new();
242
+ for handoff in &self.handoffs {
243
+ for allocation in &handoff.allocations {
244
+ for range in &allocation.source_port_ranges {
245
+ leased.push(LeasedForward {
246
+ handoff,
247
+ allocation,
248
+ range,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ // Validation admits only leased transit source addresses as targets, and
254
+ // one address never spans two active handoff links, so each publication
255
+ // has exactly one handoff.
256
+ let mut published = Vec::new();
257
+ for port in &self.published_ports {
258
+ let handoff =
259
+ self.handoffs
260
+ .iter()
261
+ .find(|handoff| {
262
+ handoff.allocations.iter().any(|allocation| {
263
+ allocation.transit_source_address == port.target_address
264
+ })
265
+ })
266
+ .ok_or(Error::Invalid)?;
267
+ published.push(PublishedForward {
268
+ port,
269
+ link: &handoff.link,
270
+ });
271
+ }
272
+ Ok(Forwarding { leased, published })
273
+ }
274
+ }
219
275
  /// One loopback TCP service that only one local user may dial: packets the host
220
276
  /// sends to the exact address and port from a socket of any other user reject,
221
277
  /// and packets arriving there on any interface but loopback drop.