herdr-remote 0.2.4 → 0.2.5
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/config.example.json +5 -1
- package/dist/tui.mjs +58 -10
- package/package.json +1 -1
- package/src/config.js +22 -0
- package/src/exit-codes.js +3 -1
- package/src/host-connector.js +130 -11
- package/src/service.js +18 -6
- package/src/supervisor.js +9 -1
package/config.example.json
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
"port": 8787,
|
|
8
8
|
"lanHost": "",
|
|
9
9
|
"publicUrl": "",
|
|
10
|
-
"remoteUrl": ""
|
|
10
|
+
"remoteUrl": "",
|
|
11
|
+
"maxHosts": 1024,
|
|
12
|
+
"maxPendingHandshakes": 1024,
|
|
13
|
+
"maxBufferedBytesPerClient": 4194304,
|
|
14
|
+
"hostReconnectGraceMs": 30000
|
|
11
15
|
},
|
|
12
16
|
"herdr": {
|
|
13
17
|
"socketPath": null,
|
package/dist/tui.mjs
CHANGED
|
@@ -78,6 +78,10 @@ var require_config = __commonJS({
|
|
|
78
78
|
remoteUrl: "",
|
|
79
79
|
maxPayloadBytes: 1024 * 1024,
|
|
80
80
|
maxClientsPerHost: 16,
|
|
81
|
+
maxHosts: 1024,
|
|
82
|
+
maxPendingHandshakes: 1024,
|
|
83
|
+
maxBufferedBytesPerClient: 4 * 1024 * 1024,
|
|
84
|
+
hostReconnectGraceMs: 30 * 1e3,
|
|
81
85
|
allowedOrigins: []
|
|
82
86
|
},
|
|
83
87
|
herdr: {
|
|
@@ -203,6 +207,20 @@ var require_config = __commonJS({
|
|
|
203
207
|
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
|
|
204
208
|
config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
|
|
205
209
|
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
210
|
+
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 1e5);
|
|
211
|
+
config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 1e5);
|
|
212
|
+
config.relay.maxBufferedBytesPerClient = parseInteger(
|
|
213
|
+
config.relay.maxBufferedBytesPerClient,
|
|
214
|
+
DEFAULTS.relay.maxBufferedBytesPerClient,
|
|
215
|
+
64 * 1024,
|
|
216
|
+
256 * 1024 * 1024
|
|
217
|
+
);
|
|
218
|
+
config.relay.hostReconnectGraceMs = parseInteger(
|
|
219
|
+
config.relay.hostReconnectGraceMs,
|
|
220
|
+
DEFAULTS.relay.hostReconnectGraceMs,
|
|
221
|
+
1e3,
|
|
222
|
+
24 * 60 * 60 * 1e3
|
|
223
|
+
);
|
|
206
224
|
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1e3, 24 * 60 * 60 * 1e3);
|
|
207
225
|
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1e3, 365 * 24 * 60 * 60 * 1e3);
|
|
208
226
|
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 1e4);
|
|
@@ -238,6 +256,10 @@ var require_config = __commonJS({
|
|
|
238
256
|
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
239
257
|
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
240
258
|
if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
259
|
+
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
260
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
261
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
262
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
241
263
|
if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
242
264
|
if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
|
|
243
265
|
if (env.HERDR_ARGS_JSON) {
|
|
@@ -1550,7 +1572,11 @@ var require_service = __commonJS({
|
|
|
1550
1572
|
RELAY_PASSWORD: state.hostToken,
|
|
1551
1573
|
RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
|
|
1552
1574
|
RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(","),
|
|
1553
|
-
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost)
|
|
1575
|
+
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
|
|
1576
|
+
RELAY_MAX_HOSTS: String(config.relay.maxHosts),
|
|
1577
|
+
RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
|
|
1578
|
+
RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
|
|
1579
|
+
RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs)
|
|
1554
1580
|
}
|
|
1555
1581
|
});
|
|
1556
1582
|
}
|
|
@@ -1733,18 +1759,26 @@ var require_service = __commonJS({
|
|
|
1733
1759
|
throw lastError || new Error("relay did not become ready");
|
|
1734
1760
|
}
|
|
1735
1761
|
async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
|
|
1736
|
-
|
|
1762
|
+
const state = ensureRuntime2();
|
|
1763
|
+
const statusEndpoint = `${resolveAdminOrigin2(config)}/api/status`;
|
|
1764
|
+
let lastStatus = null;
|
|
1737
1765
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1738
1766
|
try {
|
|
1739
|
-
|
|
1740
|
-
|
|
1767
|
+
lastStatus = await requestJson2(statusEndpoint, {
|
|
1768
|
+
timeout: 800,
|
|
1769
|
+
headers: {
|
|
1770
|
+
"X-Herdr-Host-Id": state.hostId,
|
|
1771
|
+
"X-Herdr-Host-Token": state.hostToken
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
|
|
1741
1775
|
} catch (error2) {
|
|
1742
|
-
|
|
1776
|
+
lastStatus = { ok: false, message: error2.message };
|
|
1743
1777
|
}
|
|
1744
1778
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1745
1779
|
}
|
|
1746
|
-
const error = new Error(
|
|
1747
|
-
error.health =
|
|
1780
|
+
const error = new Error(lastStatus?.message || "the Herdr host connector did not register with the relay");
|
|
1781
|
+
error.health = lastStatus;
|
|
1748
1782
|
throw error;
|
|
1749
1783
|
}
|
|
1750
1784
|
async function statusServices() {
|
|
@@ -2827,12 +2861,26 @@ function relayStartCommand(config, password) {
|
|
|
2827
2861
|
return parts.join(" ");
|
|
2828
2862
|
}
|
|
2829
2863
|
async function probeRelay(config) {
|
|
2864
|
+
const origin = (0, import_config.resolveAdminOrigin)(config);
|
|
2865
|
+
let health;
|
|
2830
2866
|
try {
|
|
2831
|
-
|
|
2832
|
-
return { ok: true, version: health.version, hosts: health.hosts };
|
|
2867
|
+
health = await (0, import_service.requestJson)(`${origin}/healthz`, { timeout: 4e3 });
|
|
2833
2868
|
} catch (error) {
|
|
2834
2869
|
return { ok: false, message: error.message };
|
|
2835
2870
|
}
|
|
2871
|
+
try {
|
|
2872
|
+
const runtime = (0, import_service.ensureRuntime)();
|
|
2873
|
+
const status = await (0, import_service.requestJson)(`${origin}/api/status`, {
|
|
2874
|
+
timeout: 4e3,
|
|
2875
|
+
headers: {
|
|
2876
|
+
"X-Herdr-Host-Id": runtime.hostId,
|
|
2877
|
+
"X-Herdr-Host-Token": runtime.hostToken
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
return { ok: true, version: String(health.version || status.version || ""), hosts: Array.isArray(status.hosts) ? status.hosts.length : 0 };
|
|
2881
|
+
} catch {
|
|
2882
|
+
return { ok: true, version: String(health.version || ""), hosts: void 0 };
|
|
2883
|
+
}
|
|
2836
2884
|
}
|
|
2837
2885
|
function formatUptime(seconds, t) {
|
|
2838
2886
|
if (!Number.isFinite(seconds)) return t("common.unknown");
|
|
@@ -3831,7 +3879,7 @@ function About({ ctx }) {
|
|
|
3831
3879
|
children: updateLabel
|
|
3832
3880
|
}
|
|
3833
3881
|
) }),
|
|
3834
|
-
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.
|
|
3882
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.5" }) }),
|
|
3835
3883
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
3836
3884
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
3837
3885
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -64,6 +64,10 @@ const DEFAULTS = {
|
|
|
64
64
|
remoteUrl: '',
|
|
65
65
|
maxPayloadBytes: 1024 * 1024,
|
|
66
66
|
maxClientsPerHost: 16,
|
|
67
|
+
maxHosts: 1024,
|
|
68
|
+
maxPendingHandshakes: 1024,
|
|
69
|
+
maxBufferedBytesPerClient: 4 * 1024 * 1024,
|
|
70
|
+
hostReconnectGraceMs: 30 * 1000,
|
|
67
71
|
allowedOrigins: [],
|
|
68
72
|
},
|
|
69
73
|
herdr: {
|
|
@@ -230,6 +234,20 @@ function validate(config) {
|
|
|
230
234
|
config.relay.port = parseInteger(config.relay.port, DEFAULTS.relay.port, 1, 65535);
|
|
231
235
|
config.relay.maxPayloadBytes = parseInteger(config.relay.maxPayloadBytes, DEFAULTS.relay.maxPayloadBytes, 4096, 16 * 1024 * 1024);
|
|
232
236
|
config.relay.maxClientsPerHost = parseInteger(config.relay.maxClientsPerHost, DEFAULTS.relay.maxClientsPerHost, 1, 256);
|
|
237
|
+
config.relay.maxHosts = parseInteger(config.relay.maxHosts, DEFAULTS.relay.maxHosts, 1, 100000);
|
|
238
|
+
config.relay.maxPendingHandshakes = parseInteger(config.relay.maxPendingHandshakes, DEFAULTS.relay.maxPendingHandshakes, 16, 100000);
|
|
239
|
+
config.relay.maxBufferedBytesPerClient = parseInteger(
|
|
240
|
+
config.relay.maxBufferedBytesPerClient,
|
|
241
|
+
DEFAULTS.relay.maxBufferedBytesPerClient,
|
|
242
|
+
64 * 1024,
|
|
243
|
+
256 * 1024 * 1024,
|
|
244
|
+
);
|
|
245
|
+
config.relay.hostReconnectGraceMs = parseInteger(
|
|
246
|
+
config.relay.hostReconnectGraceMs,
|
|
247
|
+
DEFAULTS.relay.hostReconnectGraceMs,
|
|
248
|
+
1000,
|
|
249
|
+
24 * 60 * 60 * 1000,
|
|
250
|
+
);
|
|
233
251
|
config.auth.pairingTtlMs = parseInteger(config.auth.pairingTtlMs, DEFAULTS.auth.pairingTtlMs, 30 * 1000, 24 * 60 * 60 * 1000);
|
|
234
252
|
config.auth.deviceTtlMs = parseInteger(config.auth.deviceTtlMs, DEFAULTS.auth.deviceTtlMs, 60 * 1000, 365 * 24 * 60 * 60 * 1000);
|
|
235
253
|
config.auth.maxDevices = parseInteger(config.auth.maxDevices, DEFAULTS.auth.maxDevices, 1, 10000);
|
|
@@ -277,6 +295,10 @@ function applyEnvironment(config) {
|
|
|
277
295
|
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
278
296
|
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
279
297
|
if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
298
|
+
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
299
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
300
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
301
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
280
302
|
if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
281
303
|
if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
|
|
282
304
|
if (env.HERDR_ARGS_JSON) {
|
package/src/exit-codes.js
CHANGED
|
@@ -10,5 +10,7 @@
|
|
|
10
10
|
* one exit the supervisor must respect rather than recover from.
|
|
11
11
|
*/
|
|
12
12
|
const EXIT_REPLACED = 12;
|
|
13
|
+
/** Credentials need operator intervention; a supervisor must not loop. */
|
|
14
|
+
const EXIT_AUTH_FAILED = 13;
|
|
13
15
|
|
|
14
|
-
module.exports = { EXIT_REPLACED };
|
|
16
|
+
module.exports = { EXIT_REPLACED, EXIT_AUTH_FAILED };
|
package/src/host-connector.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
3
4
|
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
4
6
|
const crypto = require('node:crypto');
|
|
5
7
|
const { WebSocket } = require('ws');
|
|
6
|
-
const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl } = require('./config');
|
|
8
|
+
const { loadConfig, hostWebSocketUrl, resolveHostRelayUrl, stateDir } = require('./config');
|
|
9
|
+
const { ensureDir } = require('./state');
|
|
7
10
|
const { resolveSocketPath, inspectSocket } = require('./socket-discovery');
|
|
8
11
|
const { PtySession } = require('./pty-session');
|
|
9
12
|
const { resolveHerdrCommand } = require('./herdr-command');
|
|
@@ -11,7 +14,7 @@ const { resolveHerdrCommand } = require('./herdr-command');
|
|
|
11
14
|
// generated from one definition.
|
|
12
15
|
const { packStreamFrame, unpackStreamFrame, PROTOCOL_VERSION } = require('herdr-remote-relay/protocol');
|
|
13
16
|
const { resolveHostPalette } = require('./terminal-palette');
|
|
14
|
-
const { EXIT_REPLACED } = require('./exit-codes');
|
|
17
|
+
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
15
18
|
|
|
16
19
|
function randomId(prefix) {
|
|
17
20
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -21,9 +24,19 @@ function sendJson(ws, payload) {
|
|
|
21
24
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload));
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
function closeSocket(ws) {
|
|
27
|
+
function closeSocket(ws, reason = 'host connector stopping') {
|
|
25
28
|
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
|
|
26
|
-
try { ws.close(1000,
|
|
29
|
+
try { ws.close(1000, reason); } catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pidAlive(pid) {
|
|
33
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return error.code === 'EPERM';
|
|
39
|
+
}
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
class HostConnector {
|
|
@@ -55,13 +68,62 @@ class HostConnector {
|
|
|
55
68
|
this.reconnectTimer = null;
|
|
56
69
|
this.heartbeatTimer = null;
|
|
57
70
|
this.reconnectAttempts = 0;
|
|
71
|
+
this.clientCount = 0;
|
|
72
|
+
this.legacyHeartbeat = false;
|
|
73
|
+
this.ready = false;
|
|
74
|
+
this.authFailure = false;
|
|
58
75
|
this.stopping = false;
|
|
76
|
+
this.lockPath = options.lockPath
|
|
77
|
+
|| process.env.HERDR_REMOTE_HOST_LOCK
|
|
78
|
+
|| path.join(stateDir(), 'host-connector.lock');
|
|
79
|
+
this.lockFd = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
acquireLock() {
|
|
83
|
+
if (this.lockFd !== null) return;
|
|
84
|
+
ensureDir(path.dirname(this.lockPath));
|
|
85
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
86
|
+
try {
|
|
87
|
+
const fd = fs.openSync(this.lockPath, 'wx', 0o600);
|
|
88
|
+
fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId: this.hostId, startedAt: new Date().toISOString() })}\n`);
|
|
89
|
+
this.lockFd = fd;
|
|
90
|
+
return;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code !== 'EEXIST') throw error;
|
|
93
|
+
let owner = null;
|
|
94
|
+
try { owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8')); } catch {}
|
|
95
|
+
if (owner && pidAlive(owner.pid)) {
|
|
96
|
+
const duplicate = new Error(`another host connector is already running (pid ${owner.pid})`);
|
|
97
|
+
duplicate.code = 'HOST_ALREADY_RUNNING';
|
|
98
|
+
throw duplicate;
|
|
99
|
+
}
|
|
100
|
+
try { fs.rmSync(this.lockPath, { force: true }); } catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const stale = new Error('could not acquire host connector lock');
|
|
104
|
+
stale.code = 'HOST_LOCK_FAILED';
|
|
105
|
+
throw stale;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
releaseLock() {
|
|
109
|
+
const owned = this.lockFd !== null;
|
|
110
|
+
if (this.lockFd !== null) {
|
|
111
|
+
try { fs.closeSync(this.lockFd); } catch {}
|
|
112
|
+
this.lockFd = null;
|
|
113
|
+
}
|
|
114
|
+
if (!owned) return;
|
|
115
|
+
try {
|
|
116
|
+
const owner = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
|
|
117
|
+
if (owner.pid !== process.pid) return;
|
|
118
|
+
} catch {}
|
|
119
|
+
try { fs.rmSync(this.lockPath, { force: true }); } catch {}
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
start() {
|
|
62
123
|
if (!this.hostToken) {
|
|
63
124
|
throw new Error('RELAY_HOST_TOKEN is required');
|
|
64
125
|
}
|
|
126
|
+
this.acquireLock();
|
|
65
127
|
this.stopping = false;
|
|
66
128
|
this.connect();
|
|
67
129
|
}
|
|
@@ -72,9 +134,14 @@ class HostConnector {
|
|
|
72
134
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
73
135
|
this.reconnectTimer = null;
|
|
74
136
|
this.heartbeatTimer = null;
|
|
137
|
+
sendJson(this.ws, { type: 'host_shutdown' });
|
|
75
138
|
this.destroySessions();
|
|
76
|
-
closeSocket(this.ws);
|
|
139
|
+
closeSocket(this.ws, 'host_shutdown');
|
|
77
140
|
this.ws = null;
|
|
141
|
+
this.ready = false;
|
|
142
|
+
this.clientCount = 0;
|
|
143
|
+
this.legacyHeartbeat = false;
|
|
144
|
+
this.releaseLock();
|
|
78
145
|
}
|
|
79
146
|
|
|
80
147
|
connect() {
|
|
@@ -89,8 +156,9 @@ class HostConnector {
|
|
|
89
156
|
this.ws = ws;
|
|
90
157
|
ws.isAlive = true;
|
|
91
158
|
ws.on('open', () => {
|
|
92
|
-
this.reconnectAttempts = 0;
|
|
93
159
|
ws.isAlive = true;
|
|
160
|
+
this.ready = false;
|
|
161
|
+
this.authFailure = false;
|
|
94
162
|
sendJson(ws, {
|
|
95
163
|
type: 'host_hello',
|
|
96
164
|
protocol: PROTOCOL_VERSION,
|
|
@@ -101,16 +169,24 @@ class HostConnector {
|
|
|
101
169
|
platform: process.platform,
|
|
102
170
|
arch: process.arch,
|
|
103
171
|
terminalPalette: this.terminalPalette || null,
|
|
172
|
+
capabilities: ['host_handoff', 'idle_heartbeat'],
|
|
104
173
|
});
|
|
105
|
-
|
|
106
|
-
|
|
174
|
+
// The relay sends host_ready with the current browser count. No business
|
|
175
|
+
// heartbeat is started until that message says somebody is watching.
|
|
107
176
|
});
|
|
108
177
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
109
178
|
ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
|
|
110
179
|
ws.on('close', (code, rawReason) => {
|
|
111
|
-
|
|
180
|
+
// A replacement socket may be live while an older socket is still
|
|
181
|
+
// delivering its close event. Never let that stale event destroy the new
|
|
182
|
+
// session or clear its heartbeat timer.
|
|
183
|
+
if (this.ws !== ws) return;
|
|
184
|
+
this.ws = null;
|
|
112
185
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
113
186
|
this.heartbeatTimer = null;
|
|
187
|
+
this.ready = false;
|
|
188
|
+
this.clientCount = 0;
|
|
189
|
+
this.legacyHeartbeat = false;
|
|
114
190
|
this.destroySessions();
|
|
115
191
|
|
|
116
192
|
// Another connector has claimed this workstation. Reconnecting would just
|
|
@@ -123,6 +199,12 @@ class HostConnector {
|
|
|
123
199
|
this.stop();
|
|
124
200
|
process.exit(EXIT_REPLACED);
|
|
125
201
|
}
|
|
202
|
+
if (this.authFailure) {
|
|
203
|
+
this.stopping = true;
|
|
204
|
+
process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
|
|
205
|
+
this.releaseLock();
|
|
206
|
+
process.exit(EXIT_AUTH_FAILED);
|
|
207
|
+
}
|
|
126
208
|
this.scheduleReconnect();
|
|
127
209
|
});
|
|
128
210
|
ws.on('error', (error) => {
|
|
@@ -160,9 +242,29 @@ class HostConnector {
|
|
|
160
242
|
// connection just closed silently and reconnected forever, leaving the user
|
|
161
243
|
// with an empty log and no idea what was wrong.
|
|
162
244
|
if (message.type === 'error' && !message.clientId) {
|
|
245
|
+
this.authFailure = ['relay_password_required', 'host_auth_failed', 'invalid_host_credentials'].includes(message.code);
|
|
163
246
|
process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
|
|
164
247
|
return;
|
|
165
248
|
}
|
|
249
|
+
if (message.type === 'host_ready') {
|
|
250
|
+
this.ready = true;
|
|
251
|
+
this.reconnectAttempts = 0;
|
|
252
|
+
if (Object.hasOwn(message, 'clientCount')) {
|
|
253
|
+
this.legacyHeartbeat = false;
|
|
254
|
+
this.setClientCount(message.clientCount);
|
|
255
|
+
} else {
|
|
256
|
+
// An older relay does not know client_count. Keep its historical
|
|
257
|
+
// telemetry behavior so rolling upgrades do not silently lose status.
|
|
258
|
+
this.legacyHeartbeat = true;
|
|
259
|
+
this.sendHeartbeat(true);
|
|
260
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (message.type === 'client_count') {
|
|
265
|
+
this.setClientCount(message.clientCount);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
166
268
|
if (message.type === 'session_start') this.startSession(message);
|
|
167
269
|
else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
168
270
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
@@ -224,6 +326,22 @@ class HostConnector {
|
|
|
224
326
|
this.sendHeartbeat();
|
|
225
327
|
}
|
|
226
328
|
|
|
329
|
+
setClientCount(value) {
|
|
330
|
+
const next = Number.isInteger(value) ? Math.max(0, value) : 0;
|
|
331
|
+
if (next === this.clientCount && (next === 0 || this.heartbeatTimer)) return;
|
|
332
|
+
const wasActive = this.clientCount > 0;
|
|
333
|
+
this.clientCount = next;
|
|
334
|
+
if (next > 0) {
|
|
335
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
336
|
+
this.sendHeartbeat(true);
|
|
337
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
338
|
+
} else {
|
|
339
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
340
|
+
this.heartbeatTimer = null;
|
|
341
|
+
if (wasActive) this.sendHeartbeat(true);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
227
345
|
resizeSession(message) {
|
|
228
346
|
const id = message.clientId || message.streamId;
|
|
229
347
|
const session = this.sessions.get(id);
|
|
@@ -238,7 +356,8 @@ class HostConnector {
|
|
|
238
356
|
this.sessions.clear();
|
|
239
357
|
}
|
|
240
358
|
|
|
241
|
-
sendHeartbeat() {
|
|
359
|
+
sendHeartbeat(force = false) {
|
|
360
|
+
if (!force && this.clientCount <= 0 && !this.legacyHeartbeat) return;
|
|
242
361
|
const memory = process.memoryUsage();
|
|
243
362
|
const load = os.loadavg();
|
|
244
363
|
sendJson(this.ws, {
|
|
@@ -270,7 +389,7 @@ if (require.main === module) {
|
|
|
270
389
|
connector.start();
|
|
271
390
|
} catch (error) {
|
|
272
391
|
process.stderr.write(`herdr-remote host connector failed: ${error.message}\n`);
|
|
273
|
-
process.exitCode = 1;
|
|
392
|
+
process.exitCode = error.code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
|
|
274
393
|
}
|
|
275
394
|
const stop = () => { connector.stop(); process.exit(0); };
|
|
276
395
|
process.once('SIGINT', stop);
|
package/src/service.js
CHANGED
|
@@ -166,6 +166,10 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
|
|
|
166
166
|
RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
|
|
167
167
|
RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
|
|
168
168
|
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
|
|
169
|
+
RELAY_MAX_HOSTS: String(config.relay.maxHosts),
|
|
170
|
+
RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
|
|
171
|
+
RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
|
|
172
|
+
RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs),
|
|
169
173
|
},
|
|
170
174
|
});
|
|
171
175
|
}
|
|
@@ -377,18 +381,26 @@ async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
|
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
|
|
380
|
-
|
|
384
|
+
const state = ensureRuntime();
|
|
385
|
+
const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
|
|
386
|
+
let lastStatus = null;
|
|
381
387
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
382
388
|
try {
|
|
383
|
-
|
|
384
|
-
|
|
389
|
+
lastStatus = await requestJson(statusEndpoint, {
|
|
390
|
+
timeout: 800,
|
|
391
|
+
headers: {
|
|
392
|
+
'X-Herdr-Host-Id': state.hostId,
|
|
393
|
+
'X-Herdr-Host-Token': state.hostToken,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
|
|
385
397
|
} catch (error) {
|
|
386
|
-
|
|
398
|
+
lastStatus = { ok: false, message: error.message };
|
|
387
399
|
}
|
|
388
400
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
389
401
|
}
|
|
390
|
-
const error = new Error(
|
|
391
|
-
error.health =
|
|
402
|
+
const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
|
|
403
|
+
error.health = lastStatus;
|
|
392
404
|
throw error;
|
|
393
405
|
}
|
|
394
406
|
|
package/src/supervisor.js
CHANGED
|
@@ -5,7 +5,7 @@ const { spawn } = require('node:child_process');
|
|
|
5
5
|
const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
|
|
6
6
|
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
7
7
|
const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
|
|
8
|
-
const { EXIT_REPLACED } = require('./exit-codes');
|
|
8
|
+
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
9
9
|
|
|
10
10
|
const MIN_BACKOFF_MS = 500;
|
|
11
11
|
const MAX_BACKOFF_MS = 30_000;
|
|
@@ -132,6 +132,14 @@ class Supervisor {
|
|
|
132
132
|
});
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
|
+
if (code === EXIT_AUTH_FAILED) {
|
|
136
|
+
this.emit({
|
|
137
|
+
type: 'fatal',
|
|
138
|
+
name,
|
|
139
|
+
message: `${name} stopped: relay authentication failed. Update credentials before restarting it.`,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
135
143
|
if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
|
|
136
144
|
this.emit({
|
|
137
145
|
type: 'exited',
|