@push.rocks/smartnftables 2.6.0 → 4.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 (44) hide show
  1. package/changelog.md +33 -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/classes.manageddockerforwarding.js +3 -4
  8. package/dist_ts/classes.managednftables.d.ts +14 -2
  9. package/dist_ts/classes.managednftables.js +48 -8
  10. package/dist_ts/managed.egress.types.d.ts +25 -3
  11. package/dist_ts/managed.types.d.ts +14 -0
  12. package/package.json +7 -7
  13. package/readme.md +262 -64
  14. package/rust/src/docker.policy.rs +2 -1
  15. package/rust/src/egress.compile.rs +153 -76
  16. package/rust/src/egress.host.rs +24 -51
  17. package/rust/src/egress.hostgrant.rs +1 -1
  18. package/rust/src/egress.keys.rs +305 -0
  19. package/rust/src/egress.leased.rs +399 -0
  20. package/rust/src/egress.private.rs +197 -0
  21. package/rust/src/egress.published.rs +370 -0
  22. package/rust/src/egress.router.rs +61 -294
  23. package/rust/src/egress.rs +140 -19
  24. package/rust/src/egress_hostgrant_tests.rs +158 -20
  25. package/rust/src/egress_localport_tests.rs +11 -2
  26. package/rust/src/egress_publishedrange_tests.rs +812 -0
  27. package/rust/src/egress_routerequivalence_tests.rs +448 -0
  28. package/rust/src/egress_tests.rs +289 -230
  29. package/rust/src/egress_workloadgrant_tests.rs +21 -13
  30. package/rust/src/main.rs +57 -3
  31. package/rust/src/owner.rs +69 -14
  32. package/rust/src/owner_host_traffic_tests.rs +4 -0
  33. package/rust/src/owner_identity_tests.rs +166 -80
  34. package/rust/src/owner_publishedrange_traffic_tests.rs +306 -0
  35. package/rust/src/owner_router_traffic_tests.rs +2 -0
  36. package/rust/src/owner_scale_traffic_tests.rs +383 -0
  37. package/rust/src/owner_tests.rs +6 -0
  38. package/rust/src/policy.rs +30 -29
  39. package/rust/src/tests.rs +100 -0
  40. package/ts/00_commitinfo_data.ts +1 -1
  41. package/ts/classes.manageddockerforwarding.ts +2 -3
  42. package/ts/classes.managednftables.ts +46 -8
  43. package/ts/managed.egress.types.ts +25 -3
  44. package/ts/managed.types.ts +17 -0
@@ -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,15 +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> {
94
- if self.schema_version != 1
95
- || self.revision == 0
96
- || self.revision > 9_007_199_254_740_991
97
- || self.endpoints.len() > 32
98
- || self.rules.len() > 128
99
- {
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
+ crate::capacity("operations", 768, program.len())?;
105
+ crate::capacity(
106
+ "ruleBytes",
107
+ 100_000,
108
+ program
109
+ .iter()
110
+ .map(|(_, attrs)| wire::encode_attrs(attrs).len() + 20)
111
+ .sum(),
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> {
119
+ if self.schema_version != 1 || self.revision == 0 || self.revision > 9_007_199_254_740_991 {
100
120
  return Err(Error::Invalid);
101
121
  }
122
+ crate::capacity("endpoints", endpoints, self.endpoints.len())?;
123
+ crate::capacity("rules", rules, self.rules.len())?;
102
124
  let mut ids = BTreeSet::new();
103
125
  let mut indices = BTreeSet::new();
104
126
  let mut names = BTreeSet::new();
@@ -185,28 +207,7 @@ impl Policy {
185
207
  }
186
208
  }
187
209
  }
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)
210
+ Ok(self)
210
211
  }
211
212
  }
212
213
 
package/rust/src/tests.rs CHANGED
@@ -117,3 +117,103 @@ fn compiler_bounds_full_policy_and_keeps_directed_port_grants() {
117
117
  value.revision = 9_007_199_254_740_992;
118
118
  assert_eq!(value.prepare(), Err(Error::Invalid));
119
119
  }
