@push.rocks/smartnftables 2.6.0 → 3.0.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.
Files changed (34) hide show
  1. package/changelog.md +19 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +5 -5
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +5 -5
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/managed.egress.types.d.ts +25 -3
  8. package/package.json +6 -6
  9. package/readme.md +205 -63
  10. package/rust/src/egress.compile.rs +155 -76
  11. package/rust/src/egress.host.rs +24 -51
  12. package/rust/src/egress.hostgrant.rs +1 -1
  13. package/rust/src/egress.keys.rs +305 -0
  14. package/rust/src/egress.leased.rs +399 -0
  15. package/rust/src/egress.private.rs +197 -0
  16. package/rust/src/egress.published.rs +370 -0
  17. package/rust/src/egress.router.rs +61 -294
  18. package/rust/src/egress.rs +126 -11
  19. package/rust/src/egress_hostgrant_tests.rs +149 -18
  20. package/rust/src/egress_localport_tests.rs +3 -1
  21. package/rust/src/egress_publishedrange_tests.rs +812 -0
  22. package/rust/src/egress_routerequivalence_tests.rs +448 -0
  23. package/rust/src/egress_tests.rs +132 -230
  24. package/rust/src/egress_workloadgrant_tests.rs +6 -7
  25. package/rust/src/owner.rs +69 -14
  26. package/rust/src/owner_host_traffic_tests.rs +4 -0
  27. package/rust/src/owner_identity_tests.rs +166 -80
  28. package/rust/src/owner_publishedrange_traffic_tests.rs +306 -0
  29. package/rust/src/owner_router_traffic_tests.rs +2 -0
  30. package/rust/src/owner_scale_traffic_tests.rs +383 -0
  31. package/rust/src/owner_tests.rs +6 -0
  32. package/rust/src/policy.rs +29 -25
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/managed.egress.types.ts +25 -3
@@ -0,0 +1,383 @@
1
+ //! A 64-workload router: every workload with local DNS, public TCP and UDP
2
+ //! egress, four platform endpoints and a publication, the first with the SIP
3
+ //! shape, applied in one batch and proven on first, middle and last workloads.
4
+ use super::egress_traffic_tests::{allocation, binding, connect, denied, ns_ip, roundtrip};
5
+ use super::host_traffic_tests::{control, published_udp};
6
+ use super::*;
7
+ use crate::egress;
8
+ use serde_json::{json, Value};
9
+ use std::io::{Read, Write};
10
+ use std::net::{TcpListener, TcpStream, UdpSocket};
11
+ use std::time::Duration;
12
+
13
+ const WORKLOADS: u32 = 64;
14
+ const ROUTER: &str = "sc_router";
15
+ const HOST: &str = "sc_host";
16
+ const PEER: &str = "sc_peer";
17
+ /// The proven workloads: first, middle and last.
18
+ const PROBED: [u32; 3] = [0, 31, 63];
19
+
20
+ fn workload(index: u32) -> String {
21
+ format!("sc_w{index}")
22
+ }
23
+ fn socket_at(namespace: &str, address: String) -> UdpSocket {
24
+ let socket = in_namespace(namespace, move || UdpSocket::bind(address).unwrap());
25
+ socket
26
+ .set_read_timeout(Some(Duration::from_millis(250)))
27
+ .unwrap();
28
+ socket
29
+ }
30
+ /// Four platform endpoints on the peer, beside the protected prefixes.
31
+ fn protection() -> Value {
32
+ json!({"authorityDigest":format!("sha256:{}","b".repeat(64)),"prefixes":["10.0.0.0/8","192.0.2.0/24"],
33
+ "platformEndpoints":[{"id":"dns","address":"192.0.2.3","protocol":"udp","port":5300},
34
+ {"id":"mail","address":"192.0.2.3","protocol":"tcp","port":3000},
35
+ {"id":"metrics","address":"192.0.2.3","protocol":"tcp","port":9100},
36
+ {"id":"registry","address":"192.0.2.3","protocol":"udp","port":5301}]})
37
+ }
38
+ /// Publication `index`: one TCP port per workload, and the SIP shape on the
39
+ /// first.
40
+ fn publications(router: bool) -> Vec<Value> {
41
+ let entry = |protocol: &str,
42
+ port: u16,
43
+ end: Option<u16>,
44
+ endpoint: u16,
45
+ address: String,
46
+ symmetric: bool| {
47
+ let mut value = if router {
48
+ json!({"protocol":protocol,"transitPort":port,"endpointPort":endpoint,
49
+ "endpointAddress":address,"transitSourceAddress":"10.240.0.2","symmetric":symmetric})
50
+ } else {
51
+ json!({"protocol":protocol,"hostPort":port,"targetPort":port,
52
+ "targetAddress":"10.240.0.2","hostIp":"192.0.2.2","symmetric":symmetric})
53
+ };
54
+ if let Some(end) = end {
55
+ value[if router {
56
+ "transitPortEnd"
57
+ } else {
58
+ "hostPortEnd"
59
+ }] = json!(end);
60
+ }
61
+ value
62
+ };
63
+ let mut result = vec![
64
+ entry("udp", 5070, None, 5070, "10.241.0.2".into(), true),
65
+ entry("tcp", 5070, None, 5070, "10.241.0.2".into(), true),
66
+ entry("udp", 20000, Some(20200), 20000, "10.241.0.2".into(), true),
67
+ ];
68
+ for index in 0..WORKLOADS {
69
+ let port = 30000 + index as u16;
70
+ result.push(entry(
71
+ "tcp",
72
+ port,
73
+ None,
74
+ if router { 8080 } else { port },
75
+ format!("10.241.{index}.2"),
76
+ false,
77
+ ));
78
+ }
79
+ result
80
+ }
81
+ fn router_policy() -> egress::Policy {
82
+ let mut endpoints = Vec::new();
83
+ let mut links = Vec::new();
84
+ let mut rules = Vec::new();
85
+ let mut grants = Vec::new();
86
+ for index in 0..WORKLOADS {
87
+ let name = format!("w{index}");
88
+ let gateway = format!("10.241.{index}.1");
89
+ let address = format!("10.241.{index}.2");
90
+ let link = binding(&name, &gateway, "veth");
91
+ endpoints.push(
92
+ json!({"id":name,"interfaceIndex":link["interfaceIndex"],"interfaceName":name,
93
+ "interfaceKind":"veth","sourcePrefixes":[format!("{address}/32")]}),
94
+ );
95
+ links.push(link);
96
+ for protocol in ["tcp", "udp"] {
97
+ rules.push(json!({"sourceEndpoint":name,"destinationEndpoint":null,
98
+ "sourcePrefix":format!("{address}/32"),"destinationPrefix":format!("{gateway}/32"),
99
+ "protocol":protocol,"sourcePort":null,"destinationPort":53}));
100
+ rules.push(json!({"sourceEndpoint":null,"destinationEndpoint":name,
101
+ "sourcePrefix":format!("{gateway}/32"),"destinationPrefix":format!("{address}/32"),
102
+ "protocol":protocol,"sourcePort":53,"destinationPort":null}));
103
+ let range = if protocol == "udp" {
104
+ (10000, 10015)
105
+ } else {
106
+ (10016, 10031)
107
+ };
108
+ grants.push(
109
+ json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
110
+ "destination":{"kind":"public","prefix":"0.0.0.0/0"},"protocol":protocol,
111
+ "sourcePort":null,"destinationPort":null,
112
+ "sourcePortRange":{"first":range.0,"last":range.1}}),
113
+ );
114
+ }
115
+ for (id, protocol, port) in [
116
+ ("dns", "udp", 5300),
117
+ ("mail", "tcp", 3000),
118
+ ("metrics", "tcp", 9100),
119
+ ("registry", "udp", 5301),
120
+ ] {
121
+ let range = if protocol == "udp" {
122
+ (10000, 10015)
123
+ } else {
124
+ (10016, 10031)
125
+ };
126
+ grants.push(
127
+ json!({"sourceEndpoint":name,"sourcePrefix":format!("{address}/32"),
128
+ "destination":{"kind":"platform","endpointId":id},"protocol":protocol,
129
+ "sourcePort":null,"destinationPort":port,
130
+ "sourcePortRange":{"first":range.0,"last":range.1}}),
131
+ );
132
+ }
133
+ }
134
+ let mut generation = allocation();
135
+ generation["conntrackZone"] = json!(17);
136
+ generation["conntrackLabel"] = json!("a".repeat(32));
137
+ generation["grants"] = json!(grants);
138
+ serde_json::from_value(json!({"schemaVersion":2,"revision":1,"scope":{"kind":"routerEgress",
139
+ "endpoints":endpoints,"rules":rules,"links":links,"handoff":binding("handoff","10.240.0.2","veth"),
140
+ "protection":protection(),"generations":[generation],"publishedPorts":publications(true)}}))
141
+ .unwrap()
142
+ }
143
+ fn host_policy() -> egress::Policy {
144
+ let mut value = egress::tests::host();
145
+ value["scope"]["protection"] = protection();
146
+ value["scope"]["handoffs"][0]["link"] = binding("router", "10.240.0.1", "veth");
147
+ value["scope"]["handoffs"][0]["allocations"] = json!([allocation()]);
148
+ value["scope"]["uplink"] = binding("uplink", "192.0.2.2", "veth");
149
+ value["scope"]["publishedPorts"] = json!(publications(false));
150
+ serde_json::from_value(value).unwrap()
151
+ }
152
+ fn apply(
153
+ namespace: &str,
154
+ name: &'static str,
155
+ policy: fn() -> egress::Policy,
156
+ ) -> (Owner, Applied, usize, Vec<(String, usize)>) {
157
+ in_namespace(namespace, move || {
158
+ let mut owner = Owner::new(options(name)).unwrap();
159
+ let target: Prepared = policy().prepare().unwrap().into();
160
+ target.validate_interfaces().expect("scale fixture binding");
161
+ let applied = owner
162
+ .reconcile(Transition {
163
+ previous: None,
164
+ target,
165
+ })
166
+ .unwrap();
167
+ assert!(owner.inspect().enforced);
168
+ let graph = owner.graph().unwrap().expect("owned table present");
169
+ let sets = graph
170
+ .sets
171
+ .iter()
172
+ .map(|(attributes, elements)| (wire::text(attributes, 2).unwrap(), elements.len()))
173
+ .collect();
174
+ (owner, applied, graph.rules.len(), sets)
175
+ })
176
+ }
177
+
178
+ #[test]
179
+ #[ignore = "requires disposable isolated native qualification guest"]
180
+ fn v2_sixty_four_workload_router_enforces_every_workload_from_set_elements() {
181
+ isolated();
182
+ for name in [ROUTER, HOST, PEER] {
183
+ ip(&["netns", "add", name]);
184
+ ns_ip(name, &["link", "set", "lo", "up"]);
185
+ in_namespace(name, || {
186
+ std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
187
+ });
188
+ }
189
+ for index in 0..WORKLOADS {
190
+ let name = workload(index);
191
+ ip(&["netns", "add", &name]);
192
+ connect(
193
+ &name,
194
+ "client",
195
+ &format!("10.241.{index}.2/24"),
196
+ ROUTER,
197
+ &format!("w{index}"),
198
+ &format!("10.241.{index}.1/24"),
199
+ );
200
+ ns_ip(
201
+ &name,
202
+ &[
203
+ "route",
204
+ "add",
205
+ "default",
206
+ "via",
207
+ &format!("10.241.{index}.1"),
208
+ ],
209
+ );
210
+ }
211
+ connect(
212
+ ROUTER,
213
+ "handoff",
214
+ "10.240.0.2/30",
215
+ HOST,
216
+ "router",
217
+ "10.240.0.1/30",
218
+ );
219
+ connect(
220
+ HOST,
221
+ "uplink",
222
+ "192.0.2.2/24",
223
+ PEER,
224
+ "underlay",
225
+ "192.0.2.3/24",
226
+ );
227
+ ns_ip(ROUTER, &["route", "add", "default", "via", "10.240.0.1"]);
228
+ ns_ip(
229
+ HOST,
230
+ &["route", "add", "10.241.0.0/16", "via", "10.240.0.2"],
231
+ );
232
+ ns_ip(
233
+ HOST,
234
+ &["route", "add", "203.0.113.2/32", "via", "192.0.2.3"],
235
+ );
236
+ ns_ip(PEER, &["address", "add", "203.0.113.2/32", "dev", "lo"]);
237
+ ns_ip(PEER, &["route", "add", "default", "via", "192.0.2.2"]);
238
+ // Positive controls before any policy: a workload reaches another workload,
239
+ // the protected peer address, and the peer with another workload's source.
240
+ let neighbour = socket_at(&workload(1), "10.241.1.2:8081".into());
241
+ let protected_peer = socket_at(PEER, "192.0.2.3:40300".into());
242
+ let spoof_target = socket_at(PEER, "203.0.113.2:42100".into());
243
+ control(
244
+ &socket_at(&workload(0), "10.241.0.2:40400".into()),
245
+ "10.241.1.2:8081",
246
+ &neighbour,
247
+ true,
248
+ );
249
+ control(
250
+ &socket_at(&workload(0), "10.241.0.2:40401".into()),
251
+ "192.0.2.3:40300",
252
+ &protected_peer,
253
+ true,
254
+ );
255
+ ns_ip(
256
+ &workload(0),
257
+ &["address", "add", "10.241.1.99/32", "dev", "client"],
258
+ );
259
+ control(
260
+ &socket_at(&workload(0), "10.241.1.99:40402".into()),
261
+ "203.0.113.2:42100",
262
+ &spoof_target,
263
+ true,
264
+ );
265
+ let (host_owner, host_applied, _, _) = apply(HOST, "scale_host", host_policy);
266
+ let (router_owner, router_applied, router_rules, router_sets) =
267
+ apply(ROUTER, "scale_router", router_policy);
268
+ // Every workload is set elements: links, endpoint indices and names, source
269
+ // prefixes, the four DNS rules each, and six leased grants each.
270
+ let named = |name: &str| {
271
+ router_sets
272
+ .iter()
273
+ .find(|(set, _)| set == name)
274
+ .map(|(_, count)| *count)
275
+ };
276
+ assert_eq!(named("private_link"), Some(WORKLOADS as usize + 1));
277
+ assert_eq!(named("private_source"), Some(WORKLOADS as usize));
278
+ assert_eq!(named("private_port"), Some(WORKLOADS as usize * 4));
279
+ assert_eq!(named("leased_public_flow"), Some(WORKLOADS as usize * 2));
280
+ assert_eq!(named("leased_platform_flow"), Some(WORKLOADS as usize * 4));
281
+ assert_eq!(named("published_forward"), Some(WORKLOADS as usize + 3));
282
+ // The rule graph a single workload compiles, independent of the count.
283
+ assert!(router_rules < 100, "{router_rules}");
284
+ let public = socket_at(PEER, "203.0.113.2:42000".into());
285
+ let platform = socket_at(PEER, "192.0.2.3:5300".into());
286
+ let registry = socket_at(PEER, "192.0.2.3:5301".into());
287
+ let metrics = in_namespace(PEER, || TcpListener::bind("192.0.2.3:9100").unwrap());
288
+ for index in PROBED {
289
+ let name = workload(index);
290
+ let address = format!("10.241.{index}.2");
291
+ // Local DNS on the workload's own gateway.
292
+ let dns = socket_at(ROUTER, format!("10.241.{index}.1:53"));
293
+ let client = socket_at(&name, format!("{address}:40001"));
294
+ assert_eq!(
295
+ roundtrip(&client, &dns).to_string(),
296
+ format!("{address}:40001")
297
+ );
298
+ // Public egress through both hops from the leased range.
299
+ let outer = roundtrip(&socket_at(&name, format!("{address}:40002")), &public);
300
+ assert_eq!(outer.ip().to_string(), "192.0.2.2");
301
+ assert!((10000..=10015).contains(&outer.port()), "{outer}");
302
+ // Two platform endpoints over UDP and one over TCP.
303
+ for target in [&platform, &registry] {
304
+ let outer = roundtrip(&socket_at(&name, format!("{address}:40003")), target);
305
+ assert_eq!(outer.ip().to_string(), "192.0.2.2");
306
+ }
307
+ let mut stream = in_namespace(&name, || {
308
+ TcpStream::connect_timeout(&"192.0.2.3:9100".parse().unwrap(), Duration::from_secs(1))
309
+ })
310
+ .expect("platform tcp opening");
311
+ let (mut served, peer) = metrics.accept().unwrap();
312
+ assert_eq!(peer.ip().to_string(), "192.0.2.2");
313
+ assert!((10016..=10031).contains(&peer.port()));
314
+ stream
315
+ .set_read_timeout(Some(Duration::from_secs(1)))
316
+ .unwrap();
317
+ served.write_all(b"pong").unwrap();
318
+ stream.read_exact(&mut [0; 4]).unwrap();
319
+ // The workload's publication, with the client address preserved.
320
+ let listener = in_namespace(&name, move || {
321
+ TcpListener::bind(format!("10.241.{index}.2:8080")).unwrap()
322
+ });
323
+ let mut inbound = in_namespace(PEER, move || {
324
+ TcpStream::connect_timeout(
325
+ &format!("192.0.2.2:{}", 30000 + index).parse().unwrap(),
326
+ Duration::from_secs(1),
327
+ )
328
+ })
329
+ .expect("published tcp opening");
330
+ let (mut accepted, client) = listener.accept().unwrap();
331
+ assert_eq!(client.ip().to_string(), "192.0.2.3");
332
+ accepted.write_all(b"pong").unwrap();
333
+ inbound
334
+ .set_read_timeout(Some(Duration::from_secs(1)))
335
+ .unwrap();
336
+ inbound.read_exact(&mut [0; 4]).unwrap();
337
+ // Nothing unnamed: another workload, a protected address outside the
338
+ // platform endpoints, and a platform port that is not granted.
339
+ denied(
340
+ &socket_at(&name, format!("{address}:40005")),
341
+ &neighbour,
342
+ false,
343
+ );
344
+ denied(
345
+ &socket_at(&name, format!("{address}:40006")),
346
+ &protected_peer,
347
+ false,
348
+ );
349
+ let ungranted = socket_at(PEER, format!("192.0.2.3:{}", 5400 + index));
350
+ denied(
351
+ &socket_at(&name, format!("{address}:40007")),
352
+ &ungranted,
353
+ false,
354
+ );
355
+ }
356
+ // The first workload's SIP publication leaves from its own port.
357
+ let sip = socket_at(&workload(0), "10.241.0.2:5070".into());
358
+ let sip_peer = socket_at(PEER, "203.0.113.2:42002".into());
359
+ assert_eq!(roundtrip(&sip, &sip_peer).to_string(), "192.0.2.2:5070");
360
+ published_udp(
361
+ &socket_at(PEER, "192.0.2.3:40500".into()),
362
+ &socket_at(&workload(0), "10.241.0.2:20100".into()),
363
+ "192.0.2.2:20100",
364
+ "192.0.2.3:40500",
365
+ );
366
+ // Another workload's source address stays behind the anti-spoof guard.
367
+ denied(
368
+ &socket_at(&workload(0), "10.241.1.99:40403".into()),
369
+ &spoof_target,
370
+ false,
371
+ );
372
+ in_namespace(ROUTER, move || {
373
+ let mut owner = router_owner;
374
+ assert!(owner.inspect().enforced);
375
+ owner.release(router_applied).unwrap();
376
+ });
377
+ in_namespace(HOST, move || {
378
+ let mut owner = host_owner;
379
+ assert!(owner.inspect().enforced);
380
+ owner.release(host_applied).unwrap();
381
+ });
382
+ println!("V2_SCALE_PROOF workloads=64 router_rules={router_rules} set_elements_verified=true private_dns=true public_egress=true platform_udp=true platform_tcp=true published_inbound=true client_address_preserved=true cross_workload_denied=true protected_denied=true ungranted_platform_port_denied=true symmetric_sip=true published_range=true spoof_denied=true positive_controls=true released=true");
383
+ }
@@ -24,6 +24,12 @@ mod host_traffic_tests;
24
24
  #[path = "owner_router_traffic_tests.rs"]
