@push.rocks/smartnftables 1.4.0 → 1.5.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 (41) hide show
  1. package/changelog.md +14 -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.managednftables.d.ts +7 -7
  8. package/dist_ts/classes.managednftables.js +1 -1
  9. package/dist_ts/index.d.ts +1 -0
  10. package/dist_ts/index.js +1 -1
  11. package/dist_ts/managed.egress.types.d.ts +91 -0
  12. package/dist_ts/managed.egress.types.js +2 -0
  13. package/dist_ts/managed.types.d.ts +20 -18
  14. package/package.json +2 -2
  15. package/readme.md +75 -4
  16. package/rust/src/egress.compile.rs +288 -0
  17. package/rust/src/egress.host.rs +103 -0
  18. package/rust/src/egress.router.rs +231 -0
  19. package/rust/src/egress.rs +682 -0
  20. package/rust/src/egress_tests.rs +332 -0
  21. package/rust/src/main.rs +3 -1
  22. package/rust/src/managed.rs +105 -0
  23. package/rust/src/owner.rs +96 -6
  24. package/rust/src/owner_coexistence_tests.rs +77 -0
  25. package/rust/src/owner_egress_identity_tests.rs +155 -0
  26. package/rust/src/owner_egress_tests.rs +153 -0
  27. package/rust/src/owner_egress_traffic_tests.rs +470 -0
  28. package/rust/src/owner_host_traffic_tests.rs +305 -0
  29. package/rust/src/owner_identity_tests.rs +169 -0
  30. package/rust/src/owner_link_tests.rs +119 -0
  31. package/rust/src/owner_packet_fixture.rs +200 -0
  32. package/rust/src/owner_tests.rs +33 -7
  33. package/rust/src/policy.rs +120 -81
  34. package/rust/src/tests.rs +22 -0
  35. package/rust/src/wire.links.rs +268 -0
  36. package/rust/src/wire.rs +3 -64
  37. package/ts/00_commitinfo_data.ts +1 -1
  38. package/ts/classes.managednftables.ts +12 -12
  39. package/ts/index.ts +1 -0
  40. package/ts/managed.egress.types.ts +85 -0
  41. package/ts/managed.types.ts +20 -16
