@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
@@ -3,11 +3,10 @@
3
3
  * `HostedNodeService.claimDetails` reads the child's `/status` and hands it here.
4
4
  */
5
5
 
6
- import { multiaddr, CODE_IP4, CODE_IP6, CODE_P2P_CIRCUIT } from '@multiformats/multiaddr';
6
+ import { encodeNodeClaimPayload, selectNodeClaimAddresses, withClaimDestination } from '@serfab/cadre-core';
7
7
 
8
- import { encodeNodeClaimPayload } from '@serfab/cadre-core';
9
-
10
- import type { NodeReachability } from '../nat/types.js';
8
+ import type { ClaimAddressSettings } from '../installer/config.js';
9
+ import type { NetworkMode, NodeReachability } from '../nat/types.js';
11
10
  import type { NodeStatus } from './node-status.js';
12
11
  import { HostedNodeError } from './types.js';
13
12
 
@@ -16,17 +15,21 @@ export interface ClaimDetails {
16
15
  /** `encodeNodeClaimPayload` of the three fields below — the text the QR code carries. */
17
16
  payload: string;
18
17
  peerId: string;
19
- /** Public addresses first (TCP, then WebSocket), then the node's own non-loopback LAN addresses. */
18
+ /** Public names (or public IPs when there is no name), then the one LAN address; see {@link dialAddressesFor}. */
20
19
  multiaddrs: string[];
21
20
  /** The NAT layer's verdict for the node, or null when it has no entry yet. A phone at home reaches the LAN address either way. */
22
21
  reachability: NodeReachability | null;
23
22
  }
24
23
 
25
- /** The NAT layer's two answers the claim details need. `NatService` satisfies this. */
24
+ /** The NAT layer's answers the claim details need. `NatService` satisfies this. */
26
25
  export interface HostedNodeAddressSource {
27
26
  /** The node's public multiaddrs (no `/p2p/` suffix), TCP then WebSocket. */
28
27
  publicAddressesFor(nodeId: string, ports: { p2p: number; ws?: number }): string[];
29
- getStatus(): { nodes: NodeReachability[] };
28
+ /**
29
+ * `gateway.lanAddress` is this machine's address on the router's subnet, when a UPnP gateway
30
+ * answered; `network.mode` is whether it sits behind a router (`lan`) or holds a public IPv4.
31
+ */
32
+ getStatus(): { nodes: NodeReachability[]; gateway: { lanAddress: string | null }; network: { mode: NetworkMode } };
30
33
  }
31
34
 
32
35
  /** One node's claim details, from its record's secret, its `/status`, and its ports. */
@@ -37,6 +40,10 @@ export interface ClaimDetailsInput {
37
40
  /** The orchestrator handle's ports; absent, the code carries LAN addresses only. */
38
41
  ports: { p2p: number; ws?: number } | undefined;
39
42
  addresses: HostedNodeAddressSource;
43
+ /** `host.config.json`'s `claimAddresses.addrs`: exact addresses replacing the automatic choice. */
44
+ exactAddrs?: readonly string[];
45
+ /** The LAN address to keep, as {@link claimLanAddress} resolved it. */
46
+ lan: string | null | undefined;
40
47
  }
41
48
 
42
49
  /**
@@ -58,39 +65,39 @@ export function buildClaimDetails(input: ClaimDetailsInput): ClaimDetails {
58
65
  }
59
66
 
60
67
  /**
61
- * The addresses the QR code names, in dial order: the node's public addresses
62
- * from the NAT layer with the peer id appended (a phone away from home needs
63
- * these), then the node's own non-loopback addresses as `/status` reports them
64
- * (they already carry `/p2p/`; a phone at home dials these). Loopback and relay
65
- * entries are dropped — nothing off this machine can use the former, and no
66
- * hosted node holds a relay reservation today.
68
+ * The LAN address a node code keeps (`selectNodeClaimAddresses`' `lan`), from `claimAddresses.lan`:
69
+ * `'none'` is null; an IP is itself; `'auto'` (default) is null in public-IP mode (no phone shares
70
+ * a LAN with a VPS), else the gateway's `lanAddress`, else the machine's primary address
71
+ * (`primaryLan`, probed only then), else undefined (keep every private address).
67
72
  */
