@push.rocks/smartnftables 1.6.0 → 1.8.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.
@@ -0,0 +1,268 @@
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.detach(applied.clone()).unwrap();
238
+ assert_eq!(owner.inspect().state, "retained");
239
+ (owner, applied)
240
+ });
241
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
242
+ probe(&source, destination, target, false, false);
243
+ probe(&host, destination, target, false, true);
244
+ }
245
+ probe(&lan, "10.241.0.1:44003", &untracked_input, false, false);
246
+ for (sender, receiver) in [(&source, replies[6]), (&host, replies[7])] {
247
+ targets[3].send_to(b"after", receiver).unwrap();
248
+ assert!(sender.recv(&mut [0; 64]).is_err());
249
+ }
250
+ roundtrip(&source, &public);
251
+ roundtrip(&source, &host);
252
+ roundtrip(&host_pool, &public);
253
+ roundtrip(&host, &lan);
254
+ let fresh = socket(SOURCE, "10.240.0.2:10001");
255
+ roundtrip(&fresh, &public);
256
+ in_namespace(HOST, move || {
257
+ let mut owner = owner;
258
+ assert!(owner.inspect().enforced);
259
+ drop(owner);
260
+ Owner::new(options("pool_traffic")).unwrap().release(applied).unwrap();
261
+ });
262
+ // Positive control after release proves the denials were this guard.
263
+ for ((destination, _, _), target) in cases.iter().zip(&targets) {
264
+ probe(&source, destination, target, true, false);
265
+ probe(&host, destination, target, true, false);
266
+ }
267
+ 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");
268
+ }
@@ -21,6 +21,12 @@ 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
+
27
+ #[path = "owner_detach_tests.rs"]
28
+ mod detach_tests;
29
+
24
30
  #[path = "owner_coexistence_tests.rs"]
25
31
  mod coexistence_tests;
26
32
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '1.6.0',
6
+ version: '1.8.0',
7
7
  description: 'A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.'
8
8
  }
@@ -50,6 +50,9 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
50
50
  #work: Promise<unknown> | null = null;
51
51
  #close: Promise<void> | null = null;
52
52
  #nativeMayOwn = false;
53
+ #retaining = false;
54
+ #detachExpected: IAppliedManagedNftPolicy<TPolicy> | null = null;
55
+ #detach: Promise<{ detached: true }> | null = null;
53
56
 
