@push.rocks/smartnftables 1.5.0 → 1.7.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 (43) hide show
  1. package/changelog.md +18 -0
  2. package/dist_rust/smartnftables_linux_amd64_musl +0 -0
  3. package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
  4. package/dist_rust/smartnftables_linux_arm64_musl +0 -0
  5. package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/classes.manageddockerforwarding.d.ts +17 -0
  8. package/dist_ts/classes.manageddockerforwarding.js +128 -0
  9. package/dist_ts/classes.managednftables.d.ts +2 -0
  10. package/dist_ts/classes.managednftables.js +2 -2
  11. package/dist_ts/index.d.ts +2 -0
  12. package/dist_ts/index.js +3 -1
  13. package/dist_ts/managed.docker.types.d.ts +78 -0
  14. package/dist_ts/managed.docker.types.js +2 -0
  15. package/dist_ts/managed.egress.types.d.ts +9 -1
  16. package/package.json +1 -1
  17. package/readme.md +100 -6
  18. package/rust/src/docker.frontend.rs +203 -0
  19. package/rust/src/docker.graph.rs +129 -0
  20. package/rust/src/docker.policy.rs +140 -0
  21. package/rust/src/docker.rs +191 -0
  22. package/rust/src/docker_process_tests.rs +29 -0
  23. package/rust/src/docker_tests.rs +148 -0
  24. package/rust/src/egress.compile.rs +3 -0
  25. package/rust/src/egress.poolguard.rs +30 -0
  26. package/rust/src/egress.rs +27 -0
  27. package/rust/src/egress_tests.rs +85 -0
  28. package/rust/src/main.rs +10 -5
  29. package/rust/src/managed.rs +4 -0
  30. package/rust/src/owner.rs +19 -1
  31. package/rust/src/owner_egress_tests.rs +1 -1
  32. package/rust/src/owner_egress_traffic_tests.rs +21 -10
  33. package/rust/src/owner_host_traffic_tests.rs +4 -10
  34. package/rust/src/owner_poolguard_tests.rs +265 -0
  35. package/rust/src/owner_tests.rs +3 -0
  36. package/rust/src/wire.links.rs +11 -0
  37. package/rust/src/wire.rs +14 -3
  38. package/ts/00_commitinfo_data.ts +1 -1
  39. package/ts/classes.manageddockerforwarding.ts +113 -0
  40. package/ts/classes.managednftables.ts +1 -1
  41. package/ts/index.ts +2 -0
  42. package/ts/managed.docker.types.ts +49 -0
  43. package/ts/managed.egress.types.ts +10 -1