25
25
  mod router_traffic_tests;
26
26
 
27
+ #[path = "owner_publishedrange_traffic_tests.rs"]
28
+ mod publishedrange_traffic_tests;
29
+
30
+ #[path = "owner_scale_traffic_tests.rs"]
31
+ mod scale_traffic_tests;
32
+
27
33
  #[path = "owner_poolguard_tests.rs"]
28
34
  mod poolguard_tests;
29
35
 
@@ -90,12 +90,37 @@ fn covers(parent: &str, child: &str) -> Result<bool> {
90
90
  }
91
91
 
92
92
  impl Policy {
93
- pub fn prepare(mut self) -> Result<Prepared> {
93
+ pub fn prepare(self) -> Result<Prepared> {
94
+ let policy = self.normalize(32, 128)?;
95
+ let digest = format!(
96
+ "sha256:{:x}",
97
+ Sha256::digest(serde_json::to_vec(&policy).map_err(|_| Error::Invalid)?)
98
+ );
99
+ let prepared = Prepared { policy, digest };
100
+ // Compile during preparation; an oversized native batch never reaches the kernel.
101
+ let program = prepared.program(&format!("snft_{}", "x".repeat(59)))?;
102
+ // Reserve enough of the batch for deleting a previous maximum
103
+ // graph, then installing this complete graph in the same transaction.
104
+ if program.len() > 768
105
+ || program
106
+ .iter()
107
+ .map(|(_, attrs)| wire::encode_attrs(attrs).len() + 20)
108
+ .sum::<usize>()
109
+ > 100_000
110
+ {
111
+ return Err(Error::Invalid);
112
+ }
113
+ Ok(prepared)
114
+ }
115
+ /// Validates and orders the endpoints and rules. Schema v1 compiles one rule
116
+ /// per endpoint and grant, so it admits 32 endpoints and 128 rules; the
117
+ /// set-backed router scope of schema v2 admits more.
118
+ pub(crate) fn normalize(mut self, endpoints: usize, rules: usize) -> Result<Self> {
94
119
  if self.schema_version != 1
95
120
  || self.revision == 0
96
121
  || self.revision > 9_007_199_254_740_991
97
- || self.endpoints.len() > 32
98
- || self.rules.len() > 128
122
+ || self.endpoints.len() > endpoints
123
+ || self.rules.len() > rules
99
124
  {
100
125
  return Err(Error::Invalid);
101
126
  }
@@ -185,28 +210,7 @@ impl Policy {
185
210
  }
186
211
  }
187
212
  }
188
- let digest = format!(
189
- "sha256:{:x}",
190
- Sha256::digest(serde_json::to_vec(&self).map_err(|_| Error::Invalid)?)
191
- );
192
- let prepared = Prepared {
193
- policy: self,
194
- digest,
195
- };
196
- // Compile during preparation; an oversized native batch never reaches the kernel.
197
- let program = prepared.program(&format!("snft_{}", "x".repeat(59)))?;
198
- // Reserve enough of the batch for deleting a previous maximum
199
- // graph, then installing this complete graph in the same transaction.
200
- if program.len() > 768
201
- || program
202
- .iter()
203
- .map(|(_, attrs)| wire::encode_attrs(attrs).len() + 20)
204
- .sum::<usize>()
205
- > 100_000
206
- {
207
- return Err(Error::Invalid);
208
- }
209
- Ok(prepared)
213
+ Ok(self)
210
214
  }
