@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/dist/bin/host.js
CHANGED
|
@@ -25,6 +25,9 @@ import { UpdateService } from '../update/index.js';
|
|
|
25
25
|
import { HostProcessOrchestrator } from '../orchestrator/index.js';
|
|
26
26
|
import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
|
|
27
27
|
import { NatService } from '../nat/index.js';
|
|
28
|
+
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
29
|
+
import { resolvePushCredentials, setFcmSecret, setApnsSecret, clearPushSecret, pushStatus, } from '../push/index.js';
|
|
30
|
+
import { AuthorityNodeClient } from '../authority/index.js';
|
|
28
31
|
import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
|
|
29
32
|
import { openBrowser } from '../installer/browser.js';
|
|
30
33
|
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
@@ -36,7 +39,6 @@ function looksLikePeerId(id) {
|
|
|
36
39
|
function resolvePort(raw) {
|
|
37
40
|
const n = Number(raw);
|
|
38
41
|
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
39
|
-
// eslint-disable-next-line no-console
|
|
40
42
|
console.error(`Invalid --port: ${raw}`);
|
|
41
43
|
process.exit(1);
|
|
42
44
|
}
|
|
@@ -76,13 +78,9 @@ program
|
|
|
76
78
|
system: Boolean(opts.system),
|
|
77
79
|
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
78
80
|
});
|
|
79
|
-
// eslint-disable-next-line no-console
|
|
80
81
|
console.log(`cadre-host installed.`);
|
|
81
|
-
// eslint-disable-next-line no-console
|
|
82
82
|
console.log(` Data dir: ${result.dataDir}`);
|
|
83
|
-
// eslint-disable-next-line no-console
|
|
84
83
|
console.log(` UI: ${result.uiUrl}`);
|
|
85
|
-
// eslint-disable-next-line no-console
|
|
86
84
|
console.log(` Service: ${result.serviceName}`);
|
|
87
85
|
if (result.enrollmentInvite) {
|
|
88
86
|
printEnrollmentInvite(result.enrollmentInvite);
|
|
@@ -90,7 +88,6 @@ program
|
|
|
90
88
|
process.exit(0);
|
|
91
89
|
}
|
|
92
90
|
catch (err) {
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
91
|
console.error(`install failed: ${err.message}`);
|
|
95
92
|
process.exit(1);
|
|
96
93
|
}
|
|
@@ -129,12 +126,10 @@ program
|
|
|
129
126
|
removeData: Boolean(opts.removeData),
|
|
130
127
|
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
131
128
|
});
|
|
132
|
-
// eslint-disable-next-line no-console
|
|
133
129
|
console.log('cadre-host uninstalled.');
|
|
134
130
|
process.exit(0);
|
|
135
131
|
}
|
|
136
132
|
catch (err) {
|
|
137
|
-
// eslint-disable-next-line no-console
|
|
138
133
|
console.error(`uninstall failed: ${err.message}`);
|
|
139
134
|
process.exit(1);
|
|
140
135
|
}
|
|
@@ -149,14 +144,11 @@ program
|
|
|
149
144
|
const installer = new Installer();
|
|
150
145
|
try {
|
|
151
146
|
const status = await installer.status();
|
|
152
|
-
// eslint-disable-next-line no-console
|
|
153
147
|
console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
|
|
154
|
-
// eslint-disable-next-line no-console
|
|
155
148
|
console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
|
|
156
149
|
process.exit(0);
|
|
157
150
|
}
|
|
158
151
|
catch (err) {
|
|
159
|
-
// eslint-disable-next-line no-console
|
|
160
152
|
console.error(`status failed: ${err.message}`);
|
|
161
153
|
process.exit(1);
|
|
162
154
|
}
|
|
@@ -176,7 +168,6 @@ program
|
|
|
176
168
|
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
177
169
|
const cfgPath = resolveConfigPath(dataDir);
|
|
178
170
|
if (!existsSync(cfgPath)) {
|
|
179
|
-
// eslint-disable-next-line no-console
|
|
180
171
|
console.error(`cadre-host start: ${cfgPath} not found. ` +
|
|
181
172
|
`Run \`cadre-host install\` first (or pass --data-dir to an existing install).`);
|
|
182
173
|
process.exit(1);
|
|
@@ -185,12 +176,10 @@ program
|
|
|
185
176
|
const cfg = readHostConfig(cfgPath);
|
|
186
177
|
const idPath = resolveIdentityPath(cfg.dataDir);
|
|
187
178
|
if (!existsSync(idPath)) {
|
|
188
|
-
// eslint-disable-next-line no-console
|
|
189
179
|
console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
|
|
190
180
|
process.exit(1);
|
|
191
181
|
return;
|
|
192
182
|
}
|
|
193
|
-
// eslint-disable-next-line no-console
|
|
194
183
|
console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
|
|
195
184
|
// Update flow: notify-by-default; auto-apply opt-in via host.config.json.
|
|
196
185
|
// The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
|
|
@@ -223,29 +212,66 @@ program
|
|
|
223
212
|
updateHostConfig(cfgPath, { updates: next });
|
|
224
213
|
}
|
|
225
214
|
catch (err) {
|
|
226
|
-
// eslint-disable-next-line no-console
|
|
227
215
|
console.error(`failed to persist updated settings to ${cfgPath}: ${err.message}`);
|
|
228
216
|
}
|
|
229
217
|
});
|
|
230
218
|
// Don't block startup on the network round-trip — call and forget.
|
|
231
219
|
void updateService.check();
|
|
232
220
|
updateService.start();
|
|
233
|
-
// Wire the long-lived HTTP management server. The
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
221
|
+
// Wire the long-lived HTTP management server. The manager holds no
|
|
222
|
+
// in-process cadre node: it spawns the admin's **authority node** as a
|
|
223
|
+
// managed child and delegates authority/membership/identity operations
|
|
224
|
+
// to it over the node's loopback admin channel (the 6.6 contract). The
|
|
225
|
+
// manager never joins the control network (see docs/cadre-host.md
|
|
226
|
+
// § Control-plane separation).
|
|
227
|
+
// Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
|
|
228
|
+
// restart re-reads the secret store (rotated keys) and nothing raw is
|
|
229
|
+
// persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
|
|
230
|
+
// cooldown/debounce) are re-read from host.config.json each call too.
|
|
231
|
+
const pushSecrets = await createSecretsStore(cfg.dataDir);
|
|
232
|
+
const orchestrator = new HostProcessOrchestrator({
|
|
233
|
+
rootDir: join(cfg.dataDir, 'orchestrator'),
|
|
234
|
+
pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
|
|
235
|
+
});
|
|
241
236
|
await orchestrator.init();
|
|
237
|
+
// Spawn the authority node. Best-effort: a spawn failure leaves the
|
|
238
|
+
// management API up (trust-circle listing degrades to local labels,
|
|
239
|
+
// authority ops return 503) rather than taking down the whole process.
|
|
240
|
+
try {
|
|
241
|
+
await orchestrator.ensureAuthorityNode({
|
|
242
|
+
identityPath: idPath,
|
|
243
|
+
partyId: cfg.installId,
|
|
244
|
+
libp2pPort: cfg.libp2pPort,
|
|
245
|
+
});
|
|
246
|
+
console.log('cadre-host: authority node spawned');
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
console.error(`authority node spawn failed: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
// The client reads the admin endpoint lazily so a node restart's fresh
|
|
252
|
+
// bearer token is picked up automatically.
|
|
253
|
+
const authority = new AuthorityNodeClient(() => orchestrator.getAuthorityAdminEndpoint());
|
|
242
254
|
const trustCircle = new TrustCircleService({
|
|
243
|
-
cadreNode:
|
|
255
|
+
cadreNode: authority,
|
|
244
256
|
store: new TrustCircleStore(cfg.dataDir),
|
|
245
257
|
});
|
|
246
258
|
const natService = new NatService({
|
|
247
259
|
rootDir: cfg.dataDir,
|
|
248
|
-
cadreNode:
|
|
260
|
+
cadreNode: authority,
|
|
261
|
+
});
|
|
262
|
+
// Push NAT-resolved invite addresses to the node on every NAT change.
|
|
263
|
+
// NatService.start() also fires this once as an initial push, retried
|
|
264
|
+
// until the freshly spawned authority node accepts it (bounded; the
|
|
265
|
+
// management API that mints invites comes up only after start() resolves).
|
|
266
|
+
natService.onAddressesChanged(async (addresses) => {
|
|
267
|
+
if (addresses.length === 0)
|
|
268
|
+
return;
|
|
269
|
+
try {
|
|
270
|
+
await authority.pushInviteAddresses(addresses);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
console.error(`invite-address push failed: ${err.message}`);
|
|
274
|
+
}
|
|
249
275
|
});
|
|
250
276
|
// Best-effort NAT start. Failures here aren't fatal — the local UI
|
|
251
277
|
// can still serve the trust circle, settings, etc.
|
|
@@ -253,7 +279,6 @@ program
|
|
|
253
279
|
await natService.start();
|
|
254
280
|
}
|
|
255
281
|
catch (err) {
|
|
256
|
-
// eslint-disable-next-line no-console
|
|
257
282
|
console.error(`NAT start failed: ${err.message}`);
|
|
258
283
|
}
|
|
259
284
|
const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
|
|
@@ -268,10 +293,8 @@ program
|
|
|
268
293
|
});
|
|
269
294
|
const { url, port } = await server.start();
|
|
270
295
|
if (port !== cfg.uiPort) {
|
|
271
|
-
// eslint-disable-next-line no-console
|
|
272
296
|
console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
|
|
273
297
|
}
|
|
274
|
-
// eslint-disable-next-line no-console
|
|
275
298
|
console.log(`cadre-host local UI: ${url}`);
|
|
276
299
|
await waitForTermination();
|
|
277
300
|
try {
|
|
@@ -282,13 +305,15 @@ program
|
|
|
282
305
|
await natService.stop();
|
|
283
306
|
}
|
|
284
307
|
catch { /* ignore */ }
|
|
308
|
+
try {
|
|
309
|
+
await orchestrator.stopAuthorityNode();
|
|
310
|
+
}
|
|
311
|
+
catch { /* ignore */ }
|
|
285
312
|
updateService.stop();
|
|
286
|
-
// eslint-disable-next-line no-console
|
|
287
313
|
console.log('cadre-host stopped.');
|
|
288
314
|
process.exit(0);
|
|
289
315
|
}
|
|
290
316
|
catch (err) {
|
|
291
|
-
// eslint-disable-next-line no-console
|
|
292
317
|
console.error(`start failed: ${err.message}`);
|
|
293
318
|
process.exit(1);
|
|
294
319
|
}
|
|
@@ -307,30 +332,25 @@ program
|
|
|
307
332
|
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
308
333
|
const cfgPath = resolveConfigPath(dataDir);
|
|
309
334
|
if (!existsSync(cfgPath)) {
|
|
310
|
-
// eslint-disable-next-line no-console
|
|
311
335
|
console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
|
|
312
336
|
process.exit(1);
|
|
313
337
|
return;
|
|
314
338
|
}
|
|
315
339
|
const cfg = readHostConfig(cfgPath);
|
|
316
340
|
const url = `http://127.0.0.1:${cfg.uiPort}`;
|
|
317
|
-
// eslint-disable-next-line no-console
|
|
318
341
|
console.log(url);
|
|
319
342
|
if (opts.browser !== false) {
|
|
320
343
|
const res = openBrowser(url);
|
|
321
344
|
if (res.spawned) {
|
|
322
|
-
// eslint-disable-next-line no-console
|
|
323
345
|
console.log('(Opening in your default browser...)');
|
|
324
346
|
}
|
|
325
347
|
else if (res.error) {
|
|
326
|
-
// eslint-disable-next-line no-console
|
|
327
348
|
console.error(`(unable to launch browser: ${res.error})`);
|
|
328
349
|
}
|
|
329
350
|
}
|
|
330
351
|
process.exit(0);
|
|
331
352
|
}
|
|
332
353
|
catch (err) {
|
|
333
|
-
// eslint-disable-next-line no-console
|
|
334
354
|
console.error(`ui failed: ${err.message}`);
|
|
335
355
|
process.exit(1);
|
|
336
356
|
}
|
|
@@ -355,28 +375,6 @@ function readPackageVersionForStart() {
|
|
|
355
375
|
return '0.0.0-unknown';
|
|
356
376
|
}
|
|
357
377
|
}
|
|
358
|
-
/**
|
|
359
|
-
* Returns a `CadreNodeLike` for TrustCircleService that fails loudly on any
|
|
360
|
-
* libp2p call. cadre-host v1 doesn't yet spawn the host's own cadre node —
|
|
361
|
-
* that ticket is separate. The trust-circle HTTP endpoints still load the
|
|
362
|
-
* local labels/pending file (listing works), but issuing/redeeming an invite
|
|
363
|
-
* requires a real node and surfaces a 500.
|
|
364
|
-
*/
|
|
365
|
-
function missingCadreNodeStub(name) {
|
|
366
|
-
return {
|
|
367
|
-
async createInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot issue invite`); },
|
|
368
|
-
async acceptPhone() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot redeem`); },
|
|
369
|
-
async removePeer() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot remove peer`); },
|
|
370
|
-
encodeInvite() { throw new Error(`${name}: cadre-host v1 has no inline cadre node — cannot encode invite`); },
|
|
371
|
-
getControlDatabase() { return null; },
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
function missingNatNodeStub() {
|
|
375
|
-
return {
|
|
376
|
-
getPeerId() { return ''; },
|
|
377
|
-
getMultiaddrs() { return []; },
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
378
|
function waitForTermination() {
|
|
381
379
|
return new Promise((resolveTerm) => {
|
|
382
380
|
const done = () => resolveTerm();
|
|
@@ -398,23 +396,18 @@ async function confirmDestructive(message) {
|
|
|
398
396
|
const requireForQr = createRequire(import.meta.url);
|
|
399
397
|
function printEnrollmentInvite(invite) {
|
|
400
398
|
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
401
|
-
// eslint-disable-next-line no-console
|
|
402
399
|
console.log('\nEnroll your first device with this invite:');
|
|
403
400
|
try {
|
|
404
401
|
const qr = requireForQr('qrcode-terminal');
|
|
405
402
|
qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
|
|
406
|
-
// eslint-disable-next-line no-console
|
|
407
403
|
console.log(rendered);
|
|
408
404
|
});
|
|
409
405
|
}
|
|
410
406
|
catch (err) {
|
|
411
|
-
// eslint-disable-next-line no-console
|
|
412
407
|
console.error(`(qrcode-terminal unavailable: ${err.message})`);
|
|
413
408
|
}
|
|
414
|
-
// eslint-disable-next-line no-console
|
|
415
409
|
console.log(` ${invite.encodedInvite}`);
|
|
416
410
|
if (invite.expiresAt) {
|
|
417
|
-
// eslint-disable-next-line no-console
|
|
418
411
|
console.log(` (expires ${invite.expiresAt})`);
|
|
419
412
|
}
|
|
420
413
|
}
|
|
@@ -431,7 +424,6 @@ program
|
|
|
431
424
|
ttlMs = parseDuration(opts.ttl);
|
|
432
425
|
}
|
|
433
426
|
catch (err) {
|
|
434
|
-
// eslint-disable-next-line no-console
|
|
435
427
|
console.error(`Invalid --ttl: ${err.message}`);
|
|
436
428
|
process.exit(1);
|
|
437
429
|
return;
|
|
@@ -447,7 +439,6 @@ program
|
|
|
447
439
|
});
|
|
448
440
|
}
|
|
449
441
|
catch (err) {
|
|
450
|
-
// eslint-disable-next-line no-console
|
|
451
442
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
|
|
452
443
|
`Hint: is cadre-host running? Try \`cadre-host start\`.`);
|
|
453
444
|
process.exit(2);
|
|
@@ -455,22 +446,18 @@ program
|
|
|
455
446
|
}
|
|
456
447
|
if (!response.ok) {
|
|
457
448
|
const text = await response.text().catch(() => '');
|
|
458
|
-
// eslint-disable-next-line no-console
|
|
459
449
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
460
450
|
process.exit(1);
|
|
461
451
|
return;
|
|
462
452
|
}
|
|
463
453
|
const body = await response.json();
|
|
464
454
|
if (!body.encodedInvite) {
|
|
465
|
-
// eslint-disable-next-line no-console
|
|
466
455
|
console.error('cadre-host returned malformed response (missing encodedInvite)');
|
|
467
456
|
process.exit(1);
|
|
468
457
|
return;
|
|
469
458
|
}
|
|
470
|
-
// eslint-disable-next-line no-console
|
|
471
459
|
console.log(body.encodedInvite);
|
|
472
460
|
if (body.expiresAt) {
|
|
473
|
-
// eslint-disable-next-line no-console
|
|
474
461
|
console.error(`(expires at ${body.expiresAt})`);
|
|
475
462
|
}
|
|
476
463
|
process.exit(0);
|
|
@@ -490,41 +477,33 @@ trust
|
|
|
490
477
|
response = await fetch(url);
|
|
491
478
|
}
|
|
492
479
|
catch (err) {
|
|
493
|
-
// eslint-disable-next-line no-console
|
|
494
480
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
|
|
495
481
|
process.exit(2);
|
|
496
482
|
return;
|
|
497
483
|
}
|
|
498
484
|
if (!response.ok) {
|
|
499
|
-
// eslint-disable-next-line no-console
|
|
500
485
|
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
501
486
|
process.exit(1);
|
|
502
487
|
return;
|
|
503
488
|
}
|
|
504
489
|
const body = await response.json();
|
|
505
|
-
// eslint-disable-next-line no-console
|
|
506
490
|
console.log('Members:');
|
|
507
491
|
if (body.members.length === 0) {
|
|
508
|
-
// eslint-disable-next-line no-console
|
|
509
492
|
console.log(' (none)');
|
|
510
493
|
}
|
|
511
494
|
else {
|
|
512
495
|
for (const m of body.members) {
|
|
513
496
|
const self = m.self ? ' [self]' : '';
|
|
514
|
-
// eslint-disable-next-line no-console
|
|
515
497
|
console.log(` ${m.peerId} ${m.label}${self}`);
|
|
516
498
|
}
|
|
517
499
|
}
|
|
518
|
-
// eslint-disable-next-line no-console
|
|
519
500
|
console.log('\nPending invites:');
|
|
520
501
|
if (body.pending.length === 0) {
|
|
521
|
-
// eslint-disable-next-line no-console
|
|
522
502
|
console.log(' (none)');
|
|
523
503
|
}
|
|
524
504
|
else {
|
|
525
505
|
for (const p of body.pending) {
|
|
526
506
|
const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
|
|
527
|
-
// eslint-disable-next-line no-console
|
|
528
507
|
console.log(` ${p.token} ${p.label}${expires}`);
|
|
529
508
|
}
|
|
530
509
|
}
|
|
@@ -550,19 +529,16 @@ trust
|
|
|
550
529
|
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
551
530
|
}
|
|
552
531
|
catch (err) {
|
|
553
|
-
// eslint-disable-next-line no-console
|
|
554
532
|
console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
|
|
555
533
|
process.exit(2);
|
|
556
534
|
return;
|
|
557
535
|
}
|
|
558
536
|
if (!response.ok) {
|
|
559
537
|
const text = await response.text().catch(() => '');
|
|
560
|
-
// eslint-disable-next-line no-console
|
|
561
538
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
562
539
|
process.exit(1);
|
|
563
540
|
return;
|
|
564
541
|
}
|
|
565
|
-
// eslint-disable-next-line no-console
|
|
566
542
|
console.log(`revoked ${kind}: ${id}`);
|
|
567
543
|
process.exit(0);
|
|
568
544
|
});
|
|
@@ -582,7 +558,6 @@ nat
|
|
|
582
558
|
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
|
|
583
559
|
const body = await getJson(url);
|
|
584
560
|
if (opts.json) {
|
|
585
|
-
// eslint-disable-next-line no-console
|
|
586
561
|
console.log(JSON.stringify(body, null, 2));
|
|
587
562
|
}
|
|
588
563
|
else {
|
|
@@ -600,7 +575,6 @@ nat
|
|
|
600
575
|
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
|
|
601
576
|
const body = await postJson(url, {});
|
|
602
577
|
if (opts.json) {
|
|
603
|
-
// eslint-disable-next-line no-console
|
|
604
578
|
console.log(JSON.stringify(body, null, 2));
|
|
605
579
|
}
|
|
606
580
|
else {
|
|
@@ -645,7 +619,6 @@ ddns
|
|
|
645
619
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
646
620
|
.action(async (provider, opts) => {
|
|
647
621
|
if (!opts.hostname) {
|
|
648
|
-
// eslint-disable-next-line no-console
|
|
649
622
|
console.error('--hostname is required');
|
|
650
623
|
process.exit(1);
|
|
651
624
|
return;
|
|
@@ -655,7 +628,6 @@ ddns
|
|
|
655
628
|
config.token = opts.token;
|
|
656
629
|
if (!config.token) {
|
|
657
630
|
if (!process.stdin.isTTY) {
|
|
658
|
-
// eslint-disable-next-line no-console
|
|
659
631
|
console.error('Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
|
|
660
632
|
'Re-run with --token <secret>.');
|
|
661
633
|
process.exit(1);
|
|
@@ -665,7 +637,6 @@ ddns
|
|
|
665
637
|
config.token = await readSecretLine('DDNS token: ');
|
|
666
638
|
}
|
|
667
639
|
catch (err) {
|
|
668
|
-
// eslint-disable-next-line no-console
|
|
669
640
|
console.error('Failed to read token: ' + err.message);
|
|
670
641
|
process.exit(1);
|
|
671
642
|
return;
|
|
@@ -699,6 +670,160 @@ ddns
|
|
|
699
670
|
printNatStatus(body);
|
|
700
671
|
process.exit(0);
|
|
701
672
|
});
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// push subcommands — provision FCM/APNs credentials for strand-wake delivery
|
|
675
|
+
// ============================================================================
|
|
676
|
+
//
|
|
677
|
+
// These operate directly on the data dir's secret store + host.config.json (they
|
|
678
|
+
// do NOT go through the running management API). Private keys land in the OS
|
|
679
|
+
// keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
|
|
680
|
+
// bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
|
|
681
|
+
// credentials take effect on the next authority-node (re)spawn — run
|
|
682
|
+
// `cadre-host` restart (or restart the service) to apply them immediately.
|
|
683
|
+
const push = program
|
|
684
|
+
.command('push')
|
|
685
|
+
.description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
|
|
686
|
+
push
|
|
687
|
+
.command('fcm')
|
|
688
|
+
.description('Store Firebase Cloud Messaging (Android) service-account credentials')
|
|
689
|
+
.requiredOption('--project-id <id>', 'GCP / Firebase project id')
|
|
690
|
+
.requiredOption('--client-email <email>', 'Service-account email')
|
|
691
|
+
.option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
|
|
692
|
+
.option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
|
|
693
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
694
|
+
.action(async (opts) => {
|
|
695
|
+
const { dataDir } = resolveHostPaths(opts.dataDir);
|
|
696
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
|
|
697
|
+
const secrets = await createSecretsStore(dataDir);
|
|
698
|
+
await setFcmSecret(secrets, {
|
|
699
|
+
projectId: opts.projectId,
|
|
700
|
+
clientEmail: opts.clientEmail,
|
|
701
|
+
privateKey,
|
|
702
|
+
});
|
|
703
|
+
console.log('✓ FCM credentials stored. Restart cadre-host to apply on the authority node.');
|
|
704
|
+
process.exit(0);
|
|
705
|
+
});
|
|
706
|
+
push
|
|
707
|
+
.command('apns')
|
|
708
|
+
.description('Store Apple Push Notification service (iOS) auth-key credentials')
|
|
709
|
+
.requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
|
|
710
|
+
.requiredOption('--team-id <id>', 'Apple developer team id')
|
|
711
|
+
.requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
|
|
712
|
+
.option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
|
|
713
|
+
.option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
|
|
714
|
+
.option('--production', 'Target the production APNs host (default: sandbox)')
|
|
715
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
716
|
+
.action(async (opts) => {
|
|
717
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
718
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
|
|
719
|
+
const secrets = await createSecretsStore(dataDir);
|
|
720
|
+
// Secret bits → secret store; non-secret app-config → host.config.json.
|
|
721
|
+
await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
|
|
722
|
+
const current = readHostConfig(cfgPath);
|
|
723
|
+
updateHostConfig(cfgPath, {
|
|
724
|
+
push: {
|
|
725
|
+
...current.push,
|
|
726
|
+
apns: { bundleId: opts.bundleId, production: opts.production === true },
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
console.log(`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
|
|
730
|
+
`Restart cadre-host to apply on the authority node.`);
|
|
731
|
+
process.exit(0);
|
|
732
|
+
});
|
|
733
|
+
push
|
|
734
|
+
.command('options')
|
|
735
|
+
.description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
|
|
736
|
+
.option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
|
|
737
|
+
.option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
|
|
738
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
739
|
+
.action((opts) => {
|
|
740
|
+
const { cfgPath } = resolveHostPaths(opts.dataDir);
|
|
741
|
+
const current = readHostConfig(cfgPath);
|
|
742
|
+
const nextPush = { ...current.push };
|
|
743
|
+
if (typeof opts.cooldownMs === 'number')
|
|
744
|
+
nextPush.cooldownMs = opts.cooldownMs;
|
|
745
|
+
if (typeof opts.debounceMs === 'number')
|
|
746
|
+
nextPush.debounceMs = opts.debounceMs;
|
|
747
|
+
updateHostConfig(cfgPath, { push: nextPush });
|
|
748
|
+
console.log('✓ Push options updated.');
|
|
749
|
+
process.exit(0);
|
|
750
|
+
});
|
|
751
|
+
push
|
|
752
|
+
.command('clear')
|
|
753
|
+
.description('Remove stored push credentials')
|
|
754
|
+
.argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
|
|
755
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
756
|
+
.action(async (target, opts) => {
|
|
757
|
+
if (!['fcm', 'apns', 'all'].includes(target)) {
|
|
758
|
+
console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
763
|
+
const secrets = await createSecretsStore(dataDir);
|
|
764
|
+
if (target === 'fcm' || target === 'all')
|
|
765
|
+
await clearPushSecret(secrets, 'fcm');
|
|
766
|
+
if (target === 'apns' || target === 'all') {
|
|
767
|
+
await clearPushSecret(secrets, 'apns');
|
|
768
|
+
const current = readHostConfig(cfgPath);
|
|
769
|
+
if (current.push?.apns) {
|
|
770
|
+
const { apns: _drop, ...rest } = current.push;
|
|
771
|
+
updateHostConfig(cfgPath, { push: rest });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
|
|
775
|
+
process.exit(0);
|
|
776
|
+
});
|
|
777
|
+
push
|
|
778
|
+
.command('status')
|
|
779
|
+
.description('Show which push platforms are configured (no secret material)')
|
|
780
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
781
|
+
.action(async (opts) => {
|
|
782
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
783
|
+
const secrets = await createSecretsStore(dataDir);
|
|
784
|
+
const status = await pushStatus(secrets);
|
|
785
|
+
const cfg = readHostConfig(cfgPath);
|
|
786
|
+
console.log('Push credentials:');
|
|
787
|
+
console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
|
|
788
|
+
const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
|
|
789
|
+
const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
|
|
790
|
+
console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
|
|
791
|
+
if (cfg.push?.cooldownMs !== undefined)
|
|
792
|
+
console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
|
|
793
|
+
if (cfg.push?.debounceMs !== undefined)
|
|
794
|
+
console.log(` debounceMs: ${cfg.push.debounceMs}`);
|
|
795
|
+
process.exit(0);
|
|
796
|
+
});
|
|
797
|
+
/** Resolve the data dir + config path for a direct-on-disk push subcommand. */
|
|
798
|
+
function resolveHostPaths(dataDirOpt) {
|
|
799
|
+
const platform = detectPlatform();
|
|
800
|
+
const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
801
|
+
const cfgPath = resolveConfigPath(dataDir);
|
|
802
|
+
if (!existsSync(cfgPath)) {
|
|
803
|
+
console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
// Resolve the real data dir from the persisted config (handles default-dir installs).
|
|
807
|
+
const cfg = readHostConfig(cfgPath);
|
|
808
|
+
return { dataDir: cfg.dataDir, cfgPath };
|
|
809
|
+
}
|
|
810
|
+
/** Read a private key from a file or inline flag; exit with a clear error if neither. */
|
|
811
|
+
function readPrivateKeyArg(file, inline, label) {
|
|
812
|
+
if (file) {
|
|
813
|
+
try {
|
|
814
|
+
return readFileSync(file, 'utf8');
|
|
815
|
+
}
|
|
816
|
+
catch (err) {
|
|
817
|
+
console.error(`Failed to read ${label} private key file ${file}: ${err.message}`);
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (inline)
|
|
822
|
+
return inline;
|
|
823
|
+
console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
|
|
824
|
+
process.exit(1);
|
|
825
|
+
throw new Error('unreachable');
|
|
826
|
+
}
|
|
702
827
|
async function getJson(url) {
|
|
703
828
|
return await callJson(url, 'GET');
|
|
704
829
|
}
|
|
@@ -719,7 +844,6 @@ async function callJson(url, method, body) {
|
|
|
719
844
|
response = await fetch(url, init);
|
|
720
845
|
}
|
|
721
846
|
catch (err) {
|
|
722
|
-
// eslint-disable-next-line no-console
|
|
723
847
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
|
|
724
848
|
`Hint: is cadre-host running? Try \`cadre-host start\`.`);
|
|
725
849
|
process.exit(2);
|
|
@@ -727,7 +851,6 @@ async function callJson(url, method, body) {
|
|
|
727
851
|
}
|
|
728
852
|
if (!response.ok) {
|
|
729
853
|
const text = await response.text().catch(() => '');
|
|
730
|
-
// eslint-disable-next-line no-console
|
|
731
854
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
732
855
|
process.exit(1);
|
|
733
856
|
throw new Error('non-ok');
|
|
@@ -737,36 +860,26 @@ async function callJson(url, method, body) {
|
|
|
737
860
|
return await response.json();
|
|
738
861
|
}
|
|
739
862
|
function printNatStatus(s) {
|
|
740
|
-
// eslint-disable-next-line no-console
|
|
741
863
|
console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
|
|
742
864
|
if (s.routerExternalIp) {
|
|
743
|
-
// eslint-disable-next-line no-console
|
|
744
865
|
console.log(`Router IP: ${s.routerExternalIp}`);
|
|
745
866
|
}
|
|
746
|
-
// eslint-disable-next-line no-console
|
|
747
867
|
console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
|
|
748
|
-
// eslint-disable-next-line no-console
|
|
749
868
|
console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
|
|
750
869
|
if (s.mappingLeaseExpiresAt) {
|
|
751
|
-
// eslint-disable-next-line no-console
|
|
752
870
|
console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
|
|
753
871
|
}
|
|
754
872
|
const ddns = s.ddns ?? {};
|
|
755
873
|
if (ddns.providerId || ddns.hostname) {
|
|
756
|
-
// eslint-disable-next-line no-console
|
|
757
874
|
console.log(`\nDDNS:`);
|
|
758
|
-
// eslint-disable-next-line no-console
|
|
759
875
|
console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
|
|
760
|
-
// eslint-disable-next-line no-console
|
|
761
876
|
console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
|
|
762
877
|
if (ddns.lastUpdateAt) {
|
|
763
878
|
const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
|
|
764
|
-
// eslint-disable-next-line no-console
|
|
765
879
|
console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
|
|
766
880
|
}
|
|
767
881
|
}
|
|
768
882
|
else {
|
|
769
|
-
// eslint-disable-next-line no-console
|
|
770
883
|
console.log('\nDDNS: not configured');
|
|
771
884
|
}
|
|
772
885
|
}
|
|
@@ -822,5 +935,5 @@ function readSecretLine(prompt) {
|
|
|
822
935
|
stdin.on('data', onData);
|
|
823
936
|
});
|
|
824
937
|
}
|
|
825
|
-
program.parseAsync();
|
|
938
|
+
void program.parseAsync();
|
|
826
939
|
//# sourceMappingURL=host.js.map
|