herdr-remote 0.2.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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/bin/herdr-remote.js +225 -0
- package/config.example.json +19 -0
- package/dist/tui.mjs +3982 -0
- package/herdr-plugin.toml +61 -0
- package/package.json +60 -0
- package/src/config.js +411 -0
- package/src/exit-codes.js +14 -0
- package/src/herdr-command.js +12 -0
- package/src/herdr-plugin.js +122 -0
- package/src/host-connector.js +280 -0
- package/src/i18n/en.js +219 -0
- package/src/i18n/index.js +56 -0
- package/src/i18n/zh.js +218 -0
- package/src/keepalive.js +404 -0
- package/src/lifecycle.js +58 -0
- package/src/net-interfaces.js +76 -0
- package/src/pty-session.js +92 -0
- package/src/service.js +492 -0
- package/src/settings-model.js +265 -0
- package/src/socket-discovery.js +55 -0
- package/src/state.js +47 -0
- package/src/supervisor.js +242 -0
- package/src/terminal-palette.js +325 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Editable settings, independent of how they are rendered.
|
|
4
|
+
//
|
|
5
|
+
// The TUI holds a *draft* copy of the config, mutates it through these pure
|
|
6
|
+
// functions and writes it out on save. Keeping the rules here (rather than in
|
|
7
|
+
// the view) means they can be tested without a terminal, and the same
|
|
8
|
+
// validation applies to the first-run wizard and the settings screen.
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
ACCESS_MODES,
|
|
12
|
+
KEEPALIVE_MANAGERS,
|
|
13
|
+
LANGUAGES,
|
|
14
|
+
configDir,
|
|
15
|
+
configPath,
|
|
16
|
+
isLoopbackHost,
|
|
17
|
+
isUnspecifiedAddress,
|
|
18
|
+
isUnspecifiedHost,
|
|
19
|
+
loadConfig,
|
|
20
|
+
resolvePublicUrl,
|
|
21
|
+
} = require('./config');
|
|
22
|
+
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
23
|
+
const { preferredLanAddress } = require('./net-interfaces');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Field metadata. `kind` drives the editor the TUI shows; `visibleFor` limits a
|
|
27
|
+
* field to the access modes where it means anything.
|
|
28
|
+
*/
|
|
29
|
+
const FIELDS = [
|
|
30
|
+
{ id: 'mode', kind: 'choice', choices: ACCESS_MODES, labelKey: 'field.mode' },
|
|
31
|
+
{ id: 'port', kind: 'text', labelKey: 'field.port', visibleFor: ['local', 'lan'] },
|
|
32
|
+
{ id: 'lanHost', kind: 'address', labelKey: 'field.lanHost', visibleFor: ['lan'] },
|
|
33
|
+
{ id: 'remoteUrl', kind: 'text', labelKey: 'field.remoteUrl', visibleFor: ['remote'] },
|
|
34
|
+
{ id: 'publicUrl', kind: 'text', labelKey: 'field.publicUrl' },
|
|
35
|
+
{ id: 'socketPath', kind: 'text', labelKey: 'field.socketPath' },
|
|
36
|
+
{ id: 'herdrArgs', kind: 'text', labelKey: 'field.herdrArgs' },
|
|
37
|
+
{ id: 'language', kind: 'choice', choices: LANGUAGES, labelKey: 'field.language' },
|
|
38
|
+
{ id: 'keepaliveManager', kind: 'choice', choices: KEEPALIVE_MANAGERS, labelKey: 'field.keepalive' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const EMPTY = '';
|
|
42
|
+
|
|
43
|
+
function clone(value) {
|
|
44
|
+
return JSON.parse(JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createDraft(config = loadConfig()) {
|
|
48
|
+
return clone(config);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fieldsForMode(mode) {
|
|
52
|
+
return FIELDS.filter((field) => !field.visibleFor || field.visibleFor.includes(mode));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Current value of a field as an editable string. */
|
|
56
|
+
function getField(draft, id) {
|
|
57
|
+
switch (id) {
|
|
58
|
+
case 'mode':
|
|
59
|
+
return draft.relay.mode;
|
|
60
|
+
case 'port':
|
|
61
|
+
return String(draft.relay.port);
|
|
62
|
+
case 'lanHost':
|
|
63
|
+
return draft.relay.lanHost || EMPTY;
|
|
64
|
+
case 'remoteUrl':
|
|
65
|
+
return draft.relay.remoteUrl || EMPTY;
|
|
66
|
+
case 'publicUrl':
|
|
67
|
+
return draft.relay.publicUrl || EMPTY;
|
|
68
|
+
case 'socketPath':
|
|
69
|
+
return draft.herdr.socketPath || EMPTY;
|
|
70
|
+
case 'herdrArgs':
|
|
71
|
+
return (draft.herdr.args || []).join(' ');
|
|
72
|
+
case 'language':
|
|
73
|
+
return draft.ui.language;
|
|
74
|
+
case 'keepaliveManager':
|
|
75
|
+
return draft.keepalive.manager;
|
|
76
|
+
default:
|
|
77
|
+
return EMPTY;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What the field shows when it is empty: the value that will actually be used.
|
|
83
|
+
* Displaying the derived value beats an empty box the user cannot interpret.
|
|
84
|
+
*/
|
|
85
|
+
function getFieldPlaceholder(draft, id) {
|
|
86
|
+
switch (id) {
|
|
87
|
+
case 'lanHost':
|
|
88
|
+
return preferredLanAddress() || '0.0.0.0';
|
|
89
|
+
case 'publicUrl':
|
|
90
|
+
return resolvePublicUrl(draft, preferredLanAddress());
|
|
91
|
+
case 'socketPath':
|
|
92
|
+
return 'placeholder.autoDiscovered';
|
|
93
|
+
case 'herdrArgs':
|
|
94
|
+
return 'placeholder.none';
|
|
95
|
+
case 'remoteUrl':
|
|
96
|
+
return 'wss://relay.example.com';
|
|
97
|
+
default:
|
|
98
|
+
return EMPTY;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isValidUrl(value, protocols) {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(value);
|
|
105
|
+
return protocols.includes(url.protocol);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Apply a value to a copy of the draft.
|
|
113
|
+
*
|
|
114
|
+
* Returns `{ draft, errorKey }`. On a validation failure the draft comes back
|
|
115
|
+
* unchanged and `errorKey` names a translatable message, so callers never have
|
|
116
|
+
* to guess whether the edit landed.
|
|
117
|
+
*/
|
|
118
|
+
function setField(draft, id, rawValue) {
|
|
119
|
+
const next = clone(draft);
|
|
120
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
|
|
121
|
+
|
|
122
|
+
switch (id) {
|
|
123
|
+
case 'mode': {
|
|
124
|
+
if (!ACCESS_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
|
|
125
|
+
next.relay.mode = value;
|
|
126
|
+
if (value === 'lan'
|
|
127
|
+
&& (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
|
|
128
|
+
next.relay.lanHost = EMPTY;
|
|
129
|
+
}
|
|
130
|
+
// A public URL pinned for one mode is wrong for the next one; clearing it
|
|
131
|
+
// lets derivation produce the right address again.
|
|
132
|
+
next.relay.publicUrl = EMPTY;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'port': {
|
|
136
|
+
const port = Number.parseInt(value, 10);
|
|
137
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { draft, errorKey: 'error.invalidPort' };
|
|
138
|
+
next.relay.port = port;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'lanHost': {
|
|
142
|
+
if (value && (isLoopbackHost(value) || isUnspecifiedAddress(value))) {
|
|
143
|
+
return { draft, errorKey: 'error.invalidLanHost' };
|
|
144
|
+
}
|
|
145
|
+
next.relay.lanHost = value || EMPTY;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'remoteUrl': {
|
|
149
|
+
if (value && !isValidUrl(value, ['ws:', 'wss:', 'http:', 'https:'])) {
|
|
150
|
+
return { draft, errorKey: 'error.invalidRelayUrl' };
|
|
151
|
+
}
|
|
152
|
+
next.relay.remoteUrl = value.replace(/\/+$/, '');
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case 'publicUrl': {
|
|
156
|
+
if (value && (!isValidUrl(value, ['http:', 'https:']) || isUnspecifiedHost(value))) {
|
|
157
|
+
return { draft, errorKey: 'error.invalidPublicUrl' };
|
|
158
|
+
}
|
|
159
|
+
next.relay.publicUrl = value.replace(/\/+$/, '');
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'socketPath': {
|
|
163
|
+
next.herdr.socketPath = value || null;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'herdrArgs': {
|
|
167
|
+
next.herdr.args = value ? value.split(/\s+/).filter(Boolean) : [];
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'language': {
|
|
171
|
+
if (!LANGUAGES.includes(value)) return { draft, errorKey: 'error.invalidLanguage' };
|
|
172
|
+
next.ui.language = value;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'keepaliveManager': {
|
|
176
|
+
if (!KEEPALIVE_MANAGERS.includes(value)) return { draft, errorKey: 'error.invalidKeepalive' };
|
|
177
|
+
next.keepalive.manager = value;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
return { draft, errorKey: 'error.unknownField' };
|
|
182
|
+
}
|
|
183
|
+
return { draft: next, errorKey: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Problems that should block saving, as translatable keys. */
|
|
187
|
+
function validateDraft(draft) {
|
|
188
|
+
const problems = [];
|
|
189
|
+
if (draft.relay.mode === 'remote' && !draft.relay.remoteUrl) problems.push('error.remoteUrlRequired');
|
|
190
|
+
return problems;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Persist a draft, merging into whatever is already on disk so keys this
|
|
195
|
+
* version does not know about survive the round trip.
|
|
196
|
+
*/
|
|
197
|
+
function saveDraft(draft) {
|
|
198
|
+
const problems = validateDraft(draft);
|
|
199
|
+
if (problems.length > 0) {
|
|
200
|
+
const error = new Error(`configuration is incomplete: ${problems.join(', ')}`);
|
|
201
|
+
error.problems = problems;
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const current = readJson(configPath(), {}) || {};
|
|
206
|
+
const merged = { ...current };
|
|
207
|
+
merged.ui = { ...(current.ui || {}), language: draft.ui.language };
|
|
208
|
+
merged.relay = {
|
|
209
|
+
...(current.relay || {}),
|
|
210
|
+
mode: draft.relay.mode,
|
|
211
|
+
port: draft.relay.port,
|
|
212
|
+
lanHost: draft.relay.mode === 'lan'
|
|
213
|
+
&& (isLoopbackHost(draft.relay.lanHost) || isUnspecifiedAddress(draft.relay.lanHost))
|
|
214
|
+
? EMPTY
|
|
215
|
+
: draft.relay.lanHost,
|
|
216
|
+
publicUrl: draft.relay.publicUrl,
|
|
217
|
+
remoteUrl: draft.relay.remoteUrl,
|
|
218
|
+
};
|
|
219
|
+
// Fields from the 0.1 schema would otherwise keep overriding the new ones on
|
|
220
|
+
// the next load.
|
|
221
|
+
delete merged.relay.local;
|
|
222
|
+
delete merged.relay.host;
|
|
223
|
+
delete merged.relay.url;
|
|
224
|
+
// The optional Herdr source patch is gone. Nothing reads this section any
|
|
225
|
+
// more, so the next save is where a config written by an older build sheds
|
|
226
|
+
// it rather than carrying stale checksums and paths forever.
|
|
227
|
+
delete merged.patch;
|
|
228
|
+
merged.herdr = {
|
|
229
|
+
...(current.herdr || {}),
|
|
230
|
+
socketPath: draft.herdr.socketPath,
|
|
231
|
+
args: draft.herdr.args,
|
|
232
|
+
};
|
|
233
|
+
if (!merged.herdr.socketPath) delete merged.herdr.socketPath;
|
|
234
|
+
merged.keepalive = { ...(current.keepalive || {}), manager: draft.keepalive.manager };
|
|
235
|
+
|
|
236
|
+
ensureDir(configDir());
|
|
237
|
+
writeJsonAtomic(configPath(), merged);
|
|
238
|
+
return { ok: true, path: configPath() };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Settings that only take effect after the services restart. Used to show a
|
|
243
|
+
* "restart required" hint rather than silently doing nothing.
|
|
244
|
+
*/
|
|
245
|
+
function requiresRestart(before, after) {
|
|
246
|
+
return before.relay.mode !== after.relay.mode
|
|
247
|
+
|| before.relay.port !== after.relay.port
|
|
248
|
+
|| before.relay.lanHost !== after.relay.lanHost
|
|
249
|
+
|| before.relay.remoteUrl !== after.relay.remoteUrl
|
|
250
|
+
|| before.relay.publicUrl !== after.relay.publicUrl
|
|
251
|
+
|| before.herdr.socketPath !== after.herdr.socketPath
|
|
252
|
+
|| before.herdr.args.join(' ') !== after.herdr.args.join(' ');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
FIELDS,
|
|
257
|
+
createDraft,
|
|
258
|
+
fieldsForMode,
|
|
259
|
+
getField,
|
|
260
|
+
getFieldPlaceholder,
|
|
261
|
+
setField,
|
|
262
|
+
validateDraft,
|
|
263
|
+
saveDraft,
|
|
264
|
+
requiresRestart,
|
|
265
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function defaultSocketPath(env = process.env, platform = process.platform) {
|
|
8
|
+
if (env.HERDR_SOCKET_PATH) return env.HERDR_SOCKET_PATH;
|
|
9
|
+
if (platform === 'win32') {
|
|
10
|
+
const appData = env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
11
|
+
return path.join(appData, 'herdr', 'herdr.sock');
|
|
12
|
+
}
|
|
13
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
14
|
+
return path.join(configHome, 'herdr', 'herdr.sock');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveSocketPath(configuredPath = null, env = process.env) {
|
|
18
|
+
return configuredPath || env.HERDR_SOCKET_PATH || defaultSocketPath(env);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function inspectSocket(socketPath, options = {}) {
|
|
22
|
+
if (typeof socketPath !== 'string' || socketPath.length === 0) {
|
|
23
|
+
return { ok: false, reason: 'socket path is empty', path: socketPath };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.statSync(socketPath);
|
|
27
|
+
if (process.platform !== 'win32' && !stat.isSocket()) {
|
|
28
|
+
return { ok: false, reason: 'path is not a Unix socket', path: socketPath };
|
|
29
|
+
}
|
|
30
|
+
if (options.requireOwner !== false && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
31
|
+
return { ok: false, reason: 'socket is not owned by the current user', path: socketPath, uid: stat.uid };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, path: socketPath, uid: stat.uid, mode: stat.mode };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { ok: false, reason: error.code === 'ENOENT' ? 'socket does not exist' : error.message, path: socketPath };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertSocket(socketPath, options = {}) {
|
|
40
|
+
const result = inspectSocket(socketPath, options);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
const error = new Error(`Herdr socket unavailable at ${socketPath}: ${result.reason}`);
|
|
43
|
+
error.code = 'HERDR_SOCKET_UNAVAILABLE';
|
|
44
|
+
error.details = result;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
defaultSocketPath,
|
|
52
|
+
resolveSocketPath,
|
|
53
|
+
inspectSocket,
|
|
54
|
+
assertSocket,
|
|
55
|
+
};
|
package/src/state.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
function ensureDir(dirPath) {
|
|
8
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
9
|
+
try {
|
|
10
|
+
fs.chmodSync(dirPath, 0o700);
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeJsonAtomic(filePath, value) {
|
|
15
|
+
ensureDir(path.dirname(filePath));
|
|
16
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
17
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
18
|
+
try {
|
|
19
|
+
fs.chmodSync(tempPath, 0o600);
|
|
20
|
+
} catch {}
|
|
21
|
+
fs.renameSync(tempPath, filePath);
|
|
22
|
+
try {
|
|
23
|
+
fs.chmodSync(filePath, 0o600);
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJson(filePath, fallback) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT') {
|
|
32
|
+
process.stderr.write(`herdr-remote: invalid state at ${filePath}: ${error.message}\n`);
|
|
33
|
+
}
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function randomToken(bytes = 32) {
|
|
39
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
ensureDir,
|
|
44
|
+
writeJsonAtomic,
|
|
45
|
+
readJson,
|
|
46
|
+
randomToken,
|
|
47
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
|
|
6
|
+
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
7
|
+
const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
|
|
8
|
+
const { EXIT_REPLACED } = require('./exit-codes');
|
|
9
|
+
|
|
10
|
+
const MIN_BACKOFF_MS = 500;
|
|
11
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
12
|
+
// A child that stayed up this long is considered healthy, so the next crash
|
|
13
|
+
// starts backing off from scratch instead of inheriting an old penalty.
|
|
14
|
+
const HEALTHY_UPTIME_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs the relay and host connector as managed children and restarts them when
|
|
18
|
+
* they die.
|
|
19
|
+
*
|
|
20
|
+
* This is the process a service manager supervises: `herdr-remote run` stays in
|
|
21
|
+
* the foreground so systemd/launchd can track it, and the same class backs the
|
|
22
|
+
* fallback daemon on systems where neither is available.
|
|
23
|
+
*/
|
|
24
|
+
class Supervisor {
|
|
25
|
+
constructor({ config = loadConfig(), state = ensureRuntime(), logToFiles = false, onEvent = null } = {}) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.state = state;
|
|
28
|
+
this.logToFiles = logToFiles;
|
|
29
|
+
this.onEvent = onEvent;
|
|
30
|
+
this.children = new Map();
|
|
31
|
+
this.stopping = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
emit(event) {
|
|
35
|
+
if (this.onEvent) this.onEvent(event);
|
|
36
|
+
const line = `[${new Date().toISOString()}] herdr-remote supervisor: ${event.message}\n`;
|
|
37
|
+
process.stdout.write(line);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stop anything a previous, unmanaged start left behind.
|
|
42
|
+
*
|
|
43
|
+
* Taking over without this is what produced the runaway: a detached relay
|
|
44
|
+
* from `herdr-remote start` kept port 8787, so every relay this supervisor
|
|
45
|
+
* spawned died with EADDRINUSE and retried forever, while the orphaned host
|
|
46
|
+
* connector and ours both claimed the same host id on the relay and kicked
|
|
47
|
+
* each other off — which is what browsers saw as an endless reconnect.
|
|
48
|
+
*/
|
|
49
|
+
async reclaimStrays({ timeoutMs = 5000 } = {}) {
|
|
50
|
+
const strays = managedPids().filter((entry) => entry.pid !== process.pid);
|
|
51
|
+
if (strays.length === 0) return strays;
|
|
52
|
+
|
|
53
|
+
for (const { pid, name } of strays) {
|
|
54
|
+
this.emit({ type: 'reclaim', name, pid, message: `stopping stray ${name} from an earlier start (pid ${pid})` });
|
|
55
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Wait for them to actually go: the relay only releases its port on exit,
|
|
59
|
+
// and spawning ours before then just reproduces the EADDRINUSE loop.
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
if (strays.every(({ pid }) => !pidAlive(pid))) return strays;
|
|
63
|
+
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
64
|
+
}
|
|
65
|
+
for (const { pid, name } of strays) {
|
|
66
|
+
if (!pidAlive(pid)) continue;
|
|
67
|
+
this.emit({ type: 'reclaim', name, pid, message: `stray ${name} (pid ${pid}) ignored SIGTERM, killing` });
|
|
68
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
69
|
+
}
|
|
70
|
+
return strays;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async start() {
|
|
74
|
+
this.stopping = false;
|
|
75
|
+
ensureDir(stateDir());
|
|
76
|
+
await this.reclaimStrays();
|
|
77
|
+
if (this.stopping) return;
|
|
78
|
+
for (const spec of serviceSpecs(this.config, this.state)) {
|
|
79
|
+
this.children.set(spec.name, { spec, child: null, pid: null, restarts: 0, backoffMs: MIN_BACKOFF_MS, timer: null });
|
|
80
|
+
this.spawnChild(spec.name);
|
|
81
|
+
}
|
|
82
|
+
this.persistPids();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
spawnChild(name) {
|
|
86
|
+
if (this.stopping) return;
|
|
87
|
+
const entry = this.children.get(name);
|
|
88
|
+
if (!entry || entry.child) return;
|
|
89
|
+
|
|
90
|
+
let stdio = ['ignore', 'inherit', 'inherit'];
|
|
91
|
+
let fd = null;
|
|
92
|
+
if (this.logToFiles) {
|
|
93
|
+
fd = fs.openSync(logPath(name), 'a');
|
|
94
|
+
stdio = ['ignore', fd, fd];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let child;
|
|
98
|
+
try {
|
|
99
|
+
child = spawn(entry.spec.command, entry.spec.args, {
|
|
100
|
+
cwd: PACKAGE_ROOT,
|
|
101
|
+
env: { ...baseEnvironment(), ...entry.spec.env },
|
|
102
|
+
stdio,
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this.emit({ type: 'spawn_failed', name, message: `could not start ${name}: ${error.message}` });
|
|
106
|
+
this.scheduleRestart(name);
|
|
107
|
+
return;
|
|
108
|
+
} finally {
|
|
109
|
+
// The child inherited the descriptor during spawn, so this copy is done.
|
|
110
|
+
if (fd !== null) {
|
|
111
|
+
try { fs.closeSync(fd); } catch {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
entry.child = child;
|
|
116
|
+
entry.pid = child.pid;
|
|
117
|
+
entry.startedAt = Date.now();
|
|
118
|
+
this.emit({ type: 'started', name, pid: child.pid, message: `${name} started (pid ${child.pid})` });
|
|
119
|
+
this.persistPids();
|
|
120
|
+
|
|
121
|
+
child.on('exit', (code, signal) => {
|
|
122
|
+
const uptimeMs = Date.now() - entry.startedAt;
|
|
123
|
+
entry.child = null;
|
|
124
|
+
entry.pid = null;
|
|
125
|
+
this.persistPids();
|
|
126
|
+
if (this.stopping) return;
|
|
127
|
+
if (code === EXIT_REPLACED) {
|
|
128
|
+
this.emit({
|
|
129
|
+
type: 'replaced',
|
|
130
|
+
name,
|
|
131
|
+
message: `${name} stood down: another instance owns this workstation. Not restarting it.`,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
|
|
136
|
+
this.emit({
|
|
137
|
+
type: 'exited',
|
|
138
|
+
name,
|
|
139
|
+
code,
|
|
140
|
+
signal,
|
|
141
|
+
message: `${name} exited (${signal || `code ${code}`}) after ${Math.round(uptimeMs / 1000)}s, restarting in ${entry.backoffMs}ms`,
|
|
142
|
+
});
|
|
143
|
+
this.scheduleRestart(name);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
child.on('error', (error) => {
|
|
147
|
+
this.emit({ type: 'error', name, message: `${name} error: ${error.message}` });
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
scheduleRestart(name) {
|
|
152
|
+
if (this.stopping) return;
|
|
153
|
+
const entry = this.children.get(name);
|
|
154
|
+
if (!entry || entry.timer) return;
|
|
155
|
+
const delay = entry.backoffMs;
|
|
156
|
+
entry.restarts += 1;
|
|
157
|
+
entry.backoffMs = Math.min(MAX_BACKOFF_MS, Math.round(entry.backoffMs * 2));
|
|
158
|
+
entry.timer = setTimeout(() => {
|
|
159
|
+
entry.timer = null;
|
|
160
|
+
this.spawnChild(name);
|
|
161
|
+
}, delay);
|
|
162
|
+
entry.timer.unref?.();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
persistPids() {
|
|
166
|
+
try {
|
|
167
|
+
const current = readJson(runtimeStatePath(), {});
|
|
168
|
+
current.supervisorPid = process.pid;
|
|
169
|
+
current.relayPid = this.children.get('relay')?.pid || null;
|
|
170
|
+
current.hostPid = this.children.get('host')?.pid || null;
|
|
171
|
+
current.startedAt = current.startedAt || new Date().toISOString();
|
|
172
|
+
current.mode = this.config.relay.mode;
|
|
173
|
+
// Merge into the ledger rather than replacing it, so a pid recorded by
|
|
174
|
+
// another writer is never dropped and left running with nobody tracking
|
|
175
|
+
// it.
|
|
176
|
+
recordManagedPid(current, 'supervisor', process.pid);
|
|
177
|
+
for (const [name, entry] of this.children) {
|
|
178
|
+
if (entry.pid) recordManagedPid(current, name, entry.pid);
|
|
179
|
+
}
|
|
180
|
+
writeJsonAtomic(runtimeStatePath(), current);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
process.stderr.write(`herdr-remote supervisor: could not persist pids: ${error.message}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async stop({ graceMs = 5000 } = {}) {
|
|
187
|
+
this.stopping = true;
|
|
188
|
+
const pending = [];
|
|
189
|
+
for (const entry of this.children.values()) {
|
|
190
|
+
if (entry.timer) {
|
|
191
|
+
clearTimeout(entry.timer);
|
|
192
|
+
entry.timer = null;
|
|
193
|
+
}
|
|
194
|
+
const child = entry.child;
|
|
195
|
+
if (!child) continue;
|
|
196
|
+
pending.push(new Promise((resolve) => {
|
|
197
|
+
const killTimer = setTimeout(() => {
|
|
198
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
199
|
+
resolve();
|
|
200
|
+
}, graceMs);
|
|
201
|
+
killTimer.unref?.();
|
|
202
|
+
child.once('exit', () => {
|
|
203
|
+
clearTimeout(killTimer);
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
try { child.kill('SIGTERM'); } catch { resolve(); }
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
await Promise.all(pending);
|
|
210
|
+
try {
|
|
211
|
+
const current = readJson(runtimeStatePath(), {});
|
|
212
|
+
current.supervisorPid = null;
|
|
213
|
+
current.relayPid = null;
|
|
214
|
+
current.hostPid = null;
|
|
215
|
+
current.startedAt = null;
|
|
216
|
+
current.managedPids = (current.managedPids || []).filter((entry) => entry && pidAlive(entry.pid));
|
|
217
|
+
writeJsonAtomic(runtimeStatePath(), current);
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Entry point for `herdr-remote run`: supervise in the foreground until told to stop. */
|
|
223
|
+
async function runForeground({ logToFiles = false } = {}) {
|
|
224
|
+
const supervisor = new Supervisor({ logToFiles });
|
|
225
|
+
await supervisor.start();
|
|
226
|
+
|
|
227
|
+
return new Promise((resolve) => {
|
|
228
|
+
let shuttingDown = false;
|
|
229
|
+
const shutdown = async (signal) => {
|
|
230
|
+
if (shuttingDown) return;
|
|
231
|
+
shuttingDown = true;
|
|
232
|
+
supervisor.emit({ type: 'shutdown', message: `${signal} received, stopping services` });
|
|
233
|
+
await supervisor.stop();
|
|
234
|
+
resolve(0);
|
|
235
|
+
};
|
|
236
|
+
process.on('SIGINT', () => { shutdown('SIGINT'); });
|
|
237
|
+
process.on('SIGTERM', () => { shutdown('SIGTERM'); });
|
|
238
|
+
process.on('SIGHUP', () => { shutdown('SIGHUP'); });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { Supervisor, runForeground, MIN_BACKOFF_MS, MAX_BACKOFF_MS, HEALTHY_UPTIME_MS };
|