@push.rocks/smartnftables 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-09-14 - 1.7.0
4
+
5
+ ### Features
6
+
7
+ - Add a host-wide allocation-pool guard with direction-aware IPv4 denial, bounded atomic updates, and native packet and recovery qualification.
8
+
3
9
  ## 2026-09-13 - 1.6.0
4
10
 
5
11
  ### Features
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "3dd9944031490243ac7c5452afde0681acc1672c975fd9c15d6956f7e8408604",
3
+ "binarySha256": "d220f9e5399c394467c212c9c29a01d56395bde551a7a1af7bd233ae313bbf21",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "1.6.0",
7
- "gitCommit": "4962c8d3ebc469287abf0c2acf5e3b413fa23e64",
6
+ "projectVersion": "1.7.0",
7
+ "gitCommit": "9f206a95e42e76d6a4a61ecce11cf5d02927fb41",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-13T17:03:57.902Z",
9
+ "builtAt": "2026-09-14T02:54:17.256Z",
10
10
  "tsrustVersion": "1.13.0",
11
11
  "binary": "smartnftables",
12
12
  "target": "linux_amd64_musl"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "0fbd6afc372d977bb68c9816e3fcb042d2202a433344a4c8ffc44b6e6d66e3c8",
3
+ "binarySha256": "a282a800a9782297af8e7862ef2b8abbbc1aefcc79d50b5d0f453feecc874e36",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "1.6.0",
7
- "gitCommit": "4962c8d3ebc469287abf0c2acf5e3b413fa23e64",
6
+ "projectVersion": "1.7.0",
7
+ "gitCommit": "9f206a95e42e76d6a4a61ecce11cf5d02927fb41",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-13T17:03:58.037Z",
9
+ "builtAt": "2026-09-14T02:54:17.376Z",
10
10
  "tsrustVersion": "1.13.0",
11
11
  "binary": "smartnftables",
12
12
  "target": "linux_arm64_musl"
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '1.6.0',
6
+ version: '1.7.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  };
9
9
  //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSwyQkFBMkI7SUFDakMsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLG1IQUFtSDtDQUNqSSxDQUFBIn0=
@@ -84,8 +84,16 @@ export interface IManagedNftHostTransitScopeV2 {
84
84
  /** Exact current address present on uplink. No masquerade or default-route inference. */
85
85
  snatAddress: string;
86
86
  }
