@jabberwocky238/dsh-ssh 0.0.0-stage → 0.2.4
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/README.md +112 -2
- package/README.zh.md +113 -0
- package/lib/index.js +572 -0
- package/lib/protocol-CDhW_IHU.js +276 -0
- package/lib/protocol.js +2 -0
- package/lib/schemas-BD3YbDxe.js +189 -0
- package/lib/schemas.js +2 -0
- package/lib/stream-security-DJlQsPG-.js +75 -0
- package/lib/types/index.d.ts +121 -0
- package/lib/types/protocol.d.ts +50 -0
- package/lib/types/schemas.d.ts +217 -0
- package/lib/types/stream-security.d.ts +19 -0
- package/package.json +41 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { a as SshRpcPeer } from "./protocol-CDhW_IHU.js";
|
|
2
|
+
import { s as helloSchema } from "./schemas-BD3YbDxe.js";
|
|
3
|
+
import { n as authenticateStream } from "./stream-security-DJlQsPG-.js";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createConnection } from "node:net";
|
|
8
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
9
|
+
import schema from "@deepseek-ai/schemastery";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { assertNever } from "@deepseek-ai/dsh-util-values";
|
|
12
|
+
//#region lib/types/index.js
|
|
13
|
+
/** OpenSSH connection owner for version-matched POSIX helpers, one per login, and their independent forwarded streams. */
|
|
14
|
+
/** Remote login names a bound Agent may run as. */
|
|
15
|
+
const LOGIN = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
16
|
+
/** Requests that address a handle an earlier request on the same helper created. */
|
|
17
|
+
const HANDLE_METHODS = new Set([
|
|
18
|
+
"process.start",
|
|
19
|
+
"process.done",
|
|
20
|
+
"process.wait",
|
|
21
|
+
"process.terminate",
|
|
22
|
+
"terminal.resize",
|
|
23
|
+
"terminal.write",
|
|
24
|
+
"terminal.inspect",
|
|
25
|
+
"terminal.activity",
|
|
26
|
+
"terminal.signal",
|
|
27
|
+
"fs.next",
|
|
28
|
+
"fs.streamClose"
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* One non-reconnecting OpenSSH master and the helper behind it, as the alias's
|
|
32
|
+
* own login or as a named login on the same alias.
|
|
33
|
+
*/
|
|
34
|
+
var SshChannel = class {
|
|
35
|
+
config;
|
|
36
|
+
login;
|
|
37
|
+
onFailure;
|
|
38
|
+
/** Verified remote helper coordinates; callers must await this before launch. */
|
|
39
|
+
ready;
|
|
40
|
+
/** Requests and forwarded sockets in flight, which keep an idle login channel open. */
|
|
41
|
+
active = 0;
|
|
42
|
+
rpc;
|
|
43
|
+
child;
|
|
44
|
+
childClosed;
|
|
45
|
+
directory;
|
|
46
|
+
heartbeat;
|
|
47
|
+
closed = false;
|
|
48
|
+
lifetime = new AbortController();
|
|
49
|
+
operations = /* @__PURE__ */ new Set();
|
|
50
|
+
disposal;
|
|
51
|
+
failure;
|
|
52
|
+
sockets = /* @__PURE__ */ new Set();
|
|
53
|
+
nextSocket = 0;
|
|
54
|
+
/** The helper's verified hello, once startup completes. */
|
|
55
|
+
remote;
|
|
56
|
+
/**
|
|
57
|
+
* @param config - validated connection configuration.
|
|
58
|
+
* @param login - remote login to authenticate as instead of the alias's own; undefined keeps the alias's.
|
|
59
|
+
* @param onFailure - observer of the channel's first failure.
|
|
60
|
+
*/
|
|
61
|
+
constructor(config, login, onFailure) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
this.login = login;
|
|
64
|
+
this.onFailure = onFailure;
|
|
65
|
+
this.ready = this.start();
|
|
66
|
+
this.ready.catch((error) => {
|
|
67
|
+
this.fail(error);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Whether the channel failed or is closing, so it accepts no new work. */
|
|
71
|
+
get unusable() {
|
|
72
|
+
return this.closed || this.failure !== void 0;
|
|
73
|
+
}
|
|
74
|
+
async request(method, params, result, signal, wait = false) {
|
|
75
|
+
this.assertOpen();
|
|
76
|
+
this.active++;
|
|
77
|
+
try {
|
|
78
|
+
await this.ready;
|
|
79
|
+
this.assertOpen();
|
|
80
|
+
const bounded = wait ? signal : signal === void 0 ? AbortSignal.timeout(this.config.requestTimeoutMs) : AbortSignal.any([signal, AbortSignal.timeout(this.config.requestTimeoutMs)]);
|
|
81
|
+
return await this.rpc.request(method, params, result, bounded);
|
|
82
|
+
} finally {
|
|
83
|
+
this.active--;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async connectStream(endpoint, signal) {
|
|
87
|
+
return this.track(this.establishStream(endpoint, signal));
|
|
88
|
+
}
|
|
89
|
+
async establishStream(endpoint, signal) {
|
|
90
|
+
const hello = await this.ready;
|
|
91
|
+
this.assertOpen();
|
|
92
|
+
signal = signal === void 0 ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal]);
|
|
93
|
+
const remote = endpoint.path;
|
|
94
|
+
if (!remote.startsWith(`${hello.root}/`) || /[:\r\n\0]/u.test(remote)) throw new Error("SSH helper returned an invalid stream path");
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
const local = join(this.directory, `s${this.nextSocket++}`);
|
|
97
|
+
const forward = `${local}:${remote}`;
|
|
98
|
+
const cancelForward = async () => {
|
|
99
|
+
if (!this.closed) await this.controlCommand([
|
|
100
|
+
"-O",
|
|
101
|
+
"cancel",
|
|
102
|
+
"-L",
|
|
103
|
+
forward
|
|
104
|
+
]).catch(() => {});
|
|
105
|
+
await rm(local, { force: true });
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
await this.controlCommand([
|
|
109
|
+
"-O",
|
|
110
|
+
"forward",
|
|
111
|
+
"-o",
|
|
112
|
+
"ExitOnForwardFailure=yes",
|
|
113
|
+
"-L",
|
|
114
|
+
forward
|
|
115
|
+
], signal);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await cancelForward();
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
const socket = createConnection({
|
|
122
|
+
path: local,
|
|
123
|
+
allowHalfOpen: true
|
|
124
|
+
});
|
|
125
|
+
this.sockets.add(socket);
|
|
126
|
+
this.active++;
|
|
127
|
+
socket.once("close", () => {
|
|
128
|
+
this.sockets.delete(socket);
|
|
129
|
+
this.active--;
|
|
130
|
+
this.track(cancelForward()).catch(() => {});
|
|
131
|
+
});
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
const cleanup = () => {
|
|
134
|
+
signal.removeEventListener("abort", aborted);
|
|
135
|
+
socket.off("connect", connected);
|
|
136
|
+
socket.off("error", failed);
|
|
137
|
+
socket.off("close", closed);
|
|
138
|
+
};
|
|
139
|
+
const connected = () => {
|
|
140
|
+
cleanup();
|
|
141
|
+
resolve();
|
|
142
|
+
};
|
|
143
|
+
const failed = (error) => {
|
|
144
|
+
cleanup();
|
|
145
|
+
reject(error);
|
|
146
|
+
};
|
|
147
|
+
const closed = () => {
|
|
148
|
+
failed(/* @__PURE__ */ new Error("SSH connection closed before stream establishment"));
|
|
149
|
+
};
|
|
150
|
+
const aborted = () => {
|
|
151
|
+
socket.destroy(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
|
|
152
|
+
};
|
|
153
|
+
socket.once("connect", connected);
|
|
154
|
+
socket.once("error", failed);
|
|
155
|
+
socket.once("close", closed);
|
|
156
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
157
|
+
});
|
|
158
|
+
const authenticated = await authenticateStream(socket, endpoint.capability, this.config.requestTimeoutMs, signal);
|
|
159
|
+
this.sockets.add(authenticated);
|
|
160
|
+
authenticated.on("error", () => {
|
|
161
|
+
authenticated.destroy();
|
|
162
|
+
});
|
|
163
|
+
authenticated.once("close", () => {
|
|
164
|
+
this.sockets.delete(authenticated);
|
|
165
|
+
});
|
|
166
|
+
return authenticated;
|
|
167
|
+
}
|
|
168
|
+
dispose() {
|
|
169
|
+
this.disposal ??= this.disposeOnce();
|
|
170
|
+
return this.disposal;
|
|
171
|
+
}
|
|
172
|
+
async disposeOnce() {
|
|
173
|
+
this.closed = true;
|
|
174
|
+
this.lifetime.abort(/* @__PURE__ */ new Error("SSH connection is closing"));
|
|
175
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
176
|
+
try {
|
|
177
|
+
await this.ready.catch(() => {});
|
|
178
|
+
if (this.failure === void 0) await this.rpc?.request("close", {}, z.null(), AbortSignal.timeout(this.config.requestTimeoutMs));
|
|
179
|
+
} finally {
|
|
180
|
+
this.rpc?.close();
|
|
181
|
+
const socketClosures = [...this.sockets].reverse().map((socket) => new Promise((resolve) => {
|
|
182
|
+
if (socket.closed) resolve();
|
|
183
|
+
else {
|
|
184
|
+
socket.once("close", () => {
|
|
185
|
+
resolve();
|
|
186
|
+
});
|
|
187
|
+
socket.destroy();
|
|
188
|
+
}
|
|
189
|
+
}));
|
|
190
|
+
this.child?.kill("SIGTERM");
|
|
191
|
+
const force = setTimeout(() => {
|
|
192
|
+
this.child?.kill("SIGKILL");
|
|
193
|
+
}, this.config.requestTimeoutMs);
|
|
194
|
+
try {
|
|
195
|
+
await this.childClosed;
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(force);
|
|
198
|
+
}
|
|
199
|
+
await Promise.all(socketClosures);
|
|
200
|
+
while (this.operations.size > 0) await Promise.allSettled([...this.operations]);
|
|
201
|
+
if (this.directory !== void 0) await rm(this.directory, {
|
|
202
|
+
recursive: true,
|
|
203
|
+
force: true
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
controlPath() {
|
|
208
|
+
return join(this.directory, "master");
|
|
209
|
+
}
|
|
210
|
+
/** The destination arguments that name the remote login and alias. */
|
|
211
|
+
destination() {
|
|
212
|
+
return this.login === void 0 ? [this.config.host] : [
|
|
213
|
+
"-l",
|
|
214
|
+
this.login,
|
|
215
|
+
this.config.host
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
assertOpen() {
|
|
219
|
+
if (this.closed) throw new Error("SSH connection is closed");
|
|
220
|
+
if (this.failure !== void 0) throw this.failure;
|
|
221
|
+
}
|
|
222
|
+
track(operation) {
|
|
223
|
+
this.operations.add(operation);
|
|
224
|
+
operation.finally(() => {
|
|
225
|
+
this.operations.delete(operation);
|
|
226
|
+
}).catch(() => {});
|
|
227
|
+
return operation;
|
|
228
|
+
}
|
|
229
|
+
async controlCommand(args, signal) {
|
|
230
|
+
const signals = [this.lifetime.signal, AbortSignal.timeout(this.config.requestTimeoutMs)];
|
|
231
|
+
if (signal !== void 0) signals.push(signal);
|
|
232
|
+
const combined = AbortSignal.any(signals);
|
|
233
|
+
combined.throwIfAborted();
|
|
234
|
+
const result = Promise.withResolvers();
|
|
235
|
+
const command = execFile("ssh", [
|
|
236
|
+
"-S",
|
|
237
|
+
this.controlPath(),
|
|
238
|
+
...args,
|
|
239
|
+
...this.destination()
|
|
240
|
+
], {
|
|
241
|
+
signal: combined,
|
|
242
|
+
maxBuffer: 64 * 1024
|
|
243
|
+
}, (error) => {
|
|
244
|
+
if (error === null) result.resolve(void 0);
|
|
245
|
+
else result.reject(error);
|
|
246
|
+
});
|
|
247
|
+
const closed = new Promise((resolve) => {
|
|
248
|
+
command.once("close", () => {
|
|
249
|
+
resolve();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
let force;
|
|
253
|
+
const escalate = () => {
|
|
254
|
+
force = setTimeout(() => {
|
|
255
|
+
command.kill("SIGKILL");
|
|
256
|
+
}, this.config.requestTimeoutMs);
|
|
257
|
+
force.unref();
|
|
258
|
+
};
|
|
259
|
+
combined.addEventListener("abort", escalate, { once: true });
|
|
260
|
+
try {
|
|
261
|
+
await result.promise;
|
|
262
|
+
} finally {
|
|
263
|
+
await closed;
|
|
264
|
+
combined.removeEventListener("abort", escalate);
|
|
265
|
+
if (force !== void 0) clearTimeout(force);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
fail(error) {
|
|
269
|
+
if (this.failure !== void 0) return;
|
|
270
|
+
this.failure = error;
|
|
271
|
+
this.lifetime.abort(error);
|
|
272
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
273
|
+
this.rpc?.close(error);
|
|
274
|
+
for (const socket of [...this.sockets].reverse()) socket.destroy(error);
|
|
275
|
+
this.child?.kill("SIGTERM");
|
|
276
|
+
this.onFailure(error);
|
|
277
|
+
}
|
|
278
|
+
async start() {
|
|
279
|
+
this.directory = await mkdtemp("/tmp/dsh-ssh-");
|
|
280
|
+
if (this.closed) throw new Error("SSH connection closed before startup");
|
|
281
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
282
|
+
const launch = this.config.launch;
|
|
283
|
+
let command;
|
|
284
|
+
let bootstrapPath;
|
|
285
|
+
let bootstrapHash;
|
|
286
|
+
switch (launch.kind) {
|
|
287
|
+
case "node-script":
|
|
288
|
+
command = [
|
|
289
|
+
launch.node,
|
|
290
|
+
"--disable-sigusr1",
|
|
291
|
+
this.config.helper
|
|
292
|
+
].map(quote).join(" ");
|
|
293
|
+
bootstrapPath = launch.bootstrapPath;
|
|
294
|
+
bootstrapHash = launch.bootstrapHash;
|
|
295
|
+
break;
|
|
296
|
+
case "executable":
|
|
297
|
+
command = `NODE_OPTIONS='--disable-sigusr1' ${quote(this.config.helper)}`;
|
|
298
|
+
break;
|
|
299
|
+
/* v8 ignore next -- Config parsing admits only these two launch kinds. */
|
|
300
|
+
default: return assertNever(launch);
|
|
301
|
+
}
|
|
302
|
+
const child = spawn("ssh", [
|
|
303
|
+
"-T",
|
|
304
|
+
"-M",
|
|
305
|
+
"-S",
|
|
306
|
+
this.controlPath(),
|
|
307
|
+
"-o",
|
|
308
|
+
"ControlPersist=no",
|
|
309
|
+
"-o",
|
|
310
|
+
"BatchMode=yes",
|
|
311
|
+
"-o",
|
|
312
|
+
"StrictHostKeyChecking=yes",
|
|
313
|
+
"-o",
|
|
314
|
+
"ForwardAgent=no",
|
|
315
|
+
"-o",
|
|
316
|
+
"ClearAllForwardings=yes",
|
|
317
|
+
"-o",
|
|
318
|
+
"ServerAliveInterval=10",
|
|
319
|
+
"-o",
|
|
320
|
+
"ServerAliveCountMax=3",
|
|
321
|
+
...this.destination(),
|
|
322
|
+
command
|
|
323
|
+
], { stdio: [
|
|
324
|
+
"pipe",
|
|
325
|
+
"pipe",
|
|
326
|
+
"pipe"
|
|
327
|
+
] });
|
|
328
|
+
this.child = child;
|
|
329
|
+
this.childClosed = new Promise((resolve) => {
|
|
330
|
+
child.once("close", () => {
|
|
331
|
+
resolve();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
child.stderr.resume();
|
|
335
|
+
child.once("error", (error) => {
|
|
336
|
+
this.fail(error);
|
|
337
|
+
});
|
|
338
|
+
child.once("close", () => {
|
|
339
|
+
this.fail(/* @__PURE__ */ new Error("SSH helper disconnected; remote outcomes and cleanup are unknown"));
|
|
340
|
+
});
|
|
341
|
+
const rpc = new SshRpcPeer(child.stdout, child.stdin, this.config.maxFrameBytes, this.config.maxPending);
|
|
342
|
+
this.rpc = rpc;
|
|
343
|
+
rpc.once("closed", (error) => {
|
|
344
|
+
this.fail(error);
|
|
345
|
+
});
|
|
346
|
+
const hello = await rpc.request("hello", {
|
|
347
|
+
protocol: 2,
|
|
348
|
+
workspace: this.config.workspace,
|
|
349
|
+
leaseMs: this.config.leaseMs,
|
|
350
|
+
...bootstrapPath === void 0 ? {} : { bootstrapPath }
|
|
351
|
+
}, helloSchema, AbortSignal.timeout(this.config.requestTimeoutMs));
|
|
352
|
+
if (hello.hash !== this.config.helperHash) throw new Error("SSH helper digest differs from the configured artifact");
|
|
353
|
+
if (hello.kind !== launch.kind) throw new Error("SSH helper runtime differs from the configured launch kind");
|
|
354
|
+
if (hello.bootstrapHash !== bootstrapHash) throw new Error("SSH PTC bootstrap digest differs from the configured artifact");
|
|
355
|
+
this.remote = hello;
|
|
356
|
+
let heartbeatPending;
|
|
357
|
+
this.heartbeat = setInterval(() => {
|
|
358
|
+
heartbeatPending ??= rpc.request("heartbeat", {}, z.null(), AbortSignal.timeout(this.config.leaseMs / 2)).catch((error) => {
|
|
359
|
+
this.fail(error);
|
|
360
|
+
}).finally(() => {
|
|
361
|
+
heartbeatPending = void 0;
|
|
362
|
+
});
|
|
363
|
+
}, Math.floor(this.config.leaseMs / 3));
|
|
364
|
+
this.heartbeat.unref();
|
|
365
|
+
return hello;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* The deployment's SSH service. Every operation runs on the alias's own login
|
|
370
|
+
* unless the Agent driving it was bound to another login with
|
|
371
|
+
* {@link SshConnection.bindLogin}; that Agent's operations then run on a
|
|
372
|
+
* connection authenticated as the bound login, opened on first use and closed
|
|
373
|
+
* after `loginIdleMs` without work. A process, terminal or text stream stays on
|
|
374
|
+
* the connection that created it, whoever later observes or cancels it.
|
|
375
|
+
*/
|
|
376
|
+
var SshConnection = class extends Service {
|
|
377
|
+
static Config = schema.object({
|
|
378
|
+
host: schema.string().required(),
|
|
379
|
+
helper: schema.string().required(),
|
|
380
|
+
launch: schema.union([schema.object({
|
|
381
|
+
kind: schema.const("node-script").required(),
|
|
382
|
+
node: schema.string().required(),
|
|
383
|
+
bootstrapPath: schema.string(),
|
|
384
|
+
bootstrapHash: schema.string()
|
|
385
|
+
}), schema.object({ kind: schema.const("executable").required() })]).required(),
|
|
386
|
+
helperHash: schema.string().required(),
|
|
387
|
+
workspace: schema.string().required(),
|
|
388
|
+
requestTimeoutMs: schema.number().default(3e4),
|
|
389
|
+
maxFrameBytes: schema.number().default(64 * 1024 * 1024),
|
|
390
|
+
maxPending: schema.number().default(128),
|
|
391
|
+
leaseMs: schema.number().default(3e4),
|
|
392
|
+
loginIdleMs: schema.number().default(6e5)
|
|
393
|
+
});
|
|
394
|
+
/** Verified remote helper coordinates of the alias's own login; callers must await this before launch. */
|
|
395
|
+
ready;
|
|
396
|
+
config;
|
|
397
|
+
primary;
|
|
398
|
+
/** Login each bound Agent runs as. */
|
|
399
|
+
bindings = /* @__PURE__ */ new WeakMap();
|
|
400
|
+
logins = /* @__PURE__ */ new Map();
|
|
401
|
+
idle = /* @__PURE__ */ new Map();
|
|
402
|
+
/** Helper-issued handle ids and the channel whose helper issued them. */
|
|
403
|
+
handles = /* @__PURE__ */ new Map();
|
|
404
|
+
closed = false;
|
|
405
|
+
disposal;
|
|
406
|
+
constructor(ctx, config) {
|
|
407
|
+
super(ctx, "ssh");
|
|
408
|
+
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error("SSH runtime requires a POSIX client");
|
|
409
|
+
this.config = z.object({
|
|
410
|
+
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.@-]*$/),
|
|
411
|
+
launch: z.discriminatedUnion("kind", [z.object({
|
|
412
|
+
kind: z.literal("node-script"),
|
|
413
|
+
node: z.string().startsWith("/"),
|
|
414
|
+
bootstrapPath: z.string().startsWith("/").optional(),
|
|
415
|
+
bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional()
|
|
416
|
+
}).strict().refine((value) => value.bootstrapPath === void 0 === (value.bootstrapHash === void 0), "bootstrapPath and bootstrapHash must be paired"), z.object({ kind: z.literal("executable") }).strict()]),
|
|
417
|
+
helper: z.string().startsWith("/"),
|
|
418
|
+
helperHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
419
|
+
workspace: z.string().startsWith("/"),
|
|
420
|
+
requestTimeoutMs: z.number().int().positive().max(2147483647),
|
|
421
|
+
maxFrameBytes: z.number().int().positive().max(64 * 1024 * 1024),
|
|
422
|
+
maxPending: z.number().int().positive().max(128),
|
|
423
|
+
leaseMs: z.number().int().min(3e3).max(6e5),
|
|
424
|
+
loginIdleMs: z.number().int().positive().max(2147483647)
|
|
425
|
+
}).strict().parse(config);
|
|
426
|
+
this.primary = new SshChannel(this.config, void 0, () => {});
|
|
427
|
+
this.ready = this.primary.ready;
|
|
428
|
+
ctx.effect(() => () => this.dispose());
|
|
429
|
+
}
|
|
430
|
+
/** Hold plugin readiness until the remote identity and helper digest are verified. */
|
|
431
|
+
async [Service.init]() {
|
|
432
|
+
await this.ready;
|
|
433
|
+
}
|
|
434
|
+
/** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
|
|
435
|
+
get ptcLaunch() {
|
|
436
|
+
const remote = this.primary.remote;
|
|
437
|
+
if (remote === void 0) throw new Error("SSH helper is not ready");
|
|
438
|
+
const launch = this.config.launch;
|
|
439
|
+
switch (launch.kind) {
|
|
440
|
+
case "executable": return {
|
|
441
|
+
kind: "embedded",
|
|
442
|
+
executable: remote.executable
|
|
443
|
+
};
|
|
444
|
+
case "node-script":
|
|
445
|
+
if (launch.bootstrapPath === void 0) throw new Error("SSH PTC requires a verified bootstrapPath and bootstrapHash");
|
|
446
|
+
return {
|
|
447
|
+
kind: "node-script",
|
|
448
|
+
executable: remote.executable,
|
|
449
|
+
bootstrapPath: launch.bootstrapPath
|
|
450
|
+
};
|
|
451
|
+
/* v8 ignore next -- Config parsing admits only these two launch kinds. */
|
|
452
|
+
default: return assertNever(launch);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Run every operation an Agent drives as `login` instead of the alias's own
|
|
457
|
+
* login. The remote host must accept the alias's key for that login. An
|
|
458
|
+
* Agent's operations are those made while it is the current initiator
|
|
459
|
+
* (`ctx.agents.currentInitiator()`); operations outside any initiator, or
|
|
460
|
+
* inside `ctx.agents.withoutInitiator()`, keep the alias's own login.
|
|
461
|
+
* @param agent - the Agent to bind; a child Agent it creates is not bound.
|
|
462
|
+
* @param login - remote login name.
|
|
463
|
+
* @returns a disposer that removes the binding; register it as an effect of the Agent's context.
|
|
464
|
+
* @throws when `login` is not a valid login name or the Agent is already bound.
|
|
465
|
+
*/
|
|
466
|
+
bindLogin(agent, login) {
|
|
467
|
+
if (!LOGIN.test(login)) throw new Error(`SSH login ${JSON.stringify(login)} is not a valid login name`);
|
|
468
|
+
if (this.bindings.has(agent)) throw new Error("SSH login is already bound for this Agent");
|
|
469
|
+
this.bindings.set(agent, login);
|
|
470
|
+
return () => {
|
|
471
|
+
this.bindings.delete(agent);
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Send a helper operation; cancellation never replays an ambiguous mutation.
|
|
476
|
+
* @param method - the private helper operation.
|
|
477
|
+
* @param params - JSON request fields validated by the helper.
|
|
478
|
+
* @param result - response validation before returning provider-visible data.
|
|
479
|
+
* @param signal - cancellation, which does not undo completed remote effects.
|
|
480
|
+
* @param wait - allow a process observation to outlast the administrative deadline.
|
|
481
|
+
* @returns the validated remote result.
|
|
482
|
+
*/
|
|
483
|
+
async request(method, params, result, signal, wait = false) {
|
|
484
|
+
const handle = HANDLE_METHODS.has(method) ? handleOf(params) : void 0;
|
|
485
|
+
const channel = (handle === void 0 ? void 0 : this.handles.get(handle)) ?? this.channelForInitiator();
|
|
486
|
+
const value = await channel.request(method, params, result, signal, wait);
|
|
487
|
+
if (channel !== this.primary) {
|
|
488
|
+
if (method === "process.prepare") this.handles.set(handleOf(value), channel);
|
|
489
|
+
else if (method === "fs.stream" && typeof value === "string") this.handles.set(value, channel);
|
|
490
|
+
}
|
|
491
|
+
if (handle !== void 0 && (method === "process.terminate" || method === "fs.streamClose" || method === "fs.next" && isDone(value))) this.handles.delete(handle);
|
|
492
|
+
if (channel !== this.primary) this.scheduleIdle(channel);
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Forward one authenticated stream through an independent SSH channel.
|
|
497
|
+
* @param endpoint - private coordinates issued by one of this service's helpers.
|
|
498
|
+
* @param signal - cancellation of allocation and the resulting socket.
|
|
499
|
+
* @returns a paused socket; attach a consumer before resuming it.
|
|
500
|
+
*/
|
|
501
|
+
async connectStream(endpoint, signal) {
|
|
502
|
+
const channel = [...this.logins.values()].find((candidate) => {
|
|
503
|
+
const root = candidate.remote?.root;
|
|
504
|
+
return root !== void 0 && endpoint.path.startsWith(`${root}/`);
|
|
505
|
+
}) ?? this.primary;
|
|
506
|
+
const socket = await channel.connectStream(endpoint, signal);
|
|
507
|
+
if (channel !== this.primary) socket.once("close", () => {
|
|
508
|
+
this.scheduleIdle(channel);
|
|
509
|
+
});
|
|
510
|
+
return socket;
|
|
511
|
+
}
|
|
512
|
+
/** Tear down every helper's remote managed ranges before releasing their SSH masters when reachable. */
|
|
513
|
+
dispose() {
|
|
514
|
+
if (this.disposal !== void 0) return this.disposal;
|
|
515
|
+
this.closed = true;
|
|
516
|
+
for (const timer of this.idle.values()) clearTimeout(timer);
|
|
517
|
+
this.idle.clear();
|
|
518
|
+
const logins = [...this.logins.values()].map((channel) => channel.dispose());
|
|
519
|
+
const primary = this.primary.dispose();
|
|
520
|
+
this.logins.clear();
|
|
521
|
+
this.handles.clear();
|
|
522
|
+
this.disposal = Promise.allSettled(logins).then(() => primary);
|
|
523
|
+
return this.disposal;
|
|
524
|
+
}
|
|
525
|
+
/** The channel for the Agent driving the current operation. */
|
|
526
|
+
channelForInitiator() {
|
|
527
|
+
if (this.closed) return this.primary;
|
|
528
|
+
const agent = this.ctx.get("agents")?.currentInitiator();
|
|
529
|
+
const login = agent === void 0 ? void 0 : this.bindings.get(agent);
|
|
530
|
+
if (login === void 0) return this.primary;
|
|
531
|
+
let channel = this.logins.get(login);
|
|
532
|
+
if (channel === void 0 || channel.unusable) {
|
|
533
|
+
const opened = new SshChannel(this.config, login, () => {
|
|
534
|
+
if (this.logins.get(login) === opened) this.logins.delete(login);
|
|
535
|
+
for (const [id, owner] of this.handles) if (owner === opened) this.handles.delete(id);
|
|
536
|
+
});
|
|
537
|
+
channel = opened;
|
|
538
|
+
this.logins.set(login, channel);
|
|
539
|
+
}
|
|
540
|
+
const timer = this.idle.get(channel);
|
|
541
|
+
if (timer !== void 0) {
|
|
542
|
+
clearTimeout(timer);
|
|
543
|
+
this.idle.delete(channel);
|
|
544
|
+
}
|
|
545
|
+
return channel;
|
|
546
|
+
}
|
|
547
|
+
/** Close a login channel once it holds no work and no handles for `loginIdleMs`. */
|
|
548
|
+
scheduleIdle(channel) {
|
|
549
|
+
if (this.closed || channel.active > 0 || [...this.handles.values()].includes(channel)) return;
|
|
550
|
+
const previous = this.idle.get(channel);
|
|
551
|
+
if (previous !== void 0) clearTimeout(previous);
|
|
552
|
+
const timer = setTimeout(() => {
|
|
553
|
+
this.idle.delete(channel);
|
|
554
|
+
if (channel.active > 0 || [...this.handles.values()].includes(channel)) return;
|
|
555
|
+
for (const [login, owner] of this.logins) if (owner === channel) this.logins.delete(login);
|
|
556
|
+
channel.dispose().catch(() => {});
|
|
557
|
+
}, this.config.loginIdleMs);
|
|
558
|
+
timer.unref();
|
|
559
|
+
this.idle.set(channel, timer);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
/** The handle id a request addresses or a prepare result carries. */
|
|
563
|
+
function handleOf(value) {
|
|
564
|
+
if (typeof value !== "object" || value === null || !("id" in value)) return void 0;
|
|
565
|
+
return typeof value.id === "string" ? value.id : void 0;
|
|
566
|
+
}
|
|
567
|
+
/** Whether an `fs.next` result reports the end of its stream. */
|
|
568
|
+
function isDone(value) {
|
|
569
|
+
return typeof value === "object" && value !== null && "done" in value && value.done === true;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
export { SshConnection, SshConnection as default };
|