@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 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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "d997150e9cde38e1ba2c93c5b894c367f9b562a31291ee568ccd1e4a54034b61",
3
+ "binarySha256": "5fb7ac747d32155fe8577b86107a82405fa7afeb1cf73552e903aa06760660d8",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.2.0",
7
- "gitCommit": "32e9f40066053a15f32031e796e3151140e98eca",
6
+ "projectVersion": "2.4.0",
7
+ "gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-15T06:33:44.972Z",
9
+ "builtAt": "2026-09-16T11:32:31.722Z",
10
10
  "tsrustVersion": "1.13.0",
11
11
  "binary": "smartnftables",
12
12
  "target": "linux_amd64_musl"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "14a625bdb893667872dad6b339aa77f64c6d7e538158bff3e4b92e5684d70e61",
3
+ "binarySha256": "33940d9812e48ac705862a3c30c57d652877f1b9abeb9601fd6f20a4fbcdc72d",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.2.0",
7
- "gitCommit": "32e9f40066053a15f32031e796e3151140e98eca",
6
+ "projectVersion": "2.4.0",
7
+ "gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-15T06:33:49.548Z",
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.2.0',
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.2.0",
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.13.2",
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.0.0",
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();
@@ -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. Unknown
71
- // ingress never reaches private/default-zone rules, even after a rename.
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.into_iter().chain(parts.grants) {
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)?;