87
+ /** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
88
+ * This has no link dependency or allow grants. It is not allocation-release or boot-order proof. */
89
+ export interface IManagedNftAllocationPoolGuardScopeV2 {
90
+ kind: 'allocationPoolGuard';
91
+ authorityDigest: string;
92
+ /** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
93
+ prefixes: string[];
94
+ }
87
95
  export interface IManagedNftPolicyV2 {
88
96
  schemaVersion: 2;
89
97
  revision: number;
90
- scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2;
98
+ scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2 | IManagedNftAllocationPoolGuardScopeV2;
91
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartnftables",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "private": false,
5
5
  "description": "A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.",
6
6
  "main": "dist_ts/index.js",
package/readme.md CHANGED
@@ -168,12 +168,40 @@ conntrack/NAT state. This restriction also applies to private veth/TUN forwardin
168
168
  The prepared/applied/transition/status interfaces accept the same policy type
169
169
  parameter; existing callers default to the unchanged schema-v1 contract. V1
170
170
  canonical digests and compiled bytes are preserved. V2 uses its own hash domain.
171
- An owner cannot transition between v1 private, v2 router, and v2 host policy kinds.
171
+ An owner cannot transition between v1 private, v2 router, v2 host transit, and v2
172
+ allocation-pool guard policy kinds.
172
173
 
173
174
  | V2 scope | Required authority and behavior |
174
175
  | --- | --- |
175
176
  | `routerEgress` | Private `endpoints` and `rules`, one exact `links` binding per endpoint, a separate veth `handoff`, `protection`, and active `generations`. Private veth/TUN/local DNS and egress share one table so terminal private denial cannot override a separate egress table. |
176
177
  | `hostTransit` | Exact handoff `link`/`allocations` pairs, complete `protection`, an explicit veth or Ethernet `uplink`, and its current `snatAddress`. It checks each handoff's leased source address and protocol/port range, default conntrack zone, direction, uplink, and protected destinations before outer SNAT. |
178
+ | `allocationPoolGuard` | An authenticated `authorityDigest` and complete current allocation-pool `prefixes`. Installs host-wide IPv4 destination denial before any handoff exists, without link, uplink or SNAT dependencies. |
179
+
180
+ `allocationPoolGuard` accepts 1–64 canonical, disjoint RFC1918 prefixes. Supply
181
+ the actual allocation pools, not the broader protected union containing management
182
+ LANs, resolvers and platform endpoints. It applies to IPv4 INPUT, FORWARD and OUTPUT
183
+ at filter priority 0, after normal destination NAT. It drops an original conntrack
184
+ destination in a pool. Reply-direction packets additionally check their current
185
+ source, then return; all remaining packets check their current destination. This
186
+ denies DNAT into or away from a pool and untracked current-destination traffic,
187
+ while permitting reverse-SNAT replies to authorized transit sources. It grants no
188
+ egress permissions; exact `hostTransit` and router policies remain separate owners.
189
+ Unrelated host traffic and IPv6 retain their existing behavior.
190
+
191
+ For example, a guard policy is `{ schemaVersion: 2, revision: 1, scope: {
192
+ kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16',
193
+ '10.241.0.0/16'] } }`. Hashing binds the supplied body, but does not authenticate
194
+ the authority or prove its completeness. An empty `hostTransit.handoffs` array
195
+ provides no host-wide denial and is not a substitute for this scope.
196
+
197
+ The caller must retain the authenticated authority, complete transition and applied
198
+ receipt, verify `inspect().enforced`, and qualify the complete packet path. This
199
+ guard alone proves neither early-boot/late-shutdown ordering nor protection after
200
+ kernel reboot. It does not fence foreign packet queues, later packet rewrites,
201
+ offloads or proxies, and supplies no quarantine release, conntrack drainage or
202
+ allocation-reuse evidence. Historical leases remain the caller's durable ownership
203
+ responsibility. PERSIST retains this table only while its network namespace exists;
204
+ `close()` still deletes it, so revoke any external protection claim before release.
177
205
 
178
206
  Local bindings include name, index, kind, MAC (null for L3 TUN), interface-link
179
207
  index, and required IPv4 addresses. RTM_GETLINK/RTM_GETADDR verify those local
@@ -4,6 +4,8 @@ use crate::wire::{self, Attr};
4
4
 
5
5
  #[path = "egress.host.rs"]
6
6
  mod host;
7
+ #[path = "egress.poolguard.rs"]
8
+ mod poolguard;
7
9
  #[path = "egress.router.rs"]
8
10
  mod router;
9
11
 
@@ -283,6 +285,7 @@ pub(super) fn compile(prepared: &Prepared, table: &str) -> Result<Vec<(u16, Vec<
283
285
  router::compile(&mut program, scope, prepared.policy.revision)?
284
286
  }
285
287
  Scope::HostTransit(scope) => host::compile(&mut program, scope)?,
288
+ Scope::AllocationPoolGuard(scope) => poolguard::compile(&mut program, scope)?,
286
289
  }
287
290
  Ok(program.operations)
288
291
  }
@@ -0,0 +1,30 @@
1
+ use super::*;
2
+
3
+ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
4
+ for (name, hook) in [("input", 1), ("forward", 2), ("output", 3)] {
5
+ program.chain(name, Some((hook, 0, "filter")))?;
6
+ }
7
+ for name in ["pool_guard", "pool_reply"] {
8
+ program.chain(name, None)?;
9
+ }
10
+ for name in ["input", "forward", "output"] {
11
+ program.jump(name, ipv4(), "pool_guard")?;
12
+ }
13
+ // An earlier owner's DNAT must not conceal the original protected target.
14
+ for prefix in &scope.prefixes {
15
+ program.end("pool_guard", original_address(prefix, false)?, 0)?;
16
+ }
17
+ // GOTO, not JUMP: RETURN from pool_reply must return to the base chain,
18
+ // skipping current-destination denial for legitimate reverse-SNAT replies.
19
+ let mut reply = ct(1, None, vec![1]);
20
+ reply.push(verdict(-4, Some("pool_reply")));
21
+ program.rule("pool_guard", reply)?;
22
+ // A missing conntrack tuple/direction breaks only its rule. Untracked or
23
+ // invalid packets still reach this unconditional current-destination guard.
24
+ for prefix in &scope.prefixes {
25
+ program.end("pool_guard", current_address(prefix, false)?, 0)?;
26
+ program.end("pool_reply", current_address(prefix, true)?, 0)?;
27
+ }
28
+ program.end("pool_guard", vec![], -5)?;
29
+ program.end("pool_reply", vec![], -5)
30
+ }
@@ -119,10 +119,17 @@ pub struct HostScope {
119
119
  pub snat_address: String,
120
120
  }
121
121
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
122
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
123
+ pub struct AllocationPoolGuardScope {
124
+ pub authority_digest: String,
125
+ pub prefixes: Vec<String>,
126
+ }
127
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
122
128
  #[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
123
129
  pub enum Scope {
124
130
  RouterEgress(RouterScope),
125
131
  HostTransit(HostScope),
132
+ AllocationPoolGuard(AllocationPoolGuardScope),
126
133
  }
127
134
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
128
135
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -532,6 +539,23 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
532
539
  )
533
540
  }))
534
541
  }
542
+ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
543
+ require(
544
+ digest(&value.authority_digest) && !value.prefixes.is_empty() && value.prefixes.len() <= 64,
545
+ )?;
546
+ value.prefixes.sort();
547
+ for (index, item) in value.prefixes.iter().enumerate() {
548
+ require(
549
+ covers("10.0.0.0/8", item)?
550
+ || covers("172.16.0.0/12", item)?
551
+ || covers("192.168.0.0/16", item)?,
552
+ )?;
553
+ for previous in &value.prefixes[..index] {
554
+ require(!overlaps(item, previous)?)?;
555
+ }
556
+ }
557
+ Ok(())
558
+ }
535
559
  fn normalize_host(value: &mut HostScope) -> Result<()> {
536
560
  normalize_protection(&mut value.protection)?;
537
561
  normalize_link(&mut value.uplink)?;
@@ -642,6 +666,7 @@ impl Policy {
642
666
  match &mut self.scope {
643
667
  Scope::RouterEgress(scope) => normalize_router(scope, self.revision)?,
644
668
  Scope::HostTransit(scope) => normalize_host(scope)?,
669
+ Scope::AllocationPoolGuard(scope) => normalize_pool_guard(scope)?,
645
670
  }
646
671
  Ok(self)
647
672
  }
@@ -667,6 +692,8 @@ impl Prepared {
667
692
  .map(|item| &item.link)
668
693
  .chain(std::iter::once(&scope.uplink))
669
694
  .collect(),
695
+ // No link/route dependency: this policy must apply before allocation.
696
+ Scope::AllocationPoolGuard(_) => return Ok(()),
670
697
  };
671
698
  crate::wire::verify_bound_interfaces(links.into_iter().map(|link| {
672
699
  crate::wire::BoundInterface {
@@ -45,6 +45,11 @@ pub(crate) fn host() -> Value {
45
45
  "handoffs":[{"link":link(4,"handoff","veth","10.240.0.1"),"allocations":[allocation()]}],
46
46
  "uplink":link(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2"}})
47
47
  }
48
+ pub(crate) fn pool_guard() -> Value {
49
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"allocationPoolGuard",
50
+ "authorityDigest":format!("sha256:{}", "b".repeat(64)),
51
+ "prefixes":["10.241.0.0/16", "10.240.0.0/16"]}})
52
+ }
48
53
  fn normalized(value: Value) -> super::Result<Policy> {
49
54
  serde_json::from_value::<Policy>(value)
50
55
  .map_err(|_| crate::Error::Invalid)?
@@ -330,3 +335,83 @@ fn managed_union_keeps_strict_duplicate_and_unknown_field_rejection() {
330
335
  )
331
336
  .is_err());
332
337
  }
338
+
339
+ #[test]
340
+ fn allocation_pool_guard_is_canonical_authority_bound_and_private_only() {
341
+ let prepared = normalized(pool_guard()).unwrap().prepare().unwrap();
342
+ let mut reordered = pool_guard();
343
+ reordered["scope"]["prefixes"]
344
+ .as_array_mut()
345
+ .unwrap()
346
+ .reverse();
347
+ assert_eq!(normalized(reordered).unwrap().prepare().unwrap(), prepared);
348
+ let mut changed_authority = pool_guard();
349
+ changed_authority["scope"]["authorityDigest"] = json!(format!("sha256:{}", "c".repeat(64)));
350
+ assert_ne!(
351
+ normalized(changed_authority)
352
+ .unwrap()
353
+ .prepare()
354
+ .unwrap()
355
+ .digest,
356
+ prepared.digest
357
+ );
358
+ for prefixes in [
359
+ json!([]),
360
+ json!(["10.0.0.1/8"]),
361
+ json!(["10.0.0.0/8", "10.1.0.0/16"]),
362
+ json!(["10.0.0.0/8", "10.0.0.0/8"]),
363
+ json!(["0.0.0.0/0"]),
364
+ json!(["192.0.2.0/24"]),
365
+ json!(["127.0.0.0/8"]),
366
+ json!(["172.0.0.0/8"]),
367
+ json!(["192.168.0.0/15"]),
368
+ json!(["10.0.0.0/08"]),
369
+ json!(["::/0"]),
370
+ ] {
371
+ assert!(normalized(changed(pool_guard(), "/scope/prefixes", prefixes)).is_err());
372
+ }
373
+ let mut extra = pool_guard();
374
+ extra["scope"]["handoffs"] = json!([]);
375
+ assert!(normalized(extra).is_err());
376
+ assert!(normalized(changed(
377
+ pool_guard(),
378
+ "/scope/authorityDigest",
379
+ json!("sha256:bad")
380
+ ))
381
+ .is_err());
382
+ assert!(normalized(changed(
383
+ pool_guard(),
384
+ "/scope/prefixes",
385
+ json!(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"])
386
+ ))
387
+ .is_ok());
388
+ let duplicate = serde_json::to_string(&pool_guard())
389
+ .unwrap()
390
+ .replace("\"prefixes\":", "\"prefixes\":[],\"prefixes\":");
391
+ assert!(serde_json::from_str::<crate::managed::Policy>(&duplicate).is_err());
392
+ }
393
+
394
+ #[test]
395
+ fn allocation_pool_guard_maximum_fits_the_atomic_graph_without_link_bindings() {
396
+ let prefixes: Vec<_> = (0..64).map(|part| format!("10.{part}.0.0/16")).collect();
397
+ let prepared = normalized(changed(pool_guard(), "/scope/prefixes", json!(prefixes)))
398
+ .unwrap()
399
+ .prepare()
400
+ .unwrap();
401
+ prepared.validate().unwrap();
402
+ prepared.validate_interfaces().unwrap();
403
+ let program = prepared
404
+ .program(&format!("snft_{}", "x".repeat(59)))
405
+ .unwrap();
406
+ let bytes: usize = program
407
+ .iter()
408
+ .map(|(_, attrs)| crate::wire::encode_attrs(attrs).len() + 20)
409
+ .sum();
410
+ assert!(program.len() <= 768 && bytes <= 100_000);
411
+ println!(
412
+ "POOL_GUARD_MAX prefixes=64 operations={} bytes={bytes}",
413
+ program.len()
414
+ );
415
+ let excessive: Vec<_> = (0..65).map(|part| format!("10.{part}.0.0/16")).collect();
416
+ assert!(normalized(changed(pool_guard(), "/scope/prefixes", json!(excessive))).is_err());
417
+ }
@@ -34,6 +34,10 @@ impl Policy {
34
34
  scope: egress::Scope::HostTransit(_),
35
35
  ..
36
36
  }) => "hostTransitV2",
37
+ Self::Egress(egress::Policy {
38
+ scope: egress::Scope::AllocationPoolGuard(_),
39
+ ..
40
+ }) => "allocationPoolGuardV2",
37
41
  }
38
42
  }
39
43
  pub fn prepare(self) -> Result<Prepared> {
@@ -62,7 +62,7 @@ fn host() -> egress::Policy {
62
62
  value["scope"]["uplink"] = binding("uplink", "192.0.2.2", "veth");
63
63
  serde_json::from_value(value).unwrap()
64
64
  }
65
- fn recover(name: &str, policy: egress::Policy) {
65
+ pub(super) fn recover(name: &str, policy: egress::Policy) {
66
66
  let prepared: Prepared = policy.clone().prepare().unwrap().into();
67
67
  prepared.validate_interfaces().expect("fixture bindings");
68
68
  let mut owner = Owner::new(options(name)).unwrap();
@@ -205,6 +205,18 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
205
205
  ns_ip(ROUTER, &["address", "add", "10.242.0.1/24", "dev", "vpn"]);
206
206
  ns_ip(ROUTER, &["link", "set", "vpn", "up"]);
207
207
  let mut device = in_namespace(ROUTER, tun);
208
+ let (pool_owner, pool_applied) = in_namespace(HOST, || {
209
+ let mut owner = Owner::new(options("pool_coexistence")).unwrap();
210
+ let policy: egress::Policy = serde_json::from_value(egress::tests::pool_guard()).unwrap();
211
+ let applied = owner
212
+ .reconcile(Transition {
213
+ previous: None,
214
+ target: policy.prepare().unwrap().into(),
215
+ })
216
+ .unwrap();
217
+ assert!(owner.inspect().enforced);
218
+ (owner, applied)
219
+ });
208
220
  let (host_owner, host_applied) = in_namespace(HOST, || {
209
221
  let mut owner = Owner::new(options("host_packets")).unwrap();
210
222
  let applied = owner
@@ -260,11 +272,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
260
272
  });
261
273
  let listener = in_namespace(PEER, || TcpListener::bind("192.0.2.3:3000").unwrap());
262
274
  let mut client = in_namespace(WORK, || {
263
- TcpStream::connect_timeout(
264
- &"192.0.2.3:3000".parse().unwrap(),
265
- Duration::from_secs(1),
266
- )
267
- .unwrap()
275
+ TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
276
+ .unwrap()
268
277
  });
269
278
  let (mut server, peer) = listener.accept().unwrap();
270
279
  assert_eq!(peer.ip().to_string(), "192.0.2.2");
@@ -290,11 +299,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
290
299
  assert_eq!(syn[33] & 0xc0, 0xc0);
291
300
  assert!((10016..=10031).contains(&u16::from_be_bytes(syn[20..22].try_into().unwrap())));
292
301
  let mut local_tcp = in_namespace(ROUTER, || {
293
- TcpStream::connect_timeout(
294
- &"192.0.2.3:3000".parse().unwrap(),
295
- Duration::from_secs(1),
296
- )
297
- .unwrap()
302
+ TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
303
+ .unwrap()
298
304
  });
299
305
  let (mut local_peer, local_outer) = listener.accept().unwrap();
300
306
  assert!((10016..=10031).contains(&local_outer.port()));
@@ -466,5 +472,10 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
466
472
  let mut owner = host_owner;
467
473
  owner.release(host_applied).unwrap();
468
474
  });
475
+ in_namespace(HOST, move || {
476
+ let mut owner = pool_owner;
477
+ assert!(owner.inspect().enforced);
478
+ owner.release(pool_applied).unwrap();
479
+ });
469
480
  println!("V2_PACKET_PROOF private_dns=true tun_private=true tun_public_denied=true workload_public=true local_dns_and_tcp=true handoff_udp_tcp_ranges=true ecn_syn=true opening_ack_rst_denied=true fragments_checked=true range_exhaustion_denied=true protected_grant_denied=true ungranted_local_denied=true retired_replies_denied=true revoked_egress_denied=true disjoint_generation_restored=true flows_drained=false reusable=false");
470
481
  }
@@ -7,7 +7,7 @@ const SOURCE: &str = "hs_source";
7
7
  const HOST: &str = "hs_host";
8
8
  const PEER: &str = "hs_peer";
9
9
 
10
- fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
10
+ pub(super) fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
11
11
  vec![
12
12
  expr(
13
13
  "payload",
@@ -21,7 +21,7 @@ fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
21
21
  compare(bytes),
22
22
  ]
23
23
  }
24
- fn immediate(value: Vec<u8>) -> Attr {
24
+ pub(super) fn immediate(value: Vec<u8>) -> Attr {
25
25
  expr(
26
26
  "immediate",
27
27
  vec![
@@ -196,10 +196,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
196
196
  HOST,
197
197
  &["route", "add", "203.0.113.2/32", "via", "192.0.2.3"],
198
198
  );
199
- ns_ip(
200
- HOST,
201
- &["route", "add", "10.250.0.2/32", "via", "192.0.2.3"],
202
- );
199
+ ns_ip(HOST, &["route", "add", "10.250.0.2/32", "via", "192.0.2.3"]);
203
200
  for address in ["203.0.113.2/32", "10.250.0.2/32"] {
204
201
  ns_ip(PEER, &["address", "add", address, "dev", "lo"]);
205
202
  }
@@ -285,10 +282,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
285
282
  control(source, destination, target, false);
286
283
  }
287
284
  let allowed = socket(SOURCE, "10.240.0.2:10000");
288
- assert_eq!(
289
- roundtrip(&allowed, &public).ip().to_string(),
290
- "192.0.2.2"
291
- );
285
+ assert_eq!(roundtrip(&allowed, &public).ip().to_string(), "192.0.2.2");
292
286
  let platform = socket(PEER, "192.0.2.3:5300");
293
287
  roundtrip(&allowed, &platform);
294
288
  let unleased = socket(SOURCE, "10.240.0.2:9999");
@@ -0,0 +1,265 @@
1
+ use super::egress_traffic_tests::{connect, ns_ip, roundtrip, socket};
2
+ use super::host_traffic_tests::{immediate, payload};
3
+ use super::*;
4
+ use crate::{
5
+ egress,
6
+ policy::{expr, meta},
7
+ };
8
+ use serde_json::json;
9
+ use std::net::{SocketAddr, UdpSocket};
10
+ use std::time::Duration;
11
+
12
+ const SOURCE: &str = "pg_source";
13
+ const HOST: &str = "pg_host";
14
+ const PEER: &str = "pg_peer";
15
+
16
+ #[test]
17
+ #[ignore = "requires disposable isolated native qualification guest"]
18
+ fn allocation_pool_guard_maximum_graph_persist_and_lost_ack_recovery() {
19
+ isolated();
20
+ let mut policy = egress::tests::pool_guard();
21
+ policy["scope"]["prefixes"] = json!((0..64)
22
+ .map(|part| format!("10.{part}.0.0/16"))
23
+ .collect::<Vec<_>>());
24
+ super::egress_tests::recover("pools_v2", serde_json::from_value(policy).unwrap());
25
+ }
26
+
27
+ // Foreign capture controls exist only in this offline guest. They are never
28
+ // composed into or removed with the managed policy under test.
29
+ fn foreign_captures() {
30
+ let table = "snft_foreign_pool_capture";
31
+ let mut operations = vec![(0, 0x600, vec![Attr::string(1, table), Attr::u32(2, 6)])];
32
+ for (chain, hook, priority, kind) in [
33
+ ("raw_pre", 0, -300_i32, "filter"),
34
+ ("raw_out", 3, -300, "filter"),
35
+ ("pre", 0, -100, "nat"),
36
+ ("out", 3, -100, "nat"),
37
+ ] {
38
+ operations.push((
39
+ 3,
40
+ 0x600,
41
+ vec![
42
+ Attr::string(1, table),
43
+ Attr::string(3, chain),
44
+ Attr::nested(4, vec![Attr::u32(1, hook), Attr::u32(2, priority as u32)]),
45
+ Attr::u32(5, 1),
46
+ Attr::string(7, kind),
47
+ Attr::u32(10, 1),
48
+ ],
49
+ ));
50
+ }
51
+ let mut rule = |chain: &str, expressions| {
52
+ operations.push((
53
+ 6,
54
+ 0xc00,
55
+ vec![
56
+ Attr::string(1, table),
57
+ Attr::string(2, chain),
58
+ Attr::nested(4, expressions),
59
+ ],
60
+ ))
61
+ };
62
+ for chain in ["raw_pre", "raw_out"] {
63
+ let mut expressions = meta(16, vec![17]);
64
+ expressions.extend(payload(2, 44003_u16.to_be_bytes().to_vec(), true));
65
+ expressions.push(expr("notrack", vec![]));
66
+ rule(chain, expressions);
67
+ }
68
+ for chain in ["pre", "out"] {
69
+ for (port, destination, target) in [
70
+ (44001_u16, [10, 250, 0, 2], [203, 0, 113, 2]),
71
+ (44002, [203, 0, 113, 2], [10, 250, 0, 2]),
72
+ (44004, [10, 250, 0, 2], [192, 0, 2, 2]),
73
+ (44005, [203, 0, 113, 2], [10, 241, 0, 1]),
74
+ ] {
75
+ let mut expressions = meta(16, vec![17]);
76
+ expressions.extend(payload(16, destination.to_vec(), false));
77
+ expressions.extend(payload(2, port.to_be_bytes().to_vec(), true));
78
+ expressions.push(immediate(target.to_vec()));
79
+ expressions.push(expr(
80
+ "nat",
81
+ vec![
82
+ Attr::u32(1, 1),
83
+ Attr::u32(2, 2),
84
+ Attr::u32(3, 1),
85
+ Attr::u32(4, 1),
86
+ Attr::u32(7, 1),
87
+ ],
88
+ ));
89
+ rule(chain, expressions);
90
+ }
91
+ }
92
+ let mut socket = wire::Socket::open().unwrap();
93
+ let generation = socket.generation().unwrap();
94
+ socket.batch(generation, operations).unwrap();
95
+ }
96
+
97
+ fn bound(namespace: &str, address: &str) -> UdpSocket {
98
+ let address = address.to_owned();
99
+ let socket = in_namespace(namespace, move || UdpSocket::bind(address).unwrap());
100
+ socket
101
+ .set_read_timeout(Some(Duration::from_millis(250)))
102
+ .unwrap();
103
+ socket
104
+ }
105
+ fn probe(
106
+ source: &UdpSocket,
107
+ destination: &str,
108
+ target: &UdpSocket,
109
+ allowed: bool,
110
+ local: bool,
111
+ ) -> Option<SocketAddr> {
112
+ let result = source.send_to(b"probe", destination);
113
+ if !allowed && local {
114
+ assert_eq!(
115
+ result.unwrap_err().raw_os_error(),
116
+ Some(libc::EPERM),
117
+ "{destination}"
118
+ );
119
+ } else {
120
+ assert_eq!(result.unwrap(), 5, "{destination}");
121
+ }
122
+ match target.recv_from(&mut [0; 64]) {
123
+ Ok((size, sender)) => {
124
+ assert!(allowed, "unexpected packet to {destination}");
125
+ assert_eq!(size, 5);
126
+ Some(sender)
127
+ }
128
+ Err(error) => {
129
+ assert!(!allowed, "missing packet to {destination}: {error}");
130
+ assert!(matches!(
131
+ error.kind(),
132
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
133
+ ));
134
+ None
135
+ }
136
+ }
137
+ }
138
+
139
+ #[test]
140
+ #[ignore = "requires disposable isolated native qualification guest"]
141
+ fn allocation_pool_guard_covers_all_host_hooks_dnat_untracked_and_reply_direction() {
142
+ isolated();
143
+ for name in [SOURCE, HOST, PEER] {
144
+ ip(&["netns", "add", name]);
145
+ ns_ip(name, &["link", "set", "lo", "up"]);
146
+ in_namespace(name, || {
147
+ std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
148
+ });
149
+ }
150
+ connect(
151
+ SOURCE,
152
+ "source",
153
+ "10.240.0.2/30",
154
+ HOST,
155
+ "router",
156
+ "10.240.0.1/30",
157
+ );
158
+ connect(
159
+ HOST,
160
+ "uplink",
161
+ "192.0.2.2/24",
162
+ PEER,
163
+ "underlay",
164
+ "192.0.2.3/24",
165
+ );
166
+ ns_ip(SOURCE, &["route", "add", "default", "via", "10.240.0.1"]);
167
+ ns_ip(PEER, &["route", "add", "default", "via", "192.0.2.2"]);
168
+ ns_ip(HOST, &["address", "add", "10.241.0.1/32", "dev", "lo"]);
169
+ for address in ["203.0.113.2", "10.250.0.2"] {
170
+ ns_ip(
171
+ PEER,
172
+ &["address", "add", &format!("{address}/32"), "dev", "lo"],
173
+ );
174
+ ns_ip(
175
+ HOST,
176
+ &["route", "add", &format!("{address}/32"), "via", "192.0.2.3"],
177
+ );
178
+ }
179
+ in_namespace(HOST, foreign_captures);
180
+ let source = socket(SOURCE, "10.240.0.2:10000");
181
+ let host = socket(HOST, "192.0.2.2:10000");
182
+ let host_pool = socket(HOST, "10.241.0.1:10000");
183
+ let public = socket(PEER, "203.0.113.2:42000");
184
+ let lan = socket(PEER, "192.0.2.3:42000");
185
+ // Controls exercise established flows before apply, plus fresh ones below.
186
+ roundtrip(&source, &public);
187
+ roundtrip(&source, &host);
188
+ roundtrip(&host_pool, &public);
189
+ roundtrip(&host, &lan);
190
+ let cases = [
191
+ ("10.250.0.2:42001", PEER, "10.250.0.2:42001"),
192
+ ("10.241.0.1:42002", HOST, "10.241.0.1:42002"),
193
+ ("10.250.0.2:44001", PEER, "203.0.113.2:44001"),
194
+ ("203.0.113.2:44002", PEER, "10.250.0.2:44002"),
195
+ ("10.250.0.2:44003", PEER, "10.250.0.2:44003"),
196
+ ("10.250.0.2:44004", HOST, "192.0.2.2:44004"),
197
+ ("203.0.113.2:44005", HOST, "10.241.0.1:44005"),
198
+ ];
199
+ let targets: Vec<_> = cases
200
+ .iter()
201
+ .map(|(_, ns, address)| bound(ns, address))
202
+ .collect();
203
+ let mut replies = Vec::new();
204
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
205
+ for sender in [&source, &host] {
206
+ replies.push(probe(sender, destination, target, true, false).unwrap());
207
+ }
208
+ }
209
+ // INPUT with NOTRACK is independent of OUTPUT/FORWARD controls.
210
+ let untracked_input = socket(HOST, "10.241.0.1:44003");
211
+ probe(&lan, "10.241.0.1:44003", &untracked_input, true, false);
212
+ // A reply to a public original destination can have a pool source before
213
+ // reverse DNAT in POSTROUTING. Both INPUT and FORWARD must deny that reply.
214
+ for (sender, receiver) in [(&source, replies[6]), (&host, replies[7])] {
215
+ targets[3].send_to(b"before", receiver).unwrap();
216
+ assert_eq!(sender.recv(&mut [0; 64]).unwrap(), 6);
217
+ }
218
+ let (owner, applied) = in_namespace(HOST, || {
219
+ let mut owner = Owner::new(options("pool_traffic")).unwrap();
220
+ let mut policy = egress::tests::pool_guard();
221
+ policy["scope"]["prefixes"]
222
+ .as_array_mut()
223
+ .unwrap()
224
+ .push(json!("10.250.0.0/16"));
225
+ let target = serde_json::from_value::<egress::Policy>(policy)
226
+ .unwrap()
227
+ .prepare()
228
+ .unwrap()
229
+ .into();
230
+ let applied = owner
231
+ .reconcile(Transition {
232
+ previous: None,
233
+ target,
234
+ })
235
+ .unwrap();
236
+ assert!(owner.inspect().enforced);
237
+ (owner, applied)
238
+ });
239
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
240
+ probe(&source, destination, target, false, false);
241
+ probe(&host, destination, target, false, true);
242
+ }
243
+ probe(&lan, "10.241.0.1:44003", &untracked_input, false, false);
244
+ for (sender, receiver) in [(&source, replies[6]), (&host, replies[7])] {
245
+ targets[3].send_to(b"after", receiver).unwrap();
246
+ assert!(sender.recv(&mut [0; 64]).is_err());
247
+ }
248
+ roundtrip(&source, &public);
249
+ roundtrip(&source, &host);
250
+ roundtrip(&host_pool, &public);
251
+ roundtrip(&host, &lan);
252
+ let fresh = socket(SOURCE, "10.240.0.2:10001");
253
+ roundtrip(&fresh, &public);
254
+ in_namespace(HOST, move || {
255
+ let mut owner = owner;
256
+ assert!(owner.inspect().enforced);
257
+ owner.release(applied).unwrap();
258
+ });
259
+ // Positive control after release proves the denials were this guard.
260
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
261
+ probe(&source, destination, target, true, false);
262
+ probe(&host, destination, target, true, false);
263
+ }
264
+ println!("POOL_GUARD_PACKET_PROOF input=true forward=true output=true current_dnat=true original_dnat=true untracked=true reply_current_source=true established_and_fresh_public=true management_lan=true release_control=true allocation_reusable=false");
265
+ }
@@ -21,6 +21,9 @@ mod packet_fixture;
21
21
  #[path = "owner_host_traffic_tests.rs"]
22
22
  mod host_traffic_tests;
23
23
 
24
+ #[path = "owner_poolguard_tests.rs"]
25
+ mod poolguard_tests;
26
+
24
27
  #[path = "owner_coexistence_tests.rs"]
25
28
  mod coexistence_tests;
26
29
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '1.6.0',
6
+ version: '1.7.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -78,8 +78,17 @@ export interface IManagedNftHostTransitScopeV2 {
78
78
  snatAddress: string;
79
79
  }
80
80
 
81
+ /** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
82
+ * This has no link dependency or allow grants. It is not allocation-release or boot-order proof. */
83
+ export interface IManagedNftAllocationPoolGuardScopeV2 {
84
+ kind: 'allocationPoolGuard';
85
+ authorityDigest: string;
86
+ /** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
87
+ prefixes: string[];
88
+ }
89
+
81
90
  export interface IManagedNftPolicyV2 {
82
91
  schemaVersion: 2;
83
92
  revision: number;
84
- scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2;
93
+ scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2 | IManagedNftAllocationPoolGuardScopeV2;
85
94
  }