@serfab/cadre-host 1.10.0 → 1.12.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 +423 -423
- package/dist/ui/assets/index-C6nELNfC.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/global-setup.ts +58 -58
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +203 -203
- package/src/__tests__/orchestrator-ports.test.ts +143 -143
- package/src/__tests__/orchestrator-teardown.ts +65 -65
- package/src/__tests__/orchestrator.test.ts +1063 -1063
- package/src/bin/host.ts +1357 -1357
- package/src/donation/__tests__/fake-orchestrator.test.ts +173 -173
- package/src/donation/donation-store.ts +176 -176
- package/src/donation/grant-service.ts +252 -252
- package/src/donation/index.ts +65 -65
- package/src/donation/types.ts +260 -260
- package/src/orchestrator/host-process-orchestrator.ts +1260 -1260
- package/src/orchestrator/port-allocator.ts +73 -73
- package/src/server/__tests__/grants-admin-route.test.ts +171 -171
- package/src/server/events/types.ts +24 -24
- package/src/server/index.ts +267 -267
- package/src/server/routes/grants-admin.ts +74 -74
package/src/bin/host.ts
CHANGED
|
@@ -1,1357 +1,1357 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* CLI entrypoint for cadre-host — the self-hosted cadre node manager.
|
|
5
|
-
*
|
|
6
|
-
* Most subcommands (`status`, `invite`, `trust`, `grant`, `nat`) are thin HTTP
|
|
7
|
-
* clients against the running cadre-host management API on loopback — they
|
|
8
|
-
* don't spin up an inline service, so cadre-host must be running.
|
|
9
|
-
*
|
|
10
|
-
* The exceptions operate on disk directly and need no running service:
|
|
11
|
-
* `install`, `uninstall`, `start`, `ui`, and the `push` group.
|
|
12
|
-
*
|
|
13
|
-
* Every command here needs a matching `### ` heading in the package README's
|
|
14
|
-
* `## CLI reference`; `__tests__/cli-reference.test.ts` enforces that.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
18
|
-
import { createRequire } from 'node:module';
|
|
19
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
-
import { dirname, join, resolve } from 'node:path';
|
|
21
|
-
|
|
22
|
-
import { Command } from 'commander';
|
|
23
|
-
|
|
24
|
-
import { parseDuration } from '../auth/duration.js';
|
|
25
|
-
import { Installer } from '../installer/index.js';
|
|
26
|
-
import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
|
|
27
|
-
import {
|
|
28
|
-
configPath as resolveConfigPath,
|
|
29
|
-
defaultDataDir,
|
|
30
|
-
defaultHostJs,
|
|
31
|
-
defaultServiceDir,
|
|
32
|
-
identityPath as resolveIdentityPath,
|
|
33
|
-
} from '../installer/paths.js';
|
|
34
|
-
import { detectPlatform } from '../installer/platform.js';
|
|
35
|
-
import { createServiceHost } from '../installer/service-host/index.js';
|
|
36
|
-
import { UpdateService } from '../update/index.js';
|
|
37
|
-
import { HostProcessOrchestrator } from '../orchestrator/index.js';
|
|
38
|
-
import { TrustCircleService, TrustCircleStore, ensureSelfLabel } from '../auth/index.js';
|
|
39
|
-
import {
|
|
40
|
-
GrantService,
|
|
41
|
-
GrantStore,
|
|
42
|
-
DonationService,
|
|
43
|
-
DonationStore,
|
|
44
|
-
DonationSupervisor,
|
|
45
|
-
DONATION_AWAITING_SEED_TTL_MS,
|
|
46
|
-
DONATION_PROVISIONING_TTL_MS,
|
|
47
|
-
DONATION_REAP_SWEEP_MS,
|
|
48
|
-
type GrantListing,
|
|
49
|
-
} from '../donation/index.js';
|
|
50
|
-
import { NatService } from '../nat/index.js';
|
|
51
|
-
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
52
|
-
import {
|
|
53
|
-
resolvePushCredentials,
|
|
54
|
-
setFcmSecret,
|
|
55
|
-
setApnsSecret,
|
|
56
|
-
clearPushSecret,
|
|
57
|
-
pushStatus,
|
|
58
|
-
} from '../push/index.js';
|
|
59
|
-
import { StrandService } from '../strands/index.js';
|
|
60
|
-
import { OwnerNodeClient } from '../owner/index.js';
|
|
61
|
-
import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
|
|
62
|
-
import { openBrowser } from '../installer/browser.js';
|
|
63
|
-
|
|
64
|
-
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
65
|
-
|
|
66
|
-
/** libp2p peer-ID prefixes by key type: Ed25519, secp256k1, legacy RSA. */
|
|
67
|
-
const PEER_ID_PREFIXES = ['12D3Koo', '16Uiu2HAm', 'Qm'] as const;
|
|
68
|
-
|
|
69
|
-
function looksLikePeerId(id: string): boolean {
|
|
70
|
-
return PEER_ID_PREFIXES.some((p) => id.startsWith(p));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function resolvePort(raw: string): number {
|
|
74
|
-
const n = Number(raw);
|
|
75
|
-
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
76
|
-
console.error(`Invalid --port: ${raw}`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
return n;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const program = new Command();
|
|
83
|
-
|
|
84
|
-
program
|
|
85
|
-
.name('cadre-host')
|
|
86
|
-
.description('Sereus cadre node manager for self-hosted basement-PC deployments')
|
|
87
|
-
.version(readPackageVersion());
|
|
88
|
-
|
|
89
|
-
// ============================================================================
|
|
90
|
-
// install — run the first-run wizard + register the system service
|
|
91
|
-
// ============================================================================
|
|
92
|
-
|
|
93
|
-
program
|
|
94
|
-
.command('install')
|
|
95
|
-
.description('Install cadre-host as a system service and run first-run setup')
|
|
96
|
-
.option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
|
|
97
|
-
.option('--data-dir <path>', 'Override the data directory')
|
|
98
|
-
.option('--ui-port <port>', 'Override the management UI port', parseIntArg)
|
|
99
|
-
.option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
|
|
100
|
-
.option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
|
|
101
|
-
.option('--no-browser', 'Do not open a browser after install')
|
|
102
|
-
.option('--no-invite', 'Skip generating the first enrollment invite')
|
|
103
|
-
.option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
|
|
104
|
-
.option('--system', 'System-wide install (not yet supported in v1)')
|
|
105
|
-
.option('--node-path <path>', 'Override the node binary embedded in the service unit')
|
|
106
|
-
.option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
|
|
107
|
-
.action(async (opts: {
|
|
108
|
-
nonInteractive?: boolean;
|
|
109
|
-
dataDir?: string;
|
|
110
|
-
uiPort?: number;
|
|
111
|
-
libp2pPort?: number;
|
|
112
|
-
upnp?: boolean;
|
|
113
|
-
browser?: boolean;
|
|
114
|
-
invite?: boolean;
|
|
115
|
-
ownCadre?: boolean;
|
|
116
|
-
system?: boolean;
|
|
117
|
-
nodePath?: string;
|
|
118
|
-
service?: boolean;
|
|
119
|
-
}) => {
|
|
120
|
-
const installer = new Installer();
|
|
121
|
-
try {
|
|
122
|
-
const result = await installer.install({
|
|
123
|
-
nonInteractive: Boolean(opts.nonInteractive),
|
|
124
|
-
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
125
|
-
...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
|
|
126
|
-
...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
|
|
127
|
-
noUpnp: opts.upnp === false,
|
|
128
|
-
openBrowser: opts.browser !== false,
|
|
129
|
-
noInvite: opts.invite === false,
|
|
130
|
-
ownCadre: opts.ownCadre === true,
|
|
131
|
-
system: Boolean(opts.system),
|
|
132
|
-
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
133
|
-
noService: opts.service === false,
|
|
134
|
-
});
|
|
135
|
-
console.log(`cadre-host installed.`);
|
|
136
|
-
console.log(` Data dir: ${result.dataDir}`);
|
|
137
|
-
console.log(` UI: ${result.uiUrl}`);
|
|
138
|
-
if (result.serviceName) {
|
|
139
|
-
console.log(` Service: ${result.serviceName}`);
|
|
140
|
-
} else {
|
|
141
|
-
console.log(' Service: not registered (--no-service)');
|
|
142
|
-
console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
|
|
143
|
-
}
|
|
144
|
-
if (result.enrollmentInvite) {
|
|
145
|
-
printEnrollmentInvite(result.enrollmentInvite);
|
|
146
|
-
}
|
|
147
|
-
process.exit(0);
|
|
148
|
-
} catch (err) {
|
|
149
|
-
console.error(`install failed: ${(err as Error).message}`);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
// ============================================================================
|
|
155
|
-
// uninstall — stop + deregister the system service
|
|
156
|
-
// ============================================================================
|
|
157
|
-
|
|
158
|
-
program
|
|
159
|
-
.command('uninstall')
|
|
160
|
-
.description('Stop and uninstall the cadre-host service')
|
|
161
|
-
.option('--yes', 'Skip the confirmation prompt (for scripts)')
|
|
162
|
-
.option('--remove-data', 'Also remove the data directory (default: preserve)')
|
|
163
|
-
.option('--data-dir <path>', 'Override the data directory to clean up')
|
|
164
|
-
.action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
|
|
165
|
-
const installer = new Installer();
|
|
166
|
-
try {
|
|
167
|
-
// --remove-data is destructive and irreversible (identity + trust circle
|
|
168
|
-
// are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
|
|
169
|
-
// confirmation when it is.
|
|
170
|
-
if (opts.removeData && !opts.yes) {
|
|
171
|
-
if (!process.stdin.isTTY) {
|
|
172
|
-
console.error('uninstall: --remove-data requires --yes when stdin is not a TTY.');
|
|
173
|
-
process.exit(1);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
const dataDir = opts.dataDir ?? '(default data directory)';
|
|
177
|
-
const confirmed = await confirmDestructive(
|
|
178
|
-
`This will permanently delete ${dataDir} (identity, trust circle, NAT state). Continue? [y/N] `,
|
|
179
|
-
);
|
|
180
|
-
if (!confirmed) {
|
|
181
|
-
console.error('uninstall aborted.');
|
|
182
|
-
process.exit(1);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
await installer.uninstall({
|
|
187
|
-
yes: Boolean(opts.yes),
|
|
188
|
-
removeData: Boolean(opts.removeData),
|
|
189
|
-
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
190
|
-
});
|
|
191
|
-
console.log('cadre-host uninstalled.');
|
|
192
|
-
process.exit(0);
|
|
193
|
-
} catch (err) {
|
|
194
|
-
console.error(`uninstall failed: ${(err as Error).message}`);
|
|
195
|
-
process.exit(1);
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ============================================================================
|
|
200
|
-
// status — show service-host registration / running state
|
|
201
|
-
// ============================================================================
|
|
202
|
-
|
|
203
|
-
program
|
|
204
|
-
.command('status')
|
|
205
|
-
.description('Show running status of cadre-host and the cadre nodes it manages')
|
|
206
|
-
.action(async () => {
|
|
207
|
-
const installer = new Installer();
|
|
208
|
-
try {
|
|
209
|
-
const status = await installer.status();
|
|
210
|
-
console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
|
|
211
|
-
console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
|
|
212
|
-
process.exit(0);
|
|
213
|
-
} catch (err) {
|
|
214
|
-
console.error(`status failed: ${(err as Error).message}`);
|
|
215
|
-
process.exit(1);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// ============================================================================
|
|
220
|
-
// start — load config + identity, bind management API listener, wait
|
|
221
|
-
// ============================================================================
|
|
222
|
-
|
|
223
|
-
program
|
|
224
|
-
.command('start')
|
|
225
|
-
.description('Start cadre-host in the foreground')
|
|
226
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
227
|
-
.option('--no-tui', 'Reserved — currently a no-op (no TUI implemented yet)')
|
|
228
|
-
.action(async (opts: { dataDir?: string; tui?: boolean }) => {
|
|
229
|
-
try {
|
|
230
|
-
void opts.tui;
|
|
231
|
-
const platform = detectPlatform();
|
|
232
|
-
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
233
|
-
const cfgPath = resolveConfigPath(dataDir);
|
|
234
|
-
if (!existsSync(cfgPath)) {
|
|
235
|
-
console.error(
|
|
236
|
-
`cadre-host start: ${cfgPath} not found. ` +
|
|
237
|
-
`Run \`cadre-host install\` first (or pass --data-dir to an existing install).`,
|
|
238
|
-
);
|
|
239
|
-
process.exit(1);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const cfg = readHostConfig(cfgPath);
|
|
243
|
-
const idPath = resolveIdentityPath(cfg.dataDir);
|
|
244
|
-
if (!existsSync(idPath)) {
|
|
245
|
-
console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
|
|
246
|
-
process.exit(1);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
|
|
250
|
-
|
|
251
|
-
// Update flow: notify-by-default; auto-apply opt-in via host.config.json.
|
|
252
|
-
// The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
|
|
253
|
-
// just construct the service so the in-process timer + state file are
|
|
254
|
-
// populated.
|
|
255
|
-
const serviceHost = createServiceHost(platform);
|
|
256
|
-
const updateService = new UpdateService({
|
|
257
|
-
dataDir: cfg.dataDir,
|
|
258
|
-
currentVersion: readPackageVersion(),
|
|
259
|
-
settings: cfg.updates,
|
|
260
|
-
...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
|
|
261
|
-
restart: async () => {
|
|
262
|
-
try {
|
|
263
|
-
await serviceHost.restart({
|
|
264
|
-
nodePath: process.execPath,
|
|
265
|
-
hostJs: defaultHostJs(),
|
|
266
|
-
dataDir: cfg.dataDir,
|
|
267
|
-
serviceDir: defaultServiceDir(),
|
|
268
|
-
});
|
|
269
|
-
return undefined;
|
|
270
|
-
} catch (err) {
|
|
271
|
-
return (err as Error).message;
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
// Persist settings changes back to host.config.json on PUT /update/settings.
|
|
276
|
-
updateService.onSettingsChanged((next) => {
|
|
277
|
-
try {
|
|
278
|
-
updateHostConfig(cfgPath, { updates: next });
|
|
279
|
-
} catch (err) {
|
|
280
|
-
console.error(`failed to persist updated settings to ${cfgPath}: ${(err as Error).message}`);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
// Don't block startup on the network round-trip — call and forget.
|
|
284
|
-
void updateService.check();
|
|
285
|
-
updateService.start();
|
|
286
|
-
|
|
287
|
-
// Wire the long-lived HTTP management server. cadre-host's primary role
|
|
288
|
-
// is **node donor**: it spawns generic cadre nodes for *other people's*
|
|
289
|
-
// cadres on request (the donation grant layer below) and never needs an
|
|
290
|
-
// owner node of its own for that. Running the host's **own** personal
|
|
291
|
-
// cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
|
|
292
|
-
// (see docs/cadre-host.md § Two roles: donor and founder). Only when it
|
|
293
|
-
// is enabled do we spawn the owner node and bring up trust-circle + NAT.
|
|
294
|
-
// The manager never joins the control network (docs/cadre-host.md
|
|
295
|
-
// § Control-plane separation).
|
|
296
|
-
// Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
|
|
297
|
-
// restart re-reads the secret store (rotated keys) and nothing raw is
|
|
298
|
-
// persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
|
|
299
|
-
// cooldown/debounce) are re-read from host.config.json each call too.
|
|
300
|
-
const pushSecrets = await createSecretsStore(cfg.dataDir);
|
|
301
|
-
const orchestrator = new HostProcessOrchestrator({
|
|
302
|
-
rootDir: join(cfg.dataDir, 'orchestrator'),
|
|
303
|
-
pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
|
|
304
|
-
});
|
|
305
|
-
await orchestrator.init();
|
|
306
|
-
|
|
307
|
-
// Donation grant layer — the always-on donor surface. Local-only:
|
|
308
|
-
// issue/validate/revoke are pure store ops (no node round-trip), so it
|
|
309
|
-
// needs no owner-node handle. The loopback `/grants-admin` surface is
|
|
310
|
-
// mounted by createLocalUiServer regardless of the founder persona.
|
|
311
|
-
const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
|
|
312
|
-
|
|
313
|
-
// Donation lifecycle service — consumes a validated grant to actually
|
|
314
|
-
// spawn a donated node into the requester's cadre. Drives the
|
|
315
|
-
// grantee-facing `/grants` surface (mounted by createLocalUiServer below).
|
|
316
|
-
const donationStore = new DonationStore(cfg.dataDir);
|
|
317
|
-
const donationService = new DonationService({
|
|
318
|
-
orchestrator,
|
|
319
|
-
grants: grantService,
|
|
320
|
-
store: donationStore,
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// Respawn supervision — nothing else brings a donated node back. A crash,
|
|
324
|
-
// an OOM kill, or a reboot otherwise leaves the record reading `seeded`
|
|
325
|
-
// with no process behind it, costing the borrower a node and their grant a
|
|
326
|
-
// quota slot. Sweeps at startup, on every child exit, and on its own timer.
|
|
327
|
-
const donationSupervisor = new DonationSupervisor({
|
|
328
|
-
service: donationService,
|
|
329
|
-
store: donationStore,
|
|
330
|
-
orchestrator,
|
|
331
|
-
});
|
|
332
|
-
donationSupervisor.start();
|
|
333
|
-
|
|
334
|
-
// Reap orphaned donations: a requester that provisioned a node but never
|
|
335
|
-
// presented a seed leaves an `awaiting_seed` child holding host ports,
|
|
336
|
-
// and a host that died between writing a `provisioning` row and finishing
|
|
337
|
-
// the spawn leaves that row stuck forever (nothing else ever revisits it).
|
|
338
|
-
// Sweep once at startup (for records recovered from disk by
|
|
339
|
-
// orchestrator.init()), then periodically.
|
|
340
|
-
const reapStale = (): void => {
|
|
341
|
-
void donationService
|
|
342
|
-
.reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
|
|
343
|
-
.catch((err) => console.error(`donation reap failed: ${(err as Error).message}`));
|
|
344
|
-
void donationService
|
|
345
|
-
.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)
|
|
346
|
-
.catch((err) => console.error(`donation provisioning reap failed: ${(err as Error).message}`));
|
|
347
|
-
};
|
|
348
|
-
reapStale();
|
|
349
|
-
const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
|
|
350
|
-
reapTimer.unref();
|
|
351
|
-
|
|
352
|
-
// Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
|
|
353
|
-
// donor: no owner node, and the /auth + /nat surfaces stay unmounted
|
|
354
|
-
// (they 404). Per-donated-node WAN reachability is deferred to
|
|
355
|
-
// backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
|
|
356
|
-
// loopback-only — nothing for NatService to map without an owner node.
|
|
357
|
-
let founder: FounderServices | undefined;
|
|
358
|
-
if (hostOwnsCadre(cfg)) {
|
|
359
|
-
// Spawn the owner node. Best-effort: a spawn failure leaves the
|
|
360
|
-
// management API up (trust-circle listing degrades to local labels,
|
|
361
|
-
// owner ops return 503) rather than taking down the whole process.
|
|
362
|
-
try {
|
|
363
|
-
await orchestrator.ensureOwnerNode({
|
|
364
|
-
identityPath: idPath,
|
|
365
|
-
partyId: cfg.installId,
|
|
366
|
-
libp2pPort: cfg.libp2pPort,
|
|
367
|
-
});
|
|
368
|
-
console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
|
|
369
|
-
} catch (err) {
|
|
370
|
-
console.error(`owner node spawn failed: ${(err as Error).message}`);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// The client reads the admin endpoint lazily so a node restart's fresh
|
|
374
|
-
// bearer token is picked up automatically.
|
|
375
|
-
const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
|
|
376
|
-
|
|
377
|
-
const trustCircleStore = new TrustCircleStore(cfg.dataDir);
|
|
378
|
-
const trustCircle = new TrustCircleService({
|
|
379
|
-
cadreNode: owner,
|
|
380
|
-
store: trustCircleStore,
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
const natService = new NatService({
|
|
384
|
-
rootDir: cfg.dataDir,
|
|
385
|
-
cadreNode: owner,
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// Strand management is founder-only for the same reason as the trust
|
|
389
|
-
// circle: it asks the owner node, and donor-only mode has none.
|
|
390
|
-
const strandService = new StrandService({ cadreNode: owner });
|
|
391
|
-
|
|
392
|
-
// Push NAT-resolved invite addresses to the node on every NAT change.
|
|
393
|
-
// NatService.start() also fires this once as an initial push, retried
|
|
394
|
-
// until the freshly spawned owner node accepts it (bounded; the
|
|
395
|
-
// management API that mints invites comes up only after start() resolves).
|
|
396
|
-
natService.onAddressesChanged(async (addresses) => {
|
|
397
|
-
if (addresses.length === 0) return;
|
|
398
|
-
try {
|
|
399
|
-
await owner.pushInviteAddresses(addresses);
|
|
400
|
-
} catch (err) {
|
|
401
|
-
console.error(`invite-address push failed: ${(err as Error).message}`);
|
|
402
|
-
}
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
// Best-effort NAT start. Failures here aren't fatal — the local UI
|
|
406
|
-
// can still serve the trust circle, settings, etc.
|
|
407
|
-
try {
|
|
408
|
-
await natService.start();
|
|
409
|
-
} catch (err) {
|
|
410
|
-
console.error(`NAT start failed: ${(err as Error).message}`);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Label the owner's own device so it shows up in the trust-circle
|
|
414
|
-
// listing (see auth/self-label.ts for why the listing can't get it for
|
|
415
|
-
// free). Runs after natService.start(), which already waits out a
|
|
416
|
-
// freshly spawned node's warm-up, so the peer ID is normally there on
|
|
417
|
-
// the first try. Best-effort: a failure logs and the next start heals
|
|
418
|
-
// it — the listing gap is cosmetic, not a security boundary.
|
|
419
|
-
try {
|
|
420
|
-
await ensureSelfLabel({ store: trustCircleStore, getPeerId: () => owner.getPeerId() });
|
|
421
|
-
} catch (err) {
|
|
422
|
-
console.error(`self trust-circle label failed: ${(err as Error).message}`);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
founder = { trustCircle, nat: natService, strands: strandService };
|
|
426
|
-
} else {
|
|
427
|
-
// Donor-only. If ownCadre was toggled off after a prior founder run,
|
|
428
|
-
// orchestrator.init() re-attaches the still-running owner child (it would
|
|
429
|
-
// otherwise linger in listNodes with no trustCircle/nat wired, serving
|
|
430
|
-
// the host's own cadre despite being disabled). Reap it now so a disabled
|
|
431
|
-
// own-cadre is actually stopped — its workdir + control-DB persist on
|
|
432
|
-
// disk, so toggling ownCadre back on re-spawns it from saved config.
|
|
433
|
-
try {
|
|
434
|
-
await orchestrator.stopOwnerNode();
|
|
435
|
-
} catch (err) {
|
|
436
|
-
console.error(`owner node reap failed: ${(err as Error).message}`);
|
|
437
|
-
}
|
|
438
|
-
console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
|
|
442
|
-
const server = createLocalUiServer({
|
|
443
|
-
uiPort: cfg.uiPort,
|
|
444
|
-
dataDir: cfg.dataDir,
|
|
445
|
-
orchestrator,
|
|
446
|
-
...(founder ? { founder } : {}),
|
|
447
|
-
update: updateService,
|
|
448
|
-
grants: grantService,
|
|
449
|
-
donations: donationService,
|
|
450
|
-
settingsStore,
|
|
451
|
-
});
|
|
452
|
-
const { url, port } = await server.start();
|
|
453
|
-
if (port !== cfg.uiPort) {
|
|
454
|
-
console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
|
|
455
|
-
}
|
|
456
|
-
console.log(`cadre-host local UI: ${url}`);
|
|
457
|
-
|
|
458
|
-
await waitForTermination();
|
|
459
|
-
clearInterval(reapTimer);
|
|
460
|
-
donationSupervisor.stop();
|
|
461
|
-
try { await server.stop(); } catch { /* ignore */ }
|
|
462
|
-
try { await founder?.nat.stop(); } catch { /* ignore */ }
|
|
463
|
-
try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
|
|
464
|
-
updateService.stop();
|
|
465
|
-
console.log('cadre-host stopped.');
|
|
466
|
-
process.exit(0);
|
|
467
|
-
} catch (err) {
|
|
468
|
-
console.error(`start failed: ${(err as Error).message}`);
|
|
469
|
-
process.exit(1);
|
|
470
|
-
}
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
// ============================================================================
|
|
474
|
-
// ui — print the local-UI URL and open it in a browser
|
|
475
|
-
// ============================================================================
|
|
476
|
-
|
|
477
|
-
program
|
|
478
|
-
.command('ui')
|
|
479
|
-
.description('Print the local-UI URL and open it in the default browser')
|
|
480
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
481
|
-
.option('--no-browser', 'Print the URL but do not open a browser')
|
|
482
|
-
.action((opts: { dataDir?: string; browser?: boolean }) => {
|
|
483
|
-
try {
|
|
484
|
-
const platform = detectPlatform();
|
|
485
|
-
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
486
|
-
const cfgPath = resolveConfigPath(dataDir);
|
|
487
|
-
if (!existsSync(cfgPath)) {
|
|
488
|
-
console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
|
|
489
|
-
process.exit(1);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
const cfg = readHostConfig(cfgPath);
|
|
493
|
-
const url = `http://127.0.0.1:${cfg.uiPort}`;
|
|
494
|
-
console.log(url);
|
|
495
|
-
if (opts.browser !== false) {
|
|
496
|
-
const res = openBrowser(url);
|
|
497
|
-
if (res.spawned) {
|
|
498
|
-
console.log('(Opening in your default browser...)');
|
|
499
|
-
} else if (res.error) {
|
|
500
|
-
console.error(`(unable to launch browser: ${res.error})`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
process.exit(0);
|
|
504
|
-
} catch (err) {
|
|
505
|
-
console.error(`ui failed: ${(err as Error).message}`);
|
|
506
|
-
process.exit(1);
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
function parseIntArg(raw: string): number {
|
|
511
|
-
const n = Number(raw);
|
|
512
|
-
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
513
|
-
throw new Error(`Invalid integer: ${raw}`);
|
|
514
|
-
}
|
|
515
|
-
return n;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function readPackageVersion(): string {
|
|
519
|
-
try {
|
|
520
|
-
// dist/bin/host.js -> ../../package.json
|
|
521
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
522
|
-
const pkgPath = resolve(here, '..', '..', 'package.json');
|
|
523
|
-
const raw = readFileSync(pkgPath, 'utf8');
|
|
524
|
-
const parsed = JSON.parse(raw) as { version?: string };
|
|
525
|
-
return parsed.version ?? '0.0.0-unknown';
|
|
526
|
-
} catch {
|
|
527
|
-
return '0.0.0-unknown';
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function waitForTermination(): Promise<void> {
|
|
532
|
-
return new Promise<void>((resolveTerm) => {
|
|
533
|
-
const done = () => resolveTerm();
|
|
534
|
-
process.once('SIGINT', done);
|
|
535
|
-
process.once('SIGTERM', done);
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async function confirmDestructive(message: string): Promise<boolean> {
|
|
540
|
-
const { createInterface } = await import('node:readline');
|
|
541
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
542
|
-
try {
|
|
543
|
-
const answer = await new Promise<string>((res) => rl.question(message, res));
|
|
544
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
545
|
-
} finally {
|
|
546
|
-
rl.close();
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const requireForQr = createRequire(import.meta.url);
|
|
551
|
-
|
|
552
|
-
function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: string }): void {
|
|
553
|
-
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
554
|
-
console.log('\nEnroll your first device with this invite:');
|
|
555
|
-
try {
|
|
556
|
-
const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
|
|
557
|
-
qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
|
|
558
|
-
console.log(rendered);
|
|
559
|
-
});
|
|
560
|
-
} catch (err) {
|
|
561
|
-
console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
|
|
562
|
-
}
|
|
563
|
-
console.log(` ${invite.encodedInvite}`);
|
|
564
|
-
if (invite.expiresAt) {
|
|
565
|
-
console.log(` (expires ${invite.expiresAt})`);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function printGrantToken(token: string, withQr: boolean): void {
|
|
570
|
-
if (withQr) {
|
|
571
|
-
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
572
|
-
try {
|
|
573
|
-
const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
|
|
574
|
-
qr.generate(token, { small: true }, (rendered) => {
|
|
575
|
-
console.error(rendered);
|
|
576
|
-
});
|
|
577
|
-
} catch (err) {
|
|
578
|
-
console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
// The token goes to stdout alone so it can be piped/copied; metadata to stderr.
|
|
582
|
-
console.log(token);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
program
|
|
586
|
-
.command('invite')
|
|
587
|
-
.description('Generate an invite to add a member to the trust circle')
|
|
588
|
-
.argument('<label>', 'Display label for the new member (e.g. "Mom\'s phone")')
|
|
589
|
-
.option('--ttl <duration>', 'Invite lifetime (e.g. 24h, 7d, 30m)', '24h')
|
|
590
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
591
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
592
|
-
.action(async (label: string, opts: { ttl: string; port: string; host: string }) => {
|
|
593
|
-
let ttlMs: number;
|
|
594
|
-
try {
|
|
595
|
-
ttlMs = parseDuration(opts.ttl);
|
|
596
|
-
} catch (err) {
|
|
597
|
-
console.error(`Invalid --ttl: ${(err as Error).message}`);
|
|
598
|
-
process.exit(1);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const port = resolvePort(opts.port);
|
|
603
|
-
|
|
604
|
-
const url = `http://${opts.host}:${port}/auth/invites`;
|
|
605
|
-
let response: Response;
|
|
606
|
-
try {
|
|
607
|
-
response = await fetch(url, {
|
|
608
|
-
method: 'POST',
|
|
609
|
-
headers: { 'content-type': 'application/json' },
|
|
610
|
-
body: JSON.stringify({ label, ttlMs }),
|
|
611
|
-
});
|
|
612
|
-
} catch (err) {
|
|
613
|
-
console.error(
|
|
614
|
-
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
615
|
-
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
616
|
-
);
|
|
617
|
-
process.exit(2);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (!response.ok) {
|
|
622
|
-
const text = await response.text().catch(() => '');
|
|
623
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
624
|
-
process.exit(1);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const body = await response.json() as { encodedInvite?: string; expiresAt?: string; token?: string };
|
|
629
|
-
if (!body.encodedInvite) {
|
|
630
|
-
console.error('cadre-host returned malformed response (missing encodedInvite)');
|
|
631
|
-
process.exit(1);
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
console.log(body.encodedInvite);
|
|
636
|
-
if (body.expiresAt) {
|
|
637
|
-
console.error(`(expires at ${body.expiresAt})`);
|
|
638
|
-
}
|
|
639
|
-
process.exit(0);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
const trust = program
|
|
643
|
-
.command('trust')
|
|
644
|
-
.description('Manage the trust circle (list members, revoke invites/members)');
|
|
645
|
-
|
|
646
|
-
trust
|
|
647
|
-
.command('list')
|
|
648
|
-
.description('List trust-circle members and pending invites')
|
|
649
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
650
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
651
|
-
.action(async (opts: { port: string; host: string }) => {
|
|
652
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/auth/trust-circle`;
|
|
653
|
-
let response: Response;
|
|
654
|
-
try {
|
|
655
|
-
response = await fetch(url);
|
|
656
|
-
} catch (err) {
|
|
657
|
-
console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
|
|
658
|
-
process.exit(2);
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
if (!response.ok) {
|
|
662
|
-
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
663
|
-
process.exit(1);
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
const body = await response.json() as {
|
|
667
|
-
members: Array<{ peerId: string; label: string; addedAt: string; self?: boolean }>;
|
|
668
|
-
pending: Array<{ token: string; label: string; createdAt: string; expiresAt?: string }>;
|
|
669
|
-
};
|
|
670
|
-
console.log('Members:');
|
|
671
|
-
if (body.members.length === 0) {
|
|
672
|
-
console.log(' (none)');
|
|
673
|
-
} else {
|
|
674
|
-
for (const m of body.members) {
|
|
675
|
-
const self = m.self ? ' [self]' : '';
|
|
676
|
-
console.log(` ${m.peerId} ${m.label}${self}`);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
console.log('\nPending invites:');
|
|
680
|
-
if (body.pending.length === 0) {
|
|
681
|
-
console.log(' (none)');
|
|
682
|
-
} else {
|
|
683
|
-
for (const p of body.pending) {
|
|
684
|
-
const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
|
|
685
|
-
console.log(` ${p.token} ${p.label}${expires}`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
process.exit(0);
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
trust
|
|
692
|
-
.command('revoke')
|
|
693
|
-
.description('Revoke a pending invite (by token) or remove a member (by peerId)')
|
|
694
|
-
.argument('<id>', 'Token (pending invite) or peerId (existing member) to remove')
|
|
695
|
-
.option('--kind <kind>', 'Force interpretation: "invite" | "member" (default: auto)', 'auto')
|
|
696
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
697
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
698
|
-
.action(async (id: string, opts: { kind: string; port: string; host: string }) => {
|
|
699
|
-
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
700
|
-
const kind = opts.kind === 'auto'
|
|
701
|
-
? (looksLikePeerId(id) ? 'member' : 'invite')
|
|
702
|
-
: opts.kind;
|
|
703
|
-
const path = kind === 'member'
|
|
704
|
-
? `/auth/members/${encodeURIComponent(id)}`
|
|
705
|
-
: `/auth/invites/${encodeURIComponent(id)}`;
|
|
706
|
-
|
|
707
|
-
let response: Response;
|
|
708
|
-
try {
|
|
709
|
-
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
710
|
-
} catch (err) {
|
|
711
|
-
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
712
|
-
process.exit(2);
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
if (!response.ok) {
|
|
716
|
-
const text = await response.text().catch(() => '');
|
|
717
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
718
|
-
process.exit(1);
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
console.log(`revoked ${kind}: ${id}`);
|
|
722
|
-
process.exit(0);
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
// ============================================================================
|
|
726
|
-
// grant subcommands — donation grant tokens (who may ask this host for a node)
|
|
727
|
-
// ============================================================================
|
|
728
|
-
//
|
|
729
|
-
// A grant token lets one grantee (friend/family) present a Bearer credential to
|
|
730
|
-
// ask this host to donate cadre nodes, up to a per-grantee cap. These commands
|
|
731
|
-
// are thin HTTP clients of the loopback `/grants-admin` admin surface — no
|
|
732
|
-
// bearer (same-machine admin), same posture as `invite` / `trust`.
|
|
733
|
-
|
|
734
|
-
const grant = program
|
|
735
|
-
.command('grant')
|
|
736
|
-
.description('Manage donation grant tokens (who may ask this host to donate a node)');
|
|
737
|
-
|
|
738
|
-
grant
|
|
739
|
-
.command('issue')
|
|
740
|
-
.description('Issue a grant token for one grantee (prints the token + QR)')
|
|
741
|
-
.argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
|
|
742
|
-
.option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
|
|
743
|
-
.option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
|
|
744
|
-
.option('--no-qr', 'Print only the token, no QR code')
|
|
745
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
746
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
747
|
-
.action(async (label: string, opts: {
|
|
748
|
-
maxNodes?: number;
|
|
749
|
-
ttl?: string;
|
|
750
|
-
qr?: boolean;
|
|
751
|
-
port: string;
|
|
752
|
-
host: string;
|
|
753
|
-
}) => {
|
|
754
|
-
const payload: { label: string; maxNodes?: number; ttlMs?: number } = { label };
|
|
755
|
-
if (typeof opts.maxNodes === 'number') payload.maxNodes = opts.maxNodes;
|
|
756
|
-
if (opts.ttl) {
|
|
757
|
-
try {
|
|
758
|
-
payload.ttlMs = parseDuration(opts.ttl);
|
|
759
|
-
} catch (err) {
|
|
760
|
-
console.error(`Invalid --ttl: ${(err as Error).message}`);
|
|
761
|
-
process.exit(1);
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
767
|
-
let response: Response;
|
|
768
|
-
try {
|
|
769
|
-
response = await fetch(url, {
|
|
770
|
-
method: 'POST',
|
|
771
|
-
headers: { 'content-type': 'application/json' },
|
|
772
|
-
body: JSON.stringify(payload),
|
|
773
|
-
});
|
|
774
|
-
} catch (err) {
|
|
775
|
-
console.error(
|
|
776
|
-
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
777
|
-
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
778
|
-
);
|
|
779
|
-
process.exit(2);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
if (!response.ok) {
|
|
783
|
-
const text = await response.text().catch(() => '');
|
|
784
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
785
|
-
process.exit(1);
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
const body = await response.json() as { grant?: { token?: string; maxNodes?: number; expiresAt?: string } };
|
|
789
|
-
const issued = body.grant;
|
|
790
|
-
if (!issued?.token) {
|
|
791
|
-
console.error('cadre-host returned malformed response (missing grant token)');
|
|
792
|
-
process.exit(1);
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
printGrantToken(issued.token, opts.qr !== false);
|
|
796
|
-
console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
|
|
797
|
-
process.exit(0);
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
grant
|
|
801
|
-
.command('list')
|
|
802
|
-
.description('List issued grant tokens')
|
|
803
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
804
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
805
|
-
.action(async (opts: { port: string; host: string }) => {
|
|
806
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
807
|
-
let response: Response;
|
|
808
|
-
try {
|
|
809
|
-
response = await fetch(url);
|
|
810
|
-
} catch (err) {
|
|
811
|
-
console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
|
|
812
|
-
process.exit(2);
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
if (!response.ok) {
|
|
816
|
-
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
817
|
-
process.exit(1);
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
const body = await response.json() as { grants: GrantListing[] };
|
|
821
|
-
console.log('Grants:');
|
|
822
|
-
if (body.grants.length === 0) {
|
|
823
|
-
console.log(' (none)');
|
|
824
|
-
} else {
|
|
825
|
-
for (const g of body.grants) {
|
|
826
|
-
const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
|
|
827
|
-
console.log(` ${g.token} ${g.label} live=${g.liveNodes} max=${g.maxNodes}${state}`);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
process.exit(0);
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
grant
|
|
834
|
-
.command('revoke')
|
|
835
|
-
.description('Revoke a grant token (blocks future requests) and shut down the nodes donated under it')
|
|
836
|
-
.argument('<token>', 'Grant token to revoke')
|
|
837
|
-
.option('--keep-nodes', 'Leave the nodes already donated under this grant running')
|
|
838
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
839
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
840
|
-
.action(async (token: string, opts: { keepNodes?: boolean; port: string; host: string }) => {
|
|
841
|
-
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
842
|
-
const query = opts.keepNodes ? '?keepNodes=true' : '';
|
|
843
|
-
const response = await adminDelete(base, `/grants-admin/${encodeURIComponent(token)}${query}`);
|
|
844
|
-
const body = await response.json() as { terminated?: string[] };
|
|
845
|
-
console.log(`revoked grant: ${token}`);
|
|
846
|
-
console.log(opts.keepNodes
|
|
847
|
-
? 'existing donated nodes left running'
|
|
848
|
-
: `terminated ${body.terminated?.length ?? 0} donated node(s)`);
|
|
849
|
-
process.exit(0);
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
grant
|
|
853
|
-
.command('terminate')
|
|
854
|
-
.description('Shut down one donated node (the id shown on the Nodes page)')
|
|
855
|
-
.argument('<donation-id>', 'Donation id (grn_…) of the node to shut down')
|
|
856
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
857
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
858
|
-
.action(async (id: string, opts: { port: string; host: string }) => {
|
|
859
|
-
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
860
|
-
await adminDelete(base, `/grants-admin/donations/${encodeURIComponent(id)}`);
|
|
861
|
-
console.log(`terminated donated node: ${id}`);
|
|
862
|
-
process.exit(0);
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
/**
|
|
866
|
-
* DELETE against the loopback admin surface, exiting the process on failure —
|
|
867
|
-
* 2 when cadre-host is unreachable, 1 on a non-OK response — so callers only
|
|
868
|
-
* ever see an OK response.
|
|
869
|
-
*/
|
|
870
|
-
async function adminDelete(base: string, path: string): Promise<Response> {
|
|
871
|
-
let response: Response;
|
|
872
|
-
try {
|
|
873
|
-
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
874
|
-
} catch (err) {
|
|
875
|
-
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
876
|
-
process.exit(2);
|
|
877
|
-
}
|
|
878
|
-
if (!response.ok) {
|
|
879
|
-
const text = await response.text().catch(() => '');
|
|
880
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
881
|
-
process.exit(1);
|
|
882
|
-
}
|
|
883
|
-
return response;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// ============================================================================
|
|
887
|
-
// nat subcommands
|
|
888
|
-
// ============================================================================
|
|
889
|
-
|
|
890
|
-
const nat = program
|
|
891
|
-
.command('nat')
|
|
892
|
-
.description('Manage NAT traversal, port forwarding, and dynamic DNS');
|
|
893
|
-
|
|
894
|
-
nat
|
|
895
|
-
.command('status')
|
|
896
|
-
.description('Show NAT, external-IP, and DDNS status')
|
|
897
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
898
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
899
|
-
.option('--json', 'Output raw JSON instead of a human-readable summary')
|
|
900
|
-
.action(async (opts: { port: string; host: string; json?: boolean }) => {
|
|
901
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
|
|
902
|
-
const body = await getJson(url);
|
|
903
|
-
if (opts.json) {
|
|
904
|
-
console.log(JSON.stringify(body, null, 2));
|
|
905
|
-
} else {
|
|
906
|
-
printNatStatus(body);
|
|
907
|
-
}
|
|
908
|
-
process.exit(0);
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
nat
|
|
912
|
-
.command('test')
|
|
913
|
-
.description('Re-run reachability probes and print the result')
|
|
914
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
915
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
916
|
-
.option('--json', 'Output raw JSON instead of a human-readable summary')
|
|
917
|
-
.action(async (opts: { port: string; host: string; json?: boolean }) => {
|
|
918
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
|
|
919
|
-
const body = await postJson(url, {});
|
|
920
|
-
if (opts.json) {
|
|
921
|
-
console.log(JSON.stringify(body, null, 2));
|
|
922
|
-
} else {
|
|
923
|
-
printNatStatus(body);
|
|
924
|
-
}
|
|
925
|
-
process.exit(0);
|
|
926
|
-
});
|
|
927
|
-
|
|
928
|
-
nat
|
|
929
|
-
.command('settings')
|
|
930
|
-
.description('Update NAT settings (external port, UPnP toggle)')
|
|
931
|
-
.option('--external-port <port>', 'External port to map')
|
|
932
|
-
.option('--internal-port <port>', 'Internal libp2p port (defaults to external)')
|
|
933
|
-
.option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
|
|
934
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
935
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
936
|
-
.action(async (opts: {
|
|
937
|
-
externalPort?: string;
|
|
938
|
-
internalPort?: string;
|
|
939
|
-
upnp?: boolean;
|
|
940
|
-
port: string;
|
|
941
|
-
host: string;
|
|
942
|
-
}) => {
|
|
943
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
|
|
944
|
-
const patch: Record<string, unknown> = {};
|
|
945
|
-
if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
|
|
946
|
-
if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
|
|
947
|
-
// Commander assigns `upnp: false` when `--no-upnp` is passed.
|
|
948
|
-
if (opts.upnp === false) patch.upnpEnabled = false;
|
|
949
|
-
if (opts.upnp === true) patch.upnpEnabled = true;
|
|
950
|
-
|
|
951
|
-
const body = await putJson(url, patch);
|
|
952
|
-
printNatStatus(body);
|
|
953
|
-
process.exit(0);
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
const ddns = nat
|
|
957
|
-
.command('ddns')
|
|
958
|
-
.description('Configure dynamic DNS');
|
|
959
|
-
|
|
960
|
-
ddns
|
|
961
|
-
.command('set')
|
|
962
|
-
.description('Configure a DDNS provider (and store credentials in the keychain)')
|
|
963
|
-
.argument('<provider>', 'Provider ID (e.g. "duckdns")')
|
|
964
|
-
.option('--hostname <hostname>', 'Hostname to publish (e.g. foo.duckdns.org)')
|
|
965
|
-
.option('--token <token>', 'Provider token (a secret value; prompts if omitted)')
|
|
966
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
967
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
968
|
-
.action(async (provider: string, opts: {
|
|
969
|
-
hostname?: string;
|
|
970
|
-
token?: string;
|
|
971
|
-
port: string;
|
|
972
|
-
host: string;
|
|
973
|
-
}) => {
|
|
974
|
-
if (!opts.hostname) {
|
|
975
|
-
console.error('--hostname is required');
|
|
976
|
-
process.exit(1);
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
const config: Record<string, string> = {};
|
|
980
|
-
if (opts.token) config.token = opts.token;
|
|
981
|
-
|
|
982
|
-
if (!config.token) {
|
|
983
|
-
if (!process.stdin.isTTY) {
|
|
984
|
-
console.error(
|
|
985
|
-
'Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
|
|
986
|
-
'Re-run with --token <secret>.',
|
|
987
|
-
);
|
|
988
|
-
process.exit(1);
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
try {
|
|
992
|
-
config.token = await readSecretLine('DDNS token: ');
|
|
993
|
-
} catch (err) {
|
|
994
|
-
console.error('Failed to read token: ' + (err as Error).message);
|
|
995
|
-
process.exit(1);
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/ddns`;
|
|
1001
|
-
const body = await putJson(url, {
|
|
1002
|
-
providerId: provider,
|
|
1003
|
-
hostname: opts.hostname,
|
|
1004
|
-
config,
|
|
1005
|
-
externallyManaged: false,
|
|
1006
|
-
});
|
|
1007
|
-
printNatStatus(body);
|
|
1008
|
-
process.exit(0);
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
ddns
|
|
1012
|
-
.command('external')
|
|
1013
|
-
.description('Declare an externally-managed DDNS hostname (cadre-host does not update it)')
|
|
1014
|
-
.requiredOption('--hostname <hostname>', 'Hostname (no provider; cadre-host will not push updates)')
|
|
1015
|
-
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
1016
|
-
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
1017
|
-
.action(async (opts: { hostname: string; port: string; host: string }) => {
|
|
1018
|
-
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
|
|
1019
|
-
const body = await putJson(url, {
|
|
1020
|
-
ddns: {
|
|
1021
|
-
providerId: 'external',
|
|
1022
|
-
hostname: opts.hostname,
|
|
1023
|
-
externallyManaged: true,
|
|
1024
|
-
},
|
|
1025
|
-
});
|
|
1026
|
-
printNatStatus(body);
|
|
1027
|
-
process.exit(0);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
// ============================================================================
|
|
1031
|
-
// push subcommands — provision FCM/APNs credentials for strand-wake delivery
|
|
1032
|
-
// ============================================================================
|
|
1033
|
-
//
|
|
1034
|
-
// These operate directly on the data dir's secret store + host.config.json (they
|
|
1035
|
-
// do NOT go through the running management API). Private keys land in the OS
|
|
1036
|
-
// keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
|
|
1037
|
-
// bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
|
|
1038
|
-
// credentials take effect on the next owner-node (re)spawn — run
|
|
1039
|
-
// `cadre-host` restart (or restart the service) to apply them immediately.
|
|
1040
|
-
|
|
1041
|
-
const push = program
|
|
1042
|
-
.command('push')
|
|
1043
|
-
.description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
|
|
1044
|
-
|
|
1045
|
-
push
|
|
1046
|
-
.command('fcm')
|
|
1047
|
-
.description('Store Firebase Cloud Messaging (Android) service-account credentials')
|
|
1048
|
-
.requiredOption('--project-id <id>', 'GCP / Firebase project id')
|
|
1049
|
-
.requiredOption('--client-email <email>', 'Service-account email')
|
|
1050
|
-
.option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
|
|
1051
|
-
.option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
|
|
1052
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1053
|
-
.action(async (opts: {
|
|
1054
|
-
projectId: string;
|
|
1055
|
-
clientEmail: string;
|
|
1056
|
-
privateKeyFile?: string;
|
|
1057
|
-
privateKey?: string;
|
|
1058
|
-
dataDir?: string;
|
|
1059
|
-
}) => {
|
|
1060
|
-
const { dataDir } = resolveHostPaths(opts.dataDir);
|
|
1061
|
-
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
|
|
1062
|
-
const secrets = await createSecretsStore(dataDir);
|
|
1063
|
-
await setFcmSecret(secrets, {
|
|
1064
|
-
projectId: opts.projectId,
|
|
1065
|
-
clientEmail: opts.clientEmail,
|
|
1066
|
-
privateKey,
|
|
1067
|
-
});
|
|
1068
|
-
console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
|
|
1069
|
-
process.exit(0);
|
|
1070
|
-
});
|
|
1071
|
-
|
|
1072
|
-
push
|
|
1073
|
-
.command('apns')
|
|
1074
|
-
.description('Store Apple Push Notification service (iOS) auth-key credentials')
|
|
1075
|
-
.requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
|
|
1076
|
-
.requiredOption('--team-id <id>', 'Apple developer team id')
|
|
1077
|
-
.requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
|
|
1078
|
-
.option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
|
|
1079
|
-
.option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
|
|
1080
|
-
.option('--production', 'Target the production APNs host (default: sandbox)')
|
|
1081
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1082
|
-
.action(async (opts: {
|
|
1083
|
-
keyId: string;
|
|
1084
|
-
teamId: string;
|
|
1085
|
-
bundleId: string;
|
|
1086
|
-
privateKeyFile?: string;
|
|
1087
|
-
privateKey?: string;
|
|
1088
|
-
production?: boolean;
|
|
1089
|
-
dataDir?: string;
|
|
1090
|
-
}) => {
|
|
1091
|
-
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1092
|
-
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
|
|
1093
|
-
const secrets = await createSecretsStore(dataDir);
|
|
1094
|
-
// Secret bits → secret store; non-secret app-config → host.config.json.
|
|
1095
|
-
await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
|
|
1096
|
-
const current = readHostConfig(cfgPath);
|
|
1097
|
-
updateHostConfig(cfgPath, {
|
|
1098
|
-
push: {
|
|
1099
|
-
...current.push,
|
|
1100
|
-
apns: { bundleId: opts.bundleId, production: opts.production === true },
|
|
1101
|
-
},
|
|
1102
|
-
});
|
|
1103
|
-
console.log(
|
|
1104
|
-
`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
|
|
1105
|
-
`Restart cadre-host to apply on the owner node.`,
|
|
1106
|
-
);
|
|
1107
|
-
process.exit(0);
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
push
|
|
1111
|
-
.command('options')
|
|
1112
|
-
.description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
|
|
1113
|
-
.option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
|
|
1114
|
-
.option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
|
|
1115
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1116
|
-
.action((opts: { cooldownMs?: number; debounceMs?: number; dataDir?: string }) => {
|
|
1117
|
-
const { cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1118
|
-
const current = readHostConfig(cfgPath);
|
|
1119
|
-
const nextPush = { ...current.push };
|
|
1120
|
-
if (typeof opts.cooldownMs === 'number') nextPush.cooldownMs = opts.cooldownMs;
|
|
1121
|
-
if (typeof opts.debounceMs === 'number') nextPush.debounceMs = opts.debounceMs;
|
|
1122
|
-
updateHostConfig(cfgPath, { push: nextPush });
|
|
1123
|
-
console.log('✓ Push options updated.');
|
|
1124
|
-
process.exit(0);
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
push
|
|
1128
|
-
.command('clear')
|
|
1129
|
-
.description('Remove stored push credentials')
|
|
1130
|
-
.argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
|
|
1131
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1132
|
-
.action(async (target: string, opts: { dataDir?: string }) => {
|
|
1133
|
-
if (!['fcm', 'apns', 'all'].includes(target)) {
|
|
1134
|
-
console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
|
|
1135
|
-
process.exit(1);
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1139
|
-
const secrets = await createSecretsStore(dataDir);
|
|
1140
|
-
if (target === 'fcm' || target === 'all') await clearPushSecret(secrets, 'fcm');
|
|
1141
|
-
if (target === 'apns' || target === 'all') {
|
|
1142
|
-
await clearPushSecret(secrets, 'apns');
|
|
1143
|
-
const current = readHostConfig(cfgPath);
|
|
1144
|
-
if (current.push?.apns) {
|
|
1145
|
-
const { apns: _drop, ...rest } = current.push;
|
|
1146
|
-
updateHostConfig(cfgPath, { push: rest });
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
|
|
1150
|
-
process.exit(0);
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
push
|
|
1154
|
-
.command('status')
|
|
1155
|
-
.description('Show which push platforms are configured (no secret material)')
|
|
1156
|
-
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1157
|
-
.action(async (opts: { dataDir?: string }) => {
|
|
1158
|
-
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1159
|
-
const secrets = await createSecretsStore(dataDir);
|
|
1160
|
-
const status = await pushStatus(secrets);
|
|
1161
|
-
const cfg = readHostConfig(cfgPath);
|
|
1162
|
-
console.log('Push credentials:');
|
|
1163
|
-
console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
|
|
1164
|
-
const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
|
|
1165
|
-
const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
|
|
1166
|
-
console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
|
|
1167
|
-
if (cfg.push?.cooldownMs !== undefined) console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
|
|
1168
|
-
if (cfg.push?.debounceMs !== undefined) console.log(` debounceMs: ${cfg.push.debounceMs}`);
|
|
1169
|
-
process.exit(0);
|
|
1170
|
-
});
|
|
1171
|
-
|
|
1172
|
-
/** Resolve the data dir + config path for a direct-on-disk push subcommand. */
|
|
1173
|
-
function resolveHostPaths(dataDirOpt?: string): { dataDir: string; cfgPath: string } {
|
|
1174
|
-
const platform = detectPlatform();
|
|
1175
|
-
const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
1176
|
-
const cfgPath = resolveConfigPath(dataDir);
|
|
1177
|
-
if (!existsSync(cfgPath)) {
|
|
1178
|
-
console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
|
|
1179
|
-
process.exit(1);
|
|
1180
|
-
}
|
|
1181
|
-
// Resolve the real data dir from the persisted config (handles default-dir installs).
|
|
1182
|
-
const cfg = readHostConfig(cfgPath);
|
|
1183
|
-
return { dataDir: cfg.dataDir, cfgPath };
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/** Read a private key from a file or inline flag; exit with a clear error if neither. */
|
|
1187
|
-
function readPrivateKeyArg(file: string | undefined, inline: string | undefined, label: string): string {
|
|
1188
|
-
if (file) {
|
|
1189
|
-
try {
|
|
1190
|
-
return readFileSync(file, 'utf8');
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
console.error(`Failed to read ${label} private key file ${file}: ${(err as Error).message}`);
|
|
1193
|
-
process.exit(1);
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
if (inline) return inline;
|
|
1197
|
-
console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
|
|
1198
|
-
process.exit(1);
|
|
1199
|
-
throw new Error('unreachable');
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
async function getJson(url: string): Promise<NatStatusLike> {
|
|
1203
|
-
return await callJson(url, 'GET');
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1207
|
-
return await callJson(url, 'POST', body);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1211
|
-
return await callJson(url, 'PUT', body);
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
interface NatStatusLike {
|
|
1215
|
-
portMode?: string;
|
|
1216
|
-
externalPort?: number;
|
|
1217
|
-
internalPort?: number;
|
|
1218
|
-
externalIp?: string | null;
|
|
1219
|
-
routerExternalIp?: string | null;
|
|
1220
|
-
cgnatDetected?: boolean;
|
|
1221
|
-
directReachability?: string;
|
|
1222
|
-
lastTestedAt?: string | null;
|
|
1223
|
-
mappingLeaseExpiresAt?: string | null;
|
|
1224
|
-
ddns?: {
|
|
1225
|
-
providerId?: string | null;
|
|
1226
|
-
hostname?: string | null;
|
|
1227
|
-
externallyManaged?: boolean;
|
|
1228
|
-
lastUpdateAt?: string | null;
|
|
1229
|
-
lastUpdateOk?: boolean | null;
|
|
1230
|
-
lastError?: string | null;
|
|
1231
|
-
};
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
async function callJson(url: string, method: string, body?: unknown): Promise<NatStatusLike> {
|
|
1235
|
-
let response: Response;
|
|
1236
|
-
try {
|
|
1237
|
-
const init: RequestInit = { method };
|
|
1238
|
-
if (body !== undefined) {
|
|
1239
|
-
init.headers = { 'content-type': 'application/json' };
|
|
1240
|
-
init.body = JSON.stringify(body);
|
|
1241
|
-
}
|
|
1242
|
-
response = await fetch(url, init);
|
|
1243
|
-
} catch (err) {
|
|
1244
|
-
console.error(
|
|
1245
|
-
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
1246
|
-
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
1247
|
-
);
|
|
1248
|
-
process.exit(2);
|
|
1249
|
-
throw err;
|
|
1250
|
-
}
|
|
1251
|
-
if (!response.ok) {
|
|
1252
|
-
const text = await response.text().catch(() => '');
|
|
1253
|
-
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
1254
|
-
process.exit(1);
|
|
1255
|
-
throw new Error('non-ok');
|
|
1256
|
-
}
|
|
1257
|
-
if (response.status === 204) return {} as NatStatusLike;
|
|
1258
|
-
return await response.json() as NatStatusLike;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
function printNatStatus(s: NatStatusLike): void {
|
|
1262
|
-
console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
|
|
1263
|
-
if (s.routerExternalIp) {
|
|
1264
|
-
console.log(`Router IP: ${s.routerExternalIp}`);
|
|
1265
|
-
}
|
|
1266
|
-
console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
|
|
1267
|
-
console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
|
|
1268
|
-
if (s.mappingLeaseExpiresAt) {
|
|
1269
|
-
console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
|
|
1270
|
-
}
|
|
1271
|
-
const ddns = s.ddns ?? {};
|
|
1272
|
-
if (ddns.providerId || ddns.hostname) {
|
|
1273
|
-
console.log(`\nDDNS:`);
|
|
1274
|
-
console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
|
|
1275
|
-
console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
|
|
1276
|
-
if (ddns.lastUpdateAt) {
|
|
1277
|
-
const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
|
|
1278
|
-
console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
|
|
1279
|
-
}
|
|
1280
|
-
} else {
|
|
1281
|
-
console.log('\nDDNS: not configured');
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
/** Read a single line from stdin with echo suppressed (TTY only). */
|
|
1286
|
-
function readSecretLine(prompt: string): Promise<string> {
|
|
1287
|
-
return new Promise<string>((resolveLine, rejectLine) => {
|
|
1288
|
-
const stdin = process.stdin;
|
|
1289
|
-
const stdout = process.stdout;
|
|
1290
|
-
const wasRaw = stdin.isRaw;
|
|
1291
|
-
try {
|
|
1292
|
-
stdout.write(prompt);
|
|
1293
|
-
stdin.setRawMode(true);
|
|
1294
|
-
} catch (err) {
|
|
1295
|
-
rejectLine(err);
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
stdin.resume();
|
|
1299
|
-
stdin.setEncoding('utf8');
|
|
1300
|
-
|
|
1301
|
-
let buf = '';
|
|
1302
|
-
const onData = (chunk: string) => {
|
|
1303
|
-
for (const ch of chunk) {
|
|
1304
|
-
if (ch === '\n' || ch === '\r' || ch === '') {
|
|
1305
|
-
stdin.removeListener('data', onData);
|
|
1306
|
-
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
1307
|
-
stdin.pause();
|
|
1308
|
-
stdout.write('\n');
|
|
1309
|
-
resolveLine(buf);
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
if (ch === '') {
|
|
1313
|
-
// Ctrl-C
|
|
1314
|
-
stdin.removeListener('data', onData);
|
|
1315
|
-
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
1316
|
-
stdin.pause();
|
|
1317
|
-
stdout.write('\n');
|
|
1318
|
-
rejectLine(new Error('cancelled'));
|
|
1319
|
-
return;
|
|
1320
|
-
}
|
|
1321
|
-
if (ch === '' || ch === '\b') {
|
|
1322
|
-
buf = buf.slice(0, -1);
|
|
1323
|
-
continue;
|
|
1324
|
-
}
|
|
1325
|
-
buf += ch;
|
|
1326
|
-
}
|
|
1327
|
-
};
|
|
1328
|
-
stdin.on('data', onData);
|
|
1329
|
-
});
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* True when this module is the process entry point rather than an import.
|
|
1334
|
-
* `import.meta.url` is already realpath-resolved by Node's ESM loader, so
|
|
1335
|
-
* `process.argv[1]` gets the same treatment before comparing — a package-manager
|
|
1336
|
-
* bin symlink otherwise fails to match.
|
|
1337
|
-
*
|
|
1338
|
-
* NOTE: accepted tradeoff — a false negative here is a CLI that exits 0 having
|
|
1339
|
-
* printed nothing, which is a quiet failure. Gating on `process.env.VITEST`
|
|
1340
|
-
* instead was rejected (a test-runner variable has no business in shipped CLI
|
|
1341
|
-
* source); the counter-guard is `src/__tests__/cli.smoke.test.ts`, which spawns
|
|
1342
|
-
* `dist/bin/host.js --help` and asserts on non-empty stdout. Revisit if that
|
|
1343
|
-
* smoke test is ever weakened or removed.
|
|
1344
|
-
*/
|
|
1345
|
-
function isEntryPoint(): boolean {
|
|
1346
|
-
const entry = process.argv[1];
|
|
1347
|
-
if (!entry) return false;
|
|
1348
|
-
try {
|
|
1349
|
-
return pathToFileURL(realpathSync(entry)).href === import.meta.url;
|
|
1350
|
-
} catch {
|
|
1351
|
-
return false;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
export { program };
|
|
1356
|
-
|
|
1357
|
-
if (isEntryPoint()) void program.parseAsync();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI entrypoint for cadre-host — the self-hosted cadre node manager.
|
|
5
|
+
*
|
|
6
|
+
* Most subcommands (`status`, `invite`, `trust`, `grant`, `nat`) are thin HTTP
|
|
7
|
+
* clients against the running cadre-host management API on loopback — they
|
|
8
|
+
* don't spin up an inline service, so cadre-host must be running.
|
|
9
|
+
*
|
|
10
|
+
* The exceptions operate on disk directly and need no running service:
|
|
11
|
+
* `install`, `uninstall`, `start`, `ui`, and the `push` group.
|
|
12
|
+
*
|
|
13
|
+
* Every command here needs a matching `### ` heading in the package README's
|
|
14
|
+
* `## CLI reference`; `__tests__/cli-reference.test.ts` enforces that.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { Command } from 'commander';
|
|
23
|
+
|
|
24
|
+
import { parseDuration } from '../auth/duration.js';
|
|
25
|
+
import { Installer } from '../installer/index.js';
|
|
26
|
+
import { readHostConfig, updateHostConfig, hostOwnsCadre } from '../installer/config.js';
|
|
27
|
+
import {
|
|
28
|
+
configPath as resolveConfigPath,
|
|
29
|
+
defaultDataDir,
|
|
30
|
+
defaultHostJs,
|
|
31
|
+
defaultServiceDir,
|
|
32
|
+
identityPath as resolveIdentityPath,
|
|
33
|
+
} from '../installer/paths.js';
|
|
34
|
+
import { detectPlatform } from '../installer/platform.js';
|
|
35
|
+
import { createServiceHost } from '../installer/service-host/index.js';
|
|
36
|
+
import { UpdateService } from '../update/index.js';
|
|
37
|
+
import { HostProcessOrchestrator } from '../orchestrator/index.js';
|
|
38
|
+
import { TrustCircleService, TrustCircleStore, ensureSelfLabel } from '../auth/index.js';
|
|
39
|
+
import {
|
|
40
|
+
GrantService,
|
|
41
|
+
GrantStore,
|
|
42
|
+
DonationService,
|
|
43
|
+
DonationStore,
|
|
44
|
+
DonationSupervisor,
|
|
45
|
+
DONATION_AWAITING_SEED_TTL_MS,
|
|
46
|
+
DONATION_PROVISIONING_TTL_MS,
|
|
47
|
+
DONATION_REAP_SWEEP_MS,
|
|
48
|
+
type GrantListing,
|
|
49
|
+
} from '../donation/index.js';
|
|
50
|
+
import { NatService } from '../nat/index.js';
|
|
51
|
+
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
52
|
+
import {
|
|
53
|
+
resolvePushCredentials,
|
|
54
|
+
setFcmSecret,
|
|
55
|
+
setApnsSecret,
|
|
56
|
+
clearPushSecret,
|
|
57
|
+
pushStatus,
|
|
58
|
+
} from '../push/index.js';
|
|
59
|
+
import { StrandService } from '../strands/index.js';
|
|
60
|
+
import { OwnerNodeClient } from '../owner/index.js';
|
|
61
|
+
import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
|
|
62
|
+
import { openBrowser } from '../installer/browser.js';
|
|
63
|
+
|
|
64
|
+
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
65
|
+
|
|
66
|
+
/** libp2p peer-ID prefixes by key type: Ed25519, secp256k1, legacy RSA. */
|
|
67
|
+
const PEER_ID_PREFIXES = ['12D3Koo', '16Uiu2HAm', 'Qm'] as const;
|
|
68
|
+
|
|
69
|
+
function looksLikePeerId(id: string): boolean {
|
|
70
|
+
return PEER_ID_PREFIXES.some((p) => id.startsWith(p));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolvePort(raw: string): number {
|
|
74
|
+
const n = Number(raw);
|
|
75
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
76
|
+
console.error(`Invalid --port: ${raw}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
return n;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const program = new Command();
|
|
83
|
+
|
|
84
|
+
program
|
|
85
|
+
.name('cadre-host')
|
|
86
|
+
.description('Sereus cadre node manager for self-hosted basement-PC deployments')
|
|
87
|
+
.version(readPackageVersion());
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// install — run the first-run wizard + register the system service
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command('install')
|
|
95
|
+
.description('Install cadre-host as a system service and run first-run setup')
|
|
96
|
+
.option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
|
|
97
|
+
.option('--data-dir <path>', 'Override the data directory')
|
|
98
|
+
.option('--ui-port <port>', 'Override the management UI port', parseIntArg)
|
|
99
|
+
.option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
|
|
100
|
+
.option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
|
|
101
|
+
.option('--no-browser', 'Do not open a browser after install')
|
|
102
|
+
.option('--no-invite', 'Skip generating the first enrollment invite')
|
|
103
|
+
.option('--own-cadre', 'Also run the host\'s own personal cadre here (founder persona; default: donor-only)')
|
|
104
|
+
.option('--system', 'System-wide install (not yet supported in v1)')
|
|
105
|
+
.option('--node-path <path>', 'Override the node binary embedded in the service unit')
|
|
106
|
+
.option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
|
|
107
|
+
.action(async (opts: {
|
|
108
|
+
nonInteractive?: boolean;
|
|
109
|
+
dataDir?: string;
|
|
110
|
+
uiPort?: number;
|
|
111
|
+
libp2pPort?: number;
|
|
112
|
+
upnp?: boolean;
|
|
113
|
+
browser?: boolean;
|
|
114
|
+
invite?: boolean;
|
|
115
|
+
ownCadre?: boolean;
|
|
116
|
+
system?: boolean;
|
|
117
|
+
nodePath?: string;
|
|
118
|
+
service?: boolean;
|
|
119
|
+
}) => {
|
|
120
|
+
const installer = new Installer();
|
|
121
|
+
try {
|
|
122
|
+
const result = await installer.install({
|
|
123
|
+
nonInteractive: Boolean(opts.nonInteractive),
|
|
124
|
+
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
125
|
+
...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
|
|
126
|
+
...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
|
|
127
|
+
noUpnp: opts.upnp === false,
|
|
128
|
+
openBrowser: opts.browser !== false,
|
|
129
|
+
noInvite: opts.invite === false,
|
|
130
|
+
ownCadre: opts.ownCadre === true,
|
|
131
|
+
system: Boolean(opts.system),
|
|
132
|
+
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
133
|
+
noService: opts.service === false,
|
|
134
|
+
});
|
|
135
|
+
console.log(`cadre-host installed.`);
|
|
136
|
+
console.log(` Data dir: ${result.dataDir}`);
|
|
137
|
+
console.log(` UI: ${result.uiUrl}`);
|
|
138
|
+
if (result.serviceName) {
|
|
139
|
+
console.log(` Service: ${result.serviceName}`);
|
|
140
|
+
} else {
|
|
141
|
+
console.log(' Service: not registered (--no-service)');
|
|
142
|
+
console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
|
|
143
|
+
}
|
|
144
|
+
if (result.enrollmentInvite) {
|
|
145
|
+
printEnrollmentInvite(result.enrollmentInvite);
|
|
146
|
+
}
|
|
147
|
+
process.exit(0);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(`install failed: ${(err as Error).message}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// uninstall — stop + deregister the system service
|
|
156
|
+
// ============================================================================
|
|
157
|
+
|
|
158
|
+
program
|
|
159
|
+
.command('uninstall')
|
|
160
|
+
.description('Stop and uninstall the cadre-host service')
|
|
161
|
+
.option('--yes', 'Skip the confirmation prompt (for scripts)')
|
|
162
|
+
.option('--remove-data', 'Also remove the data directory (default: preserve)')
|
|
163
|
+
.option('--data-dir <path>', 'Override the data directory to clean up')
|
|
164
|
+
.action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
|
|
165
|
+
const installer = new Installer();
|
|
166
|
+
try {
|
|
167
|
+
// --remove-data is destructive and irreversible (identity + trust circle
|
|
168
|
+
// are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
|
|
169
|
+
// confirmation when it is.
|
|
170
|
+
if (opts.removeData && !opts.yes) {
|
|
171
|
+
if (!process.stdin.isTTY) {
|
|
172
|
+
console.error('uninstall: --remove-data requires --yes when stdin is not a TTY.');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const dataDir = opts.dataDir ?? '(default data directory)';
|
|
177
|
+
const confirmed = await confirmDestructive(
|
|
178
|
+
`This will permanently delete ${dataDir} (identity, trust circle, NAT state). Continue? [y/N] `,
|
|
179
|
+
);
|
|
180
|
+
if (!confirmed) {
|
|
181
|
+
console.error('uninstall aborted.');
|
|
182
|
+
process.exit(1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await installer.uninstall({
|
|
187
|
+
yes: Boolean(opts.yes),
|
|
188
|
+
removeData: Boolean(opts.removeData),
|
|
189
|
+
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
190
|
+
});
|
|
191
|
+
console.log('cadre-host uninstalled.');
|
|
192
|
+
process.exit(0);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.error(`uninstall failed: ${(err as Error).message}`);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// status — show service-host registration / running state
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
program
|
|
204
|
+
.command('status')
|
|
205
|
+
.description('Show running status of cadre-host and the cadre nodes it manages')
|
|
206
|
+
.action(async () => {
|
|
207
|
+
const installer = new Installer();
|
|
208
|
+
try {
|
|
209
|
+
const status = await installer.status();
|
|
210
|
+
console.log(`Service installed: ${status.installed ? 'yes' : 'no'}`);
|
|
211
|
+
console.log(`Service running: ${status.running ? 'yes' : 'no'}`);
|
|
212
|
+
process.exit(0);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error(`status failed: ${(err as Error).message}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// start — load config + identity, bind management API listener, wait
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
program
|
|
224
|
+
.command('start')
|
|
225
|
+
.description('Start cadre-host in the foreground')
|
|
226
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
227
|
+
.option('--no-tui', 'Reserved — currently a no-op (no TUI implemented yet)')
|
|
228
|
+
.action(async (opts: { dataDir?: string; tui?: boolean }) => {
|
|
229
|
+
try {
|
|
230
|
+
void opts.tui;
|
|
231
|
+
const platform = detectPlatform();
|
|
232
|
+
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
233
|
+
const cfgPath = resolveConfigPath(dataDir);
|
|
234
|
+
if (!existsSync(cfgPath)) {
|
|
235
|
+
console.error(
|
|
236
|
+
`cadre-host start: ${cfgPath} not found. ` +
|
|
237
|
+
`Run \`cadre-host install\` first (or pass --data-dir to an existing install).`,
|
|
238
|
+
);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const cfg = readHostConfig(cfgPath);
|
|
243
|
+
const idPath = resolveIdentityPath(cfg.dataDir);
|
|
244
|
+
if (!existsSync(idPath)) {
|
|
245
|
+
console.error(`cadre-host start: identity file not found at ${idPath}. Re-run \`cadre-host install\`.`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
|
|
250
|
+
|
|
251
|
+
// Update flow: notify-by-default; auto-apply opt-in via host.config.json.
|
|
252
|
+
// The local-UI ticket (6.5) wires the handlers into HTTP routes; here we
|
|
253
|
+
// just construct the service so the in-process timer + state file are
|
|
254
|
+
// populated.
|
|
255
|
+
const serviceHost = createServiceHost(platform);
|
|
256
|
+
const updateService = new UpdateService({
|
|
257
|
+
dataDir: cfg.dataDir,
|
|
258
|
+
currentVersion: readPackageVersion(),
|
|
259
|
+
settings: cfg.updates,
|
|
260
|
+
...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
|
|
261
|
+
restart: async () => {
|
|
262
|
+
try {
|
|
263
|
+
await serviceHost.restart({
|
|
264
|
+
nodePath: process.execPath,
|
|
265
|
+
hostJs: defaultHostJs(),
|
|
266
|
+
dataDir: cfg.dataDir,
|
|
267
|
+
serviceDir: defaultServiceDir(),
|
|
268
|
+
});
|
|
269
|
+
return undefined;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
return (err as Error).message;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
// Persist settings changes back to host.config.json on PUT /update/settings.
|
|
276
|
+
updateService.onSettingsChanged((next) => {
|
|
277
|
+
try {
|
|
278
|
+
updateHostConfig(cfgPath, { updates: next });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`failed to persist updated settings to ${cfgPath}: ${(err as Error).message}`);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// Don't block startup on the network round-trip — call and forget.
|
|
284
|
+
void updateService.check();
|
|
285
|
+
updateService.start();
|
|
286
|
+
|
|
287
|
+
// Wire the long-lived HTTP management server. cadre-host's primary role
|
|
288
|
+
// is **node donor**: it spawns generic cadre nodes for *other people's*
|
|
289
|
+
// cadres on request (the donation grant layer below) and never needs an
|
|
290
|
+
// owner node of its own for that. Running the host's **own** personal
|
|
291
|
+
// cadre here — the "founder" persona — is opt-in via `ownCadre.enabled`
|
|
292
|
+
// (see docs/cadre-host.md § Two roles: donor and founder). Only when it
|
|
293
|
+
// is enabled do we spawn the owner node and bring up trust-circle + NAT.
|
|
294
|
+
// The manager never joins the control network (docs/cadre-host.md
|
|
295
|
+
// § Control-plane separation).
|
|
296
|
+
// Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
|
|
297
|
+
// restart re-reads the secret store (rotated keys) and nothing raw is
|
|
298
|
+
// persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
|
|
299
|
+
// cooldown/debounce) are re-read from host.config.json each call too.
|
|
300
|
+
const pushSecrets = await createSecretsStore(cfg.dataDir);
|
|
301
|
+
const orchestrator = new HostProcessOrchestrator({
|
|
302
|
+
rootDir: join(cfg.dataDir, 'orchestrator'),
|
|
303
|
+
pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
|
|
304
|
+
});
|
|
305
|
+
await orchestrator.init();
|
|
306
|
+
|
|
307
|
+
// Donation grant layer — the always-on donor surface. Local-only:
|
|
308
|
+
// issue/validate/revoke are pure store ops (no node round-trip), so it
|
|
309
|
+
// needs no owner-node handle. The loopback `/grants-admin` surface is
|
|
310
|
+
// mounted by createLocalUiServer regardless of the founder persona.
|
|
311
|
+
const grantService = new GrantService({ store: new GrantStore(cfg.dataDir) });
|
|
312
|
+
|
|
313
|
+
// Donation lifecycle service — consumes a validated grant to actually
|
|
314
|
+
// spawn a donated node into the requester's cadre. Drives the
|
|
315
|
+
// grantee-facing `/grants` surface (mounted by createLocalUiServer below).
|
|
316
|
+
const donationStore = new DonationStore(cfg.dataDir);
|
|
317
|
+
const donationService = new DonationService({
|
|
318
|
+
orchestrator,
|
|
319
|
+
grants: grantService,
|
|
320
|
+
store: donationStore,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Respawn supervision — nothing else brings a donated node back. A crash,
|
|
324
|
+
// an OOM kill, or a reboot otherwise leaves the record reading `seeded`
|
|
325
|
+
// with no process behind it, costing the borrower a node and their grant a
|
|
326
|
+
// quota slot. Sweeps at startup, on every child exit, and on its own timer.
|
|
327
|
+
const donationSupervisor = new DonationSupervisor({
|
|
328
|
+
service: donationService,
|
|
329
|
+
store: donationStore,
|
|
330
|
+
orchestrator,
|
|
331
|
+
});
|
|
332
|
+
donationSupervisor.start();
|
|
333
|
+
|
|
334
|
+
// Reap orphaned donations: a requester that provisioned a node but never
|
|
335
|
+
// presented a seed leaves an `awaiting_seed` child holding host ports,
|
|
336
|
+
// and a host that died between writing a `provisioning` row and finishing
|
|
337
|
+
// the spawn leaves that row stuck forever (nothing else ever revisits it).
|
|
338
|
+
// Sweep once at startup (for records recovered from disk by
|
|
339
|
+
// orchestrator.init()), then periodically.
|
|
340
|
+
const reapStale = (): void => {
|
|
341
|
+
void donationService
|
|
342
|
+
.reapStaleAwaitingSeed(DONATION_AWAITING_SEED_TTL_MS)
|
|
343
|
+
.catch((err) => console.error(`donation reap failed: ${(err as Error).message}`));
|
|
344
|
+
void donationService
|
|
345
|
+
.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)
|
|
346
|
+
.catch((err) => console.error(`donation provisioning reap failed: ${(err as Error).message}`));
|
|
347
|
+
};
|
|
348
|
+
reapStale();
|
|
349
|
+
const reapTimer = setInterval(reapStale, DONATION_REAP_SWEEP_MS);
|
|
350
|
+
reapTimer.unref();
|
|
351
|
+
|
|
352
|
+
// Founder stack (opt-in). Absent `ownCadre.enabled`, cadre-host is a pure
|
|
353
|
+
// donor: no owner node, and the /auth + /nat surfaces stay unmounted
|
|
354
|
+
// (they 404). Per-donated-node WAN reachability is deferred to
|
|
355
|
+
// backlog/feat-cadre-host-wan-grant-reachability, so v1 donor mode is
|
|
356
|
+
// loopback-only — nothing for NatService to map without an owner node.
|
|
357
|
+
let founder: FounderServices | undefined;
|
|
358
|
+
if (hostOwnsCadre(cfg)) {
|
|
359
|
+
// Spawn the owner node. Best-effort: a spawn failure leaves the
|
|
360
|
+
// management API up (trust-circle listing degrades to local labels,
|
|
361
|
+
// owner ops return 503) rather than taking down the whole process.
|
|
362
|
+
try {
|
|
363
|
+
await orchestrator.ensureOwnerNode({
|
|
364
|
+
identityPath: idPath,
|
|
365
|
+
partyId: cfg.installId,
|
|
366
|
+
libp2pPort: cfg.libp2pPort,
|
|
367
|
+
});
|
|
368
|
+
console.log('cadre-host: owner node spawned (host-own-cadre enabled)');
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error(`owner node spawn failed: ${(err as Error).message}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The client reads the admin endpoint lazily so a node restart's fresh
|
|
374
|
+
// bearer token is picked up automatically.
|
|
375
|
+
const owner = new OwnerNodeClient(() => orchestrator.getOwnerAdminEndpoint());
|
|
376
|
+
|
|
377
|
+
const trustCircleStore = new TrustCircleStore(cfg.dataDir);
|
|
378
|
+
const trustCircle = new TrustCircleService({
|
|
379
|
+
cadreNode: owner,
|
|
380
|
+
store: trustCircleStore,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const natService = new NatService({
|
|
384
|
+
rootDir: cfg.dataDir,
|
|
385
|
+
cadreNode: owner,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Strand management is founder-only for the same reason as the trust
|
|
389
|
+
// circle: it asks the owner node, and donor-only mode has none.
|
|
390
|
+
const strandService = new StrandService({ cadreNode: owner });
|
|
391
|
+
|
|
392
|
+
// Push NAT-resolved invite addresses to the node on every NAT change.
|
|
393
|
+
// NatService.start() also fires this once as an initial push, retried
|
|
394
|
+
// until the freshly spawned owner node accepts it (bounded; the
|
|
395
|
+
// management API that mints invites comes up only after start() resolves).
|
|
396
|
+
natService.onAddressesChanged(async (addresses) => {
|
|
397
|
+
if (addresses.length === 0) return;
|
|
398
|
+
try {
|
|
399
|
+
await owner.pushInviteAddresses(addresses);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error(`invite-address push failed: ${(err as Error).message}`);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Best-effort NAT start. Failures here aren't fatal — the local UI
|
|
406
|
+
// can still serve the trust circle, settings, etc.
|
|
407
|
+
try {
|
|
408
|
+
await natService.start();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.error(`NAT start failed: ${(err as Error).message}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Label the owner's own device so it shows up in the trust-circle
|
|
414
|
+
// listing (see auth/self-label.ts for why the listing can't get it for
|
|
415
|
+
// free). Runs after natService.start(), which already waits out a
|
|
416
|
+
// freshly spawned node's warm-up, so the peer ID is normally there on
|
|
417
|
+
// the first try. Best-effort: a failure logs and the next start heals
|
|
418
|
+
// it — the listing gap is cosmetic, not a security boundary.
|
|
419
|
+
try {
|
|
420
|
+
await ensureSelfLabel({ store: trustCircleStore, getPeerId: () => owner.getPeerId() });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
console.error(`self trust-circle label failed: ${(err as Error).message}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
founder = { trustCircle, nat: natService, strands: strandService };
|
|
426
|
+
} else {
|
|
427
|
+
// Donor-only. If ownCadre was toggled off after a prior founder run,
|
|
428
|
+
// orchestrator.init() re-attaches the still-running owner child (it would
|
|
429
|
+
// otherwise linger in listNodes with no trustCircle/nat wired, serving
|
|
430
|
+
// the host's own cadre despite being disabled). Reap it now so a disabled
|
|
431
|
+
// own-cadre is actually stopped — its workdir + control-DB persist on
|
|
432
|
+
// disk, so toggling ownCadre back on re-spawns it from saved config.
|
|
433
|
+
try {
|
|
434
|
+
await orchestrator.stopOwnerNode();
|
|
435
|
+
} catch (err) {
|
|
436
|
+
console.error(`owner node reap failed: ${(err as Error).message}`);
|
|
437
|
+
}
|
|
438
|
+
console.log('cadre-host: node-donor mode (host-own-cadre disabled — no owner node; /auth + /nat inactive)');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
|
|
442
|
+
const server = createLocalUiServer({
|
|
443
|
+
uiPort: cfg.uiPort,
|
|
444
|
+
dataDir: cfg.dataDir,
|
|
445
|
+
orchestrator,
|
|
446
|
+
...(founder ? { founder } : {}),
|
|
447
|
+
update: updateService,
|
|
448
|
+
grants: grantService,
|
|
449
|
+
donations: donationService,
|
|
450
|
+
settingsStore,
|
|
451
|
+
});
|
|
452
|
+
const { url, port } = await server.start();
|
|
453
|
+
if (port !== cfg.uiPort) {
|
|
454
|
+
console.log(`(configured port ${cfg.uiPort} was in use — bound on ${port} instead)`);
|
|
455
|
+
}
|
|
456
|
+
console.log(`cadre-host local UI: ${url}`);
|
|
457
|
+
|
|
458
|
+
await waitForTermination();
|
|
459
|
+
clearInterval(reapTimer);
|
|
460
|
+
donationSupervisor.stop();
|
|
461
|
+
try { await server.stop(); } catch { /* ignore */ }
|
|
462
|
+
try { await founder?.nat.stop(); } catch { /* ignore */ }
|
|
463
|
+
try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
|
|
464
|
+
updateService.stop();
|
|
465
|
+
console.log('cadre-host stopped.');
|
|
466
|
+
process.exit(0);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
console.error(`start failed: ${(err as Error).message}`);
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ============================================================================
|
|
474
|
+
// ui — print the local-UI URL and open it in a browser
|
|
475
|
+
// ============================================================================
|
|
476
|
+
|
|
477
|
+
program
|
|
478
|
+
.command('ui')
|
|
479
|
+
.description('Print the local-UI URL and open it in the default browser')
|
|
480
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
481
|
+
.option('--no-browser', 'Print the URL but do not open a browser')
|
|
482
|
+
.action((opts: { dataDir?: string; browser?: boolean }) => {
|
|
483
|
+
try {
|
|
484
|
+
const platform = detectPlatform();
|
|
485
|
+
const dataDir = opts.dataDir ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
486
|
+
const cfgPath = resolveConfigPath(dataDir);
|
|
487
|
+
if (!existsSync(cfgPath)) {
|
|
488
|
+
console.error(`cadre-host ui: ${cfgPath} not found. Run \`cadre-host install\` first.`);
|
|
489
|
+
process.exit(1);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const cfg = readHostConfig(cfgPath);
|
|
493
|
+
const url = `http://127.0.0.1:${cfg.uiPort}`;
|
|
494
|
+
console.log(url);
|
|
495
|
+
if (opts.browser !== false) {
|
|
496
|
+
const res = openBrowser(url);
|
|
497
|
+
if (res.spawned) {
|
|
498
|
+
console.log('(Opening in your default browser...)');
|
|
499
|
+
} else if (res.error) {
|
|
500
|
+
console.error(`(unable to launch browser: ${res.error})`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
process.exit(0);
|
|
504
|
+
} catch (err) {
|
|
505
|
+
console.error(`ui failed: ${(err as Error).message}`);
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
function parseIntArg(raw: string): number {
|
|
511
|
+
const n = Number(raw);
|
|
512
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
513
|
+
throw new Error(`Invalid integer: ${raw}`);
|
|
514
|
+
}
|
|
515
|
+
return n;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function readPackageVersion(): string {
|
|
519
|
+
try {
|
|
520
|
+
// dist/bin/host.js -> ../../package.json
|
|
521
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
522
|
+
const pkgPath = resolve(here, '..', '..', 'package.json');
|
|
523
|
+
const raw = readFileSync(pkgPath, 'utf8');
|
|
524
|
+
const parsed = JSON.parse(raw) as { version?: string };
|
|
525
|
+
return parsed.version ?? '0.0.0-unknown';
|
|
526
|
+
} catch {
|
|
527
|
+
return '0.0.0-unknown';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function waitForTermination(): Promise<void> {
|
|
532
|
+
return new Promise<void>((resolveTerm) => {
|
|
533
|
+
const done = () => resolveTerm();
|
|
534
|
+
process.once('SIGINT', done);
|
|
535
|
+
process.once('SIGTERM', done);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function confirmDestructive(message: string): Promise<boolean> {
|
|
540
|
+
const { createInterface } = await import('node:readline');
|
|
541
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
542
|
+
try {
|
|
543
|
+
const answer = await new Promise<string>((res) => rl.question(message, res));
|
|
544
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
545
|
+
} finally {
|
|
546
|
+
rl.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const requireForQr = createRequire(import.meta.url);
|
|
551
|
+
|
|
552
|
+
function printEnrollmentInvite(invite: { encodedInvite: string; expiresAt?: string }): void {
|
|
553
|
+
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
554
|
+
console.log('\nEnroll your first device with this invite:');
|
|
555
|
+
try {
|
|
556
|
+
const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
|
|
557
|
+
qr.generate(invite.encodedInvite, { small: true }, (rendered) => {
|
|
558
|
+
console.log(rendered);
|
|
559
|
+
});
|
|
560
|
+
} catch (err) {
|
|
561
|
+
console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
|
|
562
|
+
}
|
|
563
|
+
console.log(` ${invite.encodedInvite}`);
|
|
564
|
+
if (invite.expiresAt) {
|
|
565
|
+
console.log(` (expires ${invite.expiresAt})`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function printGrantToken(token: string, withQr: boolean): void {
|
|
570
|
+
if (withQr) {
|
|
571
|
+
// Best-effort QR render — fall back to the bare token if the lib chokes.
|
|
572
|
+
try {
|
|
573
|
+
const qr = requireForQr('qrcode-terminal') as { generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void };
|
|
574
|
+
qr.generate(token, { small: true }, (rendered) => {
|
|
575
|
+
console.error(rendered);
|
|
576
|
+
});
|
|
577
|
+
} catch (err) {
|
|
578
|
+
console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// The token goes to stdout alone so it can be piped/copied; metadata to stderr.
|
|
582
|
+
console.log(token);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
program
|
|
586
|
+
.command('invite')
|
|
587
|
+
.description('Generate an invite to add a member to the trust circle')
|
|
588
|
+
.argument('<label>', 'Display label for the new member (e.g. "Mom\'s phone")')
|
|
589
|
+
.option('--ttl <duration>', 'Invite lifetime (e.g. 24h, 7d, 30m)', '24h')
|
|
590
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
591
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
592
|
+
.action(async (label: string, opts: { ttl: string; port: string; host: string }) => {
|
|
593
|
+
let ttlMs: number;
|
|
594
|
+
try {
|
|
595
|
+
ttlMs = parseDuration(opts.ttl);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.error(`Invalid --ttl: ${(err as Error).message}`);
|
|
598
|
+
process.exit(1);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const port = resolvePort(opts.port);
|
|
603
|
+
|
|
604
|
+
const url = `http://${opts.host}:${port}/auth/invites`;
|
|
605
|
+
let response: Response;
|
|
606
|
+
try {
|
|
607
|
+
response = await fetch(url, {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
headers: { 'content-type': 'application/json' },
|
|
610
|
+
body: JSON.stringify({ label, ttlMs }),
|
|
611
|
+
});
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(
|
|
614
|
+
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
615
|
+
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
616
|
+
);
|
|
617
|
+
process.exit(2);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (!response.ok) {
|
|
622
|
+
const text = await response.text().catch(() => '');
|
|
623
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const body = await response.json() as { encodedInvite?: string; expiresAt?: string; token?: string };
|
|
629
|
+
if (!body.encodedInvite) {
|
|
630
|
+
console.error('cadre-host returned malformed response (missing encodedInvite)');
|
|
631
|
+
process.exit(1);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
console.log(body.encodedInvite);
|
|
636
|
+
if (body.expiresAt) {
|
|
637
|
+
console.error(`(expires at ${body.expiresAt})`);
|
|
638
|
+
}
|
|
639
|
+
process.exit(0);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const trust = program
|
|
643
|
+
.command('trust')
|
|
644
|
+
.description('Manage the trust circle (list members, revoke invites/members)');
|
|
645
|
+
|
|
646
|
+
trust
|
|
647
|
+
.command('list')
|
|
648
|
+
.description('List trust-circle members and pending invites')
|
|
649
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
650
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
651
|
+
.action(async (opts: { port: string; host: string }) => {
|
|
652
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/auth/trust-circle`;
|
|
653
|
+
let response: Response;
|
|
654
|
+
try {
|
|
655
|
+
response = await fetch(url);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
|
|
658
|
+
process.exit(2);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (!response.ok) {
|
|
662
|
+
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const body = await response.json() as {
|
|
667
|
+
members: Array<{ peerId: string; label: string; addedAt: string; self?: boolean }>;
|
|
668
|
+
pending: Array<{ token: string; label: string; createdAt: string; expiresAt?: string }>;
|
|
669
|
+
};
|
|
670
|
+
console.log('Members:');
|
|
671
|
+
if (body.members.length === 0) {
|
|
672
|
+
console.log(' (none)');
|
|
673
|
+
} else {
|
|
674
|
+
for (const m of body.members) {
|
|
675
|
+
const self = m.self ? ' [self]' : '';
|
|
676
|
+
console.log(` ${m.peerId} ${m.label}${self}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
console.log('\nPending invites:');
|
|
680
|
+
if (body.pending.length === 0) {
|
|
681
|
+
console.log(' (none)');
|
|
682
|
+
} else {
|
|
683
|
+
for (const p of body.pending) {
|
|
684
|
+
const expires = p.expiresAt ? ` (expires ${p.expiresAt})` : '';
|
|
685
|
+
console.log(` ${p.token} ${p.label}${expires}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
process.exit(0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
trust
|
|
692
|
+
.command('revoke')
|
|
693
|
+
.description('Revoke a pending invite (by token) or remove a member (by peerId)')
|
|
694
|
+
.argument('<id>', 'Token (pending invite) or peerId (existing member) to remove')
|
|
695
|
+
.option('--kind <kind>', 'Force interpretation: "invite" | "member" (default: auto)', 'auto')
|
|
696
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
697
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
698
|
+
.action(async (id: string, opts: { kind: string; port: string; host: string }) => {
|
|
699
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
700
|
+
const kind = opts.kind === 'auto'
|
|
701
|
+
? (looksLikePeerId(id) ? 'member' : 'invite')
|
|
702
|
+
: opts.kind;
|
|
703
|
+
const path = kind === 'member'
|
|
704
|
+
? `/auth/members/${encodeURIComponent(id)}`
|
|
705
|
+
: `/auth/invites/${encodeURIComponent(id)}`;
|
|
706
|
+
|
|
707
|
+
let response: Response;
|
|
708
|
+
try {
|
|
709
|
+
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
710
|
+
} catch (err) {
|
|
711
|
+
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
712
|
+
process.exit(2);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!response.ok) {
|
|
716
|
+
const text = await response.text().catch(() => '');
|
|
717
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
718
|
+
process.exit(1);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
console.log(`revoked ${kind}: ${id}`);
|
|
722
|
+
process.exit(0);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ============================================================================
|
|
726
|
+
// grant subcommands — donation grant tokens (who may ask this host for a node)
|
|
727
|
+
// ============================================================================
|
|
728
|
+
//
|
|
729
|
+
// A grant token lets one grantee (friend/family) present a Bearer credential to
|
|
730
|
+
// ask this host to donate cadre nodes, up to a per-grantee cap. These commands
|
|
731
|
+
// are thin HTTP clients of the loopback `/grants-admin` admin surface — no
|
|
732
|
+
// bearer (same-machine admin), same posture as `invite` / `trust`.
|
|
733
|
+
|
|
734
|
+
const grant = program
|
|
735
|
+
.command('grant')
|
|
736
|
+
.description('Manage donation grant tokens (who may ask this host to donate a node)');
|
|
737
|
+
|
|
738
|
+
grant
|
|
739
|
+
.command('issue')
|
|
740
|
+
.description('Issue a grant token for one grantee (prints the token + QR)')
|
|
741
|
+
.argument('<label>', 'Display label for the grantee (e.g. "Alice\'s cadre")')
|
|
742
|
+
.option('--max-nodes <n>', 'Max concurrently-live donated nodes this grant may hold', parseIntArg)
|
|
743
|
+
.option('--ttl <duration>', 'Grant lifetime (e.g. 30d, 12h); omit for no expiry')
|
|
744
|
+
.option('--no-qr', 'Print only the token, no QR code')
|
|
745
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
746
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
747
|
+
.action(async (label: string, opts: {
|
|
748
|
+
maxNodes?: number;
|
|
749
|
+
ttl?: string;
|
|
750
|
+
qr?: boolean;
|
|
751
|
+
port: string;
|
|
752
|
+
host: string;
|
|
753
|
+
}) => {
|
|
754
|
+
const payload: { label: string; maxNodes?: number; ttlMs?: number } = { label };
|
|
755
|
+
if (typeof opts.maxNodes === 'number') payload.maxNodes = opts.maxNodes;
|
|
756
|
+
if (opts.ttl) {
|
|
757
|
+
try {
|
|
758
|
+
payload.ttlMs = parseDuration(opts.ttl);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
console.error(`Invalid --ttl: ${(err as Error).message}`);
|
|
761
|
+
process.exit(1);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
767
|
+
let response: Response;
|
|
768
|
+
try {
|
|
769
|
+
response = await fetch(url, {
|
|
770
|
+
method: 'POST',
|
|
771
|
+
headers: { 'content-type': 'application/json' },
|
|
772
|
+
body: JSON.stringify(payload),
|
|
773
|
+
});
|
|
774
|
+
} catch (err) {
|
|
775
|
+
console.error(
|
|
776
|
+
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
777
|
+
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
778
|
+
);
|
|
779
|
+
process.exit(2);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (!response.ok) {
|
|
783
|
+
const text = await response.text().catch(() => '');
|
|
784
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
785
|
+
process.exit(1);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const body = await response.json() as { grant?: { token?: string; maxNodes?: number; expiresAt?: string } };
|
|
789
|
+
const issued = body.grant;
|
|
790
|
+
if (!issued?.token) {
|
|
791
|
+
console.error('cadre-host returned malformed response (missing grant token)');
|
|
792
|
+
process.exit(1);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
printGrantToken(issued.token, opts.qr !== false);
|
|
796
|
+
console.error(`(maxNodes ${issued.maxNodes ?? '?'}${issued.expiresAt ? `, expires ${issued.expiresAt}` : ', no expiry'})`);
|
|
797
|
+
process.exit(0);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
grant
|
|
801
|
+
.command('list')
|
|
802
|
+
.description('List issued grant tokens')
|
|
803
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
804
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
805
|
+
.action(async (opts: { port: string; host: string }) => {
|
|
806
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/grants-admin`;
|
|
807
|
+
let response: Response;
|
|
808
|
+
try {
|
|
809
|
+
response = await fetch(url);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
console.error(`Failed to reach cadre-host at ${url}: ${(err as Error).message}`);
|
|
812
|
+
process.exit(2);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!response.ok) {
|
|
816
|
+
console.error(`cadre-host returned ${response.status}: ${response.statusText}`);
|
|
817
|
+
process.exit(1);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const body = await response.json() as { grants: GrantListing[] };
|
|
821
|
+
console.log('Grants:');
|
|
822
|
+
if (body.grants.length === 0) {
|
|
823
|
+
console.log(' (none)');
|
|
824
|
+
} else {
|
|
825
|
+
for (const g of body.grants) {
|
|
826
|
+
const state = g.revokedAt ? ' [revoked]' : (g.expiresAt ? ` (expires ${g.expiresAt})` : '');
|
|
827
|
+
console.log(` ${g.token} ${g.label} live=${g.liveNodes} max=${g.maxNodes}${state}`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
process.exit(0);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
grant
|
|
834
|
+
.command('revoke')
|
|
835
|
+
.description('Revoke a grant token (blocks future requests) and shut down the nodes donated under it')
|
|
836
|
+
.argument('<token>', 'Grant token to revoke')
|
|
837
|
+
.option('--keep-nodes', 'Leave the nodes already donated under this grant running')
|
|
838
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
839
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
840
|
+
.action(async (token: string, opts: { keepNodes?: boolean; port: string; host: string }) => {
|
|
841
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
842
|
+
const query = opts.keepNodes ? '?keepNodes=true' : '';
|
|
843
|
+
const response = await adminDelete(base, `/grants-admin/${encodeURIComponent(token)}${query}`);
|
|
844
|
+
const body = await response.json() as { terminated?: string[] };
|
|
845
|
+
console.log(`revoked grant: ${token}`);
|
|
846
|
+
console.log(opts.keepNodes
|
|
847
|
+
? 'existing donated nodes left running'
|
|
848
|
+
: `terminated ${body.terminated?.length ?? 0} donated node(s)`);
|
|
849
|
+
process.exit(0);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
grant
|
|
853
|
+
.command('terminate')
|
|
854
|
+
.description('Shut down one donated node (the id shown on the Nodes page)')
|
|
855
|
+
.argument('<donation-id>', 'Donation id (grn_…) of the node to shut down')
|
|
856
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
857
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
858
|
+
.action(async (id: string, opts: { port: string; host: string }) => {
|
|
859
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
860
|
+
await adminDelete(base, `/grants-admin/donations/${encodeURIComponent(id)}`);
|
|
861
|
+
console.log(`terminated donated node: ${id}`);
|
|
862
|
+
process.exit(0);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* DELETE against the loopback admin surface, exiting the process on failure —
|
|
867
|
+
* 2 when cadre-host is unreachable, 1 on a non-OK response — so callers only
|
|
868
|
+
* ever see an OK response.
|
|
869
|
+
*/
|
|
870
|
+
async function adminDelete(base: string, path: string): Promise<Response> {
|
|
871
|
+
let response: Response;
|
|
872
|
+
try {
|
|
873
|
+
response = await fetch(`${base}${path}`, { method: 'DELETE' });
|
|
874
|
+
} catch (err) {
|
|
875
|
+
console.error(`Failed to reach cadre-host at ${base}: ${(err as Error).message}`);
|
|
876
|
+
process.exit(2);
|
|
877
|
+
}
|
|
878
|
+
if (!response.ok) {
|
|
879
|
+
const text = await response.text().catch(() => '');
|
|
880
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
return response;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ============================================================================
|
|
887
|
+
// nat subcommands
|
|
888
|
+
// ============================================================================
|
|
889
|
+
|
|
890
|
+
const nat = program
|
|
891
|
+
.command('nat')
|
|
892
|
+
.description('Manage NAT traversal, port forwarding, and dynamic DNS');
|
|
893
|
+
|
|
894
|
+
nat
|
|
895
|
+
.command('status')
|
|
896
|
+
.description('Show NAT, external-IP, and DDNS status')
|
|
897
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
898
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
899
|
+
.option('--json', 'Output raw JSON instead of a human-readable summary')
|
|
900
|
+
.action(async (opts: { port: string; host: string; json?: boolean }) => {
|
|
901
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/status`;
|
|
902
|
+
const body = await getJson(url);
|
|
903
|
+
if (opts.json) {
|
|
904
|
+
console.log(JSON.stringify(body, null, 2));
|
|
905
|
+
} else {
|
|
906
|
+
printNatStatus(body);
|
|
907
|
+
}
|
|
908
|
+
process.exit(0);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
nat
|
|
912
|
+
.command('test')
|
|
913
|
+
.description('Re-run reachability probes and print the result')
|
|
914
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
915
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
916
|
+
.option('--json', 'Output raw JSON instead of a human-readable summary')
|
|
917
|
+
.action(async (opts: { port: string; host: string; json?: boolean }) => {
|
|
918
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/test`;
|
|
919
|
+
const body = await postJson(url, {});
|
|
920
|
+
if (opts.json) {
|
|
921
|
+
console.log(JSON.stringify(body, null, 2));
|
|
922
|
+
} else {
|
|
923
|
+
printNatStatus(body);
|
|
924
|
+
}
|
|
925
|
+
process.exit(0);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
nat
|
|
929
|
+
.command('settings')
|
|
930
|
+
.description('Update NAT settings (external port, UPnP toggle)')
|
|
931
|
+
.option('--external-port <port>', 'External port to map')
|
|
932
|
+
.option('--internal-port <port>', 'Internal libp2p port (defaults to external)')
|
|
933
|
+
.option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
|
|
934
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
935
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
936
|
+
.action(async (opts: {
|
|
937
|
+
externalPort?: string;
|
|
938
|
+
internalPort?: string;
|
|
939
|
+
upnp?: boolean;
|
|
940
|
+
port: string;
|
|
941
|
+
host: string;
|
|
942
|
+
}) => {
|
|
943
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
|
|
944
|
+
const patch: Record<string, unknown> = {};
|
|
945
|
+
if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
|
|
946
|
+
if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
|
|
947
|
+
// Commander assigns `upnp: false` when `--no-upnp` is passed.
|
|
948
|
+
if (opts.upnp === false) patch.upnpEnabled = false;
|
|
949
|
+
if (opts.upnp === true) patch.upnpEnabled = true;
|
|
950
|
+
|
|
951
|
+
const body = await putJson(url, patch);
|
|
952
|
+
printNatStatus(body);
|
|
953
|
+
process.exit(0);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const ddns = nat
|
|
957
|
+
.command('ddns')
|
|
958
|
+
.description('Configure dynamic DNS');
|
|
959
|
+
|
|
960
|
+
ddns
|
|
961
|
+
.command('set')
|
|
962
|
+
.description('Configure a DDNS provider (and store credentials in the keychain)')
|
|
963
|
+
.argument('<provider>', 'Provider ID (e.g. "duckdns")')
|
|
964
|
+
.option('--hostname <hostname>', 'Hostname to publish (e.g. foo.duckdns.org)')
|
|
965
|
+
.option('--token <token>', 'Provider token (a secret value; prompts if omitted)')
|
|
966
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
967
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
968
|
+
.action(async (provider: string, opts: {
|
|
969
|
+
hostname?: string;
|
|
970
|
+
token?: string;
|
|
971
|
+
port: string;
|
|
972
|
+
host: string;
|
|
973
|
+
}) => {
|
|
974
|
+
if (!opts.hostname) {
|
|
975
|
+
console.error('--hostname is required');
|
|
976
|
+
process.exit(1);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const config: Record<string, string> = {};
|
|
980
|
+
if (opts.token) config.token = opts.token;
|
|
981
|
+
|
|
982
|
+
if (!config.token) {
|
|
983
|
+
if (!process.stdin.isTTY) {
|
|
984
|
+
console.error(
|
|
985
|
+
'Missing --token for provider "' + provider + '" and stdin is not a TTY. ' +
|
|
986
|
+
'Re-run with --token <secret>.',
|
|
987
|
+
);
|
|
988
|
+
process.exit(1);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
config.token = await readSecretLine('DDNS token: ');
|
|
993
|
+
} catch (err) {
|
|
994
|
+
console.error('Failed to read token: ' + (err as Error).message);
|
|
995
|
+
process.exit(1);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/ddns`;
|
|
1001
|
+
const body = await putJson(url, {
|
|
1002
|
+
providerId: provider,
|
|
1003
|
+
hostname: opts.hostname,
|
|
1004
|
+
config,
|
|
1005
|
+
externallyManaged: false,
|
|
1006
|
+
});
|
|
1007
|
+
printNatStatus(body);
|
|
1008
|
+
process.exit(0);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
ddns
|
|
1012
|
+
.command('external')
|
|
1013
|
+
.description('Declare an externally-managed DDNS hostname (cadre-host does not update it)')
|
|
1014
|
+
.requiredOption('--hostname <hostname>', 'Hostname (no provider; cadre-host will not push updates)')
|
|
1015
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
1016
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
1017
|
+
.action(async (opts: { hostname: string; port: string; host: string }) => {
|
|
1018
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
|
|
1019
|
+
const body = await putJson(url, {
|
|
1020
|
+
ddns: {
|
|
1021
|
+
providerId: 'external',
|
|
1022
|
+
hostname: opts.hostname,
|
|
1023
|
+
externallyManaged: true,
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
printNatStatus(body);
|
|
1027
|
+
process.exit(0);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// ============================================================================
|
|
1031
|
+
// push subcommands — provision FCM/APNs credentials for strand-wake delivery
|
|
1032
|
+
// ============================================================================
|
|
1033
|
+
//
|
|
1034
|
+
// These operate directly on the data dir's secret store + host.config.json (they
|
|
1035
|
+
// do NOT go through the running management API). Private keys land in the OS
|
|
1036
|
+
// keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
|
|
1037
|
+
// bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
|
|
1038
|
+
// credentials take effect on the next owner-node (re)spawn — run
|
|
1039
|
+
// `cadre-host` restart (or restart the service) to apply them immediately.
|
|
1040
|
+
|
|
1041
|
+
const push = program
|
|
1042
|
+
.command('push')
|
|
1043
|
+
.description('Configure FCM/APNs push credentials for mobile strand-wake delivery');
|
|
1044
|
+
|
|
1045
|
+
push
|
|
1046
|
+
.command('fcm')
|
|
1047
|
+
.description('Store Firebase Cloud Messaging (Android) service-account credentials')
|
|
1048
|
+
.requiredOption('--project-id <id>', 'GCP / Firebase project id')
|
|
1049
|
+
.requiredOption('--client-email <email>', 'Service-account email')
|
|
1050
|
+
.option('--private-key-file <path>', 'Path to the service-account private key (PEM)')
|
|
1051
|
+
.option('--private-key <pem>', 'Inline PEM private key (prefer --private-key-file)')
|
|
1052
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1053
|
+
.action(async (opts: {
|
|
1054
|
+
projectId: string;
|
|
1055
|
+
clientEmail: string;
|
|
1056
|
+
privateKeyFile?: string;
|
|
1057
|
+
privateKey?: string;
|
|
1058
|
+
dataDir?: string;
|
|
1059
|
+
}) => {
|
|
1060
|
+
const { dataDir } = resolveHostPaths(opts.dataDir);
|
|
1061
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'FCM');
|
|
1062
|
+
const secrets = await createSecretsStore(dataDir);
|
|
1063
|
+
await setFcmSecret(secrets, {
|
|
1064
|
+
projectId: opts.projectId,
|
|
1065
|
+
clientEmail: opts.clientEmail,
|
|
1066
|
+
privateKey,
|
|
1067
|
+
});
|
|
1068
|
+
console.log('✓ FCM credentials stored. Restart cadre-host to apply on the owner node.');
|
|
1069
|
+
process.exit(0);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
push
|
|
1073
|
+
.command('apns')
|
|
1074
|
+
.description('Store Apple Push Notification service (iOS) auth-key credentials')
|
|
1075
|
+
.requiredOption('--key-id <id>', 'APNs key id (the .p8 key identifier)')
|
|
1076
|
+
.requiredOption('--team-id <id>', 'Apple developer team id')
|
|
1077
|
+
.requiredOption('--bundle-id <id>', 'App bundle id (becomes the apns-topic)')
|
|
1078
|
+
.option('--private-key-file <path>', 'Path to the .p8 auth key (PEM)')
|
|
1079
|
+
.option('--private-key <pem>', 'Inline .p8 PEM key (prefer --private-key-file)')
|
|
1080
|
+
.option('--production', 'Target the production APNs host (default: sandbox)')
|
|
1081
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1082
|
+
.action(async (opts: {
|
|
1083
|
+
keyId: string;
|
|
1084
|
+
teamId: string;
|
|
1085
|
+
bundleId: string;
|
|
1086
|
+
privateKeyFile?: string;
|
|
1087
|
+
privateKey?: string;
|
|
1088
|
+
production?: boolean;
|
|
1089
|
+
dataDir?: string;
|
|
1090
|
+
}) => {
|
|
1091
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1092
|
+
const privateKey = readPrivateKeyArg(opts.privateKeyFile, opts.privateKey, 'APNs');
|
|
1093
|
+
const secrets = await createSecretsStore(dataDir);
|
|
1094
|
+
// Secret bits → secret store; non-secret app-config → host.config.json.
|
|
1095
|
+
await setApnsSecret(secrets, { keyId: opts.keyId, teamId: opts.teamId, privateKey });
|
|
1096
|
+
const current = readHostConfig(cfgPath);
|
|
1097
|
+
updateHostConfig(cfgPath, {
|
|
1098
|
+
push: {
|
|
1099
|
+
...current.push,
|
|
1100
|
+
apns: { bundleId: opts.bundleId, production: opts.production === true },
|
|
1101
|
+
},
|
|
1102
|
+
});
|
|
1103
|
+
console.log(
|
|
1104
|
+
`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
|
|
1105
|
+
`Restart cadre-host to apply on the owner node.`,
|
|
1106
|
+
);
|
|
1107
|
+
process.exit(0);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
push
|
|
1111
|
+
.command('options')
|
|
1112
|
+
.description('Set non-secret push tuning (anti-spam cooldown / burst-coalesce window)')
|
|
1113
|
+
.option('--cooldown-ms <ms>', 'Per-(peer,strand) minimum gap between wakes', parseIntArg)
|
|
1114
|
+
.option('--debounce-ms <ms>', 'Per-strand burst-coalescing window', parseIntArg)
|
|
1115
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1116
|
+
.action((opts: { cooldownMs?: number; debounceMs?: number; dataDir?: string }) => {
|
|
1117
|
+
const { cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1118
|
+
const current = readHostConfig(cfgPath);
|
|
1119
|
+
const nextPush = { ...current.push };
|
|
1120
|
+
if (typeof opts.cooldownMs === 'number') nextPush.cooldownMs = opts.cooldownMs;
|
|
1121
|
+
if (typeof opts.debounceMs === 'number') nextPush.debounceMs = opts.debounceMs;
|
|
1122
|
+
updateHostConfig(cfgPath, { push: nextPush });
|
|
1123
|
+
console.log('✓ Push options updated.');
|
|
1124
|
+
process.exit(0);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
push
|
|
1128
|
+
.command('clear')
|
|
1129
|
+
.description('Remove stored push credentials')
|
|
1130
|
+
.argument('<target>', 'Which to clear: "fcm", "apns", or "all"')
|
|
1131
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1132
|
+
.action(async (target: string, opts: { dataDir?: string }) => {
|
|
1133
|
+
if (!['fcm', 'apns', 'all'].includes(target)) {
|
|
1134
|
+
console.error(`push clear: target must be one of fcm | apns | all (got "${target}")`);
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1139
|
+
const secrets = await createSecretsStore(dataDir);
|
|
1140
|
+
if (target === 'fcm' || target === 'all') await clearPushSecret(secrets, 'fcm');
|
|
1141
|
+
if (target === 'apns' || target === 'all') {
|
|
1142
|
+
await clearPushSecret(secrets, 'apns');
|
|
1143
|
+
const current = readHostConfig(cfgPath);
|
|
1144
|
+
if (current.push?.apns) {
|
|
1145
|
+
const { apns: _drop, ...rest } = current.push;
|
|
1146
|
+
updateHostConfig(cfgPath, { push: rest });
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`✓ Cleared push credentials: ${target}. Restart cadre-host to apply.`);
|
|
1150
|
+
process.exit(0);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
push
|
|
1154
|
+
.command('status')
|
|
1155
|
+
.description('Show which push platforms are configured (no secret material)')
|
|
1156
|
+
.option('--data-dir <path>', 'Override the data directory (env: CADRE_HOST_DATA_DIR)')
|
|
1157
|
+
.action(async (opts: { dataDir?: string }) => {
|
|
1158
|
+
const { dataDir, cfgPath } = resolveHostPaths(opts.dataDir);
|
|
1159
|
+
const secrets = await createSecretsStore(dataDir);
|
|
1160
|
+
const status = await pushStatus(secrets);
|
|
1161
|
+
const cfg = readHostConfig(cfgPath);
|
|
1162
|
+
console.log('Push credentials:');
|
|
1163
|
+
console.log(` FCM: ${status.fcm ? 'configured' : 'not configured'}`);
|
|
1164
|
+
const prod = cfg.push?.apns?.production ? 'production' : 'sandbox';
|
|
1165
|
+
const bundle = cfg.push?.apns?.bundleId ? ` bundle=${cfg.push.apns.bundleId} (${prod})` : '';
|
|
1166
|
+
console.log(` APNs: ${status.apns ? `configured${bundle}` : 'not configured'}`);
|
|
1167
|
+
if (cfg.push?.cooldownMs !== undefined) console.log(` cooldownMs: ${cfg.push.cooldownMs}`);
|
|
1168
|
+
if (cfg.push?.debounceMs !== undefined) console.log(` debounceMs: ${cfg.push.debounceMs}`);
|
|
1169
|
+
process.exit(0);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
/** Resolve the data dir + config path for a direct-on-disk push subcommand. */
|
|
1173
|
+
function resolveHostPaths(dataDirOpt?: string): { dataDir: string; cfgPath: string } {
|
|
1174
|
+
const platform = detectPlatform();
|
|
1175
|
+
const dataDir = dataDirOpt ?? process.env.CADRE_HOST_DATA_DIR ?? defaultDataDir(platform);
|
|
1176
|
+
const cfgPath = resolveConfigPath(dataDir);
|
|
1177
|
+
if (!existsSync(cfgPath)) {
|
|
1178
|
+
console.error(`${cfgPath} not found. Run \`cadre-host install\` first (or pass --data-dir).`);
|
|
1179
|
+
process.exit(1);
|
|
1180
|
+
}
|
|
1181
|
+
// Resolve the real data dir from the persisted config (handles default-dir installs).
|
|
1182
|
+
const cfg = readHostConfig(cfgPath);
|
|
1183
|
+
return { dataDir: cfg.dataDir, cfgPath };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Read a private key from a file or inline flag; exit with a clear error if neither. */
|
|
1187
|
+
function readPrivateKeyArg(file: string | undefined, inline: string | undefined, label: string): string {
|
|
1188
|
+
if (file) {
|
|
1189
|
+
try {
|
|
1190
|
+
return readFileSync(file, 'utf8');
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
console.error(`Failed to read ${label} private key file ${file}: ${(err as Error).message}`);
|
|
1193
|
+
process.exit(1);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (inline) return inline;
|
|
1197
|
+
console.error(`${label}: supply --private-key-file <path> (or --private-key <pem>).`);
|
|
1198
|
+
process.exit(1);
|
|
1199
|
+
throw new Error('unreachable');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function getJson(url: string): Promise<NatStatusLike> {
|
|
1203
|
+
return await callJson(url, 'GET');
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1207
|
+
return await callJson(url, 'POST', body);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1211
|
+
return await callJson(url, 'PUT', body);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
interface NatStatusLike {
|
|
1215
|
+
portMode?: string;
|
|
1216
|
+
externalPort?: number;
|
|
1217
|
+
internalPort?: number;
|
|
1218
|
+
externalIp?: string | null;
|
|
1219
|
+
routerExternalIp?: string | null;
|
|
1220
|
+
cgnatDetected?: boolean;
|
|
1221
|
+
directReachability?: string;
|
|
1222
|
+
lastTestedAt?: string | null;
|
|
1223
|
+
mappingLeaseExpiresAt?: string | null;
|
|
1224
|
+
ddns?: {
|
|
1225
|
+
providerId?: string | null;
|
|
1226
|
+
hostname?: string | null;
|
|
1227
|
+
externallyManaged?: boolean;
|
|
1228
|
+
lastUpdateAt?: string | null;
|
|
1229
|
+
lastUpdateOk?: boolean | null;
|
|
1230
|
+
lastError?: string | null;
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function callJson(url: string, method: string, body?: unknown): Promise<NatStatusLike> {
|
|
1235
|
+
let response: Response;
|
|
1236
|
+
try {
|
|
1237
|
+
const init: RequestInit = { method };
|
|
1238
|
+
if (body !== undefined) {
|
|
1239
|
+
init.headers = { 'content-type': 'application/json' };
|
|
1240
|
+
init.body = JSON.stringify(body);
|
|
1241
|
+
}
|
|
1242
|
+
response = await fetch(url, init);
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
console.error(
|
|
1245
|
+
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
1246
|
+
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
1247
|
+
);
|
|
1248
|
+
process.exit(2);
|
|
1249
|
+
throw err;
|
|
1250
|
+
}
|
|
1251
|
+
if (!response.ok) {
|
|
1252
|
+
const text = await response.text().catch(() => '');
|
|
1253
|
+
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
1254
|
+
process.exit(1);
|
|
1255
|
+
throw new Error('non-ok');
|
|
1256
|
+
}
|
|
1257
|
+
if (response.status === 204) return {} as NatStatusLike;
|
|
1258
|
+
return await response.json() as NatStatusLike;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function printNatStatus(s: NatStatusLike): void {
|
|
1262
|
+
console.log(`Port mapping: ${s.portMode ?? '?'} (external ${s.externalPort ?? '?'} → internal ${s.internalPort ?? '?'})`);
|
|
1263
|
+
if (s.routerExternalIp) {
|
|
1264
|
+
console.log(`Router IP: ${s.routerExternalIp}`);
|
|
1265
|
+
}
|
|
1266
|
+
console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
|
|
1267
|
+
console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
|
|
1268
|
+
if (s.mappingLeaseExpiresAt) {
|
|
1269
|
+
console.log(`Lease expires: ${s.mappingLeaseExpiresAt}`);
|
|
1270
|
+
}
|
|
1271
|
+
const ddns = s.ddns ?? {};
|
|
1272
|
+
if (ddns.providerId || ddns.hostname) {
|
|
1273
|
+
console.log(`\nDDNS:`);
|
|
1274
|
+
console.log(` Provider: ${ddns.providerId ?? '(none)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
|
|
1275
|
+
console.log(` Hostname: ${ddns.hostname ?? '(unset)'}`);
|
|
1276
|
+
if (ddns.lastUpdateAt) {
|
|
1277
|
+
const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
|
|
1278
|
+
console.log(` Last update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
|
|
1279
|
+
}
|
|
1280
|
+
} else {
|
|
1281
|
+
console.log('\nDDNS: not configured');
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** Read a single line from stdin with echo suppressed (TTY only). */
|
|
1286
|
+
function readSecretLine(prompt: string): Promise<string> {
|
|
1287
|
+
return new Promise<string>((resolveLine, rejectLine) => {
|
|
1288
|
+
const stdin = process.stdin;
|
|
1289
|
+
const stdout = process.stdout;
|
|
1290
|
+
const wasRaw = stdin.isRaw;
|
|
1291
|
+
try {
|
|
1292
|
+
stdout.write(prompt);
|
|
1293
|
+
stdin.setRawMode(true);
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
rejectLine(err);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
stdin.resume();
|
|
1299
|
+
stdin.setEncoding('utf8');
|
|
1300
|
+
|
|
1301
|
+
let buf = '';
|
|
1302
|
+
const onData = (chunk: string) => {
|
|
1303
|
+
for (const ch of chunk) {
|
|
1304
|
+
if (ch === '\n' || ch === '\r' || ch === '') {
|
|
1305
|
+
stdin.removeListener('data', onData);
|
|
1306
|
+
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
1307
|
+
stdin.pause();
|
|
1308
|
+
stdout.write('\n');
|
|
1309
|
+
resolveLine(buf);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (ch === '') {
|
|
1313
|
+
// Ctrl-C
|
|
1314
|
+
stdin.removeListener('data', onData);
|
|
1315
|
+
try { stdin.setRawMode(wasRaw); } catch { /* ignore */ }
|
|
1316
|
+
stdin.pause();
|
|
1317
|
+
stdout.write('\n');
|
|
1318
|
+
rejectLine(new Error('cancelled'));
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (ch === '' || ch === '\b') {
|
|
1322
|
+
buf = buf.slice(0, -1);
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
buf += ch;
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
stdin.on('data', onData);
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* True when this module is the process entry point rather than an import.
|
|
1334
|
+
* `import.meta.url` is already realpath-resolved by Node's ESM loader, so
|
|
1335
|
+
* `process.argv[1]` gets the same treatment before comparing — a package-manager
|
|
1336
|
+
* bin symlink otherwise fails to match.
|
|
1337
|
+
*
|
|
1338
|
+
* NOTE: accepted tradeoff — a false negative here is a CLI that exits 0 having
|
|
1339
|
+
* printed nothing, which is a quiet failure. Gating on `process.env.VITEST`
|
|
1340
|
+
* instead was rejected (a test-runner variable has no business in shipped CLI
|
|
1341
|
+
* source); the counter-guard is `src/__tests__/cli.smoke.test.ts`, which spawns
|
|
1342
|
+
* `dist/bin/host.js --help` and asserts on non-empty stdout. Revisit if that
|
|
1343
|
+
* smoke test is ever weakened or removed.
|
|
1344
|
+
*/
|
|
1345
|
+
function isEntryPoint(): boolean {
|
|
1346
|
+
const entry = process.argv[1];
|
|
1347
|
+
if (!entry) return false;
|
|
1348
|
+
try {
|
|
1349
|
+
return pathToFileURL(realpathSync(entry)).href === import.meta.url;
|
|
1350
|
+
} catch {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export { program };
|
|
1356
|
+
|
|
1357
|
+
if (isEntryPoint()) void program.parseAsync();
|