@push.rocks/smartnftables 4.3.0 → 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 +20 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/managed.egress.types.d.ts +25 -4
- package/dist_ts/plugins.d.ts +1 -2
- package/dist_ts/plugins.js +2 -3
- package/package.json +6 -7
- package/readme.md +37 -1
- package/rust/src/egress.hostgrant.rs +8 -0
- package/rust/src/egress.keys.rs +7 -1
- package/rust/src/egress.poolguard.rs +77 -0
- package/rust/src/egress.rs +57 -1
- package/rust/src/egress_hostgrant_tests.rs +12 -0
- package/rust/src/egress_poolguardpublished_tests.rs +418 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +26 -4
- package/ts/plugins.ts +0 -2
package/changelog.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
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
|
+
|
|
13
|
+
## 2026-10-02 - 4.3.1
|
|
14
|
+
|
|
15
|
+
### Fixes
|
|
16
|
+
|
|
17
|
+
- Drop the unused `@push.rocks/smartlog` dependency: `ts/plugins.ts` imported it but nothing used it, and it pulled the deprecated `@push.rocks/isounique` into every consumer's production tree. The production tree is now `@push.rocks/smartpromise` and `@push.rocks/smartrust` alone.
|
|
18
|
+
|
|
19
|
+
### Maintenance
|
|
20
|
+
|
|
21
|
+
- Release tooling: pnpm 12.7.0, `@git.zone/cli` ^8.4.0, `@git.zone/tsbuild` ^5.1.2, `@git.zone/tstest` ^7.0.0 and `@types/node` ^26.6.3; the exact cli, tstest and @types/node pins become caret ranges.
|
|
22
|
+
|
|
3
23
|
## 2026-10-01 - 4.3.0
|
|
4
24
|
|
|
5
25
|
### Maintenance
|
|
Binary file
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "92ddb3d386ae7d83ce27c0df8b54bb08ce3bb54cba71e80df8e0c3e808e4da7b",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "4.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "4.4.0",
|
|
7
|
+
"gitCommit": "77c4a57ed74dce10ee91ef180972ebd9cc821211",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-10-
|
|
10
|
-
"tsrustVersion": "
|
|
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
|
}
|
|
Binary file
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "889d263bb28613ed54717f261f3fb75f5e394e88d9cf9632d611a7fbe621c582",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "4.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "4.4.0",
|
|
7
|
+
"gitCommit": "77c4a57ed74dce10ee91ef180972ebd9cc821211",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-10-
|
|
10
|
-
"tsrustVersion": "
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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/dist_ts/plugins.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as util from 'node:util';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as url from 'node:url';
|
|
5
5
|
export { childProcess, util, path, url, };
|
|
6
|
-
import * as smartlog from '@push.rocks/smartlog';
|
|
7
6
|
import * as smartpromise from '@push.rocks/smartpromise';
|
|
8
7
|
import * as smartrust from '@push.rocks/smartrust';
|
|
9
|
-
export {
|
|
8
|
+
export { smartpromise, smartrust, };
|
package/dist_ts/plugins.js
CHANGED
|
@@ -5,8 +5,7 @@ import * as path from 'node:path';
|
|
|
5
5
|
import * as url from 'node:url';
|
|
6
6
|
export { childProcess, util, path, url, };
|
|
7
7
|
// @push.rocks scope
|
|
8
|
-
import * as smartlog from '@push.rocks/smartlog';
|
|
9
8
|
import * as smartpromise from '@push.rocks/smartpromise';
|
|
10
9
|
import * as smartrust from '@push.rocks/smartrust';
|
|
11
|
-
export {
|
|
12
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
10
|
+
export { smartpromise, smartrust, };
|
|
11
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxZQUFZLE1BQU0sb0JBQW9CLENBQUM7QUFDbkQsT0FBTyxLQUFLLElBQUksTUFBTSxXQUFXLENBQUM7QUFDbEMsT0FBTyxLQUFLLElBQUksTUFBTSxXQUFXLENBQUM7QUFDbEMsT0FBTyxLQUFLLEdBQUcsTUFBTSxVQUFVLENBQUM7QUFFaEMsT0FBTyxFQUNMLFlBQVksRUFDWixJQUFJLEVBQ0osSUFBSSxFQUNKLEdBQUcsR0FDSixDQUFDO0FBRUYsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEVBQ0wsWUFBWSxFQUNaLFNBQVMsR0FDVixDQUFDIn0=
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@push.rocks/smartnftables",
|
|
3
|
-
"version": "4.
|
|
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,16 +9,15 @@
|
|
|
9
9
|
"author": "Task Venture Capital GmbH",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"devDependencies": {
|
|
12
|
-
"@git.zone/cli": "8.2
|
|
13
|
-
"@git.zone/tsbuild": "^5.1.
|
|
12
|
+
"@git.zone/cli": "^8.8.2",
|
|
13
|
+
"@git.zone/tsbuild": "^5.1.2",
|
|
14
14
|
"@git.zone/tsrun": "^3.0.2",
|
|
15
|
-
"@git.zone/tsrust": "
|
|
16
|
-
"@git.zone/tstest": "
|
|
17
|
-
"@types/node": "26.6.
|
|
15
|
+
"@git.zone/tsrust": "4.0.0",
|
|
16
|
+
"@git.zone/tstest": "^7.1.0",
|
|
17
|
+
"@types/node": "^26.6.3",
|
|
18
18
|
"typescript": "^7.0.2"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@push.rocks/smartlog": "^3.2.2",
|
|
22
21
|
"@push.rocks/smartpromise": "^4.2.5",
|
|
23
22
|
"@push.rocks/smartrust": "2.5.0"
|
|
24
23
|
},
|
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",
|
package/rust/src/egress.keys.rs
CHANGED
|
@@ -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();
|
package/rust/src/egress.rs
CHANGED
|
@@ -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
|
-
///
|
|
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
|
+
}
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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. */
|
package/ts/plugins.ts
CHANGED
|
@@ -12,11 +12,9 @@ export {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
// @push.rocks scope
|
|
15
|
-
import * as smartlog from '@push.rocks/smartlog';
|
|
16
15
|
import * as smartpromise from '@push.rocks/smartpromise';
|
|
17
16
|
import * as smartrust from '@push.rocks/smartrust';
|
|
18
17
|
export {
|
|
19
|
-
smartlog,
|
|
20
18
|
smartpromise,
|
|
21
19
|
smartrust,
|
|
22
20
|
};
|