68
- function dialAddressesFor({ id, status, ports, addresses }: ClaimDetailsInput): string[] {
69
- const publicAddrs = ports
70
- ? addresses.publicAddressesFor(id, ports).map((a) => `${a}/p2p/${status.peerId}`)
71
- : [];
72
- const lanAddrs = status.multiaddrs.filter((a) => isDialableFromLan(a));
73
- return [...new Set([...publicAddrs, ...lanAddrs])];
73
+ export async function claimLanAddress(
74
+ settings: ClaimAddressSettings | undefined,
75
+ addresses: HostedNodeAddressSource,
76
+ primaryLan: () => Promise<string | undefined>,
77
+ ): Promise<string | null | undefined> {
78
+ const lan = settings?.lan ?? 'auto';
79
+ if (lan === 'none') return null;
80
+ if (lan !== 'auto') return lan;
81
+ const { network, gateway } = addresses.getStatus();
82
+ if (network.mode === 'public') return null;
83
+ return gateway.lanAddress ?? await primaryLan();
74
84
  }
75
85
 
76
86
  /**
77
- * Whether an address the node reports is one a phone on the host's LAN can dial:
78
- * not loopback, not the unspecified address (libp2p expands a `0.0.0.0` listen into
79
- * interface addresses, but a raw one names nothing to dial), not a relay circuit,
80
- * and parsable. A DNS address passes (it names something off this machine); an
81
- * address with no host component at all is dropped.
87
+ * The addresses the QR code names, in dial order. A phone dials each in turn on its own
88
+ * timeout, so every address it cannot reach delays a failing claim and enlarges the code.
89
+ *
90
+ * - With `exactAddrs`, exactly those, each given the node's peer id.
91
+ * - Otherwise cadre-core's `selectNodeClaimAddresses` over the NAT layer's public addresses
92
+ * and the node's own (`/status`): WebSocket and relay only (a phone has no TCP), public
93
+ * names before raw public IPs, and of the private addresses only `lan`. Docker bridges,
94
+ * VPNs and other interfaces a phone cannot reach are left out.
82
95
  */
83
- function isDialableFromLan(addr: string): boolean {
84
- let components: ReturnType<ReturnType<typeof multiaddr>['getComponents']>;
85
- try {
86
- components = multiaddr(addr).getComponents();
87
- } catch {
88
- return false;
89
- }
90
- if (components.some((c) => c.code === CODE_P2P_CIRCUIT)) return false;
91
- const host = components[0];
92
- if (!host?.value) return false;
93
- if (host.code === CODE_IP4) return !host.value.startsWith('127.') && host.value !== '0.0.0.0';
94
- if (host.code === CODE_IP6) return host.value !== '::1' && host.value !== '::';
95
- return true;
96
+ function dialAddressesFor({ id, status, ports, addresses, exactAddrs, lan }: ClaimDetailsInput): string[] {
97
+ // NOTE: `claimAddresses.addrs` is host-wide, so every hosted node's code carries the same
98
+ // addresses with its own peer id; that reaches each node only if they route per node (one
99
+ // node, or a port per node). If several nodes behind one proxy is reported, make it per node.
100
+ if (exactAddrs && exactAddrs.length > 0) return [...new Set(exactAddrs.map((a) => withClaimDestination(a, status.peerId)))];
101
+ const publicAddrs = ports ? addresses.publicAddressesFor(id, ports) : [];
102
+ return selectNodeClaimAddresses([...publicAddrs, ...status.multiaddrs], status.peerId, { lan });
96
103
  }
@@ -2,10 +2,12 @@ import { randomBytes } from 'node:crypto';
2
2
  import debug from 'debug';
3
3
 
4
4
  import { CLAIM_SECRET_BYTES, decodeCadreInvitation, type CadreInvitation } from '@serfab/cadre-core';
5
+ import { primaryLanAddress } from '@serfab/cadre-core/primary-lan-address';
5
6
  import type { Orchestrator, OrchestratorCreateResult } from '@serfab/cadre-provider';
6
7
 
7
8
  import type { HostedSpawnRequest, ManagedNodeInfo, NodeStateListener } from '../orchestrator/types.js';
8
- import { buildClaimDetails, type ClaimDetails, type HostedNodeAddressSource } from './claim-details.js';
9
+ import type { ClaimAddressSettings } from '../installer/config.js';
10
+ import { buildClaimDetails, claimLanAddress, type ClaimDetails, type HostedNodeAddressSource } from './claim-details.js';
9
11
  import type { HostedNodeStore } from './hosted-node-store.js';
10
12
  import { HostedNodeWatcher } from './hosted-node-watcher.js';
11
13
  import { PLACEHOLDER_PARTY, errorMessage, readNodeStatus, statusUrlFor } from './node-status.js';
@@ -130,6 +132,10 @@ export interface HostedNodeServiceOptions {
130
132
  store: HostedNodeStore;
131
133
  /** The NAT layer, for the node's public addresses and reachability verdict. */
132
134
  addresses: HostedNodeAddressSource;
135
+ /** `host.config.json`'s `claimAddresses`, read on every claim-code request so an edit applies at once. */
136
+ claimAddressSettings?: () => ClaimAddressSettings | undefined;
137
+ /** The machine's primary address, the `lan: 'auto'` fallback when no UPnP gateway answered. */
138
+ primaryLan?: () => Promise<string | undefined>;
133
139
  /** Clock override for tests. */
134
140
  now?: () => Date;
135
141
  }
@@ -159,6 +165,8 @@ export class HostedNodeService {
159
165
  private readonly orchestrator: HostedNodeOrchestrator;
160
166
  private readonly store: HostedNodeStore;
161
167
  private readonly addresses: HostedNodeAddressSource;
168
+ private readonly claimAddressSettings: () => ClaimAddressSettings | undefined;
169
+ private readonly primaryLan: () => Promise<string | undefined>;
162
170
  private readonly now: () => Date;
163
171
  private readonly listeners = new Set<HostedNodeChangeListener>();
164
172
  private readonly watcher: HostedNodeWatcher;
@@ -171,6 +179,8 @@ export class HostedNodeService {
171
179
  this.orchestrator = opts.orchestrator;
172
180
  this.store = opts.store;
173
181
  this.addresses = opts.addresses;
182
+ this.claimAddressSettings = opts.claimAddressSettings ?? (() => undefined);
183
+ this.primaryLan = opts.primaryLan ?? primaryLanAddress;
174
184
  this.now = opts.now ?? (() => new Date());
175
185
  this.watcher = new HostedNodeWatcher({
176
186
  store: this.store,
@@ -335,7 +345,17 @@ export class HostedNodeService {
335
345
  const { secret } = node.join;
336
346
  const status = await readNodeStatus(node.statusEndpoint);
337
347
  this.recordPeerId(id, status.peerId);
338
- return buildClaimDetails({ id, secret, status, ports: this.orchestrator.getNode?.(id)?.ports, addresses: this.addresses });
348
+ const settings = this.claimAddressSettings();
349
+ const exactAddrs = settings?.addrs ?? [];
350
+ return buildClaimDetails({
351
+ id,
352
+ secret,
353
+ status,
354
+ ports: this.orchestrator.getNode?.(id)?.ports,
355
+ addresses: this.addresses,
356
+ exactAddrs,
357
+ lan: exactAddrs.length > 0 ? undefined : await claimLanAddress(settings, this.addresses, this.primaryLan),
358
+ });
339
359
  }
340
360
 
341
361
  /** Cache the peer id on the record, once. Best-effort: the claim details do not depend on it. */
@@ -767,14 +787,13 @@ function spawnRequestFor(node: HostedNode): HostedSpawnRequest {
767
787
  const base: HostedSpawnRequest = {
768
788
  containerId: node.id,
769
789
  partyId: node.partyId,
770
- bootstrapNodes: [],
771
790
  profile: node.profile,
772
791
  };
773
792
  if (node.join.kind === 'claim') return { ...base, claimSecret: node.join.secret };
774
793
  return node.status === 'joined' ? base : { ...base, invitation: node.join.encoded };
775
794
  }
776
795
 
777
- /** The record fields a spawn result lands. `seedToken` and `seedEndpoint` are the provider's shape; cadre-host ignores both. */
796
+ /** The record fields a spawn result lands. */
778
797
  function handlesOf(result: OrchestratorCreateResult): SpawnedHandles {
779
798
  return { dockerId: result.dockerId, statusEndpoint: statusUrlFor(result.healthEndpoint) };
780
799
  }