@@ -0,0 +1,268 @@
1
+ use super::{attrs, one, text, Attr, Socket};
2
+ use crate::{Error, Result};
3
+ use std::collections::BTreeSet;
4
+ use std::net::Ipv4Addr;
5
+ use std::time::{Duration, Instant};
6
+
7
+ struct Link {
8
+ hardware: u16,
9
+ flags: u32,
10
+ attributes: Vec<Attr>,
11
+ }
12
+
13
+ /// Current local facts only. Peer namespaces and link-generation lifetimes remain caller-owned.
14
+ #[derive(Clone, Copy)]
15
+ pub struct BoundInterface<'a> {
16
+ pub index: u32,
17
+ pub name: &'a str,
18
+ pub kind: &'a str,
19
+ pub mac: Option<&'a str>,
20
+ pub link_index: u32,
21
+ pub addresses: &'a [String],
22
+ }
23
+
24
+ fn read_link(socket: &mut Socket, index: u32) -> Result<Link> {
25
+ let sequence = socket.sequence()?;
26
+ let mut request = Vec::new();
27
+ request.extend(32_u32.to_ne_bytes());
28
+ request.extend(18_u16.to_ne_bytes());
29
+ request.extend(1_u16.to_ne_bytes());
30
+ request.extend(sequence.to_ne_bytes());
31
+ request.extend(socket.port.to_ne_bytes());
32
+ request.extend([0; 4]);
33
+ request.extend(index.to_ne_bytes());
34
+ request.extend([0; 8]);
35
+ socket.send(&request)?;
36
+ let until = Instant::now() + Duration::from_secs(3);
37
+ loop {
38
+ for message in socket.receive(until)? {
39
+ if message.sequence != sequence {
40
+ continue;
41
+ }
42
+ if message.kind == 2 {
43
+ let error = i32::from_ne_bytes(
44
+ message
45
+ .payload
46
+ .get(..4)
47
+ .ok_or(Error::Protocol)?
48
+ .try_into()
49
+ .unwrap(),
50
+ );
51
+ if error != 0 {
52
+ return Err(Error::kernel(error));
53
+ }
54
+ } else if message.kind == 16 && message.payload.len() >= 16 {
55
+ if u32::from_ne_bytes(message.payload[4..8].try_into().unwrap()) != index {
56
+ return Err(Error::Conflict);
57
+ }
58
+ return Ok(Link {
59
+ hardware: u16::from_ne_bytes(message.payload[2..4].try_into().unwrap()),
60
+ flags: u32::from_ne_bytes(message.payload[8..12].try_into().unwrap()),
61
+ attributes: attrs(&message.payload[16..])?,
62
+ });
63
+ } else {
64
+ return Err(Error::Protocol);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ fn unbridged(attributes: &[Attr]) -> Result<()> {
70
+ if attributes.iter().any(|attr| attr.id() == 10)
71
+ && one(attributes, 10)?.value != 0_u32.to_ne_bytes()
72
+ {
73
+ return Err(Error::Conflict);
74
+ }
75
+ Ok(())
76
+ }
77
+ fn virtual_kind(attributes: &[Attr], kind: &str) -> Result<()> {
78
+ let info = attrs(&one(attributes, 18)?.value)?;
79
+ if text(&info, 1)? != kind {
80
+ return Err(Error::Conflict);
81
+ }
82
+ if kind == "tun" && one(&attrs(&one(&info, 2)?.value)?, 3)?.value != [1] {
83
+ return Err(Error::Conflict);
84
+ }
85
+ Ok(())
86
+ }
87
+ /// Preserve schema-v1's local name/index/kind/master checks exactly.
88
+ pub fn verify_interfaces<'a>(
89
+ interfaces: impl Iterator<Item = (u32, &'a str, &'a str)>,
90
+ ) -> Result<()> {
91
+ let mut socket = Socket::open_protocol(libc::NETLINK_ROUTE)?;
92
+ for (index, name, kind) in interfaces {
93
+ let link = read_link(&mut socket, index)?;
94
+ if text(&link.attributes, 3)? != name {
95
+ return Err(Error::Conflict);
96
+ }
97
+ unbridged(&link.attributes)?;
98
+ virtual_kind(&link.attributes, kind)?;
99
+ }
100
+ Ok(())
101
+ }
102
+ fn matches(link: &Link, binding: &BoundInterface<'_>) -> Result<()> {
103
+ if text(&link.attributes, 3)? != binding.name || link.flags & libc::IFF_LOOPBACK as u32 != 0 {
104
+ return Err(Error::Conflict);
105
+ }
106
+ unbridged(&link.attributes)?;
107
+ match binding.kind {
108
+ "veth" | "tun" => virtual_kind(&link.attributes, binding.kind)?,
109
+ "ethernet" => {
110
+ // Physical/VirtIO Ethernet has no IFLA_LINKINFO; bridge, VLAN, bond,
111
+ // dummy and other virtual kinds are not an implicit uplink role.
112
+ if link.attributes.iter().any(|item| item.id() == 18) {
113
+ return Err(Error::Conflict);
114
+ }
115
+ }
116
+ _ => return Err(Error::Invalid),
117
+ }
118
+ if binding.kind == "tun" {
119
+ if link.hardware != 65534 || binding.mac.is_some() {
120
+ return Err(Error::Conflict);
121
+ }
122
+ } else {
123
+ let mac = &one(&link.attributes, 1)?.value;
124
+ if link.hardware != 1
125
+ || mac.len() != 6
126
+ || mac[0] & 1 != 0
127
+ || mac.iter().all(|item| *item == 0)
128
+ {
129
+ return Err(Error::Conflict);
130
+ }
131
+ let actual = mac
132
+ .iter()
133
+ .map(|item| format!("{item:02x}"))
134
+ .collect::<Vec<_>>()
135
+ .join(":");
136
+ if binding.mac != Some(actual.as_str()) {
137
+ return Err(Error::Conflict);
138
+ }
139
+ }
140
+ let peer = if link.attributes.iter().any(|item| item.id() == 5) {
141
+ u32::from_ne_bytes(
142
+ one(&link.attributes, 5)?
143
+ .value
144
+ .as_slice()
145
+ .try_into()
146
+ .map_err(|_| Error::Protocol)?,
147
+ )
148
+ } else {
149
+ binding.index
150
+ };
151
+ if peer != binding.link_index {
152
+ return Err(Error::Conflict);
153
+ }
154
+ Ok(())
155
+ }
156
+ fn read_addresses(socket: &mut Socket, index: u32) -> Result<BTreeSet<String>> {
157
+ let sequence = socket.sequence()?;
158
+ let mut request = Vec::new();
159
+ request.extend(24_u32.to_ne_bytes());
160
+ request.extend(22_u16.to_ne_bytes());
161
+ request.extend(0x301_u16.to_ne_bytes());
162
+ request.extend(sequence.to_ne_bytes());
163
+ request.extend(socket.port.to_ne_bytes());
164
+ request.extend([libc::AF_INET as u8, 0, 0, 0]);
165
+ request.extend(index.to_ne_bytes());
166
+ socket.send(&request)?;
167
+ let until = Instant::now() + Duration::from_secs(3);
168
+ let mut result = BTreeSet::new();
169
+ let mut total = 0;
170
+ let mut count = 0;
171
+ loop {
172
+ for message in socket.receive(until)? {
173
+ if message.sequence != sequence {
174
+ continue;
175
+ }
176
+ if message.flags & 0x10 != 0 {
177
+ return Err(Error::Conflict);
178
+ }
179
+ if message.kind == 2 || message.kind == 3 {
180
+ if message.kind == 2 || !message.payload.is_empty() {
181
+ let error = i32::from_ne_bytes(
182
+ message
183
+ .payload
184
+ .get(..4)
185
+ .ok_or(Error::Protocol)?
186
+ .try_into()
187
+ .unwrap(),
188
+ );
189
+ if error != 0 {
190
+ return Err(Error::kernel(error));
191
+ }
192
+ }
193
+ if message.kind == 3 {
194
+ return Ok(result);
195
+ }
196
+ continue;
197
+ }
198
+ count += 1;
199
+ total += message.payload.len();
200
+ if message.kind != 20 || message.payload.len() < 8 || count > 4096 || total > 2_097_152
201
+ {
202
+ return Err(Error::Protocol);
203
+ }
204
+ if message.payload[0] != libc::AF_INET as u8 {
205
+ return Err(Error::Protocol);
206
+ }
207
+ if u32::from_ne_bytes(message.payload[4..8].try_into().unwrap()) != index {
208
+ continue;
209
+ }
210
+ let data = attrs(&message.payload[8..])?;
211
+ let local = if data.iter().any(|item| item.id() == 2) {
212
+ one(&data, 2)?
213
+ } else {
214
+ one(&data, 1)?
215
+ };
216
+ let bytes: [u8; 4] = local
217
+ .value
218
+ .as_slice()
219
+ .try_into()
220
+ .map_err(|_| Error::Protocol)?;
221
+ let flags = if data.iter().any(|item| item.id() == 8) {
222
+ u32::from_ne_bytes(
223
+ one(&data, 8)?
224
+ .value
225
+ .as_slice()
226
+ .try_into()
227
+ .map_err(|_| Error::Protocol)?,
228
+ )
229
+ } else {
230
+ u32::from(message.payload[2])
231
+ };
232
+ // Tentative, failed or deprecated source addresses do not qualify.
233
+ if flags & (0x40 | 0x08 | 0x20) != 0 {
234
+ continue;
235
+ }
236
+ if data.iter().any(|item| item.id() == 6) {
237
+ let cache = &one(&data, 6)?.value;
238
+ if cache.len() != 16 {
239
+ return Err(Error::Protocol);
240
+ }
241
+ if u32::from_ne_bytes(cache[..4].try_into().unwrap()) == 0
242
+ || u32::from_ne_bytes(cache[4..8].try_into().unwrap()) == 0
243
+ {
244
+ continue;
245
+ }
246
+ }
247
+ result.insert(Ipv4Addr::from(bytes).to_string());
248
+ }
249
+ }
250
+ }
251
+ pub fn verify_bound_interfaces<'a>(
252
+ interfaces: impl Iterator<Item = BoundInterface<'a>>,
253
+ ) -> Result<()> {
254
+ let mut socket = Socket::open_protocol(libc::NETLINK_ROUTE)?;
255
+ for binding in interfaces {
256
+ matches(&read_link(&mut socket, binding.index)?, &binding)?;
257
+ let addresses = read_addresses(&mut socket, binding.index)?;
258
+ if binding
259
+ .addresses
260
+ .iter()
261
+ .any(|address| !addresses.contains(address))
262
+ {
263
+ return Err(Error::Conflict);
264
+ }
265
+ matches(&read_link(&mut socket, binding.index)?, &binding)?;
266
+ }
267
+ Ok(())
268
+ }
package/rust/src/wire.rs CHANGED
@@ -123,70 +123,9 @@ pub struct Socket {
123
123
  sequence: u32,
124
124
  }
125
125
 
126
- /// Read-only, namespace-local RTM_GETLINK qualification. This is not a public
127
- /// routing or link-creation escape; only the strict native compiler calls it.
128
- pub fn verify_interfaces<'a>(
129
- interfaces: impl Iterator<Item = (u32, &'a str, &'a str)>,
130
- ) -> Result<()> {
131
- let mut socket = Socket::open_protocol(libc::NETLINK_ROUTE)?;
132
- for (index, name, kind) in interfaces {
133
- let sequence = socket.sequence()?;
134
- let mut request = Vec::new();
135
- request.extend(32_u32.to_ne_bytes());
136
- request.extend(18_u16.to_ne_bytes());
137
- request.extend(1_u16.to_ne_bytes());
138
- request.extend(sequence.to_ne_bytes());
139
- request.extend(socket.port.to_ne_bytes());
140
- request.extend([0; 4]);
141
- request.extend(index.to_ne_bytes());
142
- request.extend([0; 8]);
143
- socket.send(&request)?;
144
- let until = Instant::now() + Duration::from_secs(3);
145
- let response = 'read: loop {
146
- for message in socket.receive(until)? {
147
- if message.sequence != sequence {
148
- continue;
149
- }
150
- if message.kind == 2 {
151
- let error = i32::from_ne_bytes(
152
- message
153
- .payload
154
- .get(..4)
155
- .ok_or(Error::Protocol)?
156
- .try_into()
157
- .unwrap(),
158
- );
159
- if error != 0 {
160
- return Err(Error::kernel(error));
161
- }
162
- } else if message.kind == 16 && message.payload.len() >= 16 {
163
- if u32::from_ne_bytes(message.payload[4..8].try_into().unwrap()) != index {
164
- return Err(Error::Conflict);
165
- }
166
- break 'read attrs(&message.payload[16..])?;
167
- } else {
168
- return Err(Error::Protocol);
169
- }
170
- }
171
- };
172
- if text(&response, 3)? != name {
173
- return Err(Error::Conflict);
174
- }
175
- if response.iter().any(|attr| attr.id() == 10)
176
- && one(&response, 10)?.value != 0_u32.to_ne_bytes()
177
- {
178
- return Err(Error::Conflict);
179
- }
180
- let info = attrs(&one(&response, 18)?.value)?;
181
- if text(&info, 1)? != kind {
182
- return Err(Error::Conflict);
183
- }
184
- if kind == "tun" && one(&attrs(&one(&info, 2)?.value)?, 3)?.value != [1] {
185
- return Err(Error::Conflict);
186
- }
187
- }
188
- Ok(())
189
- }
126
+ #[path = "wire.links.rs"]
127
+ mod links;
128
+ pub use links::{verify_bound_interfaces, verify_interfaces, BoundInterface};
190
129
  impl Socket {
191
130
  pub fn open() -> Result<Self> {
192
131
  Self::open_protocol(libc::NETLINK_NETFILTER)
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartnftables',
6
- version: '1.4.0',
6
+ version: '1.5.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,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IManagedNftOptions, IManagedNftPolicy, IManagedNftTransition, IAppliedManagedNftPolicy,
3
- IPreparedManagedNftPolicy, IManagedNftStatus, TManagedNftCommands } from './managed.types.js';
3
+ IPreparedManagedNftPolicy, IManagedNftStatus, TManagedNftCommands, TManagedNftPolicy } from './managed.types.js';
4
4
 