211
215
  }
212
216
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '2.6.0',
6
+ version: '3.0.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -60,9 +60,14 @@ export interface IManagedNftEgressGenerationV2 extends IManagedNftHandoffAllocat
60
60
  * `transitSourceAddress:transitPort`, this translates on to the workload. */
61
61
  export interface IManagedNftRouterPublishedPortV2 {
62
62
  protocol: 'tcp' | 'udp';
63
- /** Published handoff port, 1–65535 and unique per protocol across the scope.
64
- * It is the `targetPort` of the matching host-transit publication. */
63
+ /** Published handoff port, 1–65535; published ports never overlap per protocol across the
64
+ * scope. It is the `targetPort` of the matching host-transit publication. */
65
65
  transitPort: number;
66
+ /** Optional last port of a published range `transitPort..transitPortEnd`, greater than
67
+ * `transitPort`. A range keeps every port, so `endpointPort` must equal `transitPort`; like one
68
+ * port it is one interval set element per set, and its translation keeps the port. Absent for
69
+ * one port, which is its only canonical form. */
70
+ transitPortEnd?: number;
66
71
  endpointPort: number;
67
72
  /** Exact current workload endpoint address behind one veth endpoint of this
68
73
  * scope. Never a router-local address, a gateway or a platform endpoint. */
@@ -70,6 +75,12 @@ export interface IManagedNftRouterPublishedPortV2 {
70
75
  /** Exact leased transit source address of one current generation, present on
71
76
  * `handoff`. No wildcard, secondary-address or route inference. */
72
77
  transitSourceAddress: string;
78
+ /** Optional: the workload may open flows from `endpointAddress` and its published endpoint
79
+ * port(s) toward unprotected space, source-translated to `transitSourceAddress` and the
80
+ * published transit port (a range keeps the port). The protected union stays denied, and its
81
+ * endpoint ports may not overlap another publication's on the same address. Absent and
82
+ * `false` are the same canonical policy. */
83
+ symmetric?: boolean;
73
84
  }
74
85
 
75
86
  /** One exact host-origin flow: the node's own host network namespace dials one port of one local
@@ -136,13 +147,24 @@ export interface IManagedNftRouterEgressScopeV2 {
136
147
  /** One inbound uplink publication, compiled inside the same host-transit generation. */
137
148
  export interface IManagedNftPublishedPortV2 {
138
149
  protocol: 'tcp' | 'udp';
139
- /** Published uplink port, 1–65535 and unique per protocol across the scope. */
150
+ /** Published uplink port, 1–65535; published ports never overlap per protocol across the scope. */
140
151
  hostPort: number;
152
+ /** Optional last port of a published range `hostPort..hostPortEnd`, greater than `hostPort`.
153
+ * A range keeps every port, so `targetPort` must equal `hostPort`; like one port it is one
154
+ * interval set element per set, and its translation keeps the port. Absent for one port, which
155
+ * is its only canonical form. */
156
+ hostPortEnd?: number;
141
157
  targetPort: number;
142
158
  /** Exact leased transit source address of one current allocation; it selects the handoff. */
143
159
  targetAddress: string;
144
160
  /** Exact current uplink address that publishes this port. No wildcard or route inference. */
145
161
  hostIp: string;
162
+ /** Optional: flows from `targetAddress` and the published target port(s) may leave through the
163
+ * uplink toward unprotected space, source-translated to `hostIp` and the published uplink port
164
+ * (a range keeps the port). The protected union stays denied, its target ports may not fall in
165
+ * a leased range of `targetAddress` or overlap another publication's. Absent and `false` are
166
+ * the same canonical policy. */
167
+ symmetric?: boolean;
146
168
  }
147
169
 
148
170
  /** Shared-host capture barrier and explicit outer SNAT; Docker forwarding remains a separate owner. */