54
57
  constructor(options: IManagedNftOptions) {
55
58
  this.#options = snapshot(options);
@@ -68,7 +71,7 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
68
71
 
69
72
  /** Starts only the compiler/IPC owner. The returned idle status is not enforcement. */
70
73
  public start(): Promise<IManagedNftStatus<TPolicy>> {
71
- if (this.#state !== 'new') return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
74
+ if (this.#state !== 'new' || this.#retaining) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
72
75
  this.#state = 'starting';
73
76
  const work = (async () => {
74
77
  if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) throw new ManagedNftablesError('UNSUPPORTED_PLATFORM');
@@ -80,7 +83,7 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
80
83
  cliArgs: ['--management', ...(this.#options.networkNamespaceFd === undefined ? [] : ['--network-namespace-fd', '3'])],
81
84
  inheritedFileDescriptors: this.#options.networkNamespaceFd === undefined ? undefined : [this.#options.networkNamespaceFd],
82
85
  readyTimeoutMs: 3000, requestTimeoutMs: 30_000, maxPayloadSize: 262_144,
83
- maxPendingRequestsByMethod: { preparePolicy: 1, openOwner: 1, reconcilePolicy: 1, inspectPolicy: 1, releasePolicy: 1, closeOwner: 1 },
86
+ maxPendingRequestsByMethod: { preparePolicy: 1, openOwner: 1, reconcilePolicy: 1, inspectPolicy: 1, releasePolicy: 1, detachPolicy: 1, closeOwner: 1 },
84
87
  });
85
88
  this.#bridge.on('exit', () => {
86
89
  if (!['closing', 'stopped'].includes(this.#state)) this.#state = 'failed-owned';
@@ -105,15 +108,42 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
105
108
  return this.#invoke('releasePolicy', expected);
106
109
  }
107
110
 
108
- /** Join policy operations and owned-table deletion, then native termination; packet/allocation fencing belongs to the caller. */
111
+ /** Permanently selects retention cleanup, verifies exact PERSIST retention, and
112
+ * joins the native process. Retain the full applied value before this call.
113
+ * After any failure close() only terminates; it never deletes the table. */
114
+ public detach(expected: IAppliedManagedNftPolicy<TPolicy>): Promise<{ detached: true }> {
115
+ this.#retaining = true;
116
+ let captured: IAppliedManagedNftPolicy<TPolicy>;
117
+ try {
118
+ captured = snapshot(expected);
119
+ if (this.#detachExpected && !plugins.util.isDeepStrictEqual(this.#detachExpected, captured)) throw new ManagedNftablesError('CONFLICT');
120
+ if (this.#detach) return this.#detach;
121
+ if (!['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work || this.#close) throw new ManagedNftablesError('UNAVAILABLE');
122
+ this.#detachExpected = captured;
123
+ } catch (error) { return Promise.reject(error instanceof ManagedNftablesError ? error : new ManagedNftablesError('INVALID')); }
124
+ const work = this.#track(this.#bridge.sendCommand('detachPolicy', captured).then((value) => {
125
+ const result = snapshot(value);
126
+ if (result?.detached !== true || Object.keys(result).length !== 1) throw new ManagedNftablesError('PROTOCOL');
127
+ this.#nativeMayOwn = false;
128
+ return result;
129
+ }));
130
+ const detached = (async () => { const result = await work; await this.close(); return result; })();
131
+ this.#detach = detached;
132
+ void detached.catch(() => { this.#detach = null; });
133
+ return detached;
134
+ }
135
+
136
+ /** Join policy operations, then delete and terminate unless detach selected retention.
137
+ * Packet/allocation fencing belongs to the caller; already-admitted close cannot be undone. */
109
138
  public close(): Promise<void> {
110
139
  if (this.#close) return this.#close;
111
140
  if (this.#state === 'stopped') return Promise.resolve();
141
+ const retaining = this.#retaining;
112
142
  this.#state = 'closing';
113
143
  this.#close = (async () => {
114
144
  await this.#work?.catch(() => undefined);
115
145
  if (this.#bridge) {
116
- if (this.#nativeMayOwn) {
146
+ if (this.#nativeMayOwn && !retaining) {
117
147
  const result = await this.#bridge.sendCommand('closeOwner', {});
118
148
  if (result.released !== true) throw new ManagedNftablesError('PROTOCOL');
119
149
  }
@@ -128,7 +158,7 @@ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPoli
128
158
  }
129
159
 
130
160
  #invoke<K extends keyof TManagedNftCommands<TPolicy>>(method: K, params: TManagedNftCommands<TPolicy>[K]['params']): Promise<TManagedNftCommands<TPolicy>[K]['result']> {
131
- if (!['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
161
+ if (this.#retaining || !['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
132
162
  let captured: TManagedNftCommands<TPolicy>[K]['params'];
133
163
  try { captured = snapshot(params); } catch { return Promise.reject(new ManagedNftablesError('INVALID')); }
134
164
  if (method === 'reconcilePolicy' || method === 'releasePolicy') this.#nativeMayOwn = true;
@@ -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
  }
@@ -62,7 +62,7 @@ export interface IManagedNftTransition<TPolicy extends TManagedNftPolicy = IMana
62
62
 
63
63
  export interface IManagedNftStatus<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
64
64
  identity: IManagedNftIdentity;
65
- state: 'idle' | 'applied' | 'failed-owned' | 'released';
65
+ state: 'idle' | 'applied' | 'failed-owned' | 'released' | 'retained';
66
66
  enforced: boolean;
67
67
  applied: IAppliedManagedNftPolicy<TPolicy> | null;
68
68
  pending: IManagedNftTransition<TPolicy> | null;
@@ -91,5 +91,6 @@ export type TManagedNftCommands<TPolicy extends TManagedNftPolicy = IManagedNftP
91
91
  reconcilePolicy: { params: IManagedNftTransition<TPolicy>; result: IAppliedManagedNftPolicy<TPolicy> };
92
92
  inspectPolicy: { params: Record<string, never>; result: IManagedNftStatus<TPolicy> };
93
93
  releasePolicy: { params: IAppliedManagedNftPolicy<TPolicy>; result: { released: true } };
94
+ detachPolicy: { params: IAppliedManagedNftPolicy<TPolicy>; result: { detached: true } };
94
95
  closeOwner: { params: Record<string, never>; result: { released: true } };
95
96
  };