@push.rocks/smartnftables 4.1.0 → 4.3.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 (38) hide show
  1. package/changelog.md +23 -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 +4 -1
  8. package/dist_ts/classes.manageddockerforwarding.js +8 -5
  9. package/dist_ts/classes.managednftables.d.ts +8 -2
  10. package/dist_ts/classes.managednftables.js +11 -6
  11. package/dist_ts/forwardhooks.d.ts +8 -0
  12. package/dist_ts/forwardhooks.js +42 -0
  13. package/dist_ts/index.d.ts +2 -0
  14. package/dist_ts/index.js +2 -1
  15. package/dist_ts/managed.docker.types.d.ts +2 -1
  16. package/dist_ts/managed.forwardhooks.types.d.ts +35 -0
  17. package/dist_ts/managed.forwardhooks.types.js +2 -0
  18. package/package.json +4 -4
  19. package/readme.md +80 -17
  20. package/rust/src/docker.frontend.rs +47 -9
  21. package/rust/src/docker.graph.rs +53 -14
  22. package/rust/src/docker.policy.rs +90 -31
  23. package/rust/src/docker.rs +48 -20
  24. package/rust/src/docker_tests.rs +206 -2
  25. package/rust/src/egress.host.rs +44 -45
  26. package/rust/src/egress.rs +56 -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/wire.links.rs +28 -6
  31. package/rust/src/wire.rs +22 -2
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts/classes.manageddockerforwarding.ts +7 -4
  34. package/ts/classes.managednftables.ts +13 -5
  35. package/ts/forwardhooks.ts +39 -0
  36. package/ts/index.ts +2 -0
  37. package/ts/managed.docker.types.ts +2 -1
  38. package/ts/managed.forwardhooks.types.ts +26 -0
