@serfab/cadre-host 0.7.1 → 0.8.1
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 +296 -101
- package/dist/auth/trust-circle.d.ts +44 -6
- package/dist/auth/trust-circle.js +115 -58
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +4 -1
- package/dist/auth/types.js.map +1 -1
- package/dist/authority/authority-node-client.d.ts +77 -0
- package/dist/authority/authority-node-client.js +143 -0
- package/dist/authority/authority-node-client.js.map +1 -0
- package/dist/authority/index.d.ts +11 -0
- package/dist/authority/index.js +11 -0
- package/dist/authority/index.js.map +1 -0
- package/dist/bin/host.js +210 -97
- package/dist/bin/host.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/installer/browser.js +16 -17
- package/dist/installer/browser.js.map +1 -1
- package/dist/installer/config.d.ts +25 -0
- package/dist/installer/config.js +1 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +5 -2
- package/dist/installer/index.js +14 -10
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/service-host/launchd.js +1 -1
- package/dist/installer/service-host/launchd.js.map +1 -1
- package/dist/installer/service-host/systemd.js +1 -1
- package/dist/installer/service-host/systemd.js.map +1 -1
- package/dist/nat/index.d.ts +1 -1
- package/dist/nat/nat-service.d.ts +45 -3
- package/dist/nat/nat-service.js +116 -3
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/secrets/index.d.ts +7 -0
- package/dist/nat/secrets/index.js +28 -2
- package/dist/nat/secrets/index.js.map +1 -1
- package/dist/nat/types.d.ts +3 -1
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +60 -1
- package/dist/orchestrator/host-process-orchestrator.js +246 -39
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -2
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +6 -5
- package/dist/orchestrator/state-store.js +10 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +45 -10
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/push/index.d.ts +67 -0
- package/dist/push/index.js +101 -0
- package/dist/push/index.js.map +1 -0
- package/dist/server/error-handler.d.ts +2 -0
- package/dist/server/error-handler.js +4 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +4 -3
- package/dist/server/routes/nodes.js +38 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/static.d.ts +12 -0
- package/dist/server/static.js +24 -7
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-BlMXQPSw.js +14 -0
- package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/update/apply.d.ts +23 -1
- package/dist/update/apply.js +46 -4
- package/dist/update/apply.js.map +1 -1
- package/dist/update/index.d.ts +14 -3
- package/dist/update/index.js +35 -2
- package/dist/update/index.js.map +1 -1
- package/dist/update/manifest.d.ts +28 -9
- package/dist/update/manifest.js +56 -29
- package/dist/update/manifest.js.map +1 -1
- package/dist/update/release-key.d.ts +18 -7
- package/dist/update/release-key.js +28 -15
- package/dist/update/release-key.js.map +1 -1
- package/dist/update/sign.d.ts +46 -0
- package/dist/update/sign.js +53 -0
- package/dist/update/sign.js.map +1 -0
- package/package.json +9 -4
- package/service/README.md +95 -86
- package/src/__tests__/cli-invite.smoke.test.ts +6 -1
- package/src/__tests__/cli-nat.smoke.test.ts +6 -1
- package/src/__tests__/cli.smoke.test.ts +6 -1
- package/src/__tests__/host-authority.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-authority.test.ts +198 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +40 -21
- package/src/auth/__tests__/trust-circle.test.ts +90 -23
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/authority/__tests__/authority-node-client.test.ts +166 -0
- package/src/authority/authority-node-client.ts +199 -0
- package/src/authority/index.ts +12 -0
- package/src/bin/host.ts +1038 -905
- package/src/index.ts +34 -3
- package/src/installer/__tests__/installer.smoke.test.ts +64 -0
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +27 -1
- package/src/installer/index.ts +18 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/nat/__tests__/ddns-updater.test.ts +1 -1
- package/src/nat/__tests__/nat-service.test.ts +192 -0
- package/src/nat/__tests__/port-mapper.test.ts +1 -1
- package/src/nat/__tests__/secrets.test.ts +23 -1
- package/src/nat/index.ts +1 -1
- package/src/nat/nat-service.ts +131 -5
- package/src/nat/secrets/index.ts +32 -2
- package/src/nat/types.ts +3 -1
- package/src/orchestrator/host-process-orchestrator.ts +294 -54
- package/src/orchestrator/index.ts +9 -2
- package/src/orchestrator/state-store.ts +21 -3
- package/src/orchestrator/types.ts +48 -2
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +4 -4
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +2 -2
- package/src/server/error-handler.ts +4 -0
- package/src/server/routes/nodes.ts +48 -26
- package/src/server/static.ts +24 -8
- package/src/update/__tests__/apply.test.ts +58 -1
- package/src/update/__tests__/manifest.test.ts +41 -6
- package/src/update/__tests__/release-key.test.ts +29 -0
- package/src/update/__tests__/sign.test.ts +78 -0
- package/src/update/__tests__/update-service.integration.test.ts +44 -9
- package/src/update/apply.ts +46 -4
- package/src/update/index.ts +41 -3
- package/src/update/manifest.ts +214 -172
- package/src/update/release-key.ts +30 -16
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
package/src/index.ts
CHANGED
|
@@ -27,14 +27,20 @@ export type {
|
|
|
27
27
|
ContainerResources,
|
|
28
28
|
} from '@serfab/cadre-provider';
|
|
29
29
|
|
|
30
|
-
export { HostProcessOrchestrator } from './orchestrator/index.js';
|
|
30
|
+
export { HostProcessOrchestrator, AUTHORITY_CONTAINER_ID } from './orchestrator/index.js';
|
|
31
31
|
export type {
|
|
32
32
|
HostProcessConfig,
|
|
33
33
|
PersistedHandle,
|
|
34
34
|
ManagedNodeInfo,
|
|
35
35
|
NodeStateListener,
|
|
36
|
+
NodePorts,
|
|
37
|
+
AuthorityAdminEndpoint,
|
|
38
|
+
AuthoritySpawnConfig,
|
|
36
39
|
} from './orchestrator/index.js';
|
|
37
40
|
|
|
41
|
+
export { AuthorityNodeClient, AuthorityNodeUnavailableError } from './authority/index.js';
|
|
42
|
+
export type { AuthorityNodeClientOptions } from './authority/index.js';
|
|
43
|
+
|
|
38
44
|
export {
|
|
39
45
|
TrustCircleService,
|
|
40
46
|
TrustCircleStore,
|
|
@@ -54,13 +60,14 @@ export type {
|
|
|
54
60
|
TrustCircleErrorCode,
|
|
55
61
|
} from './auth/index.js';
|
|
56
62
|
|
|
57
|
-
export { Installer } from './installer/index.js';
|
|
63
|
+
export { Installer, readHostConfig, updateHostConfig, writeHostConfig } from './installer/index.js';
|
|
58
64
|
export type {
|
|
59
65
|
InstallOptions,
|
|
60
66
|
InstallResult,
|
|
61
67
|
UninstallOptions,
|
|
62
68
|
InstallerOptions,
|
|
63
69
|
HostConfigFile,
|
|
70
|
+
PushSettings,
|
|
64
71
|
WizardAnswers,
|
|
65
72
|
WizardDefaults,
|
|
66
73
|
ServiceHost,
|
|
@@ -68,6 +75,23 @@ export type {
|
|
|
68
75
|
ServiceHostStatus,
|
|
69
76
|
} from './installer/index.js';
|
|
70
77
|
|
|
78
|
+
/* ──────────────── push credentials (FCM/APNs) ──────────────── */
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
resolvePushCredentials,
|
|
82
|
+
setFcmSecret,
|
|
83
|
+
setApnsSecret,
|
|
84
|
+
clearPushSecret,
|
|
85
|
+
pushAccount,
|
|
86
|
+
pushStatus,
|
|
87
|
+
} from './push/index.js';
|
|
88
|
+
export type {
|
|
89
|
+
FcmSecret,
|
|
90
|
+
ApnsSecret,
|
|
91
|
+
PushSecretPlatform,
|
|
92
|
+
PushStatus,
|
|
93
|
+
} from './push/index.js';
|
|
94
|
+
|
|
71
95
|
export {
|
|
72
96
|
UpdateService,
|
|
73
97
|
createUpdateHandlers,
|
|
@@ -78,21 +102,28 @@ export {
|
|
|
78
102
|
fetchManifest,
|
|
79
103
|
verifyManifest,
|
|
80
104
|
canonicalJson,
|
|
81
|
-
|
|
105
|
+
signManifest,
|
|
106
|
+
validateManifestFields,
|
|
107
|
+
buildManifest,
|
|
108
|
+
derivePublicKeyBase64,
|
|
109
|
+
signAndSelfVerify,
|
|
82
110
|
compareVersions,
|
|
83
111
|
parseVersion,
|
|
84
112
|
getReleasePublicKey,
|
|
85
113
|
getReleasePublicKeyBase64,
|
|
86
114
|
ed25519FromRaw,
|
|
115
|
+
isPlaceholderReleaseKey,
|
|
87
116
|
} from './update/index.js';
|
|
88
117
|
export type {
|
|
89
118
|
UpdateServiceOptions,
|
|
90
119
|
UpdateHandlers,
|
|
91
120
|
UpdateState,
|
|
121
|
+
SignedManifest,
|
|
92
122
|
UpdateApplyResult,
|
|
93
123
|
UpdateSettings,
|
|
94
124
|
UpdateManifest,
|
|
95
125
|
UpdateErrorCode,
|
|
126
|
+
ManifestFields,
|
|
96
127
|
NpmExecutor,
|
|
97
128
|
ServiceRestarter,
|
|
98
129
|
} from './update/index.js';
|
|
@@ -108,6 +108,70 @@ describe('Installer smoke', () => {
|
|
|
108
108
|
expect(fake.installCalls).toHaveLength(0);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it('passes CLI overrides to the wizard as suggested defaults', async () => {
|
|
112
|
+
// Regression: the interactive wizard previously showed the platform
|
|
113
|
+
// default (`~/.local/share/cadre-host`) in the prompt even when the
|
|
114
|
+
// caller passed `--data-dir <path>`. Whatever the user typed was then
|
|
115
|
+
// silently discarded because the CLI flag won post-hoc. The fix: CLI
|
|
116
|
+
// overrides become the wizard's suggested defaults, and the wizard's
|
|
117
|
+
// returned answers stand on their own.
|
|
118
|
+
const fake = new FakeServiceHost();
|
|
119
|
+
const installer = new Installer({ platform: 'linux' });
|
|
120
|
+
let seenDefaults: { dataDir: string; uiPort: number; libp2pPort: number; upnpEnabled: boolean } | undefined;
|
|
121
|
+
await installer.install({
|
|
122
|
+
nonInteractive: false,
|
|
123
|
+
dataDir: tmp,
|
|
124
|
+
uiPort: 19997,
|
|
125
|
+
libp2pPort: 14003,
|
|
126
|
+
noUpnp: true,
|
|
127
|
+
openBrowser: false,
|
|
128
|
+
noInvite: true,
|
|
129
|
+
serviceHost: fake,
|
|
130
|
+
wizard: async (defaults) => {
|
|
131
|
+
seenDefaults = { ...defaults };
|
|
132
|
+
// Simulate the user pressing Enter at every prompt.
|
|
133
|
+
return {
|
|
134
|
+
dataDir: defaults.dataDir,
|
|
135
|
+
uiPort: defaults.uiPort,
|
|
136
|
+
libp2pPort: defaults.libp2pPort,
|
|
137
|
+
upnpEnabled: defaults.upnpEnabled,
|
|
138
|
+
configureDdns: false,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(seenDefaults).toEqual({
|
|
144
|
+
dataDir: tmp,
|
|
145
|
+
uiPort: 19997,
|
|
146
|
+
libp2pPort: 14003,
|
|
147
|
+
upnpEnabled: false,
|
|
148
|
+
});
|
|
149
|
+
expect(fake.installCalls).toHaveLength(1);
|
|
150
|
+
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('honors wizard-typed input when no CLI override is passed', async () => {
|
|
154
|
+
// Mirror of the previous test: with no `--data-dir`, the wizard's
|
|
155
|
+
// returned value is what the installer uses.
|
|
156
|
+
const fake = new FakeServiceHost();
|
|
157
|
+
const installer = new Installer({ platform: 'linux' });
|
|
158
|
+
await installer.install({
|
|
159
|
+
nonInteractive: false,
|
|
160
|
+
openBrowser: false,
|
|
161
|
+
noInvite: true,
|
|
162
|
+
serviceHost: fake,
|
|
163
|
+
wizard: async () => ({
|
|
164
|
+
dataDir: tmp,
|
|
165
|
+
uiPort: 19996,
|
|
166
|
+
libp2pPort: 14004,
|
|
167
|
+
upnpEnabled: true,
|
|
168
|
+
configureDdns: false,
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
expect(fake.installCalls).toHaveLength(1);
|
|
172
|
+
expect(fake.installCalls[0]!.dataDir).toBe(tmp);
|
|
173
|
+
});
|
|
174
|
+
|
|
111
175
|
it('re-running install preserves the existing identity', async () => {
|
|
112
176
|
const fake = new FakeServiceHost();
|
|
113
177
|
const installer = new Installer({ platform: 'linux' });
|
package/src/installer/browser.ts
CHANGED
|
@@ -1,42 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Best-effort URL opener. Never throws — if the platform launcher isn't
|
|
3
|
-
* available (headless box, missing xdg-utils, etc.) we just log the URL.
|
|
4
|
-
*
|
|
5
|
-
* Implemented inline rather than pulling in the `open` npm dep so the
|
|
6
|
-
* installer keeps its dependency tree small.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
10
|
-
|
|
11
|
-
export interface OpenBrowserResult {
|
|
12
|
-
attempted: boolean;
|
|
13
|
-
spawned: boolean;
|
|
14
|
-
/** Set when the spawn attempt errored synchronously. */
|
|
15
|
-
error?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): OpenBrowserResult {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return { attempted: true, spawned:
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort URL opener. Never throws — if the platform launcher isn't
|
|
3
|
+
* available (headless box, missing xdg-utils, etc.) we just log the URL.
|
|
4
|
+
*
|
|
5
|
+
* Implemented inline rather than pulling in the `open` npm dep so the
|
|
6
|
+
* installer keeps its dependency tree small.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
export interface OpenBrowserResult {
|
|
12
|
+
attempted: boolean;
|
|
13
|
+
spawned: boolean;
|
|
14
|
+
/** Set when the spawn attempt errored synchronously. */
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): OpenBrowserResult {
|
|
19
|
+
if (platform === 'darwin') return spawnOpener('open', [url]);
|
|
20
|
+
if (platform === 'win32') return spawnOpener('rundll32', ['url.dll,FileProtocolHandler', url]);
|
|
21
|
+
// linux + everything else POSIX-ish
|
|
22
|
+
return spawnOpener('xdg-open', [url]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function spawnOpener(cmd: string, args: string[]): OpenBrowserResult {
|
|
26
|
+
try {
|
|
27
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
28
|
+
// The launcher's absence (ENOENT on a headless box without xdg-utils,
|
|
29
|
+
// etc.) is reported asynchronously via the child's `error` event, not
|
|
30
|
+
// by `spawn()` throwing. Without a listener, Node escalates to an
|
|
31
|
+
// unhandled-error crash and kills the install. Swallow it: this is a
|
|
32
|
+
// best-effort convenience, the install / UI commands have already
|
|
33
|
+
// printed the URL.
|
|
34
|
+
child.on('error', () => { /* best-effort */ });
|
|
35
|
+
child.unref();
|
|
36
|
+
return { attempted: true, spawned: true };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return { attempted: true, spawned: false, error: (err as Error).message };
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/installer/config.ts
CHANGED
|
@@ -21,6 +21,26 @@ export interface UpdatesConfig {
|
|
|
21
21
|
manifestUrl?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Non-secret push (FCM/APNs) settings. The actual private keys (and the FCM
|
|
26
|
+
* service-account / APNs key identifiers) live in the secret store — see
|
|
27
|
+
* `src/push/`. Only these app-config bits, which are safe to keep in plaintext
|
|
28
|
+
* `host.config.json`, are stored here. Absent ⇒ push is not configured.
|
|
29
|
+
*/
|
|
30
|
+
export interface PushSettings {
|
|
31
|
+
/** APNs app-config (the `.p8` private key + key/team ids stay in the secret store). */
|
|
32
|
+
apns?: {
|
|
33
|
+
/** App bundle id → APNs `apns-topic` header. */
|
|
34
|
+
bundleId: string;
|
|
35
|
+
/** `true` → production APNs host; false/undefined → sandbox (dev/TestFlight). */
|
|
36
|
+
production?: boolean;
|
|
37
|
+
};
|
|
38
|
+
/** Per-(peer,strand) anti-spam cooldown (ms) forwarded into the node's push block. */
|
|
39
|
+
cooldownMs?: number;
|
|
40
|
+
/** Per-strand burst-coalescing window (ms) forwarded into the node's push block. */
|
|
41
|
+
debounceMs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export interface HostConfigFile {
|
|
25
45
|
version: 2;
|
|
26
46
|
/** A stable per-install id; useful for log correlation and the control-network partyId. */
|
|
@@ -41,6 +61,12 @@ export interface HostConfigFile {
|
|
|
41
61
|
installerVersion: string;
|
|
42
62
|
/** Update-flow settings; defaults: { autoApply: false }. */
|
|
43
63
|
updates: UpdatesConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Non-secret push settings (bundle id, sandbox/prod toggle, cooldown/debounce).
|
|
66
|
+
* Optional — absent when push is not configured. Private keys never live here;
|
|
67
|
+
* they are read from the secret store at node-spawn time (`src/push/`).
|
|
68
|
+
*/
|
|
69
|
+
push?: PushSettings;
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
const CURRENT_VERSION = 2;
|
|
@@ -75,7 +101,7 @@ export function readHostConfig(filePath: string): HostConfigFile {
|
|
|
75
101
|
try {
|
|
76
102
|
parsed = JSON.parse(raw);
|
|
77
103
|
} catch (err) {
|
|
78
|
-
throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}
|
|
104
|
+
throw new Error(`Failed to parse host.config.json at ${filePath}: ${(err as Error).message}`, { cause: err });
|
|
79
105
|
}
|
|
80
106
|
if (!parsed || typeof parsed !== 'object') {
|
|
81
107
|
throw new Error(`host.config.json at ${filePath} is not a JSON object`);
|
package/src/installer/index.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import { join } from 'node:path';
|
|
15
14
|
import debug from 'debug';
|
|
16
15
|
|
|
17
16
|
import { NatStore } from '../nat/index.js';
|
|
@@ -38,9 +37,11 @@ import {
|
|
|
38
37
|
DEFAULT_UI_PORT,
|
|
39
38
|
DEFAULT_LIBP2P_PORT,
|
|
40
39
|
type WizardAnswers,
|
|
40
|
+
type WizardDefaults,
|
|
41
41
|
} from './wizard.js';
|
|
42
42
|
|
|
43
|
-
export type { HostConfigFile } from './config.js';
|
|
43
|
+
export type { HostConfigFile, PushSettings } from './config.js';
|
|
44
|
+
export { readHostConfig, updateHostConfig, writeHostConfig } from './config.js';
|
|
44
45
|
export type { WizardAnswers, WizardDefaults } from './wizard.js';
|
|
45
46
|
export type { ServiceHost, ServiceHostContext, ServiceHostStatus } from './service-host/index.js';
|
|
46
47
|
|
|
@@ -67,6 +68,8 @@ export interface InstallOptions {
|
|
|
67
68
|
nodePath?: string;
|
|
68
69
|
/** Test-only: stub the service-host registration. */
|
|
69
70
|
serviceHost?: ServiceHost;
|
|
71
|
+
/** Test-only: stub the interactive wizard. Production callers leave unset. */
|
|
72
|
+
wizard?: (defaults: WizardDefaults) => Promise<WizardAnswers>;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export interface InstallResult {
|
|
@@ -232,17 +235,20 @@ export class Installer {
|
|
|
232
235
|
if (opts.nonInteractive) {
|
|
233
236
|
return this.resolveAnswersFromOptions(opts);
|
|
234
237
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
// CLI flags overlay the platform defaults so the wizard's `[...]` hint
|
|
239
|
+
// shows the user's chosen value. Pressing Enter accepts it; typing a new
|
|
240
|
+
// value also works (and now actually has an effect — the previous
|
|
241
|
+
// implementation showed the platform default in the prompt and silently
|
|
242
|
+
// discarded user input because the CLI flag won post-hoc).
|
|
243
|
+
const platformDefaults = defaultsForPlatform(this.platform);
|
|
244
|
+
const defaults: WizardDefaults = {
|
|
245
|
+
dataDir: opts.dataDir ?? platformDefaults.dataDir,
|
|
246
|
+
uiPort: opts.uiPort ?? platformDefaults.uiPort,
|
|
247
|
+
libp2pPort: opts.libp2pPort ?? platformDefaults.libp2pPort,
|
|
248
|
+
upnpEnabled: opts.noUpnp ? false : platformDefaults.upnpEnabled,
|
|
245
249
|
};
|
|
250
|
+
const wizard = opts.wizard ?? runWizard;
|
|
251
|
+
return await wizard(defaults);
|
|
246
252
|
}
|
|
247
253
|
}
|
|
248
254
|
|
|
@@ -85,6 +85,6 @@ function run(cmd: string, args: string[]): void {
|
|
|
85
85
|
try {
|
|
86
86
|
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
87
87
|
} catch (err) {
|
|
88
|
-
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}
|
|
88
|
+
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`, { cause: err });
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -83,6 +83,6 @@ function run(cmd: string, args: string[]): void {
|
|
|
83
83
|
try {
|
|
84
84
|
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
85
85
|
} catch (err) {
|
|
86
|
-
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}
|
|
86
|
+
throw new Error(`${cmd} ${args.join(' ')} failed: ${(err as Error).message}`, { cause: err });
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -67,7 +67,7 @@ describe('DdnsUpdater', () => {
|
|
|
67
67
|
it('no-op when externallyManaged', async () => {
|
|
68
68
|
const provider = makeProvider();
|
|
69
69
|
const secrets = makeSecrets({ [ddnsAccount('duckdns', 'token')]: 'T' });
|
|
70
|
-
|
|
70
|
+
const ip: string | null = '1.2.3.4';
|
|
71
71
|
const u = new DdnsUpdater({
|
|
72
72
|
settings: { ...baseSettings, externallyManaged: true },
|
|
73
73
|
secrets,
|
|
@@ -9,6 +9,7 @@ import type { PortMapper, PortMappingResult } from '../port-mapper.js';
|
|
|
9
9
|
import type { SecretsStore } from '../secrets/index.js';
|
|
10
10
|
import { ddnsAccount } from '../secrets/index.js';
|
|
11
11
|
import type { CadreNodeLike } from '../nat-service.js';
|
|
12
|
+
import { AuthorityNodeUnavailableError } from '../../authority/authority-node-client.js';
|
|
12
13
|
|
|
13
14
|
class StubMapper implements PortMapper {
|
|
14
15
|
mapCalls = 0;
|
|
@@ -51,6 +52,29 @@ function makeNode(peerId = '12D3KooWHost', addrs: string[] = ['/ip4/192.168.1.10
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A node that mimics a freshly spawned authority child: `getPeerId` throws
|
|
57
|
+
* `AuthorityNodeUnavailableError` 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 AuthorityNodeUnavailableError('admin channel not bound yet');
|
|
71
|
+
return peerId;
|
|
72
|
+
},
|
|
73
|
+
getMultiaddrs: async () => addrs,
|
|
74
|
+
};
|
|
75
|
+
return { node, peerIdCalls: () => calls };
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
function makeDetector(opts: { router?: string | null; pub?: string | null }): ExternalIpDetector {
|
|
55
79
|
return new ExternalIpDetector({
|
|
56
80
|
routerProbe: opts.router === undefined ? undefined : async () => opts.router ?? null,
|
|
@@ -273,6 +297,174 @@ describe('NatService', () => {
|
|
|
273
297
|
});
|
|
274
298
|
});
|
|
275
299
|
|
|
300
|
+
describe('NatService — async channel node + onAddressesChanged', () => {
|
|
301
|
+
it('getInviteAddresses awaits async getPeerId/getMultiaddrs from the channel', async () => {
|
|
302
|
+
const asyncNode: CadreNodeLike = {
|
|
303
|
+
getPeerId: async () => '12D3KooWAsync',
|
|
304
|
+
getMultiaddrs: async () => ['/ip4/10.0.0.9/tcp/4001'],
|
|
305
|
+
};
|
|
306
|
+
const svc = new NatService({
|
|
307
|
+
rootDir: tmpRoot,
|
|
308
|
+
cadreNode: asyncNode,
|
|
309
|
+
secretsStore: makeSecrets(),
|
|
310
|
+
portMapper: new StubMapper(),
|
|
311
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
|
|
312
|
+
});
|
|
313
|
+
await svc.start();
|
|
314
|
+
const addrs = await svc.getInviteAddresses();
|
|
315
|
+
expect(addrs).toEqual(['/ip4/203.0.113.50/tcp/4001/p2p/12D3KooWAsync']);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('throws node_unavailable when the node reports an empty peer ID (not ready)', async () => {
|
|
319
|
+
const notReadyNode: CadreNodeLike = {
|
|
320
|
+
getPeerId: async () => '',
|
|
321
|
+
getMultiaddrs: async () => [],
|
|
322
|
+
};
|
|
323
|
+
const svc = new NatService({
|
|
324
|
+
rootDir: tmpRoot,
|
|
325
|
+
cadreNode: notReadyNode,
|
|
326
|
+
secretsStore: makeSecrets(),
|
|
327
|
+
portMapper: new StubMapper(),
|
|
328
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.50' }),
|
|
329
|
+
});
|
|
330
|
+
await svc.start();
|
|
331
|
+
await expect(svc.getInviteAddresses()).rejects.toMatchObject({ code: 'node_unavailable' });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('fires onAddressesChanged on putSettings and testReachability', async () => {
|
|
335
|
+
const svc = new NatService({
|
|
336
|
+
rootDir: tmpRoot,
|
|
337
|
+
cadreNode: makeNode('12D3KooWHook'),
|
|
338
|
+
secretsStore: makeSecrets(),
|
|
339
|
+
portMapper: new StubMapper(),
|
|
340
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.7' }),
|
|
341
|
+
});
|
|
342
|
+
await svc.start();
|
|
343
|
+
|
|
344
|
+
const fired: string[][] = [];
|
|
345
|
+
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
346
|
+
|
|
347
|
+
await svc.putSettings({ externalPort: 4002 });
|
|
348
|
+
await svc.testReachability();
|
|
349
|
+
|
|
350
|
+
expect(fired.length).toBeGreaterThanOrEqual(2);
|
|
351
|
+
// The pushed addresses carry the node's peer ID.
|
|
352
|
+
expect(fired[fired.length - 1]!.every((a: string) => a.includes('12D3KooWHook'))).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('onAddressesChanged unsubscribe stops further notifications', async () => {
|
|
356
|
+
const svc = new NatService({
|
|
357
|
+
rootDir: tmpRoot,
|
|
358
|
+
cadreNode: makeNode(),
|
|
359
|
+
secretsStore: makeSecrets(),
|
|
360
|
+
portMapper: new StubMapper(),
|
|
361
|
+
externalIpDetector: makeDetector({ pub: '1.2.3.4' }),
|
|
362
|
+
});
|
|
363
|
+
await svc.start();
|
|
364
|
+
let count = 0;
|
|
365
|
+
const off = svc.onAddressesChanged(() => { count++; });
|
|
366
|
+
await svc.testReachability();
|
|
367
|
+
off();
|
|
368
|
+
await svc.testReachability();
|
|
369
|
+
expect(count).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('NatService — initial invite-address push retry', () => {
|
|
374
|
+
it('delivers the initial push exactly once when the node is ready on the first attempt', async () => {
|
|
375
|
+
// The common production happy path: node bound fast, listener registered
|
|
376
|
+
// BEFORE start(). No retry needed — but the push must still fire (once).
|
|
377
|
+
const { node, peerIdCalls } = makeFlakyNode(0, '12D3KooWReady');
|
|
378
|
+
const svc = new NatService({
|
|
379
|
+
rootDir: tmpRoot,
|
|
380
|
+
cadreNode: node,
|
|
381
|
+
secretsStore: makeSecrets(),
|
|
382
|
+
portMapper: new StubMapper(),
|
|
383
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
|
|
384
|
+
initialPushRetryMs: 1,
|
|
385
|
+
initialPushTimeoutMs: 2_000,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const fired: string[][] = [];
|
|
389
|
+
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
390
|
+
|
|
391
|
+
await svc.start();
|
|
392
|
+
|
|
393
|
+
expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWReady']]);
|
|
394
|
+
expect(peerIdCalls()).toBe(1); // succeeded on the first attempt, no retry
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('retries past a not-yet-ready node and delivers the push exactly once', async () => {
|
|
398
|
+
const { node, peerIdCalls } = makeFlakyNode(3, '12D3KooWFlaky');
|
|
399
|
+
const svc = new NatService({
|
|
400
|
+
rootDir: tmpRoot,
|
|
401
|
+
cadreNode: node,
|
|
402
|
+
secretsStore: makeSecrets(),
|
|
403
|
+
portMapper: new StubMapper(),
|
|
404
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
|
|
405
|
+
initialPushRetryMs: 1,
|
|
406
|
+
initialPushTimeoutMs: 2_000,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Listener registered BEFORE start() — this is the race the fix closes.
|
|
410
|
+
const fired: string[][] = [];
|
|
411
|
+
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
412
|
+
|
|
413
|
+
await svc.start();
|
|
414
|
+
|
|
415
|
+
// Delivered exactly once, with the NAT-resolved address carrying the peer ID.
|
|
416
|
+
expect(fired).toEqual([['/ip4/203.0.113.42/tcp/4001/p2p/12D3KooWFlaky']]);
|
|
417
|
+
// It took the failing attempts plus the successful one (proves it retried).
|
|
418
|
+
expect(peerIdCalls()).toBe(4);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('gives up after the bounded timeout but still resolves start() (best-effort)', async () => {
|
|
422
|
+
// Always unavailable — the retry budget elapses without a successful push.
|
|
423
|
+
const node: CadreNodeLike = {
|
|
424
|
+
getPeerId: async () => { throw new AuthorityNodeUnavailableError('never ready'); },
|
|
425
|
+
getMultiaddrs: async () => [],
|
|
426
|
+
};
|
|
427
|
+
const svc = new NatService({
|
|
428
|
+
rootDir: tmpRoot,
|
|
429
|
+
cadreNode: node,
|
|
430
|
+
secretsStore: makeSecrets(),
|
|
431
|
+
portMapper: new StubMapper(),
|
|
432
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
|
|
433
|
+
initialPushRetryMs: 2,
|
|
434
|
+
initialPushTimeoutMs: 20,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const fired: string[][] = [];
|
|
438
|
+
svc.onAddressesChanged((addrs) => { fired.push(addrs); });
|
|
439
|
+
|
|
440
|
+
await svc.start(); // resolves despite the node never coming up
|
|
441
|
+
expect(fired).toEqual([]); // best-effort: nothing delivered
|
|
442
|
+
// The process is otherwise healthy — status still builds.
|
|
443
|
+
expect(svc.getStatus().portMode).toBe('auto-upnp');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('does not retry on a non-node_unavailable error', async () => {
|
|
447
|
+
let calls = 0;
|
|
448
|
+
const node: CadreNodeLike = {
|
|
449
|
+
getPeerId: async () => { calls += 1; throw new Error('boom'); },
|
|
450
|
+
getMultiaddrs: async () => [],
|
|
451
|
+
};
|
|
452
|
+
const svc = new NatService({
|
|
453
|
+
rootDir: tmpRoot,
|
|
454
|
+
cadreNode: node,
|
|
455
|
+
secretsStore: makeSecrets(),
|
|
456
|
+
portMapper: new StubMapper(),
|
|
457
|
+
externalIpDetector: makeDetector({ pub: '203.0.113.42' }),
|
|
458
|
+
initialPushRetryMs: 1,
|
|
459
|
+
initialPushTimeoutMs: 2_000,
|
|
460
|
+
});
|
|
461
|
+
svc.onAddressesChanged(() => { /* registered before start */ });
|
|
462
|
+
|
|
463
|
+
await svc.start(); // does not hang retrying a non-transient failure
|
|
464
|
+
expect(calls).toBe(1);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
276
468
|
describe('createNatHandlers', () => {
|
|
277
469
|
it('every handler delegates to the service', async () => {
|
|
278
470
|
const mapper = new StubMapper();
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { KeytarSecretsStore } from '../secrets/keytar-store.js';
|
|
7
7
|
import { FileSecretsStore } from '../secrets/file-store.js';
|
|
8
|
-
import { ddnsAccount, type KeytarLike } from '../secrets/index.js';
|
|
8
|
+
import { ddnsAccount, resolveKeytarLike, type KeytarLike } from '../secrets/index.js';
|
|
9
9
|
import { NatError } from '../types.js';
|
|
10
10
|
|
|
11
11
|
function makeFakeKeytar(): {
|
|
@@ -60,6 +60,28 @@ describe('ddnsAccount()', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe('resolveKeytarLike()', () => {
|
|
64
|
+
it('unwraps the CJS-via-ESM default export shape', () => {
|
|
65
|
+
const { keytar } = makeFakeKeytar();
|
|
66
|
+
expect(resolveKeytarLike({ default: keytar })).toBe(keytar);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('passes through a direct named-export shape', () => {
|
|
70
|
+
const { keytar } = makeFakeKeytar();
|
|
71
|
+
expect(resolveKeytarLike(keytar)).toBe(keytar);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns null when the shape is missing methods', () => {
|
|
75
|
+
expect(resolveKeytarLike({})).toBeNull();
|
|
76
|
+
expect(resolveKeytarLike({ default: {} })).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns null for nullish input without crashing', () => {
|
|
80
|
+
expect(resolveKeytarLike(null)).toBeNull();
|
|
81
|
+
expect(resolveKeytarLike(undefined)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
63
85
|
describe('KeytarSecretsStore', () => {
|
|
64
86
|
it('round-trips a credential', async () => {
|
|
65
87
|
const { keytar } = makeFakeKeytar();
|
package/src/nat/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export { NatService, createNatHandlers } from './nat-service.js';
|
|
9
|
-
export type { NatServiceOptions, CadreNodeLike } from './nat-service.js';
|
|
9
|
+
export type { NatServiceOptions, CadreNodeLike, AddressesChangedListener } from './nat-service.js';
|
|
10
10
|
export { NatStore } from './nat-store.js';
|
|
11
11
|
export {
|
|
12
12
|
NatError,
|