@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 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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "a05a20217727664aff94f0dfeeba5215ba8cfdfc3958f962da8384e229b11df3",
3
+ "binarySha256": "5fb7ac747d32155fe8577b86107a82405fa7afeb1cf73552e903aa06760660d8",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.3.0",
7
- "gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
6
+ "projectVersion": "2.4.0",
7
+ "gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-16T07:45:32.264Z",
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": "c322c2f4444cd6c980453ff7ddab76dfb39f1c0b149ff7401f4d184f491c3d01",
3
+ "binarySha256": "33940d9812e48ac705862a3c30c57d652877f1b9abeb9601fd6f20a4fbcdc72d",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.3.0",
7
- "gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
6
+ "projectVersion": "2.4.0",
7
+ "gitCommit": "8db07b6df0c1c2f6490dd6c7333051afe3a14a2a",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-16T07:45:32.403Z",
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.3.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,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.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",
@@ -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.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,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. 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)?;
@@ -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(