@dbx-tools/lakebox 0.0.0-stage → 0.9.131

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.
@@ -0,0 +1,471 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { createConnection, createServer } from "node:net";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Duplex } from "node:stream";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { exec } from "@dbx-tools/core";
8
+ import type { ChildProcessResult } from "@dbx-tools/core/exec";
9
+ import { errorUtils, log, stringUtils } from "@dbx-tools/shared-core";
10
+ import { SocksClient } from "socks";
11
+ import {
12
+ PooledSshTransport,
13
+ type SshCommandExecution,
14
+ type SshPoolOptions,
15
+ type SshResourceLifecycle,
16
+ type SshTransportOptions,
17
+ } from "./_ssh.ts";
18
+
19
+ const SOCKS_HOST = "127.0.0.1";
20
+ const OPENSSH_HELPER_TIMEOUT_MS = 2_000;
21
+ const logger = log.logger("lakebox:openssh");
22
+ const nativeOpenSshSupportByCommand = new Map<string, Promise<boolean>>();
23
+ const activeNativeOpenSshProcesses = new Set<ChildProcessResult>();
24
+ const parentBoundNativeOpenSshProcesses = new WeakSet<ChildProcessResult>();
25
+ let stopParentTerminationSignal: (() => void) | undefined;
26
+ let processExitHookInstalled = false;
27
+ /** @internal JavaScript watchdog that terminates one child when its parent pipe closes. */
28
+ export const OPENSSH_PARENT_WATCH_SOURCE =
29
+ stringUtils.dedent(// ============================================================================
30
+ /*js*/ `
31
+ const { spawn } = require("node:child_process");
32
+
33
+ const child = spawn(process.argv[1], process.argv.slice(2), {
34
+ stdio: ["ignore", "ignore", "inherit"],
35
+ });
36
+ let stopping = false;
37
+ const stopChild = () => {
38
+ if (stopping) return;
39
+ stopping = true;
40
+ child.kill("SIGKILL");
41
+ };
42
+
43
+ process.stdin.resume();
44
+ process.stdin.once("end", stopChild);
45
+ process.stdin.once("close", stopChild);
46
+ child.once("error", (error) => {
47
+ console.error(error);
48
+ process.exit(1);
49
+ });
50
+ child.once("exit", (code, signal) => {
51
+ process.stdin.pause();
52
+ process.exit(code ?? (signal ? 1 : 0));
53
+ });
54
+ `);
55
+ // ============================================================================
56
+
57
+ /** One authenticated native OpenSSH ControlMaster resource. */
58
+ export interface OpenSshResource {
59
+ run(script: string, execution: SshCommandExecution): Promise<number>;
60
+ connect(remoteHost: string, remotePort: number): Promise<Duplex>;
61
+ test(): Promise<boolean>;
62
+ close(): Promise<void>;
63
+ }
64
+
65
+ /** Exclusive native OpenSSH ControlMaster pool. */
66
+ export class OpenSshTransport extends PooledSshTransport<OpenSshResource> {
67
+ readonly kind = "openssh" as const;
68
+
69
+ constructor(
70
+ options: SshTransportOptions,
71
+ command: string,
72
+ poolOptions: SshPoolOptions = {},
73
+ lifecycle: SshResourceLifecycle<OpenSshResource> = openSshLifecycle(options, command),
74
+ ) {
75
+ super(lifecycle, {
76
+ acquireTimeoutMillis: options.connectTimeoutMs,
77
+ ...poolOptions,
78
+ });
79
+ }
80
+
81
+ protected runWithResource(
82
+ resource: OpenSshResource,
83
+ script: string,
84
+ execution: SshCommandExecution,
85
+ ): Promise<number> {
86
+ return resource.run(script, execution);
87
+ }
88
+
89
+ protected connectWithResource(
90
+ resource: OpenSshResource,
91
+ remoteHost: string,
92
+ remotePort: number,
93
+ ): Promise<Duplex> {
94
+ return resource.connect(remoteHost, remotePort);
95
+ }
96
+ }
97
+
98
+ /** Return whether an executable is OpenSSH and accepts dynamic SOCKS5 forwarding. */
99
+ export async function supportsNativeOpenSsh(command = "ssh"): Promise<boolean> {
100
+ const cached = nativeOpenSshSupportByCommand.get(command);
101
+ if (cached) return cached;
102
+ const probe = probeNativeOpenSsh(command);
103
+ nativeOpenSshSupportByCommand.set(command, probe);
104
+ return probe;
105
+ }
106
+
107
+ /** @internal OpenSSH arguments shared by pooled ControlMaster processes. */
108
+ export function nativeOpenSshArgs(options: SshTransportOptions): string[] {
109
+ return [
110
+ "-i",
111
+ options.privateKeyPath,
112
+ "-p",
113
+ String(options.port),
114
+ "-T",
115
+ "-o",
116
+ "BatchMode=yes",
117
+ "-o",
118
+ "IdentitiesOnly=yes",
119
+ "-o",
120
+ "ForwardAgent=no",
121
+ "-o",
122
+ "PreferredAuthentications=publickey",
123
+ "-o",
124
+ "StrictHostKeyChecking=yes",
125
+ "-o",
126
+ `UserKnownHostsFile=${options.knownHostsPath}`,
127
+ "-o",
128
+ `HostKeyAlias=[${options.host}]:${options.port}`,
129
+ "-o",
130
+ "LogLevel=ERROR",
131
+ "-o",
132
+ `ConnectTimeout=${Math.max(1, Math.ceil(options.connectTimeoutMs / 1_000))}`,
133
+ "-o",
134
+ "ServerAliveInterval=15",
135
+ "-o",
136
+ "ServerAliveCountMax=3",
137
+ ];
138
+ }
139
+
140
+ function openSshLifecycle(
141
+ options: SshTransportOptions,
142
+ command: string,
143
+ ): SshResourceLifecycle<OpenSshResource> {
144
+ return {
145
+ create: () => NativeOpenSshResource.create(options, command),
146
+ destroy: (resource) => resource.close(),
147
+ test: (resource) => resource.test(),
148
+ };
149
+ }
150
+
151
+ class NativeOpenSshResource implements OpenSshResource {
152
+ private alive = true;
153
+ private closing?: Promise<void>;
154
+
155
+ private constructor(
156
+ private readonly options: SshTransportOptions,
157
+ private readonly command: string,
158
+ private readonly directory: string,
159
+ private readonly controlPath: string,
160
+ private readonly socksPort: number,
161
+ private readonly master: ChildProcessResult,
162
+ ) {
163
+ const closed = () => {
164
+ this.alive = false;
165
+ };
166
+ void master.then(closed, closed);
167
+ }
168
+
169
+ static async create(
170
+ options: SshTransportOptions,
171
+ command: string,
172
+ ): Promise<NativeOpenSshResource> {
173
+ const directory = await mkdtemp(join(tmpdir(), "dbx-tools-ssh-"));
174
+ try {
175
+ const controlPath = join(directory, "control");
176
+ const socksPort = await availableLoopbackPort();
177
+ const target = `${options.username}@${options.connectHost}`;
178
+ const master = trackNativeOpenSshProcess(
179
+ exec.spawn(
180
+ process.execPath,
181
+ [
182
+ "-e",
183
+ OPENSSH_PARENT_WATCH_SOURCE,
184
+ command,
185
+ ...nativeOpenSshArgs(options),
186
+ "-M",
187
+ "-N",
188
+ "-S",
189
+ controlPath,
190
+ "-o",
191
+ "ControlPersist=no",
192
+ "-o",
193
+ "ExitOnForwardFailure=yes",
194
+ "-D",
195
+ `${SOCKS_HOST}:${socksPort}`,
196
+ target,
197
+ ],
198
+ {
199
+ stdin: "pipe",
200
+ stdout: "ignore",
201
+ stderr: "capture",
202
+ trim: false,
203
+ forwardTerminationSignals: false,
204
+ },
205
+ ),
206
+ true,
207
+ );
208
+ const resource = new NativeOpenSshResource(
209
+ options,
210
+ command,
211
+ directory,
212
+ controlPath,
213
+ socksPort,
214
+ master,
215
+ );
216
+ try {
217
+ await resource.waitUntilReady();
218
+ return resource;
219
+ } catch (error) {
220
+ await resource.close();
221
+ throw error;
222
+ }
223
+ } catch (error) {
224
+ await rm(directory, { recursive: true, force: true });
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ async run(script: string, execution: SshCommandExecution): Promise<number> {
230
+ const child = trackNativeOpenSshProcess(
231
+ exec.spawn(
232
+ this.command,
233
+ [
234
+ ...nativeOpenSshArgs(this.options),
235
+ "-S",
236
+ this.controlPath,
237
+ "-o",
238
+ "ControlMaster=no",
239
+ `${this.options.username}@${this.options.connectHost}`,
240
+ script,
241
+ ],
242
+ {
243
+ stdin: "ignore",
244
+ stdout: "pipe",
245
+ stderr: "pipe",
246
+ trim: false,
247
+ forwardTerminationSignals: false,
248
+ },
249
+ ),
250
+ );
251
+ child.stdout?.on("data", execution.stdout);
252
+ child.stderr?.on("data", execution.stderr);
253
+ let shutdown: Promise<void> | undefined;
254
+ execution.setCancel(() => {
255
+ shutdown ??= child.shutdown({ terminateTimeout: 0, killTimeout: 1_000 }).catch((error) => {
256
+ logger.warn("failed to stop native SSH command", { error: errorUtils.toError(error) });
257
+ });
258
+ return shutdown;
259
+ });
260
+ return (await child).exitCode;
261
+ }
262
+
263
+ async connect(remoteHost: string, remotePort: number): Promise<Duplex> {
264
+ const connection = await SocksClient.createConnection({
265
+ command: "connect",
266
+ destination: { host: remoteHost, port: remotePort },
267
+ proxy: { host: SOCKS_HOST, port: this.socksPort, type: 5 },
268
+ timeout: this.options.connectTimeoutMs,
269
+ set_tcp_nodelay: true,
270
+ });
271
+ return connection.socket;
272
+ }
273
+
274
+ async test(): Promise<boolean> {
275
+ if (!this.alive || this.master.exitCode !== null) return false;
276
+ try {
277
+ return (await this.control("check")).exitCode === 0;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ async close(): Promise<void> {
284
+ if (this.closing) return this.closing;
285
+ this.alive = false;
286
+ this.closing = this.closeResource();
287
+ return this.closing;
288
+ }
289
+
290
+ private async closeResource(): Promise<void> {
291
+ try {
292
+ await this.control("exit").catch(() => undefined);
293
+ } finally {
294
+ await this.master.shutdown({ terminateTimeout: 0, killTimeout: 1_000 }).catch((error) =>
295
+ logger.warn("failed to stop native SSH ControlMaster", {
296
+ error: errorUtils.toError(error),
297
+ }),
298
+ );
299
+ await rm(this.directory, { recursive: true, force: true });
300
+ }
301
+ }
302
+
303
+ private async waitUntilReady(): Promise<void> {
304
+ const deadline = Date.now() + this.options.connectTimeoutMs;
305
+ while (Date.now() < deadline) {
306
+ if (this.master.exitCode !== null) {
307
+ const result = await this.master;
308
+ throw new Error(
309
+ `Native OpenSSH ControlMaster exited (${result.exitCode}): ${result.stderr.trim()}`,
310
+ );
311
+ }
312
+ if ((await this.test()) && (await canConnectLoopback(this.socksPort))) return;
313
+ await delay(50);
314
+ }
315
+ throw new Error(
316
+ `Native OpenSSH ControlMaster did not become ready within ${this.options.connectTimeoutMs}ms`,
317
+ );
318
+ }
319
+
320
+ private control(operation: "check" | "exit") {
321
+ const child = trackNativeOpenSshProcess(
322
+ exec.spawn(
323
+ this.command,
324
+ [
325
+ "-p",
326
+ String(this.options.port),
327
+ "-S",
328
+ this.controlPath,
329
+ "-O",
330
+ operation,
331
+ `${this.options.username}@${this.options.connectHost}`,
332
+ ],
333
+ {
334
+ stdin: "ignore",
335
+ stdout: "ignore",
336
+ stderr: "ignore",
337
+ forwardTerminationSignals: false,
338
+ },
339
+ ),
340
+ );
341
+ return waitForNativeOpenSshChild(child, `ssh -O ${operation}`);
342
+ }
343
+ }
344
+
345
+ async function probeNativeOpenSsh(command: string): Promise<boolean> {
346
+ try {
347
+ const version = await waitForNativeOpenSshChild(
348
+ trackNativeOpenSshProcess(
349
+ exec.spawn(command, ["-V"], {
350
+ stdin: "ignore",
351
+ stdout: "capture",
352
+ stderr: "capture",
353
+ forwardTerminationSignals: false,
354
+ }),
355
+ ),
356
+ "ssh -V",
357
+ );
358
+ const versionOutput = `${version.stdout}\n${version.stderr}`;
359
+ if (version.exitCode !== 0 || !/(?:^|\s)OpenSSH_/m.test(versionOutput)) return false;
360
+ const dynamicForward = await waitForNativeOpenSshChild(
361
+ trackNativeOpenSshProcess(
362
+ exec.spawn(command, ["-G", "-D", `${SOCKS_HOST}:1`, "localhost"], {
363
+ stdin: "ignore",
364
+ stdout: "ignore",
365
+ stderr: "ignore",
366
+ forwardTerminationSignals: false,
367
+ }),
368
+ ),
369
+ "ssh -G",
370
+ );
371
+ return dynamicForward.exitCode === 0;
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+
377
+ async function waitForNativeOpenSshChild(
378
+ child: ChildProcessResult,
379
+ operation: string,
380
+ ): Promise<Awaited<ChildProcessResult>> {
381
+ let timeout: ReturnType<typeof setTimeout>;
382
+ const timedOut = new Promise<never>((_resolve, reject) => {
383
+ timeout = setTimeout(() => {
384
+ void child.shutdown({ terminateTimeout: 0, killTimeout: 1_000 }).then(
385
+ () => reject(new Error(`${operation} timed out after ${OPENSSH_HELPER_TIMEOUT_MS}ms`)),
386
+ (error) => reject(error),
387
+ );
388
+ }, OPENSSH_HELPER_TIMEOUT_MS);
389
+ timeout.unref?.();
390
+ });
391
+ try {
392
+ return await Promise.race([child, timedOut]);
393
+ } finally {
394
+ clearTimeout(timeout!);
395
+ }
396
+ }
397
+
398
+ function trackNativeOpenSshProcess(
399
+ child: ChildProcessResult,
400
+ parentBound = false,
401
+ ): ChildProcessResult {
402
+ activeNativeOpenSshProcesses.add(child);
403
+ if (parentBound) parentBoundNativeOpenSshProcesses.add(child);
404
+ if (!stopParentTerminationSignal) {
405
+ stopParentTerminationSignal = exec.onParentTerminationSignal(forceStopNativeOpenSshProcesses);
406
+ }
407
+ if (!processExitHookInstalled) {
408
+ processExitHookInstalled = true;
409
+ process.once("exit", forceStopNativeOpenSshProcesses);
410
+ }
411
+ let tracked = true;
412
+ const untrack = () => {
413
+ if (!tracked) return;
414
+ tracked = false;
415
+ void child.off("exit", untrack);
416
+ void child.off("error", untrack);
417
+ activeNativeOpenSshProcesses.delete(child);
418
+ if (activeNativeOpenSshProcesses.size === 0) {
419
+ stopParentTerminationSignal?.();
420
+ stopParentTerminationSignal = undefined;
421
+ process.off("exit", forceStopNativeOpenSshProcesses);
422
+ processExitHookInstalled = false;
423
+ }
424
+ };
425
+ void child.once("exit", untrack);
426
+ void child.once("error", untrack);
427
+ return child;
428
+ }
429
+
430
+ function forceStopNativeOpenSshProcesses(): void {
431
+ for (const child of activeNativeOpenSshProcesses) {
432
+ try {
433
+ if (parentBoundNativeOpenSshProcesses.has(child)) child.stdin?.end();
434
+ else child.kill("SIGKILL");
435
+ } catch {
436
+ // The child may have exited between the tracked-state check and kill.
437
+ }
438
+ }
439
+ }
440
+
441
+ async function availableLoopbackPort(): Promise<number> {
442
+ const server = createServer();
443
+ await new Promise<void>((resolve, reject) => {
444
+ server.once("error", reject);
445
+ server.listen(0, SOCKS_HOST, resolve);
446
+ });
447
+ const address = server.address();
448
+ if (!address || typeof address === "string") {
449
+ server.close();
450
+ throw new Error("Failed to allocate a loopback SOCKS5 port");
451
+ }
452
+ await new Promise<void>((resolve) => server.close(() => resolve()));
453
+ return address.port;
454
+ }
455
+
456
+ function canConnectLoopback(port: number): Promise<boolean> {
457
+ return new Promise((resolve) => {
458
+ const socket = createConnection({ host: SOCKS_HOST, port });
459
+ let settled = false;
460
+ const finish = (connected: boolean) => {
461
+ if (settled) return;
462
+ settled = true;
463
+ socket.destroy();
464
+ resolve(connected);
465
+ };
466
+ socket.unref();
467
+ socket.setTimeout(100, () => finish(false));
468
+ socket.once("error", () => finish(false));
469
+ socket.once("connect", () => finish(true));
470
+ });
471
+ }