@@ -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,206 @@ 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
+ }
311
+
312
+ /// A host whose forwarding path refuses every apply, such as one with FORWARD
313
+ /// policy ACCEPT or without Docker, still reads as a complete filter table, so an
314
+ /// owner whose first apply was refused can prove that it holds no rule there.
315
+ #[test]
316
+ fn docker_absence_proof_reads_a_filter_table_the_apply_path_refuses() {
317
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
318
+ let prefix="snftd1:dockerfixture:";
319
+ let accept=text(&[]).replace(":FORWARD DROP",":FORWARD ACCEPT");
320
+ let dockerless="# Warning: iptables-legacy tables present\n*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n";
321
+ for host in [accept.as_str(),dockerless,"*filter\nCOMMIT\n"] {
322
+ assert_eq!(frontend::Snapshot::parse("v1.8.11 (nf_tables)".into(),host).err(),Some(Error::Conflict));
323
+ let observed=frontend::Snapshot::table("v1.8.11 (nf_tables)".into(),host).unwrap();
324
+ assert!(!observed.mentions(prefix));
325
+ // Text alone never proves absence: the native rules must be read too.
326
+ assert_eq!(observed.owns_nothing(&options()),Err(Error::Conflict));
327
+ }
328
+ // Owned rules anywhere in the table are found, also off the admitted path.
329
+ let owned=text(&rules).replace(":FORWARD DROP",":FORWARD ACCEPT");
330
+ assert!(frontend::Snapshot::table("v1.8.11 (nf_tables)".into(),&owned).unwrap().mentions(prefix));
331
+ let moved=text(&rules).replacen("-A DOCKER-USER","-A INPUT",1);
332
+ assert!(frontend::Snapshot::table("v1.8.11 (nf_tables)".into(),&moved).unwrap().mentions(prefix));
333
+ // Another owner's rules are not this owner's.
334
+ let foreign=owned.replace(prefix,"snftd1:anotherowner:");
335
+ assert!(!frontend::Snapshot::table("v1.8.11 (nf_tables)".into(),&foreign).unwrap().mentions(prefix));
336
+ // An incompatible or partial table is not a complete read.
337
+ for partial in ["# Table `filter' is incompatible, use 'nft' tool.\n","*filter\n:INPUT ACCEPT [0:0]\n",""] {
338
+ assert_eq!(frontend::Snapshot::table("v1.8.11 (nf_tables)".into(),partial).err(),Some(Error::Conflict));
339
+ }
340
+ }
341
+
342
+ #[test]
343
+ fn docker_absence_proof_finds_the_owner_comment_in_native_rules() {
344
+ let rules=policy().prepare("v1.8.11 (nf_tables)".into()).unwrap().rules(&options()).unwrap();
345
+ let row=|rule: &policy::Rule|vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::string(2,"DOCKER-USER"),
346
+ crate::wire::Attr::u64(3,42),crate::wire::Attr::nested(4,graph::expected(rule).unwrap())];
347
+ let owned:Vec<_>=rules.iter().map(row).collect();
348
+ assert!(graph::mentions(&owned,b"snftd1:dockerfixture:"));
349
+ assert!(graph::mentions(&owned[5..],b"snftd1:dockerfixture:"));
350
+ assert!(!graph::mentions(&owned,b"snftd1:anotherowner:"));
351
+ assert!(!graph::mentions(&Vec::new(),b"snftd1:dockerfixture:"));
352
+ // Wherever a frontend stores the comment, such as rule userdata, it is found.
353
+ let userdata=vec![vec![crate::wire::Attr::string(1,"filter"),crate::wire::Attr::bytes(7,rules[0].comment.as_bytes().to_vec())]];
354
+ assert!(graph::mentions(&userdata,b"snftd1:dockerfixture:"));
355
+ }
@@ -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.
@@ -0,0 +1,176 @@
1
+ //! Read-only view of every packet-filter owner on the IPv4/IPv6 forward hook of
2
+ //! this network namespace. It claims, adopts and changes nothing.
3
+ use crate::wire::{self, Attr, Socket};
4
+ use crate::{decode, encode, Error, Request, Result};
5
+ use serde::Serialize;
6
+
7
+ #[cfg(test)]
8
+ #[path = "forwardhooks_tests.rs"]
9
+ mod tests;
10
+
11
+ /// One nf_tables base chain on the forward hook.
12
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
13
+ #[serde(rename_all = "camelCase")]
14
+ pub struct Chain {
15
+ pub family: &'static str,
16
+ pub table: String,
17
+ pub table_handle: String,
18
+ pub chain: String,
19
+ #[serde(rename = "type")]
20
+ pub chain_type: String,
21
+ pub priority: i32,
22
+ pub policy: &'static str,
23
+ }
24
+ /// Docker's iptables-nft dispatch chains in table `ip filter`.
25
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
26
+ #[serde(rename_all = "camelCase")]
27
+ pub struct Docker {
28
+ pub docker_user: bool,
29
+ pub docker_forward: bool,
30
+ }
31
+ /// Registered legacy xtables tables. Their hooks, including FORWARD, are not
32
+ /// nf_tables objects and never appear in `chains`.
33
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
34
+ #[serde(rename_all = "camelCase")]
35
+ pub struct Legacy {
36
+ pub ipv4: Vec<String>,
37
+ pub ipv6: Vec<String>,
38
+ }
39
+ #[derive(Clone, Debug, Serialize, PartialEq, Eq)]
40
+ #[serde(rename_all = "camelCase")]
41
+ pub struct Inspection {
42
+ pub chains: Vec<Chain>,
43
+ pub docker: Docker,
44
+ pub legacy_tables: Legacy,
45
+ }
46
+
47
+ /// The families whose forward hook sees routed IPv4 or IPv6 packets.
48
+ fn family(value: u8) -> Option<&'static str> {
49
+ match value {
50
+ 1 => Some("inet"),
51
+ 2 => Some("ip"),
52
+ 10 => Some("ip6"),
53
+ _ => None,
54
+ }
55
+ }
56
+ const NF_INET_FORWARD: u32 = 2;
57
+
58
+ /// Classify one consistent table and chain dump.
59
+ pub(crate) fn classify(
60
+ tables: &[(u8, Vec<Attr>)],
61
+ chains: &[(u8, Vec<Attr>)],
62
+ ) -> Result<(Vec<Chain>, Docker)> {
63
+ let mut result = Vec::new();
64
+ let mut docker = Docker {
65
+ docker_user: false,
66
+ docker_forward: false,
67
+ };
68
+ for (number, attributes) in chains {
69
+ let table = wire::text(attributes, 1)?;
70
+ let name = wire::text(attributes, 3)?;
71
+ if *number == 2 && table == "filter" {
72
+ match name.as_str() {
73
+ "DOCKER-USER" => docker.docker_user = true,
74
+ "DOCKER-FORWARD" => docker.docker_forward = true,
75
+ _ => {}
76
+ }
77
+ }
78
+ let Some(family) = family(*number) else {
79
+ continue;
80
+ };
81
+ // Only base chains carry a hook.
82
+ if !attributes.iter().any(|attribute| attribute.id() == 4) {
83
+ continue;
84
+ }
85
+ let hook = wire::attrs(&wire::one(attributes, 4)?.value)?;
86
+ if wire::number(&hook, 1)? != NF_INET_FORWARD {
87
+ continue;
88
+ }
89
+ let priority = wire::number(&hook, 2)? as i32;
90
+ let policy = match wire::number(attributes, 5)? {
91
+ 0 => "drop",
92
+ 1 => "accept",
93
+ _ => return Err(Error::Protocol),
94
+ };
95
+ let handle = tables
96
+ .iter()
97
+ .filter(|(owner, table_attributes)| {
98
+ owner == number
99
+ && wire::text(table_attributes, 1).ok().as_deref() == Some(table.as_str())
100
+ })
101
+ .map(|(_, table_attributes)| wire::handle(table_attributes, 4))
102
+ .collect::<Result<Vec<_>>>()?;
103
+ let [handle] = handle.as_slice() else {
104
+ return Err(Error::Conflict);
105
+ };
106
+ result.push(Chain {
107
+ family,
108
+ table,
109
+ table_handle: handle.to_string(),
110
+ chain: name,
111
+ chain_type: wire::text(attributes, 7)?,
112
+ priority,
113
+ policy,
114
+ });
115
+ }
116
+ result.sort();
117
+ Ok((result, docker))
118
+ }
119
+
120
+ /// One registered table name per line; an absent file means none is registered.
121
+ fn legacy(path: &str) -> Result<Vec<String>> {
122
+ use std::io::Read;
123
+ let file = match std::fs::File::open(path) {
124
+ Ok(file) => file,
125
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
126
+ Err(_) => return Err(Error::Unavailable),
127
+ };
128
+ let mut text = String::new();
129
+ file.take(4097)
130
+ .read_to_string(&mut text)
131
+ .map_err(|_| Error::Protocol)?;
132
+ if text.len() > 4096 {
133
+ return Err(Error::Protocol);
134
+ }
135
+ let mut names = Vec::new();
136
+ for line in text.lines() {
137
+ if !crate::policy::identifier(line) {
138
+ return Err(Error::Protocol);
139
+ }
140
+ names.push(line.to_owned());
141
+ }
142
+ names.sort();
143
+ Ok(names)
144
+ }
145
+
146
+ pub fn inspect() -> Result<Inspection> {
147
+ let mut socket = Socket::open()?;
148
+ let generation = socket.generation()?;
149
+ let tables = socket.dump_all_families(1)?;
150
+ let chains = socket.dump_all_families(4)?;
151
+ // Both dumps describe one ruleset generation, or the view is inconclusive.
152
+ if socket.generation()? != generation {
153
+ return Err(Error::Conflict);
154
+ }
155
+ let (chains, docker) = classify(&tables, &chains)?;
156
+ Ok(Inspection {
157
+ chains,
158
+ docker,
159
+ legacy_tables: Legacy {
160
+ ipv4: legacy("/proc/self/net/ip_tables_names")?,
161
+ ipv6: legacy("/proc/self/net/ip6_tables_names")?,
162
+ },
163
+ })
164
+ }
165
+
166
+ pub fn dispatch(request: Request) -> Result<serde_json::Value> {
167
+ match request.method.as_str() {
168
+ "inspectForwardHooks" => {
169
+ if decode::<serde_json::Value>(request.params)? != serde_json::json!({}) {
170
+ return Err(Error::Invalid);
171
+ }
172
+ encode(inspect()?)
173
+ }
174
+ _ => Err(Error::Invalid),
175
+ }
176
+ }