@serfab/cadre-host 0.7.1 → 0.9.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 +296 -101
- package/dist/auth/trust-circle.d.ts +45 -7
- package/dist/auth/trust-circle.js +116 -59
- 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/bin/host.js +426 -112
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -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 +51 -0
- package/dist/installer/config.js +14 -1
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.js +17 -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/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.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 +68 -1
- package/dist/orchestrator/host-process-orchestrator.js +272 -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/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/owner/owner-node-client.d.ts +95 -0
- package/dist/owner/owner-node-client.js +167 -0
- package/dist/owner/owner-node-client.js.map +1 -0
- 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 +17 -0
- package/dist/server/error-handler.js +45 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -3
- package/dist/server/routes/nodes.js +39 -24
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.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 +29 -14
- 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-owner.smoke.test.ts +123 -0
- package/src/__tests__/orchestrator-owner.test.ts +198 -0
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +145 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
- package/src/auth/__tests__/trust-circle.test.ts +91 -24
- package/src/auth/trust-circle.ts +363 -304
- package/src/auth/types.ts +4 -1
- package/src/bin/host.ts +1258 -905
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +66 -3
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +94 -0
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/browser.ts +40 -42
- package/src/installer/config.ts +61 -1
- package/src/installer/index.ts +23 -12
- package/src/installer/service-host/launchd.ts +1 -1
- package/src/installer/service-host/systemd.ts +1 -1
- package/src/installer/wizard.ts +12 -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 +322 -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/owner/__tests__/owner-node-client.test.ts +194 -0
- package/src/owner/index.ts +12 -0
- package/src/owner/owner-node-client.ts +233 -0
- package/src/push/__tests__/push.test.ts +90 -0
- package/src/push/index.ts +146 -0
- package/src/server/__tests__/error-handler.test.ts +26 -4
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +90 -10
- package/src/server/__tests__/publishers.test.ts +1 -1
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/static.test.ts +145 -0
- package/src/server/__tests__/status-route.test.ts +23 -2
- package/src/server/error-handler.ts +47 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +49 -26
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- 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 +42 -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 +31 -15
- package/src/update/sign.ts +74 -0
- package/dist/ui/assets/index-C9uChk2P.js +0 -14
|
@@ -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 { OwnerNodeUnavailableError } from '../../owner/owner-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 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
|
+
|
|
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 OwnerNodeUnavailableError('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,
|
package/src/nat/nat-service.ts
CHANGED
|
@@ -24,21 +24,37 @@ import {
|
|
|
24
24
|
type BuildInviteAddressesInput,
|
|
25
25
|
} from './address-resolver.js';
|
|
26
26
|
import { createSecretsStore, ddnsAccount, type SecretsStore } from './secrets/index.js';
|
|
27
|
+
import { OwnerNodeUnavailableError } from '../owner/owner-node-client.js';
|
|
27
28
|
|
|
28
29
|
const log = debug('cadre:host:nat-service');
|
|
29
30
|
|
|
31
|
+
/** Sleep helper for the initial-push retry loop; unref'd so it never holds the process open. */
|
|
32
|
+
function sleep(ms: number): Promise<void> {
|
|
33
|
+
return new Promise((res) => {
|
|
34
|
+
const t = setTimeout(res, ms);
|
|
35
|
+
if (typeof t.unref === 'function') t.unref();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/**
|
|
31
40
|
* Minimal slice of CadreNode that NatService needs — defined as an interface
|
|
32
41
|
* so tests can inject a mock without a real libp2p stack. Mirrors
|
|
33
42
|
* `CadreNodeLike` in `auth/trust-circle.ts`.
|
|
43
|
+
*
|
|
44
|
+
* Both methods are async: in production they round-trip over the owner
|
|
45
|
+
* node's loopback admin channel (`GET /admin/identity`, `GET /admin/multiaddrs`).
|
|
46
|
+
* Synchronous test mocks can return a value directly (it's awaited).
|
|
34
47
|
*/
|
|
35
48
|
export interface CadreNodeLike {
|
|
36
49
|
/** Returns this node's peer ID (libp2p) as a string. */
|
|
37
|
-
getPeerId(): string;
|
|
50
|
+
getPeerId(): Promise<string> | string;
|
|
38
51
|
/** Returns the current observed multiaddrs from libp2p. */
|
|
39
|
-
getMultiaddrs(): string[];
|
|
52
|
+
getMultiaddrs(): Promise<string[]> | string[];
|
|
40
53
|
}
|
|
41
54
|
|
|
55
|
+
/** Listener notified when the invite addresses may have changed. */
|
|
56
|
+
export type AddressesChangedListener = (addresses: string[]) => void | Promise<void>;
|
|
57
|
+
|
|
42
58
|
export interface NatServiceOptions {
|
|
43
59
|
/** Cadre-host root directory (same one orchestrator + trust-circle use). */
|
|
44
60
|
rootDir: string;
|
|
@@ -56,6 +72,10 @@ export interface NatServiceOptions {
|
|
|
56
72
|
externalIpDetector?: ExternalIpDetector;
|
|
57
73
|
/** Optional: re-use an existing NatStore (mostly for tests). */
|
|
58
74
|
store?: NatStore;
|
|
75
|
+
/** Initial-push retry: poll interval while the node is not-yet-ready. Default 250ms. */
|
|
76
|
+
initialPushRetryMs?: number;
|
|
77
|
+
/** Initial-push retry: total budget before giving up (best-effort). Default 15000ms. */
|
|
78
|
+
initialPushTimeoutMs?: number;
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
/**
|
|
@@ -94,10 +114,16 @@ export class NatService {
|
|
|
94
114
|
private latestIp: ExternalIpResult | null = null;
|
|
95
115
|
private lastTestedAt: Date | null = null;
|
|
96
116
|
private started = false;
|
|
117
|
+
/** Listeners notified when the invite addresses may have changed. */
|
|
118
|
+
private readonly addressListeners = new Set<AddressesChangedListener>();
|
|
97
119
|
|
|
98
120
|
// For createSecretsStore async init.
|
|
99
121
|
private readonly secretsRootDir: string;
|
|
100
122
|
|
|
123
|
+
// Initial invite-address push retry tunables (injectable so tests run fast).
|
|
124
|
+
private readonly initialPushRetryMs: number;
|
|
125
|
+
private readonly initialPushTimeoutMs: number;
|
|
126
|
+
|
|
101
127
|
constructor(opts: NatServiceOptions) {
|
|
102
128
|
this.cadreNode = opts.cadreNode;
|
|
103
129
|
this.store = opts.store ?? new NatStore(opts.rootDir);
|
|
@@ -107,6 +133,8 @@ export class NatService {
|
|
|
107
133
|
this.portMapper = opts.portMapper ?? null;
|
|
108
134
|
this.injectedDetector = opts.externalIpDetector ?? null;
|
|
109
135
|
this.secretsRootDir = opts.rootDir;
|
|
136
|
+
this.initialPushRetryMs = opts.initialPushRetryMs ?? 250;
|
|
137
|
+
this.initialPushTimeoutMs = opts.initialPushTimeoutMs ?? 15_000;
|
|
110
138
|
this.currentSettings = this.store.load();
|
|
111
139
|
}
|
|
112
140
|
|
|
@@ -135,6 +163,10 @@ export class NatService {
|
|
|
135
163
|
now: this.nowFn,
|
|
136
164
|
});
|
|
137
165
|
await this.ddnsUpdater.start();
|
|
166
|
+
|
|
167
|
+
// Initial invite-address push. Retries on `node_unavailable` until the
|
|
168
|
+
// freshly spawned owner node accepts it (bounded; best-effort after).
|
|
169
|
+
await this.pushInitialAddresses();
|
|
138
170
|
}
|
|
139
171
|
|
|
140
172
|
/** Release the port mapping and clear timers. Idempotent. */
|
|
@@ -198,12 +230,34 @@ export class NatService {
|
|
|
198
230
|
if (this.ddnsUpdater) {
|
|
199
231
|
await this.ddnsUpdater.forceUpdate().catch((err) => log('ddns forceUpdate err: %s', (err as Error).message));
|
|
200
232
|
}
|
|
233
|
+
// Reachability/IP may have changed → invite addresses may have changed.
|
|
234
|
+
await this.fireAddressesChanged();
|
|
201
235
|
return this.getStatus();
|
|
202
236
|
}
|
|
203
237
|
|
|
204
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Build the multiaddrs to embed in invites. The peer ID and libp2p
|
|
240
|
+
* fallback addresses are fetched over the owner node's admin channel; a
|
|
241
|
+
* node-unavailable failure surfaces as `NatError('node_unavailable')`.
|
|
242
|
+
*/
|
|
205
243
|
async getInviteAddresses(): Promise<string[]> {
|
|
206
|
-
|
|
244
|
+
let peerId: string;
|
|
245
|
+
let libp2pAddrs: string[];
|
|
246
|
+
try {
|
|
247
|
+
peerId = await this.cadreNode.getPeerId();
|
|
248
|
+
libp2pAddrs = await this.cadreNode.getMultiaddrs();
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (err instanceof OwnerNodeUnavailableError) {
|
|
251
|
+
throw new NatError('node_unavailable', `Owner node unavailable: ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
// A reachable node that hasn't yet established its libp2p peer ID reports
|
|
256
|
+
// an empty peerId. Building addresses now would mint malformed `…/p2p/`
|
|
257
|
+
// suffixes (and push them to the node), so treat it as not-ready.
|
|
258
|
+
if (!peerId) {
|
|
259
|
+
throw new NatError('node_unavailable', 'Owner node has no peer ID yet (not ready)');
|
|
260
|
+
}
|
|
207
261
|
const status = this.getStatus();
|
|
208
262
|
const input: BuildInviteAddressesInput = {
|
|
209
263
|
peerId,
|
|
@@ -211,11 +265,79 @@ export class NatService {
|
|
|
211
265
|
ddnsHostname: status.ddns.hostname,
|
|
212
266
|
externalIp: status.externalIp,
|
|
213
267
|
reachable: status.directReachability === 'reachable',
|
|
214
|
-
libp2pAddrs
|
|
268
|
+
libp2pAddrs,
|
|
215
269
|
};
|
|
216
270
|
return buildInviteAddresses(input);
|
|
217
271
|
}
|
|
218
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Register a listener fired when the invite addresses may have changed
|
|
275
|
+
* (after `start`, `putSettings`, and `testReachability`). `cadre-host start`
|
|
276
|
+
* wires this to `OwnerNodeClient.pushInviteAddresses`. Returns an
|
|
277
|
+
* unsubscribe fn.
|
|
278
|
+
*/
|
|
279
|
+
onAddressesChanged(listener: AddressesChangedListener): () => void {
|
|
280
|
+
this.addressListeners.add(listener);
|
|
281
|
+
return () => { this.addressListeners.delete(listener); };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Recompute invite addresses and notify listeners. Best-effort: when the
|
|
286
|
+
* owner node is unreachable (or the build fails), the notification is
|
|
287
|
+
* skipped — the push is retried on the next NAT event.
|
|
288
|
+
*/
|
|
289
|
+
private async fireAddressesChanged(): Promise<void> {
|
|
290
|
+
if (this.addressListeners.size === 0) return;
|
|
291
|
+
let addresses: string[];
|
|
292
|
+
try {
|
|
293
|
+
addresses = await this.getInviteAddresses();
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log('onAddressesChanged skipped: %s', (err as Error).message);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
await this.notifyAddressListeners(addresses);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Deliver an address set to every registered listener (errors are logged, not thrown). */
|
|
302
|
+
private async notifyAddressListeners(addresses: string[]): Promise<void> {
|
|
303
|
+
for (const listener of this.addressListeners) {
|
|
304
|
+
try {
|
|
305
|
+
await listener(addresses);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
log('address listener threw: %s', (err as Error).message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Initial invite-address push. The owner node's admin channel is a freshly
|
|
314
|
+
* spawned detached child that may not be bound yet (and may not have a libp2p
|
|
315
|
+
* peer ID yet), so a single attempt races readiness and silently drops the first
|
|
316
|
+
* NAT-resolved address set. Retry on `node_unavailable` until the node accepts it
|
|
317
|
+
* or the bounded budget elapses (best-effort thereafter). Awaited by start() so
|
|
318
|
+
* the management API — the invite-minting path — does not come up first.
|
|
319
|
+
*/
|
|
320
|
+
private async pushInitialAddresses(): Promise<void> {
|
|
321
|
+
if (this.addressListeners.size === 0) return;
|
|
322
|
+
const deadline = this.nowFn().getTime() + this.initialPushTimeoutMs;
|
|
323
|
+
for (;;) {
|
|
324
|
+
if (!this.started) return; // stop() ran during a retry
|
|
325
|
+
try {
|
|
326
|
+
const addresses = await this.getInviteAddresses();
|
|
327
|
+
await this.notifyAddressListeners(addresses);
|
|
328
|
+
return;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const code = err instanceof NatError ? err.code : undefined;
|
|
331
|
+
// Only retry transient not-ready; surface anything unexpected via log + give up.
|
|
332
|
+
if (code !== 'node_unavailable' || this.nowFn().getTime() >= deadline) {
|
|
333
|
+
log('initial invite-address push not delivered: %s', (err as Error).message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await sleep(this.initialPushRetryMs);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
219
341
|
/** Current settings (cheap read; for handlers to surface to UI). */
|
|
220
342
|
getSettings(): NatSettingsFile {
|
|
221
343
|
return this.currentSettings;
|
|
@@ -244,6 +366,10 @@ export class NatService {
|
|
|
244
366
|
if (ddnsChanged && this.ddnsUpdater) {
|
|
245
367
|
await this.ddnsUpdater.updateSettings(next.ddns);
|
|
246
368
|
}
|
|
369
|
+
if (upnpChanged || portChanged || ddnsChanged) {
|
|
370
|
+
// Port / DDNS changes alter the advertised invite addresses.
|
|
371
|
+
await this.fireAddressesChanged();
|
|
372
|
+
}
|
|
247
373
|
}
|
|
248
374
|
return this.getStatus();
|
|
249
375
|
}
|
package/src/nat/secrets/index.ts
CHANGED
|
@@ -40,8 +40,17 @@ export async function createSecretsStore(rootDir: string): Promise<SecretsStore>
|
|
|
40
40
|
return makeFileFallback(rootDir, `keytar not installed: ${(err as Error).message}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// `keytar` is a CJS package; under Node's ESM interop the dynamic import
|
|
44
|
+
// yields a Module Namespace whose `default` is the real `module.exports`.
|
|
45
|
+
// Unwrap before handing it to KeytarSecretsStore.
|
|
46
|
+
const keytar = resolveKeytarLike(keytarMod);
|
|
47
|
+
if (!keytar) {
|
|
48
|
+
log('keytar module loaded but API surface missing; using file fallback');
|
|
49
|
+
return makeFileFallback(rootDir, 'keytar module loaded but API surface missing');
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
// Probe with a sentinel write — some installs load but throw at runtime.
|
|
44
|
-
const ks = new KeytarSecretsStore(KEYTAR_SERVICE,
|
|
53
|
+
const ks = new KeytarSecretsStore(KEYTAR_SERVICE, keytar);
|
|
45
54
|
const probeAccount = '__cadre_host_probe__';
|
|
46
55
|
try {
|
|
47
56
|
await ks.set(probeAccount, '1');
|
|
@@ -54,9 +63,30 @@ export async function createSecretsStore(rootDir: string): Promise<SecretsStore>
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Return the first candidate in `[mod, mod.default]` that satisfies the
|
|
68
|
+
* `KeytarLike` shape — or `null` if neither does. Handles the CJS-via-ESM
|
|
69
|
+
* interop case where the dynamic import yields `{ default: keytar }` rather
|
|
70
|
+
* than the keytar surface directly.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveKeytarLike(mod: unknown): KeytarLike | null {
|
|
73
|
+
const candidates: unknown[] = [mod, (mod as { default?: unknown } | null)?.default];
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
if (
|
|
76
|
+
c &&
|
|
77
|
+
typeof (c as KeytarLike).setPassword === 'function' &&
|
|
78
|
+
typeof (c as KeytarLike).getPassword === 'function' &&
|
|
79
|
+
typeof (c as KeytarLike).deletePassword === 'function' &&
|
|
80
|
+
typeof (c as KeytarLike).findCredentials === 'function'
|
|
81
|
+
) {
|
|
82
|
+
return c as KeytarLike;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
function makeFileFallback(rootDir: string, reason: string): SecretsStore {
|
|
58
89
|
const store = new FileSecretsStore(rootDir);
|
|
59
|
-
// eslint-disable-next-line no-console
|
|
60
90
|
console.warn(
|
|
61
91
|
`[cadre-host] DDNS credentials will be stored UNENCRYPTED at ` +
|
|
62
92
|
`${store.filePath()} (${reason}). Install keytar's native dependencies ` +
|
package/src/nat/types.ts
CHANGED
|
@@ -106,7 +106,9 @@ export type NatErrorCode =
|
|
|
106
106
|
| 'ddns_update_failed'
|
|
107
107
|
| 'secrets_unavailable'
|
|
108
108
|
| 'storage_error'
|
|
109
|
-
| 'invalid_config'
|
|
109
|
+
| 'invalid_config'
|
|
110
|
+
/** The owner node's admin channel is unreachable / not ready. */
|
|
111
|
+
| 'node_unavailable';
|
|
110
112
|
|
|
111
113
|
/** Typed error carrying a stable `code` for HTTP mapping in the local-ui. */
|
|
112
114
|
export class NatError extends Error {
|