@push.rocks/smartnftables 4.3.1 → 4.4.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026-10-06 - 4.4.0
4
+
5
+ ### Features
6
+
7
+ - `allocationPoolGuard.publishedPorts`: publications are exceptions of the allocation-pool guard. A `hostTransit` publication translates an inbound uplink flow to the router's transit address, which lies in a guarded pool, so the guard dropped every published flow in FORWARD by its current destination (and every reply by its current source) on any host that runs both tables: Pallet's docker-shared lab node delivered nothing on its published ports (serve.zone lab 2026-10-06, D12). Each entry `{ protocol, hostPort, hostPortEnd?, targetPort, targetAddress }` admits the flow translated from outside every pool to that exact pool address and port, and its replies, in the default conntrack zone: two or three rules in the guard chain after the original-destination denial, around the CONSTANT interval set `pool_published`. Absent and empty keep every guard's digest and compiled bytes. Readme: "Publications through the allocation-pool guard". Tests: `egress_poolguardpublished_tests` (exact admission, and the guard's own refusal for 18 foreign flows (other address, port, protocol, zone and untranslated flows), unchanged local and reply chains, canonical form, refusals, the largest guard's atomic budget).
8
+
9
+ ### Maintenance
10
+
11
+ - Release tooling: pnpm 12.8.1, `@git.zone/cli` ^8.8.2, `@git.zone/tstest` ^7.1.0 and `@git.zone/tsrust` 4.0.0. tsrust 4 always remaps local paths and fails a build whose binary still records one, so the obsolete `remapLocalPaths` setting leaves `.smartconfig.json`.
12
+
3
13
  ## 2026-10-02 - 4.3.1
4
14
 
5
15
  ### Fixes
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "cf73bfcf658ee25f8a084e50cefb3f43d2f949b0cdcc46a57b4e91a9c0497a20",
3
+ "binarySha256": "92ddb3d386ae7d83ce27c0df8b54bb08ce3bb54cba71e80df8e0c3e808e4da7b",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "4.3.1",
7
- "gitCommit": "134a7f1fd3986cc97c9450108f7a79c7b5b395ec",
6
+ "projectVersion": "4.4.0",
7
+ "gitCommit": "77c4a57ed74dce10ee91ef180972ebd9cc821211",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-10-02T22:57:27.524Z",
10
- "tsrustVersion": "3.0.0",
9
+ "builtAt": "2026-10-06T03:48:00.001Z",
10
+ "tsrustVersion": "4.0.0",
11
11
  "binary": "smartnftables",
12
12
  "target": "linux_amd64_musl"
13
13
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "ae5bf59b529f990c5f0a5ff8c8898cb42d3dcb5ce724c3786f64c69eb5f03bdd",
3
+ "binarySha256": "889d263bb28613ed54717f261f3fb75f5e394e88d9cf9632d611a7fbe621c582",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "4.3.1",
7
- "gitCommit": "134a7f1fd3986cc97c9450108f7a79c7b5b395ec",
6
+ "projectVersion": "4.4.0",
7
+ "gitCommit": "77c4a57ed74dce10ee91ef180972ebd9cc821211",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-10-02T22:57:37.239Z",
10
- "tsrustVersion": "3.0.0",
9
+ "builtAt": "2026-10-06T03:48:09.553Z",
10
+ "tsrustVersion": "4.0.0",
11
11
  "binary": "smartnftables",
12
12
  "target": "linux_arm64_musl"
13
13
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '4.3.1',
6
+ version: '4.4.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=
@@ -217,17 +217,38 @@ export interface IManagedNftLocalTcpPortOwner {
217
217
  * which only privileged mounts create. */
218
218
  uid: number;
219
219
  }
