@constructive-db/machine-runner 0.1.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 +54 -0
- package/cli.d.ts +38 -0
- package/cli.js +175 -0
- package/config.d.ts +17 -0
- package/config.js +73 -0
- package/esm/cli.js +164 -0
- package/esm/config.js +66 -0
- package/esm/index.js +4 -0
- package/esm/policy.js +34 -0
- package/esm/run.js +82 -0
- package/esm/runner.js +378 -0
- package/esm/spawn-helper.js +46 -0
- package/index.d.ts +8 -0
- package/index.js +21 -0
- package/package.json +47 -0
- package/policy.d.ts +27 -0
- package/policy.js +42 -0
- package/run.d.ts +8 -0
- package/run.js +85 -0
- package/runner.d.ts +76 -0
- package/runner.js +416 -0
- package/spawn-helper.d.ts +8 -0
- package/spawn-helper.js +53 -0
package/esm/run.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createLogger } from '@pgpmjs/logger';
|
|
3
|
+
import { defaultConfigPath, RUNNER_USAGE, runRunnerCli } from './cli';
|
|
4
|
+
import { loadRunnerConfig } from './config';
|
|
5
|
+
import { MachineRunner } from './runner';
|
|
6
|
+
import { ensureSpawnHelperExecutable, nodePtyRoot } from './spawn-helper';
|
|
7
|
+
const logger = createLogger('machine-runner');
|
|
8
|
+
/**
|
|
9
|
+
* Boot the daemon. `start` is what this process did before it grew verbs, and
|
|
10
|
+
* `--config <path>` / `MACHINE_RUNNER_CONFIG` still address the same file
|
|
11
|
+
* `enroll` writes.
|
|
12
|
+
*/
|
|
13
|
+
export function bootMachineRunner(argv) {
|
|
14
|
+
const flagIndex = argv.indexOf('--config');
|
|
15
|
+
const configPath = flagIndex >= 0 ? argv[flagIndex + 1] : defaultConfigPath();
|
|
16
|
+
if (!configPath) {
|
|
17
|
+
throw new Error('machine-runner: pass --config <path> or set MACHINE_RUNNER_CONFIG to the config file');
|
|
18
|
+
}
|
|
19
|
+
const config = loadRunnerConfig(configPath);
|
|
20
|
+
// Before the first session, not on its failure: an unusable spawn-helper
|
|
21
|
+
// surfaces as EACCES from deep inside forkpty, and the fix is a mode bit npm
|
|
22
|
+
// dropped rather than anything the tenant asked for.
|
|
23
|
+
for (const helper of ensureSpawnHelperExecutable(nodePtyRoot())) {
|
|
24
|
+
logger.warn(`machine-runner: restored the executable bit on ${helper}`);
|
|
25
|
+
}
|
|
26
|
+
const runner = new MachineRunner({
|
|
27
|
+
enrollments: config.enrollments,
|
|
28
|
+
policy: config.policy,
|
|
29
|
+
logger
|
|
30
|
+
});
|
|
31
|
+
runner.start();
|
|
32
|
+
return runner;
|
|
33
|
+
}
|
|
34
|
+
function readStdin() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let data = '';
|
|
37
|
+
process.stdin.setEncoding('utf8');
|
|
38
|
+
process.stdin.on('data', chunk => {
|
|
39
|
+
data += chunk;
|
|
40
|
+
});
|
|
41
|
+
process.stdin.on('end', () => resolve(data));
|
|
42
|
+
process.stdin.on('error', reject);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function daemonize(argv) {
|
|
46
|
+
const runner = bootMachineRunner(argv);
|
|
47
|
+
const shutdown = () => {
|
|
48
|
+
runner.stop();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
};
|
|
51
|
+
process.on('SIGINT', shutdown);
|
|
52
|
+
process.on('SIGTERM', shutdown);
|
|
53
|
+
}
|
|
54
|
+
async function main(argv) {
|
|
55
|
+
const io = {
|
|
56
|
+
out: line => process.stdout.write(`${line}\n`),
|
|
57
|
+
readStdin,
|
|
58
|
+
env: process.env
|
|
59
|
+
};
|
|
60
|
+
const [command, ...rest] = argv;
|
|
61
|
+
// `start` takes over the process, so it is not one of the CLI's verbs.
|
|
62
|
+
if (command === 'start') {
|
|
63
|
+
daemonize(rest);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const handled = await runRunnerCli(argv, io);
|
|
67
|
+
if (handled)
|
|
68
|
+
return;
|
|
69
|
+
// No verb at all keeps the pre-verb entrypoint working — the deployed unit
|
|
70
|
+
// and the bundle both invoke `run.js --config <path>`.
|
|
71
|
+
if (!command || command.startsWith('--')) {
|
|
72
|
+
daemonize(argv);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`machine-runner: unknown command '${command}'\n\n${RUNNER_USAGE}`);
|
|
76
|
+
}
|
|
77
|
+
if (require.main === module) {
|
|
78
|
+
main(process.argv.slice(2)).catch(err => {
|
|
79
|
+
logger.error(`machine-runner failed to start: ${err instanceof Error ? err.message : err}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
|
82
|
+
}
|
package/esm/runner.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// One outbound connection per enrollment. The runner dials the relay, keeps
|
|
2
|
+
// the socket alive with a reconnect loop, and serves sessions: an `open`
|
|
3
|
+
// frame spawns a pty (after the local policy says yes), `input`/`resize`/
|
|
4
|
+
// `signal` drive it, and its output and exit stream back as frames. Nothing
|
|
5
|
+
// here ever listens on a port.
|
|
6
|
+
//
|
|
7
|
+
// A pty outlives the socket that asked for it. An interactive session is a
|
|
8
|
+
// terminal on this machine, not a state of the connection, so `detach` (and a
|
|
9
|
+
// dropped relay) stop the *streaming* and leave the process running; the bytes
|
|
10
|
+
// it writes while nobody is watching go into a capped ring buffer, and a later
|
|
11
|
+
// `reattach` — from whichever client the tenant then authorizes — replays that
|
|
12
|
+
// ring so the program repaints. The runner is the only place those bytes are
|
|
13
|
+
// held in memory, and it holds a bounded window of them, never a transcript.
|
|
14
|
+
import { decodeFrame, encodeFrame, HEADER_MACHINE_DATABASE, HEADER_MACHINE_ID, RUNNER_CONNECT_PATH } from '@constructive-db/machine-protocol';
|
|
15
|
+
import * as pty from 'node-pty';
|
|
16
|
+
import { WebSocket } from 'ws';
|
|
17
|
+
import { PolicyViolationError, resolveSpawn } from './policy';
|
|
18
|
+
/** Stop presenting a credential this long before it expires. */
|
|
19
|
+
const CREDENTIAL_SKEW_MS = 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* How much recent output an unwatched session keeps, in characters. Bounded on
|
|
22
|
+
* purpose: it is a repaint buffer, not a recording, and the oldest bytes are
|
|
23
|
+
* dropped rather than growing a detached session without limit.
|
|
24
|
+
*/
|
|
25
|
+
const SCROLLBACK_LIMIT = 64 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* Signals delivered as the terminal's own control characters instead of to a
|
|
28
|
+
* pid. `kill(SIGINT)` would signal the session leader — the shell — which
|
|
29
|
+
* either ignores it or dies; writing the interrupt character lets the pty's
|
|
30
|
+
* line discipline deliver it to the *foreground* process group, which is how a
|
|
31
|
+
* keyboard `^C` stops the running command and leaves the shell prompt behind.
|
|
32
|
+
*/
|
|
33
|
+
const CONTROL_CHARACTERS = {
|
|
34
|
+
SIGINT: '\x03',
|
|
35
|
+
SIGQUIT: '\x1c'
|
|
36
|
+
};
|
|
37
|
+
/** The connection for one enrollment: dial, serve sessions, redial on drop. */
|
|
38
|
+
export class EnrollmentRunner {
|
|
39
|
+
enrollment;
|
|
40
|
+
policy;
|
|
41
|
+
logger;
|
|
42
|
+
reconnectInitialMs;
|
|
43
|
+
reconnectMaxMs;
|
|
44
|
+
ws = null;
|
|
45
|
+
sessions = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* The short-lived credential the relay minted for the last dial, held in
|
|
48
|
+
* memory only: it is never logged and never written beside the enrollment
|
|
49
|
+
* file, and it is dropped once it can no longer verify so the next dial falls
|
|
50
|
+
* back to the enrollment token and is issued a fresh one.
|
|
51
|
+
*/
|
|
52
|
+
credential = null;
|
|
53
|
+
reconnectDelayMs;
|
|
54
|
+
reconnectTimer = null;
|
|
55
|
+
stopped = false;
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.enrollment = options.enrollment;
|
|
58
|
+
this.policy = options.policy;
|
|
59
|
+
this.logger = options.logger;
|
|
60
|
+
this.reconnectInitialMs = options.reconnectInitialMs ?? 250;
|
|
61
|
+
this.reconnectMaxMs = options.reconnectMaxMs ?? 10_000;
|
|
62
|
+
this.reconnectDelayMs = this.reconnectInitialMs;
|
|
63
|
+
}
|
|
64
|
+
start() {
|
|
65
|
+
this.stopped = false;
|
|
66
|
+
this.connect();
|
|
67
|
+
}
|
|
68
|
+
stop() {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
if (this.reconnectTimer) {
|
|
71
|
+
clearTimeout(this.reconnectTimer);
|
|
72
|
+
this.reconnectTimer = null;
|
|
73
|
+
}
|
|
74
|
+
// Stopping the daemon is the one case where a live terminal does not
|
|
75
|
+
// survive: the process tree it belongs to is going away with it.
|
|
76
|
+
for (const [sessionId, session] of this.sessions) {
|
|
77
|
+
session.proc.kill();
|
|
78
|
+
this.sessions.delete(sessionId);
|
|
79
|
+
}
|
|
80
|
+
this.ws?.terminate();
|
|
81
|
+
this.ws = null;
|
|
82
|
+
}
|
|
83
|
+
get connected() {
|
|
84
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
85
|
+
}
|
|
86
|
+
/** Sessions this runner still holds, for tests and a future status frame. */
|
|
87
|
+
get liveSessions() {
|
|
88
|
+
return [...this.sessions.keys()];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What to present on this dial: the credential from the last exchange while it
|
|
92
|
+
* still verifies, else the enrollment token. The enrollment token buys an
|
|
93
|
+
* exchange; it is not the standing credential.
|
|
94
|
+
*/
|
|
95
|
+
secret() {
|
|
96
|
+
const held = this.credential;
|
|
97
|
+
if (held && held.expiresAt * 1000 > Date.now() + CREDENTIAL_SKEW_MS)
|
|
98
|
+
return held.value;
|
|
99
|
+
this.credential = null;
|
|
100
|
+
return this.enrollment.token;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Send on whichever socket is current. A session outlives the socket it was
|
|
104
|
+
* opened on, so its output must never be pinned to a closed one.
|
|
105
|
+
*/
|
|
106
|
+
send(frame) {
|
|
107
|
+
const ws = this.ws;
|
|
108
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
109
|
+
ws.send(encodeFrame(frame));
|
|
110
|
+
}
|
|
111
|
+
connect() {
|
|
112
|
+
const { relayUrl, machineId, database } = this.enrollment;
|
|
113
|
+
const url = new URL(RUNNER_CONNECT_PATH, relayUrl);
|
|
114
|
+
const ws = new WebSocket(url, {
|
|
115
|
+
headers: {
|
|
116
|
+
authorization: `Bearer ${this.secret()}`,
|
|
117
|
+
[HEADER_MACHINE_ID]: machineId,
|
|
118
|
+
[HEADER_MACHINE_DATABASE]: database
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
this.ws = ws;
|
|
122
|
+
ws.on('open', () => {
|
|
123
|
+
this.reconnectDelayMs = this.reconnectInitialMs;
|
|
124
|
+
this.logger.info(`machine-runner: '${machineId}' connected to ${relayUrl}`);
|
|
125
|
+
});
|
|
126
|
+
ws.on('message', raw => {
|
|
127
|
+
let frame;
|
|
128
|
+
try {
|
|
129
|
+
frame = decodeFrame(raw.toString());
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
this.logger.error(`machine-runner: bad frame from relay: ${message}`);
|
|
134
|
+
ws.close(4002, 'protocol error');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.handleFrame(ws, frame);
|
|
138
|
+
});
|
|
139
|
+
// 'close' always follows an errored socket, so the reconnect is scheduled
|
|
140
|
+
// there; here the failure only needs to be visible.
|
|
141
|
+
ws.on('error', err => {
|
|
142
|
+
this.logger.warn(`machine-runner: '${machineId}' socket error: ${err.message}`);
|
|
143
|
+
});
|
|
144
|
+
ws.on('close', () => {
|
|
145
|
+
if (this.ws !== ws)
|
|
146
|
+
return;
|
|
147
|
+
this.ws = null;
|
|
148
|
+
// A dropped relay is a detach the tenant never asked for: an interactive
|
|
149
|
+
// terminal keeps running and buffers, so a reattach after the relay comes
|
|
150
|
+
// back finds it alive. A command-mode session has no one to stream to and
|
|
151
|
+
// nothing to come back for, so it ends with its connection.
|
|
152
|
+
for (const [sessionId, session] of this.sessions) {
|
|
153
|
+
session.streaming = false;
|
|
154
|
+
if (session.interactive)
|
|
155
|
+
continue;
|
|
156
|
+
session.proc.kill();
|
|
157
|
+
this.sessions.delete(sessionId);
|
|
158
|
+
}
|
|
159
|
+
if (this.stopped)
|
|
160
|
+
return;
|
|
161
|
+
this.logger.warn(`machine-runner: '${machineId}' disconnected, reconnecting in ${this.reconnectDelayMs}ms`);
|
|
162
|
+
this.reconnectTimer = setTimeout(() => {
|
|
163
|
+
this.reconnectTimer = null;
|
|
164
|
+
this.connect();
|
|
165
|
+
}, this.reconnectDelayMs);
|
|
166
|
+
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
handleFrame(ws, frame) {
|
|
170
|
+
switch (frame.type) {
|
|
171
|
+
case 'open':
|
|
172
|
+
this.openSession(ws, frame.sessionId, frame.command, frame.args ?? [], {
|
|
173
|
+
interactive: frame.interactive === true,
|
|
174
|
+
cols: frame.cols,
|
|
175
|
+
rows: frame.rows
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
case 'input': {
|
|
179
|
+
const session = this.require(ws, frame.sessionId);
|
|
180
|
+
if (!session)
|
|
181
|
+
return;
|
|
182
|
+
session.proc.write(frame.data);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case 'resize': {
|
|
186
|
+
const session = this.require(ws, frame.sessionId);
|
|
187
|
+
if (!session)
|
|
188
|
+
return;
|
|
189
|
+
session.cols = frame.cols;
|
|
190
|
+
session.rows = frame.rows;
|
|
191
|
+
session.proc.resize(frame.cols, frame.rows);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
case 'signal': {
|
|
195
|
+
const session = this.require(ws, frame.sessionId);
|
|
196
|
+
if (!session)
|
|
197
|
+
return;
|
|
198
|
+
this.deliverSignal(session, frame.sessionId, frame.signal);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
case 'detach': {
|
|
202
|
+
const session = this.require(ws, frame.sessionId);
|
|
203
|
+
if (!session)
|
|
204
|
+
return;
|
|
205
|
+
// The terminal keeps running; what stops is the streaming. The window it
|
|
206
|
+
// buffered goes back once, for the ledger to record, and is then dropped
|
|
207
|
+
// from this side of the wire.
|
|
208
|
+
session.streaming = false;
|
|
209
|
+
ws.send(encodeFrame({
|
|
210
|
+
type: 'scrollback',
|
|
211
|
+
sessionId: frame.sessionId,
|
|
212
|
+
data: session.scrollback,
|
|
213
|
+
truncated: session.truncated
|
|
214
|
+
}));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case 'reattach': {
|
|
218
|
+
const session = this.require(ws, frame.sessionId);
|
|
219
|
+
if (!session)
|
|
220
|
+
return;
|
|
221
|
+
session.streaming = true;
|
|
222
|
+
if (frame.cols !== undefined && frame.rows !== undefined) {
|
|
223
|
+
session.cols = frame.cols;
|
|
224
|
+
session.rows = frame.rows;
|
|
225
|
+
session.proc.resize(frame.cols, frame.rows);
|
|
226
|
+
}
|
|
227
|
+
// Repaint: what the terminal looked like to whoever left it. A full-screen
|
|
228
|
+
// program redraws on the resize above, so this is what makes a scrolling
|
|
229
|
+
// session (a shell's history) come back too.
|
|
230
|
+
if (session.scrollback.length > 0) {
|
|
231
|
+
ws.send(encodeFrame({ type: 'output', sessionId: frame.sessionId, data: session.scrollback }));
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
case 'close': {
|
|
236
|
+
const session = this.sessions.get(frame.sessionId);
|
|
237
|
+
if (session) {
|
|
238
|
+
session.proc.kill();
|
|
239
|
+
this.sessions.delete(frame.sessionId);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
case 'enrolled':
|
|
244
|
+
// The exchange's result: the tenant resolved this machine to a principal
|
|
245
|
+
// and minted a credential to dial with next time. Its value never reaches
|
|
246
|
+
// the log — only that it exists, and for how long.
|
|
247
|
+
this.credential = { value: frame.credential, expiresAt: frame.expiresAt };
|
|
248
|
+
this.logger.info(`machine-runner: '${frame.machineId}' enrolled as principal ${frame.principalId}; ` +
|
|
249
|
+
`credential valid for ${Math.max(0, frame.expiresAt - Math.floor(Date.now() / 1000))}s`);
|
|
250
|
+
return;
|
|
251
|
+
default:
|
|
252
|
+
this.logger.error(`machine-runner: unexpected '${frame.type}' frame from relay`);
|
|
253
|
+
ws.close(4002, 'protocol error');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** The session a frame addresses, or an `error` frame back and undefined. */
|
|
257
|
+
require(ws, sessionId) {
|
|
258
|
+
const session = this.sessions.get(sessionId);
|
|
259
|
+
if (!session) {
|
|
260
|
+
ws.send(encodeFrame({ type: 'error', sessionId, message: 'unknown session' }));
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
return session;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Deliver a signal the way a terminal would. The two signals a keyboard
|
|
267
|
+
* produces go through the line discipline so they reach the foreground
|
|
268
|
+
* process group; the rest go to the session leader and therefore end the
|
|
269
|
+
* session, which is what asking for them means.
|
|
270
|
+
*/
|
|
271
|
+
deliverSignal(session, sessionId, signal) {
|
|
272
|
+
const character = CONTROL_CHARACTERS[signal];
|
|
273
|
+
if (character) {
|
|
274
|
+
session.proc.write(character);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
session.proc.kill(signal);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
// node-pty throws when the process is already gone, which is a race the
|
|
282
|
+
// requester should see rather than a failure of the runner.
|
|
283
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
284
|
+
this.logger.warn(`machine-runner: ${signal} to session '${sessionId}' failed: ${message}`);
|
|
285
|
+
this.send({ type: 'error', sessionId, message: `${signal} failed: ${message}` });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
openSession(ws, sessionId, command, args, shape) {
|
|
289
|
+
if (this.sessions.has(sessionId)) {
|
|
290
|
+
ws.send(encodeFrame({ type: 'error', sessionId, message: 'session id already in use' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const cols = shape.cols ?? 80;
|
|
294
|
+
const rows = shape.rows ?? 24;
|
|
295
|
+
let proc;
|
|
296
|
+
try {
|
|
297
|
+
const spec = resolveSpawn(this.policy, command, args);
|
|
298
|
+
proc = pty.spawn(spec.command, spec.args, {
|
|
299
|
+
name: 'xterm-256color',
|
|
300
|
+
cols,
|
|
301
|
+
rows,
|
|
302
|
+
cwd: spec.cwd,
|
|
303
|
+
env: spec.env
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
// A rejected command is the policy answering "no": the requester learns
|
|
308
|
+
// why, the machine's log records it, and the connection stays up.
|
|
309
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
310
|
+
if (!(err instanceof PolicyViolationError)) {
|
|
311
|
+
this.logger.error(`machine-runner: spawn '${command}' failed: ${message}`);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.logger.warn(`machine-runner: refused '${command}': ${message}`);
|
|
315
|
+
}
|
|
316
|
+
ws.send(encodeFrame({ type: 'error', sessionId, message }));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const session = {
|
|
320
|
+
proc,
|
|
321
|
+
interactive: shape.interactive,
|
|
322
|
+
cols,
|
|
323
|
+
rows,
|
|
324
|
+
streaming: true,
|
|
325
|
+
scrollback: '',
|
|
326
|
+
truncated: false
|
|
327
|
+
};
|
|
328
|
+
this.sessions.set(sessionId, session);
|
|
329
|
+
proc.onData(data => {
|
|
330
|
+
// Buffered for an interactive session whether or not anyone is watching:
|
|
331
|
+
// that buffer is the repaint a reattach needs. Command mode streams and
|
|
332
|
+
// keeps nothing — its transcript is the ledger.
|
|
333
|
+
if (session.interactive)
|
|
334
|
+
this.remember(session, data);
|
|
335
|
+
if (session.streaming)
|
|
336
|
+
this.send({ type: 'output', sessionId, data });
|
|
337
|
+
});
|
|
338
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
339
|
+
this.sessions.delete(sessionId);
|
|
340
|
+
if (session.streaming)
|
|
341
|
+
this.send({ type: 'exit', sessionId, exitCode, signal });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** Append to the capped ring, dropping the oldest bytes past the limit. */
|
|
345
|
+
remember(session, data) {
|
|
346
|
+
const combined = session.scrollback + data;
|
|
347
|
+
if (combined.length <= SCROLLBACK_LIMIT) {
|
|
348
|
+
session.scrollback = combined;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
session.scrollback = combined.slice(combined.length - SCROLLBACK_LIMIT);
|
|
352
|
+
session.truncated = true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** The daemon: one EnrollmentRunner per enrollment in the config. */
|
|
356
|
+
export class MachineRunner {
|
|
357
|
+
runners;
|
|
358
|
+
constructor(options) {
|
|
359
|
+
if (options.enrollments.length === 0) {
|
|
360
|
+
throw new Error('machine-runner: at least one enrollment is required');
|
|
361
|
+
}
|
|
362
|
+
this.runners = options.enrollments.map(enrollment => new EnrollmentRunner({
|
|
363
|
+
enrollment,
|
|
364
|
+
policy: options.policy,
|
|
365
|
+
logger: options.logger,
|
|
366
|
+
reconnectInitialMs: options.reconnectInitialMs,
|
|
367
|
+
reconnectMaxMs: options.reconnectMaxMs
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
start() {
|
|
371
|
+
for (const runner of this.runners)
|
|
372
|
+
runner.start();
|
|
373
|
+
}
|
|
374
|
+
stop() {
|
|
375
|
+
for (const runner of this.runners)
|
|
376
|
+
runner.stop();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// node-pty ships its darwin builds as prebuilt binaries in the npm tarball, and
|
|
2
|
+
// the tarball does not carry the executable bit for anything outside `bin`: the
|
|
3
|
+
// extracted `prebuilds/darwin-<arch>/spawn-helper` lands 0644, and the first
|
|
4
|
+
// `pty.spawn()` then dies with EACCES — on the user's own laptop, which is the
|
|
5
|
+
// only machine this daemon ever runs on. Restoring the bit is idempotent and
|
|
6
|
+
// costs two stat calls, so the runner does it itself at startup instead of in a
|
|
7
|
+
// postinstall script: `npm i -g` runs no lifecycle script of ours, and a
|
|
8
|
+
// prebuild replaced by a later reinstall would lose the fix again.
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
/** The mode a helper binary needs: executable wherever it is readable. */
|
|
12
|
+
const EXECUTABLE_MODE = 0o755;
|
|
13
|
+
/**
|
|
14
|
+
* Helper binaries the installed node-pty carries: one per prebuild it shipped,
|
|
15
|
+
* plus the one a local node-gyp build produces. Only paths that exist.
|
|
16
|
+
*/
|
|
17
|
+
function spawnHelpers(packageRoot) {
|
|
18
|
+
const helpers = [path.join(packageRoot, 'build', 'Release', 'spawn-helper')];
|
|
19
|
+
const prebuilds = path.join(packageRoot, 'prebuilds');
|
|
20
|
+
if (fs.existsSync(prebuilds)) {
|
|
21
|
+
for (const entry of fs.readdirSync(prebuilds)) {
|
|
22
|
+
helpers.push(path.join(prebuilds, entry, 'spawn-helper'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return helpers.filter(helper => fs.existsSync(helper));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Make every spawn-helper of the node-pty rooted at `packageRoot` executable,
|
|
29
|
+
* and return the ones that had to be changed (nothing to say on a tree that is
|
|
30
|
+
* already correct — which is every Linux install, where there is no such file).
|
|
31
|
+
*/
|
|
32
|
+
export function ensureSpawnHelperExecutable(packageRoot) {
|
|
33
|
+
const repaired = [];
|
|
34
|
+
for (const helper of spawnHelpers(packageRoot)) {
|
|
35
|
+
const mode = fs.statSync(helper).mode;
|
|
36
|
+
if ((mode & 0o111) !== 0)
|
|
37
|
+
continue;
|
|
38
|
+
fs.chmodSync(helper, EXECUTABLE_MODE);
|
|
39
|
+
repaired.push(helper);
|
|
40
|
+
}
|
|
41
|
+
return repaired;
|
|
42
|
+
}
|
|
43
|
+
/** Where the node-pty this runner will spawn through is installed. */
|
|
44
|
+
export function nodePtyRoot() {
|
|
45
|
+
return path.dirname(require.resolve('node-pty/package.json'));
|
|
46
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { CliIo } from './cli';
|
|
2
|
+
export { defaultConfigPath, parseFlags, resolveEnrollmentToken, RUNNER_USAGE, runRunnerCli, TOKEN_ENV, writeEnrollment } from './cli';
|
|
3
|
+
export type { Enrollment, RunnerConfig } from './config';
|
|
4
|
+
export { loadRunnerConfig, parseRunnerConfig } from './config';
|
|
5
|
+
export type { RunnerPolicy, SpawnSpec } from './policy';
|
|
6
|
+
export { DEFAULT_ENV_ALLOW, PolicyViolationError, resolveSpawn } from './policy';
|
|
7
|
+
export type { EnrollmentRunnerOptions, MachineRunnerOptions } from './runner';
|
|
8
|
+
export { EnrollmentRunner, MachineRunner } from './runner';
|
package/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MachineRunner = exports.EnrollmentRunner = exports.resolveSpawn = exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = exports.parseRunnerConfig = exports.loadRunnerConfig = exports.writeEnrollment = exports.TOKEN_ENV = exports.runRunnerCli = exports.RUNNER_USAGE = exports.resolveEnrollmentToken = exports.parseFlags = exports.defaultConfigPath = void 0;
|
|
4
|
+
var cli_1 = require("./cli");
|
|
5
|
+
Object.defineProperty(exports, "defaultConfigPath", { enumerable: true, get: function () { return cli_1.defaultConfigPath; } });
|
|
6
|
+
Object.defineProperty(exports, "parseFlags", { enumerable: true, get: function () { return cli_1.parseFlags; } });
|
|
7
|
+
Object.defineProperty(exports, "resolveEnrollmentToken", { enumerable: true, get: function () { return cli_1.resolveEnrollmentToken; } });
|
|
8
|
+
Object.defineProperty(exports, "RUNNER_USAGE", { enumerable: true, get: function () { return cli_1.RUNNER_USAGE; } });
|
|
9
|
+
Object.defineProperty(exports, "runRunnerCli", { enumerable: true, get: function () { return cli_1.runRunnerCli; } });
|
|
10
|
+
Object.defineProperty(exports, "TOKEN_ENV", { enumerable: true, get: function () { return cli_1.TOKEN_ENV; } });
|
|
11
|
+
Object.defineProperty(exports, "writeEnrollment", { enumerable: true, get: function () { return cli_1.writeEnrollment; } });
|
|
12
|
+
var config_1 = require("./config");
|
|
13
|
+
Object.defineProperty(exports, "loadRunnerConfig", { enumerable: true, get: function () { return config_1.loadRunnerConfig; } });
|
|
14
|
+
Object.defineProperty(exports, "parseRunnerConfig", { enumerable: true, get: function () { return config_1.parseRunnerConfig; } });
|
|
15
|
+
var policy_1 = require("./policy");
|
|
16
|
+
Object.defineProperty(exports, "DEFAULT_ENV_ALLOW", { enumerable: true, get: function () { return policy_1.DEFAULT_ENV_ALLOW; } });
|
|
17
|
+
Object.defineProperty(exports, "PolicyViolationError", { enumerable: true, get: function () { return policy_1.PolicyViolationError; } });
|
|
18
|
+
Object.defineProperty(exports, "resolveSpawn", { enumerable: true, get: function () { return policy_1.resolveSpawn; } });
|
|
19
|
+
var runner_1 = require("./runner");
|
|
20
|
+
Object.defineProperty(exports, "EnrollmentRunner", { enumerable: true, get: function () { return runner_1.EnrollmentRunner; } });
|
|
21
|
+
Object.defineProperty(exports, "MachineRunner", { enumerable: true, get: function () { return runner_1.MachineRunner; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@constructive-db/machine-runner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The machine runner: a Node daemon installed on a user's own machine that dials out to a machine relay over WebSocket, spawns policy-checked processes in a pty, and streams their bytes back. No inbound port, ever.",
|
|
5
|
+
"author": "Constructive <developers@constructive.io>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pty",
|
|
9
|
+
"remote-control",
|
|
10
|
+
"terminal",
|
|
11
|
+
"websocket"
|
|
12
|
+
],
|
|
13
|
+
"main": "index.js",
|
|
14
|
+
"module": "esm/index.js",
|
|
15
|
+
"types": "index.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"machine-runner": "run.js"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"directory": "dist"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@pgpmjs/logger": "^2.25.0",
|
|
25
|
+
"node-pty": "1.1.0",
|
|
26
|
+
"ws": "^8.18.0",
|
|
27
|
+
"@constructive-db/machine-protocol": "0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/jest": "^29.5.14",
|
|
31
|
+
"@types/node": "^22.10.4",
|
|
32
|
+
"@types/ws": "^8.5.13",
|
|
33
|
+
"jest": "^29.7.0",
|
|
34
|
+
"makage": "^0.1.12",
|
|
35
|
+
"ts-jest": "^29.2.0",
|
|
36
|
+
"typescript": "^5.1.6",
|
|
37
|
+
"@constructive-db/machine-relay": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "makage build && cp LICENSE dist/LICENSE",
|
|
41
|
+
"build:dev": "makage build --dev && cp LICENSE dist/LICENSE",
|
|
42
|
+
"clean": "makage clean",
|
|
43
|
+
"lint": "eslint . --fix",
|
|
44
|
+
"start": "node dist/run.js",
|
|
45
|
+
"test": "jest"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/policy.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface RunnerPolicy {
|
|
2
|
+
/** Commands (argv[0], exact match) a session may start. */
|
|
3
|
+
allowedCommands: string[];
|
|
4
|
+
/** Working directory every session starts in. */
|
|
5
|
+
cwd: string;
|
|
6
|
+
/**
|
|
7
|
+
* Environment the spawned process sees: `allow` names process.env vars to
|
|
8
|
+
* pass through, `set` adds explicit values. Nothing else leaks — the
|
|
9
|
+
* runner's own environment (tokens included) never reaches a session.
|
|
10
|
+
*/
|
|
11
|
+
env?: {
|
|
12
|
+
allow?: string[];
|
|
13
|
+
set?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Pass-through vars a pty session cannot reasonably run without. */
|
|
17
|
+
export declare const DEFAULT_ENV_ALLOW: string[];
|
|
18
|
+
export declare class PolicyViolationError extends Error {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
export interface SpawnSpec {
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
cwd: string;
|
|
25
|
+
env: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
export declare function resolveSpawn(policy: RunnerPolicy, command: string, args?: string[]): SpawnSpec;
|
package/policy.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local policy: the runner, not the relay, decides what may run on the
|
|
3
|
+
// machine it guards. The policy is written by the machine's owner and never
|
|
4
|
+
// travels over the wire — a compromised relay can ask, but the answer is
|
|
5
|
+
// decided here.
|
|
6
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
7
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
|
+
};
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.PolicyViolationError = exports.DEFAULT_ENV_ALLOW = void 0;
|
|
11
|
+
exports.resolveSpawn = resolveSpawn;
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
13
|
+
/** Pass-through vars a pty session cannot reasonably run without. */
|
|
14
|
+
exports.DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'TERM', 'LANG', 'USER', 'SHELL'];
|
|
15
|
+
class PolicyViolationError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'PolicyViolationError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
exports.PolicyViolationError = PolicyViolationError;
|
|
22
|
+
function resolveSpawn(policy, command, args = []) {
|
|
23
|
+
if (!policy.allowedCommands.includes(command)) {
|
|
24
|
+
throw new PolicyViolationError(`command '${command}' is not in the allowed command list`);
|
|
25
|
+
}
|
|
26
|
+
const allow = policy.env?.allow ?? exports.DEFAULT_ENV_ALLOW;
|
|
27
|
+
const env = {};
|
|
28
|
+
for (const name of allow) {
|
|
29
|
+
const value = process.env[name];
|
|
30
|
+
if (value !== undefined)
|
|
31
|
+
env[name] = value;
|
|
32
|
+
}
|
|
33
|
+
for (const [name, value] of Object.entries(policy.env?.set ?? {})) {
|
|
34
|
+
env[name] = value;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
command,
|
|
38
|
+
args,
|
|
39
|
+
cwd: policy.cwd || os_1.default.homedir(),
|
|
40
|
+
env
|
|
41
|
+
};
|
|
42
|
+
}
|
package/run.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MachineRunner } from './runner';
|
|
3
|
+
/**
|
|
4
|
+
* Boot the daemon. `start` is what this process did before it grew verbs, and
|
|
5
|
+
* `--config <path>` / `MACHINE_RUNNER_CONFIG` still address the same file
|
|
6
|
+
* `enroll` writes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function bootMachineRunner(argv: string[]): MachineRunner;
|