herdr-remote 0.2.4 → 0.2.6
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 +69 -11
- package/herdr-plugin.toml +2 -2
- package/package.json +1 -1
- package/src/config.js +28 -0
- package/src/exit-codes.js +3 -1
- package/src/host-connector.js +130 -11
- package/src/i18n/en.js +1 -0
- package/src/i18n/zh.js +1 -0
- package/src/service.js +18 -6
- package/src/settings-model.js +5 -1
- 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);
|
|
@@ -220,6 +238,10 @@ var require_config = __commonJS({
|
|
|
220
238
|
if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === "string")) {
|
|
221
239
|
config.herdr.args = [];
|
|
222
240
|
}
|
|
241
|
+
if (config.herdr.args.includes("--no-session")) {
|
|
242
|
+
config.herdr.args = config.herdr.args.filter((arg) => arg !== "--no-session");
|
|
243
|
+
process.stderr.write("herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n");
|
|
244
|
+
}
|
|
223
245
|
if (typeof config.herdr.socketPath !== "string" || config.herdr.socketPath.length === 0) {
|
|
224
246
|
config.herdr.socketPath = null;
|
|
225
247
|
}
|
|
@@ -238,6 +260,10 @@ var require_config = __commonJS({
|
|
|
238
260
|
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
239
261
|
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
240
262
|
if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
263
|
+
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
264
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
265
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
266
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
241
267
|
if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
242
268
|
if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
|
|
243
269
|
if (env.HERDR_ARGS_JSON) {
|
|
@@ -547,6 +573,7 @@ var require_en = __commonJS({
|
|
|
547
573
|
"error.invalidKeepalive": "Unknown keep-alive manager.",
|
|
548
574
|
"error.unknownField": "Unknown setting.",
|
|
549
575
|
"error.remoteUrlRequired": "Relay URL is required for self-hosted relay mode.",
|
|
576
|
+
"error.removedHerdrArg": "Herdr 0.9.0 removed --no-session; remove it from the extra arguments.",
|
|
550
577
|
"error.saveFailed": "Could not save configuration: {message}",
|
|
551
578
|
"hint.navigate": "\u2191\u2193 move",
|
|
552
579
|
"hint.select": "\u21B5 select",
|
|
@@ -771,6 +798,7 @@ var require_zh = __commonJS({
|
|
|
771
798
|
"error.invalidKeepalive": "\u672A\u77E5\u7684\u4FDD\u6D3B\u7BA1\u7406\u65B9\u5F0F\u3002",
|
|
772
799
|
"error.unknownField": "\u672A\u77E5\u7684\u8BBE\u7F6E\u9879\u3002",
|
|
773
800
|
"error.remoteUrlRequired": "\u81EA\u5EFA Relay \u6A21\u5F0F\u987B\u586B\u5199 Relay \u5730\u5740\u3002",
|
|
801
|
+
"error.removedHerdrArg": "Herdr 0.9.0 \u5DF2\u79FB\u9664 --no-session\uFF0C\u8BF7\u4ECE\u9644\u52A0\u53C2\u6570\u4E2D\u79FB\u9664\u3002",
|
|
774
802
|
"error.saveFailed": "\u4FDD\u5B58\u914D\u7F6E\u5931\u8D25\uFF1A{message}",
|
|
775
803
|
"hint.navigate": "\u2191\u2193 \u79FB\u52A8",
|
|
776
804
|
"hint.select": "\u21B5 \u9009\u62E9",
|
|
@@ -1104,7 +1132,11 @@ var require_settings_model = __commonJS({
|
|
|
1104
1132
|
break;
|
|
1105
1133
|
}
|
|
1106
1134
|
case "herdrArgs": {
|
|
1107
|
-
|
|
1135
|
+
const args = value ? value.split(/\s+/).filter(Boolean) : [];
|
|
1136
|
+
if (args.includes("--no-session")) {
|
|
1137
|
+
return { draft, errorKey: "error.removedHerdrArg" };
|
|
1138
|
+
}
|
|
1139
|
+
next.herdr.args = args;
|
|
1108
1140
|
break;
|
|
1109
1141
|
}
|
|
1110
1142
|
case "language": {
|
|
@@ -1550,7 +1582,11 @@ var require_service = __commonJS({
|
|
|
1550
1582
|
RELAY_PASSWORD: state.hostToken,
|
|
1551
1583
|
RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
|
|
1552
1584
|
RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(","),
|
|
1553
|
-
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost)
|
|
1585
|
+
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
|
|
1586
|
+
RELAY_MAX_HOSTS: String(config.relay.maxHosts),
|
|
1587
|
+
RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
|
|
1588
|
+
RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
|
|
1589
|
+
RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs)
|
|
1554
1590
|
}
|
|
1555
1591
|
});
|
|
1556
1592
|
}
|
|
@@ -1733,18 +1769,26 @@ var require_service = __commonJS({
|
|
|
1733
1769
|
throw lastError || new Error("relay did not become ready");
|
|
1734
1770
|
}
|
|
1735
1771
|
async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
|
|
1736
|
-
|
|
1772
|
+
const state = ensureRuntime2();
|
|
1773
|
+
const statusEndpoint = `${resolveAdminOrigin2(config)}/api/status`;
|
|
1774
|
+
let lastStatus = null;
|
|
1737
1775
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1738
1776
|
try {
|
|
1739
|
-
|
|
1740
|
-
|
|
1777
|
+
lastStatus = await requestJson2(statusEndpoint, {
|
|
1778
|
+
timeout: 800,
|
|
1779
|
+
headers: {
|
|
1780
|
+
"X-Herdr-Host-Id": state.hostId,
|
|
1781
|
+
"X-Herdr-Host-Token": state.hostToken
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
|
|
1741
1785
|
} catch (error2) {
|
|
1742
|
-
|
|
1786
|
+
lastStatus = { ok: false, message: error2.message };
|
|
1743
1787
|
}
|
|
1744
1788
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1745
1789
|
}
|
|
1746
|
-
const error = new Error(
|
|
1747
|
-
error.health =
|
|
1790
|
+
const error = new Error(lastStatus?.message || "the Herdr host connector did not register with the relay");
|
|
1791
|
+
error.health = lastStatus;
|
|
1748
1792
|
throw error;
|
|
1749
1793
|
}
|
|
1750
1794
|
async function statusServices() {
|
|
@@ -2827,12 +2871,26 @@ function relayStartCommand(config, password) {
|
|
|
2827
2871
|
return parts.join(" ");
|
|
2828
2872
|
}
|
|
2829
2873
|
async function probeRelay(config) {
|
|
2874
|
+
const origin = (0, import_config.resolveAdminOrigin)(config);
|
|
2875
|
+
let health;
|
|
2830
2876
|
try {
|
|
2831
|
-
|
|
2832
|
-
return { ok: true, version: health.version, hosts: health.hosts };
|
|
2877
|
+
health = await (0, import_service.requestJson)(`${origin}/healthz`, { timeout: 4e3 });
|
|
2833
2878
|
} catch (error) {
|
|
2834
2879
|
return { ok: false, message: error.message };
|
|
2835
2880
|
}
|
|
2881
|
+
try {
|
|
2882
|
+
const runtime = (0, import_service.ensureRuntime)();
|
|
2883
|
+
const status = await (0, import_service.requestJson)(`${origin}/api/status`, {
|
|
2884
|
+
timeout: 4e3,
|
|
2885
|
+
headers: {
|
|
2886
|
+
"X-Herdr-Host-Id": runtime.hostId,
|
|
2887
|
+
"X-Herdr-Host-Token": runtime.hostToken
|
|
2888
|
+
}
|
|
2889
|
+
});
|
|
2890
|
+
return { ok: true, version: String(health.version || status.version || ""), hosts: Array.isArray(status.hosts) ? status.hosts.length : 0 };
|
|
2891
|
+
} catch {
|
|
2892
|
+
return { ok: true, version: String(health.version || ""), hosts: void 0 };
|
|
2893
|
+
}
|
|
2836
2894
|
}
|
|
2837
2895
|
function formatUptime(seconds, t) {
|
|
2838
2896
|
if (!Number.isFinite(seconds)) return t("common.unknown");
|
|
@@ -3831,7 +3889,7 @@ function About({ ctx }) {
|
|
|
3831
3889
|
children: updateLabel
|
|
3832
3890
|
}
|
|
3833
3891
|
) }),
|
|
3834
|
-
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.
|
|
3892
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.6" }) }),
|
|
3835
3893
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
3836
3894
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
3837
3895
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
package/herdr-plugin.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
id = "herdr.remote.web"
|
|
2
2
|
name = "Herdr Remote Web"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
min_herdr_version = "0.
|
|
3
|
+
version = "0.2.6"
|
|
4
|
+
min_herdr_version = "0.9.0"
|
|
5
5
|
description = "Browser access to Herdr workspaces via local or self-hosted relay"
|
|
6
6
|
platforms = ["linux", "macos"]
|
|
7
7
|
|
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);
|
|
@@ -255,6 +273,12 @@ function validate(config) {
|
|
|
255
273
|
if (!Array.isArray(config.herdr.args) || !config.herdr.args.every((arg) => typeof arg === 'string')) {
|
|
256
274
|
config.herdr.args = [];
|
|
257
275
|
}
|
|
276
|
+
// Herdr 0.9.0 removed --no-session; retaining it in saved configs causes
|
|
277
|
+
// session creation to fail. Filter it out if present.
|
|
278
|
+
if (config.herdr.args.includes('--no-session')) {
|
|
279
|
+
config.herdr.args = config.herdr.args.filter((arg) => arg !== '--no-session');
|
|
280
|
+
process.stderr.write('herdr-remote: ignoring removed --no-session argument (removed in Herdr 0.9.0)\n');
|
|
281
|
+
}
|
|
258
282
|
if (typeof config.herdr.socketPath !== 'string' || config.herdr.socketPath.length === 0) {
|
|
259
283
|
config.herdr.socketPath = null;
|
|
260
284
|
}
|
|
@@ -277,6 +301,10 @@ function applyEnvironment(config) {
|
|
|
277
301
|
if (env.RELAY_PORT) config.relay.port = env.RELAY_PORT;
|
|
278
302
|
if (env.RELAY_PUBLIC_URL) config.relay.publicUrl = env.RELAY_PUBLIC_URL;
|
|
279
303
|
if (env.RELAY_REMOTE_URL) config.relay.remoteUrl = env.RELAY_REMOTE_URL;
|
|
304
|
+
if (env.RELAY_MAX_HOSTS) config.relay.maxHosts = env.RELAY_MAX_HOSTS;
|
|
305
|
+
if (env.RELAY_MAX_PENDING_HANDSHAKES) config.relay.maxPendingHandshakes = env.RELAY_MAX_PENDING_HANDSHAKES;
|
|
306
|
+
if (env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT) config.relay.maxBufferedBytesPerClient = env.RELAY_MAX_BUFFERED_BYTES_PER_CLIENT;
|
|
307
|
+
if (env.RELAY_HOST_RECONNECT_GRACE_MS) config.relay.hostReconnectGraceMs = env.RELAY_HOST_RECONNECT_GRACE_MS;
|
|
280
308
|
if (env.HERDR_SOCKET_PATH) config.herdr.socketPath = env.HERDR_SOCKET_PATH;
|
|
281
309
|
if (env.HERDR_CWD) config.herdr.cwd = env.HERDR_CWD;
|
|
282
310
|
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/i18n/en.js
CHANGED
|
@@ -206,6 +206,7 @@ module.exports = {
|
|
|
206
206
|
'error.invalidKeepalive': 'Unknown keep-alive manager.',
|
|
207
207
|
'error.unknownField': 'Unknown setting.',
|
|
208
208
|
'error.remoteUrlRequired': 'Relay URL is required for self-hosted relay mode.',
|
|
209
|
+
'error.removedHerdrArg': 'Herdr 0.9.0 removed --no-session; remove it from the extra arguments.',
|
|
209
210
|
'error.saveFailed': 'Could not save configuration: {message}',
|
|
210
211
|
|
|
211
212
|
'hint.navigate': '↑↓ move',
|
package/src/i18n/zh.js
CHANGED
|
@@ -205,6 +205,7 @@ module.exports = {
|
|
|
205
205
|
'error.invalidKeepalive': '未知的保活管理方式。',
|
|
206
206
|
'error.unknownField': '未知的设置项。',
|
|
207
207
|
'error.remoteUrlRequired': '自建 Relay 模式须填写 Relay 地址。',
|
|
208
|
+
'error.removedHerdrArg': 'Herdr 0.9.0 已移除 --no-session,请从附加参数中移除。',
|
|
208
209
|
'error.saveFailed': '保存配置失败:{message}',
|
|
209
210
|
|
|
210
211
|
'hint.navigate': '↑↓ 移动',
|
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/settings-model.js
CHANGED
|
@@ -204,7 +204,11 @@ function setField(draft, id, rawValue) {
|
|
|
204
204
|
break;
|
|
205
205
|
}
|
|
206
206
|
case 'herdrArgs': {
|
|
207
|
-
|
|
207
|
+
const args = value ? value.split(/\s+/).filter(Boolean) : [];
|
|
208
|
+
if (args.includes('--no-session')) {
|
|
209
|
+
return { draft, errorKey: 'error.removedHerdrArg' };
|
|
210
|
+
}
|
|
211
|
+
next.herdr.args = args;
|
|
208
212
|
break;
|
|
209
213
|
}
|
|
210
214
|
case 'language': {
|
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',
|