@push.rocks/smartnftables 4.0.0 → 4.0.1

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-28 - 4.0.1
4
+
5
+ ### Fixes
6
+
7
+ - Accept a `hostTransit` scope whose uplink addresses lie outside `protection.prefixes`. Every local address had to be inside the protected union, including the uplink lease and `snatAddress`, but the protected authority is cluster-wide and covers pools, resolvers, platform endpoints and the VPN, not a node's uplink lease, which is usually public; so every node with an uplink outside the plan was refused `INVALID` on each preparation. The host barrier now protects the uplink addresses itself: each one no protected prefix covers joins `protected_out` and `protected_back` as a `/32`, after the authority's prefixes, so a forwarded flow whose live or originally tracked destination is the host's uplink lease, or one sourced from it toward a handoff, is still denied, and the scope compiles exactly as if the authority had named that `/32`. Handoff addresses must still be protected, and a platform endpoint on an uplink address must still be protected and declared in `localPlatformEndpoints`. A scope whose uplink is covered keeps its exact digest and compiled bytes.
8
+
3
9
  ## 2026-09-28 - 4.0.0
4
10
 
5
11
  ### Breaking Changes
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "e879efc88997e38bb45c045fae690c3a07a3ff515c2a2581689466e3088ca236",
3
+ "binarySha256": "e57ca68c62eeca26010cf3acf5159dd360205566ca1e96189faebffc2d49b0d7",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "4.0.0",
7
- "gitCommit": "7c66795f5e0edea87f2c9f7f1f70455cdf7e6bab",
6
+ "projectVersion": "4.0.1",
7
+ "gitCommit": "16dff8eb2060592212ea695074d7b53f39be8192",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-28T03:45:45.694Z",
9
+ "builtAt": "2026-09-28T17:43:55.875Z",
10
10
  "tsrustVersion": "1.15.1",
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": "268d3371a7986e7f1efcd6fb7fdd02b04635060a3e845c0ef45baec75e16a32a",
3
+ "binarySha256": "35d2fab639a6bc81cbe8395dc8d2dded56ce16712995007b127018fe0ed5ad20",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "4.0.0",
7
- "gitCommit": "7c66795f5e0edea87f2c9f7f1f70455cdf7e6bab",
6
+ "projectVersion": "4.0.1",
7
+ "gitCommit": "16dff8eb2060592212ea695074d7b53f39be8192",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-28T03:45:55.540Z",
9
+ "builtAt": "2026-09-28T17:44:06.408Z",
10
10
  "tsrustVersion": "1.15.1",
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: '4.0.0',
6
+ version: '4.0.1',
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=
@@ -175,6 +175,8 @@ export interface IManagedNftHostTransitScopeV2 {
175
175
  link: IManagedNftLocalLinkV2;
176
176
  allocations: IManagedNftHandoffAllocationV2[];
177
177
  }>;
178
+ /** Its addresses need not lie in `protection.prefixes`; the host barrier protects each
179
+ * uncovered one as a `/32` destination. Handoff addresses must be protected. */
178
180
  uplink: IManagedNftLocalLinkV2;
179
181
  /** Exact current address present on uplink. No masquerade or default-route inference. */
180
182
  snatAddress: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartnftables",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
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
@@ -369,6 +369,15 @@ so foreign DNAT cannot turn a protected destination into a public exception or
369
369
  redirect public traffic into protected space. INPUT diversion, local OUTPUT into
370
370
  handoffs, unmatched handoff traffic and IPv6 forwarding are denied.
371
371
 
372
+ Every handoff address and platform endpoint lies in the protected prefixes. The
373
+ `hostTransit` uplink addresses need not: the authority is the platform's address
374
+ space, and a node's uplink lease and `snatAddress` is usually a public address
375
+ outside it. The host barrier protects the uplink addresses itself, adding each one
376
+ no protected prefix covers as a `/32` after the authority's prefixes, so a
377
+ forwarded flow whose current or original destination is the host's uplink address,
378
+ or one sourced from it toward a handoff, is denied like any protected destination.
379
+ A scope whose uplink the prefixes already cover compiles exactly as before.
380
+
372
381
  #### Published host ports
