@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 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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "format": "tsrust.build-provenance.v2",
3
- "binarySha256": "d997150e9cde38e1ba2c93c5b894c367f9b562a31291ee568ccd1e4a54034b61",
3
+ "binarySha256": "a05a20217727664aff94f0dfeeba5215ba8cfdfc3958f962da8384e229b11df3",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.2.0",
7
- "gitCommit": "32e9f40066053a15f32031e796e3151140e98eca",
6
+ "projectVersion": "2.3.0",
7
+ "gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-15T06:33:44.972Z",
9
+ "builtAt": "2026-09-16T07:45:32.264Z",
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": "c322c2f4444cd6c980453ff7ddab76dfb39f1c0b149ff7401f4d184f491c3d01",
4
4
  "buildInfo": {
5
5
  "projectName": "@push.rocks/smartnftables",
6
- "projectVersion": "2.2.0",
7
- "gitCommit": "32e9f40066053a15f32031e796e3151140e98eca",
6
+ "projectVersion": "2.3.0",
7
+ "gitCommit": "47dfc97a139c73ecbcb17b86382878153a58189f",
8
8
  "gitDirty": false,
9
- "builtAt": "2026-09-15T06:33:49.548Z",
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.2.0',
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.2.0",
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.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.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();
@@ -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")?;
@@ -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
@@ -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::net::UdpSocket;
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
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '2.2.0',
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
  }
@@ -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.