@serfab/cadre-host 1.14.0 → 1.16.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
@@ -9,6 +9,7 @@ import {
9
9
  type NatHandlers,
10
10
  type NatSettingsFile,
11
11
  type NatStatusSnapshot,
12
+ type NetworkMode,
12
13
  type NodeReachability,
13
14
  type PortKind,
14
15
  type PortRoute,
@@ -26,6 +27,7 @@ import { evaluateHostReachability, evaluateNodeReachability } from './reachabili
26
27
  import { DdnsUpdater } from './ddns/updater.js';
27
28
  import { getProvider, listProviders } from './ddns/index.js';
28
29
  import { buildPublicAddresses, isPublicIpv4 } from './address-resolver.js';
30
+ import { effectiveNetworkMode, publicInterfaceAddress } from './network-mode.js';
29
31
  import { createSecretsStore, ddnsAccount, type SecretsStore } from './secrets/index.js';
30
32
  import { AddressWatch, type NodeAddressesStaleListener } from './address-watch.js';
31
33
  import type { ManagedNodeInfo, NodeStateListener } from '../orchestrator/types.js';
@@ -80,6 +82,8 @@ export interface NatServiceOptions {
80
82
  store?: NatStore;
81
83
  /** Lease TTL asked of the router. Default 1 h. */
82
84
  leaseTtlMs?: number;
85
+ /** Test-only: stub the interface read behind `network: auto`. Default: `publicInterfaceAddress()`. */
86
+ publicInterfaceAddress?: () => string | null;
83
87
  }
84
88
 
85
89
  /** A node's two mapped ports — `tcp` is `NodePorts.p2p`, `ws` is `NodePorts.ws`. */
@@ -105,6 +109,11 @@ function noRoute(internalPort: number): PortRoute {
105
109
  return { internalPort, externalPort: null, source: null, leaseExpiresAt: null, error: null };
106
110
  }
107
111
 
112
+ /** Public mode: the port is reached at its own number, with nothing to map. */
113
+ function directRoute(internalPort: number): PortRoute {
114
+ return { internalPort, externalPort: internalPort, source: 'direct', leaseExpiresAt: null, error: null };
115
+ }
116
+
108
117
  function manualRoute(internalPort: number, externalPort: number): PortRoute {
109
118
  return { internalPort, externalPort, source: 'manual', leaseExpiresAt: null, error: null };
110
119
  }
@@ -168,6 +177,9 @@ export class NatService {
168
177
 
169
178
  private currentSettings: NatSettingsFile;
170
179
  private gateway: NatGatewayStatus = { found: false, lanAddress: null, routerExternalIp: null, lastError: null };
180
+ /** The public IPv4 an interface holds (`publicInterfaceAddress`), re-read at start and on every probe. */
181
+ private publicInterfaceIp: string | null = null;
182
+ private readonly readInterfaces: () => string | null;
171
183
  private latestIp: ExternalIpResult | null = null;
172
184
  private lastTestedAt: Date | null = null;
173
185
  private readonly table = new Map<string, NodeEntry>();
@@ -193,6 +205,7 @@ export class NatService {
193
205
  this.secretsStore = opts.secretsStore ?? null;
194
206
  this.injectedDetector = opts.externalIpDetector ?? null;
195
207
  this.secretsRootDir = opts.rootDir;
208
+ this.readInterfaces = opts.publicInterfaceAddress ?? (() => publicInterfaceAddress());
196
209
  this.currentSettings = this.store.load();
197
210
  this.addressWatch = new AddressWatch(() => this.nowFn().getTime());
198
211
  }
@@ -211,7 +224,9 @@ export class NatService {
211
224
  if (!this.secretsStore) {
212
225
  this.secretsStore = await createSecretsStore(this.secretsRootDir);
213
226
  }
214
- if (this.currentSettings.upnpEnabled) await this.discoverGateway();
227
+ this.publicInterfaceIp = this.readInterfaces();
228
+ log('network mode %s (setting %s, public interface address %s)', this.networkMode(), this.currentSettings.network, this.publicInterfaceIp ?? 'none');
229
+ if (this.usesUpnp()) await this.discoverGateway();
215
230
  await this.detectIp();
216
231
 
217
232
  this.ddnsUpdater = new DdnsUpdater({
@@ -269,11 +284,12 @@ export class NatService {
269
284
 
270
285
  /** Cheap read of the current snapshot. */
271
286
  getStatus(): NatStatusSnapshot {
272
- const cgnatDetected = this.latestIp?.cgnat ?? false;
287
+ const cgnatDetected = this.cgnatDetected();
273
288
  const nodes = [...this.table.values()]
274
289
  .sort((a, b) => a.nodeId.localeCompare(b.nodeId))
275
290
  .map((entry) => this.describeNode(entry, cgnatDetected));
276
291
  return {
292
+ network: { setting: this.currentSettings.network, mode: this.networkMode(), publicInterfaceIp: this.publicInterfaceIp },
277
293
  upnpEnabled: this.currentSettings.upnpEnabled,
278
294
  gateway: { ...this.gateway, routerExternalIp: this.latestIp?.routerIp ?? null },
279
295
  externalIp: this.detectedIp(),
@@ -357,18 +373,19 @@ export class NatService {
357
373
  }
358
374
 
359
375
  /**
360
- * The 5-minute probe pass: while UPnP is on and no router has answered,
361
- * search again and map the running nodes as soon as one does — a host that
362
- * boots before its network, or a router that comes up later, is found here
363
- * rather than at the next restart; then re-detect the external IP.
376
+ * The 5-minute probe pass: re-read the interfaces and re-route every node when
377
+ * the mode `auto` lands on changes; while UPnP is in use and no router has
378
+ * answered, search again and map the running nodes as soon as one does — a
379
+ * host that boots before its network, or a router that comes up later, is
380
+ * found here rather than at the next restart; then re-detect the external IP.
364
381
  */
365
382
  async redetect(): Promise<void> {
366
- if (this.currentSettings.upnpEnabled && !this.gateway.found) {
367
- await this.enqueue(async () => {
368
- await this.discoverGateway();
369
- if (this.gateway.found) await this.reconcileOnce({ mapMissing: true });
370
- });
371
- }
383
+ await this.enqueue(async () => {
384
+ const modeChanged = this.refreshNetworkMode();
385
+ const gatewayWasFound = this.gateway.found;
386
+ if (this.usesUpnp() && !gatewayWasFound) await this.discoverGateway();
387
+ if (modeChanged || (!gatewayWasFound && this.gateway.found)) await this.reconcileOnce({ mapMissing: true });
388
+ });
372
389
  await this.detectIp();
373
390
  this.publish();
374
391
  }
@@ -378,7 +395,8 @@ export class NatService {
378
395
  /** Re-discover the router if needed, re-map everything, re-detect the IP, then return a fresh snapshot. */
379
396
  async testReachability(): Promise<NatStatusSnapshot> {
380
397
  await this.enqueue(async () => {
381
- if (this.currentSettings.upnpEnabled && !this.gateway.found) await this.discoverGateway();
398
+ this.refreshNetworkMode();
399
+ if (this.usesUpnp() && !this.gateway.found) await this.discoverGateway();
382
400
  await this.reconcileOnce({ mapMissing: false });
383
401
  await this.renewOnce();
384
402
  });
@@ -394,7 +412,8 @@ export class NatService {
394
412
  /** Replace settings; persists to disk and reconfigures the runtime. */
395
413
  async putSettings(patch: Partial<Omit<NatSettingsFile, 'version' | 'forwards'>>): Promise<NatStatusSnapshot> {
396
414
  const next = this.store.update(patch);
397
- const upnpChanged = next.upnpEnabled !== this.currentSettings.upnpEnabled;
415
+ const wasUsingUpnp = this.usesUpnp();
416
+ const modeBefore = this.networkMode();
398
417
  const ddnsChanged =
399
418
  next.ddns.providerId !== this.currentSettings.ddns.providerId ||
400
419
  next.ddns.hostname !== this.currentSettings.ddns.hostname ||
@@ -406,12 +425,12 @@ export class NatService {
406
425
  if (ddnsChanged && this.ddnsUpdater) {
407
426
  await this.ddnsUpdater.updateSettings(next.ddns);
408
427
  }
409
- if (upnpChanged) {
410
- // Turning UPnP on with no gateway known yet: look for one before the
411
- // pass, so the pass can map. Turning it off: the pass releases every
412
- // `upnp` route and keeps the manual ones.
428
+ if (this.usesUpnp() !== wasUsingUpnp || this.networkMode() !== modeBefore) {
429
+ // UPnP coming into use (turned on, or leaving public mode) with no gateway known yet:
430
+ // look for one before the pass, so the pass can map. Going out of use: the pass
431
+ // releases every `upnp` route and keeps the manual ones; public mode adds `direct` ones.
413
432
  await this.enqueue(async () => {
414
- if (next.upnpEnabled && !this.gateway.found) await this.discoverGateway();
433
+ if (this.usesUpnp() && !this.gateway.found) await this.discoverGateway();
415
434
  await this.reconcileOnce({ mapMissing: true });
416
435
  });
417
436
  }
@@ -587,9 +606,14 @@ export class NatService {
587
606
  entry.routes[kind] = manualRoute(route.internalPort, forward);
588
607
  continue;
589
608
  }
590
- if (route.source === 'manual') {
609
+ if (route.source === 'manual' || route.source === 'direct') {
591
610
  entry.routes[kind] = noRoute(route.internalPort);
592
611
  }
612
+ if (this.networkMode() === 'public') {
613
+ if (route.source === 'upnp') await this.unmapRoute(entry, kind);
614
+ entry.routes[kind] = directRoute(route.internalPort);
615
+ continue;
616
+ }
593
617
  if (!this.currentSettings.upnpEnabled) {
594
618
  if (entry.routes[kind]!.source === 'upnp') await this.unmapRoute(entry, kind);
595
619
  entry.routes[kind] = noRoute(route.internalPort);
@@ -606,12 +630,12 @@ export class NatService {
606
630
 
607
631
  private async renewOnce(): Promise<void> {
608
632
  if (!this.started) return;
609
- if (!this.currentSettings.upnpEnabled || !this.gateway.found) return;
633
+ if (!this.usesUpnp() || !this.gateway.found) return;
610
634
  for (const entry of this.table.values()) {
611
635
  if (!this.started) break;
612
636
  if (!entry.running) continue;
613
637
  for (const kind of this.kindsOf(entry)) {
614
- if (entry.routes[kind]!.source === 'manual') continue;
638
+ if (entry.routes[kind]!.source !== 'upnp' && entry.routes[kind]!.source !== null) continue;
615
639
  await this.mapRoute(entry, kind);
616
640
  }
617
641
  }
@@ -735,12 +759,40 @@ export class NatService {
735
759
  return this.latestIp?.publicIp ?? this.latestIp?.routerIp ?? null;
736
760
  }
737
761
 
738
- /** The IP used in `/ip4/` addresses: only a public IPv4 counts. */
762
+ /** The IP used in `/ip4/` addresses: only a public IPv4 counts. Public mode prefers the interface's own. */
739
763
  private publicIpv4(): string | null {
764
+ if (this.networkMode() === 'public' && this.publicInterfaceIp) return this.publicInterfaceIp;
740
765
  const ip = this.detectedIp();
741
766
  return ip && isPublicIpv4(ip) ? ip : null;
742
767
  }
743
768
 
769
+ /**
770
+ * Re-read the interfaces (a VPS gains an address, a laptop moves networks); true when that
771
+ * changed the mode in effect, so the caller re-routes every node in the same pass.
772
+ */
773
+ private refreshNetworkMode(): boolean {
774
+ const modeBefore = this.networkMode();
775
+ this.publicInterfaceIp = this.readInterfaces();
776
+ const changed = this.networkMode() !== modeBefore;
777
+ if (changed) log('network mode changed to %s (public interface address %s)', this.networkMode(), this.publicInterfaceIp ?? 'none');
778
+ return changed;
779
+ }
780
+
781
+ /** The mode in effect: the `network` setting, or what `auto` reads from the interfaces. */
782
+ private networkMode(): NetworkMode {
783
+ return effectiveNetworkMode(this.currentSettings.network, this.publicInterfaceIp);
784
+ }
785
+
786
+ /** UPnP is asked for nothing in public mode: there is no router to ask. */
787
+ private usesUpnp(): boolean {
788
+ return this.currentSettings.upnpEnabled && this.networkMode() === 'lan';
789
+ }
790
+
791
+ /** An interface holding the public address rules out carrier-grade NAT, whatever the probes say. */
792
+ private cgnatDetected(): boolean {
793
+ return this.networkMode() === 'public' ? false : this.latestIp?.cgnat ?? false;
794
+ }
795
+
744
796
  // --- status assembly ---
745
797
 
746
798
  private describeNode(entry: NodeEntry, cgnatDetected: boolean): NodeReachability {
@@ -751,7 +803,7 @@ export class NatService {
751
803
  ws,
752
804
  hostKnown: this.hostKnown(),
753
805
  cgnatDetected,
754
- upnpEnabled: this.currentSettings.upnpEnabled,
806
+ upnpEnabled: this.usesUpnp(),
755
807
  gatewayFound: this.gateway.found,
756
808
  lanAddress: this.gateway.lanAddress,
757
809
  });
@@ -774,7 +826,7 @@ export class NatService {
774
826
  return buildPublicAddresses({
775
827
  ddnsHostname: this.currentSettings.ddns.hostname,
776
828
  externalIp: this.publicIpv4(),
777
- cgnatDetected: this.latestIp?.cgnat ?? false,
829
+ cgnatDetected: this.cgnatDetected(),
778
830
  tcp,
779
831
  ws,
780
832
  });
@@ -783,12 +835,13 @@ export class NatService {
783
835
  private routeOrPrediction(nodeId: string, kind: PortKind, internalPort: number, entry: NodeEntry | undefined): PortRoute {
784
836
  const forward = this.currentSettings.forwards[nodeId]?.[kind];
785
837
  if (forward !== undefined) return manualRoute(internalPort, forward);
838
+ if (this.networkMode() === 'public') return directRoute(internalPort);
786
839
  const existing = entry?.routes[kind];
787
840
  if (existing && existing.internalPort === internalPort) {
788
841
  const route = this.effectiveRoute(existing);
789
842
  if (route.source !== null || route.error !== null) return route;
790
843
  }
791
- if (this.currentSettings.upnpEnabled && this.gateway.found) {
844
+ if (this.usesUpnp() && this.gateway.found) {
792
845
  return { internalPort, externalPort: internalPort, source: 'upnp', leaseExpiresAt: null, error: null };
793
846
  }
794
847
  return noRoute(internalPort);