@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
@@ -1,143 +1,155 @@
1
- /**
2
- * Reachability verdicts: one per hosted node, and the host-level roll-up the
3
- * dashboard badge shows. Heuristics over the mapping table, not a dial-back —
4
- * libp2p AutoNAT is a later ticket.
5
- */
6
-
7
- import type {
8
- DirectReachability,
9
- NodeReachability,
10
- NodeVerdict,
11
- PortKind,
12
- PortRoute,
13
- } from './types.js';
14
-
15
- export interface NodeVerdictInput {
16
- tcp: PortRoute;
17
- /** Null for a handle persisted by an older build without a WebSocket port. */
18
- ws: PortRoute | null;
19
- /** Whether a public host part exists: a DDNS hostname, or a public IPv4. */
20
- hostKnown: boolean;
21
- cgnatDetected: boolean;
22
- upnpEnabled: boolean;
23
- gatewayFound: boolean;
24
- /** The address a user forwards to; null when no gateway was found. */
25
- lanAddress: string | null;
26
- }
27
-
28
- export interface NodeVerdictResult {
29
- verdict: NodeVerdict;
30
- reason: string | null;
31
- }
32
-
33
- /**
34
- * `unreachable` when either port has no usable route or the host part is
35
- * unknown; otherwise `manual` when either port is forwarded by hand, else
36
- * `mapped`. A UPnP route under CGNAT is not usable: the router's mapping is on
37
- * a carrier-private address.
38
- */
39
- export function evaluateNodeReachability(input: NodeVerdictInput): NodeVerdictResult {
40
- const ports = portRoutes(input);
41
- const failing = ports.filter(({ route }) => !usable(route, input.cgnatDetected));
42
- if (!input.hostKnown) {
43
- return { verdict: 'unreachable', reason: HOST_UNKNOWN_REASON };
44
- }
45
- if (failing.length > 0) {
46
- return { verdict: 'unreachable', reason: describeFailures(failing, input) };
47
- }
48
- const manual = ports.some(({ route }) => route.source === 'manual');
49
- return { verdict: manual ? 'manual' : 'mapped', reason: null };
50
- }
51
-
52
- /**
53
- * Host roll-up: `cgnat` when CGNAT is detected and no node is reachable
54
- * through a manual route; `unknown` with no running nodes; `reachable` when
55
- * every running node is `mapped`/`manual`; else `unreachable`.
56
- */
57
- export function evaluateHostReachability(
58
- nodes: ReadonlyArray<Pick<NodeReachability, 'running' | 'verdict'>>,
59
- cgnatDetected: boolean,
60
- ): DirectReachability {
61
- const running = nodes.filter((n) => n.running);
62
- if (cgnatDetected && !running.some((n) => n.verdict === 'manual')) return 'cgnat';
63
- if (running.length === 0) return 'unknown';
64
- return running.every((n) => n.verdict !== 'unreachable') ? 'reachable' : 'unreachable';
65
- }
66
-
67
- const HOST_UNKNOWN_REASON =
68
- "This host's public address is unknown: no dynamic DNS hostname is set and no public IPv4 address was detected. " +
69
- 'Set a DDNS hostname, or check that this machine can reach the internet.';
70
-
71
- interface PortEntry {
72
- kind: PortKind;
73
- route: PortRoute;
74
- }
75
-
76
- function portRoutes(input: NodeVerdictInput): PortEntry[] {
77
- const out: PortEntry[] = [{ kind: 'tcp', route: input.tcp }];
78
- if (input.ws) out.push({ kind: 'ws', route: input.ws });
79
- return out;
80
- }
81
-
82
- function usable(route: PortRoute, cgnatDetected: boolean): boolean {
83
- if (route.externalPort === null || route.source === null) return false;
84
- return !(cgnatDetected && route.source === 'upnp');
85
- }
86
-
87
- type FailureCause = 'cgnat' | 'refused' | 'upnp_off' | 'no_gateway' | 'pending';
88
-
89
- /** Behind CGNAT no router-side remedy helps, so CGNAT outranks whatever else failed the port. */
90
- function causeOf(route: PortRoute, input: NodeVerdictInput): FailureCause {
91
- if (input.cgnatDetected) return 'cgnat';
92
- if (route.error) return 'refused';
93
- if (!input.upnpEnabled) return 'upnp_off';
94
- if (!input.gatewayFound) return 'no_gateway';
95
- return 'pending';
96
- }
97
-
98
- /** One sentence per distinct cause, each naming every port that failed for it. */
99
- function describeFailures(failing: PortEntry[], input: NodeVerdictInput): string {
100
- const byCause = new Map<FailureCause, PortEntry[]>();
101
- for (const entry of failing) {
102
- const cause = causeOf(entry.route, input);
103
- byCause.set(cause, [...(byCause.get(cause) ?? []), entry]);
104
- }
105
- return [...byCause.entries()].map(([cause, entries]) => describeCause(cause, entries, input)).join(' ');
106
- }
107
-
108
- function describeCause(cause: FailureCause, entries: PortEntry[], input: NodeVerdictInput): string {
109
- const names = entries.map(({ kind, route }) => `${kindLabel(kind)} port ${route.internalPort}`);
110
- const lan = input.lanAddress ?? "this machine's LAN address";
111
- const forwardHint = `Forward ${entries.length === 1 ? 'port' : 'ports'} ${listPorts(entries)} to ${lan} on your router, then tell cadre-host the external ${entries.length === 1 ? 'port' : 'ports'}.`;
112
- switch (cause) {
113
- case 'cgnat':
114
- return `This host is behind carrier-grade NAT, so the ${joinNames(names)} cannot be reached from outside through your router, and neither UPnP nor a port forward on your router will help; a relay is needed.`;
115
- case 'refused': {
116
- const errors = entries.map(({ route }) => route.error).filter((e): e is string => !!e);
117
- return `Router refused the ${joinNames(names)} mapping (${errors.join('; ')}). ${forwardHint}`;
118
- }
119
- case 'upnp_off':
120
- return `UPnP is off, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Turn UPnP on, or: ${lowerFirst(forwardHint)}`;
121
- case 'no_gateway':
122
- return `No UPnP router answered, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Enable UPnP on your router, or: ${lowerFirst(forwardHint)}`;
123
- case 'pending':
124
- return `The ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped yet; the router is being asked.`;
125
- }
126
- }
127
-
128
- function kindLabel(kind: PortKind): string {
129
- return kind === 'tcp' ? 'TCP' : 'WebSocket';
130
- }
131
-
132
- function listPorts(entries: PortEntry[]): string {
133
- return joinNames(entries.map(({ route }) => String(route.internalPort)));
134
- }
135
-
136
- function joinNames(names: string[]): string {
137
- if (names.length <= 1) return names[0] ?? '';
138
- return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
139
- }
140
-
141
- function lowerFirst(s: string): string {
142
- return s.charAt(0).toLowerCase() + s.slice(1);
143
- }
1
+ /**
2
+ * Reachability verdicts: one per hosted node, and the host-level roll-up the
3
+ * dashboard badge shows. Heuristics over the mapping table, not a dial-back —
4
+ * libp2p AutoNAT is a later ticket.
5
+ */
6
+
7
+ import type {
8
+ DirectReachability,
9
+ NodeReachability,
10
+ NodeVerdict,
11
+ PortKind,
12
+ PortRoute,
13
+ } from './types.js';
14
+
15
+ export interface NodeVerdictInput {
16
+ tcp: PortRoute;
17
+ /** Null for a handle persisted by an older build without a WebSocket port. */
18
+ ws: PortRoute | null;
19
+ /** Whether a public host part exists: a DDNS hostname, or a public IPv4. */
20
+ hostKnown: boolean;
21
+ cgnatDetected: boolean;
22
+ upnpEnabled: boolean;
23
+ gatewayFound: boolean;
24
+ /** The address a user forwards to; null when no gateway was found. */
25
+ lanAddress: string | null;
26
+ }
27
+
28
+ export interface NodeVerdictResult {
29
+ verdict: NodeVerdict;
30
+ reason: string | null;
31
+ }
32
+
33
+ /**
34
+ * `unreachable` when either port has no usable route or the host part is
35
+ * unknown; otherwise `manual` when either port is forwarded by hand, `direct`
36
+ * when the ports are reached as-is (public mode, with the firewall reminder as
37
+ * the reason), else `mapped`. A UPnP route under CGNAT is not usable: the
38
+ * router's mapping is on a carrier-private address.
39
+ */
40
+ export function evaluateNodeReachability(input: NodeVerdictInput): NodeVerdictResult {
41
+ const ports = portRoutes(input);
42
+ const failing = ports.filter(({ route }) => !usable(route, input.cgnatDetected));
43
+ if (!input.hostKnown) {
44
+ return { verdict: 'unreachable', reason: HOST_UNKNOWN_REASON };
45
+ }
46
+ if (failing.length > 0) {
47
+ return { verdict: 'unreachable', reason: describeFailures(failing, input) };
48
+ }
49
+ if (ports.some(({ route }) => route.source === 'manual')) return { verdict: 'manual', reason: null };
50
+ if (ports.some(({ route }) => route.source === 'direct')) return { verdict: 'direct', reason: firewallReminder(ports) };
51
+ return { verdict: 'mapped', reason: null };
52
+ }
53
+
54
+ /**
55
+ * Host roll-up: `cgnat` when CGNAT is detected and no node is reachable
56
+ * through a manual route; `unknown` with no running nodes; `reachable` when
57
+ * every running node is `mapped`/`manual`/`direct`; else `unreachable`.
58
+ */
59
+ export function evaluateHostReachability(
60
+ nodes: ReadonlyArray<Pick<NodeReachability, 'running' | 'verdict'>>,
61
+ cgnatDetected: boolean,
62
+ ): DirectReachability {
63
+ const running = nodes.filter((n) => n.running);
64
+ if (cgnatDetected && !running.some((n) => n.verdict === 'manual')) return 'cgnat';
65
+ if (running.length === 0) return 'unknown';
66
+ return running.every((n) => n.verdict !== 'unreachable') ? 'reachable' : 'unreachable';
67
+ }
68
+
69
+ const HOST_UNKNOWN_REASON =
70
+ "This host's public address is unknown: no dynamic DNS hostname is set and no public IPv4 address was detected. " +
71
+ 'Set a DDNS hostname, or check that this machine can reach the internet.';
72
+
73
+ interface PortEntry {
74
+ kind: PortKind;
75
+ route: PortRoute;
76
+ }
77
+
78
+ function portRoutes(input: NodeVerdictInput): PortEntry[] {
79
+ const out: PortEntry[] = [{ kind: 'tcp', route: input.tcp }];
80
+ if (input.ws) out.push({ kind: 'ws', route: input.ws });
81
+ return out;
82
+ }
83
+
84
+ function usable(route: PortRoute, cgnatDetected: boolean): boolean {
85
+ if (route.externalPort === null || route.source === null) return false;
86
+ return !(cgnatDetected && route.source === 'upnp');
87
+ }
88
+
89
+ type FailureCause = 'cgnat' | 'refused' | 'upnp_off' | 'no_gateway' | 'pending';
90
+
91
+ /** Behind CGNAT no router-side remedy helps, so CGNAT outranks whatever else failed the port. */
92
+ function causeOf(route: PortRoute, input: NodeVerdictInput): FailureCause {
93
+ if (input.cgnatDetected) return 'cgnat';
94
+ if (route.error) return 'refused';
95
+ if (!input.upnpEnabled) return 'upnp_off';
96
+ if (!input.gatewayFound) return 'no_gateway';
97
+ return 'pending';
98
+ }
99
+
100
+ /** One sentence per distinct cause, each naming every port that failed for it. */
101
+ function describeFailures(failing: PortEntry[], input: NodeVerdictInput): string {
102
+ const byCause = new Map<FailureCause, PortEntry[]>();
103
+ for (const entry of failing) {
104
+ const cause = causeOf(entry.route, input);
105
+ byCause.set(cause, [...(byCause.get(cause) ?? []), entry]);
106
+ }
107
+ return [...byCause.entries()].map(([cause, entries]) => describeCause(cause, entries, input)).join(' ');
108
+ }
109
+
110
+ function describeCause(cause: FailureCause, entries: PortEntry[], input: NodeVerdictInput): string {
111
+ const names = entries.map(({ kind, route }) => `${kindLabel(kind)} port ${route.internalPort}`);
112
+ const lan = input.lanAddress ?? "this machine's LAN address";
113
+ const forwardHint = `Forward ${entries.length === 1 ? 'port' : 'ports'} ${listPorts(entries)} to ${lan} on your router, then tell cadre-host the external ${entries.length === 1 ? 'port' : 'ports'}.`;
114
+ switch (cause) {
115
+ case 'cgnat':
116
+ return `This host is behind carrier-grade NAT, so the ${joinNames(names)} cannot be reached from outside through your router, and neither UPnP nor a port forward on your router will help; a relay is needed.`;
117
+ case 'refused': {
118
+ const errors = entries.map(({ route }) => route.error).filter((e): e is string => !!e);
119
+ return `Router refused the ${joinNames(names)} mapping (${errors.join('; ')}). ${forwardHint}`;
120
+ }
121
+ case 'upnp_off':
122
+ return `UPnP is off, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Turn UPnP on, or: ${lowerFirst(forwardHint)}`;
123
+ case 'no_gateway':
124
+ return `No UPnP router answered, so the ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped. Enable UPnP on your router, or: ${lowerFirst(forwardHint)}`;
125
+ case 'pending':
126
+ return `The ${joinNames(names)} ${entries.length === 1 ? 'is' : 'are'} not mapped yet; the router is being asked.`;
127
+ }
128
+ }
129
+
130
+ /** Public mode: nothing to map, but a host or cloud firewall can still block the ports. */
131
+ function firewallReminder(ports: PortEntry[]): string {
132
+ const numbers = ports.map(({ route }) => route.internalPort);
133
+ const ufw = numbers.length === 2 && Math.abs(numbers[0]! - numbers[1]!) === 1
134
+ ? `${Math.min(...numbers)}:${Math.max(...numbers)}/tcp`
135
+ : `${numbers.join(',')}/tcp`;
136
+ return `Reached directly at this machine's public address. Allow TCP ${ports.length === 1 ? 'port' : 'ports'} ${listPorts(ports)} through any firewall `
137
+ + `(for example: sudo ufw allow ${ufw}; and any cloud-provider firewall).`;
138
+ }
139
+
140
+ function kindLabel(kind: PortKind): string {
141
+ return kind === 'tcp' ? 'TCP' : 'WebSocket';
142
+ }
143
+
144
+ function listPorts(entries: PortEntry[]): string {
145
+ return joinNames(entries.map(({ route }) => String(route.internalPort)));
146
+ }
147
+
148
+ function joinNames(names: string[]): string {
149
+ if (names.length <= 1) return names[0] ?? '';
150
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
151
+ }
152
+
153
+ function lowerFirst(s: string): string {
154
+ return s.charAt(0).toLowerCase() + s.slice(1);
155
+ }