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