@serfab/cadre-host 1.14.0 → 1.15.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.
Files changed (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
package/src/bin/host.ts CHANGED
@@ -18,12 +18,13 @@
18
18
  */
19
19
 
20
20
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
21
- import { fileURLToPath, pathToFileURL } from 'node:url';
22
- import { dirname, join, resolve } from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+ import { join } from 'node:path';
23
23
 
24
24
  import { Command, InvalidArgumentError } from 'commander';
25
25
 
26
26
  import { Installer } from '../installer/index.js';
27
+ import { packageVersion } from '../package-version.js';
27
28
  import { readHostConfig, updateHostConfig } from '../installer/config.js';
28
29
  import {
29
30
  configPath as resolveConfigPath,
@@ -42,7 +43,7 @@ import {
42
43
  HOSTED_NODE_REAP_SWEEP_MS,
43
44
  HOSTED_NODE_SPAWNING_TTL_MS,
44
45
  } from '../hosted/index.js';
45
- import { NatService } from '../nat/index.js';
46
+ import { effectiveNetworkMode, NatService, NatStore, publicInterfaceAddress } from '../nat/index.js';
46
47
  import type { ManualForwardPatch } from '../nat/types.js';
47
48
  import { createSecretsStore } from '../nat/secrets/index.js';
48
49
  import {
@@ -55,6 +56,7 @@ import {
55
56
  import { createLocalUiServer, HostSettingsStore } from '../server/index.js';
56
57
  import { openBrowser } from '../installer/browser.js';
57
58
  import { printForwardResult, printNatStatus, type NatStatusLike } from './nat-output.js';
59
+ import { isHeadless, uiAccessLines } from './ui-access.js';
58
60
  import {
59
61
  printClaimPayload,
60
62
  printClaimed,
@@ -80,7 +82,7 @@ const program = new Command();
80
82
  program
81
83
  .name('cadre-host')
82
84
  .description('Sereus cadre node manager for self-hosted basement-PC deployments')
83
- .version(readPackageVersion());
85
+ .version(packageVersion);
84
86
 
85
87
  // ============================================================================
86
88
  // install — run the first-run wizard + register the system service
@@ -114,7 +116,8 @@ program
114
116
  ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
115
117
  ...(typeof opts.uiPort === 'number' ? { uiPort: opts.uiPort } : {}),
116
118
  noUpnp: opts.upnp === false,
117
- openBrowser: opts.browser !== false,
119
+ // Headless, a browser would open nowhere the admin can see; the `ssh -L` lines below say how instead.
120
+ openBrowser: opts.browser !== false && !isHeadless(),
118
121
  system: Boolean(opts.system),
119
122
  ...(opts.nodePath ? { nodePath: opts.nodePath } : {}),
120
123
  noService: opts.service === false,
@@ -128,6 +131,15 @@ program
128
131
  console.log(' Service: not registered (--no-service)');
129
132
  console.log(` Run the host: cadre-host start --data-dir "${result.dataDir}"`);
130
133
  }
134
+ // A re-install keeps the `network` setting an earlier one recorded in nat.json.
135
+ const publicIp = publicInterfaceAddress();
136
+ const mode = effectiveNetworkMode(new NatStore(result.dataDir).load().network, publicIp);
137
+ console.log(mode === 'public'
138
+ ? ` Network: public IP${publicIp ? ` ${publicIp}` : ''}: nodes are reached directly. Allow their ports through your firewall (the UI lists them).`
139
+ : ' Network: behind a router: nodes need UPnP or a port forward (the UI shows which).');
140
+ console.log('');
141
+ for (const line of uiAccessLines(Number(new URL(result.uiUrl).port), isHeadless())) console.log(line);
142
+ console.log('Everything else (a public name, joining a cadre, ports) is in the UI.');
131
143
  process.exit(0);
132
144
  } catch (err) {
133
145
  console.error(`install failed: ${(err as Error).message}`);
@@ -233,7 +245,7 @@ program
233
245
  const serviceHost = createServiceHost(platform);
234
246
  const updateService = new UpdateService({
235
247
  dataDir: cfg.dataDir,
236
- currentVersion: readPackageVersion(),
248
+ currentVersion: packageVersion,
237
249
  settings: cfg.updates,
238
250
  ...(cfg.updates.manifestUrl ? { manifestUrl: cfg.updates.manifestUrl } : {}),
239
251
  restart: async () => {
@@ -304,6 +316,8 @@ program
304
316
  orchestrator,
305
317
  store: hostedNodeStore,
306
318
  addresses: natService,
319
+ // Re-read per claim code so a Settings edit (or a hand edit) applies to the next code shown.
320
+ claimAddressSettings: () => readHostConfig(cfgPath).claimAddresses,
307
321
  });
308
322
 
309
323
  // Respawn supervision — nothing else brings a hosted node back. A crash,
@@ -389,6 +403,12 @@ program
389
403
  }
390
404
  const cfg = readHostConfig(cfgPath);
391
405
  const url = `http://127.0.0.1:${cfg.uiPort}`;
406
+ if (isHeadless()) {
407
+ // A browser here would open nowhere the admin can see: say how to reach it instead.
408
+ for (const line of uiAccessLines(cfg.uiPort, true)) console.log(line);
409
+ process.exit(0);
410
+ return;
411
+ }
392
412
  console.log(url);
393
413
  if (opts.browser !== false) {
394
414
  const res = openBrowser(url);
@@ -413,19 +433,6 @@ function parseIntArg(raw: string): number {
413
433
  return n;
414
434
  }
415
435
 
416
- function readPackageVersion(): string {
417
- try {
418
- // dist/bin/host.js -> ../../package.json
419
- const here = dirname(fileURLToPath(import.meta.url));
420
- const pkgPath = resolve(here, '..', '..', 'package.json');
421
- const raw = readFileSync(pkgPath, 'utf8');
422
- const parsed = JSON.parse(raw) as { version?: string };
423
- return parsed.version ?? '0.0.0-unknown';
424
- } catch {
425
- return '0.0.0-unknown';
426
- }
427
- }
428
-
429
436
  function waitForTermination(): Promise<void> {
430
437
  return new Promise<void>((resolveTerm) => {
431
438
  const done = () => resolveTerm();
@@ -673,18 +680,21 @@ nat
673
680
 
674
681
  nat
675
682
  .command('settings')
676
- .description('Update NAT settings (UPnP toggle)')
683
+ .description('Update NAT settings (network mode, UPnP toggle)')
684
+ .option('--network <mode>', 'auto (default: public when this machine holds a public IPv4), lan (behind a router) or public (reached directly, e.g. a VPS)')
677
685
  .option('--upnp', 'Ask the router for port mappings over UPnP')
678
686
  .option('--no-upnp', 'Stop asking the router for port mappings (manual forwards stay)')
679
687
  .option('--port <port>', 'cadre-host management API port', String(DEFAULT_PORT))
680
688
  .option('--host <host>', 'cadre-host management API host', '127.0.0.1')
681
689
  .action(async (opts: {
690
+ network?: string;
682
691
  upnp?: boolean;
683
692
  port: string;
684
693
  host: string;
685
694
  }) => {
686
695
  const url = `http://${opts.host}:${resolvePort(opts.port)}/nat/settings`;
687
696
  const patch: Record<string, unknown> = {};
697
+ if (opts.network !== undefined) patch.network = opts.network;
688
698
  // Commander assigns `upnp: false` when `--no-upnp` is passed.
689
699
  if (opts.upnp === false) patch.upnpEnabled = false;
690
700
  if (opts.upnp === true) patch.upnpEnabled = true;
@@ -26,6 +26,7 @@ export interface NatNodeLike {
26
26
  }
27
27
 
28
28
  export interface NatStatusLike {
29
+ network?: { setting?: string; mode?: string; publicInterfaceIp?: string | null };
29
30
  upnpEnabled?: boolean;
30
31
  gateway?: {
31
32
  found?: boolean;
@@ -85,6 +86,15 @@ export function printForwardResult(nodeId: string, s: NatStatusLike): void {
85
86
  }
86
87
 
87
88
  function printHostLines(s: NatStatusLike): void {
89
+ const network = s.network ?? {};
90
+ const how = network.setting === 'auto' || network.setting === undefined ? 'detected' : 'set';
91
+ if (network.mode === 'public') {
92
+ console.log(`Network: public IP ${network.publicInterfaceIp ?? s.externalIp ?? '?'} on this machine (${how}); no router, so no UPnP`);
93
+ console.log(`External IP: ${s.externalIp ?? network.publicInterfaceIp ?? '(unknown)'}`);
94
+ console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
95
+ return;
96
+ }
97
+ console.log(`Network: behind a router (${how})`);
88
98
  const gateway = s.gateway ?? {};
89
99
  const gatewayLine = gateway.found
90
100
  ? `found (this machine is ${gateway.lanAddress ?? '?'} on its network)`
@@ -115,7 +125,7 @@ function printNodeReachability(n: NatNodeLike, s: NatStatusLike): void {
115
125
  printField('TCP', [formatRoute(n.tcp)]);
116
126
  printField('WebSocket', [n.ws ? formatRoute(n.ws) : 'not available']);
117
127
  printField('Public', n.publicAddrs?.length ? n.publicAddrs : ['none']);
118
- if (n.reason) printField('Why', [n.reason]);
128
+ if (n.reason) printField(n.verdict === 'direct' ? 'Firewall' : 'Why', [n.reason]);
119
129
  const advice = forwardAdvice(n, s);
120
130
  if (advice.length > 0) printField('To fix', advice);
121
131
  }
@@ -129,6 +139,7 @@ function formatRoute(r: NatPortRouteLike | undefined): string {
129
139
  const internal = `internal ${r.internalPort ?? '?'}`;
130
140
  if (r.externalPort == null) return `${internal} → not mapped${r.error ? ` (${r.error})` : ''}`;
131
141
  const failed = r.error ? `; last attempt failed: ${r.error}` : '';
142
+ if (r.source === 'direct') return `${internal} → public, as-is${failed}`;
132
143
  return `${internal} → external ${r.externalPort} (${r.source === 'manual' ? 'forwarded by hand' : 'UPnP'}${failed})`;
133
144
  }
134
145
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How to reach the local UI from where the admin is sitting. On a machine with a desktop, open
3
+ * the URL; over SSH or on a headless server, forward the port from the admin's own computer
4
+ * first. The UI binds 127.0.0.1 only and checks the Host header, so the local port must equal
5
+ * the remote one.
6
+ */
7
+
8
+ import { hostname, userInfo } from 'node:os';
9
+
10
+ /** Over SSH, or Linux with no display server: a browser here would show nothing to the admin. */
11
+ export function isHeadless(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): boolean {
12
+ if (env.SSH_CONNECTION || env.SSH_TTY) return true;
13
+ return platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY;
14
+ }
15
+
16
+ /** Who to SSH in as, and where; `sshPort` only when it is not 22. */
17
+ export interface SshLogin {
18
+ user: string;
19
+ host: string;
20
+ sshPort?: number;
21
+ }
22
+
23
+ /** The lines telling the admin how to open the UI at `port`. */
24
+ export function uiAccessLines(port: number, headless: boolean, who: SshLogin = currentLogin()): string[] {
25
+ const url = `http://127.0.0.1:${port}`;
26
+ if (!headless) return [`Open the UI: ${url}`];
27
+ const portFlag = who.sshPort !== undefined && who.sshPort !== 22 ? ` -p ${who.sshPort}` : '';
28
+ return [
29
+ 'Open the UI from your own computer (this session has no screen):',
30
+ ` ssh${portFlag} -L ${port}:127.0.0.1:${port} ${who.user}@${who.host}`,
31
+ ` then browse to ${url}`,
32
+ ];
33
+ }
34
+
35
+ /**
36
+ * Over SSH, the server address and port the admin's client connected to (`SSH_CONNECTION` is
37
+ * "client-ip client-port server-ip server-port"): a VPS's hostname rarely resolves from the
38
+ * admin's computer, the address they just reached does. Otherwise this machine's hostname.
39
+ */
40
+ export function currentLogin(env: NodeJS.ProcessEnv = process.env): SshLogin {
41
+ let user = '<you>';
42
+ try {
43
+ user = userInfo().username;
44
+ } catch {
45
+ // no passwd entry (some containers): keep the placeholder
46
+ }
47
+ const [, , serverIp, serverPort] = (env.SSH_CONNECTION ?? '').trim().split(/\s+/);
48
+ if (serverIp && serverPort) return { user, host: serverIp, sshPort: Number(serverPort) };
49
+ return { user, host: hostname() };
50
+ }
@@ -23,7 +23,6 @@ import { FakeOrchestrator } from './fake-orchestrator.js';
23
23
  const request = (containerId: string): HostedSpawnRequest => ({
24
24
  containerId,
25
25
  partyId: 'party-P',
26
- bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
27
26
  profile: 'storage',
28
27
  });
29
28