@serfab/cadre-host 1.12.0 → 1.14.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 +332 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +301 -505
- 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 +49 -0
- package/dist/bin/nat-output.js +127 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +44 -0
- package/dist/hosted/claim-details.js +66 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +318 -0
- package/dist/hosted/hosted-node-service.js +666 -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 +83 -0
- package/dist/hosted/hosted-node-watcher.js +224 -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 +35 -0
- package/dist/hosted/node-status.js +61 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +134 -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 +4 -34
- package/dist/installer/config.js +3 -18
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -19
- package/dist/installer/index.js +14 -69
- 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 +0 -7
- package/dist/installer/wizard.js +1 -8
- 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 +12 -9
- package/dist/nat/index.js +6 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +203 -77
- package/dist/nat/nat-service.js +602 -237
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +114 -30
- package/dist/nat/nat-store.js.map +1 -1
- 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 +31 -15
- package/dist/nat/reachability.js +97 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +78 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +29 -71
- package/dist/orchestrator/host-process-orchestrator.js +78 -218
- 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/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 +2 -7
- package/dist/server/routes/settings.js +3 -7
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +1 -11
- 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-4FWuU8ye.js +19 -0
- package/dist/ui/assets/index-4FWuU8ye.js.map +1 -0
- package/dist/ui/assets/index-Di9Q4d01.css +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 -6
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +1 -1
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +112 -0
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +182 -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 +1019 -1063
- package/src/bin/host.ts +368 -553
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +177 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +173 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +265 -243
- package/src/hosted/__tests__/hosted-node-service.test.ts +609 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +474 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +96 -0
- package/src/hosted/hosted-node-service.ts +795 -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 +250 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +71 -0
- package/src/hosted/types.ts +153 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +5 -86
- package/src/installer/__tests__/wizard.test.ts +56 -64
- package/src/installer/config.ts +135 -175
- package/src/installer/index.ts +15 -83
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +87 -103
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +433 -408
- package/src/nat/__tests__/nat-store.test.ts +127 -93
- 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 +87 -71
- package/src/nat/nat-service.ts +889 -501
- package/src/nat/nat-store.ts +237 -154
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +134 -25
- package/src/nat/types.ts +209 -159
- 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 +81 -243
- 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/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +40 -44
- package/src/server/__tests__/hosted-nodes-route.test.ts +160 -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 +179 -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 +115 -123
- package/src/server/routes/status.ts +102 -134
- 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,38 +1,94 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { NatService,
|
|
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';
|
|
7
8
|
import { ExternalIpDetector } from '../external-ip.js';
|
|
8
|
-
import
|
|
9
|
+
import { NatError } from '../types.js';
|
|
10
|
+
import type { GatewayInfo, PortMapper, PortMapRequest, PortMapResult } from '../port-mapper.js';
|
|
9
11
|
import type { SecretsStore } from '../secrets/index.js';
|
|
10
12
|
import { ddnsAccount } from '../secrets/index.js';
|
|
11
|
-
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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),
|
|
30
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);
|
|
31
91
|
}
|
|
32
|
-
async verify() { return true; }
|
|
33
|
-
async externalIp() { return this.externalIpValue; }
|
|
34
|
-
async unmap() { this.unmapCalls++; }
|
|
35
|
-
async stop() { this.stopCalls++; }
|
|
36
92
|
}
|
|
37
93
|
|
|
38
94
|
function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
|
|
@@ -45,36 +101,6 @@ function makeSecrets(seed: Record<string, string> = {}): SecretsStore {
|
|
|
45
101
|
};
|
|
46
102
|
}
|
|
47
103
|
|
|
48
|
-
function makeNode(peerId = '12D3KooWHost', addrs: string[] = ['/ip4/192.168.1.10/tcp/4001']): CadreNodeLike {
|
|
49
|
-
return {
|
|
50
|
-
getPeerId: () => peerId,
|
|
51
|
-
getMultiaddrs: () => addrs,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* A node that mimics a freshly spawned owner child: `getPeerId` throws
|
|
57
|
-
* `OwnerNodeUnavailableError` for the first `failTimes` calls (admin channel
|
|
58
|
-
* not bound yet), then returns a real peer ID. Exposes the call count so tests
|
|
59
|
-
* can assert how many attempts the retry loop made.
|
|
60
|
-
*/
|
|
61
|
-
function makeFlakyNode(
|
|
62
|
-
failTimes: number,
|
|
63
|
-
peerId = '12D3KooWFlaky',
|
|
64
|
-
addrs: string[] = ['/ip4/192.168.1.10/tcp/4001'],
|
|
65
|
-
): { node: CadreNodeLike; peerIdCalls: () => number } {
|
|
66
|
-
let calls = 0;
|
|
67
|
-
const node: CadreNodeLike = {
|
|
68
|
-
getPeerId: async () => {
|
|
69
|
-
calls += 1;
|
|
70
|
-
if (calls <= failTimes) throw new OwnerNodeUnavailableError('admin channel not bound yet');
|
|
71
|
-
return peerId;
|
|
72
|
-
},
|
|
73
|
-
getMultiaddrs: async () => addrs,
|
|
74
|
-
};
|
|
75
|
-
return { node, peerIdCalls: () => calls };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
104
|
function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
|
|
79
105
|
return new ExternalIpDetector({
|
|
80
106
|
routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
|
|
@@ -87,12 +113,7 @@ function makeDetector(opts: { router?: string | null; pub?: string | null }): Ex
|
|
|
87
113
|
});
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
/**
|
|
91
|
-
* The provider call a `putDdns` lands on. Injected wherever a case configures a real
|
|
92
|
-
* provider, so no case here reaches www.duckdns.org — an outbound request in a unit
|
|
93
|
-
* test is a hang waiting for a machine without network. DuckDNS's own contract (the
|
|
94
|
-
* plain-text `OK`) is covered in the ddns specs.
|
|
95
|
-
*/
|
|
116
|
+
/** The DuckDNS call a `putDdns` lands on; no case here reaches www.duckdns.org. */
|
|
96
117
|
function okFetch(): typeof fetch {
|
|
97
118
|
return (async () => ({
|
|
98
119
|
ok: true,
|
|
@@ -103,413 +124,417 @@ function okFetch(): typeof fetch {
|
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
let tmpRoot: string;
|
|
127
|
+
let clock: number;
|
|
128
|
+
const now = (): Date => new Date(clock);
|
|
129
|
+
|
|
106
130
|
beforeEach(() => {
|
|
107
131
|
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-nat-svc-'));
|
|
132
|
+
clock = Date.parse('2026-01-01T00:00:00Z');
|
|
108
133
|
});
|
|
109
134
|
afterEach(() => {
|
|
110
135
|
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
111
136
|
});
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
cadreNode: makeNode(),
|
|
119
|
-
secretsStore: makeSecrets(),
|
|
120
|
-
portMapper: mapper,
|
|
121
|
-
externalIpDetector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }),
|
|
122
|
-
});
|
|
123
|
-
await svc.start();
|
|
124
|
-
const s = svc.getStatus();
|
|
125
|
-
expect(s.portMode).toBe('auto-upnp');
|
|
126
|
-
expect(s.externalPort).toBe(4001);
|
|
127
|
-
expect(s.externalIp).toBe('203.0.113.10');
|
|
128
|
-
expect(s.directReachability).toBe('reachable');
|
|
129
|
-
expect(mapper.mapCalls).toBe(1);
|
|
130
|
-
});
|
|
138
|
+
interface Rig {
|
|
139
|
+
svc: NatService;
|
|
140
|
+
mapper: FakeMapper;
|
|
141
|
+
nodes: FakeNodes;
|
|
142
|
+
}
|
|
131
143
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
function rig(opts: {
|
|
145
|
+
mapper?: FakeMapper;
|
|
146
|
+
nodes?: FakeNodes;
|
|
147
|
+
detector?: ExternalIpDetector;
|
|
148
|
+
fetch?: typeof fetch;
|
|
149
|
+
secrets?: SecretsStore;
|
|
150
|
+
} = {}): Rig {
|
|
151
|
+
const mapper = opts.mapper ?? new FakeMapper();
|
|
152
|
+
const nodes = opts.nodes ?? new FakeNodes();
|
|
153
|
+
const svc = new NatService({
|
|
154
|
+
rootDir: tmpRoot,
|
|
155
|
+
nodeSource: nodes,
|
|
156
|
+
now,
|
|
157
|
+
secretsStore: opts.secrets ?? makeSecrets(),
|
|
158
|
+
portMapper: mapper,
|
|
159
|
+
externalIpDetector: opts.detector ?? makeDetector({ pub: '203.0.113.10' }),
|
|
160
|
+
...(opts.fetch ? { fetch: opts.fetch } : {}),
|
|
145
161
|
});
|
|
162
|
+
nodes.announce = (id, ports) => svc.publicAddressesFor(id, ports);
|
|
163
|
+
return { svc, mapper, nodes };
|
|
164
|
+
}
|
|
146
165
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
cadreNode: makeNode(),
|
|
153
|
-
secretsStore: makeSecrets(),
|
|
154
|
-
portMapper: mapper,
|
|
155
|
-
externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
|
|
156
|
-
});
|
|
157
|
-
await svc.start();
|
|
158
|
-
expect(svc.getStatus().portMode).toBe('failed');
|
|
159
|
-
expect(svc.getStatus().directReachability).toBe('unreachable');
|
|
160
|
-
});
|
|
166
|
+
/** `start()` maps in the background; a second pass is sequenced after it. */
|
|
167
|
+
async function startAndSettle(svc: NatService): Promise<void> {
|
|
168
|
+
await svc.start();
|
|
169
|
+
await svc.reconcile();
|
|
170
|
+
}
|
|
161
171
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
secretsStore: makeSecrets(),
|
|
168
|
-
portMapper: mapper,
|
|
169
|
-
externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
|
|
170
|
-
});
|
|
171
|
-
await svc.start();
|
|
172
|
-
await svc.stop();
|
|
173
|
-
await svc.stop();
|
|
174
|
-
expect(mapper.unmapCalls).toBe(1);
|
|
175
|
-
expect(mapper.stopCalls).toBe(1);
|
|
176
|
-
});
|
|
172
|
+
function node(svc: NatService, id: string) {
|
|
173
|
+
const found = svc.getStatus().nodes.find((n) => n.nodeId === id);
|
|
174
|
+
if (!found) throw new Error(`node ${id} not in status`);
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
|
|
187
|
-
fetch: okFetch(),
|
|
188
|
-
});
|
|
189
|
-
await svc.start();
|
|
190
|
-
await svc.putDdns({
|
|
191
|
-
providerId: 'duckdns',
|
|
192
|
-
hostname: 'foo.duckdns.org',
|
|
193
|
-
config: { token: 'T' },
|
|
194
|
-
});
|
|
195
|
-
const addrs = await svc.getInviteAddresses();
|
|
196
|
-
expect(addrs).toEqual(['/dns4/foo.duckdns.org/tcp/4001/p2p/12D3KooWHost']);
|
|
197
|
-
});
|
|
178
|
+
/** Poll until `check` holds; for passes the service queues on its own, which nothing public awaits. */
|
|
179
|
+
async function waitUntil(check: () => boolean, timeoutMs = 2_000): Promise<void> {
|
|
180
|
+
const deadline = Date.now() + timeoutMs;
|
|
181
|
+
while (!check()) {
|
|
182
|
+
if (Date.now() > deadline) throw new Error('condition not met in time');
|
|
183
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
198
186
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const svc =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
187
|
+
describe('NatService — mapping table', () => {
|
|
188
|
+
it('maps both ports of every running node, and stop() unmaps nothing', async () => {
|
|
189
|
+
const { svc, mapper, nodes } = rig({ detector: makeDetector({ router: '203.0.113.10', pub: '203.0.113.10' }) });
|
|
190
|
+
nodes.add('grn_b', { p2p: 4001, ws: 10001 });
|
|
191
|
+
nodes.add('grn_a', { p2p: 10003, ws: 10004 });
|
|
192
|
+
await startAndSettle(svc);
|
|
193
|
+
|
|
194
|
+
const status = svc.getStatus();
|
|
195
|
+
expect(status.gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20', routerExternalIp: '203.0.113.10' });
|
|
196
|
+
expect(status.directReachability).toBe('reachable');
|
|
197
|
+
expect(node(svc, 'grn_a')).toMatchObject({
|
|
198
|
+
verdict: 'mapped',
|
|
199
|
+
tcp: { internalPort: 10003, externalPort: 10003, source: 'upnp' },
|
|
200
|
+
ws: { internalPort: 10004, externalPort: 10004, source: 'upnp' },
|
|
201
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/10003', '/ip4/203.0.113.10/tcp/10004/ws'],
|
|
207
202
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
expect(addrs).toEqual(['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWHost']);
|
|
211
|
-
});
|
|
203
|
+
expect(node(svc, 'grn_b').tcp.externalPort).toBe(4001);
|
|
204
|
+
expect(mapper.mapped.size).toBe(4);
|
|
212
205
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
mapper.mapFails = true;
|
|
216
|
-
const svc = new NatService({
|
|
217
|
-
rootDir: tmpRoot,
|
|
218
|
-
cadreNode: makeNode('12D3KooWHost', ['/ip4/10.0.0.5/tcp/4001']),
|
|
219
|
-
secretsStore: makeSecrets(),
|
|
220
|
-
portMapper: mapper,
|
|
221
|
-
externalIpDetector: makeDetector({ pub: null }),
|
|
222
|
-
});
|
|
223
|
-
await svc.start();
|
|
224
|
-
const addrs = await svc.getInviteAddresses();
|
|
225
|
-
expect(addrs).toEqual(['/ip4/10.0.0.5/tcp/4001/p2p/12D3KooWHost']);
|
|
206
|
+
await svc.stop();
|
|
207
|
+
expect(mapper.unmapCalls).toEqual([]);
|
|
226
208
|
});
|
|
227
209
|
|
|
228
|
-
it('
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const svc =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
expect(status.ddns.providerId).toBe('duckdns');
|
|
246
|
-
expect(status.ddns.hostname).toBe('h.duckdns.org');
|
|
247
|
-
expect(await secrets.get(ddnsAccount('duckdns', 'token'))).toBe('TOK');
|
|
248
|
-
});
|
|
210
|
+
it('a router that caps its mappings fails per port; the other node keeps its routes across a renewal', async () => {
|
|
211
|
+
const mapper = new FakeMapper();
|
|
212
|
+
mapper.cap = 3;
|
|
213
|
+
const { svc, nodes } = rig({ mapper });
|
|
214
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
215
|
+
nodes.add('b', { p2p: 10005, ws: 10006 });
|
|
216
|
+
await startAndSettle(svc);
|
|
217
|
+
|
|
218
|
+
expect(node(svc, 'a').verdict).toBe('mapped');
|
|
219
|
+
const b = node(svc, 'b');
|
|
220
|
+
expect(b.verdict).toBe('unreachable');
|
|
221
|
+
expect(b.tcp.externalPort).toBe(10005);
|
|
222
|
+
expect(b.ws).toMatchObject({ externalPort: null, error: expect.stringContaining('full') });
|
|
223
|
+
expect(b.reason).toContain('WebSocket port 10006');
|
|
224
|
+
expect(b.reason).toContain('192.168.1.20');
|
|
225
|
+
expect(b.publicAddrs).toEqual(['/ip4/203.0.113.10/tcp/10005']);
|
|
226
|
+
expect(svc.getStatus().directReachability).toBe('unreachable');
|
|
249
227
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
secretsStore: makeSecrets(),
|
|
256
|
-
portMapper: mapper,
|
|
257
|
-
externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
|
|
228
|
+
await svc.renewMappings();
|
|
229
|
+
expect(node(svc, 'a')).toMatchObject({
|
|
230
|
+
verdict: 'mapped',
|
|
231
|
+
tcp: { externalPort: 10003, source: 'upnp' },
|
|
232
|
+
ws: { externalPort: 10004, source: 'upnp' },
|
|
258
233
|
});
|
|
259
|
-
|
|
260
|
-
await expect(svc.putDdns({
|
|
261
|
-
providerId: 'no-such-provider',
|
|
262
|
-
hostname: 'x',
|
|
263
|
-
config: {},
|
|
264
|
-
})).rejects.toMatchObject({ code: 'ddns_provider_unknown' });
|
|
234
|
+
expect(node(svc, 'b').ws?.externalPort).toBeNull();
|
|
265
235
|
});
|
|
266
236
|
|
|
267
|
-
it('
|
|
268
|
-
const mapper = new
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
237
|
+
it('the route and the public addresses carry the external port the router assigned', async () => {
|
|
238
|
+
const mapper = new FakeMapper();
|
|
239
|
+
mapper.offset = 1;
|
|
240
|
+
const { svc, nodes } = rig({ mapper });
|
|
241
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
242
|
+
await startAndSettle(svc);
|
|
243
|
+
|
|
244
|
+
expect(node(svc, 'a')).toMatchObject({
|
|
245
|
+
tcp: { internalPort: 10003, externalPort: 10004 },
|
|
246
|
+
ws: { internalPort: 10004, externalPort: 10005 },
|
|
247
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/10004', '/ip4/203.0.113.10/tcp/10005/ws'],
|
|
275
248
|
});
|
|
276
|
-
|
|
277
|
-
expect(mapper.mapCalls).toBe(1);
|
|
249
|
+
});
|
|
278
250
|
|
|
279
|
-
|
|
280
|
-
|
|
251
|
+
it('a handle without a WebSocket port maps only its TCP port', async () => {
|
|
252
|
+
const { svc, nodes } = rig();
|
|
253
|
+
nodes.add('old', { p2p: 10003 });
|
|
254
|
+
await startAndSettle(svc);
|
|
281
255
|
|
|
282
|
-
|
|
283
|
-
expect(s.portMode).toBe('auto-upnp');
|
|
284
|
-
expect(mapper.mapCalls).toBeGreaterThanOrEqual(2);
|
|
256
|
+
expect(node(svc, 'old')).toMatchObject({ verdict: 'mapped', ws: null, publicAddrs: ['/ip4/203.0.113.10/tcp/10003'] });
|
|
285
257
|
});
|
|
286
258
|
|
|
287
|
-
it('
|
|
288
|
-
const mapper =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
expect(
|
|
298
|
-
|
|
299
|
-
expect(svc.getStatus().
|
|
259
|
+
it('a terminated node is unmapped at once and its manual forward is dropped', async () => {
|
|
260
|
+
const { svc, mapper, nodes } = rig();
|
|
261
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
262
|
+
await startAndSettle(svc);
|
|
263
|
+
await svc.putForward('a', { tcp: 40000 });
|
|
264
|
+
expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).toContain('"a"');
|
|
265
|
+
|
|
266
|
+
nodes.remove('a');
|
|
267
|
+
await svc.reconcile(); // sequenced after the pass the removal event queued
|
|
268
|
+
|
|
269
|
+
expect(mapper.unmapCalls).toContain(10004);
|
|
270
|
+
expect(mapper.mapped.size).toBe(0);
|
|
271
|
+
expect(svc.getStatus().nodes).toEqual([]);
|
|
272
|
+
expect(svc.getSettings().forwards).toEqual({});
|
|
273
|
+
expect(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).not.toContain('"a"');
|
|
300
274
|
});
|
|
301
275
|
|
|
302
|
-
it('
|
|
303
|
-
const svc =
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
expect(
|
|
313
|
-
|
|
276
|
+
it('a node stopped inside the grace keeps its mapping; past it the mapping is released and a respawn re-maps', async () => {
|
|
277
|
+
const { svc, mapper, nodes } = rig();
|
|
278
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
279
|
+
await startAndSettle(svc);
|
|
280
|
+
|
|
281
|
+
nodes.setStatus('a', 'stopped');
|
|
282
|
+
await svc.reconcile(); // the stop event's own pass records when the node was first seen stopped
|
|
283
|
+
clock += NAT_UNMAP_GRACE_MS - 1_000;
|
|
284
|
+
await svc.reconcile();
|
|
285
|
+
expect(mapper.unmapCalls).toEqual([]);
|
|
286
|
+
expect(node(svc, 'a')).toMatchObject({ running: false, tcp: { externalPort: 10003 } });
|
|
287
|
+
|
|
288
|
+
clock += 1_000;
|
|
289
|
+
await svc.reconcile();
|
|
290
|
+
expect(mapper.unmapCalls).toEqual([10003, 10004]);
|
|
291
|
+
expect(node(svc, 'a').tcp.externalPort).toBeNull();
|
|
292
|
+
|
|
293
|
+
nodes.setStatus('a', 'running');
|
|
294
|
+
await svc.reconcile();
|
|
295
|
+
expect(node(svc, 'a')).toMatchObject({ running: true, verdict: 'mapped', tcp: { externalPort: 10003 } });
|
|
314
296
|
});
|
|
315
|
-
});
|
|
316
297
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
298
|
+
it('a node spawned while an event-triggered pass runs is mapped by the pass that follows it', async () => {
|
|
299
|
+
const mapper = new FakeMapper();
|
|
300
|
+
const { svc, nodes } = rig({ mapper });
|
|
301
|
+
await startAndSettle(svc);
|
|
302
|
+
|
|
303
|
+
// The spawn of `a` queues an event pass; `b` spawns while that pass is mapping a's first port.
|
|
304
|
+
mapper.onMap = () => {
|
|
305
|
+
mapper.onMap = undefined;
|
|
306
|
+
nodes.add('b', { p2p: 10005, ws: 10006 });
|
|
322
307
|
};
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
portMapper: new StubMapper(),
|
|
328
|
-
externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
|
|
329
|
-
});
|
|
330
|
-
await svc.start();
|
|
331
|
-
const addrs = await svc.getInviteAddresses();
|
|
332
|
-
expect(addrs).toEqual(['/ip4/203.0.113.50/tcp/4001/p2p/12D3KooWAsync']);
|
|
308
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
309
|
+
|
|
310
|
+
await waitUntil(() => svc.getStatus().nodes.some((n) => n.nodeId === 'b' && n.verdict === 'mapped'));
|
|
311
|
+
expect(node(svc, 'a').verdict).toBe('mapped');
|
|
333
312
|
});
|
|
334
313
|
|
|
335
|
-
it('
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
});
|
|
347
|
-
await svc.start();
|
|
348
|
-
await expect(svc.getInviteAddresses()).rejects.toMatchObject({ code: 'node_unavailable' });
|
|
314
|
+
it('a router that answers a later probe maps the running nodes without a restart', async () => {
|
|
315
|
+
const mapper = new FakeMapper();
|
|
316
|
+
mapper.gateway = null;
|
|
317
|
+
const { svc, nodes } = rig({ mapper });
|
|
318
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
319
|
+
await startAndSettle(svc);
|
|
320
|
+
expect(node(svc, 'a').reason).toContain('No UPnP router answered');
|
|
321
|
+
|
|
322
|
+
mapper.gateway = { lanAddress: '192.168.1.20', routerHost: '192.168.1.1' };
|
|
323
|
+
await svc.redetect();
|
|
324
|
+
expect(svc.getStatus().gateway).toMatchObject({ found: true, lanAddress: '192.168.1.20' });
|
|
325
|
+
expect(node(svc, 'a')).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
349
326
|
});
|
|
327
|
+
});
|
|
350
328
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
329
|
+
describe('NatService — manual forwards and the UPnP toggle', () => {
|
|
330
|
+
it('a manual forward wins over UPnP and is re-requested when cleared', async () => {
|
|
331
|
+
const { svc, mapper, nodes } = rig();
|
|
332
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
333
|
+
await startAndSettle(svc);
|
|
334
|
+
|
|
335
|
+
let status = await svc.putForward('a', { tcp: 40000 });
|
|
336
|
+
expect(mapper.unmapCalls).toEqual([10003]);
|
|
337
|
+
expect(status.nodes[0]).toMatchObject({
|
|
338
|
+
verdict: 'manual',
|
|
339
|
+
tcp: { externalPort: 40000, source: 'manual' },
|
|
340
|
+
ws: { externalPort: 10004, source: 'upnp' },
|
|
341
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/10004/ws'],
|
|
358
342
|
});
|
|
359
|
-
|
|
343
|
+
expect(JSON.parse(readFileSync(join(tmpRoot, 'nat.json'), 'utf8')).forwards).toEqual({ a: { tcp: 40000 } });
|
|
360
344
|
|
|
361
|
-
|
|
362
|
-
|
|
345
|
+
status = await svc.putForward('a', { tcp: null });
|
|
346
|
+
expect(status.nodes[0]).toMatchObject({ verdict: 'mapped', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
347
|
+
expect(svc.getSettings().forwards).toEqual({});
|
|
348
|
+
});
|
|
363
349
|
|
|
364
|
-
|
|
365
|
-
|
|
350
|
+
it('putForward refuses an unknown node and an out-of-range port', async () => {
|
|
351
|
+
const { svc, nodes } = rig();
|
|
352
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
353
|
+
await startAndSettle(svc);
|
|
366
354
|
|
|
367
|
-
expect(
|
|
368
|
-
|
|
369
|
-
expect(fired[fired.length - 1]!.every((a: string) => a.includes('12D3KooWHook'))).toBe(true);
|
|
355
|
+
await expect(svc.putForward('nope', { tcp: 1 })).rejects.toMatchObject({ code: 'unknown_node' });
|
|
356
|
+
await expect(svc.putForward('a', { ws: 70000 })).rejects.toMatchObject({ code: 'invalid_config' });
|
|
370
357
|
});
|
|
371
358
|
|
|
372
|
-
it('
|
|
373
|
-
const svc =
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
359
|
+
it('turning UPnP off releases upnp routes and keeps manual ones', async () => {
|
|
360
|
+
const { svc, mapper, nodes } = rig();
|
|
361
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
362
|
+
await startAndSettle(svc);
|
|
363
|
+
await svc.putForward('a', { ws: 40004 });
|
|
364
|
+
|
|
365
|
+
const status = await svc.putSettings({ upnpEnabled: false });
|
|
366
|
+
expect(mapper.unmapCalls).toEqual([10004, 10003]);
|
|
367
|
+
const a = status.nodes[0]!;
|
|
368
|
+
expect(a.ws).toMatchObject({ externalPort: 40004, source: 'manual' });
|
|
369
|
+
expect(a.tcp).toMatchObject({ externalPort: null, source: null });
|
|
370
|
+
expect(a.verdict).toBe('unreachable');
|
|
371
|
+
expect(a.reason).toContain('UPnP is off');
|
|
372
|
+
expect(a.reason).toContain('TCP port 10003');
|
|
373
|
+
|
|
374
|
+
await svc.putSettings({ upnpEnabled: true });
|
|
375
|
+
expect(node(svc, 'a')).toMatchObject({ verdict: 'manual', tcp: { externalPort: 10003, source: 'upnp' } });
|
|
387
376
|
});
|
|
388
377
|
});
|
|
389
378
|
|
|
390
|
-
describe('NatService —
|
|
391
|
-
it('
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
407
|
-
|
|
408
|
-
await svc.start();
|
|
379
|
+
describe('NatService — public addresses', () => {
|
|
380
|
+
it('predicts the identity mapping for a port with no route yet, but not after a failed attempt', async () => {
|
|
381
|
+
const mapper = new FakeMapper();
|
|
382
|
+
mapper.cap = 1;
|
|
383
|
+
const { svc, nodes } = rig({ mapper });
|
|
384
|
+
await startAndSettle(svc);
|
|
385
|
+
|
|
386
|
+
expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual([
|
|
387
|
+
'/ip4/203.0.113.10/tcp/20000',
|
|
388
|
+
'/ip4/203.0.113.10/tcp/20001/ws',
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
nodes.add('new', { p2p: 20000, ws: 20001 });
|
|
392
|
+
await svc.reconcile();
|
|
393
|
+
expect(svc.publicAddressesFor('new', { p2p: 20000, ws: 20001 })).toEqual(['/ip4/203.0.113.10/tcp/20000']);
|
|
394
|
+
});
|
|
409
395
|
|
|
410
|
-
|
|
411
|
-
|
|
396
|
+
it('predicts nothing without a gateway, and a manual forward regardless', async () => {
|
|
397
|
+
const mapper = new FakeMapper();
|
|
398
|
+
mapper.gateway = null;
|
|
399
|
+
const { svc, nodes } = rig({ mapper });
|
|
400
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
401
|
+
await startAndSettle(svc);
|
|
402
|
+
|
|
403
|
+
expect(svc.getStatus().gateway).toMatchObject({ found: false, lastError: expect.stringContaining('no UPnP gateway') });
|
|
404
|
+
expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([]);
|
|
405
|
+
expect(node(svc, 'a').reason).toContain('No UPnP router answered');
|
|
406
|
+
|
|
407
|
+
await svc.putForward('a', { tcp: 40000, ws: 40001 });
|
|
408
|
+
expect(svc.publicAddressesFor('a', { p2p: 10003, ws: 10004 })).toEqual([
|
|
409
|
+
'/ip4/203.0.113.10/tcp/40000',
|
|
410
|
+
'/ip4/203.0.113.10/tcp/40001/ws',
|
|
411
|
+
]);
|
|
412
|
+
expect(node(svc, 'a').verdict).toBe('manual');
|
|
412
413
|
});
|
|
413
414
|
|
|
414
|
-
it('
|
|
415
|
-
const {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
cadreNode: node,
|
|
419
|
-
secretsStore: makeSecrets(),
|
|
420
|
-
portMapper: new StubMapper(),
|
|
421
|
-
externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
|
|
422
|
-
initialPushRetryMs: 1,
|
|
423
|
-
initialPushTimeoutMs: 2_000,
|
|
415
|
+
it('uses the full DDNS name of the provider as the host part, even when typed as a bare subdomain', async () => {
|
|
416
|
+
const { svc, nodes } = rig({
|
|
417
|
+
secrets: makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' }),
|
|
418
|
+
fetch: okFetch(),
|
|
424
419
|
});
|
|
420
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
421
|
+
await startAndSettle(svc);
|
|
425
422
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
await svc.start();
|
|
431
|
-
|
|
432
|
-
// Delivered exactly once, with the NAT-resolved address carrying the peer ID.
|
|
433
|
-
expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWFlaky']]);
|
|
434
|
-
// It took the failing attempts plus the successful one (proves it retried).
|
|
435
|
-
expect(peerIdCalls()).toBe(4);
|
|
423
|
+
const status = await svc.putDdns({ providerId: 'duckdns', hostname: 'foo', config: { token: 'T' } });
|
|
424
|
+
expect(status.ddns).toMatchObject({ providerId: 'duckdns', hostname: 'foo.duckdns.org' });
|
|
425
|
+
expect(status.nodes[0]!.publicAddrs).toEqual(['/dns4/foo.duckdns.org/tcp/10003', '/dns4/foo.duckdns.org/tcp/10004/ws']);
|
|
436
426
|
});
|
|
437
427
|
|
|
438
|
-
it('
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
428
|
+
it('under CGNAT a upnp route yields no address and the host rolls up to cgnat; a manual route still counts', async () => {
|
|
429
|
+
const { svc, nodes } = rig({ detector: makeDetector({ router: '100.64.0.5', pub: '203.0.113.10' }) });
|
|
430
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
431
|
+
await startAndSettle(svc);
|
|
432
|
+
|
|
433
|
+
const status = svc.getStatus();
|
|
434
|
+
expect(status.cgnatDetected).toBe(true);
|
|
435
|
+
expect(status.directReachability).toBe('cgnat');
|
|
436
|
+
expect(status.nodes[0]).toMatchObject({ verdict: 'unreachable', publicAddrs: [] });
|
|
437
|
+
expect(status.nodes[0]!.reason).toContain('carrier-grade NAT');
|
|
438
|
+
|
|
439
|
+
const after = await svc.putForward('a', { tcp: 40000, ws: 40001 });
|
|
440
|
+
expect(after.nodes[0]).toMatchObject({
|
|
441
|
+
verdict: 'manual',
|
|
442
|
+
publicAddrs: ['/ip4/203.0.113.10/tcp/40000', '/ip4/203.0.113.10/tcp/40001/ws'],
|
|
452
443
|
});
|
|
453
|
-
|
|
454
|
-
const fired: string[][] = [];
|
|
455
|
-
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
456
|
-
|
|
457
|
-
await svc.start(); // resolves despite the node never coming up
|
|
458
|
-
expect(fired).toEqual([]); // best-effort: nothing delivered
|
|
459
|
-
// The process is otherwise healthy — status still builds.
|
|
460
|
-
expect(svc.getStatus().portMode).toBe('auto-upnp');
|
|
444
|
+
expect(after.directReachability).toBe('reachable');
|
|
461
445
|
});
|
|
446
|
+
});
|
|
462
447
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
448
|
+
describe('NatService — stale announced addresses', () => {
|
|
449
|
+
it('asks to restart only the node whose router-assigned port differs from the one it announced, and only once', async () => {
|
|
450
|
+
const mapper = new FakeMapper();
|
|
451
|
+
mapper.reassigned.set(10006, 30006);
|
|
452
|
+
const { svc, nodes } = rig({ mapper });
|
|
453
|
+
await startAndSettle(svc);
|
|
454
|
+
const stale: string[] = [];
|
|
455
|
+
svc.onNodeAddressesStale((id) => { stale.push(id); });
|
|
456
|
+
|
|
457
|
+
// Both spawn announcing the predicted identity mapping.
|
|
458
|
+
nodes.add('same', { p2p: 10003, ws: 10004 });
|
|
459
|
+
nodes.add('moved', { p2p: 10005, ws: 10006 });
|
|
460
|
+
await svc.reconcile();
|
|
461
|
+
expect(stale).toEqual(['moved']);
|
|
462
|
+
|
|
463
|
+
nodes.restart('moved');
|
|
464
|
+
clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS;
|
|
465
|
+
await svc.reconcile();
|
|
466
|
+
expect(nodes.listNodes().find((n) => n.id === 'moved')!.announcedAddrs)
|
|
467
|
+
.toEqual(['/ip4/203.0.113.10/tcp/10005', '/ip4/203.0.113.10/tcp/30006/ws']);
|
|
468
|
+
expect(stale).toEqual(['moved']);
|
|
469
|
+
});
|
|
479
470
|
|
|
480
|
-
|
|
481
|
-
|
|
471
|
+
it('defers a second restart of one node until the rate-limit window has passed', async () => {
|
|
472
|
+
const mapper = new FakeMapper();
|
|
473
|
+
mapper.reassigned.set(10004, 30004);
|
|
474
|
+
const { svc, nodes } = rig({ mapper });
|
|
475
|
+
await startAndSettle(svc);
|
|
476
|
+
const stale: string[] = [];
|
|
477
|
+
svc.onNodeAddressesStale((id) => { stale.push(id); });
|
|
478
|
+
|
|
479
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
480
|
+
await svc.reconcile();
|
|
481
|
+
expect(stale).toEqual(['a']);
|
|
482
|
+
|
|
483
|
+
// Never restarted, so the difference stands at every later pass.
|
|
484
|
+
clock += NAT_ADDRESS_RESTART_MIN_INTERVAL_MS - 1;
|
|
485
|
+
await svc.reconcile();
|
|
486
|
+
expect(stale).toEqual(['a']);
|
|
487
|
+
|
|
488
|
+
clock += 1;
|
|
489
|
+
await svc.reconcile();
|
|
490
|
+
expect(stale).toEqual(['a', 'a']);
|
|
482
491
|
});
|
|
483
492
|
});
|
|
484
493
|
|
|
485
|
-
describe('
|
|
486
|
-
it('
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
494
|
+
describe('NatService — external IP and change notification', () => {
|
|
495
|
+
it('a re-detection that loses the public probe keeps the previous result, CGNAT flag included', async () => {
|
|
496
|
+
let offline = false;
|
|
497
|
+
const detector = new ExternalIpDetector({
|
|
498
|
+
routerProbe: async () => '100.64.0.5',
|
|
499
|
+
fetch: (async () => {
|
|
500
|
+
if (offline) throw new Error('offline');
|
|
501
|
+
return { ok: true, status: 200, async text() { return '203.0.113.10'; } };
|
|
502
|
+
}) as unknown as typeof fetch,
|
|
503
|
+
publicIpUrls: ['https://stub.test'],
|
|
495
504
|
});
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
505
|
+
const { svc } = rig({ detector });
|
|
506
|
+
await startAndSettle(svc);
|
|
507
|
+
expect(svc.getStatus()).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
|
|
508
|
+
|
|
509
|
+
// Only the router answers now: taking that result would drop the CGNAT verdict
|
|
510
|
+
// and read the carrier address as the host's.
|
|
511
|
+
offline = true;
|
|
512
|
+
const status = await svc.testReachability();
|
|
513
|
+
expect(status).toMatchObject({ externalIp: '203.0.113.10', cgnatDetected: true });
|
|
503
514
|
expect(status.lastTestedAt).not.toBeNull();
|
|
515
|
+
});
|
|
504
516
|
|
|
505
|
-
|
|
506
|
-
|
|
517
|
+
it('onChange fires when the snapshot changes and stays quiet across an idle pass', async () => {
|
|
518
|
+
const { svc, nodes } = rig();
|
|
519
|
+
const seen: string[] = [];
|
|
520
|
+
svc.onChange((snap) => { seen.push(snap.directReachability); });
|
|
521
|
+
await startAndSettle(svc);
|
|
522
|
+
const afterStart = seen.length;
|
|
523
|
+
expect(afterStart).toBeGreaterThan(0);
|
|
524
|
+
|
|
525
|
+
await svc.reconcile();
|
|
526
|
+
expect(seen.length).toBe(afterStart);
|
|
527
|
+
|
|
528
|
+
nodes.add('a', { p2p: 10003, ws: 10004 });
|
|
529
|
+
await svc.reconcile();
|
|
530
|
+
expect(seen.length).toBeGreaterThan(afterStart);
|
|
531
|
+
expect(seen[seen.length - 1]).toBe('reachable');
|
|
532
|
+
});
|
|
507
533
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
expect(ddnsStatus.ddns.hostname).toBe('a.duckdns.org');
|
|
534
|
+
it('putDdns with an unknown provider → ddns_provider_unknown', async () => {
|
|
535
|
+
const { svc } = rig();
|
|
536
|
+
await startAndSettle(svc);
|
|
537
|
+
await expect(svc.putDdns({ providerId: 'no-such-provider', hostname: 'x', config: {} }))
|
|
538
|
+
.rejects.toMatchObject({ code: 'ddns_provider_unknown' });
|
|
514
539
|
});
|
|
515
540
|
});
|