@push.rocks/smartnftables 2.2.0 → 2.3.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 +6 -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 +16 -0
- package/package.json +3 -3
- package/readme.md +42 -1
- package/rust/src/egress.compile.rs +20 -0
- package/rust/src/egress.host.rs +73 -0
- package/rust/src/egress.rs +61 -0
- package/rust/src/egress_tests.rs +194 -1
- package/rust/src/owner_host_traffic_tests.rs +224 -1
- package/rust/src/owner_identity_tests.rs +37 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +17 -0
package/changelog.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026-09-16 - 2.3.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
3
9
|
## 2026-09-15 - 2.2.0
|
|
4
10
|
|
|
5
11
|
### Features
|
|
Binary file
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "a05a20217727664aff94f0dfeeba5215ba8cfdfc3958f962da8384e229b11df3",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "2.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "2.3.0",
|
|
7
|
+
"gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-16T07:45:32.264Z",
|
|
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": "c322c2f4444cd6c980453ff7ddab76dfb39f1c0b149ff7401f4d184f491c3d01",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "2.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "2.3.0",
|
|
7
|
+
"gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-16T07:45:32.403Z",
|
|
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.3.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=
|
|
@@ -72,6 +72,17 @@ export interface IManagedNftRouterEgressScopeV2 {
|
|
|
72
72
|
/** Empty retains private/local enforcement while denying external egress. */
|
|
73
73
|
generations: IManagedNftEgressGenerationV2[];
|
|
74
74
|
}
|
|
75
|
+
/** One inbound uplink publication, compiled inside the same host-transit generation. */
|
|
76
|
+
export interface IManagedNftPublishedPortV2 {
|
|
77
|
+
protocol: 'tcp' | 'udp';
|
|
78
|
+
/** Published uplink port, 1–65535 and unique per protocol across the scope. */
|
|
79
|
+
hostPort: number;
|
|
80
|
+
targetPort: number;
|
|
81
|
+
/** Exact leased transit source address of one current allocation; it selects the handoff. */
|
|
82
|
+
targetAddress: string;
|
|
83
|
+
/** Exact current uplink address that publishes this port. No wildcard or route inference. */
|
|
84
|
+
hostIp: string;
|
|
85
|
+
}
|
|
75
86
|
/** Shared-host capture barrier and explicit outer SNAT; Docker forwarding remains a separate owner. */
|
|
76
87
|
export interface IManagedNftHostTransitScopeV2 {
|
|
77
88
|
kind: 'hostTransit';
|
|
@@ -83,6 +94,11 @@ export interface IManagedNftHostTransitScopeV2 {
|
|
|
83
94
|
uplink: IManagedNftLocalLinkV2;
|
|
84
95
|
/** Exact current address present on uplink. No masquerade or default-route inference. */
|
|
85
96
|
snatAddress: string;
|
|
97
|
+
/** Optional inbound destination NAT. Absent and empty are the same canonical policy and
|
|
98
|
+
* keep the compiled graph unchanged. Every entry compiles its own uplink DNAT plus the
|
|
99
|
+
* exact forward and reply admission in this generation, so a failed apply, a target
|
|
100
|
+
* without it, or release removes it with the generation. */
|
|
101
|
+
publishedPorts?: IManagedNftPublishedPortV2[];
|
|
86
102
|
}
|
|
87
103
|
/** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
|
|
88
104
|
* 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.3.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.0",
|
|
17
17
|
"@types/node": "26.5.0",
|
|
18
18
|
"typescript": "^7.0.2"
|
|
19
19
|
},
|
package/readme.md
CHANGED
|
@@ -265,7 +265,7 @@ allocation-pool guard policy kinds.
|
|
|
265
265
|
| V2 scope | Required authority and behavior |
|
|
266
266
|
| --- | --- |
|
|
267
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. |
|
|
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
|
|
@@ -442,6 +478,11 @@ local TCP, TUN isolation, observed UDP/TCP handoff ranges, range exhaustion,
|
|
|
442
478
|
fragment reassembly, ECN SYN and invalid opening flags, protected current/original
|
|
443
479
|
DNAT barriers, nonzero foreign zones, local diversion, IPv6 denial, revocation
|
|
444
480
|
and restoration with disjoint generation ranges while old flows remain retained.
|
|
481
|
+
Published host ports are qualified end to end: translated TCP and UDP delivery to the
|
|
482
|
+
leased transit address with the actual client address preserved, reverse translation
|
|
483
|
+
back to the published address, SYN-only opening, denial of non-SYN openings, denial of
|
|
484
|
+
unpublished workload and uplink ports against a pre-policy positive control, and the
|
|
485
|
+
port going dark with the withdrawn and released generation.
|
|
445
486
|
The arm64 binary is cross-built; native packet qualification is currently x86_64.
|
|
446
487
|
Kernel 6.8 is unsupported. No production activation is implied by these tests.
|
|
447
488
|
|
|
@@ -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")?;
|
package/rust/src/egress.rs
CHANGED
|
@@ -110,6 +110,15 @@ pub struct HostHandoff {
|
|
|
110
110
|
pub link: LocalLink,
|
|
111
111
|
pub allocations: Vec<Allocation>,
|
|
112
112
|
}
|
|
113
|
+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
114
|
+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
115
|
+
pub struct PublishedPort {
|
|
116
|
+
pub protocol: String,
|
|
117
|
+
pub host_port: u16,
|
|
118
|
+
pub target_port: u16,
|
|
119
|
+
pub target_address: String,
|
|
120
|
+
pub host_ip: String,
|
|
121
|
+
}
|
|
113
122
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
114
123
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
115
124
|
pub struct HostScope {
|
|
@@ -117,6 +126,10 @@ pub struct HostScope {
|
|
|
117
126
|
pub handoffs: Vec<HostHandoff>,
|
|
118
127
|
pub uplink: LocalLink,
|
|
119
128
|
pub snat_address: String,
|
|
129
|
+
/// Absent and empty are the same canonical policy, so an unpublished scope
|
|
130
|
+
/// keeps its exact previous digest and compiled graph.
|
|
131
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
132
|
+
pub published_ports: Vec<PublishedPort>,
|
|
120
133
|
}
|
|
121
134
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
122
135
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
@@ -556,6 +569,53 @@ fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
|
|
|
556
569
|
}
|
|
557
570
|
Ok(())
|
|
558
571
|
}
|
|
572
|
+
/// Inbound publication is bounded by the same authority the scope already proves:
|
|
573
|
+
/// an exact current uplink address, an exact leased transit source address, and no
|
|
574
|
+
/// overlap with the leased outbound source ports translated to that same address.
|
|
575
|
+
fn normalize_published(value: &mut HostScope) -> Result<()> {
|
|
576
|
+
let mut ports = std::mem::take(&mut value.published_ports);
|
|
577
|
+
require(ports.len() <= 64)?;
|
|
578
|
+
ports.sort();
|
|
579
|
+
for (index, port) in ports.iter().enumerate() {
|
|
580
|
+
require(
|
|
581
|
+
protocol(&port.protocol)
|
|
582
|
+
&& port.host_port > 0
|
|
583
|
+
&& port.target_port > 0
|
|
584
|
+
&& (index == 0
|
|
585
|
+
|| ports[index - 1].protocol != port.protocol
|
|
586
|
+
|| ports[index - 1].host_port != port.host_port),
|
|
587
|
+
)?;
|
|
588
|
+
// rtnetlink verifies this exact uplink address at apply, recovery and
|
|
589
|
+
// inspection. No wildcard, secondary-link or default-route inference.
|
|
590
|
+
require(value.uplink.required_ipv4_addresses.contains(&port.host_ip))?;
|
|
591
|
+
// Only a current leased transit source address can be published; that
|
|
592
|
+
// binding is what proves the exact handoff link of the forward admission.
|
|
593
|
+
require(
|
|
594
|
+
value
|
|
595
|
+
.handoffs
|
|
596
|
+
.iter()
|
|
597
|
+
.flat_map(|handoff| handoff.allocations.iter())
|
|
598
|
+
.any(|allocation| allocation.transit_source_address == port.target_address),
|
|
599
|
+
)?;
|
|
600
|
+
if port.host_ip == value.snat_address {
|
|
601
|
+
for handoff in &value.handoffs {
|
|
602
|
+
for allocation in &handoff.allocations {
|
|
603
|
+
for range in &allocation.source_port_ranges {
|
|
604
|
+
// Outer SNAT translates to this same address, so a published
|
|
605
|
+
// port inside a leased outbound range is a translation conflict.
|
|
606
|
+
require(
|
|
607
|
+
range.protocol != port.protocol
|
|
608
|
+
|| port.host_port < range.first
|
|
609
|
+
|| port.host_port > range.last,
|
|
610
|
+
)?;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
value.published_ports = ports;
|
|
617
|
+
Ok(())
|
|
618
|
+
}
|
|
559
619
|
fn normalize_host(value: &mut HostScope) -> Result<()> {
|
|
560
620
|
normalize_protection(&mut value.protection)?;
|
|
561
621
|
normalize_link(&mut value.uplink)?;
|
|
@@ -622,6 +682,7 @@ fn normalize_host(value: &mut HostScope) -> Result<()> {
|
|
|
622
682
|
}
|
|
623
683
|
}
|
|
624
684
|
}
|
|
685
|
+
normalize_published(value)?;
|
|
625
686
|
unique_links(
|
|
626
687
|
value
|
|
627
688
|
.handoffs
|
package/rust/src/egress_tests.rs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use super::{Policy, Scope};
|
|
1
|
+
use super::{Policy, Prepared, Scope};
|
|
2
2
|
use serde_json::{json, Value};
|
|
3
3
|
|
|
4
4
|
fn link(index: u32, name: &str, kind: &str, address: &str) -> Value {
|
|
@@ -483,3 +483,196 @@ fn allocation_pool_guard_maximum_fits_the_atomic_graph_without_link_bindings() {
|
|
|
483
483
|
let excessive: Vec<_> = (0..65).map(|part| format!("10.{part}.0.0/16")).collect();
|
|
484
484
|
assert!(normalized(changed(pool_guard(), "/scope/prefixes", json!(excessive))).is_err());
|
|
485
485
|
}
|
|
486
|
+
|
|
487
|
+
pub(crate) fn published_port(protocol: &str, host_port: u16, target_port: u16) -> Value {
|
|
488
|
+
json!({"protocol":protocol,"hostPort":host_port,"targetPort":target_port,
|
|
489
|
+
"targetAddress":"10.240.0.2","hostIp":"192.0.2.2"})
|
|
490
|
+
}
|
|
491
|
+
pub(crate) fn published_host(ports: Value) -> Value {
|
|
492
|
+
let mut value = host();
|
|
493
|
+
value["scope"]["publishedPorts"] = ports;
|
|
494
|
+
value
|
|
495
|
+
}
|
|
496
|
+
fn prepared(value: Value) -> Prepared {
|
|
497
|
+
normalized(value).unwrap().prepare().unwrap()
|
|
498
|
+
}
|
|
499
|
+
fn program_bytes(prepared: &Prepared, table: &str) -> Vec<u8> {
|
|
500
|
+
let mut bytes = Vec::new();
|
|
501
|
+
for (kind, attributes) in prepared.program(table).unwrap() {
|
|
502
|
+
let encoded = crate::wire::encode_attrs(&attributes);
|
|
503
|
+
bytes.extend(kind.to_be_bytes());
|
|
504
|
+
bytes.extend((encoded.len() as u32).to_be_bytes());
|
|
505
|
+
bytes.extend(encoded);
|
|
506
|
+
}
|
|
507
|
+
bytes
|
|
508
|
+
}
|
|
509
|
+
/// Chain names in creation order, then every rule as (chain, expression names).
|
|
510
|
+
fn graph(prepared: &Prepared, table: &str) -> (Vec<String>, Vec<(String, Vec<String>)>) {
|
|
511
|
+
let mut chains = Vec::new();
|
|
512
|
+
let mut rules = Vec::new();
|
|
513
|
+
for (kind, attributes) in prepared.program(table).unwrap() {
|
|
514
|
+
assert_eq!(crate::wire::text(&attributes, 1).unwrap(), table);
|
|
515
|
+
if kind == 3 {
|
|
516
|
+
chains.push(crate::wire::text(&attributes, 3).unwrap());
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
let expressions = crate::wire::attrs(&crate::wire::one(&attributes, 4).unwrap().value).unwrap();
|
|
520
|
+
rules.push((
|
|
521
|
+
crate::wire::text(&attributes, 2).unwrap(),
|
|
522
|
+
expressions
|
|
523
|
+
.iter()
|
|
524
|
+
.map(|expression| {
|
|
525
|
+
let fields = crate::wire::attrs(&expression.value).unwrap();
|
|
526
|
+
crate::wire::text(&fields, 1).unwrap()
|
|
527
|
+
})
|
|
528
|
+
.collect(),
|
|
529
|
+
));
|
|
530
|
+
}
|
|
531
|
+
(chains, rules)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#[test]
|
|
535
|
+
fn host_v2_without_published_ports_keeps_its_exact_digest_and_compiled_bytes() {
|
|
536
|
+
use sha2::{Digest, Sha256};
|
|
537
|
+
let baseline = prepared(host());
|
|
538
|
+
// Frozen before the optional publication field existed.
|
|
539
|
+
assert_eq!(
|
|
540
|
+
baseline.digest,
|
|
541
|
+
"sha256:b24cd2bc4e812014cabd71a77f57a2fe085c4b83d4e7b7939ccfca28949c6848"
|
|
542
|
+
);
|
|
543
|
+
let bytes = program_bytes(&baseline, "snft_v2_host_golden");
|
|
544
|
+
assert_eq!(bytes.len(), 24932);
|
|
545
|
+
assert_eq!(
|
|
546
|
+
format!("{:x}", Sha256::digest(&bytes)),
|
|
547
|
+
"91735de50a831184648bf79cd7a840f7af5264f3c6ec461f5412b3fc1d1ef6ec"
|
|
548
|
+
);
|
|
549
|
+
let serialized = serde_json::to_string(&baseline.policy).unwrap();
|
|
550
|
+
assert!(!serialized.contains("publishedPorts"));
|
|
551
|
+
// An explicit empty publication is the same canonical policy, digest and graph.
|
|
552
|
+
assert_eq!(prepared(published_host(json!([]))), baseline);
|
|
553
|
+
let (chains, rules) = graph(&baseline, "snft_v2_host_golden");
|
|
554
|
+
assert!(!chains.contains(&"pre".to_string()));
|
|
555
|
+
assert!(rules.iter().all(|(chain, _)| chain != "pre"));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
#[test]
|
|
559
|
+
fn host_v2_published_ports_compile_uplink_dnat_and_exact_forward_admission() {
|
|
560
|
+
use sha2::{Digest, Sha256};
|
|
561
|
+
let baseline = graph(&prepared(host()), "snft_v2_host_golden");
|
|
562
|
+
let value = published_host(json!([
|
|
563
|
+
published_port("tcp", 443, 8443),
|
|
564
|
+
published_port("udp", 8125, 8125)
|
|
565
|
+
]));
|
|
566
|
+
let published = prepared(value.clone());
|
|
567
|
+
assert_ne!(published.digest, prepared(host()).digest);
|
|
568
|
+
// Publication order is canonical, so a reordered list is the same policy.
|
|
569
|
+
let mut reordered = value;
|
|
570
|
+
reordered["scope"]["publishedPorts"]
|
|
571
|
+
.as_array_mut()
|
|
572
|
+
.unwrap()
|
|
573
|
+
.reverse();
|
|
574
|
+
assert_eq!(prepared(reordered), published);
|
|
575
|
+
let (chains, rules) = graph(&published, "snft_v2_host_golden");
|
|
576
|
+
assert_eq!(chains.len(), baseline.0.len() + 1);
|
|
577
|
+
assert_eq!(chains.iter().filter(|name| *name == "pre").count(), 1);
|
|
578
|
+
assert_eq!(rules.len(), baseline.1.len() + 8);
|
|
579
|
+
let translations: Vec<_> = rules.iter().filter(|(chain, _)| chain == "pre").collect();
|
|
580
|
+
assert_eq!(translations.len(), 2);
|
|
581
|
+
for (_, expressions) in &translations {
|
|
582
|
+
assert_eq!(expressions.last().unwrap(), "nat");
|
|
583
|
+
assert_eq!(expressions.iter().filter(|name| *name == "immediate").count(), 2);
|
|
584
|
+
}
|
|
585
|
+
// The three admissions precede the capture barrier jumps of their handoff.
|
|
586
|
+
let forward: Vec<_> = rules
|
|
587
|
+
.iter()
|
|
588
|
+
.filter(|(chain, _)| chain == "forward")
|
|
589
|
+
.map(|(_, expressions)| expressions.len())
|
|
590
|
+
.collect();
|
|
591
|
+
assert_eq!(forward.len(), baseline.1.iter().filter(|(chain, _)| chain == "forward").count() + 6);
|
|
592
|
+
let (_, first) = rules.iter().find(|(chain, _)| chain == "forward").unwrap();
|
|
593
|
+
assert!(first.contains(&"ct".to_string()) && first.len() > 4);
|
|
594
|
+
let bytes = program_bytes(&published, "snft_v2_host_golden");
|
|
595
|
+
assert_eq!(bytes.len(), 36322);
|
|
596
|
+
assert_eq!(
|
|
597
|
+
format!("{:x}", Sha256::digest(&bytes)),
|
|
598
|
+
"1d3485bb739ac3601642610eba465b80c9b5ac34df22a5374cfbc8773623710b"
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#[test]
|
|
603
|
+
fn host_v2_published_ports_are_owned_by_their_generation_and_leave_with_it() {
|
|
604
|
+
let published = prepared(published_host(json!([published_port("tcp", 443, 8443)])));
|
|
605
|
+
let rollback = prepared(host());
|
|
606
|
+
let (chains, rules) = graph(&published, "snft_v2_host_rollback");
|
|
607
|
+
assert!(chains.contains(&"pre".to_string()));
|
|
608
|
+
assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
|
|
609
|
+
// Every operation names the owned table, and the rollback target compiles the
|
|
610
|
+
// same graph as before publication; reconcile deletes the complete previous
|
|
611
|
+
// graph and re-adds the target in one batch, so the publication cannot survive.
|
|
612
|
+
let (rollback_chains, rollback_rules) = graph(&rollback, "snft_v2_host_rollback");
|
|
613
|
+
assert!(!rollback_chains.contains(&"pre".to_string()));
|
|
614
|
+
assert!(rollback_rules.iter().all(|(chain, _)| chain != "pre"));
|
|
615
|
+
assert_eq!(rollback_chains.len() + 1, chains.len());
|
|
616
|
+
assert_eq!(rollback_rules.len() + 4, rules.len());
|
|
617
|
+
assert_ne!(published.digest, rollback.digest);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
#[test]
|
|
621
|
+
fn host_v2_published_ports_reject_unbound_targets_conflicts_and_out_of_range_ports() {
|
|
622
|
+
assert!(normalized(published_host(json!([published_port("tcp", 443, 8443)]))).is_ok());
|
|
623
|
+
let mut unknown = published_port("tcp", 443, 8443);
|
|
624
|
+
unknown["hostInterface"] = json!("ens18");
|
|
625
|
+
let mut second_address = published_host(json!([
|
|
626
|
+
changed(published_port("tcp", 443, 8443), "/hostIp", json!("192.0.2.5"))
|
|
627
|
+
]));
|
|
628
|
+
second_address["scope"]["uplink"]["requiredIpv4Addresses"] = json!(["192.0.2.2", "192.0.2.5"]);
|
|
629
|
+
let mut duplicate_across_addresses = second_address.clone();
|
|
630
|
+
duplicate_across_addresses["scope"]["publishedPorts"]
|
|
631
|
+
.as_array_mut()
|
|
632
|
+
.unwrap()
|
|
633
|
+
.push(published_port("tcp", 443, 9443));
|
|
634
|
+
assert!(normalized(second_address).is_ok());
|
|
635
|
+
for invalid in [
|
|
636
|
+
published_host(json!([unknown])),
|
|
637
|
+
published_host(json!([published_port("sctp", 443, 8443)])),
|
|
638
|
+
published_host(json!([published_port("tcp", 0, 8443)])),
|
|
639
|
+
published_host(json!([published_port("tcp", 443, 0)])),
|
|
640
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/hostPort", json!(65536))])),
|
|
641
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/targetPort", json!(65536))])),
|
|
642
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/hostPort", json!(-1))])),
|
|
643
|
+
// The published address must be an exact current uplink address.
|
|
644
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/hostIp", json!("192.0.2.5"))])),
|
|
645
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/hostIp", json!("10.240.0.1"))])),
|
|
646
|
+
// The target must be a current leased transit source address.
|
|
647
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("10.240.0.3"))])),
|
|
648
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("10.240.0.1"))])),
|
|
649
|
+
published_host(json!([changed(published_port("tcp", 443, 8443), "/targetAddress", json!("192.0.2.2"))])),
|
|
650
|
+
// A published port cannot collide with the leased outbound SNAT ranges.
|
|
651
|
+
published_host(json!([published_port("tcp", 10500, 8443)])),
|
|
652
|
+
published_host(json!([published_port("udp", 10000, 8443)])),
|
|
653
|
+
// One protocol/port is published exactly once, including across addresses.
|
|
654
|
+
published_host(json!([published_port("tcp", 443, 8443), published_port("tcp", 443, 9443)])),
|
|
655
|
+
duplicate_across_addresses,
|
|
656
|
+
published_host(json!((0..65).map(|index| published_port("tcp", 20000 + index, 8443)).collect::<Vec<_>>())),
|
|
657
|
+
] {
|
|
658
|
+
assert!(normalized(invalid).is_err());
|
|
659
|
+
}
|
|
660
|
+
// Bounded input still meets the atomic reserve first: every publication costs
|
|
661
|
+
// one translation and three admissions, so prepare rejects before any kernel work.
|
|
662
|
+
let ports = |count: u16| -> Value {
|
|
663
|
+
json!((0..count)
|
|
664
|
+
.map(|index| published_port("tcp", 20000 + index, 8443))
|
|
665
|
+
.collect::<Vec<_>>())
|
|
666
|
+
};
|
|
667
|
+
let prepared = prepared(published_host(ports(12)));
|
|
668
|
+
let program = prepared
|
|
669
|
+
.program(&format!("snft_{}", "x".repeat(59)))
|
|
670
|
+
.unwrap();
|
|
671
|
+
let bytes: usize = program
|
|
672
|
+
.iter()
|
|
673
|
+
.map(|(_, attributes)| crate::wire::encode_attrs(attributes).len() + 20)
|
|
674
|
+
.sum();
|
|
675
|
+
assert!(program.len() <= 768 && bytes <= 100_000);
|
|
676
|
+
assert!(normalized(published_host(ports(13))).unwrap().prepare().is_err());
|
|
677
|
+
println!("PUBLISHED_MAX ports=12 operations={} bytes={bytes}", program.len());
|
|
678
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
use super::egress_traffic_tests::{connect, denied, host_policy, ns_ip, roundtrip, socket};
|
|
2
2
|
use super::*;
|
|
3
|
+
use crate::egress;
|
|
3
4
|
use crate::policy::{compare, expr, meta};
|
|
4
|
-
use std::
|
|
5
|
+
use std::io::{Read, Write};
|
|
6
|
+
use std::net::{TcpListener, TcpStream, UdpSocket};
|
|
7
|
+
use std::time::Duration;
|
|
5
8
|
|
|
6
9
|
const SOURCE: &str = "hs_source";
|
|
7
10
|
const HOST: &str = "hs_host";
|
|
@@ -297,3 +300,223 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
|
|
|
297
300
|
});
|
|
298
301
|
println!("V2_HOST_PROOF original_protected_dnat_denied=true current_protected_dnat_denied=true nonzero_zone_denied=true input_diversion_denied=true output_spoof_denied=true ipv6_forward_denied=true unleased_port_denied=true exact_platform_allowed=true positive_controls=true");
|
|
299
302
|
}
|
|
303
|
+
|
|
304
|
+
const PUBLISH_SOURCE: &str = "hp_source";
|
|
305
|
+
const PUBLISH_HOST: &str = "hp_host";
|
|
306
|
+
const PUBLISH_PEER: &str = "hp_peer";
|
|
307
|
+
|
|
308
|
+
fn chain_names(owner: &mut Owner) -> Vec<String> {
|
|
309
|
+
owner
|
|
310
|
+
.graph()
|
|
311
|
+
.unwrap()
|
|
312
|
+
.expect("owned table present")
|
|
313
|
+
.chains
|
|
314
|
+
.iter()
|
|
315
|
+
.map(|chain| wire::text(chain, 3).unwrap())
|
|
316
|
+
.collect()
|
|
317
|
+
}
|
|
318
|
+
/// Inbound publication through the uplink: the workload must observe the actual
|
|
319
|
+
/// client address, and the client must observe the published address back.
|
|
320
|
+
fn published_udp(client: &UdpSocket, workload: &UdpSocket, published: &str, client_address: &str) {
|
|
321
|
+
let mut bytes = [0; 64];
|
|
322
|
+
assert_eq!(client.send_to(b"query", published).unwrap(), 5);
|
|
323
|
+
let (size, observed) = workload.recv_from(&mut bytes).expect("published delivery");
|
|
324
|
+
assert_eq!(&bytes[..size], b"query");
|
|
325
|
+
assert_eq!(observed.to_string(), client_address);
|
|
326
|
+
assert_eq!(workload.send_to(b"reply", observed).unwrap(), 5);
|
|
327
|
+
let (size, from) = client.recv_from(&mut bytes).expect("published reply");
|
|
328
|
+
assert_eq!(&bytes[..size], b"reply");
|
|
329
|
+
assert_eq!(from.to_string(), published);
|
|
330
|
+
}
|
|
331
|
+
fn dark_udp(client: &UdpSocket, workload: &UdpSocket, published: &str) {
|
|
332
|
+
assert_eq!(client.send_to(b"query", published).unwrap(), 5);
|
|
333
|
+
assert!(workload.recv(&mut [0; 64]).is_err());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[test]
|
|
337
|
+
#[ignore = "requires disposable isolated native qualification guest"]
|
|
338
|
+
fn v2_host_publishes_exact_uplink_ports_and_removes_them_with_the_generation() {
|
|
339
|
+
isolated();
|
|
340
|
+
for name in [PUBLISH_SOURCE, PUBLISH_HOST, PUBLISH_PEER] {
|
|
341
|
+
ip(&["netns", "add", name]);
|
|
342
|
+
ns_ip(name, &["link", "set", "lo", "up"]);
|
|
343
|
+
in_namespace(name, || {
|
|
344
|
+
std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
connect(
|
|
348
|
+
PUBLISH_SOURCE,
|
|
349
|
+
"source",
|
|
350
|
+
"10.240.0.2/30",
|
|
351
|
+
PUBLISH_HOST,
|
|
352
|
+
"router",
|
|
353
|
+
"10.240.0.1/30",
|
|
354
|
+
);
|
|
355
|
+
connect(
|
|
356
|
+
PUBLISH_HOST,
|
|
357
|
+
"uplink",
|
|
358
|
+
"192.0.2.2/24",
|
|
359
|
+
PUBLISH_PEER,
|
|
360
|
+
"underlay",
|
|
361
|
+
"192.0.2.3/24",
|
|
362
|
+
);
|
|
363
|
+
ns_ip(PUBLISH_SOURCE, &["route", "add", "default", "via", "10.240.0.1"]);
|
|
364
|
+
ns_ip(PUBLISH_PEER, &["route", "add", "default", "via", "192.0.2.2"]);
|
|
365
|
+
let workload_udp = socket(PUBLISH_SOURCE, "10.240.0.2:8125");
|
|
366
|
+
let workload_direct = socket(PUBLISH_SOURCE, "10.240.0.2:8444");
|
|
367
|
+
let workload_unpublished = socket(PUBLISH_SOURCE, "10.240.0.2:9999");
|
|
368
|
+
// Every probe uses its own client port: an unreplied conntrack entry would
|
|
369
|
+
// otherwise carry its null translation across the policy change.
|
|
370
|
+
let control_client = socket(PUBLISH_PEER, "192.0.2.3:40010");
|
|
371
|
+
let client_udp = socket(PUBLISH_PEER, "192.0.2.3:40000");
|
|
372
|
+
// Positive controls: without policy the workload is reachable directly, and
|
|
373
|
+
// the published address is not, so every later result is caused by the policy.
|
|
374
|
+
control(&control_client, "10.240.0.2:8444", &workload_direct, true);
|
|
375
|
+
dark_udp(
|
|
376
|
+
&socket(PUBLISH_PEER, "192.0.2.3:40011"),
|
|
377
|
+
&workload_udp,
|
|
378
|
+
"192.0.2.2:8125",
|
|
379
|
+
);
|
|
380
|
+
let (owner, applied, published_policy, names) = in_namespace(PUBLISH_HOST, || {
|
|
381
|
+
let mut policy = host_policy();
|
|
382
|
+
let egress::Scope::HostTransit(scope) = &mut policy.scope else {
|
|
383
|
+
panic!()
|
|
384
|
+
};
|
|
385
|
+
scope.published_ports = vec![
|
|
386
|
+
egress::PublishedPort {
|
|
387
|
+
protocol: "tcp".into(),
|
|
388
|
+
host_port: 443,
|
|
389
|
+
target_port: 8443,
|
|
390
|
+
target_address: "10.240.0.2".into(),
|
|
391
|
+
host_ip: "192.0.2.2".into(),
|
|
392
|
+
},
|
|
393
|
+
egress::PublishedPort {
|
|
394
|
+
protocol: "udp".into(),
|
|
395
|
+
host_port: 8125,
|
|
396
|
+
target_port: 8125,
|
|
397
|
+
target_address: "10.240.0.2".into(),
|
|
398
|
+
host_ip: "192.0.2.2".into(),
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
let mut owner = Owner::new(options("host_published")).unwrap();
|
|
402
|
+
let target: Prepared = policy.clone().prepare().unwrap().into();
|
|
403
|
+
target
|
|
404
|
+
.validate_interfaces()
|
|
405
|
+
.expect("published host fixture binding");
|
|
406
|
+
let applied = owner
|
|
407
|
+
.reconcile(Transition {
|
|
408
|
+
previous: None,
|
|
409
|
+
target,
|
|
410
|
+
})
|
|
411
|
+
.unwrap();
|
|
412
|
+
assert!(owner.inspect().enforced);
|
|
413
|
+
let names = chain_names(&mut owner);
|
|
414
|
+
(owner, applied, policy, names)
|
|
415
|
+
});
|
|
416
|
+
assert!(names.contains(&"pre".to_string()));
|
|
417
|
+
// The fixture observes ingress only, so the translated opening is read where
|
|
418
|
+
// it arrives: the workload side of the handoff.
|
|
419
|
+
let observer = in_namespace(PUBLISH_SOURCE, || packet_fixture::PacketSocket::open("source"));
|
|
420
|
+
published_udp(&client_udp, &workload_udp, "192.0.2.2:8125", "192.0.2.3:40000");
|
|
421
|
+
let listener = in_namespace(PUBLISH_SOURCE, || {
|
|
422
|
+
TcpListener::bind("10.240.0.2:8443").unwrap()
|
|
423
|
+
});
|
|
424
|
+
let mut client = in_namespace(PUBLISH_PEER, || {
|
|
425
|
+
TcpStream::connect_timeout(&"192.0.2.2:443".parse().unwrap(), Duration::from_secs(1))
|
|
426
|
+
.expect("published tcp opening")
|
|
427
|
+
});
|
|
428
|
+
let (mut server, peer) = listener.accept().unwrap();
|
|
429
|
+
assert_eq!(peer.ip().to_string(), "192.0.2.3");
|
|
430
|
+
assert_eq!(client.peer_addr().unwrap().to_string(), "192.0.2.2:443");
|
|
431
|
+
for stream in [&client, &server] {
|
|
432
|
+
stream.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
|
|
433
|
+
}
|
|
434
|
+
client.write_all(b"ping").unwrap();
|
|
435
|
+
server.read_exact(&mut [0; 4]).unwrap();
|
|
436
|
+
server.write_all(b"pong").unwrap();
|
|
437
|
+
client.read_exact(&mut [0; 4]).unwrap();
|
|
438
|
+
// The translated opening is observable on the handoff, with the actual client
|
|
439
|
+
// address preserved and SYN only.
|
|
440
|
+
let syn = observer
|
|
441
|
+
.matching(|packet| {
|
|
442
|
+
packet.len() >= 40
|
|
443
|
+
&& packet[9] == 6
|
|
444
|
+
&& packet[16..20] == [10, 240, 0, 2]
|
|
445
|
+
&& packet[22..24] == 8443_u16.to_be_bytes()
|
|
446
|
+
&& packet[33] & 0x17 == 0x02
|
|
447
|
+
})
|
|
448
|
+
.expect("translated opening on the handoff");
|
|
449
|
+
assert_eq!(syn[12..16], [192, 0, 2, 3]);
|
|
450
|
+
// An opening that is not SYN cannot acquire the publication.
|
|
451
|
+
let injector = in_namespace(PUBLISH_PEER, || {
|
|
452
|
+
packet_fixture::PacketSocket::open("underlay")
|
|
453
|
+
});
|
|
454
|
+
let uplink_mac = in_namespace(PUBLISH_HOST, || packet_fixture::mac("uplink"));
|
|
455
|
+
for (port, flags) in [(40050_u16, 0x10_u8), (40051, 0x04), (40052, 0x12)] {
|
|
456
|
+
injector.send(
|
|
457
|
+
uplink_mac,
|
|
458
|
+
&packet_fixture::tcp([192, 0, 2, 3], [192, 0, 2, 2], port, 443, flags),
|
|
459
|
+
);
|
|
460
|
+
// The exact injected flow, never the live connection's own acknowledgements.
|
|
461
|
+
assert!(
|
|
462
|
+
observer
|
|
463
|
+
.matching(|packet| packet.len() >= 40
|
|
464
|
+
&& packet[9] == 6
|
|
465
|
+
&& packet[16..20] == [10, 240, 0, 2]
|
|
466
|
+
&& packet[20..22] == port.to_be_bytes()
|
|
467
|
+
&& packet[22..24] == 8443_u16.to_be_bytes()
|
|
468
|
+
&& packet[24..28] == 100_u32.to_be_bytes()
|
|
469
|
+
&& packet[33] == flags)
|
|
470
|
+
.is_none(),
|
|
471
|
+
"{flags:#04x}"
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
// The capture barrier still denies every unpublished path to the workload.
|
|
475
|
+
control(&control_client, "10.240.0.2:8444", &workload_direct, false);
|
|
476
|
+
let unpublished_client = socket(PUBLISH_PEER, "192.0.2.3:40002");
|
|
477
|
+
dark_udp(&unpublished_client, &workload_unpublished, "192.0.2.2:9999");
|
|
478
|
+
assert!(in_namespace(PUBLISH_PEER, || TcpStream::connect_timeout(
|
|
479
|
+
&"192.0.2.2:444".parse().unwrap(),
|
|
480
|
+
Duration::from_secs(1)
|
|
481
|
+
))
|
|
482
|
+
.is_err());
|
|
483
|
+
drop(client);
|
|
484
|
+
drop(server);
|
|
485
|
+
// Withdrawing the publication is an ordinary generation replacement.
|
|
486
|
+
let (mut owner, rolled_back, names) = in_namespace(PUBLISH_HOST, {
|
|
487
|
+
let mut owner = owner;
|
|
488
|
+
move || {
|
|
489
|
+
let mut withdrawn = published_policy;
|
|
490
|
+
withdrawn.revision = 2;
|
|
491
|
+
let egress::Scope::HostTransit(scope) = &mut withdrawn.scope else {
|
|
492
|
+
panic!()
|
|
493
|
+
};
|
|
494
|
+
scope.published_ports.clear();
|
|
495
|
+
let applied = owner
|
|
496
|
+
.reconcile(Transition {
|
|
497
|
+
previous: Some(applied),
|
|
498
|
+
target: withdrawn.prepare().unwrap().into(),
|
|
499
|
+
})
|
|
500
|
+
.unwrap();
|
|
501
|
+
assert!(owner.inspect().enforced);
|
|
502
|
+
let names = chain_names(&mut owner);
|
|
503
|
+
(owner, applied, names)
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
assert!(!names.contains(&"pre".to_string()));
|
|
507
|
+
let dark_client = socket(PUBLISH_PEER, "192.0.2.3:40003");
|
|
508
|
+
dark_udp(&dark_client, &workload_udp, "192.0.2.2:8125");
|
|
509
|
+
assert!(in_namespace(PUBLISH_PEER, || TcpStream::connect_timeout(
|
|
510
|
+
&"192.0.2.2:443".parse().unwrap(),
|
|
511
|
+
Duration::from_secs(1)
|
|
512
|
+
))
|
|
513
|
+
.is_err());
|
|
514
|
+
control(&control_client, "10.240.0.2:8444", &workload_direct, false);
|
|
515
|
+
in_namespace(PUBLISH_HOST, move || {
|
|
516
|
+
assert!(owner.inspect().enforced);
|
|
517
|
+
owner.release(rolled_back).unwrap();
|
|
518
|
+
});
|
|
519
|
+
let released_client = socket(PUBLISH_PEER, "192.0.2.3:40004");
|
|
520
|
+
dark_udp(&released_client, &workload_udp, "192.0.2.2:8125");
|
|
521
|
+
println!("V2_HOST_PUBLISHED_PROOF udp_published_delivered=true tcp_published_delivered=true client_address_preserved=true reverse_translation=true syn_only_opening=true nonsyn_opening_denied=true unpublished_workload_port_denied=true unpublished_uplink_port_denied=true barrier_positive_control=true withdrawn_chain_absent=true withdrawn_port_dark=true released_port_dark=true");
|
|
522
|
+
}
|
|
@@ -167,3 +167,40 @@ fn egress_identity_rejects_unknown_duplicate_or_incomplete_operands() {
|
|
|
167
167
|
assert!(rule_identity(&rule(vec![expression(name, extra)])).is_err());
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
/// A published host-transit generation must survive the same apply-time graph
|
|
172
|
+
/// identity as the rest of the table: the kernel dump is compared operand by
|
|
173
|
+
/// operand, so destination NAT and its admissions cannot be silently rewritten.
|
|
174
|
+
#[test]
|
|
175
|
+
fn published_host_graph_identity_preserves_destination_nat_and_admission_operands() {
|
|
176
|
+
let compiled = |target_port: u16| {
|
|
177
|
+
let value = crate::egress::tests::published_host(serde_json::json!([
|
|
178
|
+
crate::egress::tests::published_port("tcp", 443, target_port)
|
|
179
|
+
]));
|
|
180
|
+
let prepared = serde_json::from_value::<crate::egress::Policy>(value)
|
|
181
|
+
.unwrap()
|
|
182
|
+
.prepare()
|
|
183
|
+
.unwrap();
|
|
184
|
+
let mut chains = Vec::new();
|
|
185
|
+
let mut rules = Vec::new();
|
|
186
|
+
for (kind, attributes) in prepared.program("snft_identity").unwrap() {
|
|
187
|
+
if kind == 3 {
|
|
188
|
+
chains.push(super::chain_identity(&attributes).unwrap());
|
|
189
|
+
} else {
|
|
190
|
+
rules.push(rule_identity(&attributes).unwrap());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
(chains, rules)
|
|
194
|
+
};
|
|
195
|
+
let (chains, rules) = compiled(8443);
|
|
196
|
+
assert_eq!(rules.iter().filter(|(chain, _)| chain == "pre").count(), 1);
|
|
197
|
+
assert_eq!(chains.len(), 7);
|
|
198
|
+
let translation = rules.iter().find(|(chain, _)| chain == "pre").unwrap();
|
|
199
|
+
let (_, changed) = compiled(9443);
|
|
200
|
+
let other = changed.iter().find(|(chain, _)| chain == "pre").unwrap();
|
|
201
|
+
assert_ne!(translation.1, other.1);
|
|
202
|
+
assert_eq!(
|
|
203
|
+
rules.iter().filter(|(chain, _)| chain == "forward").count(),
|
|
204
|
+
changed.iter().filter(|(chain, _)| chain == "forward").count()
|
|
205
|
+
);
|
|
206
|
+
}
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -68,6 +68,18 @@ export interface IManagedNftRouterEgressScopeV2 {
|
|
|
68
68
|
generations: IManagedNftEgressGenerationV2[];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** One inbound uplink publication, compiled inside the same host-transit generation. */
|
|
72
|
+
export interface IManagedNftPublishedPortV2 {
|
|
73
|
+
protocol: 'tcp' | 'udp';
|
|
74
|
+
/** Published uplink port, 1–65535 and unique per protocol across the scope. */
|
|
75
|
+
hostPort: number;
|
|
76
|
+
targetPort: number;
|
|
77
|
+
/** Exact leased transit source address of one current allocation; it selects the handoff. */
|
|
78
|
+
targetAddress: string;
|
|
79
|
+
/** Exact current uplink address that publishes this port. No wildcard or route inference. */
|
|
80
|
+
hostIp: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
/** Shared-host capture barrier and explicit outer SNAT; Docker forwarding remains a separate owner. */
|
|
72
84
|
export interface IManagedNftHostTransitScopeV2 {
|
|
73
85
|
kind: 'hostTransit';
|
|
@@ -76,6 +88,11 @@ export interface IManagedNftHostTransitScopeV2 {
|
|
|
76
88
|
uplink: IManagedNftLocalLinkV2;
|
|
77
89
|
/** Exact current address present on uplink. No masquerade or default-route inference. */
|
|
78
90
|
snatAddress: string;
|
|
91
|
+
/** Optional inbound destination NAT. Absent and empty are the same canonical policy and
|
|
92
|
+
* keep the compiled graph unchanged. Every entry compiles its own uplink DNAT plus the
|
|
93
|
+
* exact 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?: IManagedNftPublishedPortV2[];
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
|