@serfab/cadre-host 1.12.0 → 1.14.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 +332 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +301 -505
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/join-output.d.ts +56 -0
- package/dist/bin/join-output.js +92 -0
- package/dist/bin/join-output.js.map +1 -0
- package/dist/bin/nat-output.d.ts +49 -0
- package/dist/bin/nat-output.js +127 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +44 -0
- package/dist/hosted/claim-details.js +66 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +318 -0
- package/dist/hosted/hosted-node-service.js +666 -0
- package/dist/hosted/hosted-node-service.js.map +1 -0
- package/dist/hosted/hosted-node-store.d.ts +40 -0
- package/dist/hosted/hosted-node-store.js +126 -0
- package/dist/hosted/hosted-node-store.js.map +1 -0
- package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
- package/dist/hosted/hosted-node-supervisor.js +396 -0
- package/dist/hosted/hosted-node-supervisor.js.map +1 -0
- package/dist/hosted/hosted-node-watcher.d.ts +83 -0
- package/dist/hosted/hosted-node-watcher.js +224 -0
- package/dist/hosted/hosted-node-watcher.js.map +1 -0
- package/dist/hosted/index.d.ts +16 -0
- package/dist/hosted/index.js +13 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/node-status.d.ts +35 -0
- package/dist/hosted/node-status.js +61 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +134 -0
- package/dist/hosted/types.js +22 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +12 -24
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +4 -34
- package/dist/installer/config.js +3 -18
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -19
- package/dist/installer/index.js +14 -69
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/paths.d.ts +0 -2
- package/dist/installer/paths.js +0 -4
- package/dist/installer/paths.js.map +1 -1
- package/dist/installer/wizard.d.ts +0 -7
- package/dist/installer/wizard.js +1 -8
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/address-resolver.d.ts +18 -17
- package/dist/nat/address-resolver.js +79 -25
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/nat/address-watch.d.ts +37 -0
- package/dist/nat/address-watch.js +84 -0
- package/dist/nat/address-watch.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +2 -1
- package/dist/nat/ddns/duckdns.js +5 -1
- package/dist/nat/ddns/duckdns.js.map +1 -1
- package/dist/nat/ddns/index.d.ts +6 -0
- package/dist/nat/ddns/index.js.map +1 -1
- package/dist/nat/index.d.ts +12 -9
- package/dist/nat/index.js +6 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +203 -77
- package/dist/nat/nat-service.js +602 -237
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +114 -30
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/port-mapper.d.ts +83 -79
- package/dist/nat/port-mapper.js +148 -189
- package/dist/nat/port-mapper.js.map +1 -1
- package/dist/nat/reachability.d.ts +31 -15
- package/dist/nat/reachability.js +97 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +78 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +29 -71
- package/dist/orchestrator/host-process-orchestrator.js +78 -218
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
- package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
- package/dist/orchestrator/identity-file.js.map +1 -0
- package/dist/orchestrator/index.d.ts +2 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +6 -6
- package/dist/orchestrator/node-identity.js +7 -7
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +5 -6
- package/dist/orchestrator/port-allocator.js +9 -11
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +3 -5
- package/dist/orchestrator/state-store.js +2 -10
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +33 -22
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/server/error-handler.d.ts +5 -25
- package/dist/server/error-handler.js +11 -74
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +7 -11
- package/dist/server/events/types.js +2 -1
- package/dist/server/events/types.js.map +1 -1
- package/dist/server/index.d.ts +11 -37
- package/dist/server/index.js +30 -48
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/hosted-nodes.d.ts +28 -0
- package/dist/server/routes/hosted-nodes.js +68 -0
- package/dist/server/routes/hosted-nodes.js.map +1 -0
- package/dist/server/routes/nat.d.ts +4 -1
- package/dist/server/routes/nat.js +13 -2
- package/dist/server/routes/nat.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +6 -20
- package/dist/server/routes/nodes.js +7 -86
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +2 -7
- package/dist/server/routes/settings.js +3 -7
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +1 -11
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/server.js +8 -8
- package/dist/server/settings-store.d.ts +2 -2
- package/dist/server/settings-store.js +2 -2
- package/dist/server/static.js +1 -2
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-4FWuU8ye.js +19 -0
- package/dist/ui/assets/index-4FWuU8ye.js.map +1 -0
- package/dist/ui/assets/index-Di9Q4d01.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/store.d.ts +1 -1
- package/dist/update/store.js +1 -1
- package/dist/update/types.d.ts +1 -1
- package/dist/update/types.js +1 -1
- package/package.json +5 -6
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +1 -1
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +112 -0
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +182 -203
- package/src/__tests__/orchestrator-ports.test.ts +124 -143
- package/src/__tests__/orchestrator-push.test.ts +138 -146
- package/src/__tests__/orchestrator.test.ts +1019 -1063
- package/src/bin/host.ts +368 -553
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +177 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +173 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +265 -243
- package/src/hosted/__tests__/hosted-node-service.test.ts +609 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +474 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +96 -0
- package/src/hosted/hosted-node-service.ts +795 -0
- package/src/hosted/hosted-node-store.ts +144 -0
- package/src/hosted/hosted-node-supervisor.ts +439 -0
- package/src/hosted/hosted-node-watcher.ts +250 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +71 -0
- package/src/hosted/types.ts +153 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +5 -86
- package/src/installer/__tests__/wizard.test.ts +56 -64
- package/src/installer/config.ts +135 -175
- package/src/installer/index.ts +15 -83
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +87 -103
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +433 -408
- package/src/nat/__tests__/nat-store.test.ts +127 -93
- package/src/nat/__tests__/port-mapper.test.ts +30 -191
- package/src/nat/__tests__/reachability.test.ts +96 -13
- package/src/nat/address-resolver.ts +94 -52
- package/src/nat/address-watch.ts +92 -0
- package/src/nat/ddns/duckdns.ts +5 -1
- package/src/nat/ddns/index.ts +6 -0
- package/src/nat/index.ts +87 -71
- package/src/nat/nat-service.ts +889 -501
- package/src/nat/nat-store.ts +237 -154
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +134 -25
- package/src/nat/types.ts +209 -159
- package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +81 -243
- package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
- package/src/orchestrator/index.ts +9 -10
- package/src/orchestrator/node-identity.ts +41 -41
- package/src/orchestrator/port-allocator.ts +71 -73
- package/src/orchestrator/state-store.ts +76 -90
- package/src/orchestrator/types.ts +34 -23
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +40 -44
- package/src/server/__tests__/hosted-nodes-route.test.ts +160 -0
- package/src/server/__tests__/nodes-route.test.ts +114 -283
- package/src/server/__tests__/publishers.test.ts +193 -180
- package/src/server/__tests__/server.smoke.test.ts +134 -223
- package/src/server/__tests__/settings-route.test.ts +179 -195
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/status-route.test.ts +82 -121
- package/src/server/error-handler.ts +112 -178
- package/src/server/events/types.ts +20 -24
- package/src/server/index.ts +223 -267
- package/src/server/routes/hosted-nodes.ts +83 -0
- package/src/server/routes/nat.ts +75 -63
- package/src/server/routes/nodes.ts +96 -188
- package/src/server/routes/settings.ts +115 -123
- package/src/server/routes/status.ts +102 -134
- package/src/server/server.ts +8 -8
- package/src/server/settings-store.ts +2 -2
- package/src/server/static.ts +1 -2
- package/src/update/index.ts +2 -2
- package/src/update/store.ts +1 -1
- package/src/update/types.ts +1 -1
- package/dist/auth/duration.d.ts +0 -1
- package/dist/auth/duration.js +0 -32
- package/dist/auth/duration.js.map +0 -1
- package/dist/auth/index.d.ts +0 -11
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/self-label.d.ts +0 -36
- package/dist/auth/self-label.js +0 -57
- package/dist/auth/self-label.js.map +0 -1
- package/dist/auth/trust-circle-store.d.ts +0 -26
- package/dist/auth/trust-circle-store.js +0 -129
- package/dist/auth/trust-circle-store.js.map +0 -1
- package/dist/auth/trust-circle.d.ts +0 -156
- package/dist/auth/trust-circle.js +0 -307
- package/dist/auth/trust-circle.js.map +0 -1
- package/dist/auth/types.d.ts +0 -84
- package/dist/auth/types.js +0 -24
- package/dist/auth/types.js.map +0 -1
- package/dist/donation/donation-service.d.ts +0 -389
- package/dist/donation/donation-service.js +0 -828
- package/dist/donation/donation-service.js.map +0 -1
- package/dist/donation/donation-store.d.ts +0 -51
- package/dist/donation/donation-store.js +0 -152
- package/dist/donation/donation-store.js.map +0 -1
- package/dist/donation/donation-supervisor.js +0 -364
- package/dist/donation/donation-supervisor.js.map +0 -1
- package/dist/donation/grant-service.d.ts +0 -80
- package/dist/donation/grant-service.js +0 -210
- package/dist/donation/grant-service.js.map +0 -1
- package/dist/donation/grant-store.d.ts +0 -38
- package/dist/donation/grant-store.js +0 -135
- package/dist/donation/grant-store.js.map +0 -1
- package/dist/donation/index.d.ts +0 -20
- package/dist/donation/index.js +0 -17
- package/dist/donation/index.js.map +0 -1
- package/dist/donation/types.d.ts +0 -224
- package/dist/donation/types.js +0 -40
- package/dist/donation/types.js.map +0 -1
- package/dist/installer/identity.js.map +0 -1
- package/dist/owner/index.d.ts +0 -11
- package/dist/owner/index.js +0 -11
- package/dist/owner/index.js.map +0 -1
- package/dist/owner/owner-node-client.d.ts +0 -110
- package/dist/owner/owner-node-client.js +0 -184
- package/dist/owner/owner-node-client.js.map +0 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
- package/dist/server/routes/bootstrap-node-validation.js +0 -197
- package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
- package/dist/server/routes/grants-admin.d.ts +0 -29
- package/dist/server/routes/grants-admin.js +0 -58
- package/dist/server/routes/grants-admin.js.map +0 -1
- package/dist/server/routes/grants.d.ts +0 -28
- package/dist/server/routes/grants.js +0 -165
- package/dist/server/routes/grants.js.map +0 -1
- package/dist/server/routes/provision-request-validation.d.ts +0 -45
- package/dist/server/routes/provision-request-validation.js +0 -97
- package/dist/server/routes/provision-request-validation.js.map +0 -1
- package/dist/server/routes/strands.d.ts +0 -20
- package/dist/server/routes/strands.js +0 -38
- package/dist/server/routes/strands.js.map +0 -1
- package/dist/server/routes/trust-circle.d.ts +0 -17
- package/dist/server/routes/trust-circle.js +0 -35
- package/dist/server/routes/trust-circle.js.map +0 -1
- package/dist/strands/index.d.ts +0 -7
- package/dist/strands/index.js +0 -6
- package/dist/strands/index.js.map +0 -1
- package/dist/strands/strand-service.d.ts +0 -50
- package/dist/strands/strand-service.js +0 -103
- package/dist/strands/strand-service.js.map +0 -1
- package/dist/strands/types.d.ts +0 -93
- package/dist/strands/types.js +0 -23
- package/dist/strands/types.js.map +0 -1
- package/dist/ui/assets/index-C6nELNfC.js +0 -23
- package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
- package/dist/ui/assets/index-TtIYSeZO.css +0 -1
- package/src/__tests__/cli-invite.smoke.test.ts +0 -192
- package/src/__tests__/host-owner.smoke.test.ts +0 -123
- package/src/__tests__/orchestrator-owner.test.ts +0 -234
- package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
- package/src/auth/__tests__/duration.test.ts +0 -28
- package/src/auth/__tests__/self-label.test.ts +0 -85
- package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
- package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
- package/src/auth/__tests__/trust-circle.test.ts +0 -445
- package/src/auth/duration.ts +0 -33
- package/src/auth/index.ts +0 -21
- package/src/auth/self-label.ts +0 -74
- package/src/auth/trust-circle-store.ts +0 -155
- package/src/auth/trust-circle.ts +0 -395
- package/src/auth/types.ts +0 -99
- package/src/donation/__tests__/donation-service.test.ts +0 -1193
- package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
- package/src/donation/__tests__/grant-service.test.ts +0 -213
- package/src/donation/__tests__/grant-store.test.ts +0 -113
- package/src/donation/donation-service.ts +0 -1033
- package/src/donation/donation-store.ts +0 -176
- package/src/donation/donation-supervisor.ts +0 -411
- package/src/donation/grant-service.ts +0 -252
- package/src/donation/grant-store.ts +0 -152
- package/src/donation/index.ts +0 -65
- package/src/donation/types.ts +0 -260
- package/src/owner/__tests__/owner-node-client.test.ts +0 -194
- package/src/owner/index.ts +0 -12
- package/src/owner/owner-node-client.ts +0 -259
- package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
- package/src/server/__tests__/grants-admin-route.test.ts +0 -171
- package/src/server/__tests__/grants-route.test.ts +0 -394
- package/src/server/__tests__/strands-route.test.ts +0 -234
- package/src/server/routes/bootstrap-node-validation.ts +0 -206
- package/src/server/routes/grants-admin.ts +0 -74
- package/src/server/routes/grants.ts +0 -193
- package/src/server/routes/provision-request-validation.ts +0 -105
- package/src/server/routes/strands.ts +0 -54
- package/src/server/routes/trust-circle.ts +0 -49
- package/src/strands/__tests__/strand-service.test.ts +0 -165
- package/src/strands/index.ts +0 -14
- package/src/strands/strand-service.ts +0 -135
- package/src/strands/types.ts +0 -102
package/src/bin/host.ts
CHANGED
|
@@ -3,51 +3,47 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* CLI entrypoint for cadre-host — the self-hosted cadre node manager.
|
|
5
5
|
*
|
|
6
|
-
* Most subcommands (`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Most subcommands (`join`, `node`, `nat`) are thin HTTP clients against the
|
|
7
|
+
* running cadre-host management API on loopback — they don't spin up an inline
|
|
8
|
+
* service, so cadre-host must be running.
|
|
9
9
|
*
|
|
10
10
|
* The exceptions operate on disk directly and need no running service:
|
|
11
11
|
* `install`, `uninstall`, `start`, `ui`, and the `push` group.
|
|
12
12
|
*
|
|
13
13
|
* Every command here needs a matching `### ` heading in the package README's
|
|
14
14
|
* `## CLI reference`; `__tests__/cli-reference.test.ts` enforces that.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: this file is ~1200 lines; `nat-output.ts` shows the split to make —
|
|
17
|
+
* when a command group's code grows, move it to its own module under `bin/`.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
18
|
-
import { createRequire } from 'node:module';
|
|
19
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
22
|
import { dirname, join, resolve } from 'node:path';
|
|
21
23
|
|
|
22
|
-
import { Command } from 'commander';
|
|
24
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
23
25
|
|
|
24
|
-
import { parseDuration } from '../auth/duration.js';
|
|
25
26
|
import { Installer } from '../installer/index.js';
|
|
26
|
-
import { readHostConfig, updateHostConfig
|
|
27
|
+
import { readHostConfig, updateHostConfig } from '../installer/config.js';
|
|
27
28
|
import {
|
|
28
29
|
configPath as resolveConfigPath,
|
|
29
30
|
defaultDataDir,
|
|
30
31
|
defaultHostJs,
|
|
31
32
|
defaultServiceDir,
|
|
32
|
-
identityPath as resolveIdentityPath,
|
|
33
33
|
} from '../installer/paths.js';
|
|
34
34
|
import { detectPlatform } from '../installer/platform.js';
|
|
35
35
|
import { createServiceHost } from '../installer/service-host/index.js';
|
|
36
36
|
import { UpdateService } from '../update/index.js';
|
|
37
37
|
import { HostProcessOrchestrator } from '../orchestrator/index.js';
|
|
38
|
-
import { TrustCircleService, TrustCircleStore, ensureSelfLabel } from '../auth/index.js';
|
|
39
38
|
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
DONATION_PROVISIONING_TTL_MS,
|
|
47
|
-
DONATION_REAP_SWEEP_MS,
|
|
48
|
-
type GrantListing,
|
|
49
|
-
} from '../donation/index.js';
|
|
39
|
+
HostedNodeService,
|
|
40
|
+
HostedNodeStore,
|
|
41
|
+
HostedNodeSupervisor,
|
|
42
|
+
HOSTED_NODE_REAP_SWEEP_MS,
|
|
43
|
+
HOSTED_NODE_SPAWNING_TTL_MS,
|
|
44
|
+
} from '../hosted/index.js';
|
|
50
45
|
import { NatService } from '../nat/index.js';
|
|
46
|
+
import type { ManualForwardPatch } from '../nat/types.js';
|
|
51
47
|
import { createSecretsStore } from '../nat/secrets/index.js';
|
|
52
48
|
import {
|
|
53
49
|
resolvePushCredentials,
|
|
@@ -56,20 +52,20 @@ import {
|
|
|
56
52
|
clearPushSecret,
|
|
57
53
|
pushStatus,
|
|
58
54
|
} from '../push/index.js';
|
|
59
|
-
import {
|
|
60
|
-
import { OwnerNodeClient } from '../owner/index.js';
|
|
61
|
-
import { createLocalUiServer, HostSettingsStore, type FounderServices } from '../server/index.js';
|
|
55
|
+
import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
|
|
62
56
|
import { openBrowser } from '../installer/browser.js';
|
|
57
|
+
import { printForwardResult, printNatStatus, type NatStatusLike } from './nat-output.js';
|
|
58
|
+
import {
|
|
59
|
+
printClaimPayload,
|
|
60
|
+
printClaimed,
|
|
61
|
+
printInvitationOutcome,
|
|
62
|
+
printNodeList,
|
|
63
|
+
type ClaimDetailsLike,
|
|
64
|
+
type HostedNodeLike,
|
|
65
|
+
} from './join-output.js';
|
|
63
66
|
|
|
64
67
|
const DEFAULT_PORT = Number(process.env.CADRE_HOST_PORT ?? '8765');
|
|
65
68
|
|
|
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
69
|
function resolvePort(raw: string): number {
|
|
74
70
|
const n = Number(raw);
|
|
75
71
|
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
@@ -96,11 +92,8 @@ program
|
|
|
96
92
|
.option('--non-interactive', 'Use defaults / CLI flags only; never prompt')
|
|
97
93
|
.option('--data-dir <path>', 'Override the data directory')
|
|
98
94
|
.option('--ui-port <port>', 'Override the management UI port', parseIntArg)
|
|
99
|
-
.option('--libp2p-port <port>', 'Override the cadre libp2p port', parseIntArg)
|
|
100
95
|
.option('--no-upnp', 'Disable UPnP/NAT-PMP probing on first run')
|
|
101
96
|
.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
97
|
.option('--system', 'System-wide install (not yet supported in v1)')
|
|
105
98
|
.option('--node-path <path>', 'Override the node binary embedded in the service unit')
|
|
106
99
|
.option('--no-service', 'Write the data dir only; register no OS service (run the host with `cadre-host start`)')
|
|
@@ -108,11 +101,8 @@ program
|
|
|
108
101
|
nonInteractive?: boolean;
|
|
109
102
|
dataDir?: string;
|
|
110
103
|
uiPort?: number;
|
|
111
|
-
libp2pPort?: number;
|
|
112
104
|
upnp?: boolean;
|
|
113
105
|
browser?: boolean;
|
|
114
|
-
invite?: boolean;
|
|
115
|
-
ownCadre?: boolean;
|
|
116
106
|
system?: boolean;
|
|
117
107
|
nodePath?: string;
|
|
118
108
|
service?: boolean;
|
|
@@ -123,11 +113,8 @@ program
|
|
|
123
113
|
nonInteractive: Boolean(opts.nonInteractive),
|
|
124
114
|
...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
|
|
125
115
|
...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
|
|
126
|
-
...(typeof opts.libp2pPort === 'number' ? { libp2pPort: opts.libp2pPort } : {}),
|
|
127
116
|
noUpnp: opts.upnp === false,
|
|
128
117
|
openBrowser: opts.browser !== false,
|
|
129
|
-
noInvite: opts.invite === false,
|
|
130
|
-
ownCadre: opts.ownCadre === true,
|
|
131
118
|
system: Boolean(opts.system),
|
|
132
119
|
...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
|
|
133
120
|
noService: opts.service === false,
|
|
@@ -141,9 +128,6 @@ program
|
|
|
141
128
|
console.log(' Service: not registered (--no-service)');
|
|
142
129
|
console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
|
|
143
130
|
}
|
|
144
|
-
if (result.enrollmentInvite) {
|
|
145
|
-
printEnrollmentInvite(result.enrollmentInvite);
|
|
146
|
-
}
|
|
147
131
|
process.exit(0);
|
|
148
132
|
} catch (err) {
|
|
149
133
|
console.error(`install failed: ${(err as Error).message}`);
|
|
@@ -164,8 +148,8 @@ program
|
|
|
164
148
|
.action(async (opts: { yes?: boolean; removeData?: boolean; dataDir?: string }) => {
|
|
165
149
|
const installer = new Installer();
|
|
166
150
|
try {
|
|
167
|
-
// --remove-data is destructive and irreversible (
|
|
168
|
-
// are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
|
|
151
|
+
// --remove-data is destructive and irreversible (node identities, hosted-node
|
|
152
|
+
// records and NAT state are wiped). Require explicit --yes when stdin isn't a TTY, and prompt
|
|
169
153
|
// confirmation when it is.
|
|
170
154
|
if (opts.removeData && !opts.yes) {
|
|
171
155
|
if (!process.stdin.isTTY) {
|
|
@@ -175,7 +159,7 @@ program
|
|
|
175
159
|
}
|
|
176
160
|
const dataDir = opts.dataDir ?? '(default data directory)';
|
|
177
161
|
const confirmed = await confirmDestructive(
|
|
178
|
-
`This will permanently delete ${dataDir} (
|
|
162
|
+
`This will permanently delete ${dataDir} (node identities, hosted-node records, NAT state). Continue? [y/N] `,
|
|
179
163
|
);
|
|
180
164
|
if (!confirmed) {
|
|
181
165
|
console.error('uninstall aborted.');
|
|
@@ -217,7 +201,7 @@ program
|
|
|
217
201
|
});
|
|
218
202
|
|
|
219
203
|
// ============================================================================
|
|
220
|
-
// start — load config
|
|
204
|
+
// start — load config, bind management API listener, wait
|
|
221
205
|
// ============================================================================
|
|
222
206
|
|
|
223
207
|
program
|
|
@@ -240,12 +224,6 @@ program
|
|
|
240
224
|
return;
|
|
241
225
|
}
|
|
242
226
|
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
227
|
console.log(`cadre-host starting (dataDir=${cfg.dataDir}, uiPort=${cfg.uiPort})`);
|
|
250
228
|
|
|
251
229
|
// Update flow: notify-by-default; auto-apply opt-in via host.config.json.
|
|
@@ -284,169 +262,89 @@ program
|
|
|
284
262
|
void updateService.check();
|
|
285
263
|
updateService.start();
|
|
286
264
|
|
|
287
|
-
// Wire the long-lived HTTP management server. cadre-host
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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).
|
|
265
|
+
// Wire the long-lived HTTP management server. cadre-host spawns cadre
|
|
266
|
+
// nodes for cadres that live on people's phones (the hosted-node service
|
|
267
|
+
// below); it holds no owner key and never founds a cadre. The manager
|
|
268
|
+
// never joins the control network (docs/cadre-host.md § Control-plane
|
|
269
|
+
// separation).
|
|
296
270
|
// Push (FCM/APNs) credentials are resolved fresh on every node spawn so a
|
|
297
271
|
// restart re-reads the secret store (rotated keys) and nothing raw is
|
|
298
272
|
// persisted in state.json. The non-secret bits (bundle id / sandbox toggle,
|
|
299
273
|
// cooldown/debounce) are re-read from host.config.json each call too.
|
|
300
274
|
const pushSecrets = await createSecretsStore(cfg.dataDir);
|
|
275
|
+
// Each node is spawned announcing its public addresses. Read through a late-bound
|
|
276
|
+
// reference: NatService takes the orchestrator as its node source, so it is built after.
|
|
277
|
+
const addresses: { source?: NatService } = {};
|
|
301
278
|
const orchestrator = new HostProcessOrchestrator({
|
|
302
279
|
rootDir: join(cfg.dataDir, 'orchestrator'),
|
|
303
280
|
pushResolver: () => resolvePushCredentials(pushSecrets, readHostConfig(cfgPath).push),
|
|
281
|
+
announceAddrs: (id, ports) => addresses.source?.publicAddressesFor(id, ports) ?? [],
|
|
304
282
|
});
|
|
305
283
|
await orchestrator.init();
|
|
306
284
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
285
|
+
// NAT layer: every node the host runs gets its two libp2p ports mapped
|
|
286
|
+
// on the router (or the user's manual forward recorded for them). Started
|
|
287
|
+
// before anything is spawned so the first spawns see a discovered gateway. `start()` awaits
|
|
288
|
+
// only gateway discovery and IP detection (both bounded); the mappings
|
|
289
|
+
// for re-attached nodes land in the background. Best-effort: a failure
|
|
290
|
+
// here leaves the management API up.
|
|
291
|
+
const natService = new NatService({ rootDir: cfg.dataDir, nodeSource: orchestrator });
|
|
292
|
+
addresses.source = natService;
|
|
293
|
+
try {
|
|
294
|
+
await natService.start();
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error(`NAT start failed: ${(err as Error).message}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Hosted nodes — "Join a cadre": a child started waiting to be claimed, the
|
|
300
|
+
// QR code the owner's phone scans, and the record of whose cadre it joined.
|
|
301
|
+
// Drives `/api/hosted-nodes` (mounted by createLocalUiServer below).
|
|
302
|
+
const hostedNodeStore = new HostedNodeStore(cfg.dataDir);
|
|
303
|
+
const hostedNodes = new HostedNodeService({
|
|
318
304
|
orchestrator,
|
|
319
|
-
|
|
320
|
-
|
|
305
|
+
store: hostedNodeStore,
|
|
306
|
+
addresses: natService,
|
|
321
307
|
});
|
|
322
308
|
|
|
323
|
-
// Respawn supervision — nothing else brings a
|
|
324
|
-
// an OOM kill, or a reboot otherwise leaves the record reading `
|
|
325
|
-
// with no process behind it, costing
|
|
326
|
-
//
|
|
327
|
-
const
|
|
328
|
-
service:
|
|
329
|
-
store:
|
|
309
|
+
// Respawn supervision — nothing else brings a hosted node back. A crash,
|
|
310
|
+
// an OOM kill, or a reboot otherwise leaves the record reading `joined`
|
|
311
|
+
// with no process behind it, costing its cadre a node. Sweeps at startup,
|
|
312
|
+
// on every child exit, and on its own timer.
|
|
313
|
+
const hostedNodeSupervisor = new HostedNodeSupervisor({
|
|
314
|
+
service: hostedNodes,
|
|
315
|
+
store: hostedNodeStore,
|
|
330
316
|
orchestrator,
|
|
331
317
|
});
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
318
|
+
hostedNodeSupervisor.start();
|
|
319
|
+
// Follows each node's /status: the claim, and whether it holds a connection.
|
|
320
|
+
hostedNodes.startWatching();
|
|
321
|
+
|
|
322
|
+
// A node learns its public addresses only at start, so one whose addresses
|
|
323
|
+
// changed (a mapping on another port, a forward, the DDNS hostname, the external
|
|
324
|
+
// IP) is restarted, at most once per node per 10 minutes.
|
|
325
|
+
natService.onNodeAddressesStale((id) => hostedNodeSupervisor.restart(id));
|
|
326
|
+
|
|
327
|
+
// Reap records stuck in `spawning`: a host that died between writing the
|
|
328
|
+
// row and finishing the spawn leaves it stuck forever (nothing else ever
|
|
329
|
+
// revisits it). Sweep once at startup (for records recovered from disk by
|
|
339
330
|
// orchestrator.init()), then periodically.
|
|
340
331
|
const reapStale = (): void => {
|
|
341
|
-
void
|
|
342
|
-
.
|
|
343
|
-
.catch((err) => console.error(`
|
|
344
|
-
void donationService
|
|
345
|
-
.reapStaleProvisioning(DONATION_PROVISIONING_TTL_MS)
|
|
346
|
-
.catch((err) => console.error(`donation provisioning reap failed: ${(err as Error).message}`));
|
|
332
|
+
void hostedNodes
|
|
333
|
+
.reapStuckSpawning(HOSTED_NODE_SPAWNING_TTL_MS)
|
|
334
|
+
.catch((err) => console.error(`hosted node reap failed: ${(err as Error).message}`));
|
|
347
335
|
};
|
|
348
336
|
reapStale();
|
|
349
|
-
const reapTimer = setInterval(reapStale,
|
|
337
|
+
const reapTimer = setInterval(reapStale, HOSTED_NODE_REAP_SWEEP_MS);
|
|
350
338
|
reapTimer.unref();
|
|
351
339
|
|
|
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
340
|
const settingsStore = new HostSettingsStore({ dataDir: cfg.dataDir });
|
|
442
341
|
const server = createLocalUiServer({
|
|
443
342
|
uiPort: cfg.uiPort,
|
|
444
343
|
dataDir: cfg.dataDir,
|
|
445
344
|
orchestrator,
|
|
446
|
-
|
|
345
|
+
nat: natService,
|
|
447
346
|
update: updateService,
|
|
448
|
-
|
|
449
|
-
donations: donationService,
|
|
347
|
+
hostedNodes,
|
|
450
348
|
settingsStore,
|
|
451
349
|
});
|
|
452
350
|
const { url, port } = await server.start();
|
|
@@ -457,10 +355,10 @@ program
|
|
|
457
355
|
|
|
458
356
|
await waitForTermination();
|
|
459
357
|
clearInterval(reapTimer);
|
|
460
|
-
|
|
358
|
+
hostedNodes.stopWatching();
|
|
359
|
+
hostedNodeSupervisor.stop();
|
|
461
360
|
try { await server.stop(); } catch { /* ignore */ }
|
|
462
|
-
try { await
|
|
463
|
-
try { await orchestrator.stopOwnerNode(); } catch { /* ignore */ }
|
|
361
|
+
try { await natService.stop(); } catch { /* ignore */ }
|
|
464
362
|
updateService.stop();
|
|
465
363
|
console.log('cadre-host stopped.');
|
|
466
364
|
process.exit(0);
|
|
@@ -547,340 +445,188 @@ async function confirmDestructive(message: string): Promise<boolean> {
|
|
|
547
445
|
}
|
|
548
446
|
}
|
|
549
447
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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);
|
|
448
|
+
// ============================================================================
|
|
449
|
+
// join — start a node waiting to be claimed and show the QR code, or one that
|
|
450
|
+
// redeems a cadre invitation
|
|
451
|
+
// ============================================================================
|
|
452
|
+
//
|
|
453
|
+
// The host's one action. The owner's phone scans the code (or takes the pasted
|
|
454
|
+
// text), claims the node, and the node joins that phone's cadre. To put up a
|
|
455
|
+
// node for a friend, run it again and let the friend scan. With `--invitation`
|
|
456
|
+
// the node instead redeems an invitation the owner's app copied, at whichever
|
|
457
|
+
// member of the cadre it can reach. These commands are thin HTTP clients of the
|
|
458
|
+
// loopback `/api/hosted-nodes` surface — no bearer (same-machine admin), same
|
|
459
|
+
// posture as `nat`.
|
|
460
|
+
|
|
461
|
+
/** How long `join` waits for the new child's `/status` before giving up on the claim details. */
|
|
462
|
+
const CLAIM_DETAILS_WAIT_MS = 60_000;
|
|
463
|
+
const CLAIM_DETAILS_POLL_MS = 500;
|
|
464
|
+
/** How often `join` re-reads the node while waiting for the claim or the redemption. */
|
|
465
|
+
const CLAIM_WAIT_POLL_MS = 2_000;
|
|
466
|
+
|
|
467
|
+
interface JoinOptions {
|
|
468
|
+
qr?: boolean;
|
|
469
|
+
wait?: boolean;
|
|
470
|
+
invitation?: string;
|
|
471
|
+
port: string;
|
|
472
|
+
host: string;
|
|
583
473
|
}
|
|
584
474
|
|
|
585
475
|
program
|
|
586
|
-
.command('
|
|
587
|
-
.description('
|
|
588
|
-
.
|
|
589
|
-
.option('--
|
|
476
|
+
.command('join')
|
|
477
|
+
.description('Start a node waiting to be claimed and show the code the owner\'s phone scans to add it to their cadre, or with --invitation one that redeems a cadre invitation')
|
|
478
|
+
.option('--invitation <encoded>', 'Join with a cadre invitation the owner\'s app copied instead of showing a code; a member of the cadre must be reachable from this machine')
|
|
479
|
+
.option('--no-qr', 'Print only the join text, no QR code')
|
|
480
|
+
.option('--no-wait', 'Exit once the code is shown (or the node started) instead of waiting for it to join')
|
|
590
481
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
591
482
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
592
|
-
.action(async (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
483
|
+
.action(async (opts: JoinOptions) => {
|
|
484
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
485
|
+
if (opts.invitation !== undefined) {
|
|
486
|
+
const { node } = await callApi<{ node: HostedNodeLike }>(
|
|
487
|
+
`${base}/api/hosted-nodes`, 'POST', { invitation: opts.invitation },
|
|
488
|
+
(error) => `cadre-host refused the invitation: ${error.message} (${error.code})`,
|
|
489
|
+
);
|
|
490
|
+
process.exit(await followInvitationJoin(base, node, opts));
|
|
600
491
|
}
|
|
492
|
+
const { node } = await callApi<{ node: HostedNodeLike }>(`${base}/api/hosted-nodes`, 'POST', {});
|
|
493
|
+
await showClaimAndWait(base, node, opts);
|
|
494
|
+
process.exit(0);
|
|
495
|
+
});
|
|
601
496
|
|
|
602
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Print the node's claim code once its child answers, then — unless `--no-wait`
|
|
499
|
+
* — follow the node until a phone claims it. Ctrl-C leaves the node waiting.
|
|
500
|
+
*/
|
|
501
|
+
async function showClaimAndWait(base: string, node: HostedNodeLike, opts: JoinOptions): Promise<void> {
|
|
502
|
+
const id = node.id ?? '';
|
|
503
|
+
console.error(`Started hosted node ${id}; waiting for it to report its addresses…`);
|
|
504
|
+
const details = await waitForClaimDetails(base, id);
|
|
505
|
+
printClaimPayload(details, { qr: opts.qr !== false });
|
|
506
|
+
if (opts.wait === false) return;
|
|
507
|
+
console.error('Waiting for a phone to claim this node (Ctrl-C leaves it waiting)…');
|
|
508
|
+
const settled = await waitUntilSettled(base, id);
|
|
509
|
+
if (settled.status === 'error') {
|
|
510
|
+
console.error(`✗ Hosted node ${id} failed: ${settled.error ?? 'unknown error'}`);
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
printClaimed(settled);
|
|
514
|
+
}
|
|
603
515
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
`Hint: is cadre-host running? Try \`cadre-host start\`.`,
|
|
616
|
-
);
|
|
617
|
-
process.exit(2);
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
516
|
+
/**
|
|
517
|
+
* Follow a node redeeming its invitation until a member admits it or the redemption fails,
|
|
518
|
+
* unless `--no-wait`. Returns the exit code: 1 when it failed. Ctrl-C leaves the node joining.
|
|
519
|
+
*/
|
|
520
|
+
async function followInvitationJoin(base: string, node: HostedNodeLike, opts: { wait?: boolean }): Promise<number> {
|
|
521
|
+
console.error(`Joining cadre ${node.partyId ?? '?'} through the invitation…`);
|
|
522
|
+
if (opts.wait === false) return 0;
|
|
523
|
+
const settled = await waitUntilSettled(base, node.id ?? '');
|
|
524
|
+
printInvitationOutcome(settled);
|
|
525
|
+
return settled.status === 'joined' ? 0 : 1;
|
|
526
|
+
}
|
|
620
527
|
|
|
621
|
-
|
|
528
|
+
/** Poll `GET …/:id/claim` until the child answers (503 meanwhile), up to `CLAIM_DETAILS_WAIT_MS`. */
|
|
529
|
+
async function waitForClaimDetails(base: string, id: string): Promise<ClaimDetailsLike> {
|
|
530
|
+
const url = `${base}/api/hosted-nodes/${encodeURIComponent(id)}/claim`;
|
|
531
|
+
const deadline = Date.now() + CLAIM_DETAILS_WAIT_MS;
|
|
532
|
+
while (true) {
|
|
533
|
+
const response = await fetchOrExit(url, { method: 'GET' });
|
|
534
|
+
if (response.ok) return (await response.json() as ApiEnvelope<ClaimDetailsLike>).data;
|
|
535
|
+
if (response.status !== 503 || Date.now() >= deadline) {
|
|
622
536
|
const text = await response.text().catch(() => '');
|
|
623
537
|
console.error(`cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
624
538
|
process.exit(1);
|
|
625
|
-
return;
|
|
626
539
|
}
|
|
540
|
+
await sleep(CLAIM_DETAILS_POLL_MS);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
627
543
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
544
|
+
/** Poll `GET …/:id` until the node is `joined` or `error`. */
|
|
545
|
+
async function waitUntilSettled(base: string, id: string): Promise<HostedNodeLike> {
|
|
546
|
+
const url = `${base}/api/hosted-nodes/${encodeURIComponent(id)}`;
|
|
547
|
+
while (true) {
|
|
548
|
+
const { node } = await callApi<{ node: HostedNodeLike }>(url, 'GET');
|
|
549
|
+
if (node.status === 'joined' || node.status === 'error') return node;
|
|
550
|
+
await sleep(CLAIM_WAIT_POLL_MS);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
634
553
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
process.exit(0);
|
|
640
|
-
});
|
|
554
|
+
// ============================================================================
|
|
555
|
+
// node subcommands — the nodes this host runs
|
|
556
|
+
// ============================================================================
|
|
641
557
|
|
|
642
|
-
const
|
|
643
|
-
.command('
|
|
644
|
-
.description('
|
|
558
|
+
const node = program
|
|
559
|
+
.command('node')
|
|
560
|
+
.description('List, remove, reset or retry the nodes this host runs');
|
|
645
561
|
|
|
646
|
-
|
|
562
|
+
node
|
|
647
563
|
.command('list')
|
|
648
|
-
.description('List
|
|
564
|
+
.description('List hosted nodes: id, status, cadre, owner fingerprint, connected')
|
|
649
565
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
650
566
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
651
567
|
.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
568
|
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
700
|
-
const
|
|
701
|
-
|
|
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'})`);
|
|
569
|
+
const { nodes } = await callApi<{ nodes: HostedNodeLike[] }>(`${base}/api/hosted-nodes`, 'GET');
|
|
570
|
+
printNodeList(nodes);
|
|
797
571
|
process.exit(0);
|
|
798
572
|
});
|
|
799
573
|
|
|
800
|
-
|
|
801
|
-
.command('
|
|
802
|
-
.description('
|
|
574
|
+
node
|
|
575
|
+
.command('remove')
|
|
576
|
+
.description('Stop a hosted node and delete its data on this machine (its cadre keeps its row until the owner removes it there)')
|
|
577
|
+
.argument('<id>', 'Hosted node id (hn_…), as `cadre-host node list` shows it')
|
|
803
578
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
804
579
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
805
|
-
.action(async (opts: { port: string; host: string }) => {
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
}
|
|
580
|
+
.action(async (id: string, opts: { port: string; host: string }) => {
|
|
581
|
+
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
582
|
+
await callApi(`${base}/api/hosted-nodes/${encodeURIComponent(id)}`, 'DELETE', undefined, (error) => (error.code === 'not_found'
|
|
583
|
+
? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
|
|
584
|
+
: `cadre-host refused the removal: ${error.message} (${error.code})`));
|
|
585
|
+
console.log(`removed hosted node ${id}`);
|
|
830
586
|
process.exit(0);
|
|
831
587
|
});
|
|
832
588
|
|
|
833
|
-
|
|
834
|
-
.command('
|
|
835
|
-
.description('
|
|
836
|
-
.argument('<
|
|
837
|
-
.option('--
|
|
589
|
+
node
|
|
590
|
+
.command('reset')
|
|
591
|
+
.description('Remove a hosted node and start a fresh one with a new code (for a node someone else claimed, or one that failed)')
|
|
592
|
+
.argument('<id>', 'Hosted node id (hn_…) to replace')
|
|
593
|
+
.option('--no-qr', 'Print only the join text, no QR code')
|
|
594
|
+
.option('--no-wait', 'Exit once the code is shown instead of waiting for the claim')
|
|
838
595
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
839
596
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
840
|
-
.action(async (
|
|
597
|
+
.action(async (id: string, opts: JoinOptions) => {
|
|
841
598
|
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
599
|
+
const { node: fresh } = await callApi<{ node: HostedNodeLike }>(
|
|
600
|
+
`${base}/api/hosted-nodes/${encodeURIComponent(id)}/reset`, 'POST', {},
|
|
601
|
+
(error) => (error.code === 'not_found'
|
|
602
|
+
? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
|
|
603
|
+
: `cadre-host refused the reset: ${error.message} (${error.code})`),
|
|
604
|
+
);
|
|
605
|
+
console.error(`Removed hosted node ${id}.`);
|
|
606
|
+
await showClaimAndWait(base, fresh, opts);
|
|
849
607
|
process.exit(0);
|
|
850
608
|
});
|
|
851
609
|
|
|
852
|
-
|
|
853
|
-
.command('
|
|
854
|
-
.description('
|
|
855
|
-
.argument('<
|
|
610
|
+
node
|
|
611
|
+
.command('retry')
|
|
612
|
+
.description('Start an invitation node again after no member of its cadre could be reached, and wait until it joins or fails')
|
|
613
|
+
.argument('<id>', 'Hosted node id (hn_…) whose invitation failed')
|
|
614
|
+
.option('--no-wait', 'Exit once the node is started again instead of waiting for it to join')
|
|
856
615
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
857
616
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
858
|
-
.action(async (id: string, opts: { port: string; host: string }) => {
|
|
617
|
+
.action(async (id: string, opts: { wait?: boolean; port: string; host: string }) => {
|
|
859
618
|
const base = `http://${opts.host}:${resolvePort(opts.port)}`;
|
|
860
|
-
await
|
|
861
|
-
|
|
862
|
-
|
|
619
|
+
const { node: retried } = await callApi<{ node: HostedNodeLike }>(
|
|
620
|
+
`${base}/api/hosted-nodes/${encodeURIComponent(id)}/retry`, 'POST', {},
|
|
621
|
+
(error) => (error.code === 'not_found'
|
|
622
|
+
? `No hosted node with id "${id}". Run \`cadre-host node list\` to list them.`
|
|
623
|
+
: `cadre-host refused the retry: ${error.message} (${error.code})`),
|
|
624
|
+
);
|
|
625
|
+
process.exit(await followInvitationJoin(base, retried, opts));
|
|
863
626
|
});
|
|
864
627
|
|
|
865
|
-
|
|
866
|
-
|
|
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;
|
|
628
|
+
function sleep(ms: number): Promise<void> {
|
|
629
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
884
630
|
}
|
|
885
631
|
|
|
886
632
|
// ============================================================================
|
|
@@ -927,23 +673,18 @@ nat
|
|
|
927
673
|
|
|
928
674
|
nat
|
|
929
675
|
.command('settings')
|
|
930
|
-
.description('Update NAT settings (
|
|
931
|
-
.option('--
|
|
932
|
-
.option('--
|
|
933
|
-
.option('--no-upnp', 'Disable UPnP/NAT-PMP port mapping')
|
|
676
|
+
.description('Update NAT settings (UPnP toggle)')
|
|
677
|
+
.option('--upnp', 'Ask the router for port mappings over UPnP')
|
|
678
|
+
.option('--no-upnp', 'Stop asking the router for port mappings (manual forwards stay)')
|
|
934
679
|
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
935
680
|
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
936
681
|
.action(async (opts: {
|
|
937
|
-
externalPort?: string;
|
|
938
|
-
internalPort?: string;
|
|
939
682
|
upnp?: boolean;
|
|
940
683
|
port: string;
|
|
941
684
|
host: string;
|
|
942
685
|
}) => {
|
|
943
686
|
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
|
|
944
687
|
const patch: Record<string, unknown> = {};
|
|
945
|
-
if (opts.externalPort) patch.externalPort = resolvePort(opts.externalPort);
|
|
946
|
-
if (opts.internalPort) patch.internalPort = resolvePort(opts.internalPort);
|
|
947
688
|
// Commander assigns `upnp: false` when `--no-upnp` is passed.
|
|
948
689
|
if (opts.upnp === false) patch.upnpEnabled = false;
|
|
949
690
|
if (opts.upnp === true) patch.upnpEnabled = true;
|
|
@@ -953,6 +694,67 @@ nat
|
|
|
953
694
|
process.exit(0);
|
|
954
695
|
});
|
|
955
696
|
|
|
697
|
+
interface ForwardOptions {
|
|
698
|
+
tcp?: number;
|
|
699
|
+
ws?: number;
|
|
700
|
+
clearTcp?: boolean;
|
|
701
|
+
clearWs?: boolean;
|
|
702
|
+
clear?: boolean;
|
|
703
|
+
port: string;
|
|
704
|
+
host: string;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
nat
|
|
708
|
+
.command('forward')
|
|
709
|
+
.description('Tell cadre-host the external ports you forwarded on your router for one node')
|
|
710
|
+
.argument('<nodeId>', 'Node id as `cadre-host nat status` lists it')
|
|
711
|
+
.option('--tcp <port>', "External port your router forwards to the node's TCP port", parsePortArg)
|
|
712
|
+
.option('--ws <port>', "External port your router forwards to the node's WebSocket port", parsePortArg)
|
|
713
|
+
.option('--clear-tcp', 'Forget the TCP forward')
|
|
714
|
+
.option('--clear-ws', 'Forget the WebSocket forward')
|
|
715
|
+
.option('--clear', 'Forget both forwards')
|
|
716
|
+
.option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
|
|
717
|
+
.option('--host <host>', 'cadre-host management API host', '127.0.0.1')
|
|
718
|
+
.action(async (nodeId: string, opts: ForwardOptions) => {
|
|
719
|
+
const patch = forwardPatchFrom(opts);
|
|
720
|
+
const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/nodes/${encodeURIComponent(nodeId)}/forward`;
|
|
721
|
+
const body = await callJson<NatStatusLike>(url, 'PUT', patch, (error) => (error.code === 'unknown_node'
|
|
722
|
+
? `No node with id "${nodeId}" runs on this host. Run \`cadre-host nat status\` to list node ids.`
|
|
723
|
+
: `cadre-host refused the forward: ${error.message} (${error.code})`));
|
|
724
|
+
printForwardResult(nodeId, body);
|
|
725
|
+
process.exit(0);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
/** The route's patch: a port sets that forward, a clear flag sends `null`. Exits on conflicting or missing flags. */
|
|
729
|
+
function forwardPatchFrom(opts: ForwardOptions): ManualForwardPatch {
|
|
730
|
+
const patch: ManualForwardPatch = {};
|
|
731
|
+
const clears = { tcp: opts.clearTcp === true, ws: opts.clearWs === true };
|
|
732
|
+
for (const kind of ['tcp', 'ws'] as const) {
|
|
733
|
+
const value = opts[kind];
|
|
734
|
+
const clear = opts.clear === true || clears[kind];
|
|
735
|
+
if (value !== undefined && clear) {
|
|
736
|
+
console.error(`--${kind} conflicts with ${opts.clear ? '--clear' : `--clear-${kind}`}`);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
if (value !== undefined) patch[kind] = value;
|
|
740
|
+
else if (clear) patch[kind] = null;
|
|
741
|
+
}
|
|
742
|
+
if (Object.keys(patch).length === 0) {
|
|
743
|
+
console.error('Nothing to change: pass --tcp, --ws, --clear-tcp, --clear-ws or --clear.');
|
|
744
|
+
process.exit(1);
|
|
745
|
+
}
|
|
746
|
+
return patch;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Commander parser for an external port. */
|
|
750
|
+
function parsePortArg(raw: string): number {
|
|
751
|
+
const n = Number(raw);
|
|
752
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
753
|
+
throw new InvalidArgumentError('expected a whole number from 1 to 65535.');
|
|
754
|
+
}
|
|
755
|
+
return n;
|
|
756
|
+
}
|
|
757
|
+
|
|
956
758
|
const ddns = nat
|
|
957
759
|
.command('ddns')
|
|
958
760
|
.description('Configure dynamic DNS');
|
|
@@ -1035,8 +837,7 @@ ddns
|
|
|
1035
837
|
// do NOT go through the running management API). Private keys land in the OS
|
|
1036
838
|
// keychain (keytar) or the 0600 file-store fallback; the non-secret bits (APNs
|
|
1037
839
|
// bundle id / sandbox toggle, cooldown/debounce) land in host.config.json. New
|
|
1038
|
-
// credentials
|
|
1039
|
-
// `cadre-host` restart (or restart the service) to apply them immediately.
|
|
840
|
+
// credentials reach a node the next time it is spawned.
|
|
1040
841
|
|
|
1041
842
|
const push = program
|
|
1042
843
|
.command('push')
|
|
@@ -1065,7 +866,7 @@ push
|
|
|
1065
866
|
clientEmail: opts.clientEmail,
|
|
1066
867
|
privateKey,
|
|
1067
868
|
});
|
|
1068
|
-
console.log('✓ FCM credentials stored.
|
|
869
|
+
console.log('✓ FCM credentials stored. A node picks them up the next time it is spawned.');
|
|
1069
870
|
process.exit(0);
|
|
1070
871
|
});
|
|
1071
872
|
|
|
@@ -1102,7 +903,7 @@ push
|
|
|
1102
903
|
});
|
|
1103
904
|
console.log(
|
|
1104
905
|
`✓ APNs credentials stored (${opts.production ? 'production' : 'sandbox'}). ` +
|
|
1105
|
-
`
|
|
906
|
+
`A node picks them up the next time it is spawned.`,
|
|
1106
907
|
);
|
|
1107
908
|
process.exit(0);
|
|
1108
909
|
});
|
|
@@ -1146,7 +947,7 @@ push
|
|
|
1146
947
|
updateHostConfig(cfgPath, { push: rest });
|
|
1147
948
|
}
|
|
1148
949
|
}
|
|
1149
|
-
console.log(`✓ Cleared push credentials: ${target}.
|
|
950
|
+
console.log(`✓ Cleared push credentials: ${target}. A node stops carrying them the next time it is spawned.`);
|
|
1150
951
|
process.exit(0);
|
|
1151
952
|
});
|
|
1152
953
|
|
|
@@ -1200,46 +1001,77 @@ function readPrivateKeyArg(file: string | undefined, inline: string | undefined,
|
|
|
1200
1001
|
}
|
|
1201
1002
|
|
|
1202
1003
|
async function getJson(url: string): Promise<NatStatusLike> {
|
|
1203
|
-
return await callJson(url, 'GET');
|
|
1004
|
+
return await callJson<NatStatusLike>(url, 'GET');
|
|
1204
1005
|
}
|
|
1205
1006
|
|
|
1206
1007
|
async function postJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1207
|
-
return await callJson(url, 'POST', body);
|
|
1008
|
+
return await callJson<NatStatusLike>(url, 'POST', body);
|
|
1208
1009
|
}
|
|
1209
1010
|
|
|
1210
1011
|
async function putJson(url: string, body: unknown): Promise<NatStatusLike> {
|
|
1211
|
-
return await callJson(url, 'PUT', body);
|
|
1012
|
+
return await callJson<NatStatusLike>(url, 'PUT', body);
|
|
1212
1013
|
}
|
|
1213
1014
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
};
|
|
1015
|
+
/** The `{ code, message }` of a management-API error response. */
|
|
1016
|
+
interface ApiErrorBody {
|
|
1017
|
+
code: string;
|
|
1018
|
+
message: string;
|
|
1232
1019
|
}
|
|
1233
1020
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1021
|
+
/** The `/api/*` routes' success envelope. */
|
|
1022
|
+
interface ApiEnvelope<T> {
|
|
1023
|
+
ok: true;
|
|
1024
|
+
data: T;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* One request to an `/api/*` route, unwrapping its `{ ok, data }` envelope.
|
|
1029
|
+
* Exits on failure as `callJson` does; a 204 answers `undefined`.
|
|
1030
|
+
*/
|
|
1031
|
+
async function callApi<T = undefined>(
|
|
1032
|
+
url: string,
|
|
1033
|
+
method: string,
|
|
1034
|
+
body?: unknown,
|
|
1035
|
+
describeError?: (error: ApiErrorBody) => string,
|
|
1036
|
+
): Promise<T> {
|
|
1037
|
+
const envelope = await callJson<ApiEnvelope<T> | undefined>(url, method, body, describeError);
|
|
1038
|
+
return envelope?.data as T;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* One request to the management API, exiting on failure: 2 when cadre-host is
|
|
1043
|
+
* unreachable, 1 on a non-OK response, printed by `describeError` when the
|
|
1044
|
+
* response carries a typed error and the caller has words for it.
|
|
1045
|
+
*/
|
|
1046
|
+
async function callJson<T>(
|
|
1047
|
+
url: string,
|
|
1048
|
+
method: string,
|
|
1049
|
+
body?: unknown,
|
|
1050
|
+
describeError?: (error: ApiErrorBody) => string,
|
|
1051
|
+
): Promise<T> {
|
|
1052
|
+
const init: RequestInit = { method };
|
|
1053
|
+
if (body !== undefined) {
|
|
1054
|
+
init.headers = { 'content-type': 'application/json' };
|
|
1055
|
+
init.body = JSON.stringify(body);
|
|
1056
|
+
}
|
|
1057
|
+
const response = await fetchOrExit(url, init);
|
|
1058
|
+
if (!response.ok) {
|
|
1059
|
+
const text = await response.text().catch(() => '');
|
|
1060
|
+
const error = describeError ? apiErrorOf(response, text) : null;
|
|
1061
|
+
console.error(error && describeError
|
|
1062
|
+
? describeError(error)
|
|
1063
|
+
: `cadre-host returned ${response.status}: ${text || response.statusText}`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
throw new Error('non-ok');
|
|
1066
|
+
}
|
|
1067
|
+
if (response.status === 204) return undefined as T;
|
|
1068
|
+
return await response.json() as T;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** `fetch`, exiting 2 with a hint when cadre-host cannot be reached at all. */
|
|
1072
|
+
async function fetchOrExit(url: string, init: RequestInit): Promise<Response> {
|
|
1236
1073
|
try {
|
|
1237
|
-
|
|
1238
|
-
if (body !== undefined) {
|
|
1239
|
-
init.headers = { 'content-type': 'application/json' };
|
|
1240
|
-
init.body = JSON.stringify(body);
|
|
1241
|
-
}
|
|
1242
|
-
response = await fetch(url, init);
|
|
1074
|
+
return await fetch(url, init);
|
|
1243
1075
|
} catch (err) {
|
|
1244
1076
|
console.error(
|
|
1245
1077
|
`Failed to reach cadre-host at ${url}: ${(err as Error).message}\n` +
|
|
@@ -1248,38 +1080,21 @@ async function callJson(url: string, method: string, body?: unknown): Promise<Na
|
|
|
1248
1080
|
process.exit(2);
|
|
1249
1081
|
throw err;
|
|
1250
1082
|
}
|
|
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
1083
|
}
|
|
1260
1084
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
if (
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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');
|
|
1085
|
+
/** The typed error in a `{ ok: false, error: { code, message } }` body (`server/error-handler.ts`), or null. */
|
|
1086
|
+
function apiErrorOf(response: Response, text: string): ApiErrorBody | null {
|
|
1087
|
+
if (!response.headers.get('content-type')?.includes('application/json')) return null;
|
|
1088
|
+
let parsed: { error?: { code?: unknown; message?: unknown } };
|
|
1089
|
+
try {
|
|
1090
|
+
parsed = JSON.parse(text) as typeof parsed;
|
|
1091
|
+
} catch {
|
|
1092
|
+
// Malformed JSON: the caller prints the raw text instead.
|
|
1093
|
+
return null;
|
|
1282
1094
|
}
|
|
1095
|
+
const { code, message } = parsed.error ?? {};
|
|
1096
|
+
if (typeof code !== 'string') return null;
|
|
1097
|
+
return { code, message: typeof message === 'string' ? message : '' };
|
|
1283
1098
|
}
|
|
1284
1099
|
|
|
1285
1100
|
/** Read a single line from stdin with echo suppressed (TTY only). */
|