@@ -0,0 +1,265 @@
1
+ use super::egress_traffic_tests::{connect, ns_ip, roundtrip, socket};
2
+ use super::host_traffic_tests::{immediate, payload};
3
+ use super::*;
4
+ use crate::{
5
+ egress,
6
+ policy::{expr, meta},
7
+ };
8
+ use serde_json::json;
9
+ use std::net::{SocketAddr, UdpSocket};
10
+ use std::time::Duration;
11
+
12
+ const SOURCE: &str = "pg_source";
13
+ const HOST: &str = "pg_host";
14
+ const PEER: &str = "pg_peer";
15
+
16
+ #[test]
17
+ #[ignore = "requires disposable isolated native qualification guest"]
18
+ fn allocation_pool_guard_maximum_graph_persist_and_lost_ack_recovery() {
19
+ isolated();
20
+ let mut policy = egress::tests::pool_guard();
21
+ policy["scope"]["prefixes"] = json!((0..64)
22
+ .map(|part| format!("10.{part}.0.0/16"))
23
+ .collect::<Vec<_>>());
24
+ super::egress_tests::recover("pools_v2", serde_json::from_value(policy).unwrap());
25
+ }
26
+
27
+ // Foreign capture controls exist only in this offline guest. They are never
28
+ // composed into or removed with the managed policy under test.
29
+ fn foreign_captures() {
30
+ let table = "snft_foreign_pool_capture";
31
+ let mut operations = vec![(0, 0x600, vec![Attr::string(1, table), Attr::u32(2, 6)])];
32
+ for (chain, hook, priority, kind) in [
33
+ ("raw_pre", 0, -300_i32, "filter"),
34
+ ("raw_out", 3, -300, "filter"),
35
+ ("pre", 0, -100, "nat"),
36
+ ("out", 3, -100, "nat"),
37
+ ] {
38
+ operations.push((
39
+ 3,
40
+ 0x600,
41
+ vec![
42
+ Attr::string(1, table),
43
+ Attr::string(3, chain),
44
+ Attr::nested(4, vec![Attr::u32(1, hook), Attr::u32(2, priority as u32)]),
45
+ Attr::u32(5, 1),
46
+ Attr::string(7, kind),
47
+ Attr::u32(10, 1),
48
+ ],
49
+ ));
50
+ }
51
+ let mut rule = |chain: &str, expressions| {
52
+ operations.push((
53
+ 6,
54
+ 0xc00,
55
+ vec![
56
+ Attr::string(1, table),
57
+ Attr::string(2, chain),
58
+ Attr::nested(4, expressions),
59
+ ],
60
+ ))
61
+ };
62
+ for chain in ["raw_pre", "raw_out"] {
63
+ let mut expressions = meta(16, vec![17]);
64
+ expressions.extend(payload(2, 44003_u16.to_be_bytes().to_vec(), true));
65
+ expressions.push(expr("notrack", vec![]));
66
+ rule(chain, expressions);
67
+ }
68
+ for chain in ["pre", "out"] {
69
+ for (port, destination, target) in [
70
+ (44001_u16, [10, 250, 0, 2], [203, 0, 113, 2]),
71
+ (44002, [203, 0, 113, 2], [10, 250, 0, 2]),
72
+ (44004, [10, 250, 0, 2], [192, 0, 2, 2]),
73
+ (44005, [203, 0, 113, 2], [10, 241, 0, 1]),
74
+ ] {
75
+ let mut expressions = meta(16, vec![17]);
76
+ expressions.extend(payload(16, destination.to_vec(), false));
77
+ expressions.extend(payload(2, port.to_be_bytes().to_vec(), true));
78
+ expressions.push(immediate(target.to_vec()));
79
+ expressions.push(expr(
80
+ "nat",
81
+ vec![
82
+ Attr::u32(1, 1),
83
+ Attr::u32(2, 2),
84
+ Attr::u32(3, 1),
85
+ Attr::u32(4, 1),
86
+ Attr::u32(7, 1),
87
+ ],
88
+ ));
89
+ rule(chain, expressions);
90
+ }
91
+ }
92
+ let mut socket = wire::Socket::open().unwrap();
93
+ let generation = socket.generation().unwrap();
94
+ socket.batch(generation, operations).unwrap();
95
+ }
96
+
97
+ fn bound(namespace: &str, address: &str) -> UdpSocket {
98
+ let address = address.to_owned();
99
+ let socket = in_namespace(namespace, move || UdpSocket::bind(address).unwrap());
100
+ socket
101
+ .set_read_timeout(Some(Duration::from_millis(250)))
102
+ .unwrap();
103
+ socket
104
+ }
105
+ fn probe(
106
+ source: &UdpSocket,
107
+ destination: &str,
108
+ target: &UdpSocket,
109
+ allowed: bool,
110
+ local: bool,
111
+ ) -> Option<SocketAddr> {
112
+ let result = source.send_to(b"probe", destination);
113
+ if !allowed && local {
114
+ assert_eq!(
115
+ result.unwrap_err().raw_os_error(),
116
+ Some(libc::EPERM),
117
+ "{destination}"
118
+ );
119
+ } else {
120
+ assert_eq!(result.unwrap(), 5, "{destination}");
121
+ }
122
+ match target.recv_from(&mut [0; 64]) {
123
+ Ok((size, sender)) => {
124
+ assert!(allowed, "unexpected packet to {destination}");
125
+ assert_eq!(size, 5);
126
+ Some(sender)
127
+ }
128
+ Err(error) => {
129
+ assert!(!allowed, "missing packet to {destination}: {error}");
130
+ assert!(matches!(
131
+ error.kind(),
132
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
133
+ ));
134
+ None
135
+ }
136
+ }
137
+ }
138
+
139
+ #[test]
140
+ #[ignore = "requires disposable isolated native qualification guest"]
141
+ fn allocation_pool_guard_covers_all_host_hooks_dnat_untracked_and_reply_direction() {
142
+ isolated();
143
+ for name in [SOURCE, HOST, PEER] {
144
+ ip(&["netns", "add", name]);
145
+ ns_ip(name, &["link", "set", "lo", "up"]);
146
+ in_namespace(name, || {
147
+ std::fs::write("/proc/sys/net/ipv4/ip_forward", "1").unwrap()
148
+ });
149
+ }
150
+ connect(
151
+ SOURCE,
152
+ "source",
153
+ "10.240.0.2/30",
154
+ HOST,
155
+ "router",
156
+ "10.240.0.1/30",
157
+ );
158
+ connect(
159
+ HOST,
160
+ "uplink",
161
+ "192.0.2.2/24",
162
+ PEER,
163
+ "underlay",
164
+ "192.0.2.3/24",
165
+ );
166
+ ns_ip(SOURCE, &["route", "add", "default", "via", "10.240.0.1"]);
167
+ ns_ip(PEER, &["route", "add", "default", "via", "192.0.2.2"]);
168
+ ns_ip(HOST, &["address", "add", "10.241.0.1/32", "dev", "lo"]);
169
+ for address in ["203.0.113.2", "10.250.0.2"] {
170
+ ns_ip(
171
+ PEER,
172
+ &["address", "add", &format!("{address}/32"), "dev", "lo"],
173
+ );
174
+ ns_ip(
175
+ HOST,
176
+ &["route", "add", &format!("{address}/32"), "via", "192.0.2.3"],
177
+ );
178
+ }
179
+ in_namespace(HOST, foreign_captures);
180
+ let source = socket(SOURCE, "10.240.0.2:10000");
181
+ let host = socket(HOST, "192.0.2.2:10000");
182
+ let host_pool = socket(HOST, "10.241.0.1:10000");
183
+ let public = socket(PEER, "203.0.113.2:42000");
184
+ let lan = socket(PEER, "192.0.2.3:42000");
185
+ // Controls exercise established flows before apply, plus fresh ones below.
186
+ roundtrip(&source, &public);
187
+ roundtrip(&source, &host);
188
+ roundtrip(&host_pool, &public);
189
+ roundtrip(&host, &lan);
190
+ let cases = [
191
+ ("10.250.0.2:42001", PEER, "10.250.0.2:42001"),
192
+ ("10.241.0.1:42002", HOST, "10.241.0.1:42002"),
193
+ ("10.250.0.2:44001", PEER, "203.0.113.2:44001"),
194
+ ("203.0.113.2:44002", PEER, "10.250.0.2:44002"),
195
+ ("10.250.0.2:44003", PEER, "10.250.0.2:44003"),
196
+ ("10.250.0.2:44004", HOST, "192.0.2.2:44004"),
197
+ ("203.0.113.2:44005", HOST, "10.241.0.1:44005"),
198
+ ];
199
+ let targets: Vec<_> = cases
200
+ .iter()
201
+ .map(|(_, ns, address)| bound(ns, address))
202
+ .collect();
203
+ let mut replies = Vec::new();
204
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
205
+ for sender in [&source, &host] {
206
+ replies.push(probe(sender, destination, target, true, false).unwrap());
207
+ }
208
+ }
209
+ // INPUT with NOTRACK is independent of OUTPUT/FORWARD controls.
210
+ let untracked_input = socket(HOST, "10.241.0.1:44003");
211
+ probe(&lan, "10.241.0.1:44003", &untracked_input, true, false);
212
+ // A reply to a public original destination can have a pool source before
213
+ // reverse DNAT in POSTROUTING. Both INPUT and FORWARD must deny that reply.
214
+ for (sender, receiver) in [(&source, replies[6]), (&host, replies[7])] {
215
+ targets[3].send_to(b"before", receiver).unwrap();
216
+ assert_eq!(sender.recv(&mut [0; 64]).unwrap(), 6);
217
+ }
218
+ let (owner, applied) = in_namespace(HOST, || {
219
+ let mut owner = Owner::new(options("pool_traffic")).unwrap();
220
+ let mut policy = egress::tests::pool_guard();
221
+ policy["scope"]["prefixes"]
222
+ .as_array_mut()
223
+ .unwrap()
224
+ .push(json!("10.250.0.0/16"));
225
+ let target = serde_json::from_value::<egress::Policy>(policy)
226
+ .unwrap()
227
+ .prepare()
228
+ .unwrap()
229
+ .into();
230
+ let applied = owner
231
+ .reconcile(Transition {
232
+ previous: None,
233
+ target,
234
+ })
235
+ .unwrap();
236
+ assert!(owner.inspect().enforced);
237
+ (owner, applied)
238
+ });
239
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
240
+ probe(&source, destination, target, false, false);
241
+ probe(&host, destination, target, false, true);
242
+ }
243
+ probe(&lan, "10.241.0.1:44003", &untracked_input, false, false);
244
+ for (sender, receiver) in [(&source, replies[6]), (&host, replies[7])] {
245
+ targets[3].send_to(b"after", receiver).unwrap();
246
+ assert!(sender.recv(&mut [0; 64]).is_err());
247
+ }
248
+ roundtrip(&source, &public);
249
+ roundtrip(&source, &host);
250
+ roundtrip(&host_pool, &public);
251
+ roundtrip(&host, &lan);
252
+ let fresh = socket(SOURCE, "10.240.0.2:10001");
253
+ roundtrip(&fresh, &public);
254
+ in_namespace(HOST, move || {
255
+ let mut owner = owner;
256
+ assert!(owner.inspect().enforced);
257
+ owner.release(applied).unwrap();
258
+ });
259
+ // Positive control after release proves the denials were this guard.
260
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
261
+ probe(&source, destination, target, true, false);
262
+ probe(&host, destination, target, true, false);
263
+ }
264
+ println!("POOL_GUARD_PACKET_PROOF input=true forward=true output=true current_dnat=true original_dnat=true untracked=true reply_current_source=true established_and_fresh_public=true management_lan=true release_control=true allocation_reusable=false");
265
+ }
@@ -21,6 +21,9 @@ mod packet_fixture;
21
21
  #[path = "owner_host_traffic_tests.rs"]