373
382
 
374
383
  `hostTransit.publishedPorts` is optional. Absent and empty are the same canonical
@@ -494,7 +503,8 @@ the ESTABLISHED original direction, a NEW opening per protocol with opening TCP
494
503
  restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply. The handoff
495
504
  ingress classification precedes the
496
505
  barrier's protected-source denial, because the uplink or management network that
497
- reaches a published port is itself protected space. The endpoint ingress
506
+ reaches a published port can itself be protected space (an uplink address outside
507
+ the protected prefixes is still guarded as a /32). The endpoint ingress
498
508
  classification matches the exact workload link, address and published endpoint
499
509
  port, and precedes every leased classifier of every generation: raw runs before
500
510
  conntrack, so a client whose source port equals a leased grant's destination port
@@ -1,5 +1,22 @@
1
1
  use super::*;
2
2
 
3
+ /// The host's own uplink addresses are protected destinations of this scope. A
4
+ /// forwarded packet never reaches them, so a live or originally tracked tuple
5
+ /// holding one is a translation into the host, and a forwarded one sourced from
6
+ /// one is a spoof. The protected authority is cluster-wide and need not cover a
7
+ /// node's uplink lease, which is usually public, so every uplink address outside
8
+ /// it joins the barrier as a /32, in the uplink's canonical address order. An address the authority
9
+ /// covers meets its prefix already, so a covered uplink compiles exactly as
10
+ /// before.
11
+ fn uplink_barrier(scope: &HostScope) -> Result<Vec<String>> {
12
+ let mut barrier = Vec::new();
13
+ for ip in &scope.uplink.required_ipv4_addresses {
14
+ if !covers_address(&scope.protection.prefixes, ip)? {
15
+ barrier.push(format!("{ip}/32"));
16
+ }
17
+ }
18
+ Ok(barrier)
19
+ }
3
20
  fn protection(program: &mut Program<'_>, scope: &HostScope, reply: bool) -> Result<()> {
4
21
  let chain = if reply {
5
22
  "protected_back"
@@ -17,7 +34,8 @@ fn protection(program: &mut Program<'_>, scope: &HostScope, reply: bool) -> Resu
17
34
  expressions.extend(original_port(endpoint.port, false));
18
35
  program.end(chain, expressions, -5)?;
19
36
  }
20
- for prefix in &scope.protection.prefixes {
37
+ let barrier = uplink_barrier(scope)?;
38
+ for prefix in scope.protection.prefixes.iter().chain(&barrier) {
21
39
  for original in [false, true] {
22
40
  let mut expressions = ipv4();
23
41
  expressions.extend(if original {
@@ -1102,13 +1102,14 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
1102
1102
  .ok_or(Error::Invalid)?;
1103
1103
  require(local_addresses.contains(&endpoint.address))?;
1104
1104
  }
1105
+ // Handoff addresses lie in protected space, checked per handoff below. An
1106
+ // uplink address need not: the uplink lease and SNAT address is the host's
1107
+ // public side, usually outside the protected authority, and the compiler
1108
+ // adds each uncovered one to the host barrier as a protected destination.
1105
1109
  for ip in &local_addresses {
1106
- require(
1107
- covers_address(&value.protection.prefixes, ip)?
1108
- && value.protection.platform_endpoints.iter().all(|endpoint| {
1109
- endpoint.address != *ip || value.local_platform_endpoints.contains(&endpoint.id)
1110
- }),
1111
- )?;
1110
+ require(value.protection.platform_endpoints.iter().all(|endpoint| {
1111
+ endpoint.address != *ip || value.local_platform_endpoints.contains(&endpoint.id)
1112
+ }))?;
1112
1113
  }
1113
1114
  for handoff in &mut value.handoffs {
1114
1115
  normalize_link(&mut handoff.link)?;
@@ -1186,6 +1187,9 @@ mod routerequivalence_tests;
1186
1187
  #[path = "egress_tests.rs"]
1187
1188
  pub(crate) mod tests;
1188
1189
  #[cfg(test)]
1190
+ #[path = "egress_uplink_tests.rs"]
1191
+ mod uplink_tests;
1192
+ #[cfg(test)]
1189
1193
  #[path = "egress_workloadgrant_tests.rs"]
1190
1194
  pub(crate) mod workloadgrant_tests;
1191
1195
  impl Policy {
@@ -0,0 +1,159 @@
1
+ //! Host transit on an uplink whose own addresses lie outside the protected
2
+ //! authority: the cluster-wide authority covers pools, resolvers, platform
3
+ //! endpoints and the VPN, not a node's uplink lease, which is usually public.
4
+ //! The scope prepares, and the host barrier protects the uplink addresses itself.
5
+ use super::hostgrant_tests::{
6
+ chains, decide, normalized, opening, prepared, Flow, ACK, ESTABLISHED,
7
+ };
8
+ use super::tests::host;
9
+ use serde_json::{json, Value};
10
+
11
+ const TRANSIT: [u8; 4] = [10, 240, 0, 2];
12
+ const UPLINK: [u8; 4] = [198, 51, 100, 7];
13
+ const PUBLIC: [u8; 4] = [198, 51, 100, 200];
14
+ const HANDOFF_LINK: Option<(u32, &str)> = Some((4, "handoff"));
15
+ const UPLINK_LINK: Option<(u32, &str)> = Some((2, "ens18"));
16
+
17
+ /// The `preparePolicy` request Pallet 35.1.0 sent pallet-guard on a lab node,
18
+ /// captured on the native IPC (lab-only private addresses). Its uplink lease and
19
+ /// SNAT address 10.92.26.30 lies outside the protected prefix 10.240.0.0/12; the
20
+ /// two other uplink addresses hold host-local platform endpoints inside it.
21
+ fn captured() -> Value {
22
+ json!({"schemaVersion":2,"revision":1,"scope":{"kind":"hostTransit",
23
+ "protection":{
24
+ "authorityDigest":"sha256:f436a15c8c8f4d4f0f23bc9fc69f45a8c9137a9586db1d2c2be8c09dac3467c9",
25
+ "prefixes":["10.240.0.0/12"],
26
+ "platformEndpoints":[
27
+ {"id":"5aaa706266a3c4e9b0cc99eb354496a732f88cdf3d36fd5911ab6fbb98ef7489","address":"10.244.0.30","port":8443,"protocol":"udp"},
28
+ {"id":"0298aa0b89b4248e058b7a279abc9d9c1793d2d012752432e95881367b43d6ac","address":"10.245.0.30","port":27017,"protocol":"tcp"},
29
+ {"id":"f0808a59e876a51c1a8a5b8cf38e0fc323dd5309dec3a8890468a1afc69dc9ff","address":"10.245.0.30","port":9000,"protocol":"tcp"},
30
+ {"id":"f4744ddb8e7d63883a7fbd4635f3ee4a031650effaaf5c13a1c664182b2b0501","address":"10.245.0.30","port":8443,"protocol":"tcp"},
31
+ {"id":"8933bdd0997dc2b230b0be7960c1708a57d72a0fa9481ec4aa56b9311ae68751","address":"10.243.0.1","port":53,"protocol":"tcp"},
32
+ {"id":"6e88da7a803f69d77d8d8fd86c5952ae87c26c3172b2f9a751662793e0ee8a7e","address":"10.243.0.1","port":53,"protocol":"udp"}]},
33
+ "handoffs":[{
34
+ "link":{"interfaceIndex":11,"interfaceName":"phc05f15afde9d","interfaceKind":"veth",
35
+ "macAddress":"4a:d5:5b:95:91:fa","interfaceLinkIndex":2,"requiredIpv4Addresses":["10.240.0.0"]},
36
+ "allocations":[{
37
+ "lease":{"id":"049eda6820b222e7e371bb4d1e24a325ac92422cff1900443c1e74a9efbb5d5e","generation":1,
38
+ "digest":"sha256:9a5635b213263796ac3b883cce942a0ca45161d9412ec6c6c9a5d748c22a3a7c"},
39
+ "transitSourceAddress":"10.240.0.1",
40
+ "sourcePortRanges":[{"protocol":"tcp","first":1024,"last":65535},{"protocol":"udp","first":1024,"last":65535}]}]}],
41
+ "uplink":{"interfaceIndex":2,"interfaceName":"enp1s0","interfaceKind":"ethernet",
42
+ "macAddress":"52:54:00:92:26:30","interfaceLinkIndex":2,
43
+ "requiredIpv4Addresses":["10.244.0.30","10.245.0.30","10.92.26.30"]},
44
+ "snatAddress":"10.92.26.30",
45
+ "localPlatformEndpoints":[
46
+ "0298aa0b89b4248e058b7a279abc9d9c1793d2d012752432e95881367b43d6ac",
47
+ "5aaa706266a3c4e9b0cc99eb354496a732f88cdf3d36fd5911ab6fbb98ef7489",
48
+ "f0808a59e876a51c1a8a5b8cf38e0fc323dd5309dec3a8890468a1afc69dc9ff",
49
+ "f4744ddb8e7d63883a7fbd4635f3ee4a031650effaaf5c13a1c664182b2b0501"]}})
50
+ }
51
+ /// The host fixture on a public uplink lease outside every protected prefix.
52
+ fn public() -> Value {
53
+ let mut value = host();
54
+ value["scope"]["uplink"]["requiredIpv4Addresses"] = json!(["198.51.100.7"]);
55
+ value["scope"]["snatAddress"] = json!("198.51.100.7");
56
+ value
57
+ }
58
+ /// A leased opening from the handoff out through the uplink, to `destination`
59
+ /// in the live tuple and `original` in the originally tracked one.
60
+ fn egress(destination: [u8; 4], original: [u8; 4]) -> Flow {
61
+ Flow {
62
+ source: TRANSIT,
63
+ destination,
64
+ source_port: 10500,
65
+ destination_port: 443,
66
+ original: (TRANSIT, original, 10500, 443),
67
+ ..opening(HANDOFF_LINK, UPLINK_LINK)
68
+ }
69
+ }
70
+
71
+ #[test]
72
+ fn host_transit_prepares_the_captured_lab_request_with_an_unprotected_uplink_lease() {
73
+ // Refused INVALID by 4.0.0: the uplink lease was required to be protected.
74
+ let lab = prepared(captured());
75
+ // The lease joins the barrier as a /32, exactly as if the authority had
76
+ // named it; the covered uplink addresses meet their prefix already.
77
+ let mut value = captured();
78
+ value["scope"]["protection"]["prefixes"] = json!(["10.240.0.0/12", "10.92.26.30/32"]);
79
+ assert_eq!(chains(&lab), chains(&prepared(value)));
80
+ }
81
+
82
+ #[test]
83
+ fn host_transit_uplink_addresses_need_no_protected_prefix_but_handoff_addresses_do() {
84
+ assert!(normalized(public()).is_ok());
85
+ // A handoff address outside the authority still refuses; the control
86
+ // covering it prepares.
87
+ let handoff = |prefix: &str| {
88
+ let mut value = public();
89
+ value["scope"]["protection"]["prefixes"] =
90
+ json!([prefix, "192.0.2.0/24", "203.0.113.10/32"]);
91
+ normalized(value)
92
+ };
93
+ assert!(handoff("10.240.0.2/32").is_err());
94
+ assert!(handoff("10.240.0.0/30").is_ok());
95
+ // A platform endpoint on the uplink lease lies in protected space, and it
96
+ // still refuses unless declared host-local.
97
+ let mut value = public();
98
+ value["scope"]["protection"]["prefixes"] =
99
+ json!(["10.0.0.0/8", "192.0.2.0/24", "198.51.100.7/32"]);
100
+ value["scope"]["protection"]["platformEndpoints"] = json!([
101
+ {"id":"hub","address":"198.51.100.7","protocol":"tcp","port":8443}]);
102
+ assert!(normalized(value.clone()).is_err());
103
+ value["scope"]["localPlatformEndpoints"] = json!(["hub"]);
104
+ assert!(normalized(value).is_ok());
105
+ }
106
+
107
+ #[test]
108
+ fn host_transit_barrier_protects_an_unprotected_uplink_lease_as_a_destination() {
109
+ let with = prepared(public());
110
+ // Positive control: leased egress to public space passes, including a flow
111
+ // another owner translated between two public destinations.
112
+ for flow in [egress(PUBLIC, PUBLIC), egress(PUBLIC, [198, 51, 100, 201])] {
113
+ assert_eq!(decide(&with, "forward", &flow).verdict, 1);
114
+ }
115
+ // A flow whose live or originally tracked destination is the host's own
116
+ // uplink lease is a translation into the host: the barrier refuses it.
117
+ for flow in [egress(PUBLIC, UPLINK), egress(UPLINK, PUBLIC)] {
118
+ let decision = decide(&with, "forward", &flow);
119
+ assert_eq!(
120
+ (decision.verdict, decision.chain.as_str()),
121
+ (0, "protected_out")
122
+ );
123
+ }
124
+ // A packet forwarded toward the handoff from the uplink lease is a spoof.
125
+ let reply = Flow {
126
+ input: UPLINK_LINK,
127
+ output: HANDOFF_LINK,
128
+ source: UPLINK,
129
+ destination: TRANSIT,
130
+ source_port: 443,
131
+ destination_port: 10500,
132
+ tcp_flags: ACK,
133
+ state: ESTABLISHED,
134
+ reply: true,
135
+ ..egress(PUBLIC, PUBLIC)
136
+ };
137
+ let decision = decide(&with, "forward", &reply);
138
+ assert_eq!(
139
+ (decision.verdict, decision.chain.as_str()),
140
+ (0, "protected_back")
141
+ );
142
+ let reply = Flow {
143
+ source: PUBLIC,
144
+ ..reply
145
+ };
146
+ assert_eq!(decide(&with, "forward", &reply).verdict, 1);
147
+ }
148
+
149
+ #[test]
150
+ fn host_transit_covered_uplink_compiles_exactly_the_authority_barrier() {
151
+ // The fixture's uplink lies inside the authority: no address joins the
152
+ // barrier, and its compiled bytes stay frozen by the golden host test.
153
+ let covered = chains(&prepared(host()));
154
+ let public = chains(&prepared(public()));
155
+ for name in ["protected_out", "protected_back"] {
156
+ // One /32 adds a live and an originally tracked destination check.
157
+ assert_eq!(public[name].len(), covered[name].len() + 2, "{name}");
158
+ }
159
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '4.0.0',
6
+ version: '4.0.1',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -172,6 +172,8 @@ export interface IManagedNftHostTransitScopeV2 {
172
172
  kind: 'hostTransit';
173
173
  protection: IManagedNftProtectionV2;
174
174
  handoffs: Array<{ link: IManagedNftLocalLinkV2; allocations: IManagedNftHandoffAllocationV2[] }>;
175
+ /** Its addresses need not lie in `protection.prefixes`; the host barrier protects each
176
+ * uncovered one as a `/32` destination. Handoff addresses must be protected. */
175
177
  uplink: IManagedNftLocalLinkV2;
176
178
  /** Exact current address present on uplink. No masquerade or default-route inference. */
177
179
  snatAddress: string;