220
+ /** One publication an allocation-pool guard admits into a pool: a flow whose original destination lies
221
+ * outside every listed pool, on `hostPort` (or the range `hostPort..hostPortEnd`), translated to exactly
222
+ * `targetAddress` and `targetPort`, and its replies, in the default conntrack zone. The guard checks
223
+ * no link and no uplink address: the scope that translates the flow (`hostTransit.publishedPorts`)
224
+ * binds those. Compile the same publication into the guard and the host-transit scope it crosses. */
225
+ export interface IManagedNftPoolGuardPublishedPort {
226
+ protocol: 'tcp' | 'udp';
227
+ /** Published port, 1–65535; each protocol and port is published at most once per guard. */
228
+ hostPort: number;
229
+ /** Optional last port of a published range, greater than `hostPort`; a range keeps every port,
230
+ * so `targetPort` must equal `hostPort`. Absent for one port, its only canonical form. */
231
+ hostPortEnd?: number;
232
+ targetPort: number;
233
+ /** Exact unicast address inside a listed pool that the publication is translated to. */
234
+ targetAddress: string;
235
+ }
220
236
  /** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
221
- * It has no link dependency; exact host grants are its only exceptions. It is not allocation-release or boot-order proof. */
237
+ * It has no link dependency; exact host grants and publications are its only exceptions. It is not
238
+ * allocation-release or boot-order proof. */
222
239
  export interface IManagedNftAllocationPoolGuardScopeV2 {
223
240
  kind: 'allocationPoolGuard';
224
241
  authorityDigest: string;
225
242
  /** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
226
243
  prefixes: string[];
227
- /** The only exceptions to the guard: exact host-origin flows whose `destinationAddress` lies in a
228
- * listed pool, and their replies. Absent and empty are the same canonical policy; at most 1024,
229
- * held in a named set behind a constant number of rules. */
244
+ /** Exceptions to the guard: exact host-origin flows whose `destinationAddress` lies in a listed
245
+ * pool, and their replies. Absent and empty are the same canonical policy; at most 1024, held in a
246
+ * named set behind a constant number of rules. */
230
247
  hostGrants?: IManagedNftHostGrant[];
248
+ /** Exceptions to the guard: publications translated from outside every listed pool into one, and
249
+ * their replies. Absent and empty are the same canonical policy, digest and compiled graph; at most
250
+ * 1024, held in a named set behind a constant number of rules. */
251
+ publishedPorts?: IManagedNftPoolGuardPublishedPort[];
231
252
  /** Optional host-wide loopback port ownership, compiled ahead of the guard in the same table.
232
253
  * Absent and empty are the same canonical policy, digest and compiled graph; at most 8, one per
233
254
  * exact address and port. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@push.rocks/smartnftables",
3
- "version": "4.3.1",
3
+ "version": "4.4.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",
@@ -9,11 +9,11 @@
9
9
  "author": "Task Venture Capital GmbH",
10
10
  "license": "MIT",
11
11
  "devDependencies": {
12
- "@git.zone/cli": "^8.4.0",
12
+ "@git.zone/cli": "^8.8.2",
13
13
  "@git.zone/tsbuild": "^5.1.2",
14
14
  "@git.zone/tsrun": "^3.0.2",
15
- "@git.zone/tsrust": "3.0.0",
16
- "@git.zone/tstest": "^7.0.0",
15
+ "@git.zone/tsrust": "4.0.0",
16
+ "@git.zone/tstest": "^7.1.0",
17
17
  "@types/node": "^26.6.3",
18
18
  "typescript": "^7.0.2"
19
19
  },
package/readme.md CHANGED
@@ -324,7 +324,7 @@ allocation-pool guard policy kinds.
324
324
  | --- | --- |
325
325
  | `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. Optional `publishedPorts` add the inbound second hop from the handoff to a workload endpoint, one port or a port range; both directions are classified into the default conntrack zone ahead of every leased classifier, so a published endpoint port is dedicated to its publication and never becomes leased egress. A `symmetric` publication also lets the workload open flows from its published ports. Optional `hostGrants` forward exact host-origin flows from the handoff to a workload endpoint. Optional `workloadGrants` let one workload open one exact port of another, one way. |
326
326
  | `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. Optional `publishedPorts` add inbound uplink destination NAT inside the same generation, one port or a port range, and a `symmetric` publication also carries the workload's own flows from its published ports out through the uplink. Optional `hostGrants` let the host's own address on a handoff dial exact workload ports. Optional `localPlatformEndpoints` serve platform endpoints on the host's own addresses to leased flows. Optional `exclusiveForwarding` makes the table the host's only forwarding owner: every other forwarded packet drops. |
327
- | `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. Optional `hostGrants` are its only exceptions. Optional `localTcpPortOwners` restrict loopback TCP ports to one local user each. |
327
+ | `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. Optional `hostGrants` and `publishedPorts` are its only exceptions. Optional `localTcpPortOwners` restrict loopback TCP ports to one local user each. |
328
328
 
329
329
  `allocationPoolGuard` accepts 1–64 canonical, disjoint RFC1918 prefixes. Supply
330
330
  the actual allocation pools, not the broader protected union containing management
@@ -742,6 +742,42 @@ These flows need no private `rules`. A private rule is stateless: answering thro
742
742
  private rules needs a reverse rule, which would also let the destination open toward
743
743
  the source.
744
744
 
745
+ #### Publications through the allocation-pool guard
746
+
747
+ A `hostTransit` publication translates an inbound flow from the uplink into the
748
+ router's transit address, which lies in a guarded pool, so the guard denies it by
749
+ its current destination and its replies by their current source.
750
+ `allocationPoolGuard.publishedPorts` is optional and lists the publications the
751
+ guard admits: each `{ protocol, hostPort, hostPortEnd?, targetPort, targetAddress }`
752
+ names the published port or range, and the exact pool address and port it is
753
+ translated to (a range keeps every port, so `targetPort` equals `hostPort`). The
754
+ target must be a unicast address inside a listed pool; each protocol and port is
755
+ published at most once; up to 1024 fit. Compile the same publications into the
756
+ guard and into the host-transit scope that translates them, which binds the uplink
757
+ address and the handoff link the guard does not check.
758
+
759
+ ```typescript
760
+ const guard: IManagedNftPolicyV2 = { schemaVersion: 2, revision: 1, scope: {
761
+ kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16', '10.241.0.0/16'],
762
+ publishedPorts: [{ protocol: 'tcp', hostPort: 443, targetPort: 443, targetAddress: '10.240.0.1' },
763
+ { protocol: 'udp', hostPort: 20000, hostPortEnd: 20200, targetPort: 20000, targetAddress: '10.240.0.1' }] } };
764
+ ```
765
+
766
+ The admissions sit in the guard chain right after its denial of every pool as an
767
+ original destination, so only a flow translated from outside every pool can match,
768
+ and ahead of every current-address denial: an ESTABLISHED packet of either
769
+ direction and a NEW opening per protocol (TCP only with SYN and FIN/RST/ACK clear),
770
+ in the default conntrack zone, each one lookup of the protocol, the reply tuple's
771
+ source address and port (where the flow was translated to, in both directions) and
772
+ the originally tracked destination port in the CONSTANT interval set
773
+ `pool_published`. Another target, target port, published port, protocol or zone, a
774
+ non-SYN opening and an original destination inside a pool meet the guard unchanged.
775
+ A publication is the exception of every hook it crosses; a forwarded publication
776
+ crosses FORWARD alone. Absent and empty are the same canonical policy, digest and
777
+ compiled bytes. The largest guard (64 pools, 1024 host grants, 8 loopback port
778
+ owners and 1024 publications) compiles to 99,688 rule bytes and 74,640 element
779
+ bytes at revision 1.
780
+
745
781
  #### Loopback TCP port owners
746
782
 
747
783
  `allocationPoolGuard.localTcpPortOwners` is optional and restricts a loopback TCP
@@ -45,6 +45,14 @@ pub(super) fn ct_to(key: u32, original: bool, index: u32) -> Attr {
45
45
  }
46
46
  store("ct", data, index)
47
47
  }
48
+ /// A conntrack key of the reply direction's tuple.
49
+ pub(super) fn ct_reply_to(key: u32, index: u32) -> Attr {
50
+ store(
51
+ "ct",
52
+ vec![Attr::u32(2, key), Attr::bytes(3, vec![1])],
53
+ index,
54
+ )
55
+ }
48
56
  pub(super) fn lookup(set: &str) -> Attr {
49
57
  expr(
50
58
  "lookup",
@@ -2,7 +2,7 @@
2
2
  //! the loads that build a key in consecutive 32-bit registers, element bounds
3
3
  //! built field by field, the disjoint union the kernel's interval backend needs,
4
4
  //! and set declarations that are never created empty.
5
- use super::hostgrant::{ct_to, lookup, meta_to, payload_to, register};
5
+ use super::hostgrant::{ct_reply_to, ct_to, lookup, meta_to, payload_to, register};
6
6
  use super::*;
7
7
 
8
8
  /// Field byte lengths. Each field occupies whole 32-bit registers of the key.
@@ -65,6 +65,12 @@ impl Loads {
65
65
  pub(super) fn original_port(&mut self, source: bool) -> &mut Self {
66
66
  self.push(|at| ct_to(if source { 11 } else { 12 }, true, at), PORT)
67
67
  }
68
+ /// The reply tuple's source address and port: where a translated flow was
69
+ /// translated to, whichever direction the packet travels.
70
+ pub(super) fn translated(&mut self) -> &mut Self {
71
+ self.push(|at| ct_reply_to(19, at), ADDRESS)
72
+ .push(|at| ct_reply_to(11, at), PORT)
73
+ }
68
74
  pub(super) fn zone(&mut self) -> &mut Self {
69
75
  self.push(|at| ct_to(17, false, at), ZONE)
70
76
  }
@@ -1,5 +1,11 @@
1
+ use super::keys::{self, Key, Loads, ADDRESS, PORT, PROTOCOL};
1
2
  use super::*;
2
3
 
4
+ /// Publications translated into a pool: the protocol, the address and port the
5
+ /// flow was translated to, and its originally tracked destination port. Port fields are inclusive intervals, so one element holds
6
+ /// one port or a whole range.
7
+ const PUBLISHED: &str = "pool_published";
8
+
3
9
  pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
4
10
  for (name, hook) in [("input", 1), ("forward", 2), ("output", 3)] {
5
11
  program.chain(name, Some((hook, 0, "filter")))?;
@@ -18,6 +24,9 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScop
18
24
  hostgrant::tuple_set(program, &scope.host_grants, |_| Ok(None))?;
19
25
  hostgrant::admissions(program, ["output", "input"], false, |_| vec![])?;
20
26
  }
27
+ if !scope.published_ports.is_empty() {
28
+ published_set(program, scope)?;
29
+ }
21
30
  for name in ["input", "forward", "output"] {
22
31
  program.jump(name, ipv4(), "pool_guard")?;
23
32
  }
@@ -25,6 +34,11 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScop
25
34
  for prefix in &scope.prefixes {
26
35
  program.end("pool_guard", original_address(prefix, false)?, 0)?;
27
36
  }
37
+ // Publications follow that denial, so only a flow translated from outside
38
+ // every pool can match one, and precede every current-address denial.
39
+ if !scope.published_ports.is_empty() {
40
+ published(program, scope)?;
41
+ }
28
42
  // GOTO, not JUMP: RETURN from pool_reply must return to the base chain,
29
43
  // skipping current-destination denial for legitimate reverse-SNAT replies.
30
44
  let mut reply = ct(1, None, vec![1]);
@@ -40,6 +54,69 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScop
40
54
  program.end("pool_reply", vec![], -5)
41
55
  }
42
56
 
57
+ /// The publications of the scope as one interval set.
58
+ fn published_set(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
59
+ let mut elements = Vec::new();
60
+ for port in &scope.published_ports {
61
+ elements.push(
62
+ Key::default()
63
+ .protocol(&port.protocol)
64
+ .address(&port.target_address)?
65
+ .span(port.target_port, port.target_last())
66
+ .span(port.host_port, port.host_last())
67
+ .element(None),
68
+ );
69
+ }
70
+ keys::interval(
71
+ program,
72
+ PUBLISHED,
73
+ 2,
74
+ &[PROTOCOL, ADDRESS, PORT, PORT],
75
+ None,
76
+ elements,
77
+ )
78
+ .map(|_| ())
79
+ }
80
+ /// The admissions of every publication, whatever their number: behind the
81
+ /// default conntrack zone, an ESTABLISHED packet of either direction, and a NEW
82
+ /// opening per protocol (TCP only with SYN and FIN/RST/ACK clear). Each looks its
83
+ /// flow up once by the reply tuple's source, which is the address and port the
84
+ /// flow was translated to in both directions, and the originally tracked
85
+ /// destination port. They run in the guard chain after the original-destination
86
+ /// denial, so a flow whose original destination lies in a pool never reaches
87
+ /// them. A publication is the exception of every hook it crosses: on a host that
88
+ /// forwards it, that is FORWARD alone.
89
+ fn published(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
90
+ let lookup = || {
91
+ let mut loads = Loads::new();
92
+ loads.protocol().translated().original_port(false);
93
+ loads.lookup(PUBLISHED)
94
+ };
95
+ let envelope = |connection_state: u32| {
96
+ let mut result = ct(17, None, 0_u16.to_ne_bytes().to_vec());
97
+ result.extend(state(connection_state));
98
+ result
99
+ };
100
+ let mut established = envelope(2);
101
+ established.extend(lookup());
102
+ program.end("pool_guard", established, 1)?;
103
+ let transports: std::collections::BTreeSet<&str> = scope
104
+ .published_ports
105
+ .iter()
106
+ .map(|port| port.protocol.as_str())
107
+ .collect();
108
+ for transport in transports {
109
+ let mut opening = envelope(8);
110
+ opening.extend(meta(16, vec![protocol(transport)]));
111
+ if transport == "tcp" {
112
+ opening.extend(opening_tcp());
113
+ }
114
+ opening.extend(lookup());
115
+ program.end("pool_guard", opening, 1)?;
116
+ }
117
+ Ok(())
118
+ }
119
+
43
120
  /// The exact loopback TCP destination: IPv4, TCP, address and port.
44
121
  fn local_tcp_port(owner: &LocalTcpPortOwner) -> Result<Vec<Attr>> {
45
122
  let mut result = ipv4();
@@ -282,15 +282,43 @@ pub struct LocalTcpPortOwner {
282
282
  pub port: u16,
283
283
  pub uid: u32,
284
284
  }
285
+ /// One publication a guard admits into a pool: a forwarded flow whose original
286
+ /// destination lies outside every guarded pool, on the published port or range,
287
+ /// translated to exactly `target_address` and the target port(s), and its replies.
288
+ /// A range keeps every port: `target_port` equals `host_port`. Absent for one port.
289
+ #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
290
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
291
+ pub struct PoolGuardPublishedPort {
292
+ pub protocol: String,
293
+ pub host_port: u16,
294
+ #[serde(default, skip_serializing_if = "Option::is_none")]
295
+ pub host_port_end: Option<u16>,
296
+ pub target_port: u16,
297
+ pub target_address: String,
298
+ }
299
+ impl PoolGuardPublishedPort {
300
+ /// Last published port; equal to `host_port` for one port.
301
+ pub fn host_last(&self) -> u16 {
302
+ self.host_port_end.unwrap_or(self.host_port)
303
+ }
304
+ /// Last target port the publication translates to.
305
+ pub fn target_last(&self) -> u16 {
306
+ self.target_port + (self.host_last() - self.host_port)
307
+ }
308
+ }
285
309
  #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
286
310
  #[serde(rename_all = "camelCase", deny_unknown_fields)]
287
311
  pub struct AllocationPoolGuardScope {
288
312
  pub authority_digest: String,
289
313
  pub prefixes: Vec<String>,
290
- /// The only exceptions to the guard: exact host-origin flows into a pool.
314
+ /// Exceptions to the guard: exact host-origin flows into a pool.
291
315
  /// Absent and empty are the same canonical policy.
292
316
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
293
317
  pub host_grants: Vec<HostGrant>,
318
+ /// Exceptions to the guard: forwarded publications translated into a pool,
319
+ /// and their replies. Absent and empty are the same canonical policy.
320
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
321
+ pub published_ports: Vec<PoolGuardPublishedPort>,
294
322
  /// Host-wide loopback port ownership. Absent and empty are the same
295
323
  /// canonical policy.
296
324
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -974,8 +1002,33 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
974
1002
  require(covers_address(&value.prefixes, &grant.destination_address)?)
975
1003
  })?;
976
1004
  value.host_grants = grants;
1005
+ normalize_pool_guard_published(value)?;
977
1006
  normalize_local_tcp_port_owners(&mut value.local_tcp_port_owners)
978
1007
  }
1008
+ /// A guard publication translates into exactly one address of a guarded pool,
1009
+ /// keeping a range's ports, and publishes each protocol and port at most once,
1010
+ /// as a host-transit scope does: sorted by protocol and first port, disjoint
1011
+ /// neighbours are disjoint spans.
1012
+ fn normalize_pool_guard_published(value: &mut AllocationPoolGuardScope) -> Result<()> {
1013
+ let mut ports = std::mem::take(&mut value.published_ports);
1014
+ crate::capacity("publishedPorts", PUBLISHED_PORTS, ports.len())?;
1015
+ ports.sort();
1016
+ for (index, port) in ports.iter().enumerate() {
1017
+ require(
1018
+ protocol(&port.protocol)
1019
+ && port.host_port > 0
1020
+ && port.target_port > 0
1021
+ && published_span(port.host_port, port.host_port_end, port.target_port)
1022
+ && (index == 0
1023
+ || ports[index - 1].protocol != port.protocol
1024
+ || ports[index - 1].host_last() < port.host_port),
1025
+ )?;
1026
+ unicast(&port.target_address)?;
1027
+ require(covers_address(&value.prefixes, &port.target_address)?)?;
1028
+ }
1029
+ value.published_ports = ports;
1030
+ Ok(())
1031
+ }
979
1032
  /// One exact IPv4 loopback host address and port per entry, never a prefix,
980
1033
  /// wildcard or range, each with exactly one owning uid. `(uid_t)-1` is not a
981
1034
  /// user; it is the kernel's "unchanged" sentinel.
@@ -1242,6 +1295,9 @@ pub(crate) mod localplatform_tests;
1242
1295
  #[path = "egress_localport_tests.rs"]
1243
1296
  pub(crate) mod localport_tests;
1244
1297
  #[cfg(test)]
1298
+ #[path = "egress_poolguardpublished_tests.rs"]
1299
+ mod poolguardpublished_tests;
1300
+ #[cfg(test)]
1245
1301
  #[path = "egress_publishedrange_tests.rs"]
1246
1302
  mod publishedrange_tests;
1247
1303
  #[cfg(test)]
@@ -357,6 +357,18 @@ fn load(flow: &Flow, name: &str, fields: &[Attr]) -> Option<Vec<u8>> {
357
357
  assert_eq!(loaded.len() as u32, wire::number(fields, 4).unwrap());
358
358
  loaded
359
359
  }
360
+ "ct" if wire::one(fields, 3).is_ok_and(|direction| direction.value == vec![1]) => {
361
+ // The reply tuple's source is where the flow was translated to: the
362
+ // live destination of the original direction, the live source of the
363
+ // reply, as a filter hook after destination NAT sees them.
364
+ match wire::number(fields, 2).unwrap() {
365
+ 19 if flow.reply => flow.source.to_vec(),
366
+ 19 => flow.destination.to_vec(),
367
+ 11 if flow.reply => flow.source_port.to_be_bytes().to_vec(),
368
+ 11 => flow.destination_port.to_be_bytes().to_vec(),
369
+ key => panic!("ct reply {key}"),
370
+ }
371
+ }
360
372
  "ct" => {
361
373
  if let Ok(direction) = wire::one(fields, 3) {
362
374
  assert_eq!(direction.value, vec![0]);
@@ -0,0 +1,418 @@
1
+ //! Publications through the allocation-pool guard: a publication translates an
2
+ //! inbound flow from the uplink into a pool (the router's transit address), which
3
+ //! the guard denies by its current destination and, on the reply, by its current
4
+ //! source. A guard publication admits exactly that forwarded flow and its replies;
5
+ //! every other tuple meets the decision of the guard without it.
6
+ use super::hostgrant_tests::{
7
+ bytes, chains, decide, granted, host_grant, normalized, prepared, Flow, ACK, ESTABLISHED,
8
+ };
9
+ use super::tests::pool_guard;
10
+ use super::Prepared;
11
+ use serde_json::{json, Value};
12
+
13
+ const NEW: u32 = 8;
14
+ const SYN: u8 = 0x02;
15
+ const CLIENT: [u8; 4] = [198, 51, 100, 7];
16
+ const UPLINK: [u8; 4] = [192, 0, 2, 2];
17
+ const TRANSIT: [u8; 4] = [10, 240, 0, 1];
18
+ const ENS18: Option<(u32, &str)> = Some((2, "ens18"));
19
+ const HANDOFF: Option<(u32, &str)> = Some((4, "handoff"));
20
+
21
+ fn port(protocol: &str, first: u16, end: Option<u16>, target: u16, address: &str) -> Value {
22
+ let mut value =
23
+ json!({"protocol":protocol,"hostPort":first,"targetPort":target,"targetAddress":address});
24
+ if let Some(end) = end {
25
+ value["hostPortEnd"] = json!(end);
26
+ }
27
+ value
28
+ }
29
+ fn published(mut value: Value, ports: Value) -> Value {
30
+ value["scope"]["publishedPorts"] = ports;
31
+ value
32
+ }
33
+ /// One port translated to another, and a range that keeps every port.
34
+ fn fixture() -> Value {
35
+ published(
36
+ pool_guard(),
37
+ json!([
38
+ port("tcp", 443, None, 8443, "10.240.0.1"),
39
+ port("udp", 20000, Some(20200), 20000, "10.240.0.1")
40
+ ]),
41
+ )
42
+ }
43
+ /// The opening of an inbound publication as the forward hook sees it: after the
44
+ /// destination NAT to the transit address, the original destination still the uplink.
45
+ fn inbound(protocol: u8, outside: u16, inside: u16) -> Flow {
46
+ Flow {
47
+ input: ENS18,
48
+ output: HANDOFF,
49
+ protocol,
50
+ source: CLIENT,
51
+ destination: TRANSIT,
52
+ source_port: 40000,
53
+ destination_port: inside,
54
+ tcp_flags: if protocol == 6 { SYN } else { 0 },
55
+ state: NEW,
56
+ reply: false,
57
+ zone: 0,
58
+ original: (CLIENT, UPLINK, 40000, outside),
59
+ uid: None,
60
+ label: [0; 16],
61
+ }
62
+ }
63
+ /// The workload's reply as the forward hook sees it, before the reverse NAT.
64
+ fn reply(flow: Flow) -> Flow {
65
+ Flow {
66
+ input: flow.output,
67
+ output: flow.input,
68
+ source: flow.destination,
69
+ destination: flow.source,
70
+ source_port: flow.destination_port,
71
+ destination_port: flow.source_port,
72
+ tcp_flags: if flow.protocol == 6 { ACK } else { 0 },
73
+ state: ESTABLISHED,
74
+ reply: true,
75
+ ..flow
76
+ }
77
+ }
78
+ /// Every admitted variant: the opening, the established original direction and
79
+ /// the reply, for the one-port and every boundary of the range.
80
+ fn admitted() -> Vec<Flow> {
81
+ let mut flows = Vec::new();
82
+ for opening in [
83
+ inbound(6, 443, 8443),
84
+ inbound(17, 20000, 20000),
85
+ inbound(17, 20100, 20100),
86
+ inbound(17, 20200, 20200),
87
+ ] {
88
+ flows.push(opening);
89
+ flows.push(Flow {
90
+ state: ESTABLISHED,
91
+ tcp_flags: if opening.protocol == 6 { ACK } else { 0 },
92
+ ..opening
93
+ });
94
+ flows.push(reply(opening));
95
+ }
96
+ flows
97
+ }
98
+ /// Tuples the publications do not name, each of which the guard alone denies.
99
+ fn foreign() -> Vec<Flow> {
100
+ let tcp = inbound(6, 443, 8443);
101
+ let udp = inbound(17, 20000, 20000);
102
+ vec![
103
+ // An original destination in a pool: no translation from outside every pool.
104
+ Flow {
105
+ original: (CLIENT, [10, 240, 0, 9], 40000, 443),
106
+ ..tcp
107
+ },
108
+ Flow {
109
+ original: (CLIENT, TRANSIT, 40000, 8443),
110
+ ..tcp
111
+ },
112
+ reply(Flow {
113
+ original: (CLIENT, [10, 241, 0, 9], 40000, 20000),
114
+ ..udp
115
+ }),
116
+ // Another target address, target port, published port or protocol.
117
+ Flow {
118
+ destination: [10, 240, 0, 3],
119
+ ..tcp
120
+ },
121
+ Flow {
122
+ destination_port: 8444,
123
+ ..tcp
124
+ },
125
+ Flow {
126
+ original: (CLIENT, UPLINK, 40000, 444),
127
+ ..tcp
128
+ },
129
+ Flow {
130
+ original: (CLIENT, UPLINK, 40000, 8443),
131
+ ..tcp
132
+ },
133
+ Flow {
134
+ protocol: 17,
135
+ tcp_flags: 0,
136
+ ..tcp
137
+ },
138
+ Flow {
139
+ protocol: 6,
140
+ tcp_flags: SYN,
141
+ ..udp
142
+ },
143
+ inbound(17, 19999, 19999),
144
+ inbound(17, 20201, 20201),
145
+ reply(inbound(17, 20201, 20201)),
146
+ // Another conntrack zone, a non-SYN opening, a reply that is not ESTABLISHED
147
+ // and the reply direction carrying the original direction's tuple.
148
+ Flow { zone: 7, ..tcp },
149
+ reply(Flow { zone: 7, ..udp }),
150
+ Flow {
151
+ tcp_flags: ACK,
152
+ ..tcp
153
+ },
154
+ Flow {
155
+ state: NEW,
156
+ ..reply(tcp)
157
+ },
158
+ ]
159
+ }
160
+
161
+ #[test]
162
+ fn pool_guard_publications_admit_exactly_the_forwarded_flow_and_its_reply() {
163
+ let baseline = prepared(pool_guard());
164
+ let with = prepared(fixture());
165
+ for flow in admitted() {
166
+ assert_eq!(
167
+ decide(&baseline, "forward", &flow).verdict,
168
+ 0,
169
+ "{:?} baseline",
170
+ flow.destination_port
171
+ );
172
+ let decision = decide(&with, "forward", &flow);
173
+ assert_eq!(
174
+ (decision.verdict, decision.chain.as_str()),
175
+ (1, "pool_guard"),
176
+ "{:?}",
177
+ flow.destination_port
178
+ );
179
+ }
180
+ for (index, flow) in foreign().iter().enumerate() {
181
+ assert_eq!(
182
+ decide(&baseline, "forward", flow).verdict,
183
+ 0,
184
+ "variant {index} baseline"
185
+ );
186
+ assert_eq!(
187
+ decide(&with, "forward", flow),
188
+ decide(&baseline, "forward", flow),
189
+ "variant {index}"
190
+ );
191
+ }
192
+ // The reply direction carrying the original direction's tuple is not from a
193
+ // pool, so the guard passes it with or without publications.
194
+ let turned = Flow {
195
+ reply: true,
196
+ state: ESTABLISHED,
197
+ ..inbound(6, 443, 8443)
198
+ };
199
+ assert_eq!(
200
+ decide(&with, "forward", &turned),
201
+ decide(&baseline, "forward", &turned)
202
+ );
203
+ }
204
+
205
+ #[test]
206
+ fn pool_guard_publications_add_only_their_admissions_to_the_guard_chain() {
207
+ let baseline = prepared(pool_guard());
208
+ let with = prepared(fixture());
209
+ let graph = chains(&with);
210
+ let reference = chains(&baseline);
211
+ assert_eq!(
212
+ graph.keys().collect::<Vec<_>>(),
213
+ reference.keys().collect::<Vec<_>>()
214
+ );
215
+ for name in ["input", "forward", "output", "pool_reply"] {
216
+ assert_eq!(graph[name], reference[name], "{name}");
217
+ }
218
+ // Whatever the number of publications: the ESTABLISHED admission and one
219
+ // opening per protocol, right after the original-destination denial of every pool.
220
+ let pools = 2;
221
+ assert_eq!(
222
+ graph["pool_guard"].len(),
223
+ reference["pool_guard"].len() + 1 + 2
224
+ );
225
+ assert_eq!(
226
+ graph["pool_guard"][..pools],
227
+ reference["pool_guard"][..pools]
228
+ );
229
+ assert_eq!(
230
+ graph["pool_guard"][pools + 3..],
231
+ reference["pool_guard"][pools..]
232
+ );
233
+ // A host-local flow with a publication's tuples, translated from outside every
234
+ // pool, is the same publication on the local hooks.
235
+ for flow in admitted() {
236
+ for (chain, local) in [
237
+ (
238
+ "input",
239
+ Flow {
240
+ output: None,
241
+ ..flow
242
+ },
243
+ ),
244
+ (
245
+ "output",
246
+ Flow {
247
+ input: None,
248
+ ..flow
249
+ },
250
+ ),
251
+ ] {
252
+ assert_eq!(
253
+ decide(&baseline, chain, &local).verdict,
254
+ 0,
255
+ "{chain} baseline"
256
+ );
257
+ assert_eq!(decide(&with, chain, &local).verdict, 1, "{chain}");
258
+ }
259
+ }
260
+ }
261
+
262
+ #[test]
263
+ fn pool_guard_publications_absent_and_empty_keep_the_digest_and_compiled_bytes() {
264
+ let baseline = prepared(pool_guard());
265
+ assert!(!serde_json::to_string(&baseline.policy)
266
+ .unwrap()
267
+ .contains("publishedPorts"));
268
+ let empty = prepared(published(pool_guard(), json!([])));
269
+ assert_eq!(empty, baseline);
270
+ assert_eq!(bytes(&empty), bytes(&baseline));
271
+ let with = prepared(fixture());
272
+ assert_ne!(with.digest, baseline.digest);
273
+ // Canonical order: a reordered list is the same policy.
274
+ let reordered = prepared(published(
275
+ pool_guard(),
276
+ json!([
277
+ port("udp", 20000, Some(20200), 20000, "10.240.0.1"),
278
+ port("tcp", 443, None, 8443, "10.240.0.1")
279
+ ]),
280
+ ));
281
+ assert_eq!(reordered, with);
282
+ // Host grants and publications are independent exceptions of one guard.
283
+ let both = prepared(granted(
284
+ fixture(),
285
+ json!([host_grant("tcp", "10.240.0.1", "10.241.0.2", 8080)]),
286
+ ));
287
+ for flow in admitted() {
288
+ assert_eq!(decide(&both, "forward", &flow).verdict, 1);
289
+ }
290
+ }
291
+
292
+ #[test]
293
+ fn pool_guard_publications_refuse_every_entry_outside_the_contract() {
294
+ let refused = |ports: Value| normalized(published(pool_guard(), ports)).is_err();
295
+ // A target outside every pool needs no exception and is no publication into one.
296
+ assert!(refused(json!([port("tcp", 443, None, 8443, "10.250.0.1")])));
297
+ assert!(refused(json!([port(
298
+ "tcp",
299
+ 443,
300
+ None,
301
+ 8443,
302
+ "10.240.0.0/16"
303
+ )])));
304
+ assert!(refused(json!([port("tcp", 443, None, 8443, "127.0.0.1")])));
305
+ assert!(refused(json!([port(
306
+ "sctp",
307
+ 443,
308
+ None,
309
+ 8443,
310
+ "10.240.0.1"
311
+ )])));
312
+ assert!(refused(json!([port("tcp", 0, None, 8443, "10.240.0.1")])));
313
+ assert!(refused(json!([port("tcp", 443, None, 0, "10.240.0.1")])));
314
+ // A range keeps every port and runs upward; one port has no end.
315
+ assert!(refused(json!([port(
316
+ "udp",
317
+ 20000,
318
+ Some(20200),
319
+ 30000,
320
+ "10.240.0.1"
321
+ )])));
322
+ assert!(refused(json!([port(
323
+ "udp",
324
+ 20000,
325
+ Some(20000),
326
+ 20000,
327
+ "10.240.0.1"
328
+ )])));
329
+ assert!(refused(json!([port(
330
+ "udp",
331
+ 20000,
332
+ Some(19999),
333
+ 20000,
334
+ "10.240.0.1"
335
+ )])));
336
+ // One protocol and port is published once.
337
+ assert!(refused(json!([
338
+ port("tcp", 443, None, 8443, "10.240.0.1"),
339
+ port("tcp", 443, None, 8444, "10.240.0.1")
340
+ ])));
341
+ assert!(refused(json!([
342
+ port("udp", 20000, Some(20200), 20000, "10.240.0.1"),
343
+ port("udp", 20200, None, 5060, "10.240.0.1")
344
+ ])));
345
+ assert!(!refused(json!([
346
+ port("tcp", 443, None, 8443, "10.240.0.1"),
347
+ port("udp", 443, None, 8443, "10.240.0.1")
348
+ ])));
349
+ let mut unknown = port("tcp", 443, None, 8443, "10.240.0.1");
350
+ unknown["hostIp"] = json!("192.0.2.2");
351
+ assert!(refused(json!([unknown])));
352
+ }
353
+
354
+ #[test]
355
+ fn pool_guard_publications_are_bounded_and_fit_the_largest_guard() {
356
+ let many = |count: u16| -> Value {
357
+ json!((0..count)
358
+ .map(|index| port("tcp", 1000 + index, None, 1000 + index, "10.240.0.1"))
359
+ .collect::<Vec<_>>())
360
+ };
361
+ match normalized(published(pool_guard(), many(1025))) {
362
+ Err(crate::Error::Exhausted(bound)) => assert_eq!(bound.name, "publishedPorts"),
363
+ other => panic!("{other:?}"),
364
+ }
365
+ // The largest guard: 64 pools, 1024 host grants, 1024 publications and every
366
+ // loopback port owner compile inside the atomic budget.
367
+ let mut guard = pool_guard();
368
+ guard["scope"]["prefixes"] = json!((0..64)
369
+ .map(|part| format!("10.{part}.0.0/16"))
370
+ .collect::<Vec<_>>());
371
+ let grants: Vec<_> = (0..1024_u16)
372
+ .map(|index| {
373
+ host_grant(
374
+ if index % 2 == 0 { "tcp" } else { "udp" },
375
+ "10.40.0.1",
376
+ "10.41.0.2",
377
+ 20000 + index / 2,
378
+ )
379
+ })
380
+ .collect();
381
+ guard["scope"]["hostGrants"] = json!(grants);
382
+ guard["scope"]["localTcpPortOwners"] = json!((0..8)
383
+ .map(|index| json!({"address":"127.0.0.1","port":10010 + index,"uid":0}))
384
+ .collect::<Vec<_>>());
385
+ let ports: Vec<_> = (0..1024_u16)
386
+ .map(|index| {
387
+ port(
388
+ if index % 2 == 0 { "tcp" } else { "udp" },
389
+ 1000 + index / 2,
390
+ None,
391
+ 1000 + index / 2,
392
+ "10.40.0.1",
393
+ )
394
+ })
395
+ .collect();
396
+ let largest: Prepared = prepared(published(guard, json!(ports)));
397
+ let program = largest
398
+ .program(&format!("snft_{}", "x".repeat(59)))
399
+ .unwrap();
400
+ let size = |kinds: &[u16]| -> usize {
401
+ program
402
+ .iter()
403
+ .filter(|(kind, _)| kinds.contains(kind))
404
+ .map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
405
+ .sum()
406
+ };
407
+ let (graph, elements) = (size(&[3, 6, 9]), size(&[12]));
408
+ assert!(
409
+ program.len() < 768
410
+ && graph <= super::compile::RULE_BYTES
411
+ && graph + elements <= super::compile::TARGET_BYTES
412
+ );
413
+ assert!(768 * (20 + 72 + 36 + 12) + graph + elements + 64 <= crate::wire::BATCH_BYTES);
414
+ println!(
415
+ "POOL_GUARD_PUBLISHED_MAX prefixes=64 grants=1024 owners=8 published=1024 operations={} rule_bytes={graph} element_bytes={elements}",
416
+ program.len()
417
+ );
418
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '4.3.1',
6
+ version: '4.4.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -216,17 +216,39 @@ export interface IManagedNftLocalTcpPortOwner {
216
216
  uid: number;
217
217
  }
218
218
 
219
+ /** One publication an allocation-pool guard admits into a pool: a flow whose original destination lies
220
+ * outside every listed pool, on `hostPort` (or the range `hostPort..hostPortEnd`), translated to exactly
221
+ * `targetAddress` and `targetPort`, and its replies, in the default conntrack zone. The guard checks
222
+ * no link and no uplink address: the scope that translates the flow (`hostTransit.publishedPorts`)
223
+ * binds those. Compile the same publication into the guard and the host-transit scope it crosses. */
224
+ export interface IManagedNftPoolGuardPublishedPort {
225
+ protocol: 'tcp' | 'udp';
226
+ /** Published port, 1–65535; each protocol and port is published at most once per guard. */
227
+ hostPort: number;
228
+ /** Optional last port of a published range, greater than `hostPort`; a range keeps every port,
229
+ * so `targetPort` must equal `hostPort`. Absent for one port, its only canonical form. */
230
+ hostPortEnd?: number;
231
+ targetPort: number;
232
+ /** Exact unicast address inside a listed pool that the publication is translated to. */
233
+ targetAddress: string;
234
+ }
235
+
219
236
  /** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
220
- * It has no link dependency; exact host grants are its only exceptions. It is not allocation-release or boot-order proof. */
237
+ * It has no link dependency; exact host grants and publications are its only exceptions. It is not
238
+ * allocation-release or boot-order proof. */
221
239
  export interface IManagedNftAllocationPoolGuardScopeV2 {
222
240
  kind: 'allocationPoolGuard';
223
241
  authorityDigest: string;
224
242
  /** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
225
243
  prefixes: string[];
226
- /** The only exceptions to the guard: exact host-origin flows whose `destinationAddress` lies in a
227
- * listed pool, and their replies. Absent and empty are the same canonical policy; at most 1024,
228
- * held in a named set behind a constant number of rules. */
244
+ /** Exceptions to the guard: exact host-origin flows whose `destinationAddress` lies in a listed
245
+ * pool, and their replies. Absent and empty are the same canonical policy; at most 1024, held in a
246
+ * named set behind a constant number of rules. */
229
247
  hostGrants?: IManagedNftHostGrant[];
248
+ /** Exceptions to the guard: publications translated from outside every listed pool into one, and
249
+ * their replies. Absent and empty are the same canonical policy, digest and compiled graph; at most
250
+ * 1024, held in a named set behind a constant number of rules. */
251
+ publishedPorts?: IManagedNftPoolGuardPublishedPort[];
230
252
  /** Optional host-wide loopback port ownership, compiled ahead of the guard in the same table.
231
253
  * Absent and empty are the same canonical policy, digest and compiled graph; at most 8, one per
232
254
  * exact address and port. */