22
22
  mod host_traffic_tests;
23
23
 
24
+ #[path = "owner_poolguard_tests.rs"]
25
+ mod poolguard_tests;
26
+
24
27
  #[path = "owner_coexistence_tests.rs"]
25
28
  mod coexistence_tests;
26
29
 
@@ -266,3 +266,14 @@ pub fn verify_bound_interfaces<'a>(
266
266
  }
267
267
  Ok(())
268
268
  }
269
+
270
+ /// Mutation fence: the caller retains these exact links until cleanup completes.
271
+ pub fn verify_bound_interfaces_down<'a>(interfaces: impl Iterator<Item = BoundInterface<'a>>) -> Result<()> {
272
+ let mut socket = Socket::open_protocol(libc::NETLINK_ROUTE)?;
273
+ for binding in interfaces {
274
+ let link = read_link(&mut socket, binding.index)?;
275
+ matches(&link, &binding)?;
276
+ if link.flags & libc::IFF_UP as u32 != 0 {return Err(Error::Conflict);}
277
+ }
278
+ Ok(())
279
+ }
package/rust/src/wire.rs CHANGED
@@ -125,7 +125,7 @@ pub struct Socket {
125
125
 
126
126
  #[path = "wire.links.rs"]
127
127
  mod links;
128
- pub use links::{verify_bound_interfaces, verify_interfaces, BoundInterface};
128
+ pub use links::{verify_bound_interfaces, verify_bound_interfaces_down, verify_interfaces, BoundInterface};
129
129
  impl Socket {
130
130
  pub fn open() -> Result<Self> {
131
131
  Self::open_protocol(libc::NETLINK_NETFILTER)
@@ -192,6 +192,9 @@ impl Socket {
192
192
  Ok(self.sequence)
193
193
  }
194
194
  fn message(&mut self, kind: u16, flags: u16, attributes: &[Attr]) -> Result<(u32, Vec<u8>)> {
195
+ self.message_family(kind, flags, attributes, 1)
196
+ }
197
+ fn message_family(&mut self, kind: u16, flags: u16, attributes: &[Attr], family: u8) -> Result<(u32, Vec<u8>)> {
195
198
  let sequence = self.sequence()?;
196
199
  let batch = kind == 16 || kind == 17;
197
200
  let payload = encode_attrs(attributes);
@@ -204,7 +207,7 @@ impl Socket {
204
207
  bytes.extend(flags.to_ne_bytes());
205
208
  bytes.extend(sequence.to_ne_bytes());
206
209
  bytes.extend(self.port.to_ne_bytes());
207
- bytes.extend([if batch { 0 } else { 1 }, 0]);
210
+ bytes.extend([if batch { 0 } else { family }, 0]);
208
211
  bytes.extend(if batch { 10_u16 } else { 0 }.to_be_bytes());
209
212
  bytes.extend(payload);
210
213
  Ok((sequence, bytes))
@@ -287,8 +290,15 @@ impl Socket {
287
290
  input: Vec<Attr>,
288
291
  dump: bool,
289
292
  ) -> Result<Vec<Vec<Attr>>> {
293
+ self.query_family(operation, input, dump, 1)
294
+ }
295
+ /// Read Docker's IPv4 nft graph without claiming or mutating its table.
296
+ pub fn query_ipv4(&mut self, operation: u16, input: Vec<Attr>, dump: bool) -> Result<Vec<Vec<Attr>>> {
297
+ self.query_family(operation, input, dump, 2)
298
+ }
299
+ fn query_family(&mut self, operation: u16, input: Vec<Attr>, dump: bool, family: u8) -> Result<Vec<Vec<Attr>>> {
290
300
  let (sequence, bytes) =
291
- self.message(0x0a00 | operation, 1 | if dump { 0x300 } else { 0 }, &input)?;
301
+ self.message_family(0x0a00 | operation, 1 | if dump { 0x300 } else { 0 }, &input, family)?;
292
302
  self.send(&bytes)?;
293
303
  let until = Instant::now() + Duration::from_secs(3);
294
304
  let mut result = Vec::new();
@@ -325,6 +335,7 @@ impl Socket {
325
335
  if total > 2_097_152 || result.len() >= 4096 || message.payload.len() < 4 {
326
336
  return Err(Error::Protocol);
327
337
  }
338
+ if family == 2 && message.payload[0] != family {return Err(Error::Protocol);}
328
339
  result.push(attrs(&message.payload[4..])?);
329
340
  if !dump {
330
341
  return Ok(result);
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '1.5.0',
6
+ version: '1.7.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -0,0 +1,113 @@
1
+ import * as plugins from './plugins.js';
2
+ import { snapshot, ManagedNftablesError } from './classes.managednftables.js';
3
+ import type { IManagedDockerForwardingOptions, IManagedDockerForwardingStatus, IManagedDockerForwardingPolicy, IPreparedManagedDockerForwardingPolicy, IManagedDockerForwardingTransition, IAppliedManagedDockerForwardingPolicy, TManagedDockerForwardingCommands } from './managed.docker.types.js';
4
+
5
+ /** One node-level Docker DOCKER-USER contribution. Caller owns durable intents and handoff lifetimes. */
6
+ export class ManagedDockerForwarding {
7
+ readonly #options: Readonly<IManagedDockerForwardingOptions>;
8
+ #bridge: plugins.smartrust.RustBridge<TManagedDockerForwardingCommands> | null = null;
9
+ #state: 'new' | 'starting' | 'ready' | 'closing' | 'failed-owned' | 'stopped' = 'new';
10
+ #work: Promise<unknown> | null = null;
11
+ #close: Promise<void> | null = null;
12
+ #nativeMayOwn = false;
13
+
14
+ constructor(options: IManagedDockerForwardingOptions) {
15
+ this.#options = snapshot(options);
16
+ if (Object.keys(this.#options).some((key) => !['ownerId', 'instanceId', 'binaryPath', 'networkNamespaceFd'].includes(key))
17
+ || ![this.#options.ownerId, this.#options.instanceId].every((value) => typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value))
18
+ || this.#options.instanceId.length < 16
19
+ || this.#options.binaryPath !== undefined && (!plugins.path.isAbsolute(this.#options.binaryPath) || this.#options.binaryPath.includes('\0'))) {
20
+ throw new ManagedNftablesError('INVALID');
21
+ }
22
+ if (this.#options.networkNamespaceFd !== undefined && (process.platform !== 'linux'
23
+ || !Number.isInteger(this.#options.networkNamespaceFd) || this.#options.networkNamespaceFd < 0
24
+ || this.#options.networkNamespaceFd > 0x7fff_ffff)) throw new ManagedNftablesError('INVALID');
25
+ }
26
+
27
+ public get state(): string { return this.#state; }
28
+
29
+ /** Starts only the compiler/IPC owner. The returned idle status is not enforcement. */
30
+ public start(): Promise<IManagedDockerForwardingStatus> {
31
+ if (this.#state !== 'new') return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
32
+ this.#state = 'starting';
33
+ const work = (async () => {
34
+ if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) throw new ManagedNftablesError('UNSUPPORTED_PLATFORM');
35
+ const root = plugins.url.fileURLToPath(new URL('../', import.meta.url));
36
+ this.#bridge = new plugins.smartrust.RustBridge<TManagedDockerForwardingCommands>({
37
+ binaryName: 'smartnftables', binaryPath: this.#options.binaryPath
38
+ ?? plugins.path.join(root, 'dist_rust', `smartnftables_linux_${process.arch === 'x64' ? 'amd64' : 'arm64'}_musl`),
39
+ localPaths: [], searchSystemPath: false,
40
+ cliArgs: ['--docker-forwarding', ...(this.#options.networkNamespaceFd === undefined ? [] : ['--network-namespace-fd', '3'])],
41
+ inheritedFileDescriptors: this.#options.networkNamespaceFd === undefined ? undefined : [this.#options.networkNamespaceFd],
42
+ readyTimeoutMs: 3000, requestTimeoutMs: 30_000, maxPayloadSize: 262_144,
43
+ maxPendingRequestsByMethod: { prepareDockerForwarding: 1, openDockerForwarding: 1, reconcileDockerForwarding: 1, inspectDockerForwarding: 1, releaseDockerForwarding: 1, closeDockerForwarding: 1 },
44
+ });
45
+ this.#bridge.on('exit', () => {
46
+ if (!['closing', 'stopped'].includes(this.#state)) this.#state = 'failed-owned';
47
+ });
48
+ if (!await this.#bridge.spawn()) throw new ManagedNftablesError('UNAVAILABLE');
49
+ const { ownerId, instanceId } = this.#options;
50
+ const status = snapshot(await this.#bridge.sendCommand('openDockerForwarding', { ownerId, instanceId }));
51
+ if (this.#state !== 'starting') throw new ManagedNftablesError('UNAVAILABLE');
52
+ this.#state = 'ready'; return status;
53
+ })();
54
+ return this.#track(work);
55
+ }
56
+
57
+ public prepare(policy: IManagedDockerForwardingPolicy): Promise<IPreparedManagedDockerForwardingPolicy> {
58
+ return this.#invoke('prepareDockerForwarding', policy);
59
+ }
60
+ public reconcile(transition: IManagedDockerForwardingTransition): Promise<IAppliedManagedDockerForwardingPolicy> {
61
+ return this.#invoke('reconcileDockerForwarding', transition);
62
+ }
63
+ public inspect(): Promise<IManagedDockerForwardingStatus> { return this.#invoke('inspectDockerForwarding', {}); }
64
+ public release(expected: IAppliedManagedDockerForwardingPolicy): Promise<{ released: true }> {
65
+ return this.#invoke('releaseDockerForwarding', expected);
66
+ }
67
+
68
+ /** Join operations and exact contributed-rule deletion, then native termination. Keep affected handoff links down through cleanup. */
69
+ public close(): Promise<void> {
70
+ if (this.#close) return this.#close;
71
+ if (this.#state === 'stopped') return Promise.resolve();
72
+ this.#state = 'closing';
73
+ this.#close = (async () => {
74
+ await this.#work?.catch(() => undefined);
75
+ if (this.#bridge) {
76
+ if (this.#nativeMayOwn) {
77
+ const result = await this.#bridge.sendCommand('closeDockerForwarding', {});
78
+ if (result.released !== true) throw new ManagedNftablesError('PROTOCOL');
79
+ }
80
+ await this.#bridge.terminate({ gracePeriodMs: 1000 });
81
+ }
82
+ this.#bridge = null; this.#state = 'stopped';
83
+ })().catch(() => {
84
+ this.#state = 'failed-owned'; this.#close = null;
85
+ throw new ManagedNftablesError('CLEANUP_UNCONFIRMED');
86
+ });
87
+ return this.#close;
88
+ }
89
+
90
+ #invoke<K extends keyof TManagedDockerForwardingCommands>(method: K, params: TManagedDockerForwardingCommands[K]['params']): Promise<TManagedDockerForwardingCommands[K]['result']> {
91
+ if (!['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
92
+ let captured: TManagedDockerForwardingCommands[K]['params'];
93
+ try { captured = snapshot(params); } catch { return Promise.reject(new ManagedNftablesError('INVALID')); }
94
+ if (method === 'reconcileDockerForwarding' || method === 'releaseDockerForwarding') this.#nativeMayOwn = true;
95
+ return this.#track(this.#bridge.sendCommand(method, captured).then((result) => {
96
+ const capturedResult = snapshot(result);
97
+ if (method === 'releaseDockerForwarding') this.#nativeMayOwn = false;
98
+ if (this.#state !== 'closing' && (method === 'reconcileDockerForwarding' || method === 'releaseDockerForwarding'
99
+ || method === 'inspectDockerForwarding' && (capturedResult as IManagedDockerForwardingStatus).error === null)) this.#state = 'ready';
100
+ return capturedResult;
101
+ }));
102
+ }
103
+ #track<T>(work: Promise<T>): Promise<T> {
104
+ const owned = work.catch((error) => {
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');
108
+ });
109
+ this.#work = owned;
110
+ void owned.then(() => { this.#work = null; }, () => { this.#work = null; });
111
+ return owned;
112
+ }
113
+ }
@@ -7,7 +7,7 @@ export class ManagedNftablesError extends Error {
7
7
  }
8
8
 
9
9
  /** Capture inert input without evaluating getters, proxies, coercions or toJSON. */
10
- function snapshot<T>(input: T): T {
10
+ export function snapshot<T>(input: T): T {
11
11
  let count = 0;
12
12
  let stringBytes = 0;
13
13
  const string = (value: string): string => {
package/ts/index.ts CHANGED
@@ -62,3 +62,5 @@ export type {
62
62
  ISmartNftablesOptions,
63
63
  INftStatus,
64
64
  } from './nft.types.js';
65
+ export * from './managed.docker.types.js';
66
+ export * from './classes.manageddockerforwarding.js';
@@ -0,0 +1,49 @@
1
+ import type { IAppliedManagedNftPolicy, IManagedNftOptions } from './managed.types.js';
2
+ import type { IManagedNftPolicyV2, IManagedNftHostTransitScopeV2 } from './managed.egress.types.js';
3
+
4
+ export interface IManagedDockerForwardingOptions extends Omit<IManagedNftOptions, 'tableName'> {}
5
+ export interface IManagedDockerForwardingPolicy {
6
+ schemaVersion: 1;
7
+ revision: number;
8
+ /** Exact dedicated host barrier. This contribution never adopts or modifies its table. */
9
+ barrier: IAppliedManagedNftPolicy<IManagedNftPolicyV2 & { scope: IManagedNftHostTransitScopeV2 }>;
10
+ }
11
+ export interface IPreparedManagedDockerForwardingPolicy {
12
+ policy: IManagedDockerForwardingPolicy;
13
+ /** Exact nft frontend version captured during prepare and checked through recovery. */
14
+ backend: string;
15
+ digest: string;
16
+ }
17
+ export interface IManagedDockerForwardingIdentity {
18
+ ownerId: string;
19
+ instanceId: string;
20
+ bootId: string;
21
+ namespaceDevice: string;
22
+ namespaceInode: string;
23
+ }
24
+ export interface IAppliedManagedDockerForwardingPolicy {
25
+ receipt: { identity: IManagedDockerForwardingIdentity; backend: string; revision: number; digest: string };
26
+ prepared: IPreparedManagedDockerForwardingPolicy;
27
+ }
28
+ /** Persist before mutation. Keep both generations' exact handoff links down until reconciliation is confirmed. */
29
+ export interface IManagedDockerForwardingTransition {
30
+ previous: IAppliedManagedDockerForwardingPolicy | null;
31
+ target: IPreparedManagedDockerForwardingPolicy;
32
+ }
33
+ export interface IManagedDockerForwardingStatus {
34
+ identity: IManagedDockerForwardingIdentity;
35
+ state: 'idle' | 'applied' | 'failed-owned' | 'released';
36
+ /** Exact contribution and barrier inspected; does not attest the whole host path or durable fencing. */
37
+ present: boolean;
38
+ applied: IAppliedManagedDockerForwardingPolicy | null;
39
+ pending: IManagedDockerForwardingTransition | null;
40
+ error: string | null;
41
+ }
42
+ export type TManagedDockerForwardingCommands = {
43
+ prepareDockerForwarding: { params: IManagedDockerForwardingPolicy; result: IPreparedManagedDockerForwardingPolicy };
44
+ openDockerForwarding: { params: Pick<IManagedDockerForwardingOptions, 'ownerId' | 'instanceId'>; result: IManagedDockerForwardingStatus };
45
+ reconcileDockerForwarding: { params: IManagedDockerForwardingTransition; result: IAppliedManagedDockerForwardingPolicy };
46
+ inspectDockerForwarding: { params: Record<string, never>; result: IManagedDockerForwardingStatus };
47
+ releaseDockerForwarding: { params: IAppliedManagedDockerForwardingPolicy; result: { released: true } };
48
+ closeDockerForwarding: { params: Record<string, never>; result: { released: true } };
49
+ };
@@ -78,8 +78,17 @@ export interface IManagedNftHostTransitScopeV2 {
78
78
  snatAddress: string;
79
79
  }
80
80
 
81
+ /** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
82
+ * This has no link dependency or allow grants. It is not allocation-release or boot-order proof. */
83
+ export interface IManagedNftAllocationPoolGuardScopeV2 {
84
+ kind: 'allocationPoolGuard';
85
+ authorityDigest: string;
86
+ /** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
87
+ prefixes: string[];
88
+ }
89
+
81
90
  export interface IManagedNftPolicyV2 {
82
91
  schemaVersion: 2;
83
92
  revision: number;
84
- scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2;
93
+ scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2 | IManagedNftAllocationPoolGuardScopeV2;
85
94
  }