@serfab/cadre-host 1.13.0 → 1.15.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/README.md +347 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +332 -524
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/join-output.d.ts +56 -0
- package/dist/bin/join-output.js +92 -0
- package/dist/bin/join-output.js.map +1 -0
- package/dist/bin/nat-output.d.ts +54 -0
- package/dist/bin/nat-output.js +138 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/bin/ui-access.d.ts +22 -0
- package/dist/bin/ui-access.js +44 -0
- package/dist/bin/ui-access.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +66 -0
- package/dist/hosted/claim-details.js +60 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +325 -0
- package/dist/hosted/hosted-node-service.js +678 -0
- package/dist/hosted/hosted-node-service.js.map +1 -0
- package/dist/hosted/hosted-node-store.d.ts +40 -0
- package/dist/hosted/hosted-node-store.js +126 -0
- package/dist/hosted/hosted-node-store.js.map +1 -0
- package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
- package/dist/hosted/hosted-node-supervisor.js +396 -0
- package/dist/hosted/hosted-node-supervisor.js.map +1 -0
- package/dist/hosted/hosted-node-watcher.d.ts +95 -0
- package/dist/hosted/hosted-node-watcher.js +260 -0
- package/dist/hosted/hosted-node-watcher.js.map +1 -0
- package/dist/hosted/index.d.ts +16 -0
- package/dist/hosted/index.js +13 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/node-status.d.ts +51 -0
- package/dist/hosted/node-status.js +77 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +141 -0
- package/dist/hosted/types.js +22 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +12 -24
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +27 -34
- package/dist/installer/config.js +46 -15
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +9 -19
- package/dist/installer/index.js +21 -85
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/paths.d.ts +0 -2
- package/dist/installer/paths.js +0 -4
- package/dist/installer/paths.js.map +1 -1
- package/dist/installer/wizard.d.ts +12 -9
- package/dist/installer/wizard.js +15 -14
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/address-resolver.d.ts +18 -17
- package/dist/nat/address-resolver.js +79 -25
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/nat/address-watch.d.ts +37 -0
- package/dist/nat/address-watch.js +84 -0
- package/dist/nat/address-watch.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +2 -1
- package/dist/nat/ddns/duckdns.js +5 -1
- package/dist/nat/ddns/duckdns.js.map +1 -1
- package/dist/nat/ddns/index.d.ts +6 -0
- package/dist/nat/ddns/index.js.map +1 -1
- package/dist/nat/index.d.ts +13 -9
- package/dist/nat/index.js +7 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +220 -77
- package/dist/nat/nat-service.js +653 -239
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +124 -29
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/network-mode.d.ts +19 -0
- package/dist/nat/network-mode.js +32 -0
- package/dist/nat/network-mode.js.map +1 -0
- package/dist/nat/port-mapper.d.ts +83 -79
- package/dist/nat/port-mapper.js +148 -189
- package/dist/nat/port-mapper.js.map +1 -1
- package/dist/nat/reachability.d.ts +32 -15
- package/dist/nat/reachability.js +110 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +98 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +32 -74
- package/dist/orchestrator/host-process-orchestrator.js +84 -233
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
- package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
- package/dist/orchestrator/identity-file.js.map +1 -0
- package/dist/orchestrator/index.d.ts +2 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +6 -6
- package/dist/orchestrator/node-identity.js +7 -7
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +5 -6
- package/dist/orchestrator/port-allocator.js +9 -11
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +3 -5
- package/dist/orchestrator/state-store.js +2 -10
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +33 -22
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/package-version.d.ts +1 -0
- package/dist/package-version.js +4 -0
- package/dist/package-version.js.map +1 -0
- package/dist/server/error-handler.d.ts +5 -25
- package/dist/server/error-handler.js +11 -74
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +7 -11
- package/dist/server/events/types.js +2 -1
- package/dist/server/events/types.js.map +1 -1
- package/dist/server/index.d.ts +11 -37
- package/dist/server/index.js +30 -48
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/hosted-nodes.d.ts +28 -0
- package/dist/server/routes/hosted-nodes.js +68 -0
- package/dist/server/routes/hosted-nodes.js.map +1 -0
- package/dist/server/routes/nat.d.ts +4 -1
- package/dist/server/routes/nat.js +13 -2
- package/dist/server/routes/nat.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +6 -20
- package/dist/server/routes/nodes.js +7 -86
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +3 -7
- package/dist/server/routes/settings.js +18 -8
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +3 -43
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/server.js +8 -8
- package/dist/server/settings-store.d.ts +2 -2
- package/dist/server/settings-store.js +2 -2
- package/dist/server/static.js +1 -2
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BHd8CWZA.css +1 -0
- package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
- package/dist/ui/assets/index-Ccr2GjPT.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/store.d.ts +1 -1
- package/dist/update/store.js +1 -1
- package/dist/update/types.d.ts +1 -1
- package/dist/update/types.js +1 -1
- package/package.json +5 -7
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +2 -2
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +111 -0
- package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +177 -203
- package/src/__tests__/orchestrator-ports.test.ts +124 -143
- package/src/__tests__/orchestrator-push.test.ts +138 -146
- package/src/__tests__/orchestrator.test.ts +1017 -1063
- package/src/__tests__/ui-access.test.ts +30 -0
- package/src/bin/host.ts +397 -572
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +188 -0
- package/src/bin/ui-access.ts +50 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +172 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +43 -23
- package/src/hosted/__tests__/hosted-node-service.test.ts +708 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +103 -0
- package/src/hosted/hosted-node-service.ts +814 -0
- package/src/hosted/hosted-node-store.ts +144 -0
- package/src/hosted/hosted-node-supervisor.ts +439 -0
- package/src/hosted/hosted-node-watcher.ts +288 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +97 -0
- package/src/hosted/types.ts +160 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +184 -263
- package/src/installer/__tests__/wizard.test.ts +25 -13
- package/src/installer/config.ts +201 -175
- package/src/installer/index.ts +252 -327
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +24 -26
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +608 -515
- package/src/nat/__tests__/nat-store.test.ts +49 -12
- package/src/nat/__tests__/network-mode.test.ts +38 -0
- package/src/nat/__tests__/port-mapper.test.ts +30 -191
- package/src/nat/__tests__/reachability.test.ts +96 -13
- package/src/nat/address-resolver.ts +94 -52
- package/src/nat/address-watch.ts +92 -0
- package/src/nat/ddns/duckdns.ts +5 -1
- package/src/nat/ddns/index.ts +6 -0
- package/src/nat/index.ts +33 -13
- package/src/nat/nat-service.ts +942 -501
- package/src/nat/nat-store.ts +126 -31
- package/src/nat/network-mode.ts +36 -0
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +155 -34
- package/src/nat/types.ts +110 -37
- package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +1089 -1260
- package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
- package/src/orchestrator/index.ts +9 -10
- package/src/orchestrator/node-identity.ts +41 -41
- package/src/orchestrator/port-allocator.ts +71 -73
- package/src/orchestrator/state-store.ts +76 -90
- package/src/orchestrator/types.ts +34 -23
- package/src/package-version.ts +4 -0
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +15 -18
- package/src/server/__tests__/hosted-nodes-route.test.ts +161 -0
- package/src/server/__tests__/nodes-route.test.ts +114 -283
- package/src/server/__tests__/publishers.test.ts +193 -180
- package/src/server/__tests__/server.smoke.test.ts +134 -223
- package/src/server/__tests__/settings-route.test.ts +210 -195
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/status-route.test.ts +82 -121
- package/src/server/error-handler.ts +112 -178
- package/src/server/events/types.ts +20 -24
- package/src/server/index.ts +223 -267
- package/src/server/routes/hosted-nodes.ts +83 -0
- package/src/server/routes/nat.ts +75 -63
- package/src/server/routes/nodes.ts +96 -188
- package/src/server/routes/settings.ts +20 -15
- package/src/server/routes/status.ts +5 -67
- package/src/server/server.ts +8 -8
- package/src/server/settings-store.ts +2 -2
- package/src/server/static.ts +1 -2
- package/src/update/index.ts +2 -2
- package/src/update/store.ts +1 -1
- package/src/update/types.ts +1 -1
- package/dist/auth/duration.d.ts +0 -1
- package/dist/auth/duration.js +0 -32
- package/dist/auth/duration.js.map +0 -1
- package/dist/auth/index.d.ts +0 -11
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/self-label.d.ts +0 -36
- package/dist/auth/self-label.js +0 -57
- package/dist/auth/self-label.js.map +0 -1
- package/dist/auth/trust-circle-store.d.ts +0 -26
- package/dist/auth/trust-circle-store.js +0 -129
- package/dist/auth/trust-circle-store.js.map +0 -1
- package/dist/auth/trust-circle.d.ts +0 -156
- package/dist/auth/trust-circle.js +0 -307
- package/dist/auth/trust-circle.js.map +0 -1
- package/dist/auth/types.d.ts +0 -84
- package/dist/auth/types.js +0 -24
- package/dist/auth/types.js.map +0 -1
- package/dist/donation/donation-service.d.ts +0 -389
- package/dist/donation/donation-service.js +0 -828
- package/dist/donation/donation-service.js.map +0 -1
- package/dist/donation/donation-store.d.ts +0 -51
- package/dist/donation/donation-store.js +0 -152
- package/dist/donation/donation-store.js.map +0 -1
- package/dist/donation/donation-supervisor.js +0 -364
- package/dist/donation/donation-supervisor.js.map +0 -1
- package/dist/donation/grant-service.d.ts +0 -80
- package/dist/donation/grant-service.js +0 -210
- package/dist/donation/grant-service.js.map +0 -1
- package/dist/donation/grant-store.d.ts +0 -38
- package/dist/donation/grant-store.js +0 -135
- package/dist/donation/grant-store.js.map +0 -1
- package/dist/donation/index.d.ts +0 -20
- package/dist/donation/index.js +0 -17
- package/dist/donation/index.js.map +0 -1
- package/dist/donation/types.d.ts +0 -224
- package/dist/donation/types.js +0 -40
- package/dist/donation/types.js.map +0 -1
- package/dist/installer/identity.js.map +0 -1
- package/dist/owner/index.d.ts +0 -11
- package/dist/owner/index.js +0 -11
- package/dist/owner/index.js.map +0 -1
- package/dist/owner/owner-node-client.d.ts +0 -110
- package/dist/owner/owner-node-client.js +0 -184
- package/dist/owner/owner-node-client.js.map +0 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
- package/dist/server/routes/bootstrap-node-validation.js +0 -197
- package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
- package/dist/server/routes/grants-admin.d.ts +0 -29
- package/dist/server/routes/grants-admin.js +0 -58
- package/dist/server/routes/grants-admin.js.map +0 -1
- package/dist/server/routes/grants.d.ts +0 -28
- package/dist/server/routes/grants.js +0 -165
- package/dist/server/routes/grants.js.map +0 -1
- package/dist/server/routes/provision-request-validation.d.ts +0 -45
- package/dist/server/routes/provision-request-validation.js +0 -97
- package/dist/server/routes/provision-request-validation.js.map +0 -1
- package/dist/server/routes/strands.d.ts +0 -20
- package/dist/server/routes/strands.js +0 -38
- package/dist/server/routes/strands.js.map +0 -1
- package/dist/server/routes/trust-circle.d.ts +0 -17
- package/dist/server/routes/trust-circle.js +0 -35
- package/dist/server/routes/trust-circle.js.map +0 -1
- package/dist/strands/index.d.ts +0 -7
- package/dist/strands/index.js +0 -6
- package/dist/strands/index.js.map +0 -1
- package/dist/strands/strand-service.d.ts +0 -50
- package/dist/strands/strand-service.js +0 -103
- package/dist/strands/strand-service.js.map +0 -1
- package/dist/strands/types.d.ts +0 -93
- package/dist/strands/types.js +0 -23
- package/dist/strands/types.js.map +0 -1
- package/dist/ui/assets/index-C6nELNfC.js +0 -23
- package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
- package/dist/ui/assets/index-TtIYSeZO.css +0 -1
- package/src/__tests__/cli-invite.smoke.test.ts +0 -192
- package/src/__tests__/host-owner.smoke.test.ts +0 -123
- package/src/__tests__/orchestrator-owner.test.ts +0 -234
- package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
- package/src/auth/__tests__/duration.test.ts +0 -28
- package/src/auth/__tests__/self-label.test.ts +0 -85
- package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
- package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
- package/src/auth/__tests__/trust-circle.test.ts +0 -445
- package/src/auth/duration.ts +0 -33
- package/src/auth/index.ts +0 -21
- package/src/auth/self-label.ts +0 -74
- package/src/auth/trust-circle-store.ts +0 -155
- package/src/auth/trust-circle.ts +0 -395
- package/src/auth/types.ts +0 -99
- package/src/donation/__tests__/donation-service.test.ts +0 -1193
- package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
- package/src/donation/__tests__/grant-service.test.ts +0 -213
- package/src/donation/__tests__/grant-store.test.ts +0 -113
- package/src/donation/donation-service.ts +0 -1033
- package/src/donation/donation-store.ts +0 -176
- package/src/donation/donation-supervisor.ts +0 -411
- package/src/donation/grant-service.ts +0 -252
- package/src/donation/grant-store.ts +0 -152
- package/src/donation/index.ts +0 -65
- package/src/donation/types.ts +0 -260
- package/src/owner/__tests__/owner-node-client.test.ts +0 -194
- package/src/owner/index.ts +0 -12
- package/src/owner/owner-node-client.ts +0 -259
- package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
- package/src/server/__tests__/grants-admin-route.test.ts +0 -171
- package/src/server/__tests__/grants-route.test.ts +0 -394
- package/src/server/__tests__/strands-route.test.ts +0 -234
- package/src/server/routes/bootstrap-node-validation.ts +0 -206
- package/src/server/routes/grants-admin.ts +0 -74
- package/src/server/routes/grants.ts +0 -193
- package/src/server/routes/provision-request-validation.ts +0 -105
- package/src/server/routes/strands.ts +0 -54
- package/src/server/routes/trust-circle.ts +0 -49
- package/src/strands/__tests__/strand-service.test.ts +0 -165
- package/src/strands/index.ts +0 -14
- package/src/strands/strand-service.ts +0 -135
- package/src/strands/types.ts +0 -102
|
@@ -1,515 +1,608 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
import { NatService,
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
mapper
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
expect(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
expect(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
await svc.
|
|
299
|
-
expect(svc
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
it('
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await svc.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
await svc
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { NatService, NAT_UNMAP_GRACE_MS, type NatNodeSource } from '../nat-service.js';
|
|
7
|
+
import { NAT_ADDRESS_RESTART_MIN_INTERVAL_MS } from '../address-watch.js';
|
|
8
|
+
import { ExternalIpDetector } from '../external-ip.js';
|
|
9
|
+
import { NatError } from '../types.js';
|
|
10
|
+
import type { GatewayInfo, PortMapper, PortMapRequest, PortMapResult } from '../port-mapper.js';
|
|
11
|
+
import type { SecretsStore } from '../secrets/index.js';
|
|
12
|
+
import { ddnsAccount } from '../secrets/index.js';
|
|
13
|
+
import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
|
|
14
|
+
|
|
15
|
+
/** A router: maps `internal + offset` (or a `reassigned` port), refuses once `cap` ports are mapped. */
|
|
16
|
+
class FakeMapper implements PortMapper {
|
|
17
|
+
gateway: GatewayInfo | null = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
|
|
18
|
+
cap = Number.POSITIVE_INFINITY;
|
|
19
|
+
offset = 0;
|
|
20
|
+
/** Internal port → the external port the router grants for it instead. */
|
|
21
|
+
readonly reassigned = new Map<number, number>();
|
|
22
|
+
routerIp: string | null = '203.0.113.10';
|
|
23
|
+
readonly mapped = new Map<number, number>();
|
|
24
|
+
readonly mapCalls: number[] = [];
|
|
25
|
+
readonly unmapCalls: number[] = [];
|
|
26
|
+
/** Runs at the start of every `map`: a test's chance to act while a pass is mid-flight. */
|
|
27
|
+
onMap: (() => void) | undefined;
|
|
28
|
+
async discover(): Promise<GatewayInfo | null> { return this.gateway; }
|
|
29
|
+
async map(req: PortMapRequest): Promise<PortMapResult> {
|
|
30
|
+
this.mapCalls.push(req.internalPort);
|
|
31
|
+
this.onMap?.();
|
|
32
|
+
if (!this.mapped.has(req.internalPort) && this.mapped.size >= this.cap) {
|
|
33
|
+
throw new NatError('mapping_failed', 'router: mapping table is full');
|
|
34
|
+
}
|
|
35
|
+
const externalPort = this.reassigned.get(req.internalPort) ?? req.internalPort + this.offset;
|
|
36
|
+
this.mapped.set(req.internalPort, externalPort);
|
|
37
|
+
return { externalPort, leaseExpiresAt: new Date(Date.now() + req.ttlMs) };
|
|
38
|
+
}
|
|
39
|
+
async unmap(internalPort: number): Promise<void> {
|
|
40
|
+
this.unmapCalls.push(internalPort);
|
|
41
|
+
this.mapped.delete(internalPort);
|
|
42
|
+
}
|
|
43
|
+
async externalIp(): Promise<string | null> { return this.routerIp; }
|
|
44
|
+
async stop(): Promise<void> { /* nothing to release */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The orchestrator slice the service reads: a node list plus state-change events. */
|
|
48
|
+
class FakeNodes implements NatNodeSource {
|
|
49
|
+
private readonly nodes = new Map<string, ManagedNodeInfo>();
|
|
50
|
+
private readonly listeners = new Set<NodeStateListener>();
|
|
51
|
+
/** The orchestrator's announce hook, asked at every spawn; `rig` points it at the service. */
|
|
52
|
+
announce: (id: string, ports: { p2p: number; ws?: number }) => string[] = () => [];
|
|
53
|
+
listNodes(): ManagedNodeInfo[] { return [...this.nodes.values()]; }
|
|
54
|
+
onStateChange(listener: NodeStateListener): () => void {
|
|
55
|
+
this.listeners.add(listener);
|
|
56
|
+
return () => { this.listeners.delete(listener); };
|
|
57
|
+
}
|
|
58
|
+
add(id: string, ports: { p2p: number; ws?: number }, status: ManagedNodeInfo['status'] = 'running'): void {
|
|
59
|
+
const info: ManagedNodeInfo = {
|
|
60
|
+
id,
|
|
61
|
+
dockerId: `1:${id}`,
|
|
62
|
+
partyId: 'party',
|
|
63
|
+
profile: 'storage',
|
|
64
|
+
status,
|
|
65
|
+
spawnedAt: '2026-01-01T00:00:00Z',
|
|
66
|
+
workdir: `/w/${id}`,
|
|
67
|
+
ports: { health: 1, metrics: 2, p2p: ports.p2p, ws: ports.ws as number },
|
|
68
|
+
announcedAddrs: this.announce(id, ports),
|
|
69
|
+
};
|
|
70
|
+
this.nodes.set(id, info);
|
|
71
|
+
this.emit(info);
|
|
72
|
+
}
|
|
73
|
+
/** A respawn on the same ports, announcing what the hook gives now. */
|
|
74
|
+
restart(id: string): void {
|
|
75
|
+
const { ports } = this.nodes.get(id)!;
|
|
76
|
+
this.add(id, { p2p: ports.p2p, ws: ports.ws });
|
|
77
|
+
}
|
|
78
|
+
setStatus(id: string, status: ManagedNodeInfo['status']): void {
|
|
79
|
+
const info = { ...this.nodes.get(id)!, status };
|
|
80
|
+
this.nodes.set(id, info);
|
|
81
|
+
this.emit(info);
|
|
82
|
+
}
|
|
83
|
+
/** `removeContainer`: the handle is gone from the list by the time the event fires. */
|
|
84
|
+
remove(id: string): void {
|
|
85
|
+
const info = { ...this.nodes.get(id)!, status: 'stopped' as const };
|
|
86
|
+
this.nodes.delete(id);
|
|
87
|
+
this.emit(info);
|
|
88
|
+
}
|
|
89
|
+
private emit(info: ManagedNodeInfo): void {
|
|
90
|
+
for (const l of this.listeners) l(info);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
|
|
95
|
+
const m = new Map(Object.entries(seed));
|
|
96
|
+
return {
|
|
97
|
+
async set(a, v) { m.set(a, v); },
|
|
98
|
+
async get(a) { return m.has(a) ? m.get(a)! : null; },
|
|
99
|
+
async delete(a) { return m.delete(a); },
|
|
100
|
+
async list() { return [...m.keys()]; },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
|
|
105
|
+
return new ExternalIpDetector({
|
|
106
|
+
routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
|
|
107
|
+
fetch: (async () => ({
|
|
108
|
+
ok: opts.pub != null,
|
|
109
|
+
status: opts.pub != null ? 200 : 500,
|
|
110
|
+
async text() { return opts.pub ?? ''; },
|
|
111
|
+
})) as unknown as typeof fetch,
|
|
112
|
+
publicIpUrls: ['https://stub.test'],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The DuckDNS call a `putDdns` lands on; no case here reaches www.duckdns.org. */
|
|
117
|
+
function okFetch(): typeof fetch {
|
|
118
|
+
return (async () => ({
|
|
119
|
+
ok: true,
|
|
120
|
+
status: 200,
|
|
121
|
+
statusText: 'OK',
|
|
122
|
+
async text() { return 'OK'; },
|
|
123
|
+
})) as unknown as typeof fetch;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let tmpRoot: string;
|
|
127
|
+
let clock: number;
|
|
128
|
+
const now = (): Date => new Date(clock);
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
|
|
132
|
+
clock = Date.parse('2026-01-01T00:00:00Z');
|
|
133
|
+
});
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
interface Rig {
|
|
139
|
+
svc: NatService;
|
|
140
|
+
mapper: FakeMapper;
|
|
141
|
+
nodes: FakeNodes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rig(opts: {
|
|
145
|
+
mapper?: FakeMapper;
|
|
146
|
+
nodes?: FakeNodes;
|
|
147
|
+
detector?: ExternalIpDetector;
|
|
148
|
+
fetch?: typeof fetch;
|
|
149
|
+
secrets?: SecretsStore;
|
|
150
|
+
/** The public IPv4 an interface holds; default none (a machine behind a router). */
|
|
151
|
+
publicInterfaceIp?: string | null;
|
|
152
|
+
} = {}): Rig {
|
|
153
|
+
const mapper = opts.mapper ?? new FakeMapper();
|
|
154
|
+
const nodes = opts.nodes ?? new FakeNodes();
|
|
155
|
+
const svc = new NatService({
|
|
156
|
+
rootDir: tmpRoot,
|
|
157
|
+
nodeSource: nodes,
|
|
158
|
+
now,
|
|
159
|
+
secretsStore: opts.secrets ?? makeSecrets(),
|
|
160
|
+
portMapper: mapper,
|
|
161
|
+
externalIpDetector: opts.detector ?? makeDetector({ pub: '203.0.113.10' }),
|
|
162
|
+
// Deterministic: never read the test machine's real interfaces.
|
|
163
|
+
publicInterfaceAddress: () => opts.publicInterfaceIp ?? null,
|
|
164
|
+
...(opts.fetch ? { fetch: opts.fetch } : {}),
|
|
165
|
+
});
|
|
166
|
+
nodes.announce = (id, ports) => svc.publicAddressesFor(id, ports);
|
|
167
|
+
return { svc, mapper, nodes };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `start()` maps in the background; a second pass is sequenced after it. */
|
|
171
|
+
async function startAndSettle(svc: NatService): Promise<void> {
|
|
172
|
+
await svc.start();
|
|
173
|
+
await svc.reconcile();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function node(svc: NatService, id: string) {
|
|
177
|
+
const found = svc.getStatus().nodes.find((n) => n.nodeId === id);
|
|
178
|
+
if (!found) throw new Error(`node ${id} not in status`);
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Poll until `check` holds; for passes the service queues on its own, which nothing public awaits. */
|
|
183
|
+
async function waitUntil(check: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
184
|
+
const deadline = Date.now() + timeoutMs;
|
|
185
|
+
while (!check()) {
|
|
186
|
+
if (Date.now() > deadline) throw new Error('condition not met in time');
|
|
187
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe('NatService — mapping table', () => {
|
|
192
|
+
it('maps both ports of every running node, and stop() unmaps nothing', async () => {
|
|
193
|
+
const { svc, mapper, nodes } = rig({ detector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }) });
|
|
194
|
+
nodes.add('grn_b', { p2p: 4001, ws: 10001 });
|
|
195
|
+
nodes.add('grn_a', { p2p: 10003, ws: 10004 });
|
|
196
|
+
await startAndSettle(svc);
|
|
197
|
+
|
|
198
|
+
const status = svc.getStatus();
|
|
199
|
+
expect(status.gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20', routerExternalIp: '203.0.113.10' });
|
|
200
|
+
expect(status.directReachability).toBe('reachable');
|
|
201
|
+
expect(node(svc, 'grn_a')).toMatchObject({
|
|
202
|
+
verdict: 'mapped',
|
|
203
|
+
tcp: { internalPort: 10003, externalPort: 10003, source: 'upnp' },
|
|
204
|
+
ws: { internalPort: 10004, externalPort: 10004, source: 'upnp' },
|
|
205
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/10003', '/ip4/203.0.113.10/tcp/10004/ws'],
|
|
206
|
+
});
|
|
207
|
+
expect(node(svc, 'grn_b').tcp.externalPort).toBe(4001);
|
|
208
|
+
expect(mapper.mapped.size).toBe(4);
|
|
209
|
+
|
|
210
|
+
await svc.stop();
|
|
211
|
+
expect(mapper.unmapCalls).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('a router that caps its mappings fails per port; the other node keeps its routes across a renewal', async () => {
|
|
215
|
+
const mapper = new FakeMapper();
|
|
216
|
+
mapper.cap = 3;
|
|
217
|
+
const { svc, nodes } = rig({ mapper });
|
|
218
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
219
|
+
nodes.add('b', { p2p: 10005, ws: 10006 });
|
|
220
|
+
await startAndSettle(svc);
|
|
221
|
+
|
|
222
|
+
expect(node(svc, 'a').verdict).toBe('mapped');
|
|
223
|
+
const b = node(svc, 'b');
|
|
224
|
+
expect(b.verdict).toBe('unreachable');
|
|
225
|
+
expect(b.tcp.externalPort).toBe(10005);
|
|
226
|
+
expect(b.ws).toMatchObject({ externalPort: null, error: expect.stringContaining('full') });
|
|
227
|
+
expect(b.reason).toContain('WebSocket port 10006');
|
|
228
|
+
expect(b.reason).toContain('192.168.1.20');
|
|
229
|
+
expect(b.publicAddrs).toEqual(['/ip4/203.0.113.10/tcp/10005']);
|
|
230
|
+
expect(svc.getStatus().directReachability).toBe('unreachable');
|
|
231
|
+
|
|
232
|
+
await svc.renewMappings();
|
|
233
|
+
expect(node(svc, 'a')).toMatchObject({
|
|
234
|
+
verdict: 'mapped',
|
|
235
|
+
tcp: { externalPort: 10003, source: 'upnp' },
|
|
236
|
+
ws: { externalPort: 10004, source: 'upnp' },
|
|
237
|
+
});
|
|
238
|
+
expect(node(svc, 'b').ws?.externalPort).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('the route and the public addresses carry the external port the router assigned', async () => {
|
|
242
|
+
const mapper = new FakeMapper();
|
|
243
|
+
mapper.offset = 1;
|
|
244
|
+
const { svc, nodes } = rig({ mapper });
|
|
245
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
246
|
+
await startAndSettle(svc);
|
|
247
|
+
|
|
248
|
+
expect(node(svc, 'a')).toMatchObject({
|
|
249
|
+
tcp: { internalPort: 10003, externalPort: 10004 },
|
|
250
|
+
ws: { internalPort: 10004, externalPort: 10005 },
|
|
251
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/10004', '/ip4/203.0.113.10/tcp/10005/ws'],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a handle without a WebSocket port maps only its TCP port', async () => {
|
|
256
|
+
const { svc, nodes } = rig();
|
|
257
|
+
nodes.add('old', { p2p: 10003 });
|
|
258
|
+
await startAndSettle(svc);
|
|
259
|
+
|
|
260
|
+
expect(node(svc, 'old')).toMatchObject({ verdict: 'mapped', ws: null, publicAddrs: ['/ip4/203.0.113.10/tcp/10003'] });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('a terminated node is unmapped at once and its manual forward is dropped', async () => {
|
|
264
|
+
const { svc, mapper, nodes } = rig();
|
|
265
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
266
|
+
await startAndSettle(svc);
|
|
267
|
+
await svc.putForward('a', { tcp: 40000 });
|
|
268
|
+
expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).toContain('"a"');
|
|
269
|
+
|
|
270
|
+
nodes.remove('a');
|
|
271
|
+
await svc.reconcile(); // sequenced after the pass the removal event queued
|
|
272
|
+
|
|
273
|
+
expect(mapper.unmapCalls).toContain(10004);
|
|
274
|
+
expect(mapper.mapped.size).toBe(0);
|
|
275
|
+
expect(svc.getStatus().nodes).toEqual([]);
|
|
276
|
+
expect(svc.getSettings().forwards).toEqual({});
|
|
277
|
+
expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).not.toContain('"a"');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('a node stopped inside the grace keeps its mapping; past it the mapping is released and a respawn re-maps', async () => {
|
|
281
|
+
const { svc, mapper, nodes } = rig();
|
|
282
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
283
|
+
await startAndSettle(svc);
|
|
284
|
+
|
|
285
|
+
nodes.setStatus('a', 'stopped');
|
|
286
|
+
await svc.reconcile(); // the stop event's own pass records when the node was first seen stopped
|
|
287
|
+
clock += NAT_UNMAP_GRACE_MS - 1_000;
|
|
288
|
+
await svc.reconcile();
|
|
289
|
+
expect(mapper.unmapCalls).toEqual([]);
|
|
290
|
+
expect(node(svc, 'a')).toMatchObject({ running: false, tcp: { externalPort: 10003 } });
|
|
291
|
+
|
|
292
|
+
clock += 1_000;
|
|
293
|
+
await svc.reconcile();
|
|
294
|
+
expect(mapper.unmapCalls).toEqual([10003, 10004]);
|
|
295
|
+
expect(node(svc, 'a').tcp.externalPort).toBeNull();
|
|
296
|
+
|
|
297
|
+
nodes.setStatus('a', 'running');
|
|
298
|
+
await svc.reconcile();
|
|
299
|
+
expect(node(svc, 'a')).toMatchObject({ running: true, verdict: 'mapped', tcp: { externalPort: 10003 } });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('a node spawned while an event-triggered pass runs is mapped by the pass that follows it', async () => {
|
|
303
|
+
const mapper = new FakeMapper();
|
|
304
|
+
const { svc, nodes } = rig({ mapper });
|
|
305
|
+
await startAndSettle(svc);
|
|
306
|
+
|
|
307
|
+
// The spawn of `a` queues an event pass; `b` spawns while that pass is mapping a's first port.
|
|
308
|
+
mapper.onMap = () => {
|
|
309
|
+
mapper.onMap = undefined;
|
|
310
|
+
nodes.add('b', { p2p: 10005, ws: 10006 });
|
|
311
|
+
};
|
|
312
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
313
|
+
|
|
314
|
+
await waitUntil(() => svc.getStatus().nodes.some((n) => n.nodeId === 'b' && n.verdict === 'mapped'));
|
|
315
|
+
expect(node(svc, 'a').verdict).toBe('mapped');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('a router that answers a later probe maps the running nodes without a restart', async () => {
|
|
319
|
+
const mapper = new FakeMapper();
|
|
320
|
+
mapper.gateway = null;
|
|
321
|
+
const { svc, nodes } = rig({ mapper });
|
|
322
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
323
|
+
await startAndSettle(svc);
|
|
324
|
+
expect(node(svc, 'a').reason).toContain('No UPnP router answered');
|
|
325
|
+
|
|
326
|
+
mapper.gateway = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
|
|
327
|
+
await svc.redetect();
|
|
328
|
+
expect(svc.getStatus().gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20' });
|
|
329
|
+
expect(node(svc, 'a')).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('NatService — manual forwards and the UPnP toggle', () => {
|
|
334
|
+
it('a manual forward wins over UPnP and is re-requested when cleared', async () => {
|
|
335
|
+
const { svc, mapper, nodes } = rig();
|
|
336
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
337
|
+
await startAndSettle(svc);
|
|
338
|
+
|
|
339
|
+
let status = await svc.putForward('a', { tcp: 40000 });
|
|
340
|
+
expect(mapper.unmapCalls).toEqual([10003]);
|
|
341
|
+
expect(status.nodes[0]).toMatchObject({
|
|
342
|
+
verdict: 'manual',
|
|
343
|
+
tcp: { externalPort: 40000, source: 'manual' },
|
|
344
|
+
ws: { externalPort: 10004, source: 'upnp' },
|
|
345
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/10004/ws'],
|
|
346
|
+
});
|
|
347
|
+
expect(JSON.parse(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).forwards).toEqual({ a: { tcp: 40000 } });
|
|
348
|
+
|
|
349
|
+
status = await svc.putForward('a', { tcp: null });
|
|
350
|
+
expect(status.nodes[0]).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
351
|
+
expect(svc.getSettings().forwards).toEqual({});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('putForward refuses an unknown node and an out-of-range port', async () => {
|
|
355
|
+
const { svc, nodes } = rig();
|
|
356
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
357
|
+
await startAndSettle(svc);
|
|
358
|
+
|
|
359
|
+
await expect(svc.putForward('nope', { tcp: 1 })).rejects.toMatchObject({ code: 'unknown_node' });
|
|
360
|
+
await expect(svc.putForward('a', { ws: 70000 })).rejects.toMatchObject({ code: 'invalid_config' });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('turning UPnP off releases upnp routes and keeps manual ones', async () => {
|
|
364
|
+
const { svc, mapper, nodes } = rig();
|
|
365
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
366
|
+
await startAndSettle(svc);
|
|
367
|
+
await svc.putForward('a', { ws: 40004 });
|
|
368
|
+
|
|
369
|
+
const status = await svc.putSettings({ upnpEnabled: false });
|
|
370
|
+
expect(mapper.unmapCalls).toEqual([10004, 10003]);
|
|
371
|
+
const a = status.nodes[0]!;
|
|
372
|
+
expect(a.ws).toMatchObject({ externalPort: 40004, source: 'manual' });
|
|
373
|
+
expect(a.tcp).toMatchObject({ externalPort: null, source: null });
|
|
374
|
+
expect(a.verdict).toBe('unreachable');
|
|
375
|
+
expect(a.reason).toContain('UPnP is off');
|
|
376
|
+
expect(a.reason).toContain('TCP port 10003');
|
|
377
|
+
|
|
378
|
+
await svc.putSettings({ upnpEnabled: true });
|
|
379
|
+
expect(node(svc, 'a')).toMatchObject({ verdict: 'manual', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('NatService — public addresses', () => {
|
|
384
|
+
it('predicts the identity mapping for a port with no route yet, but not after a failed attempt', async () => {
|
|
385
|
+
const mapper = new FakeMapper();
|
|
386
|
+
mapper.cap = 1;
|
|
387
|
+
const { svc, nodes } = rig({ mapper });
|
|
388
|
+
await startAndSettle(svc);
|
|
389
|
+
|
|
390
|
+
expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual([
|
|
391
|
+
'/ip4/203.0.113.10/tcp/20000',
|
|
392
|
+
'/ip4/203.0.113.10/tcp/20001/ws',
|
|
393
|
+
]);
|
|
394
|
+
|
|
395
|
+
nodes.add('new', { p2p: 20000, ws: 20001 });
|
|
396
|
+
await svc.reconcile();
|
|
397
|
+
expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual(['/ip4/203.0.113.10/tcp/20000']);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('predicts nothing without a gateway, and a manual forward regardless', async () => {
|
|
401
|
+
const mapper = new FakeMapper();
|
|
402
|
+
mapper.gateway = null;
|
|
403
|
+
const { svc, nodes } = rig({ mapper });
|
|
404
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
405
|
+
await startAndSettle(svc);
|
|
406
|
+
|
|
407
|
+
expect(svc.getStatus().gateway).toMatchObject({ found: false, lastError: expect.stringContaining('no UPnP gateway') });
|
|
408
|
+
expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([]);
|
|
409
|
+
expect(node(svc, 'a').reason).toContain('No UPnP router answered');
|
|
410
|
+
|
|
411
|
+
await svc.putForward('a', { tcp: 40000, ws: 40001 });
|
|
412
|
+
expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([
|
|
413
|
+
'/ip4/203.0.113.10/tcp/40000',
|
|
414
|
+
'/ip4/203.0.113.10/tcp/40001/ws',
|
|
415
|
+
]);
|
|
416
|
+
expect(node(svc, 'a').verdict).toBe('manual');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('uses the full DDNS name of the provider as the host part, even when typed as a bare subdomain', async () => {
|
|
420
|
+
const { svc, nodes } = rig({
|
|
421
|
+
secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
|
|
422
|
+
fetch: okFetch(),
|
|
423
|
+
});
|
|
424
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
425
|
+
await startAndSettle(svc);
|
|
426
|
+
|
|
427
|
+
const status = await svc.putDdns({ providerId: 'duckdns', hostname: 'foo', config: { token: 'T' } });
|
|
428
|
+
expect(status.ddns).toMatchObject({ providerId: 'duckdns', hostname: 'foo.duckdns.org' });
|
|
429
|
+
expect(status.nodes[0]!.publicAddrs).toEqual(['/dns4/foo.duckdns.org/tcp/10003', '/dns4/foo.duckdns.org/tcp/10004/ws']);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('under CGNAT a upnp route yields no address and the host rolls up to cgnat; a manual route still counts', async () => {
|
|
433
|
+
const { svc, nodes } = rig({ detector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }) });
|
|
434
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
435
|
+
await startAndSettle(svc);
|
|
436
|
+
|
|
437
|
+
const status = svc.getStatus();
|
|
438
|
+
expect(status.cgnatDetected).toBe(true);
|
|
439
|
+
expect(status.directReachability).toBe('cgnat');
|
|
440
|
+
expect(status.nodes[0]).toMatchObject({ verdict: 'unreachable', publicAddrs: [] });
|
|
441
|
+
expect(status.nodes[0]!.reason).toContain('carrier-grade NAT');
|
|
442
|
+
|
|
443
|
+
const after = await svc.putForward('a', { tcp: 40000, ws: 40001 });
|
|
444
|
+
expect(after.nodes[0]).toMatchObject({
|
|
445
|
+
verdict: 'manual',
|
|
446
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/40001/ws'],
|
|
447
|
+
});
|
|
448
|
+
expect(after.directReachability).toBe('reachable');
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe('NatService — stale announced addresses', () => {
|
|
453
|
+
it('asks to restart only the node whose router-assigned port differs from the one it announced, and only once', async () => {
|
|
454
|
+
const mapper = new FakeMapper();
|
|
455
|
+
mapper.reassigned.set(10006, 30006);
|
|
456
|
+
const { svc, nodes } = rig({ mapper });
|
|
457
|
+
await startAndSettle(svc);
|
|
458
|
+
const stale: string[] = [];
|
|
459
|
+
svc.onNodeAddressesStale((id) => { stale.push(id); });
|
|
460
|
+
|
|
461
|
+
// Both spawn announcing the predicted identity mapping.
|
|
462
|
+
nodes.add('same', { p2p: 10003, ws: 10004 });
|
|
463
|
+
nodes.add('moved', { p2p: 10005, ws: 10006 });
|
|
464
|
+
await svc.reconcile();
|
|
465
|
+
expect(stale).toEqual(['moved']);
|
|
466
|
+
|
|
467
|
+
nodes.restart('moved');
|
|
468
|
+
clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS;
|
|
469
|
+
await svc.reconcile();
|
|
470
|
+
expect(nodes.listNodes().find((n) => n.id === 'moved')!.announcedAddrs)
|
|
471
|
+
.toEqual(['/ip4/203.0.113.10/tcp/10005', '/ip4/203.0.113.10/tcp/30006/ws']);
|
|
472
|
+
expect(stale).toEqual(['moved']);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('defers a second restart of one node until the rate-limit window has passed', async () => {
|
|
476
|
+
const mapper = new FakeMapper();
|
|
477
|
+
mapper.reassigned.set(10004, 30004);
|
|
478
|
+
const { svc, nodes } = rig({ mapper });
|
|
479
|
+
await startAndSettle(svc);
|
|
480
|
+
const stale: string[] = [];
|
|
481
|
+
svc.onNodeAddressesStale((id) => { stale.push(id); });
|
|
482
|
+
|
|
483
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
484
|
+
await svc.reconcile();
|
|
485
|
+
expect(stale).toEqual(['a']);
|
|
486
|
+
|
|
487
|
+
// Never restarted, so the difference stands at every later pass.
|
|
488
|
+
clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS - 1;
|
|
489
|
+
await svc.reconcile();
|
|
490
|
+
expect(stale).toEqual(['a']);
|
|
491
|
+
|
|
492
|
+
clock += 1;
|
|
493
|
+
await svc.reconcile();
|
|
494
|
+
expect(stale).toEqual(['a', 'a']);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('NatService — external IP and change notification', () => {
|
|
499
|
+
it('a re-detection that loses the public probe keeps the previous result, CGNAT flag included', async () => {
|
|
500
|
+
let offline = false;
|
|
501
|
+
const detector = new ExternalIpDetector({
|
|
502
|
+
routerProbe: async () => '100.64.0.5',
|
|
503
|
+
fetch: (async () => {
|
|
504
|
+
if (offline) throw new Error('offline');
|
|
505
|
+
return { ok: true, status: 200, async text() { return '203.0.113.10'; } };
|
|
506
|
+
}) as unknown as typeof fetch,
|
|
507
|
+
publicIpUrls: ['https://stub.test'],
|
|
508
|
+
});
|
|
509
|
+
const { svc } = rig({ detector });
|
|
510
|
+
await startAndSettle(svc);
|
|
511
|
+
expect(svc.getStatus()).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
|
|
512
|
+
|
|
513
|
+
// Only the router answers now: taking that result would drop the CGNAT verdict
|
|
514
|
+
// and read the carrier address as the host's.
|
|
515
|
+
offline = true;
|
|
516
|
+
const status = await svc.testReachability();
|
|
517
|
+
expect(status).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
|
|
518
|
+
expect(status.lastTestedAt).not.toBeNull();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('onChange fires when the snapshot changes and stays quiet across an idle pass', async () => {
|
|
522
|
+
const { svc, nodes } = rig();
|
|
523
|
+
const seen: string[] = [];
|
|
524
|
+
svc.onChange((snap) => { seen.push(snap.directReachability); });
|
|
525
|
+
await startAndSettle(svc);
|
|
526
|
+
const afterStart = seen.length;
|
|
527
|
+
expect(afterStart).toBeGreaterThan(0);
|
|
528
|
+
|
|
529
|
+
await svc.reconcile();
|
|
530
|
+
expect(seen.length).toBe(afterStart);
|
|
531
|
+
|
|
532
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
533
|
+
await svc.reconcile();
|
|
534
|
+
expect(seen.length).toBeGreaterThan(afterStart);
|
|
535
|
+
expect(seen[seen.length - 1]).toBe('reachable');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('putDdns with an unknown provider → ddns_provider_unknown', async () => {
|
|
539
|
+
const { svc } = rig();
|
|
540
|
+
await startAndSettle(svc);
|
|
541
|
+
await expect(svc.putDdns({ providerId: 'no-such-provider', hostname: 'x', config: {} }))
|
|
542
|
+
.rejects.toMatchObject({ code: 'ddns_provider_unknown' });
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('NatService network mode', () => {
|
|
547
|
+
it('auto: a public IPv4 on an interface means public mode: direct routes, no UPnP, the interface IP announced', async () => {
|
|
548
|
+
const { svc, mapper, nodes } = rig({ publicInterfaceIp: '198.51.100.7', detector: makeDetector({ pub: '198.51.100.7' }) });
|
|
549
|
+
nodes.add('n1', { p2p: 10002, ws: 10003 });
|
|
550
|
+
await startAndSettle(svc);
|
|
551
|
+
|
|
552
|
+
const status = svc.getStatus();
|
|
553
|
+
expect(status.network).toEqual({ setting: 'auto', mode: 'public', publicInterfaceIp: '198.51.100.7' });
|
|
554
|
+
expect(status.gateway.found).toBe(false);
|
|
555
|
+
expect(mapper.mapCalls).toEqual([]);
|
|
556
|
+
const n1 = node(svc, 'n1');
|
|
557
|
+
expect(n1.verdict).toBe('direct');
|
|
558
|
+
expect(n1.reason).toMatch(/sudo ufw allow 10002:10003\/tcp/);
|
|
559
|
+
expect(n1.tcp).toMatchObject({ internalPort: 10002, externalPort: 10002, source: 'direct' });
|
|
560
|
+
expect(n1.publicAddrs).toEqual(['/ip4/198.51.100.7/tcp/10002', '/ip4/198.51.100.7/tcp/10003/ws']);
|
|
561
|
+
expect(svc.publicAddressesFor('n1', { p2p: 10002, ws: 10003 })).toEqual(n1.publicAddrs);
|
|
562
|
+
expect(status.directReachability).toBe('reachable');
|
|
563
|
+
expect(status.cgnatDetected).toBe(false);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('public mode announces the hostname when one is set, and a manual forward still wins', async () => {
|
|
567
|
+
const { svc, nodes } = rig({ publicInterfaceIp: '198.51.100.7' });
|
|
568
|
+
nodes.add('n1', { p2p: 10002, ws: 10003 });
|
|
569
|
+
await startAndSettle(svc);
|
|
570
|
+
await svc.putSettings({ ddns: { providerId: 'external', hostname: 'kjeib.example.org', externallyManaged: true, intervalMs: 300_000 } });
|
|
571
|
+
expect(node(svc, 'n1').publicAddrs).toEqual(['/dns4/kjeib.example.org/tcp/10002', '/dns4/kjeib.example.org/tcp/10003/ws']);
|
|
572
|
+
|
|
573
|
+
await svc.putForward('n1', { ws: 443 });
|
|
574
|
+
expect(node(svc, 'n1').ws).toMatchObject({ externalPort: 443, source: 'manual' });
|
|
575
|
+
expect(node(svc, 'n1').verdict).toBe('manual');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('auto with only private addresses stays in LAN mode and maps over UPnP as before', async () => {
|
|
579
|
+
const { svc, mapper, nodes } = rig();
|
|
580
|
+
nodes.add('n1', { p2p: 10002, ws: 10003 });
|
|
581
|
+
await startAndSettle(svc);
|
|
582
|
+
expect(svc.getStatus().network.mode).toBe('lan');
|
|
583
|
+
expect(mapper.mapCalls).toEqual(expect.arrayContaining([10002, 10003]));
|
|
584
|
+
expect(node(svc, 'n1').verdict).toBe('mapped');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('switching the setting re-routes: lan → public releases UPnP mappings; public → lan maps again', async () => {
|
|
588
|
+
const { svc, mapper, nodes } = rig();
|
|
589
|
+
nodes.add('n1', { p2p: 10002, ws: 10003 });
|
|
590
|
+
await startAndSettle(svc);
|
|
591
|
+
expect(node(svc, 'n1').verdict).toBe('mapped');
|
|
592
|
+
|
|
593
|
+
await svc.putSettings({ network: 'public' });
|
|
594
|
+
expect(svc.getStatus().network).toMatchObject({ setting: 'public', mode: 'public' });
|
|
595
|
+
expect(mapper.unmapCalls).toEqual(expect.arrayContaining([10002, 10003]));
|
|
596
|
+
expect(node(svc, 'n1').verdict).toBe('direct');
|
|
597
|
+
|
|
598
|
+
await svc.putSettings({ network: 'lan' });
|
|
599
|
+
expect(svc.getStatus().network.mode).toBe('lan');
|
|
600
|
+
expect(node(svc, 'n1').verdict).toBe('mapped');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('refuses an unknown network setting', async () => {
|
|
604
|
+
const { svc } = rig();
|
|
605
|
+
await svc.start();
|
|
606
|
+
await expect(svc.putSettings({ network: 'vpn' as never })).rejects.toMatchObject({ code: 'invalid_config' });
|
|
607
|
+
});
|
|
608
|
+
});
|