@push.rocks/smartnftables 1.6.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.
- package/changelog.md +6 -0
- package/dist_rust/smartnftables_linux_amd64_musl +0 -0
- package/dist_rust/smartnftables_linux_amd64_musl.tsrust-build.json +4 -4
- package/dist_rust/smartnftables_linux_arm64_musl +0 -0
- package/dist_rust/smartnftables_linux_arm64_musl.tsrust-build.json +4 -4
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/managed.egress.types.d.ts +9 -1
- package/package.json +1 -1
- package/readme.md +29 -1
- package/rust/src/egress.compile.rs +3 -0
- package/rust/src/egress.poolguard.rs +30 -0
- package/rust/src/egress.rs +27 -0
- package/rust/src/egress_tests.rs +85 -0
- package/rust/src/managed.rs +4 -0
- package/rust/src/owner_egress_tests.rs +1 -1
- package/rust/src/owner_egress_traffic_tests.rs +21 -10
- package/rust/src/owner_host_traffic_tests.rs +4 -10
- package/rust/src/owner_poolguard_tests.rs +265 -0
- package/rust/src/owner_tests.rs +3 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/managed.egress.types.ts +10 -1
package/changelog.md
CHANGED
|
Binary file
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "d220f9e5399c394467c212c9c29a01d56395bde551a7a1af7bd233ae313bbf21",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "1.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "1.7.0",
|
|
7
|
+
"gitCommit": "9f206a95e42e76d6a4a61ecce11cf5d02927fb41",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-14T02:54:17.256Z",
|
|
10
10
|
"tsrustVersion": "1.13.0",
|
|
11
11
|
"binary": "smartnftables",
|
|
12
12
|
"target": "linux_amd64_musl"
|
|
Binary file
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "tsrust.build-provenance.v2",
|
|
3
|
-
"binarySha256": "
|
|
3
|
+
"binarySha256": "a282a800a9782297af8e7862ef2b8abbbc1aefcc79d50b5d0f453feecc874e36",
|
|
4
4
|
"buildInfo": {
|
|
5
5
|
"projectName": "@push.rocks/smartnftables",
|
|
6
|
-
"projectVersion": "1.
|
|
7
|
-
"gitCommit": "
|
|
6
|
+
"projectVersion": "1.7.0",
|
|
7
|
+
"gitCommit": "9f206a95e42e76d6a4a61ecce11cf5d02927fb41",
|
|
8
8
|
"gitDirty": false,
|
|
9
|
-
"builtAt": "2026-09-
|
|
9
|
+
"builtAt": "2026-09-14T02:54:17.376Z",
|
|
10
10
|
"tsrustVersion": "1.13.0",
|
|
11
11
|
"binary": "smartnftables",
|
|
12
12
|
"target": "linux_arm64_musl"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@push.rocks/smartnftables',
|
|
6
|
-
version: '1.
|
|
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
|
};
|
|
9
9
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSwyQkFBMkI7SUFDakMsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLG1IQUFtSDtDQUNqSSxDQUFBIn0=
|
|
@@ -84,8 +84,16 @@ export interface IManagedNftHostTransitScopeV2 {
|
|
|
84
84
|
/** Exact current address present on uplink. No masquerade or default-route inference. */
|
|
85
85
|
snatAddress: string;
|
|
86
86
|
}
|
|
87
|
+
/** Host-wide IPv4 destination denial for caller-authenticated private allocation pools.
|
|
88
|
+
* This has no link dependency or allow grants. It is not allocation-release or boot-order proof. */
|
|
89
|
+
export interface IManagedNftAllocationPoolGuardScopeV2 {
|
|
90
|
+
kind: 'allocationPoolGuard';
|
|
91
|
+
authorityDigest: string;
|
|
92
|
+
/** Complete current pool list: 1–64 canonical, disjoint RFC1918 prefixes. */
|
|
93
|
+
prefixes: string[];
|
|
94
|
+
}
|
|
87
95
|
export interface IManagedNftPolicyV2 {
|
|
88
96
|
schemaVersion: 2;
|
|
89
97
|
revision: number;
|
|
90
|
-
scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2;
|
|
98
|
+
scope: IManagedNftRouterEgressScopeV2 | IManagedNftHostTransitScopeV2 | IManagedNftAllocationPoolGuardScopeV2;
|
|
91
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@push.rocks/smartnftables",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.",
|
|
6
6
|
"main": "dist_ts/index.js",
|
package/readme.md
CHANGED
|
@@ -168,12 +168,40 @@ conntrack/NAT state. This restriction also applies to private veth/TUN forwardin
|
|
|
168
168
|
The prepared/applied/transition/status interfaces accept the same policy type
|
|
169
169
|
parameter; existing callers default to the unchanged schema-v1 contract. V1
|
|
170
170
|
canonical digests and compiled bytes are preserved. V2 uses its own hash domain.
|
|
171
|
-
An owner cannot transition between v1 private, v2 router,
|
|
171
|
+
An owner cannot transition between v1 private, v2 router, v2 host transit, and v2
|
|
172
|
+
allocation-pool guard policy kinds.
|
|
172
173
|
|
|
173
174
|
| V2 scope | Required authority and behavior |
|
|
174
175
|
| --- | --- |
|
|
175
176
|
| `routerEgress` | Private `endpoints` and `rules`, one exact `links` binding per endpoint, a separate veth `handoff`, `protection`, and active `generations`. Private veth/TUN/local DNS and egress share one table so terminal private denial cannot override a separate egress table. |
|
|
176
177
|
| `hostTransit` | Exact handoff `link`/`allocations` pairs, complete `protection`, an explicit veth or Ethernet `uplink`, and its current `snatAddress`. It checks each handoff's leased source address and protocol/port range, default conntrack zone, direction, uplink, and protected destinations before outer SNAT. |
|
|
178
|
+
| `allocationPoolGuard` | An authenticated `authorityDigest` and complete current allocation-pool `prefixes`. Installs host-wide IPv4 destination denial before any handoff exists, without link, uplink or SNAT dependencies. |
|
|
179
|
+
|
|
180
|
+
`allocationPoolGuard` accepts 1–64 canonical, disjoint RFC1918 prefixes. Supply
|
|
181
|
+
the actual allocation pools, not the broader protected union containing management
|
|
182
|
+
LANs, resolvers and platform endpoints. It applies to IPv4 INPUT, FORWARD and OUTPUT
|
|
183
|
+
at filter priority 0, after normal destination NAT. It drops an original conntrack
|
|
184
|
+
destination in a pool. Reply-direction packets additionally check their current
|
|
185
|
+
source, then return; all remaining packets check their current destination. This
|
|
186
|
+
denies DNAT into or away from a pool and untracked current-destination traffic,
|
|
187
|
+
while permitting reverse-SNAT replies to authorized transit sources. It grants no
|
|
188
|
+
egress permissions; exact `hostTransit` and router policies remain separate owners.
|
|
189
|
+
Unrelated host traffic and IPv6 retain their existing behavior.
|
|
190
|
+
|
|
191
|
+
For example, a guard policy is `{ schemaVersion: 2, revision: 1, scope: {
|
|
192
|
+
kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16',
|
|
193
|
+
'10.241.0.0/16'] } }`. Hashing binds the supplied body, but does not authenticate
|
|
194
|
+
the authority or prove its completeness. An empty `hostTransit.handoffs` array
|
|
195
|
+
provides no host-wide denial and is not a substitute for this scope.
|
|
196
|
+
|
|
197
|
+
The caller must retain the authenticated authority, complete transition and applied
|
|
198
|
+
receipt, verify `inspect().enforced`, and qualify the complete packet path. This
|
|
199
|
+
guard alone proves neither early-boot/late-shutdown ordering nor protection after
|
|
200
|
+
kernel reboot. It does not fence foreign packet queues, later packet rewrites,
|
|
201
|
+
offloads or proxies, and supplies no quarantine release, conntrack drainage or
|
|
202
|
+
allocation-reuse evidence. Historical leases remain the caller's durable ownership
|
|
203
|
+
responsibility. PERSIST retains this table only while its network namespace exists;
|
|
204
|
+
`close()` still deletes it, so revoke any external protection claim before release.
|
|
177
205
|
|
|
178
206
|
Local bindings include name, index, kind, MAC (null for L3 TUN), interface-link
|
|
179
207
|
index, and required IPv4 addresses. RTM_GETLINK/RTM_GETADDR verify those local
|
|
@@ -4,6 +4,8 @@ use crate::wire::{self, Attr};
|
|
|
4
4
|
|
|
5
5
|
#[path = "egress.host.rs"]
|
|
6
6
|
mod host;
|
|
7
|
+
#[path = "egress.poolguard.rs"]
|
|
8
|
+
mod poolguard;
|
|
7
9
|
#[path = "egress.router.rs"]
|
|
8
10
|
mod router;
|
|
9
11
|
|
|
@@ -283,6 +285,7 @@ pub(super) fn compile(prepared: &Prepared, table: &str) -> Result<Vec<(u16, Vec<
|
|
|
283
285
|
router::compile(&mut program, scope, prepared.policy.revision)?
|
|
284
286
|
}
|
|
285
287
|
Scope::HostTransit(scope) => host::compile(&mut program, scope)?,
|
|
288
|
+
Scope::AllocationPoolGuard(scope) => poolguard::compile(&mut program, scope)?,
|
|
286
289
|
}
|
|
287
290
|
Ok(program.operations)
|
|
288
291
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn compile(program: &mut Program<'_>, scope: &AllocationPoolGuardScope) -> Result<()> {
|
|
4
|
+
for (name, hook) in [("input", 1), ("forward", 2), ("output", 3)] {
|
|
5
|
+
program.chain(name, Some((hook, 0, "filter")))?;
|
|
6
|
+
}
|
|
7
|
+
for name in ["pool_guard", "pool_reply"] {
|
|
8
|
+
program.chain(name, None)?;
|
|
9
|
+
}
|
|
10
|
+
for name in ["input", "forward", "output"] {
|
|
11
|
+
program.jump(name, ipv4(), "pool_guard")?;
|
|
12
|
+
}
|
|
13
|
+
// An earlier owner's DNAT must not conceal the original protected target.
|
|
14
|
+
for prefix in &scope.prefixes {
|
|
15
|
+
program.end("pool_guard", original_address(prefix, false)?, 0)?;
|
|
16
|
+
}
|
|
17
|
+
// GOTO, not JUMP: RETURN from pool_reply must return to the base chain,
|
|
18
|
+
// skipping current-destination denial for legitimate reverse-SNAT replies.
|
|
19
|
+
let mut reply = ct(1, None, vec![1]);
|
|
20
|
+
reply.push(verdict(-4, Some("pool_reply")));
|
|
21
|
+
program.rule("pool_guard", reply)?;
|
|
22
|
+
// A missing conntrack tuple/direction breaks only its rule. Untracked or
|
|
23
|
+
// invalid packets still reach this unconditional current-destination guard.
|
|
24
|
+
for prefix in &scope.prefixes {
|
|
25
|
+
program.end("pool_guard", current_address(prefix, false)?, 0)?;
|
|
26
|
+
program.end("pool_reply", current_address(prefix, true)?, 0)?;
|
|
27
|
+
}
|
|
28
|
+
program.end("pool_guard", vec![], -5)?;
|
|
29
|
+
program.end("pool_reply", vec![], -5)
|
|
30
|
+
}
|
package/rust/src/egress.rs
CHANGED
|
@@ -119,10 +119,17 @@ pub struct HostScope {
|
|
|
119
119
|
pub snat_address: String,
|
|
120
120
|
}
|
|
121
121
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
122
|
+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
123
|
+
pub struct AllocationPoolGuardScope {
|
|
124
|
+
pub authority_digest: String,
|
|
125
|
+
pub prefixes: Vec<String>,
|
|
126
|
+
}
|
|
127
|
+
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
122
128
|
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
|
|
123
129
|
pub enum Scope {
|
|
124
130
|
RouterEgress(RouterScope),
|
|
125
131
|
HostTransit(HostScope),
|
|
132
|
+
AllocationPoolGuard(AllocationPoolGuardScope),
|
|
126
133
|
}
|
|
127
134
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
128
135
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
@@ -532,6 +539,23 @@ fn normalize_router(value: &mut RouterScope, revision: u64) -> Result<()> {
|
|
|
532
539
|
)
|
|
533
540
|
}))
|
|
534
541
|
}
|
|
542
|
+
fn normalize_pool_guard(value: &mut AllocationPoolGuardScope) -> Result<()> {
|
|
543
|
+
require(
|
|
544
|
+
digest(&value.authority_digest) && !value.prefixes.is_empty() && value.prefixes.len() <= 64,
|
|
545
|
+
)?;
|
|
546
|
+
value.prefixes.sort();
|
|
547
|
+
for (index, item) in value.prefixes.iter().enumerate() {
|
|
548
|
+
require(
|
|
549
|
+
covers("10.0.0.0/8", item)?
|
|
550
|
+
|| covers("172.16.0.0/12", item)?
|
|
551
|
+
|| covers("192.168.0.0/16", item)?,
|
|
552
|
+
)?;
|
|
553
|
+
for previous in &value.prefixes[..index] {
|
|
554
|
+
require(!overlaps(item, previous)?)?;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
Ok(())
|
|
558
|
+
}
|
|
535
559
|
fn normalize_host(value: &mut HostScope) -> Result<()> {
|
|
536
560
|
normalize_protection(&mut value.protection)?;
|
|
537
561
|
normalize_link(&mut value.uplink)?;
|
|
@@ -642,6 +666,7 @@ impl Policy {
|
|
|
642
666
|
match &mut self.scope {
|
|
643
667
|
Scope::RouterEgress(scope) => normalize_router(scope, self.revision)?,
|
|
644
668
|
Scope::HostTransit(scope) => normalize_host(scope)?,
|
|
669
|
+
Scope::AllocationPoolGuard(scope) => normalize_pool_guard(scope)?,
|
|
645
670
|
}
|
|
646
671
|
Ok(self)
|
|
647
672
|
}
|
|
@@ -667,6 +692,8 @@ impl Prepared {
|
|
|
667
692
|
.map(|item| &item.link)
|
|
668
693
|
.chain(std::iter::once(&scope.uplink))
|
|
669
694
|
.collect(),
|
|
695
|
+
// No link/route dependency: this policy must apply before allocation.
|
|
696
|
+
Scope::AllocationPoolGuard(_) => return Ok(()),
|
|
670
697
|
};
|
|
671
698
|
crate::wire::verify_bound_interfaces(links.into_iter().map(|link| {
|
|
672
699
|
crate::wire::BoundInterface {
|
package/rust/src/egress_tests.rs
CHANGED
|
@@ -45,6 +45,11 @@ pub(crate) fn host() -> Value {
|
|
|
45
45
|
"handoffs":[{"link":link(4,"handoff","veth","10.240.0.1"),"allocations":[allocation()]}],
|
|
46
46
|
"uplink":link(2,"ens18","ethernet","192.0.2.2"),"snatAddress":"192.0.2.2"}})
|
|
47
47
|
}
|
|
48
|
+
pub(crate) fn pool_guard() -> Value {
|
|
49
|
+
json!({"schemaVersion":2,"revision":1,"scope":{"kind":"allocationPoolGuard",
|
|
50
|
+
"authorityDigest":format!("sha256:{}", "b".repeat(64)),
|
|
51
|
+
"prefixes":["10.241.0.0/16", "10.240.0.0/16"]}})
|
|
52
|
+
}
|
|
48
53
|
fn normalized(value: Value) -> super::Result<Policy> {
|
|
49
54
|
serde_json::from_value::<Policy>(value)
|
|
50
55
|
.map_err(|_| crate::Error::Invalid)?
|
|
@@ -330,3 +335,83 @@ fn managed_union_keeps_strict_duplicate_and_unknown_field_rejection() {
|
|
|
330
335
|
)
|
|
331
336
|
.is_err());
|
|
332
337
|
}
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn allocation_pool_guard_is_canonical_authority_bound_and_private_only() {
|
|
341
|
+
let prepared = normalized(pool_guard()).unwrap().prepare().unwrap();
|
|
342
|
+
let mut reordered = pool_guard();
|
|
343
|
+
reordered["scope"]["prefixes"]
|
|
344
|
+
.as_array_mut()
|
|
345
|
+
.unwrap()
|
|
346
|
+
.reverse();
|
|
347
|
+
assert_eq!(normalized(reordered).unwrap().prepare().unwrap(), prepared);
|
|
348
|
+
let mut changed_authority = pool_guard();
|
|
349
|
+
changed_authority["scope"]["authorityDigest"] = json!(format!("sha256:{}", "c".repeat(64)));
|
|
350
|
+
assert_ne!(
|
|
351
|
+
normalized(changed_authority)
|
|
352
|
+
.unwrap()
|
|
353
|
+
.prepare()
|
|
354
|
+
.unwrap()
|
|
355
|
+
.digest,
|
|
356
|
+
prepared.digest
|
|
357
|
+
);
|
|
358
|
+
for prefixes in [
|
|
359
|
+
json!([]),
|
|
360
|
+
json!(["10.0.0.1/8"]),
|
|
361
|
+
json!(["10.0.0.0/8", "10.1.0.0/16"]),
|
|
362
|
+
json!(["10.0.0.0/8", "10.0.0.0/8"]),
|
|
363
|
+
json!(["0.0.0.0/0"]),
|
|
364
|
+
json!(["192.0.2.0/24"]),
|
|
365
|
+
json!(["127.0.0.0/8"]),
|
|
366
|
+
json!(["172.0.0.0/8"]),
|
|
367
|
+
json!(["192.168.0.0/15"]),
|
|
368
|
+
json!(["10.0.0.0/08"]),
|
|
369
|
+
json!(["::/0"]),
|
|
370
|
+
] {
|
|
371
|
+
assert!(normalized(changed(pool_guard(), "/scope/prefixes", prefixes)).is_err());
|
|
372
|
+
}
|
|
373
|
+
let mut extra = pool_guard();
|
|
374
|
+
extra["scope"]["handoffs"] = json!([]);
|
|
375
|
+
assert!(normalized(extra).is_err());
|
|
376
|
+
assert!(normalized(changed(
|
|
377
|
+
pool_guard(),
|
|
378
|
+
"/scope/authorityDigest",
|
|
379
|
+
json!("sha256:bad")
|
|
380
|
+
))
|
|
381
|
+
.is_err());
|
|
382
|
+
assert!(normalized(changed(
|
|
383
|
+
pool_guard(),
|
|
384
|
+
"/scope/prefixes",
|
|
385
|
+
json!(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"])
|
|
386
|
+
))
|
|
387
|
+
.is_ok());
|
|
388
|
+
let duplicate = serde_json::to_string(&pool_guard())
|
|
389
|
+
.unwrap()
|
|
390
|
+
.replace("\"prefixes\":", "\"prefixes\":[],\"prefixes\":");
|
|
391
|
+
assert!(serde_json::from_str::<crate::managed::Policy>(&duplicate).is_err());
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[test]
|
|
395
|
+
fn allocation_pool_guard_maximum_fits_the_atomic_graph_without_link_bindings() {
|
|
396
|
+
let prefixes: Vec<_> = (0..64).map(|part| format!("10.{part}.0.0/16")).collect();
|
|
397
|
+
let prepared = normalized(changed(pool_guard(), "/scope/prefixes", json!(prefixes)))
|
|
398
|
+
.unwrap()
|
|
399
|
+
.prepare()
|
|
400
|
+
.unwrap();
|
|
401
|
+
prepared.validate().unwrap();
|
|
402
|
+
prepared.validate_interfaces().unwrap();
|
|
403
|
+
let program = prepared
|
|
404
|
+
.program(&format!("snft_{}", "x".repeat(59)))
|
|
405
|
+
.unwrap();
|
|
406
|
+
let bytes: usize = program
|
|
407
|
+
.iter()
|
|
408
|
+
.map(|(_, attrs)| crate::wire::encode_attrs(attrs).len() + 20)
|
|
409
|
+
.sum();
|
|
410
|
+
assert!(program.len() <= 768 && bytes <= 100_000);
|
|
411
|
+
println!(
|
|
412
|
+
"POOL_GUARD_MAX prefixes=64 operations={} bytes={bytes}",
|
|
413
|
+
program.len()
|
|
414
|
+
);
|
|
415
|
+
let excessive: Vec<_> = (0..65).map(|part| format!("10.{part}.0.0/16")).collect();
|
|
416
|
+
assert!(normalized(changed(pool_guard(), "/scope/prefixes", json!(excessive))).is_err());
|
|
417
|
+
}
|
package/rust/src/managed.rs
CHANGED
|
@@ -34,6 +34,10 @@ impl Policy {
|
|
|
34
34
|
scope: egress::Scope::HostTransit(_),
|
|
35
35
|
..
|
|
36
36
|
}) => "hostTransitV2",
|
|
37
|
+
Self::Egress(egress::Policy {
|
|
38
|
+
scope: egress::Scope::AllocationPoolGuard(_),
|
|
39
|
+
..
|
|
40
|
+
}) => "allocationPoolGuardV2",
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
pub fn prepare(self) -> Result<Prepared> {
|
|
@@ -62,7 +62,7 @@ fn host() -> egress::Policy {
|
|
|
62
62
|
value["scope"]["uplink"] = binding("uplink", "192.0.2.2", "veth");
|
|
63
63
|
serde_json::from_value(value).unwrap()
|
|
64
64
|
}
|
|
65
|
-
fn recover(name: &str, policy: egress::Policy) {
|
|
65
|
+
pub(super) fn recover(name: &str, policy: egress::Policy) {
|
|
66
66
|
let prepared: Prepared = policy.clone().prepare().unwrap().into();
|
|
67
67
|
prepared.validate_interfaces().expect("fixture bindings");
|
|
68
68
|
let mut owner = Owner::new(options(name)).unwrap();
|
|
@@ -205,6 +205,18 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
|
|
|
205
205
|
ns_ip(ROUTER, &["address", "add", "10.242.0.1/24", "dev", "vpn"]);
|
|
206
206
|
ns_ip(ROUTER, &["link", "set", "vpn", "up"]);
|
|
207
207
|
let mut device = in_namespace(ROUTER, tun);
|
|
208
|
+
let (pool_owner, pool_applied) = in_namespace(HOST, || {
|
|
209
|
+
let mut owner = Owner::new(options("pool_coexistence")).unwrap();
|
|
210
|
+
let policy: egress::Policy = serde_json::from_value(egress::tests::pool_guard()).unwrap();
|
|
211
|
+
let applied = owner
|
|
212
|
+
.reconcile(Transition {
|
|
213
|
+
previous: None,
|
|
214
|
+
target: policy.prepare().unwrap().into(),
|
|
215
|
+
})
|
|
216
|
+
.unwrap();
|
|
217
|
+
assert!(owner.inspect().enforced);
|
|
218
|
+
(owner, applied)
|
|
219
|
+
});
|
|
208
220
|
let (host_owner, host_applied) = in_namespace(HOST, || {
|
|
209
221
|
let mut owner = Owner::new(options("host_packets")).unwrap();
|
|
210
222
|
let applied = owner
|
|
@@ -260,11 +272,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
|
|
|
260
272
|
});
|
|
261
273
|
let listener = in_namespace(PEER, || TcpListener::bind("192.0.2.3:3000").unwrap());
|
|
262
274
|
let mut client = in_namespace(WORK, || {
|
|
263
|
-
TcpStream::connect_timeout(
|
|
264
|
-
|
|
265
|
-
Duration::from_secs(1),
|
|
266
|
-
)
|
|
267
|
-
.unwrap()
|
|
275
|
+
TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
|
|
276
|
+
.unwrap()
|
|
268
277
|
});
|
|
269
278
|
let (mut server, peer) = listener.accept().unwrap();
|
|
270
279
|
assert_eq!(peer.ip().to_string(), "192.0.2.2");
|
|
@@ -290,11 +299,8 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
|
|
|
290
299
|
assert_eq!(syn[33] & 0xc0, 0xc0);
|
|
291
300
|
assert!((10016..=10031).contains(&u16::from_be_bytes(syn[20..22].try_into().unwrap())));
|
|
292
301
|
let mut local_tcp = in_namespace(ROUTER, || {
|
|
293
|
-
TcpStream::connect_timeout(
|
|
294
|
-
|
|
295
|
-
Duration::from_secs(1),
|
|
296
|
-
)
|
|
297
|
-
.unwrap()
|
|
302
|
+
TcpStream::connect_timeout(&"192.0.2.3:3000".parse().unwrap(), Duration::from_secs(1))
|
|
303
|
+
.unwrap()
|
|
298
304
|
});
|
|
299
305
|
let (mut local_peer, local_outer) = listener.accept().unwrap();
|
|
300
306
|
assert!((10016..=10031).contains(&local_outer.port()));
|
|
@@ -466,5 +472,10 @@ fn v2_four_namespace_private_tun_local_dns_egress_and_revocation() {
|
|
|
466
472
|
let mut owner = host_owner;
|
|
467
473
|
owner.release(host_applied).unwrap();
|
|
468
474
|
});
|
|
475
|
+
in_namespace(HOST, move || {
|
|
476
|
+
let mut owner = pool_owner;
|
|
477
|
+
assert!(owner.inspect().enforced);
|
|
478
|
+
owner.release(pool_applied).unwrap();
|
|
479
|
+
});
|
|
469
480
|
println!("V2_PACKET_PROOF private_dns=true tun_private=true tun_public_denied=true workload_public=true local_dns_and_tcp=true handoff_udp_tcp_ranges=true ecn_syn=true opening_ack_rst_denied=true fragments_checked=true range_exhaustion_denied=true protected_grant_denied=true ungranted_local_denied=true retired_replies_denied=true revoked_egress_denied=true disjoint_generation_restored=true flows_drained=false reusable=false");
|
|
470
481
|
}
|
|
@@ -7,7 +7,7 @@ const SOURCE: &str = "hs_source";
|
|
|
7
7
|
const HOST: &str = "hs_host";
|
|
8
8
|
const PEER: &str = "hs_peer";
|
|
9
9
|
|
|
10
|
-
fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
|
|
10
|
+
pub(super) fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
|
|
11
11
|
vec![
|
|
12
12
|
expr(
|
|
13
13
|
"payload",
|
|
@@ -21,7 +21,7 @@ fn payload(offset: u32, bytes: Vec<u8>, transport: bool) -> Vec<Attr> {
|
|
|
21
21
|
compare(bytes),
|
|
22
22
|
]
|
|
23
23
|
}
|
|
24
|
-
fn immediate(value: Vec<u8>) -> Attr {
|
|
24
|
+
pub(super) fn immediate(value: Vec<u8>) -> Attr {
|
|
25
25
|
expr(
|
|
26
26
|
"immediate",
|
|
27
27
|
vec![
|
|
@@ -196,10 +196,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
|
|
|
196
196
|
HOST,
|
|
197
197
|
&["route", "add", "203.0.113.2/32", "via", "192.0.2.3"],
|
|
198
198
|
);
|
|
199
|
-
ns_ip(
|
|
200
|
-
HOST,
|
|
201
|
-
&["route", "add", "10.250.0.2/32", "via", "192.0.2.3"],
|
|
202
|
-
);
|
|
199
|
+
ns_ip(HOST, &["route", "add", "10.250.0.2/32", "via", "192.0.2.3"]);
|
|
203
200
|
for address in ["203.0.113.2/32", "10.250.0.2/32"] {
|
|
204
201
|
ns_ip(PEER, &["address", "add", address, "dev", "lo"]);
|
|
205
202
|
}
|
|
@@ -285,10 +282,7 @@ fn v2_host_rejects_foreign_dnat_zone_local_diversion_and_ipv6() {
|
|
|
285
282
|
control(source, destination, target, false);
|
|
286
283
|
}
|
|
287
284
|
let allowed = socket(SOURCE, "10.240.0.2:10000");
|
|
288
|
-
assert_eq!(
|
|
289
|
-
roundtrip(&allowed, &public).ip().to_string(),
|
|
290
|
-
"192.0.2.2"
|
|
291
|
-
);
|
|
285
|
+
assert_eq!(roundtrip(&allowed, &public).ip().to_string(), "192.0.2.2");
|
|
292
286
|
let platform = socket(PEER, "192.0.2.3:5300");
|
|
293
287
|
roundtrip(&allowed, &platform);
|
|
294
288
|
let unleased = socket(SOURCE, "10.240.0.2:9999");
|
|
@@ -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
|
+
}
|
package/rust/src/owner_tests.rs
CHANGED
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -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
|
}
|