@push.rocks/smartnftables 2.2.0 → 2.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 +13 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/managed.egress.types.d.ts +38 -0
- package/package.json +3 -3
- package/readme.md +104 -2
- package/rust/src/egress.compile.rs +20 -0
- package/rust/src/egress.host.rs +73 -0
- package/rust/src/egress.router.rs +148 -3
- package/rust/src/egress.rs +156 -1
- package/rust/src/egress_tests.rs +608 -1
- package/rust/src/owner_egress_traffic_tests.rs +3 -3
- package/rust/src/owner_host_traffic_tests.rs +225 -2
- package/rust/src/owner_identity_tests.rs +82 -0
- package/rust/src/owner_router_traffic_tests.rs +393 -0
- package/rust/src/owner_tests.rs +3 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +40 -0
package/changelog.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-09-16 - 2.4.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- Add optional managed published workload ports to the schema-v2 `routerEgress` scope: the second hop of a published host port compiles its own handoff destination NAT, the handoff ingress classification ahead of the raw capture barrier, and the exact forward and reply admissions, all applied, replaced and released with the generation. Absent and empty publications keep the previous canonical policy, digest and compiled bytes.
|
|
8
|
+
- Classify both directions of a publication into the default conntrack zone: the workload side is matched on its exact link, published address and published endpoint port ahead of every leased classifier, so a client whose source port equals a leased grant's destination port can use the publication instead of having the workload's reply zoned into that generation and dropped or source-translated. A published `endpointPort` is therefore dedicated to its publication and new outbound flows from that exact address and port are not leased egress.
|
|
9
|
+
|
|
10
|
+
## 2026-09-16 - 2.3.0
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Add optional managed published host ports to the schema-v2 `hostTransit` scope: inbound uplink destination NAT plus its exact forward and reply admissions are compiled, applied, replaced and released with the generation. Absent and empty publications keep the previous canonical policy, digest and compiled bytes.
|
|
15
|
+
|
|
3
16
|
## 2026-09-15 - 2.2.0
|
|
4
17
|
|
|
5
18
|
### Features
|
|
Binary file
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "5fb7ac747d32155fe8577b86107a82405fa7afeb1cf73552e903aa06760660d8",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "2.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "2.4.0",
|
|
7
|
+
"gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-16T11:32:31.722Z",
|
|
10
10
|
"tsrustVersion": "1.13.0",
|
|
11
11
|
"binary": "smartnftables",
|
|
12
12
|
"target": "linux_amd64_musl"
|
|
Binary file
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "33940d9812e48ac705862a3c30c57d652877f1b9abeb9601fd6f20a4fbcdc72d",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "2.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "2.4.0",
|
|
7
|
+
"gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-16T11:32:31.854Z",
|
|
10
10
|
"tsrustVersion": "1.13.0",
|
|
11
11
|
"binary": "smartnftables",
|
|
12
12
|
"target": "linux_arm64_musl"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@push.rocks/smartnftables',
|
|
6
|
-
version: '2.
|
|
6
|
+
version: '2.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=
|
|
@@ -60,6 +60,22 @@ export interface IManagedNftEgressGenerationV2 extends IManagedNftHandoffAllocat
|
|
|
60
60
|
conntrackLabel: string;
|
|
61
61
|
grants: IManagedNftEgressGrantV2[];
|
|
62
62
|
}
|
|
63
|
+
/** One inbound handoff publication, compiled inside the same router generation.
|
|
64
|
+
* It is the second hop of a published host port: the host translates to
|
|
65
|
+
* `transitSourceAddress:transitPort`, this translates on to the workload. */
|
|
66
|
+
export interface IManagedNftRouterPublishedPortV2 {
|
|
67
|
+
protocol: 'tcp' | 'udp';
|
|
68
|
+
/** Published handoff port, 1–65535 and unique per protocol across the scope.
|
|
69
|
+
* It is the `targetPort` of the matching host-transit publication. */
|
|
70
|
+
transitPort: number;
|
|
71
|
+
endpointPort: number;
|
|
72
|
+
/** Exact current workload endpoint address behind one veth endpoint of this
|
|
73
|
+
* scope. Never a router-local address, a gateway or a platform endpoint. */
|
|
74
|
+
endpointAddress: string;
|
|
75
|
+
/** Exact leased transit source address of one current generation, present on
|
|
76
|
+
* `handoff`. No wildcard, secondary-address or route inference. */
|
|
77
|
+
transitSourceAddress: string;
|
|
78
|
+
}
|
|
63
79
|
/** One combined private/TUN/local/egress table; never compose a second ACCEPT over v1 terminal drops. */
|
|
64
80
|
export interface IManagedNftRouterEgressScopeV2 {
|
|
65
81
|
kind: 'routerEgress';
|
|
@@ -71,6 +87,23 @@ export interface IManagedNftRouterEgressScopeV2 {
|
|
|
71
87
|
protection: IManagedNftProtectionV2;
|
|
72
88
|
/** Empty retains private/local enforcement while denying external egress. */
|
|
73
89
|
generations: IManagedNftEgressGenerationV2[];
|
|
90
|
+
/** Optional inbound destination NAT toward a workload endpoint. Absent and empty
|
|
91
|
+
* are the same canonical policy and keep the compiled graph unchanged. Every entry
|
|
92
|
+
* compiles its own handoff DNAT, the handoff ingress classification and the exact
|
|
93
|
+
* forward and reply admission in this generation, so a failed apply, a target
|
|
94
|
+
* without it, or release removes it with the generation. */
|
|
95
|
+
publishedPorts?: IManagedNftRouterPublishedPortV2[];
|
|
96
|
+
}
|
|
97
|
+
/** One inbound uplink publication, compiled inside the same host-transit generation. */
|
|
98
|
+
export interface IManagedNftPublishedPortV2 {
|
|
99
|
+
protocol: 'tcp' | 'udp';
|
|
100
|
+
/** Published uplink port, 1–65535 and unique per protocol across the scope. */
|
|
101
|
+
hostPort: number;
|
|
102
|
+
targetPort: number;
|
|
103
|
+
/** Exact leased transit source address of one current allocation; it selects the handoff. */
|
|
104
|
+
targetAddress: string;
|
|
105
|
+
/** Exact current uplink address that publishes this port. No wildcard or route inference. */
|
|
106
|
+
hostIp: string;
|
|
74
107
|
}
|
|
75
108
|
/** Shared-host capture barrier and explicit outer SNAT; Docker forwarding remains a separate owner. */
|
|
76
109
|
export interface IManagedNftHostTransitScopeV2 {
|
|
@@ -83,6 +116,11 @@ export interface IManagedNftHostTransitScopeV2 {
|
|
|
83
116
|
uplink: IManagedNftLocalLinkV2;
|
|
84
117
|
/** Exact current address present on uplink. No masquerade or default-route inference. */
|
|
85
118
|
snatAddress: string;
|
|
119
|
+
/** Optional inbound destination NAT. Absent and empty are the same canonical policy and
|
|
120
|
+
* keep the compiled graph unchanged. Every entry compiles its own uplink DNAT plus the
|
|
121
|
+
* exact forward and reply admission in this generation, so a failed apply, a target
|
|
122
|
+
* without it, or release removes it with the generation. */
|
|
123
|
+
publishedPorts?: IManagedNftPublishedPortV2[];
|
|
86
124
|
}
|
|
87
125
|
/** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
|
|
88
126
|
* This has no link dependency or allow grants. It is not allocation-release or boot-order proof. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@push.rocks/smartnftables",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": "6.
|
|
12
|
+
"@git.zone/cli": "6.14.0",
|
|
13
13
|
"@git.zone/tsbuild": "^4.4.3",
|
|
14
14
|
"@git.zone/tsrun": "^2.0.6",
|
|
15
15
|
"@git.zone/tsrust": "1.13.0",
|
|
16
|
-
"@git.zone/tstest": "6.
|
|
16
|
+
"@git.zone/tstest": "6.1.1",
|
|
17
17
|
"@types/node": "26.5.0",
|
|
18
18
|
"typescript": "^7.0.2"
|
|
19
19
|
},
|
package/readme.md
CHANGED
|
@@ -264,8 +264,8 @@ allocation-pool guard policy kinds.
|
|
|
264
264
|
|
|
265
265
|
| V2 scope | Required authority and behavior |
|
|
266
266
|
| --- | --- |
|
|
267
|
-
| `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. |
|
|
268
|
-
| `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. |
|
|
267
|
+
| `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; 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. |
|
|
268
|
+
| `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. |
|
|
269
269
|
| `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. |
|
|
270
270
|
|
|
271
271
|
`allocationPoolGuard` accepts 1–64 canonical, disjoint RFC1918 prefixes. Supply
|
|
@@ -313,6 +313,42 @@ so foreign DNAT cannot turn a protected destination into a public exception or
|
|
|
313
313
|
redirect public traffic into protected space. INPUT diversion, local OUTPUT into
|
|
314
314
|
handoffs, unmatched handoff traffic and IPv6 forwarding are denied.
|
|
315
315
|
|
|
316
|
+
#### Published host ports
|
|
317
|
+
|
|
318
|
+
`hostTransit.publishedPorts` is optional. Absent and empty are the same canonical
|
|
319
|
+
policy, so an unpublished scope keeps its exact previous digest and compiled bytes.
|
|
320
|
+
Each entry is `{ protocol, hostPort, targetPort, targetAddress, hostIp }`. `hostIp`
|
|
321
|
+
must be an exact current uplink address, which rtnetlink verifies at apply, recovery
|
|
322
|
+
and inspection; there is no wildcard, secondary-link or default-route inference.
|
|
323
|
+
`targetAddress` must be a current leased `transitSourceAddress` of one allocation,
|
|
324
|
+
and that binding is what selects the handoff link of the admission; an address that
|
|
325
|
+
no active lease holds, a local host address or a bare workload prefix rejects. Ports
|
|
326
|
+
are 1–65535, one `protocol`/`hostPort` is published exactly once across the scope
|
|
327
|
+
including across uplink addresses, and a port published on the `snatAddress` may not
|
|
328
|
+
fall inside any leased outbound source-port range of the same protocol, because outer
|
|
329
|
+
SNAT translates to that same address.
|
|
330
|
+
|
|
331
|
+
A publication compiles inside the generation: one destination NAT rule in this
|
|
332
|
+
table's own prerouting `nat` chain at priority -100, plus three forward admissions
|
|
333
|
+
placed ahead of the capture barrier — original direction NEW and ESTABLISHED, with
|
|
334
|
+
opening TCP restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply.
|
|
335
|
+
Every admission checks both links, the conntrack protocol, the default zone, the
|
|
336
|
+
direction, the translated current address/port and the original conntrack
|
|
337
|
+
destination, so foreign NAT cannot borrow the exception for another flow. Published
|
|
338
|
+
traffic is not source-translated, so the workload observes the actual client address.
|
|
339
|
+
The chain, its translation and its admissions are created, replaced and deleted with
|
|
340
|
+
the generation: a target without the entry, a failed batch, or release removes them,
|
|
341
|
+
and a caller never needs a separate teardown.
|
|
342
|
+
|
|
343
|
+
Input bounds are 64 entries, but the atomic graph reserve binds first. A publication
|
|
344
|
+
costs four operations and about 6,000 bytes, so roughly a dozen fit beside a small
|
|
345
|
+
host-transit graph and `prepare()` rejects an excessive set before any kernel work.
|
|
346
|
+
The caller still owns the route to `targetAddress` through that handoff, the
|
|
347
|
+
workload listener, and every other packet owner on the host. As with leased egress,
|
|
348
|
+
an ACCEPT here cannot override Docker's independent FORWARD DROP; the
|
|
349
|
+
`ManagedDockerForwarding` contribution below derives only leased handoff egress from
|
|
350
|
+
the barrier and currently carries no inbound publication.
|
|
351
|
+
|
|
316
352
|
Each router generation binds an immutable lease reference, transit source address,
|
|
317
353
|
leased TCP/UDP source-port ranges, a nonzero conntrack zone and a 16-byte label.
|
|
318
354
|
Each directed grant selects one exact leased protocol range for SNAT. A grant's
|
|
@@ -325,6 +361,57 @@ Opening TCP requires SYN with FIN/RST/ACK clear and permits ECN negotiation.
|
|
|
325
361
|
Replies must match the labelled original flow and current directed grant.
|
|
326
362
|
There is no broad ESTABLISHED/RELATED bypass.
|
|
327
363
|
|
|
364
|
+
#### Published workload ports
|
|
365
|
+
|
|
366
|
+
`routerEgress.publishedPorts` is optional and is the second hop of a published
|
|
367
|
+
host port. Absent and empty are the same canonical policy, so an unpublished scope
|
|
368
|
+
keeps its exact previous digest and compiled bytes. Each entry is `{ protocol,
|
|
369
|
+
transitPort, endpointPort, endpointAddress, transitSourceAddress }`.
|
|
370
|
+
`transitSourceAddress` must be a current leased generation address that is present
|
|
371
|
+
on `handoff`, which rtnetlink verifies at apply, recovery and inspection; the
|
|
372
|
+
handoff can carry several current leased addresses, so the arrival address is
|
|
373
|
+
always explicit and never inferred. `transitPort` is the `targetPort` of the
|
|
374
|
+
matching `hostTransit` publication. `endpointAddress` must be an address of one
|
|
375
|
+
current veth workload endpoint of this scope: a router-local link or gateway
|
|
376
|
+
address, the transit address, a TUN endpoint's private space, a platform endpoint,
|
|
377
|
+
loopback and IPv6 all reject. Ports are 1–65535, one `protocol`/`transitPort` is
|
|
378
|
+
published exactly once across the scope, and a published port may not fall inside
|
|
379
|
+
any leased outbound source-port range of the same protocol on that transit address,
|
|
380
|
+
because source NAT translates to that same address.
|
|
381
|
+
|
|
382
|
+
A publication compiles inside the generation: one destination NAT rule in this
|
|
383
|
+
table's own prerouting `nat` chain at priority -100, two raw classifications, and
|
|
384
|
+
three forward admissions placed ahead of the capture barrier — original direction
|
|
385
|
+
NEW and ESTABLISHED, with opening TCP restricted to SYN with FIN/RST/ACK clear,
|
|
386
|
+
and the ESTABLISHED reply. The handoff ingress classification precedes the
|
|
387
|
+
barrier's protected-source denial, because the uplink or management network that
|
|
388
|
+
reaches a published port is itself protected space. The endpoint ingress
|
|
389
|
+
classification matches the exact workload link, address and published endpoint
|
|
390
|
+
port, and precedes every leased classifier of every generation: raw runs before
|
|
391
|
+
conntrack, so a client whose source port equals a leased grant's destination port
|
|
392
|
+
would otherwise carry the workload's published packets into that generation, where
|
|
393
|
+
the publication's flow does not exist. Both classifications match the arriving
|
|
394
|
+
tuple only, set no generation zone and leave the flow in the default conntrack
|
|
395
|
+
zone. The private anti-spoof guards still run first, and every admission checks
|
|
396
|
+
both links, the conntrack protocol, the default zone, the direction, the
|
|
397
|
+
translated current address/port and the original conntrack destination.
|
|
398
|
+
|
|
399
|
+
A published `endpointPort` is therefore dedicated to its publication: a new
|
|
400
|
+
outbound flow that the workload opens from that exact address and port on that
|
|
401
|
+
exact link stays in the default zone too and is not translated by the leased
|
|
402
|
+
outbound source NAT. Published traffic is not source-translated on either hop, so
|
|
403
|
+
the workload observes the actual client address. The chain, its translation, its
|
|
404
|
+
classifications and its admissions are created, replaced and deleted with the
|
|
405
|
+
generation: a target without the entry, a failed batch, or release removes them.
|
|
406
|
+
|
|
407
|
+
Input bounds are 64 entries, but the atomic graph reserve binds first. A router
|
|
408
|
+
publication costs six operations and about 7,600 bytes, so a combined
|
|
409
|
+
two-endpoint router policy leaves room for roughly five, and `prepare()` rejects an
|
|
410
|
+
excessive set before any kernel work. The caller still owns both hops: the exact
|
|
411
|
+
matching `hostTransit` publication, the route to the workload through this router,
|
|
412
|
+
the workload listener and every other packet owner. A publication grants no egress;
|
|
413
|
+
outbound flows still need their leased grants.
|
|
414
|
+
|
|
328
415
|
Active overlapping classifiers, duplicate zones/labels and conflicting handoff
|
|
329
416
|
allocations reject. Empty router generations retain private routing while denying
|
|
330
417
|
egress. Input bounds include 32 private endpoints, 128 private rules, 32 active
|
|
@@ -442,6 +529,21 @@ local TCP, TUN isolation, observed UDP/TCP handoff ranges, range exhaustion,
|
|
|
442
529
|
fragment reassembly, ECN SYN and invalid opening flags, protected current/original
|
|
443
530
|
DNAT barriers, nonzero foreign zones, local diversion, IPv6 denial, revocation
|
|
444
531
|
and restoration with disjoint generation ranges while old flows remain retained.
|
|
532
|
+
Published host ports are qualified end to end: translated TCP and UDP delivery to the
|
|
533
|
+
leased transit address with the actual client address preserved, reverse translation
|
|
534
|
+
back to the published address, SYN-only opening, denial of non-SYN openings, denial of
|
|
535
|
+
unpublished workload and uplink ports against a pre-policy positive control, and the
|
|
536
|
+
port going dark with the withdrawn and released generation. Published workload ports
|
|
537
|
+
are qualified across both hops in the same four-namespace topology: TCP and UDP reach
|
|
538
|
+
a listener inside the workload namespace with the client address preserved and both
|
|
539
|
+
translations reversed, only an exact SYN opens the publication against a positive
|
|
540
|
+
control on the same injector, an unpublished transit port and the unpublished
|
|
541
|
+
workload path stay behind the router barrier, and withdrawing only the router
|
|
542
|
+
publication or releasing it takes the port dark while the host publication remains.
|
|
543
|
+
A client whose source port equals a leased grant's destination port on that grant's
|
|
544
|
+
public address completes the published TCP handshake and receives its UDP reply
|
|
545
|
+
from the published address, so the leased classifiers of the same generation cannot
|
|
546
|
+
capture the workload's published packets.
|
|
445
547
|
The arm64 binary is cross-built; native packet qualification is currently x86_64.
|
|
446
548
|
Kernel 6.8 is unsupported. No production activation is implied by these tests.
|
|
447
549
|
|
|
@@ -248,6 +248,26 @@ fn snat(address: &str, range: Option<(u16, u16)>) -> Result<Vec<Attr>> {
|
|
|
248
248
|
result.push(expr("nat", data));
|
|
249
249
|
Ok(result)
|
|
250
250
|
}
|
|
251
|
+
/// Published inbound translation. Address and port are always both specified, so
|
|
252
|
+
/// the kernel never picks a port and the graph identity carries explicit registers.
|
|
253
|
+
fn dnat(address: &str, port: u16) -> Result<Vec<Attr>> {
|
|
254
|
+
Ok(vec![
|
|
255
|
+
immediate(1, super::address(address)?.to_be_bytes().to_vec()),
|
|
256
|
+
immediate(2, port.to_be_bytes().to_vec()),
|
|
257
|
+
expr(
|
|
258
|
+
"nat",
|
|
259
|
+
vec![
|
|
260
|
+
Attr::u32(1, 1),
|
|
261
|
+
Attr::u32(2, 2),
|
|
262
|
+
Attr::u32(3, 1),
|
|
263
|
+
Attr::u32(4, 1),
|
|
264
|
+
Attr::u32(5, 2),
|
|
265
|
+
Attr::u32(6, 2),
|
|
266
|
+
Attr::u32(7, 3),
|
|
267
|
+
],
|
|
268
|
+
),
|
|
269
|
+
])
|
|
270
|
+
}
|
|
251
271
|
fn tuple(grant: &Grant, protection: &Protection, reply: bool, original: bool) -> Result<Vec<Attr>> {
|
|
252
272
|
let destination = super::destination(grant, protection)?;
|
|
253
273
|
let mut result = ipv4();
|
package/rust/src/egress.host.rs
CHANGED
|
@@ -51,6 +51,69 @@ fn envelope(
|
|
|
51
51
|
expressions.extend(original_range(range.first, range.last));
|
|
52
52
|
Ok(expressions)
|
|
53
53
|
}
|
|
54
|
+
/// Inbound publication envelope. Both directions check the exact links, the
|
|
55
|
+
/// translated current tuple and the original conntrack destination, so foreign
|
|
56
|
+
/// NAT cannot borrow this admission for another flow.
|
|
57
|
+
fn published_envelope(
|
|
58
|
+
scope: &HostScope,
|
|
59
|
+
handoff: &HostHandoff,
|
|
60
|
+
port: &PublishedPort,
|
|
61
|
+
reply: bool,
|
|
62
|
+
) -> Result<Vec<Attr>> {
|
|
63
|
+
let mut expressions = ipv4();
|
|
64
|
+
expressions.extend(link(&scope.uplink, !reply));
|
|
65
|
+
expressions.extend(link(&handoff.link, reply));
|
|
66
|
+
expressions.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
67
|
+
expressions.extend(ct(10, None, vec![protocol(&port.protocol)]));
|
|
68
|
+
expressions.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
|
|
69
|
+
expressions.extend(ct(1, None, vec![u8::from(reply)]));
|
|
70
|
+
let target = format!("{}/32", port.target_address);
|
|
71
|
+
expressions.extend(current_address(&target, reply)?);
|
|
72
|
+
expressions.extend(current_port(port.target_port, reply));
|
|
73
|
+
let published = format!("{}/32", port.host_ip);
|
|
74
|
+
expressions.extend(original_address(&published, false)?);
|
|
75
|
+
expressions.extend(original_port(port.host_port, false));
|
|
76
|
+
Ok(expressions)
|
|
77
|
+
}
|
|
78
|
+
fn published(program: &mut Program<'_>, scope: &HostScope) -> Result<()> {
|
|
79
|
+
for port in &scope.published_ports {
|
|
80
|
+
// Validation admits only leased transit source addresses, and one address
|
|
81
|
+
// never spans two active handoff links.
|
|
82
|
+
let handoff = scope
|
|
83
|
+
.handoffs
|
|
84
|
+
.iter()
|
|
85
|
+
.find(|handoff| {
|
|
86
|
+
handoff
|
|
87
|
+
.allocations
|
|
88
|
+
.iter()
|
|
89
|
+
.any(|allocation| allocation.transit_source_address == port.target_address)
|
|
90
|
+
})
|
|
91
|
+
.ok_or(crate::Error::Invalid)?;
|
|
92
|
+
let original = published_envelope(scope, handoff, port, false)?;
|
|
93
|
+
for connection_state in [2, 8] {
|
|
94
|
+
let mut expressions = original.clone();
|
|
95
|
+
expressions.extend(state(connection_state));
|
|
96
|
+
if connection_state == 8 && port.protocol == "tcp" {
|
|
97
|
+
expressions.extend(opening_tcp());
|
|
98
|
+
}
|
|
99
|
+
program.end("forward", expressions, 1)?;
|
|
100
|
+
}
|
|
101
|
+
let mut reply = published_envelope(scope, handoff, port, true)?;
|
|
102
|
+
reply.extend(state(2));
|
|
103
|
+
program.end("forward", reply, 1)?;
|
|
104
|
+
let mut translation = ipv4();
|
|
105
|
+
translation.extend(link(&scope.uplink, true));
|
|
106
|
+
translation.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
107
|
+
translation.extend(ct(10, None, vec![protocol(&port.protocol)]));
|
|
108
|
+
translation.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
|
|
109
|
+
translation.extend(ct(1, None, vec![0]));
|
|
110
|
+
translation.extend(current_address(&format!("{}/32", port.host_ip), false)?);
|
|
111
|
+
translation.extend(current_port(port.host_port, false));
|
|
112
|
+
translation.extend(dnat(&port.target_address, port.target_port)?);
|
|
113
|
+
program.rule("pre", translation)?;
|
|
114
|
+
}
|
|
115
|
+
Ok(())
|
|
116
|
+
}
|
|
54
117
|
pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()> {
|
|
55
118
|
// Filtering runs after destination NAT. Docker has independent filter hooks.
|
|
56
119
|
// This table cannot override another owner's DROP; Docker contribution and
|
|
@@ -63,11 +126,21 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &HostScope) -> Result<()
|
|
|
63
126
|
] {
|
|
64
127
|
program.chain(name, Some((hook, priority, kind)))?;
|
|
65
128
|
}
|
|
129
|
+
if !scope.published_ports.is_empty() {
|
|
130
|
+
// Published destination NAT lives in this table's own prerouting chain, so
|
|
131
|
+
// it is created, replaced and deleted with the exact generation. An empty
|
|
132
|
+
// publication list keeps the previous chain set and compiled bytes.
|
|
133
|
+
program.chain("pre", Some((0, -100, "nat")))?;
|
|
134
|
+
}
|
|
66
135
|
for name in ["protected_out", "protected_back"] {
|
|
67
136
|
program.chain(name, None)?;
|
|
68
137
|
}
|
|
69
138
|
protection(program, scope, false)?;
|
|
70
139
|
protection(program, scope, true)?;
|
|
140
|
+
// Published admission precedes the capture barrier: the barrier denies every
|
|
141
|
+
// protected original destination, which includes this uplink address, and an
|
|
142
|
+
// exact inbound publication is the explicit authority for that exception.
|
|
143
|
+
published(program, scope)?;
|
|
71
144
|
for handoff in &scope.handoffs {
|
|
72
145
|
program.jump("forward", link(&handoff.link, true), "protected_out")?;
|
|
73
146
|
program.jump("forward", link(&handoff.link, false), "protected_back")?;
|
|
@@ -47,6 +47,125 @@ fn raw_return(scope: &RouterScope, grant: &Grant, generation: &Generation) -> Re
|
|
|
47
47
|
));
|
|
48
48
|
Ok(result)
|
|
49
49
|
}
|
|
50
|
+
/// The exact veth link of the workload endpoint that holds a published address.
|
|
51
|
+
/// Validation admits only a current endpoint address, and endpoint prefixes are
|
|
52
|
+
/// pairwise disjoint, so one publication never spans two endpoint links.
|
|
53
|
+
fn published_link<'a>(scope: &'a RouterScope, port: &RouterPublishedPort) -> Result<&'a LocalLink> {
|
|
54
|
+
let target = format!("{}/32", port.endpoint_address);
|
|
55
|
+
let endpoint = scope
|
|
56
|
+
.endpoints
|
|
57
|
+
.iter()
|
|
58
|
+
.find(|endpoint| {
|
|
59
|
+
endpoint
|
|
60
|
+
.source_prefixes
|
|
61
|
+
.iter()
|
|
62
|
+
.any(|source| super::super::covers(source, &target) == Ok(true))
|
|
63
|
+
})
|
|
64
|
+
.ok_or(crate::Error::Invalid)?;
|
|
65
|
+
scope
|
|
66
|
+
.links
|
|
67
|
+
.iter()
|
|
68
|
+
.find(|link| link.interface_index == endpoint.interface_index)
|
|
69
|
+
.ok_or(crate::Error::Invalid)
|
|
70
|
+
}
|
|
71
|
+
/// Inbound publication envelope. Both directions check the exact links, the
|
|
72
|
+
/// translated current tuple and the original conntrack destination, so foreign
|
|
73
|
+
/// NAT cannot borrow this admission for another flow.
|
|
74
|
+
fn published_envelope(
|
|
75
|
+
scope: &RouterScope,
|
|
76
|
+
endpoint: &LocalLink,
|
|
77
|
+
port: &RouterPublishedPort,
|
|
78
|
+
reply: bool,
|
|
79
|
+
) -> Result<Vec<Attr>> {
|
|
80
|
+
let mut expressions = ipv4();
|
|
81
|
+
expressions.extend(link(&scope.handoff, !reply));
|
|
82
|
+
expressions.extend(link(endpoint, reply));
|
|
83
|
+
expressions.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
84
|
+
expressions.extend(ct(10, None, vec![protocol(&port.protocol)]));
|
|
85
|
+
expressions.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
|
|
86
|
+
expressions.extend(ct(1, None, vec![u8::from(reply)]));
|
|
87
|
+
let target = format!("{}/32", port.endpoint_address);
|
|
88
|
+
expressions.extend(current_address(&target, reply)?);
|
|
89
|
+
expressions.extend(current_port(port.endpoint_port, reply));
|
|
90
|
+
let published = format!("{}/32", port.transit_source_address);
|
|
91
|
+
expressions.extend(original_address(&published, false)?);
|
|
92
|
+
expressions.extend(original_port(port.transit_port, false));
|
|
93
|
+
Ok(expressions)
|
|
94
|
+
}
|
|
95
|
+
/// Handoff ingress classification for an exact current publication. Raw runs
|
|
96
|
+
/// before conntrack, so it matches the arriving tuple only and never sets a
|
|
97
|
+
/// generation zone: a publication is an ordinary default-zone flow.
|
|
98
|
+
fn published_return(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
|
|
99
|
+
for port in &scope.published_ports {
|
|
100
|
+
let mut expressions = ipv4();
|
|
101
|
+
expressions.extend(link(&scope.handoff, true));
|
|
102
|
+
expressions.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
103
|
+
expressions.extend(current_address(
|
|
104
|
+
&format!("{}/32", port.transit_source_address),
|
|
105
|
+
false,
|
|
106
|
+
)?);
|
|
107
|
+
expressions.extend(current_port(port.transit_port, false));
|
|
108
|
+
program.end("raw_return", expressions, 1)?;
|
|
109
|
+
}
|
|
110
|
+
Ok(())
|
|
111
|
+
}
|
|
112
|
+
/// Endpoint ingress classification for an exact current publication. The workload
|
|
113
|
+
/// side of a publication is an ordinary default-zone flow too, so it is matched on
|
|
114
|
+
/// its exact link, address and dedicated published port before the leased
|
|
115
|
+
/// classifiers below can zone it: a client whose source port equals a leased
|
|
116
|
+
/// grant's destination port would otherwise move the workload's published packets
|
|
117
|
+
/// into that generation, where they are not its flow. This only classifies; both
|
|
118
|
+
/// the anti-spoof guards and the publication's admissions still decide admission.
|
|
119
|
+
fn published_endpoint(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
|
|
120
|
+
for port in &scope.published_ports {
|
|
121
|
+
let mut expressions = ipv4();
|
|
122
|
+
expressions.extend(link(published_link(scope, port)?, true));
|
|
123
|
+
expressions.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
124
|
+
expressions.extend(current_address(
|
|
125
|
+
&format!("{}/32", port.endpoint_address),
|
|
126
|
+
true,
|
|
127
|
+
)?);
|
|
128
|
+
expressions.extend(current_port(port.endpoint_port, true));
|
|
129
|
+
program.end("raw_in", expressions, 1)?;
|
|
130
|
+
}
|
|
131
|
+
Ok(())
|
|
132
|
+
}
|
|
133
|
+
fn published_translation(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
|
|
134
|
+
for port in &scope.published_ports {
|
|
135
|
+
let mut expressions = ipv4();
|
|
136
|
+
expressions.extend(link(&scope.handoff, true));
|
|
137
|
+
expressions.extend(meta(16, vec![protocol(&port.protocol)]));
|
|
138
|
+
expressions.extend(ct(10, None, vec![protocol(&port.protocol)]));
|
|
139
|
+
expressions.extend(ct(17, None, 0_u16.to_ne_bytes().to_vec()));
|
|
140
|
+
expressions.extend(ct(1, None, vec![0]));
|
|
141
|
+
expressions.extend(current_address(
|
|
142
|
+
&format!("{}/32", port.transit_source_address),
|
|
143
|
+
false,
|
|
144
|
+
)?);
|
|
145
|
+
expressions.extend(current_port(port.transit_port, false));
|
|
146
|
+
expressions.extend(dnat(&port.endpoint_address, port.endpoint_port)?);
|
|
147
|
+
program.rule("pre", expressions)?;
|
|
148
|
+
}
|
|
149
|
+
Ok(())
|
|
150
|
+
}
|
|
151
|
+
fn published_admissions(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
|
|
152
|
+
for port in &scope.published_ports {
|
|
153
|
+
let endpoint = published_link(scope, port)?;
|
|
154
|
+
let original = published_envelope(scope, endpoint, port, false)?;
|
|
155
|
+
for connection_state in [2, 8] {
|
|
156
|
+
let mut expressions = original.clone();
|
|
157
|
+
expressions.extend(state(connection_state));
|
|
158
|
+
if connection_state == 8 && port.protocol == "tcp" {
|
|
159
|
+
expressions.extend(opening_tcp());
|
|
160
|
+
}
|
|
161
|
+
program.end("forward", expressions, 1)?;
|
|
162
|
+
}
|
|
163
|
+
let mut reply = published_envelope(scope, endpoint, port, true)?;
|
|
164
|
+
reply.extend(state(2));
|
|
165
|
+
program.end("forward", reply, 1)?;
|
|
166
|
+
}
|
|
167
|
+
Ok(())
|
|
168
|
+
}
|
|
50
169
|
fn protected(
|
|
51
170
|
program: &mut Program<'_>,
|
|
52
171
|
chain: &str,
|
|
@@ -67,10 +186,19 @@ fn protected(
|
|
|
67
186
|
program.end(chain, vec![], -5)
|
|
68
187
|
}
|
|
69
188
|
fn raw(program: &mut Program<'_>, scope: &RouterScope) -> Result<()> {
|
|
70
|
-
// Handoff ingress must classify only exact leased return envelopes
|
|
71
|
-
// ingress never reaches private/default-zone
|
|
189
|
+
// Handoff ingress must classify only exact leased return envelopes and exact
|
|
190
|
+
// inbound publications. Unknown ingress never reaches private/default-zone
|
|
191
|
+
// rules, even after a rename.
|
|
72
192
|
program.jump("raw_in", link(&scope.handoff, true), "raw_return")?;
|
|
73
193
|
program.deny_link("raw_in", &scope.handoff, true)?;
|
|
194
|
+
// A publication is the explicit authority for its exact tuple, so it is
|
|
195
|
+
// classified ahead of the protected-source denial below, exactly as a
|
|
196
|
+
// platform return envelope is. The host publishes into this address, so its
|
|
197
|
+
// clients are not confined to unprotected space.
|
|
198
|
+
published_return(program, scope)?;
|
|
199
|
+
// Both sides of a publication lead the leased classifiers of every generation,
|
|
200
|
+
// so neither direction depends on the client's choice of source port.
|
|
201
|
+
published_endpoint(program, scope)?;
|
|
74
202
|
for platform in [true, false] {
|
|
75
203
|
if !platform {
|
|
76
204
|
for prefix in &scope.protection.prefixes {
|
|
@@ -190,6 +318,12 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
|
|
|
190
318
|
] {
|
|
191
319
|
program.chain(name, Some((hook, priority, kind)))?;
|
|
192
320
|
}
|
|
321
|
+
if !scope.published_ports.is_empty() {
|
|
322
|
+
// Published destination NAT lives in this table's own prerouting chain, so
|
|
323
|
+
// it is created, replaced and deleted with the exact generation. An empty
|
|
324
|
+
// publication list keeps the previous chain set and compiled bytes.
|
|
325
|
+
program.chain("pre", Some((0, -100, "nat")))?;
|
|
326
|
+
}
|
|
193
327
|
for name in ["raw_return", "protected_out", "protected_back"] {
|
|
194
328
|
program.chain(name, None)?;
|
|
195
329
|
}
|
|
@@ -210,9 +344,20 @@ pub(super) fn compile(program: &mut Program<'_>, scope: &RouterScope, revision:
|
|
|
210
344
|
raw(program, scope)?;
|
|
211
345
|
protected(program, "protected_out", scope, false)?;
|
|
212
346
|
protected(program, "protected_back", scope, true)?;
|
|
347
|
+
published_translation(program, scope)?;
|
|
213
348
|
for chain in ["input", "forward", "output"] {
|
|
214
349
|
let parts = private.filter_rules(chain)?;
|
|
215
|
-
for expressions in parts.guards
|
|
350
|
+
for expressions in parts.guards {
|
|
351
|
+
program.rule(chain, expressions)?;
|
|
352
|
+
}
|
|
353
|
+
// Published admission keeps the private anti-spoof guards ahead of it and
|
|
354
|
+
// precedes the capture barrier: the barrier denies every protected original
|
|
355
|
+
// destination, which includes this transit address, and an exact inbound
|
|
356
|
+
// publication is the explicit authority for that exception.
|
|
357
|
+
if chain == "forward" {
|
|
358
|
+
published_admissions(program, scope)?;
|
|
359
|
+
}
|
|
360
|
+
for expressions in parts.grants {
|
|
216
361
|
program.rule(chain, expressions)?;
|
|
217
362
|
}
|
|
218
363
|
grants(program, scope, chain, true)?;
|