120
+
121
+ fn exhausted<T>(name: &'static str, limit: usize, actual: usize) -> crate::Result<T> {
122
+ Err(Error::Exhausted(crate::Bound {
123
+ name,
124
+ limit,
125
+ actual,
126
+ }))
127
+ }
128
+
129
+ /// Every endpoint with 16 prefixes and `rules` distinct directed rules between
130
+ /// them: well-formed input whose only fault can be its size.
131
+ fn sized(endpoints: u32, rules: u16) -> Policy {
132
+ let mut value = policy();
133
+ let template = value.endpoints[0].clone();
134
+ value.endpoints = (0..endpoints)
135
+ .map(|index| {
136
+ let mut endpoint = template.clone();
137
+ endpoint.id = format!("e{index}");
138
+ endpoint.interface_index = 10 + index;
139
+ endpoint.interface_name = format!("w{index}");
140
+ endpoint.source_prefixes = (0..16)
141
+ .map(|part| format!("10.{}.{part}.0/24", 100 + index))
142
+ .collect();
143
+ endpoint
144
+ })
145
+ .collect();
146
+ let template = value.rules[0].clone();
147
+ value.rules = (0..rules)
148
+ .map(|index| {
149
+ let (from, to) = (
150
+ u32::from(index) % endpoints,
151
+ (u32::from(index) + 1) % endpoints,
152
+ );
153
+ let mut rule = template.clone();
154
+ rule.source_endpoint = Some(format!("e{from}"));
155
+ rule.destination_endpoint = Some(format!("e{to}"));
156
+ rule.source_prefix = format!("10.{}.0.0/24", 100 + from);
157
+ rule.destination_prefix = format!("10.{}.0.0/24", 100 + to);
158
+ rule.destination_port = Some(1000 + index);
159
+ rule
160
+ })
161
+ .collect();
162
+ value
163
+ }
164
+
165
+ #[test]
166
+ fn v1_capacity_refusals_name_their_bound_and_invalid_input_stays_invalid() {
167
+ assert_eq!(sized(2, 2).prepare().map(|_| ()), Ok(()));
168
+ // Within the schema's counts, the complete graph exceeds the byte and the
169
+ // operation budget; the complete program is measured, so both are exact.
170
+ assert_eq!(
171
+ sized(8, 16).prepare(),
172
+ exhausted("ruleBytes", 100_000, 186_952)
173
+ );
174
+ assert_eq!(sized(32, 128).prepare(), exhausted("operations", 768, 1667));
175
+ assert_eq!(sized(33, 16).prepare(), exhausted("endpoints", 32, 33));
176
+ assert_eq!(sized(8, 129).prepare(), exhausted("rules", 128, 129));
177
+ // Invalid input is never reported as capacity, whatever its size.
178
+ let mut value = sized(33, 129);
179
+ value.schema_version = 2;
180
+ assert_eq!(value.prepare(), Err(Error::Invalid));
181
+ let mut value = sized(2, 2);
182
+ value.endpoints[0].interface_name = "w".repeat(16);
183
+ assert_eq!(value.prepare(), Err(Error::Invalid));
184
+ let mut value = sized(2, 2);
185
+ value.endpoints[0]
186
+ .source_prefixes
187
+ .push("10.200.0.0/24".into());
188
+ assert_eq!(value.prepare(), Err(Error::Invalid));
189
+ }
190
+
191
+ #[test]
192
+ fn failure_responses_carry_the_refusal_reason_and_capacity_bound() {
193
+ let bound = crate::Bound {
194
+ name: "targetBytes",
195
+ limit: 212_000,
196
+ actual: 212_345,
197
+ };
198
+ assert_eq!(
199
+ crate::failure("7", Error::Exhausted(bound)),
200
+ serde_json::json!({"id":"7","success":false,
201
+ "error":"Managed policy exceeds the targetBytes capacity of 212000: it reached 212345.",
202
+ "errorCode":"EXHAUSTED","errorData":{"bound":"targetBytes","limit":212_000,"actual":212_345}})
203
+ );
204
+ assert_eq!(
205
+ crate::failure("8", Error::Invalid),
206
+ serde_json::json!({"id":"8","success":false,"error":"Managed policy input is invalid.","errorCode":"INVALID"})
207
+ );
208
+ for error in [
209
+ Error::Conflict,
210
+ Error::Permission,
211
+ Error::Protocol,
212
+ Error::Unavailable,
213
+ ] {
214
+ assert_eq!(
215
+ crate::failure("9", error),
216
+ serde_json::json!({"id":"9","success":false,"error":"Managed policy operation failed.","errorCode":error.code()})
217
+ );
218
+ }
219
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '2.6.0',
6
+ version: '4.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
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { snapshot, managedIpcBytes, ManagedNftablesError } from './classes.managednftables.js';
2
+ import { snapshot, managedIpcBytes, ManagedNftablesError, nativeError } from './classes.managednftables.js';
3
3
  import type { IManagedDockerForwardingOptions, IManagedDockerForwardingStatus, IManagedDockerForwardingPolicy, IPreparedManagedDockerForwardingPolicy, IManagedDockerForwardingTransition, IAppliedManagedDockerForwardingPolicy, TManagedDockerForwardingCommands } from './managed.docker.types.js';
4
4
 
5
5
  /** One node-level Docker DOCKER-USER contribution. Caller owns durable intents and handoff lifetimes. */
@@ -103,8 +103,7 @@ export class ManagedDockerForwarding {
103
103
  #track<T>(work: Promise<T>): Promise<T> {
104
104
  const owned = work.catch((error) => {
105
105
  if (this.#state !== 'closing') this.#state = 'failed-owned';
106
- const code = error instanceof plugins.smartrust.RustBridgeRequestError ? error.responseErrorCode : undefined;
107
- throw new ManagedNftablesError(typeof code === 'string' && /^[A-Z_]{1,64}$/.test(code) ? code : 'UNAVAILABLE');
106
+ throw nativeError(error);
108
107
  });
109
108
  this.#work = owned;
110
109
  void owned.then(() => { this.#work = null; }, () => { this.#work = null; });