herdr-remote 0.2.18 → 0.2.20
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/bin/herdr-remote.js +3 -225
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +8 -8
- package/dist/tui.mjs +0 -5775
- package/src/agent-status.js +0 -139
- package/src/binary-plist.js +0 -146
- package/src/config.js +0 -454
- package/src/font-subset.js +0 -110
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -1069
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -530
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-font.js +0 -1204
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Serving the workstation's terminal font to browser windows: its public
|
|
2
|
+
// description, its files a chunk at a time, and characters cut out of large
|
|
3
|
+
// (CJK) fonts on demand.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { TERMINAL_FONT_CHUNK_BYTES } from 'herdr-remote-relay/protocol';
|
|
7
|
+
import { FontSubsetter } from '../font-subset.js';
|
|
8
|
+
import { loadHostTerminalFont, publicTerminalFont, readFontChunk, } from '../terminal-font/index.js';
|
|
9
|
+
/** Cut fonts kept for the slices a browser has yet to fetch, by total size. */
|
|
10
|
+
const FONT_SUBSET_CACHE_BYTES = 48 * 1024 * 1024;
|
|
11
|
+
const FONT_CHANGED = 'The terminal font changed or is no longer on this workstation';
|
|
12
|
+
/**
|
|
13
|
+
* Only the family, size and file hashes leave this process; a browser that
|
|
14
|
+
* lacks the font fetches the files by hash, one chunk at a time.
|
|
15
|
+
*/
|
|
16
|
+
export class FontServer {
|
|
17
|
+
font;
|
|
18
|
+
load;
|
|
19
|
+
subsetter;
|
|
20
|
+
/** Subsets too large for one message, by hash, until fetched. */
|
|
21
|
+
subsets = new Map();
|
|
22
|
+
send;
|
|
23
|
+
constructor(options, send) {
|
|
24
|
+
this.load = options.loadTerminalFont || loadHostTerminalFont;
|
|
25
|
+
this.font = options.terminalFont !== undefined ? options.terminalFont : this.load();
|
|
26
|
+
this.subsetter = options.fontSubsetter || new FontSubsetter();
|
|
27
|
+
this.send = send;
|
|
28
|
+
}
|
|
29
|
+
/** What may leave this machine: the record without its file paths. */
|
|
30
|
+
publicFont() {
|
|
31
|
+
return publicTerminalFont(this.font);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A service started at boot runs before anything has seen a terminal, so it
|
|
35
|
+
* starts with no font. The first `herdr-remote start` from a terminal (or
|
|
36
|
+
* from Herdr's plugin hook) writes one down; the next window to open picks
|
|
37
|
+
* it up here instead of waiting for a restart.
|
|
38
|
+
*/
|
|
39
|
+
pickUp() {
|
|
40
|
+
if (this.font)
|
|
41
|
+
return;
|
|
42
|
+
let next = null;
|
|
43
|
+
try {
|
|
44
|
+
next = this.load();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Unreadable now; the next window to open asks again.
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!next)
|
|
51
|
+
return;
|
|
52
|
+
this.font = next;
|
|
53
|
+
this.send({ type: 'terminal_font', terminalFont: publicTerminalFont(next) });
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the terminal's font settings again, at a browser's request. Every
|
|
57
|
+
* window of this workstation hears the answer, since they all draw with it.
|
|
58
|
+
*/
|
|
59
|
+
refresh() {
|
|
60
|
+
try {
|
|
61
|
+
const next = this.load({ refresh: true });
|
|
62
|
+
if (next)
|
|
63
|
+
this.font = next;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
process.stderr.write(`herdr-remote host connector: could not read the terminal font: ${error.message}\n`);
|
|
67
|
+
}
|
|
68
|
+
this.send({ type: 'terminal_font', terminalFont: this.publicFont() });
|
|
69
|
+
}
|
|
70
|
+
/** One slice of a terminal font file, for the window that asked for it. */
|
|
71
|
+
sendChunk(streamId, request) {
|
|
72
|
+
const chunk = this.subsetChunk(request.sha256, request.index) ||
|
|
73
|
+
readFontChunk(this.font, request.sha256, request.index);
|
|
74
|
+
if (!chunk) {
|
|
75
|
+
this.send({
|
|
76
|
+
type: 'error',
|
|
77
|
+
clientId: streamId,
|
|
78
|
+
code: 'host_font_unavailable',
|
|
79
|
+
message: FONT_CHANGED,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.send({
|
|
84
|
+
type: 'host_font_chunk',
|
|
85
|
+
clientId: streamId,
|
|
86
|
+
sha256: request.sha256,
|
|
87
|
+
index: request.index,
|
|
88
|
+
total: chunk.total,
|
|
89
|
+
dataBase64: chunk.data.toString('base64'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The characters a window is about to draw, cut out of a large font (CJK).
|
|
94
|
+
* A small result travels in the answer itself; a large one (the common
|
|
95
|
+
* characters fetched up front) is held here and pulled in slices.
|
|
96
|
+
*/
|
|
97
|
+
sendSubset(streamId, request) {
|
|
98
|
+
const fail = (code, text) => this.send({ type: 'error', clientId: streamId, code, message: text });
|
|
99
|
+
const source = this.font?.subsets?.find((candidate) => candidate.sha256 === request.sha256);
|
|
100
|
+
if (!source || !this.unchanged(source)) {
|
|
101
|
+
fail('host_font_unavailable', FONT_CHANGED);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const codepoints = [
|
|
105
|
+
...new Set(Array.from(String(request.text || ''), (char) => char.codePointAt(0))),
|
|
106
|
+
];
|
|
107
|
+
let data;
|
|
108
|
+
try {
|
|
109
|
+
data = this.subsetter.subset(source, codepoints);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
fail('host_font_subset_failed', error.message || 'The font could not be subset');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const subsetSha = crypto.createHash('sha256').update(data).digest('hex');
|
|
116
|
+
const answer = {
|
|
117
|
+
type: 'host_font_subset_ready',
|
|
118
|
+
clientId: streamId,
|
|
119
|
+
requestId: request.requestId,
|
|
120
|
+
sha256: source.sha256,
|
|
121
|
+
subsetSha,
|
|
122
|
+
bytes: data.length,
|
|
123
|
+
};
|
|
124
|
+
if (data.length <= TERMINAL_FONT_CHUNK_BYTES) {
|
|
125
|
+
answer.dataBase64 = data.toString('base64');
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.hold(subsetSha, data);
|
|
129
|
+
}
|
|
130
|
+
this.send(answer);
|
|
131
|
+
}
|
|
132
|
+
/** Whether the file behind `source` is still the one that was announced. */
|
|
133
|
+
unchanged(source) {
|
|
134
|
+
try {
|
|
135
|
+
const stat = fs.statSync(source.path);
|
|
136
|
+
return stat.size === source.bytes && stat.mtimeMs === source.mtimeMs;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Keep a cut font for its slices, dropping the oldest beyond the cache size. */
|
|
143
|
+
hold(subsetSha, data) {
|
|
144
|
+
this.subsets.delete(subsetSha);
|
|
145
|
+
this.subsets.set(subsetSha, data);
|
|
146
|
+
let held = [...this.subsets.values()].reduce((sum, item) => sum + item.length, 0);
|
|
147
|
+
for (const [key, item] of this.subsets) {
|
|
148
|
+
if (held <= FONT_SUBSET_CACHE_BYTES || key === subsetSha)
|
|
149
|
+
break;
|
|
150
|
+
this.subsets.delete(key);
|
|
151
|
+
held -= item.length;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** A slice of a cut font still held for the browser that asked for it. */
|
|
155
|
+
subsetChunk(sha256, index) {
|
|
156
|
+
const data = this.subsets.get(sha256);
|
|
157
|
+
if (!data)
|
|
158
|
+
return null;
|
|
159
|
+
const total = Math.ceil(data.length / TERMINAL_FONT_CHUNK_BYTES);
|
|
160
|
+
if (!Number.isInteger(index) || index < 0 || index >= total)
|
|
161
|
+
return null;
|
|
162
|
+
const start = index * TERMINAL_FONT_CHUNK_BYTES;
|
|
163
|
+
return { data: data.subarray(start, start + TERMINAL_FONT_CHUNK_BYTES), total };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// The workstation's link to the relay: connect and reconnect, say hello, keep
|
|
2
|
+
// the socket alive, report load while someone watches, and hand each relay
|
|
3
|
+
// message to the part of the connector that owns it.
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { CAPABILITY, PROTOCOL_VERSION, unpackStreamFrame, } from 'herdr-remote-relay/protocol';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { loadConfig } from '../config.js';
|
|
9
|
+
import { EXIT_AUTH_FAILED, EXIT_REPLACED } from '../exit-codes.js';
|
|
10
|
+
import { cleanPastedDir, savePastedFile } from '../pasted-files.js';
|
|
11
|
+
import { stateDir } from '../paths.js';
|
|
12
|
+
import { hostWebSocketUrl, resolveHostRelayUrl } from '../relay-urls.js';
|
|
13
|
+
import { resolveSocketPath } from '../socket-discovery.js';
|
|
14
|
+
import { resolveHostPalette } from '../terminal-palette.js';
|
|
15
|
+
import { randomToken } from 'herdr-remote-relay/state';
|
|
16
|
+
import { AgentWatch } from './agent-watch.js';
|
|
17
|
+
import { FontServer } from './font-server.js';
|
|
18
|
+
import { acquireHostLock, releaseHostLock } from './lock.js';
|
|
19
|
+
import { Sessions } from './sessions.js';
|
|
20
|
+
import { UpdateReport } from './update-report.js';
|
|
21
|
+
import { closeSocket, sendJson, streamOf, terminateSocket } from './wire.js';
|
|
22
|
+
/** Handshake errors that no reconnect can fix. */
|
|
23
|
+
const AUTH_ERROR_CODES = [
|
|
24
|
+
'relay_password_required',
|
|
25
|
+
'host_auth_failed',
|
|
26
|
+
'invalid_host_credentials',
|
|
27
|
+
];
|
|
28
|
+
export class HostConnector {
|
|
29
|
+
config;
|
|
30
|
+
relayUrl;
|
|
31
|
+
hostId;
|
|
32
|
+
hostToken;
|
|
33
|
+
relayPassword;
|
|
34
|
+
socketPath;
|
|
35
|
+
/**
|
|
36
|
+
* What the workstation's own terminal looks like. A browser has no way to
|
|
37
|
+
* know it — the PTY carries color indices, not colors — so the host reports
|
|
38
|
+
* it and the browser renders the session the way the workstation sees it.
|
|
39
|
+
* Normally captured by the start path that still had a terminal and passed
|
|
40
|
+
* down in the environment; `null` when nobody could ask.
|
|
41
|
+
*/
|
|
42
|
+
terminalPalette;
|
|
43
|
+
sessions;
|
|
44
|
+
font;
|
|
45
|
+
agents;
|
|
46
|
+
updates;
|
|
47
|
+
ws = null;
|
|
48
|
+
reconnectTimer = null;
|
|
49
|
+
heartbeatTimer = null;
|
|
50
|
+
keepaliveTimer = null;
|
|
51
|
+
reconnectAttempts = 0;
|
|
52
|
+
clientCount = 0;
|
|
53
|
+
/** The relay predates `client_count`; report load all the time, as it expects. */
|
|
54
|
+
legacyHeartbeat = false;
|
|
55
|
+
authFailure = false;
|
|
56
|
+
stopping = false;
|
|
57
|
+
lockPath;
|
|
58
|
+
lockFd = null;
|
|
59
|
+
onFatal;
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
const config = options.config || loadConfig();
|
|
62
|
+
this.config = config;
|
|
63
|
+
this.relayUrl =
|
|
64
|
+
options.relayUrl ||
|
|
65
|
+
(process.env.RELAY_URL
|
|
66
|
+
? hostWebSocketUrl(process.env.RELAY_URL)
|
|
67
|
+
: resolveHostRelayUrl(config));
|
|
68
|
+
this.hostId = options.hostId || process.env.RELAY_HOST_ID || `host-${randomToken(9)}`;
|
|
69
|
+
this.hostToken = options.hostToken || process.env.RELAY_HOST_TOKEN || '';
|
|
70
|
+
// Optional; a relay without a password accepts any workstation.
|
|
71
|
+
this.relayPassword = options.relayPassword ?? process.env.RELAY_PASSWORD ?? '';
|
|
72
|
+
this.socketPath = options.socketPath || resolveSocketPath(config.herdr.socketPath);
|
|
73
|
+
this.terminalPalette =
|
|
74
|
+
options.terminalPalette !== undefined ? options.terminalPalette : resolveHostPalette();
|
|
75
|
+
const send = (payload) => sendJson(this.ws, payload);
|
|
76
|
+
this.sessions = new Sessions({
|
|
77
|
+
...options,
|
|
78
|
+
socketPath: this.socketPath,
|
|
79
|
+
herdrArgs: options.herdrArgs || config.herdr.args,
|
|
80
|
+
cwd: options.cwd || config.herdr.cwd,
|
|
81
|
+
herdrLogPath: options.herdrLogPath || path.join(stateDir(), 'herdr-server.log'),
|
|
82
|
+
}, {
|
|
83
|
+
send,
|
|
84
|
+
sendFrame: (frame) => {
|
|
85
|
+
if (this.ws?.readyState === WebSocket.OPEN)
|
|
86
|
+
this.ws.send(frame);
|
|
87
|
+
},
|
|
88
|
+
changed: () => this.sendHeartbeat(),
|
|
89
|
+
});
|
|
90
|
+
this.font = new FontServer(options, send);
|
|
91
|
+
this.agents = new AgentWatch({ ...options, socketPath: this.socketPath }, send);
|
|
92
|
+
this.updates = new UpdateReport(options, send);
|
|
93
|
+
this.lockPath =
|
|
94
|
+
options.lockPath ||
|
|
95
|
+
process.env.HERDR_REMOTE_HOST_LOCK ||
|
|
96
|
+
path.join(stateDir(), 'host-connector.lock');
|
|
97
|
+
this.onFatal = options.onFatal || ((exitCode) => process.exit(exitCode));
|
|
98
|
+
try {
|
|
99
|
+
cleanPastedDir();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Old pastes are tidied again next start; a connector must still come up.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
acquireLock() {
|
|
106
|
+
if (this.lockFd !== null)
|
|
107
|
+
return;
|
|
108
|
+
this.lockFd = acquireHostLock(this.lockPath, this.hostId);
|
|
109
|
+
}
|
|
110
|
+
releaseLock() {
|
|
111
|
+
if (this.lockFd === null)
|
|
112
|
+
return;
|
|
113
|
+
releaseHostLock(this.lockPath, this.lockFd);
|
|
114
|
+
this.lockFd = null;
|
|
115
|
+
}
|
|
116
|
+
start() {
|
|
117
|
+
if (!this.hostToken) {
|
|
118
|
+
throw new Error('RELAY_HOST_TOKEN is required');
|
|
119
|
+
}
|
|
120
|
+
this.acquireLock();
|
|
121
|
+
this.stopping = false;
|
|
122
|
+
this.connect();
|
|
123
|
+
// Off unless the user switched it on: Herdr is theirs to start.
|
|
124
|
+
if (this.config.herdr?.autoStart) {
|
|
125
|
+
this.sessions.startHerdr().catch((error) => {
|
|
126
|
+
process.stderr.write(`herdr-remote host connector: could not start Herdr: ${error.message}\n`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
stop() {
|
|
131
|
+
this.stopping = true;
|
|
132
|
+
if (this.reconnectTimer)
|
|
133
|
+
clearTimeout(this.reconnectTimer);
|
|
134
|
+
this.reconnectTimer = null;
|
|
135
|
+
sendJson(this.ws, { type: 'host_shutdown' });
|
|
136
|
+
this.disconnected();
|
|
137
|
+
closeSocket(this.ws, 'host_shutdown');
|
|
138
|
+
this.ws = null;
|
|
139
|
+
this.releaseLock();
|
|
140
|
+
}
|
|
141
|
+
/** Forget everything that lived on the relay connection. */
|
|
142
|
+
disconnected() {
|
|
143
|
+
if (this.heartbeatTimer)
|
|
144
|
+
clearInterval(this.heartbeatTimer);
|
|
145
|
+
this.heartbeatTimer = null;
|
|
146
|
+
this.stopKeepalive();
|
|
147
|
+
// Nothing to report to, so stop asking Herdr. A reconnect brings the
|
|
148
|
+
// watcher back with the relay's next client count.
|
|
149
|
+
this.agents.stop();
|
|
150
|
+
this.clientCount = 0;
|
|
151
|
+
this.legacyHeartbeat = false;
|
|
152
|
+
this.sessions.stopAll();
|
|
153
|
+
}
|
|
154
|
+
connect() {
|
|
155
|
+
if (this.stopping ||
|
|
156
|
+
this.ws?.readyState === WebSocket.OPEN ||
|
|
157
|
+
this.ws?.readyState === WebSocket.CONNECTING)
|
|
158
|
+
return;
|
|
159
|
+
this.stopKeepalive();
|
|
160
|
+
let ws;
|
|
161
|
+
try {
|
|
162
|
+
ws = new WebSocket(this.relayUrl);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
this.scheduleReconnect(error);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.ws = ws;
|
|
169
|
+
ws.isAlive = true;
|
|
170
|
+
ws.on('open', () => {
|
|
171
|
+
// Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
|
|
172
|
+
try {
|
|
173
|
+
ws._socket?.setNoDelay(true);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// The socket may not be TCP (tests) or already gone; Nagle is only a latency tweak.
|
|
177
|
+
}
|
|
178
|
+
ws.isAlive = true;
|
|
179
|
+
this.authFailure = false;
|
|
180
|
+
sendJson(ws, {
|
|
181
|
+
type: 'host_hello',
|
|
182
|
+
protocol: PROTOCOL_VERSION,
|
|
183
|
+
hostId: this.hostId,
|
|
184
|
+
token: this.hostToken,
|
|
185
|
+
password: this.relayPassword || null,
|
|
186
|
+
hostname: os.hostname(),
|
|
187
|
+
platform: process.platform,
|
|
188
|
+
arch: process.arch,
|
|
189
|
+
terminalPalette: this.terminalPalette || null,
|
|
190
|
+
terminalFont: this.font.publicFont(),
|
|
191
|
+
capabilities: [CAPABILITY.hostHandoff, CAPABILITY.idleHeartbeat, CAPABILITY.binaryFrameV2],
|
|
192
|
+
});
|
|
193
|
+
// The relay sends host_ready with the current browser count. No business
|
|
194
|
+
// heartbeat is started until that message says somebody is watching.
|
|
195
|
+
});
|
|
196
|
+
ws.on('pong', () => {
|
|
197
|
+
ws.isAlive = true;
|
|
198
|
+
});
|
|
199
|
+
ws.on('message', (raw, isBinary) => this.handleMessage(raw, isBinary));
|
|
200
|
+
ws.on('close', (_code, rawReason) => {
|
|
201
|
+
// A replacement socket may be live while an older socket is still
|
|
202
|
+
// delivering its close event. Never let that stale event destroy the new
|
|
203
|
+
// session or clear its heartbeat timer.
|
|
204
|
+
if (this.ws !== ws)
|
|
205
|
+
return;
|
|
206
|
+
this.ws = null;
|
|
207
|
+
this.disconnected();
|
|
208
|
+
// Another connector has claimed this workstation. Reconnecting would just
|
|
209
|
+
// displace it in turn, and each swap drops every attached browser, so
|
|
210
|
+
// stand down instead of fighting for the slot.
|
|
211
|
+
const reason = rawReason ? rawReason.toString() : '';
|
|
212
|
+
if (reason === 'host_replaced') {
|
|
213
|
+
process.stderr.write('herdr-remote host connector: another instance took over this workstation; exiting\n');
|
|
214
|
+
this.stop();
|
|
215
|
+
this.onFatal(EXIT_REPLACED);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (this.authFailure) {
|
|
219
|
+
this.stopping = true;
|
|
220
|
+
process.stderr.write('herdr-remote host connector: authentication failed; update relay credentials and restart the service\n');
|
|
221
|
+
this.releaseLock();
|
|
222
|
+
this.onFatal(EXIT_AUTH_FAILED);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.scheduleReconnect();
|
|
226
|
+
});
|
|
227
|
+
ws.on('error', (error) => {
|
|
228
|
+
process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
scheduleReconnect(error = null) {
|
|
232
|
+
if (this.stopping || this.reconnectTimer)
|
|
233
|
+
return;
|
|
234
|
+
if (error)
|
|
235
|
+
process.stderr.write(`herdr-remote host connector: ${error.message}\n`);
|
|
236
|
+
const delay = Math.min(30_000, 500 * 1.5 ** this.reconnectAttempts + Math.floor(Math.random() * 250));
|
|
237
|
+
this.reconnectAttempts += 1;
|
|
238
|
+
this.reconnectTimer = setTimeout(() => {
|
|
239
|
+
this.reconnectTimer = null;
|
|
240
|
+
this.connect();
|
|
241
|
+
}, delay);
|
|
242
|
+
}
|
|
243
|
+
handleMessage(raw, isBinary = false) {
|
|
244
|
+
if (isBinary) {
|
|
245
|
+
this.handleFrame(raw);
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
let message;
|
|
249
|
+
try {
|
|
250
|
+
message = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Not JSON: nothing a relay speaking this protocol would send.
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
switch (message.type) {
|
|
257
|
+
case 'error':
|
|
258
|
+
// A relay-level error (no clientId) is a rejected handshake — a wrong
|
|
259
|
+
// relay password, or a host token the relay does not recognise. Without
|
|
260
|
+
// this the connection just closed silently and reconnected forever,
|
|
261
|
+
// leaving the user with an empty log and no idea what was wrong.
|
|
262
|
+
if (message.clientId)
|
|
263
|
+
return undefined;
|
|
264
|
+
this.authFailure = AUTH_ERROR_CODES.includes(message.code);
|
|
265
|
+
process.stderr.write(`herdr-remote host connector: relay rejected the connection: ${message.message || message.code}\n`);
|
|
266
|
+
return undefined;
|
|
267
|
+
case 'host_ready':
|
|
268
|
+
this.hostReady(message);
|
|
269
|
+
return undefined;
|
|
270
|
+
case 'client_count':
|
|
271
|
+
this.setClientCount(message.clientCount);
|
|
272
|
+
return undefined;
|
|
273
|
+
case 'session_start':
|
|
274
|
+
return this.startSession(message);
|
|
275
|
+
case 'herdr_start':
|
|
276
|
+
return this.sessions.startHerdrFor(streamOf(message));
|
|
277
|
+
case 'session_stop': {
|
|
278
|
+
const streamId = streamOf(message);
|
|
279
|
+
if (streamId)
|
|
280
|
+
this.sessions.stop(streamId);
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
case 'resize':
|
|
284
|
+
this.sessions.resize(message);
|
|
285
|
+
return undefined;
|
|
286
|
+
case 'paste_file':
|
|
287
|
+
this.savePaste(streamOf(message), message);
|
|
288
|
+
return undefined;
|
|
289
|
+
case 'host_font_chunk_request':
|
|
290
|
+
this.font.sendChunk(streamOf(message), message);
|
|
291
|
+
return undefined;
|
|
292
|
+
case 'host_font_subset_request':
|
|
293
|
+
this.font.sendSubset(streamOf(message), message);
|
|
294
|
+
return undefined;
|
|
295
|
+
case 'host_font_refresh':
|
|
296
|
+
this.font.refresh();
|
|
297
|
+
return undefined;
|
|
298
|
+
default:
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
handleFrame(raw) {
|
|
303
|
+
let frame;
|
|
304
|
+
try {
|
|
305
|
+
frame = unpackStreamFrame(raw);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (frame.type !== 'input')
|
|
312
|
+
return;
|
|
313
|
+
this.sessions.write(frame.version === 2 ? { streamIndex: frame.streamIndex } : { streamId: frame.streamId }, frame.payload);
|
|
314
|
+
}
|
|
315
|
+
hostReady(message) {
|
|
316
|
+
this.reconnectAttempts = 0;
|
|
317
|
+
this.startKeepalive();
|
|
318
|
+
if (Object.hasOwn(message, 'clientCount')) {
|
|
319
|
+
this.legacyHeartbeat = false;
|
|
320
|
+
this.setClientCount(message.clientCount);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// An older relay does not know client_count. Keep its historical
|
|
324
|
+
// telemetry behavior so rolling upgrades do not silently lose status.
|
|
325
|
+
this.legacyHeartbeat = true;
|
|
326
|
+
this.sendHeartbeat(true);
|
|
327
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
328
|
+
}
|
|
329
|
+
startSession(message) {
|
|
330
|
+
if (!streamOf(message))
|
|
331
|
+
return undefined;
|
|
332
|
+
// A window opening is when a person is looking; that is when to ask.
|
|
333
|
+
this.updates.report();
|
|
334
|
+
this.font.pickUp();
|
|
335
|
+
return this.sessions.start(message);
|
|
336
|
+
}
|
|
337
|
+
savePaste(streamId, message) {
|
|
338
|
+
try {
|
|
339
|
+
const savedPath = savePastedFile({ mime: message.mime, dataBase64: message.dataBase64 });
|
|
340
|
+
sendJson(this.ws, { type: 'paste_file_ready', clientId: streamId, path: savedPath });
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
sendJson(this.ws, {
|
|
344
|
+
type: 'error',
|
|
345
|
+
clientId: streamId,
|
|
346
|
+
code: 'paste_file_write_failed',
|
|
347
|
+
message: error.message || 'Failed to save pasted file',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
startKeepalive() {
|
|
352
|
+
this.stopKeepalive();
|
|
353
|
+
if (!this.ws || this.stopping)
|
|
354
|
+
return;
|
|
355
|
+
this.ws.isAlive = true;
|
|
356
|
+
const interval = this.config?.cleanup?.heartbeatIntervalMs || 30_000;
|
|
357
|
+
this.keepaliveTimer = setInterval(() => this.tickKeepalive(), interval);
|
|
358
|
+
}
|
|
359
|
+
stopKeepalive() {
|
|
360
|
+
if (this.keepaliveTimer) {
|
|
361
|
+
clearInterval(this.keepaliveTimer);
|
|
362
|
+
this.keepaliveTimer = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
tickKeepalive() {
|
|
366
|
+
const ws = this.ws;
|
|
367
|
+
if (!ws || this.stopping)
|
|
368
|
+
return;
|
|
369
|
+
if (ws.readyState === WebSocket.CLOSING) {
|
|
370
|
+
terminateSocket(ws);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
374
|
+
return;
|
|
375
|
+
if (!ws.isAlive) {
|
|
376
|
+
terminateSocket(ws);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
ws.isAlive = false;
|
|
380
|
+
try {
|
|
381
|
+
if (typeof ws.ping === 'function')
|
|
382
|
+
ws.ping();
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// A socket failing mid-ping is caught by the next heartbeat, which terminates it.
|
|
386
|
+
}
|
|
387
|
+
if (this.clientCount <= 0 && !this.legacyHeartbeat) {
|
|
388
|
+
sendJson(ws, { type: 'heartbeat', load: {}, ptys: [] });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
setClientCount(value) {
|
|
392
|
+
const next = Number.isInteger(value) ? Math.max(0, value) : 0;
|
|
393
|
+
if (next === this.clientCount && (next === 0 || this.heartbeatTimer))
|
|
394
|
+
return;
|
|
395
|
+
const wasActive = this.clientCount > 0;
|
|
396
|
+
this.clientCount = next;
|
|
397
|
+
if (this.heartbeatTimer)
|
|
398
|
+
clearInterval(this.heartbeatTimer);
|
|
399
|
+
this.heartbeatTimer = null;
|
|
400
|
+
if (next > 0) {
|
|
401
|
+
this.sendHeartbeat(true);
|
|
402
|
+
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.config.cleanup.heartbeatIntervalMs);
|
|
403
|
+
}
|
|
404
|
+
else if (wasActive) {
|
|
405
|
+
this.sendHeartbeat(true);
|
|
406
|
+
}
|
|
407
|
+
// Nobody watching, nothing to ask Herdr about itself.
|
|
408
|
+
if (next > 0 && !this.stopping)
|
|
409
|
+
this.agents.start();
|
|
410
|
+
else
|
|
411
|
+
this.agents.stop();
|
|
412
|
+
}
|
|
413
|
+
sendHeartbeat(force = false) {
|
|
414
|
+
if (!force && this.clientCount <= 0 && !this.legacyHeartbeat)
|
|
415
|
+
return;
|
|
416
|
+
const memory = process.memoryUsage();
|
|
417
|
+
const load = os.loadavg();
|
|
418
|
+
sendJson(this.ws, {
|
|
419
|
+
type: 'heartbeat',
|
|
420
|
+
load: {
|
|
421
|
+
load1m: load[0] || 0,
|
|
422
|
+
load5m: load[1] || 0,
|
|
423
|
+
load15m: load[2] || 0,
|
|
424
|
+
rssBytes: memory.rss,
|
|
425
|
+
heapUsedBytes: memory.heapUsed,
|
|
426
|
+
},
|
|
427
|
+
ptys: this.sessions.summaries(),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// One host connector per workstation: a lock file naming the process that holds it.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ensureDir } from 'herdr-remote-relay/state';
|
|
5
|
+
import { pidAlive } from '../lib/process.js';
|
|
6
|
+
function readOwner(lockPath) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Missing, or half-written by a process that died mid-write: nobody owns it.
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function lockError(message, code) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.code = code;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Take the lock, or throw `HOST_ALREADY_RUNNING` when a live process holds it.
|
|
22
|
+
* A lock left by a dead process is removed and taken over.
|
|
23
|
+
*/
|
|
24
|
+
export function acquireHostLock(lockPath, hostId) {
|
|
25
|
+
ensureDir(path.dirname(lockPath));
|
|
26
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
27
|
+
try {
|
|
28
|
+
const fd = fs.openSync(lockPath, 'wx', 0o600);
|
|
29
|
+
fs.writeFileSync(fd, `${JSON.stringify({ pid: process.pid, hostId, startedAt: new Date().toISOString() })}\n`);
|
|
30
|
+
return fd;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'EEXIST')
|
|
34
|
+
throw error;
|
|
35
|
+
const owner = readOwner(lockPath);
|
|
36
|
+
if (owner && pidAlive(owner.pid)) {
|
|
37
|
+
throw lockError(`another host connector is already running (pid ${owner.pid})`, 'HOST_ALREADY_RUNNING');
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
fs.rmSync(lockPath, { force: true });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Not ours to remove; the second attempt fails and says so below.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lockError('could not acquire host connector lock', 'HOST_LOCK_FAILED');
|
|
48
|
+
}
|
|
49
|
+
/** Close `fd` and remove the lock, unless another process has taken it since. */
|
|
50
|
+
export function releaseHostLock(lockPath, fd) {
|
|
51
|
+
try {
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Already closed; the file below is what matters.
|
|
56
|
+
}
|
|
57
|
+
const owner = readOwner(lockPath);
|
|
58
|
+
if (owner && owner.pid !== process.pid)
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
fs.rmSync(lockPath, { force: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Stopping must not fail over a lock file; a dead owner's lock is taken over.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Process entry for the host connector; service.ts spawns this file.
|
|
2
|
+
import { EXIT_REPLACED } from '../exit-codes.js';
|
|
3
|
+
import { HostConnector } from './host-connector.js';
|
|
4
|
+
const connector = new HostConnector({ onFatal: (exitCode) => process.exit(exitCode) });
|
|
5
|
+
try {
|
|
6
|
+
connector.start();
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
const { code, message } = error;
|
|
10
|
+
process.stderr.write(`herdr-remote host connector failed: ${message}\n`);
|
|
11
|
+
process.exitCode = code === 'HOST_ALREADY_RUNNING' ? EXIT_REPLACED : 1;
|
|
12
|
+
}
|
|
13
|
+
const stop = () => {
|
|
14
|
+
connector.stop();
|
|
15
|
+
process.exit(0);
|
|
16
|
+
};
|
|
17
|
+
process.once('SIGINT', stop);
|
|
18
|
+
process.once('SIGTERM', stop);
|