@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
package/dist/bin/host.js
CHANGED
|
@@ -17,14 +17,18 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
17
17
|
import { Command } from 'commander';
|
|
18
18
|
import { parseDuration } from '../auth/duration.js';
|
|
19
19
|
import { Installer } from '../installer/index.js';
|
|
20
|
-
import { readHostConfig, updateHostConfig } from '../installer/config.js';
|
|
20
|
+
import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
|
|
21
21
|
import { configPath as resolveConfigPath, defaultDataDir, defaultHostJs, defaultServiceDir, identityPath as resolveIdentityPath, } from '../installer/paths.js';
|
|
22
22
|
import { detectPlatform } from '../installer/platform.js';
|
|
23
23
|
import { createServiceHost } from '../installer/service-host/index.js';
|
|
24
24
|
import { UpdateService } from '../update/index.js';
|
|
25
25
|
import { HostProcessOrchestrator } from '../orchestrator/index.js';
|
|
26
26
|
import { TrustCircleService, TrustCircleStore } from '../auth/index.js';
|
|
27
|
+
import { GrantService, GrantStore, DonationService, DonationStore, DONATION_AWAITING_SEED_TTL_MS, DONATION_REAP_SWEEP_MS, } from '../donation/index.js';
|
|
27
28
|
import { NatService } from '../nat/index.js';
|
|
29
|
+
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
30
|
+
import { resolvePushCredentials, setFcmSecret, setApnsSecret, clearPushSecret, pushStatus, } from '../push/index.js';
|
|
31
|
+
import { OwnerNodeClient } from '../owner/index.js';
|
|
28
32
|
import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
|
|
29
33
|
import { openBrowser } from '../installer/browser.js';
|
|
30
34
|
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
@@ -36,7 +40,6 @@ function looksLikePeerId(id) {
|
|
|
36
40
|
function resolvePort(raw) {
|
|
37
41
|
const n = Number(raw);
|
|
38
42
|
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
39
|
-
// eslint-disable-next-line no-console
|
|
40
43
|
console.error(`Invalid --port: ${raw}`);
|
|
41
44
|
process.exit(1);
|
|
42
45
|
}
|
|
@@ -60,6 +63,7 @@ program
|
|
|
60
63
|
.option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
|
|
61
64
|
.option('--no-browser', 'Do not open a browser after install')
|
|
62
65
|
.option('--no-invite', 'Skip generating the first enrollment invite')
|
|
66
|
+
.option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
|
|
63
67
|
.option('--system', 'System-wide install (not yet supported in v1)')
|
|
64
68
|
.option('--node-path <path>', 'Override the node binary embedded in the service unit')
|
|
65
69
|
.action(async (opts) => {
|
|
@@ -73,16 +77,13 @@ program
|
|
|
73
77
|
noUpnp: opts.upnp === false,
|
|
74
78
|
openBrowser: opts.browser !== false,
|
|
75
79
|
noInvite: opts.invite === false,
|
|
80
|
+
ownCadre: opts.ownCadre === true,
|
|
76
81
|
system: Boolean(opts.system),
|
|
77
82
|
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
78
83
|
});
|
|
79
|
-
// eslint-disable-next-line no-console
|
|
80
84
|
console.log(`cadre-host installed.`);
|
|
81
|
-
// eslint-disable-next-line no-console
|
|
82
85
|
console.log(` Data dir: ${result.dataDir}`);
|
|
83
|
-
// eslint-disable-next-line no-console
|
|
84
86
|
console.log(` UI: ${result.uiUrl}`);
|
|
85
|
-
// eslint-disable-next-line no-console
|
|
86
87
|
console.log(` Service: ${result.serviceName}`);
|
|
87
88
|
if (result.enrollmentInvite) {
|
|
88
89
|
printEnrollmentInvite(result.enrollmentInvite);
|
|
@@ -90,7 +91,6 @@ program
|
|
|
90
91
|
process.exit(0);
|
|
91
92
|
}
|
|
92
93
|
catch (err) {
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
94
|
console.error(`install failed: ${err.message}`);
|
|
95
95
|
process.exit(1);
|
|
96
96
|
}
|
|
@@ -129,12 +129,10 @@ program
|
|
|
129
129
|
removeData: Boolean(opts.removeData),
|
|
130
130
|
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
131
131
|
});
|
|
132
|
-
// eslint-disable-next-line no-console
|
|
133
132
|
console.log('cadre-host uninstalled.');
|
|
134
133
|
process.exit(0);
|
|
135
134
|
}
|
|
136
135
|
catch (err) {
|
|
137
|
-
// eslint-disable-next-line no-console
|
|
138
136
|
console.error(`uninstall failed: ${err.message}`);
|
|
139
137
|
process.exit(1);
|
|
140
138
|
}
|
|
@@ -149,14 +147,11 @@ program
|
|
|
149
147
|
const installer = new Installer();
|
|
150
148
|
try {
|
|
151
149
|
const status = await installer.status();
|
|
152
|
-
// eslint-disable-next-line no-console
|
|
153
150
|
console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
|
|
154
|
-
// eslint-disable-next-line no-console
|
|
155
151
|
console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
|
|
156
152
|
process.exit(0);
|
|
157
153
|
}
|
|
158
154
|
catch (err) {
|
|
159
|
-
// eslint-disable-next-line no-console
|
|
160
155
|
console.error(`status failed: ${err.message}`);
|
|
161
156
|
process.exit(1);
|
|
162
157
|
}
|
|
@@ -176,7 +171,6 @@ program
|
|
|
176
171
|
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
177
172
|
const cfgPath = resolveConfigPath(dataDir);
|
|
178
173
|
if (!existsSync(cfgPath)) {
|
|
179
|
-
// eslint-disable-next-line no-console
|
|
180
174
|
console.error(`cadre-host start: ${cfgPath} not found. ` +
|
|
181
175
|
`Run \`cadre-host install\` first (or pass --data-dir to an existing install).`);
|
|
182
176
|
process.exit(1);
|
|
@@ -185,12 +179,10 @@ program
|
|
|
185
179
|
const cfg = readHostConfig(cfgPath);
|
|
186
180
|
const idPath = resolveIdentityPath(cfg.dataDir);
|
|
187
181
|
if (!existsSync(idPath)) {
|
|
188
|
-
// eslint-disable-next-line no-console
|
|
189
182
|
console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
|
|
190
183
|
process.exit(1);
|
|
191
184
|
return;
|
|
192
185
|
}
|
|
193
|
-
// eslint-disable-next-line no-console
|
|
194
186
|
console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
|
|
195
187
|
// Update flow: notify-by-default; auto-apply opt-in via host.config.json.
|
|
196
188
|
// The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
|
|
@@ -223,72 +215,163 @@ program
|
|
|
223
215
|
updateHostConfig(cfgPath, { updates: next });
|
|
224
216
|
}
|
|
225
217
|
catch (err) {
|
|
226
|
-
// eslint-disable-next-line no-console
|
|
227
218
|
console.error(`failed to persist updated settings to ${cfgPath}: ${err.message}`);
|
|
228
219
|
}
|
|
229
220
|
});
|
|
230
221
|
// Don't block startup on the network round-trip — call and forget.
|
|
231
222
|
void updateService.check();
|
|
232
223
|
updateService.start();
|
|
233
|
-
// Wire the long-lived HTTP management server.
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
224
|
+
// Wire the long-lived HTTP management server. cadre-host's primary role
|
|
225
|
+
// is **node donor**: it spawns generic cadre nodes for *other people's*
|
|
226
|
+
// cadres on request (the donation grant layer below) and never needs an
|
|
227
|
+
// owner node of its own for that. Running the host's **own** personal
|
|
228
|
+
// cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
|
|
229
|
+
// (see docs/cadre-host.md § Two roles: donor and founder). Only when it
|
|
230
|
+
// is enabled do we spawn the owner node and bring up trust-circle + NAT.
|
|
231
|
+
// The manager never joins the control network (docs/cadre-host.md
|
|
232
|
+
// § Control-plane separation).
|
|
233
|
+
// Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
|
|
234
|
+
// restart re-reads the secret store (rotated keys) and nothing raw is
|
|
235
|
+
// persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
|
|
236
|
+
// cooldown/debounce) are re-read from host.config.json each call too.
|
|
237
|
+
const pushSecrets = await createSecretsStore(cfg.dataDir);
|
|
238
|
+
const orchestrator = new HostProcessOrchestrator({
|
|
239
|
+
rootDir: join(cfg.dataDir, 'orchestrator'),
|
|
240
|
+
pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
|
|
245
241
|
});
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
242
|
+
await orchestrator.init();
|
|
243
|
+
// Donation grant layer — the always-on donor surface. Local-only:
|
|
244
|
+
// issue/validate/revoke are pure store ops (no node round-trip), so it
|
|
245
|
+
// needs no owner-node handle. The loopback `/grants-admin` surface is
|
|
246
|
+
// mounted by createLocalUiServer regardless of the founder persona.
|
|
247
|
+
const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
|
|
248
|
+
// Donation lifecycle service — consumes a validated grant to actually
|
|
249
|
+
// spawn a donated node into the requester's cadre. Drives the
|
|
250
|
+
// grantee-facing `/grants` surface (mounted by createLocalUiServer below).
|
|
251
|
+
const donationService = new DonationService({
|
|
252
|
+
orchestrator,
|
|
253
|
+
grants: grantService,
|
|
254
|
+
store: new DonationStore(cfg.dataDir),
|
|
249
255
|
});
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
256
|
+
// Reap orphaned donations: a requester that provisioned a node but never
|
|
257
|
+
// presented a seed leaves an `awaiting_seed` child holding host ports.
|
|
258
|
+
// Sweep once at startup (for records recovered from disk by
|
|
259
|
+
// orchestrator.init()), then periodically.
|
|
260
|
+
const reapStale = () => {
|
|
261
|
+
void donationService
|
|
262
|
+
.reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
|
|
263
|
+
.catch((err) => console.error(`donation reap failed: ${err.message}`));
|
|
264
|
+
};
|
|
265
|
+
reapStale();
|
|
266
|
+
const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
|
|
267
|
+
reapTimer.unref();
|
|
268
|
+
// Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
|
|
269
|
+
// donor: no owner node, and the /auth + /nat surfaces stay unmounted
|
|
270
|
+
// (they 404). Per-donated-node WAN reachability is deferred to
|
|
271
|
+
// backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
|
|
272
|
+
// loopback-only — nothing for NatService to map without an owner node.
|
|
273
|
+
let trustCircle;
|
|
274
|
+
let natService;
|
|
275
|
+
if (hostOwnsCadre(cfg)) {
|
|
276
|
+
// Spawn the owner node. Best-effort: a spawn failure leaves the
|
|
277
|
+
// management API up (trust-circle listing degrades to local labels,
|
|
278
|
+
// owner ops return 503) rather than taking down the whole process.
|
|
279
|
+
try {
|
|
280
|
+
await orchestrator.ensureOwnerNode({
|
|
281
|
+
identityPath: idPath,
|
|
282
|
+
partyId: cfg.installId,
|
|
283
|
+
libp2pPort: cfg.libp2pPort,
|
|
284
|
+
});
|
|
285
|
+
console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
console.error(`owner node spawn failed: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
// The client reads the admin endpoint lazily so a node restart's fresh
|
|
291
|
+
// bearer token is picked up automatically.
|
|
292
|
+
const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
|
|
293
|
+
trustCircle = new TrustCircleService({
|
|
294
|
+
cadreNode: owner,
|
|
295
|
+
store: new TrustCircleStore(cfg.dataDir),
|
|
296
|
+
});
|
|
297
|
+
natService = new NatService({
|
|
298
|
+
rootDir: cfg.dataDir,
|
|
299
|
+
cadreNode: owner,
|
|
300
|
+
});
|
|
301
|
+
// Push NAT-resolved invite addresses to the node on every NAT change.
|
|
302
|
+
// NatService.start() also fires this once as an initial push, retried
|
|
303
|
+
// until the freshly spawned owner node accepts it (bounded; the
|
|
304
|
+
// management API that mints invites comes up only after start() resolves).
|
|
305
|
+
natService.onAddressesChanged(async (addresses) => {
|
|
306
|
+
if (addresses.length === 0)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
309
|
+
await owner.pushInviteAddresses(addresses);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
console.error(`invite-address push failed: ${err.message}`);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
// Best-effort NAT start. Failures here aren't fatal — the local UI
|
|
316
|
+
// can still serve the trust circle, settings, etc.
|
|
317
|
+
try {
|
|
318
|
+
await natService.start();
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.error(`NAT start failed: ${err.message}`);
|
|
322
|
+
}
|
|
254
323
|
}
|
|
255
|
-
|
|
256
|
-
//
|
|
257
|
-
|
|
324
|
+
else {
|
|
325
|
+
// Donor-only. If ownCadre was toggled off after a prior founder run,
|
|
326
|
+
// orchestrator.init() re-attaches the still-running owner child (it would
|
|
327
|
+
// otherwise linger in listNodes with no trustCircle/nat wired, serving
|
|
328
|
+
// the host's own cadre despite being disabled). Reap it now so a disabled
|
|
329
|
+
// own-cadre is actually stopped — its workdir + control-DB persist on
|
|
330
|
+
// disk, so toggling ownCadre back on re-spawns it from saved config.
|
|
331
|
+
try {
|
|
332
|
+
await orchestrator.stopOwnerNode();
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
console.error(`owner node reap failed: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
|
|
258
338
|
}
|
|
259
339
|
const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
|
|
260
340
|
const server = createLocalUiServer({
|
|
261
341
|
uiPort: cfg.uiPort,
|
|
262
342
|
dataDir: cfg.dataDir,
|
|
263
343
|
orchestrator,
|
|
264
|
-
trustCircle,
|
|
265
|
-
nat: natService,
|
|
344
|
+
...(trustCircle ? { trustCircle } : {}),
|
|
345
|
+
...(natService ? { nat: natService } : {}),
|
|
266
346
|
update: updateService,
|
|
347
|
+
grants: grantService,
|
|
348
|
+
donations: donationService,
|
|
267
349
|
settingsStore,
|
|
268
350
|
});
|
|
269
351
|
const { url, port } = await server.start();
|
|
270
352
|
if (port !== cfg.uiPort) {
|
|
271
|
-
// eslint-disable-next-line no-console
|
|
272
353
|
console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
|
|
273
354
|
}
|
|
274
|
-
// eslint-disable-next-line no-console
|
|
275
355
|
console.log(`cadre-host local UI: ${url}`);
|
|
276
356
|
await waitForTermination();
|
|
357
|
+
clearInterval(reapTimer);
|
|
277
358
|
try {
|
|
278
359
|
await server.stop();
|
|
279
360
|
}
|
|
280
361
|
catch { /* ignore */ }
|
|
281
362
|
try {
|
|
282
|
-
await natService
|
|
363
|
+
await natService?.stop();
|
|
364
|
+
}
|
|
365
|
+
catch { /* ignore */ }
|
|
366
|
+
try {
|
|
367
|
+
await orchestrator.stopOwnerNode();
|
|
283
368
|
}
|
|
284
369
|
catch { /* ignore */ }
|
|
285
370
|
updateService.stop();
|
|
286
|
-
// eslint-disable-next-line no-console
|
|
287
371
|
console.log('cadre-host stopped.');
|
|
288
372
|
process.exit(0);
|
|
289
373
|
}
|
|
290
374
|
catch (err) {
|
|
291
|
-
// eslint-disable-next-line no-console
|
|
292
375
|
console.error(`start failed: ${err.message}`);
|
|
293
376
|
process.exit(1);
|
|
294
377
|
}
|
|
@@ -307,30 +390,25 @@ program
|
|
|
307
390
|
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
308
391
|
const cfgPath = resolveConfigPath(dataDir);
|
|
309
392
|
if (!existsSync(cfgPath)) {
|
|
310
|
-
// eslint-disable-next-line no-console
|
|
311
393
|
console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
|
|
312
394
|
process.exit(1);
|
|
313
395
|
return;
|
|
314
396
|
}
|
|
315
397
|
const cfg = readHostConfig(cfgPath);
|
|
316
398
|
const url = `http://127.0.0.1:${cfg.uiPort}`;
|
|
317
|
-
// eslint-disable-next-line no-console
|
|
318
399
|
console.log(url);
|
|
319
400
|
if (opts.browser !== false) {
|
|
320
401
|
const res = openBrowser(url);
|
|
321
402
|
if (res.spawned) {
|
|
322
|
-
// eslint-disable-next-line no-console
|
|
323
403
|
console.log('(Opening in your default browser...)');
|
|
324
404
|
}
|
|
325
405
|
else if (res.error) {
|
|
326
|
-
// eslint-disable-next-line no-console
|
|
327
406
|
console.error(`(unable to launch browser: ${res.error})`);
|
|
328
407
|
}
|
|
329
408
|
}
|
|
330
409
|
process.exit(0);
|
|
331
410
|
}
|
|
332
411
|
catch (err) {
|
|
333
|
-
// eslint-disable-next-line no-console
|
|
334
412
|
console.error(`ui failed: ${err.message}`);
|
|
335
413
|
process.exit(1);
|
|
336
414
|
}
|
|
@@ -355,28 +433,6 @@ function readPackageVersionForStart() {
|
|
|
355
433
|
return '0.0.0-unknown';
|
|
356
434
|
}
|
|
357
435
|
}
|
|
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
436
|
function waitForTermination() {
|
|
381
437
|
return new Promise((resolveTerm) => {
|
|
382
438
|
const done = () => resolveTerm();
|
|
@@ -398,26 +454,37 @@ async function confirmDestructive(message) {
|
|
|
398
454
|
const requireForQr = createRequire(import.meta.url);
|
|
399
455
|
function printEnrollmentInvite(invite) {
|
|
400
456
|
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
401
|
-
// eslint-disable-next-line no-console
|
|
402
457
|
console.log('\nEnroll your first device with this invite:');
|
|
403
458
|
try {
|
|
404
459
|
const qr = requireForQr('qrcode-terminal');
|
|
405
460
|
qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
|
|
406
|
-
// eslint-disable-next-line no-console
|
|
407
461
|
console.log(rendered);
|
|
408
462
|
});
|
|
409
463
|
}
|
|
410
464
|
catch (err) {
|
|
411
|
-
// eslint-disable-next-line no-console
|
|
412
465
|
console.error(`(qrcode-terminal unavailable: ${err.message})`);
|
|
413
466
|
}
|
|
414
|
-
// eslint-disable-next-line no-console
|
|
415
467
|
console.log(` ${invite.encodedInvite}`);
|
|
416
468
|
if (invite.expiresAt) {
|
|
417
|
-
// eslint-disable-next-line no-console
|
|
418
469
|
console.log(` (expires ${invite.expiresAt})`);
|
|
419
470
|
}
|
|
420
471
|
}
|
|
472
|
+
function printGrantToken(token, withQr) {
|
|
473
|
+
if (withQr) {
|
|
474
|
+
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
475
|
+
try {
|
|
476
|
+
const qr = requireForQr('qrcode-terminal');
|
|
477
|
+
qr.generate(token, { small: true }, (rendered) => {
|
|
478
|
+
console.error(rendered);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
console.error(`(qrcode-terminal unavailable: ${err.message})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// The token goes to stdout alone so it can be piped/copied; metadata to stderr.
|
|
486
|
+
console.log(token);
|
|
487
|
+
}
|
|
421
488
|
program
|
|
422
489
|
.command('invite')
|
|
423
490
|
.description('Generate an invite to add a member to the trust circle')
|
|
@@ -431,7 +498,6 @@ program
|
|
|
431
498
|
ttlMs = parseDuration(opts.ttl);
|
|
432
499
|
}
|
|
433
500
|
catch (err) {
|
|
434
|
-
// eslint-disable-next-line no-console
|
|
435
501
|
console.error(`Invalid --ttl: ${err.message}`);
|
|
436
502
|
process.exit(1);
|
|
437
503
|
return;
|
|
@@ -447,7 +513,6 @@ program
|
|
|
447
513
|
});
|
|
448
514
|
}
|
|
449
515
|
catch (err) {
|
|
450
|
-
// eslint-disable-next-line no-console
|
|
451
516
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
|
|
452
517
|
`Hint: is cadre-host running? Try \`cadre-host start\`.`);
|
|
453
518
|
process.exit(2);
|
|
@@ -455,22 +520,18 @@ program
|
|
|
455
520
|
}
|
|
456
521
|
if (!response.ok) {
|
|
457
522
|
const text = await response.text().catch(() => '');
|
|
458
|
-
// eslint-disable-next-line no-console
|
|
459
523
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
460
524
|
process.exit(1);
|
|
461
525
|
return;
|
|
462
526
|
}
|
|
463
527
|
const body = await response.json();
|
|
464
528
|
if (!body.encodedInvite) {
|
|
465
|
-
// eslint-disable-next-line no-console
|
|
466
529
|
console.error('cadre-host returned malformed response (missing encodedInvite)');
|
|
467
530
|
process.exit(1);
|
|
468
531
|
return;
|
|
469
532
|
}
|
|
470
|
-
// eslint-disable-next-line no-console
|
|
471
533
|
console.log(body.encodedInvite);
|
|
472
534
|
if (body.expiresAt) {
|
|
473
|
-
// eslint-disable-next-line no-console
|
|
474
535
|
console.error(`(expires at ${body.expiresAt})`);
|
|
475
536
|
}
|
|
476
537
|
process.exit(0);
|
|
@@ -490,41 +551,33 @@ trust
|
|
|
490
551
|
response = await fetch(url);
|
|
491
552
|
}
|
|
492
553
|
catch (err) {
|
|
493
|
-
// eslint-disable-next-line no-console
|
|
494
554
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
|
|
495
555
|
process.exit(2);
|
|
496
556
|
return;
|
|
497
557
|
}
|
|
498
558
|
if (!response.ok) {
|
|
499
|
-
// eslint-disable-next-line no-console
|
|
500
559
|
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
501
560
|
process.exit(1);
|
|
502
561
|
return;
|
|
503
562
|
}
|
|
504
563
|
const body = await response.json();
|
|
505
|
-
// eslint-disable-next-line no-console
|
|
506
564
|
console.log('Members:');
|
|
507
565
|
if (body.members.length === 0) {
|
|
508
|
-
// eslint-disable-next-line no-console
|
|
509
566
|
console.log(' (none)');
|
|
510
567
|
}
|
|
511
568
|
else {
|
|
512
569
|
for (const m of body.members) {
|
|
513
570
|
const self = m.self ? ' [self]' : '';
|
|
514
|
-
// eslint-disable-next-line no-console
|
|
515
571
|
console.log(` ${m.peerId} ${m.label}${self}`);
|
|
516
572
|
}
|
|
517
573
|
}
|
|
518
|
-
// eslint-disable-next-line no-console
|
|
519
574
|
console.log('\nPending invites:');
|
|
520
575
|
if (body.pending.length === 0) {
|
|
521
|
-
// eslint-disable-next-line no-console
|
|
522
576
|
console.log(' (none)');
|
|
523
577
|
}
|
|
524
578
|
else {
|
|
525
579
|
for (const p of body.pending) {
|
|
526
580
|
const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
|
|
527
|
-
// eslint-disable-next-line no-console
|
|
528
581
|
console.log(` ${p.token} ${p.label}${expires}`);
|
|
529
582
|
}
|
|
530
583
|
}
|
|
@@ -550,23 +603,147 @@ trust
|
|
|
550
603
|
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
551
604
|
}
|
|
552
605
|
catch (err) {
|
|
553
|
-
// eslint-disable-next-line no-console
|
|
554
606
|
console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
|
|
555
607
|
process.exit(2);
|
|
556
608
|
return;
|
|
557
609
|
}
|
|
558
610
|
if (!response.ok) {
|
|
559
611
|
const text = await response.text().catch(() => '');
|
|
560
|
-
// eslint-disable-next-line no-console
|
|
561
612
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
562
613
|
process.exit(1);
|
|
563
614
|
return;
|
|
564
615
|
}
|
|
565
|
-
// eslint-disable-next-line no-console
|
|
566
616
|
console.log(`revoked ${kind}: ${id}`);
|
|
567
617
|
process.exit(0);
|
|
568
618
|
});
|
|
569
619
|
// ============================================================================
|
|
620
|
+
// grant subcommands — donation grant tokens (who may ask this host for a node)
|
|
621
|
+
// ============================================================================
|
|
622
|
+
//
|
|
623
|
+
// A grant token lets one grantee (friend/family) present a Bearer credential to
|
|
624
|
+
// ask this host to donate cadre nodes, up to a per-grantee cap. These commands
|
|
625
|
+
// are thin HTTP clients of the loopback `/grants-admin` admin surface — no
|
|
626
|
+
// bearer (same-machine admin), same posture as `invite` / `trust`. The
|
|
627
|
+
// grantee-facing provisioning surface lands in the donation-service ticket.
|
|
628
|
+
const grant = program
|
|
629
|
+
.command('grant')
|
|
630
|
+
.description('Manage donation grant tokens (who may ask this host to donate a node)');
|
|
631
|
+
grant
|
|
632
|
+
.command('issue')
|
|
633
|
+
.description('Issue a grant token for one grantee (prints the token + QR)')
|
|
634
|
+
.argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
|
|
635
|
+
.option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
|
|
636
|
+
.option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
|
|
637
|
+
.option('--no-qr', 'Print only the token, no QR code')
|
|
638
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
639
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
640
|
+
.action(async (label, opts) => {
|
|
641
|
+
const payload = { label };
|
|
642
|
+
if (typeof opts.maxNodes === 'number')
|
|
643
|
+
payload.maxNodes = opts.maxNodes;
|
|
644
|
+
if (opts.ttl) {
|
|
645
|
+
try {
|
|
646
|
+
payload.ttlMs = parseDuration(opts.ttl);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
console.error(`Invalid --ttl: ${err.message}`);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
655
|
+
let response;
|
|
656
|
+
try {
|
|
657
|
+
response = await fetch(url, {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
headers: { 'content-type': 'application/json' },
|
|
660
|
+
body: JSON.stringify(payload),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
|
|
665
|
+
`Hint: is cadre-host running? Try \`cadre-host start\`.`);
|
|
666
|
+
process.exit(2);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (!response.ok) {
|
|
670
|
+
const text = await response.text().catch(() => '');
|
|
671
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
672
|
+
process.exit(1);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const body = await response.json();
|
|
676
|
+
const issued = body.grant;
|
|
677
|
+
if (!issued?.token) {
|
|
678
|
+
console.error('cadre-host returned malformed response (missing grant token)');
|
|
679
|
+
process.exit(1);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
printGrantToken(issued.token, opts.qr !== false);
|
|
683
|
+
console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
|
|
684
|
+
process.exit(0);
|
|
685
|
+
});
|
|
686
|
+
grant
|
|
687
|
+
.command('list')
|
|
688
|
+
.description('List issued grant tokens')
|
|
689
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
690
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
691
|
+
.action(async (opts) => {
|
|
692
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
693
|
+
let response;
|
|
694
|
+
try {
|
|
695
|
+
response = await fetch(url);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
console.error(`Failed to reach cadre-host at ${url}: ${err.message}`);
|
|
699
|
+
process.exit(2);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (!response.ok) {
|
|
703
|
+
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const body = await response.json();
|
|
708
|
+
console.log('Grants:');
|
|
709
|
+
if (body.grants.length === 0) {
|
|
710
|
+
console.log(' (none)');
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
for (const g of body.grants) {
|
|
714
|
+
const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
|
|
715
|
+
console.log(` ${g.token} ${g.label} max=${g.maxNodes}${state}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
process.exit(0);
|
|
719
|
+
});
|
|
720
|
+
grant
|
|
721
|
+
.command('revoke')
|
|
722
|
+
.description('Revoke a grant token (blocks future requests; live nodes are not torn down)')
|
|
723
|
+
.argument('<token>', 'Grant token to revoke')
|
|
724
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
725
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
726
|
+
.action(async (token, opts) => {
|
|
727
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
728
|
+
let response;
|
|
729
|
+
try {
|
|
730
|
+
response = await fetch(`${base}/grants-admin/${encodeURIComponent(token)}`, { method: 'DELETE' });
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
console.error(`Failed to reach cadre-host at ${base}: ${err.message}`);
|
|
734
|
+
process.exit(2);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (!response.ok) {
|
|
738
|
+
const text = await response.text().catch(() => '');
|
|
739
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
740
|
+
process.exit(1);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
console.log(`revoked grant: ${token}`);
|
|
744
|
+
process.exit(0);
|
|
745
|
+
});
|
|
746
|
+
// ============================================================================
|
|
570
747
|
// nat subcommands
|
|
571
748
|
// ============================================================================
|
|
572
749
|
const nat = program
|
|
@@ -582,7 +759,6 @@ nat
|
|
|
582
759
|
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
|
|
583
760
|
const body = await getJson(url);
|
|
584
761
|
if (opts.json) {
|
|
585
|
-
// eslint-disable-next-line no-console
|
|
586
762
|
console.log(JSON.stringify(body, null, 2));
|
|
587
763
|
}
|
|
588
764
|
else {
|
|
@@ -600,7 +776,6 @@ nat
|
|
|
600
776
|
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
|
|
601
777
|
const body = await postJson(url, {});
|
|
602
778
|
if (opts.json) {
|
|
603
|
-
// eslint-disable-next-line no-console
|
|
604
779
|
console.log(JSON.stringify(body, null, 2));
|
|
605
780
|
}
|
|
606
781
|
else {
|
|
@@ -645,7 +820,6 @@ ddns
|
|
|
645
820
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
646
821
|
.action(async (provider, opts) => {
|
|
647
822
|
if (!opts.hostname) {
|
|
648
|
-
// eslint-disable-next-line no-console
|
|
649
823
|
console.error('--hostname is required');
|
|
650
824
|
process.exit(1);
|
|
651
825
|
return;
|
|
@@ -655,7 +829,6 @@ ddns
|
|
|
655
829
|
config.token = opts.token;
|
|
656
830
|
if (!config.token) {
|
|
657
831
|
if (!process.stdin.isTTY) {
|
|
658
|
-
// eslint-disable-next-line no-console
|
|
659
832
|
console.error('Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
|
|
660
833
|
'Re-run with --token <secret>.');
|
|
661
834
|
process.exit(1);
|
|
@@ -665,7 +838,6 @@ ddns
|
|
|
665
838
|
config.token = await readSecretLine('DDNS token: ');
|
|
666
839
|
}
|
|
667
840
|
catch (err) {
|
|
668
|
-
// eslint-disable-next-line no-console
|
|
669
841
|
console.error('Failed to read token: ' + err.message);
|
|
670
842
|
process.exit(1);
|
|
671
843
|
return;
|
|
@@ -699,6 +871,160 @@ ddns
|
|
|
699
871
|
printNatStatus(body);
|
|
700
872
|
process.exit(0);
|
|
701
873
|
});
|
|
874
|
+
// ============================================================================
|
|
875
|
+
// push subcommands — provision FCM/APNs credentials for strand-wake delivery
|
|
876
|
+
// ============================================================================
|
|
877
|
+
//
|
|
878
|
+
// These operate directly on the data dir's secret store + host.config.json (they
|
|
879
|
+
// do NOT go through the running management API). Private keys land in the OS
|
|
880
|
+
// keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
|
|
881
|
+
// bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
|
|
882
|
+
// credentials take effect on the next owner-node (re)spawn — run
|
|
883
|
+
// `cadre-host` restart (or restart the service) to apply them immediately.
|
|
884
|
+
const push = program
|
|
885
|
+
.command('push')
|
|
886
|
+
.description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
|
|
887
|
+
push
|
|
888
|
+
.command('fcm')
|
|
889
|
+
.description('Store Firebase Cloud Messaging (Android) service-account credentials')
|
|
890
|
+
.requiredOption('--project-id <id>', 'GCP / Firebase project id')
|
|
891
|
+
.requiredOption('--client-email <email>', 'Service-account email')
|
|
892
|
+
.option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
|
|
893
|
+
.option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
|
|
894
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
895
|
+
.action(async (opts) => {
|
|
896
|
+
const { dataDir } = resolveHostPaths(opts.dataDir);
|
|
897
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
|
|
898
|
+
const secrets = await createSecretsStore(dataDir);
|
|
899
|
+
await setFcmSecret(secrets, {
|
|
900
|
+
projectId: opts.projectId,
|
|
901
|
+
clientEmail: opts.clientEmail,
|
|
902
|
+
privateKey,
|
|
903
|
+
});
|
|
904
|
+
console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
|
|
905
|
+
process.exit(0);
|
|
906
|
+
});
|
|
907
|
+
push
|
|
908
|
+
.command('apns')
|
|
909
|
+
.description('Store Apple Push Notification service (iOS) auth-key credentials')
|
|
910
|
+
.requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
|
|
911
|
+
.requiredOption('--team-id <id>', 'Apple developer team id')
|
|
912
|
+
.requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
|
|
913
|
+
.option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
|
|
914
|
+
.option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
|
|
915
|
+
.option('--production', 'Target the production APNs host (default: sandbox)')
|
|
916
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
917
|
+
.action(async (opts) => {
|
|
918
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
919
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
|
|
920
|
+
const secrets = await createSecretsStore(dataDir);
|
|
921
|
+
// Secret bits → secret store; non-secret app-config → host.config.json.
|
|
922
|
+
await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
|
|
923
|
+
const current = readHostConfig(cfgPath);
|
|
924
|
+
updateHostConfig(cfgPath, {
|
|
925
|
+
push: {
|
|
926
|
+
...current.push,
|
|
927
|
+
apns: { bundleId: opts.bundleId, production: opts.production === true },
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
console.log(`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
|
|
931
|
+
`Restart cadre-host to apply on the owner node.`);
|
|
932
|
+
process.exit(0);
|
|
933
|
+
});
|
|
934
|
+
push
|
|
935
|
+
.command('options')
|
|
936
|
+
.description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
|
|
937
|
+
.option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
|
|
938
|
+
.option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
|
|
939
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
940
|
+
.action((opts) => {
|
|
941
|
+
const { cfgPath } = resolveHostPaths(opts.dataDir);
|
|
942
|
+
const current = readHostConfig(cfgPath);
|
|
943
|
+
const nextPush = { ...current.push };
|
|
944
|
+
if (typeof opts.cooldownMs === 'number')
|
|
945
|
+
nextPush.cooldownMs = opts.cooldownMs;
|
|
946
|
+
if (typeof opts.debounceMs === 'number')
|
|
947
|
+
nextPush.debounceMs = opts.debounceMs;
|
|
948
|
+
updateHostConfig(cfgPath, { push: nextPush });
|
|
949
|
+
console.log('✓ Push options updated.');
|
|
950
|
+
process.exit(0);
|
|
951
|
+
});
|
|
952
|
+
push
|
|
953
|
+
.command('clear')
|
|
954
|
+
.description('Remove stored push credentials')
|
|
955
|
+
.argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
|
|
956
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
957
|
+
.action(async (target, opts) => {
|
|
958
|
+
if (!['fcm', 'apns', 'all'].includes(target)) {
|
|
959
|
+
console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
|
|
960
|
+
process.exit(1);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
964
|
+
const secrets = await createSecretsStore(dataDir);
|
|
965
|
+
if (target === 'fcm' || target === 'all')
|
|
966
|
+
await clearPushSecret(secrets, 'fcm');
|
|
967
|
+
if (target === 'apns' || target === 'all') {
|
|
968
|
+
await clearPushSecret(secrets, 'apns');
|
|
969
|
+
const current = readHostConfig(cfgPath);
|
|
970
|
+
if (current.push?.apns) {
|
|
971
|
+
const { apns: _drop, ...rest } = current.push;
|
|
972
|
+
updateHostConfig(cfgPath, { push: rest });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
|
|
976
|
+
process.exit(0);
|
|
977
|
+
});
|
|
978
|
+
push
|
|
979
|
+
.command('status')
|
|
980
|
+
.description('Show which push platforms are configured (no secret material)')
|
|
981
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
982
|
+
.action(async (opts) => {
|
|
983
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
984
|
+
const secrets = await createSecretsStore(dataDir);
|
|
985
|
+
const status = await pushStatus(secrets);
|
|
986
|
+
const cfg = readHostConfig(cfgPath);
|
|
987
|
+
console.log('Push credentials:');
|
|
988
|
+
console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
|
|
989
|
+
const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
|
|
990
|
+
const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
|
|
991
|
+
console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
|
|
992
|
+
if (cfg.push?.cooldownMs !== undefined)
|
|
993
|
+
console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
|
|
994
|
+
if (cfg.push?.debounceMs !== undefined)
|
|
995
|
+
console.log(` debounceMs: ${cfg.push.debounceMs}`);
|
|
996
|
+
process.exit(0);
|
|
997
|
+
});
|
|
998
|
+
/** Resolve the data dir + config path for a direct-on-disk push subcommand. */
|
|
999
|
+
function resolveHostPaths(dataDirOpt) {
|
|
1000
|
+
const platform = detectPlatform();
|
|
1001
|
+
const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
1002
|
+
const cfgPath = resolveConfigPath(dataDir);
|
|
1003
|
+
if (!existsSync(cfgPath)) {
|
|
1004
|
+
console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
// Resolve the real data dir from the persisted config (handles default-dir installs).
|
|
1008
|
+
const cfg = readHostConfig(cfgPath);
|
|
1009
|
+
return { dataDir: cfg.dataDir, cfgPath };
|
|
1010
|
+
}
|
|
1011
|
+
/** Read a private key from a file or inline flag; exit with a clear error if neither. */
|
|
1012
|
+
function readPrivateKeyArg(file, inline, label) {
|
|
1013
|
+
if (file) {
|
|
1014
|
+
try {
|
|
1015
|
+
return readFileSync(file, 'utf8');
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
console.error(`Failed to read ${label} private key file ${file}: ${err.message}`);
|
|
1019
|
+
process.exit(1);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (inline)
|
|
1023
|
+
return inline;
|
|
1024
|
+
console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
throw new Error('unreachable');
|
|
1027
|
+
}
|
|
702
1028
|
async function getJson(url) {
|
|
703
1029
|
return await callJson(url, 'GET');
|
|
704
1030
|
}
|
|
@@ -719,7 +1045,6 @@ async function callJson(url, method, body) {
|
|
|
719
1045
|
response = await fetch(url, init);
|
|
720
1046
|
}
|
|
721
1047
|
catch (err) {
|
|
722
|
-
// eslint-disable-next-line no-console
|
|
723
1048
|
console.error(`Failed to reach cadre-host at ${url}: ${err.message}\n` +
|
|
724
1049
|
`Hint: is cadre-host running? Try \`cadre-host start\`.`);
|
|
725
1050
|
process.exit(2);
|
|
@@ -727,7 +1052,6 @@ async function callJson(url, method, body) {
|
|
|
727
1052
|
}
|
|
728
1053
|
if (!response.ok) {
|
|
729
1054
|
const text = await response.text().catch(() => '');
|
|
730
|
-
// eslint-disable-next-line no-console
|
|
731
1055
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
732
1056
|
process.exit(1);
|
|
733
1057
|
throw new Error('non-ok');
|
|
@@ -737,36 +1061,26 @@ async function callJson(url, method, body) {
|
|
|
737
1061
|
return await response.json();
|
|
738
1062
|
}
|
|
739
1063
|
function printNatStatus(s) {
|
|
740
|
-
// eslint-disable-next-line no-console
|
|
741
1064
|
console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
|
|
742
1065
|
if (s.routerExternalIp) {
|
|
743
|
-
// eslint-disable-next-line no-console
|
|
744
1066
|
console.log(`Router IP: ${s.routerExternalIp}`);
|
|
745
1067
|
}
|
|
746
|
-
// eslint-disable-next-line no-console
|
|
747
1068
|
console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
|
|
748
|
-
// eslint-disable-next-line no-console
|
|
749
1069
|
console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
|
|
750
1070
|
if (s.mappingLeaseExpiresAt) {
|
|
751
|
-
// eslint-disable-next-line no-console
|
|
752
1071
|
console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
|
|
753
1072
|
}
|
|
754
1073
|
const ddns = s.ddns ?? {};
|
|
755
1074
|
if (ddns.providerId || ddns.hostname) {
|
|
756
|
-
// eslint-disable-next-line no-console
|
|
757
1075
|
console.log(`\nDDNS:`);
|
|
758
|
-
// eslint-disable-next-line no-console
|
|
759
1076
|
console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
|
|
760
|
-
// eslint-disable-next-line no-console
|
|
761
1077
|
console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
|
|
762
1078
|
if (ddns.lastUpdateAt) {
|
|
763
1079
|
const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
|
|
764
|
-
// eslint-disable-next-line no-console
|
|
765
1080
|
console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
|
|
766
1081
|
}
|
|
767
1082
|
}
|
|
768
1083
|
else {
|
|
769
|
-
// eslint-disable-next-line no-console
|
|
770
1084
|
console.log('\nDDNS: not configured');
|
|
771
1085
|
}
|
|
772
1086
|
}
|
|
@@ -822,5 +1136,5 @@ function readSecretLine(prompt) {
|
|
|
822
1136
|
stdin.on('data', onData);
|
|
823
1137
|
});
|
|
824
1138
|
}
|
|
825
|
-
program.parseAsync();
|
|
1139
|
+
void program.parseAsync();
|
|
826
1140
|
//# sourceMappingURL=host.js.map
|