@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/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 { AuthorityNodeUnavailableError } from '../authority/authority-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 authority
|
|
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 authority 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 authority 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 AuthorityNodeUnavailableError) {
|
|
251
|
+
throw new NatError('node_unavailable', `Authority 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', 'Authority 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 `AuthorityNodeClient.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
|
+
* authority 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 authority 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 authority 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 {
|