5
5
  export class ManagedNftablesError extends Error {
6
6
  constructor(public readonly code: string) { super(`Managed nftables ${code}.`); }
@@ -43,9 +43,9 @@ function snapshot<T>(input: T): T {
43
43
  }
44
44
 
45
45
  /** One private native/netlink owner. Persistence and workload link lifetimes belong to its caller. */
46
- export class ManagedNftables {
46
+ export class ManagedNftables<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
47
47
  readonly #options: Readonly<IManagedNftOptions>;
48
- #bridge: plugins.smartrust.RustBridge<TManagedNftCommands> | null = null;
48
+ #bridge: plugins.smartrust.RustBridge<TManagedNftCommands<TPolicy>> | null = null;
49
49
  #state: 'new' | 'starting' | 'ready' | 'closing' | 'failed-owned' | 'stopped' = 'new';
50
50
  #work: Promise<unknown> | null = null;
51
51
  #close: Promise<void> | null = null;
@@ -67,13 +67,13 @@ export class ManagedNftables {
67
67
  public get state(): string { return this.#state; }
68
68
 
69
69
  /** Starts only the compiler/IPC owner. The returned idle status is not enforcement. */
70
- public start(): Promise<IManagedNftStatus> {
70
+ public start(): Promise<IManagedNftStatus<TPolicy>> {
71
71
  if (this.#state !== 'new') return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
72
72
  this.#state = 'starting';
73
73
  const work = (async () => {
74
74
  if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) throw new ManagedNftablesError('UNSUPPORTED_PLATFORM');
75
75
  const root = plugins.url.fileURLToPath(new URL('../', import.meta.url));
76
- this.#bridge = new plugins.smartrust.RustBridge<TManagedNftCommands>({
76
+ this.#bridge = new plugins.smartrust.RustBridge<TManagedNftCommands<TPolicy>>({
77
77
  binaryName: 'smartnftables', binaryPath: this.#options.binaryPath
78
78
  ?? plugins.path.join(root, 'dist_rust', `smartnftables_linux_${process.arch === 'x64' ? 'amd64' : 'arm64'}_musl`),
79
79
  localPaths: [], searchSystemPath: false,
@@ -94,14 +94,14 @@ export class ManagedNftables {
94
94
  return this.#track(work);
95
95
  }
96
96
 
97
- public prepare(policy: IManagedNftPolicy): Promise<IPreparedManagedNftPolicy> {
97
+ public prepare(policy: TPolicy): Promise<IPreparedManagedNftPolicy<TPolicy>> {
98
98
  return this.#invoke('preparePolicy', policy);
99
99
  }
100
- public reconcile(transition: IManagedNftTransition): Promise<IAppliedManagedNftPolicy> {
100
+ public reconcile(transition: IManagedNftTransition<TPolicy>): Promise<IAppliedManagedNftPolicy<TPolicy>> {
101
101
  return this.#invoke('reconcilePolicy', transition);
102
102
  }
103
- public inspect(): Promise<IManagedNftStatus> { return this.#invoke('inspectPolicy', {}); }
104
- public release(expected: IAppliedManagedNftPolicy): Promise<{ released: true }> {
103
+ public inspect(): Promise<IManagedNftStatus<TPolicy>> { return this.#invoke('inspectPolicy', {}); }
104
+ public release(expected: IAppliedManagedNftPolicy<TPolicy>): Promise<{ released: true }> {
105
105
  return this.#invoke('releasePolicy', expected);
106
106
  }
107
107
 
@@ -127,16 +127,16 @@ export class ManagedNftables {
127
127
  return this.#close;
128
128
  }
129
129
 
130
- #invoke<K extends keyof TManagedNftCommands>(method: K, params: TManagedNftCommands[K]['params']): Promise<TManagedNftCommands[K]['result']> {
130
+ #invoke<K extends keyof TManagedNftCommands<TPolicy>>(method: K, params: TManagedNftCommands<TPolicy>[K]['params']): Promise<TManagedNftCommands<TPolicy>[K]['result']> {
131
131
  if (!['ready', 'failed-owned'].includes(this.#state) || !this.#bridge || this.#work) return Promise.reject(new ManagedNftablesError('UNAVAILABLE'));
132
- let captured: TManagedNftCommands[K]['params'];
132
+ let captured: TManagedNftCommands<TPolicy>[K]['params'];
133
133
  try { captured = snapshot(params); } catch { return Promise.reject(new ManagedNftablesError('INVALID')); }
134
134
  if (method === 'reconcilePolicy' || method === 'releasePolicy') this.#nativeMayOwn = true;
135
135
  return this.#track(this.#bridge.sendCommand(method, captured).then((result) => {
136
136
  const capturedResult = snapshot(result);
137
137
  if (method === 'releasePolicy') this.#nativeMayOwn = false;
138
138
  if (this.#state !== 'closing' && (method === 'reconcilePolicy' || method === 'releasePolicy'
139
- || method === 'inspectPolicy' && (capturedResult as IManagedNftStatus).error === null)) this.#state = 'ready';
139
+ || method === 'inspectPolicy' && (capturedResult as IManagedNftStatus<TPolicy>).error === null)) this.#state = 'ready';
140
140
  return capturedResult;
141
141
  }));
142
142
  }
package/ts/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export { SmartNftables } from './nft.manager.js';
3
3
  export { ManagedNftables, ManagedNftablesError } from './classes.managednftables.js';
4
4
  export type * from './managed.types.js';
5
+ export type * from './managed.egress.types.js';
5
6
 
6
7
  // Sub-managers
7
8
  export { NatManager } from './nft.manager.nat.js';
@@ -0,0 +1,85 @@
1
+ import type { IManagedNftEndpoint, IManagedNftRule } from './managed.types.js';
2
+
3
+ /** Namespace-local facts, checked through rtnetlink. The caller retains actual link-generation ownership. */
4
+ export interface IManagedNftLocalLinkV2 {
5
+ interfaceIndex: number;
6
+ interfaceName: string;
7
+ interfaceKind: 'veth' | 'tun' | 'ethernet';
8
+ /** Canonical unicast Ethernet address, or null for an L3 TUN. */
9
+ macAddress: string | null;
10
+ interfaceLinkIndex: number;
11
+ requiredIpv4Addresses: string[];
12
+ }
13
+
14
+ export interface IManagedNftPlatformEndpointV2 {
15
+ id: string;
16
+ address: string;
17
+ protocol: 'tcp' | 'udp';
18
+ port: number;
19
+ }
20
+
21
+ /** Caller-authenticated complete protected authority; hashing does not attest completeness. */
22
+ export interface IManagedNftProtectionV2 {
23
+ authorityDigest: string;
24
+ prefixes: string[];
25
+ platformEndpoints: IManagedNftPlatformEndpointV2[];
26
+ }
27
+
28
+ export interface IManagedNftHandoffPortRangeV2 {
29
+ protocol: 'tcp' | 'udp';
30
+ first: number;
31
+ last: number;
32
+ }
33
+
34
+ export interface IManagedNftHandoffAllocationV2 {
35
+ lease: { id: string; generation: number; digest: string };
36
+ transitSourceAddress: string;
37
+ sourcePortRanges: IManagedNftHandoffPortRangeV2[];
38
+ }
39
+
40
+ export interface IManagedNftEgressGrantV2 {
41
+ /** A workload veth endpoint, or the actual router-local host for DNS/relay sockets. */
42
+ sourceEndpoint: string | null;
43
+ sourcePrefix: string;
44
+ destination: { kind: 'public'; prefix: string } | { kind: 'platform'; endpointId: string };
45
+ protocol: 'tcp' | 'udp';
46
+ sourcePort: number | null;
47
+ destinationPort: number | null;
48
+ /** One exact range from this generation's lease; selects the SNAT range for this grant. */
49
+ sourcePortRange: { first: number; last: number };
50
+ }
51
+
52
+ export interface IManagedNftEgressGenerationV2 extends IManagedNftHandoffAllocationV2 {
53
+ conntrackZone: number;
54
+ conntrackLabel: string;
55
+ grants: IManagedNftEgressGrantV2[];
56
+ }
57
+
58
+ /** One combined private/TUN/local/egress table; never compose a second ACCEPT over v1 terminal drops. */
59
+ export interface IManagedNftRouterEgressScopeV2 {
60
+ kind: 'routerEgress';
61
+ endpoints: IManagedNftEndpoint[];
62
+ rules: IManagedNftRule[];
63
+ /** One exact local binding for every private endpoint. */
64
+ links: IManagedNftLocalLinkV2[];
65
+ handoff: IManagedNftLocalLinkV2;
66
+ protection: IManagedNftProtectionV2;
67
+ /** Empty retains private/local enforcement while denying external egress. */
68
+ generations: IManagedNftEgressGenerationV2[];
69
+ }
70
+
71
+ /** Shared-host capture barrier and explicit outer SNAT; Docker forwarding remains a separate owner. */
72
+ export interface IManagedNftHostTransitScopeV2 {
73
+ kind: 'hostTransit';
74
+ protection: IManagedNftProtectionV2;
75
+ handoffs: Array<{ link: IManagedNftLocalLinkV2; allocations: IManagedNftHandoffAllocationV2[] }>;
76
+ uplink: IManagedNftLocalLinkV2;
77
+ /** Exact current address present on uplink. No masquerade or default-route inference. */
78
+ snatAddress: string;
79
+ }
80
+
81
+ export interface IManagedNftPolicyV2 {
82
+ schemaVersion: 2;
83
+ revision: number;
84
+ scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2;
85
+ }
@@ -1,3 +1,5 @@
1
+ import type { IManagedNftPolicyV2 } from './managed.egress.types.js';
2
+
1
3
  /** The caller owns these workload links and keeps them disabled until apply is confirmed. */
2
4
  export interface IManagedNftEndpoint {
3
5
  id: string;
@@ -26,8 +28,8 @@ export interface IManagedNftPolicy {
26
28
  rules: IManagedNftRule[];
27
29
  }
28
30
 
29
- export interface IPreparedManagedNftPolicy {
30
- policy: IManagedNftPolicy;
31
+ export interface IPreparedManagedNftPolicy<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
32
+ policy: TPolicy;
31
33
  digest: string;
32
34
  }
33
35
 
@@ -47,23 +49,23 @@ export interface IManagedNftReceipt {
47
49
  digest: string;
48
50
  }
49
51
 
50
- export interface IAppliedManagedNftPolicy {
52
+ export interface IAppliedManagedNftPolicy<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
51
53
  receipt: IManagedNftReceipt;
52
- prepared: IPreparedManagedNftPolicy;
54
+ prepared: IPreparedManagedNftPolicy<TPolicy>;
53
55
  }
54
56
 
55
57
  /** Persist this complete command before reconcile, then retain its complete result. */
56
- export interface IManagedNftTransition {
57
- previous: IAppliedManagedNftPolicy | null;
58
- target: IPreparedManagedNftPolicy;
58
+ export interface IManagedNftTransition<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
59
+ previous: IAppliedManagedNftPolicy<TPolicy> | null;
60
+ target: IPreparedManagedNftPolicy<TPolicy>;
59
61
  }
60
62
 
61
- export interface IManagedNftStatus {
63
+ export interface IManagedNftStatus<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> {
62
64
  identity: IManagedNftIdentity;
63
65
  state: 'idle' | 'applied' | 'failed-owned' | 'released';
64
66
  enforced: boolean;
65
- applied: IAppliedManagedNftPolicy | null;
66
- pending: IManagedNftTransition | null;
67
+ applied: IAppliedManagedNftPolicy<TPolicy> | null;
68
+ pending: IManagedNftTransition<TPolicy> | null;
67
69
  error: string | null;
68
70
  }
69
71
 
@@ -81,11 +83,13 @@ export interface IManagedNftOptions {
81
83
  networkNamespaceFd?: number;
82
84
  }
83
85
 
84
- export type TManagedNftCommands = {
85
- preparePolicy: { params: IManagedNftPolicy; result: IPreparedManagedNftPolicy };
86
- openOwner: { params: Pick<IManagedNftOptions, 'ownerId' | 'instanceId' | 'tableName'>; result: IManagedNftStatus };
87
- reconcilePolicy: { params: IManagedNftTransition; result: IAppliedManagedNftPolicy };
88
- inspectPolicy: { params: Record<string, never>; result: IManagedNftStatus };
89
- releasePolicy: { params: IAppliedManagedNftPolicy; result: { released: true } };
86
+ export type TManagedNftPolicy = IManagedNftPolicy | IManagedNftPolicyV2;
87
+
88
+ export type TManagedNftCommands<TPolicy extends TManagedNftPolicy = IManagedNftPolicy> = {
89
+ preparePolicy: { params: TPolicy; result: IPreparedManagedNftPolicy<TPolicy> };
90
+ openOwner: { params: Pick<IManagedNftOptions, 'ownerId' | 'instanceId' | 'tableName'>; result: IManagedNftStatus<TPolicy> };
91
+ reconcilePolicy: { params: IManagedNftTransition<TPolicy>; result: IAppliedManagedNftPolicy<TPolicy> };
92
+ inspectPolicy: { params: Record<string, never>; result: IManagedNftStatus<TPolicy> };
93
+ releasePolicy: { params: IAppliedManagedNftPolicy<TPolicy>; result: { released: true } };
90
94
  closeOwner: { params: Record<string, never>; result: { released: true } };
91
95
  };