@push.rocks/smartnftables 2.3.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 +7 -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 +22 -0
- package/package.json +2 -2
- package/readme.md +63 -2
- package/rust/src/egress.router.rs +148 -3
- package/rust/src/egress.rs +95 -1
- package/rust/src/egress_tests.rs +414 -0
- package/rust/src/owner_egress_traffic_tests.rs +3 -3
- package/rust/src/owner_host_traffic_tests.rs +4 -4
- package/rust/src/owner_identity_tests.rs +45 -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 +23 -0
package/changelog.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 2026-09-16 - 2.3.0
|
|
4
11
|
|
|
5
12
|
### 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,12 @@ 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[];
|
|
74
96
|
}
|
|
75
97
|
/** One inbound uplink publication, compiled inside the same host-transit generation. */
|
|
76
98
|
export interface IManagedNftPublishedPortV2 {
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
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.1.
|
|
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,7 +264,7 @@ 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. |
|
|
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
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
|
|
|
@@ -361,6 +361,57 @@ Opening TCP requires SYN with FIN/RST/ACK clear and permits ECN negotiation.
|
|
|
361
361
|
Replies must match the labelled original flow and current directed grant.
|
|
362
362
|
There is no broad ESTABLISHED/RELATED bypass.
|
|
363
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
|
+
|
|
364
415
|
Active overlapping classifiers, duplicate zones/labels and conflicting handoff
|
|
365
416
|
allocations reject. Empty router generations retain private routing while denying
|
|
366
417
|
egress. Input bounds include 32 private endpoints, 128 private rules, 32 active
|
|
@@ -482,7 +533,17 @@ Published host ports are qualified end to end: translated TCP and UDP delivery t
|
|
|
482
533
|
leased transit address with the actual client address preserved, reverse translation
|
|
483
534
|
back to the published address, SYN-only opening, denial of non-SYN openings, denial of
|
|
484
535
|
unpublished workload and uplink ports against a pre-policy positive control, and the
|
|
485
|
-
port going dark with the withdrawn and released generation.
|
|
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.
|
|
486
547
|
The arm64 binary is cross-built; native packet qualification is currently x86_64.
|
|
487
548
|
Kernel 6.8 is unsupported. No production activation is implied by these tests.
|
|
488
549
|
|
|
@@ -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)?;
|
package/rust/src/egress.rs
CHANGED
|
@@ -94,6 +94,15 @@ pub struct Generation {
|
|
|
94
94
|
pub conntrack_label: String,
|
|
95
95
|
pub grants: Vec<Grant>,
|
|
96
96
|
}
|
|
97
|
+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
98
|
+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
99
|
+
pub struct RouterPublishedPort {
|
|
100
|
+
pub protocol: String,
|
|
101
|
+
pub transit_port: u16,
|
|
102
|
+
pub endpoint_port: u16,
|
|
103
|
+
pub endpoint_address: String,
|
|
104
|
+
pub transit_source_address: String,
|
|
105
|
+
}
|
|
97
106
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
98
107
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
99
108
|
pub struct RouterScope {
|
|
@@ -103,6 +112,10 @@ pub struct RouterScope {
|
|
|
103
112
|
pub handoff: LocalLink,
|
|
104
113
|
pub protection: Protection,
|
|
105
114
|
pub generations: Vec<Generation>,
|
|
115
|
+
/// Absent and empty are the same canonical policy, so an unpublished scope
|
|
116
|
+
/// keeps its exact previous digest and compiled graph.
|
|
117
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
118
|
+
pub published_ports: Vec<RouterPublishedPort>,
|
|
106
119
|
}
|
|
107
120
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
108
121
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
@@ -380,6 +393,86 @@ pub fn destination(grant: &Grant, protection: &Protection) -> Result<String> {
|
|
|
380
393
|
}
|
|
381
394
|
}
|
|
382
395
|
}
|
|
396
|
+
/// Inbound publication is bounded by the authority this scope already proves: an
|
|
397
|
+
/// exact leased transit source address present on the handoff, an exact current
|
|
398
|
+
/// workload endpoint address behind one of this scope's veth links, and no overlap
|
|
399
|
+
/// with the leased outbound source ports translated to that same transit address.
|
|
400
|
+
fn normalize_router_published(value: &mut RouterScope) -> Result<()> {
|
|
401
|
+
let mut ports = std::mem::take(&mut value.published_ports);
|
|
402
|
+
require(ports.len() <= 64)?;
|
|
403
|
+
ports.sort();
|
|
404
|
+
for (index, port) in ports.iter().enumerate() {
|
|
405
|
+
require(
|
|
406
|
+
protocol(&port.protocol)
|
|
407
|
+
&& port.transit_port > 0
|
|
408
|
+
&& port.endpoint_port > 0
|
|
409
|
+
&& (index == 0
|
|
410
|
+
|| ports[index - 1].protocol != port.protocol
|
|
411
|
+
|| ports[index - 1].transit_port != port.transit_port),
|
|
412
|
+
)?;
|
|
413
|
+
// rtnetlink verifies this exact handoff address at apply, recovery and
|
|
414
|
+
// inspection, and only a current leased transit source address receives a
|
|
415
|
+
// publication; that binding is what the host publication already targets.
|
|
416
|
+
require(
|
|
417
|
+
value
|
|
418
|
+
.handoff
|
|
419
|
+
.required_ipv4_addresses
|
|
420
|
+
.contains(&port.transit_source_address)
|
|
421
|
+
&& value.generations.iter().any(|generation| {
|
|
422
|
+
generation.transit_source_address == port.transit_source_address
|
|
423
|
+
}),
|
|
424
|
+
)?;
|
|
425
|
+
unicast(&port.endpoint_address)?;
|
|
426
|
+
// Endpoint source prefixes are canonical and pairwise disjoint, so at most
|
|
427
|
+
// one endpoint can hold this address. It must be an actual workload veth
|
|
428
|
+
// behind this scope, never a router-local address, a gateway, the transit
|
|
429
|
+
// address or a protected platform endpoint.
|
|
430
|
+
let target = format!("{}/32", port.endpoint_address);
|
|
431
|
+
let endpoint = value
|
|
432
|
+
.endpoints
|
|
433
|
+
.iter()
|
|
434
|
+
.find(|endpoint| {
|
|
435
|
+
endpoint
|
|
436
|
+
.source_prefixes
|
|
437
|
+
.iter()
|
|
438
|
+
.any(|source| covers(source, &target) == Ok(true))
|
|
439
|
+
})
|
|
440
|
+
.ok_or(Error::Invalid)?;
|
|
441
|
+
require(
|
|
442
|
+
endpoint.interface_kind == "veth"
|
|
443
|
+
&& value
|
|
444
|
+
.links
|
|
445
|
+
.iter()
|
|
446
|
+
.chain(std::iter::once(&value.handoff))
|
|
447
|
+
.all(|link| {
|
|
448
|
+
!link
|
|
449
|
+
.required_ipv4_addresses
|
|
450
|
+
.contains(&port.endpoint_address)
|
|
451
|
+
})
|
|
452
|
+
&& value
|
|
453
|
+
.protection
|
|
454
|
+
.platform_endpoints
|
|
455
|
+
.iter()
|
|
456
|
+
.all(|item| item.address != port.endpoint_address),
|
|
457
|
+
)?;
|
|
458
|
+
for generation in &value.generations {
|
|
459
|
+
if generation.transit_source_address != port.transit_source_address {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
for range in &generation.source_port_ranges {
|
|
463
|
+
// Source NAT translates to this same transit address, so a published
|
|
464
|
+
// port inside a leased outbound range is a translation conflict.
|
|
465
|
+
require(
|
|
466
|
+
range.protocol != port.protocol
|
|
467
|
+
|| port.transit_port < range.first
|
|
468
|
+
|| port.transit_port > range.last,
|
|
469
|
+
)?;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
value.published_ports = ports;
|
|
474
|
+
Ok(())
|
|
475
|
+
}
|
|
383
476
|
fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
|
|
384
477
|
require(value.links.len() <= 32 && value.generations.len() <= 32)?;
|
|
385
478
|
let private = policy::Policy {
|
|
@@ -550,7 +643,8 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
|
|
|
550
643
|
item.transit_source_address.as_str(),
|
|
551
644
|
item.source_port_ranges.as_slice(),
|
|
552
645
|
)
|
|
553
|
-
}))
|
|
646
|
+
}))?;
|
|
647
|
+
normalize_router_published(value)
|
|
554
648
|
}
|
|
555
649
|
fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
|
|
556
650
|
require(
|