@dbx-tools/lakebox 0.0.0-stage → 0.9.132
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 +202 -0
- package/README.md +146 -2
- package/index.ts +18 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +15 -0
- package/lib/src/_ssh.d.ts +152 -0
- package/lib/src/_ssh.js +489 -0
- package/lib/src/_ssh_openssh.d.ts +18 -0
- package/lib/src/_ssh_openssh.js +382 -0
- package/lib/src/_ssh_ssh2.d.ts +16 -0
- package/lib/src/_ssh_ssh2.js +225 -0
- package/lib/src/key-store.d.ts +39 -0
- package/lib/src/key-store.js +145 -0
- package/lib/src/lifecycle.d.ts +18 -0
- package/lib/src/lifecycle.js +75 -0
- package/lib/src/rest.d.ts +81 -0
- package/lib/src/rest.js +199 -0
- package/lib/src/session.d.ts +89 -0
- package/lib/src/session.js +309 -0
- package/lib/src/transport.d.ts +15 -0
- package/lib/src/transport.js +16 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +117 -4
- package/src/_ssh.ts +610 -0
- package/src/_ssh_openssh.ts +471 -0
- package/src/_ssh_ssh2.ts +273 -0
- package/src/key-store.ts +177 -0
- package/src/lifecycle.ts +88 -0
- package/src/rest.ts +264 -0
- package/src/session.ts +371 -0
- package/src/transport.ts +25 -0
package/src/_ssh.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { PassThrough, type Duplex, type Readable } from "node:stream";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { errorUtils, log } from "@dbx-tools/shared-core";
|
|
4
|
+
import type { LakeboxCommandResult } from "@dbx-tools/shared-lakebox";
|
|
5
|
+
import { createPool, type Factory, type Options, type Pool } from "generic-pool";
|
|
6
|
+
import type { Client as Ssh2Client } from "ssh2";
|
|
7
|
+
import type { Ssh2PatchMode, SshTransportMode } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
const logger = log.logger("lakebox:ssh-transport");
|
|
10
|
+
/** Maximum authenticated resources maintained by each SSH operation pool. */
|
|
11
|
+
export const SSH_POOL_MAX = 10;
|
|
12
|
+
|
|
13
|
+
/** One command execution request after the remote shell script is assembled. */
|
|
14
|
+
export interface SshExecutionOptions {
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Transport callbacks owned by one live SSH command. */
|
|
20
|
+
export interface SshCommandExecution {
|
|
21
|
+
readonly signal: AbortSignal;
|
|
22
|
+
stdout(chunk: Buffer | string): void;
|
|
23
|
+
stderr(chunk: Buffer | string): void;
|
|
24
|
+
setCancel(cancel: () => void | Promise<void>): void;
|
|
25
|
+
follow(command: SshCommand): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Live SSH command that streams output and awaits to its numeric exit code. */
|
|
29
|
+
export class SshCommand implements Promise<number> {
|
|
30
|
+
readonly [Symbol.toStringTag] = "SshCommand";
|
|
31
|
+
/** Live standard output decoded as UTF-8 strings. */
|
|
32
|
+
readonly stdout: Readable & AsyncIterable<string>;
|
|
33
|
+
/** Live standard error decoded as UTF-8 strings. */
|
|
34
|
+
readonly stderr: Readable & AsyncIterable<string>;
|
|
35
|
+
/** Completed result with captured output, timing, and timeout metadata. */
|
|
36
|
+
readonly result: Promise<LakeboxCommandResult>;
|
|
37
|
+
/** Numeric exit code after completion. */
|
|
38
|
+
exitCode: number | undefined;
|
|
39
|
+
|
|
40
|
+
private readonly startedAt = Date.now();
|
|
41
|
+
private readonly stdoutStream = new PassThrough();
|
|
42
|
+
private readonly stderrStream = new PassThrough();
|
|
43
|
+
private readonly stdoutDecoder = new StringDecoder("utf8");
|
|
44
|
+
private readonly stderrDecoder = new StringDecoder("utf8");
|
|
45
|
+
private readonly abortController = new AbortController();
|
|
46
|
+
private readonly completion: Promise<number>;
|
|
47
|
+
private resolveCompletion!: (exitCode: number) => void;
|
|
48
|
+
private rejectCompletion!: (error: unknown) => void;
|
|
49
|
+
private cancelBackend?: () => void | Promise<void>;
|
|
50
|
+
private cancellation?: Promise<void>;
|
|
51
|
+
private stdoutText = "";
|
|
52
|
+
private stderrText = "";
|
|
53
|
+
private settled = false;
|
|
54
|
+
private timedOut = false;
|
|
55
|
+
|
|
56
|
+
/** Creates a command and schedules its asynchronous setup and execution. */
|
|
57
|
+
constructor(
|
|
58
|
+
readonly command: string,
|
|
59
|
+
readonly args: readonly string[],
|
|
60
|
+
options: SshExecutionOptions,
|
|
61
|
+
start: (execution: SshCommandExecution) => Promise<number>,
|
|
62
|
+
) {
|
|
63
|
+
this.stdoutStream.setEncoding("utf8");
|
|
64
|
+
this.stderrStream.setEncoding("utf8");
|
|
65
|
+
this.stdout = this.stdoutStream as Readable & AsyncIterable<string>;
|
|
66
|
+
this.stderr = this.stderrStream as Readable & AsyncIterable<string>;
|
|
67
|
+
this.completion = new Promise<number>((resolve, reject) => {
|
|
68
|
+
this.resolveCompletion = resolve;
|
|
69
|
+
this.rejectCompletion = reject;
|
|
70
|
+
});
|
|
71
|
+
this.result = this.completion.then((exitCode) => ({
|
|
72
|
+
success: exitCode === 0 && !this.timedOut,
|
|
73
|
+
exitCode,
|
|
74
|
+
stdout: this.stdoutText,
|
|
75
|
+
stderr: this.stderrText,
|
|
76
|
+
executionTimeMs: Date.now() - this.startedAt,
|
|
77
|
+
...(this.timedOut ? { timedOut: true } : {}),
|
|
78
|
+
command: this.command,
|
|
79
|
+
args: [...this.args],
|
|
80
|
+
}));
|
|
81
|
+
void this.completion.catch(() => undefined);
|
|
82
|
+
void this.result.catch(() => undefined);
|
|
83
|
+
|
|
84
|
+
const externalAbort = () => {
|
|
85
|
+
void this.cancel().catch(() => undefined);
|
|
86
|
+
};
|
|
87
|
+
if (options.signal?.aborted) externalAbort();
|
|
88
|
+
else options.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
this.timedOut = true;
|
|
91
|
+
void this.cancel(124).catch(() => undefined);
|
|
92
|
+
}, options.timeoutMs);
|
|
93
|
+
timeout.unref?.();
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
options.signal?.removeEventListener("abort", externalAbort);
|
|
97
|
+
};
|
|
98
|
+
void this.completion.then(cleanup, cleanup);
|
|
99
|
+
|
|
100
|
+
queueMicrotask(() => {
|
|
101
|
+
if (this.settled) return;
|
|
102
|
+
void start({
|
|
103
|
+
signal: this.abortController.signal,
|
|
104
|
+
stdout: (chunk) => this.writeOutput("stdout", chunk),
|
|
105
|
+
stderr: (chunk) => this.writeOutput("stderr", chunk),
|
|
106
|
+
setCancel: (cancel) => {
|
|
107
|
+
this.cancelBackend = cancel;
|
|
108
|
+
if (this.abortController.signal.aborted) {
|
|
109
|
+
void this.invokeCancel().catch(() => undefined);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
follow: (command) => this.follow(command),
|
|
113
|
+
}).then(
|
|
114
|
+
(exitCode) => this.finish(exitCode),
|
|
115
|
+
(error) => this.fail(error),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Cancel the command once. Caller cancellation resolves with exit code 130. */
|
|
121
|
+
cancel(exitCode = 130): Promise<void> {
|
|
122
|
+
if (this.settled) return this.cancellation ?? Promise.resolve();
|
|
123
|
+
if (!this.abortController.signal.aborted) this.abortController.abort();
|
|
124
|
+
const cancellation = this.invokeCancel();
|
|
125
|
+
this.finish(exitCode);
|
|
126
|
+
return cancellation;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
then<TResult1 = number, TResult2 = never>(
|
|
130
|
+
onfulfilled?: ((value: number) => TResult1 | PromiseLike<TResult1>) | null,
|
|
131
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
132
|
+
): Promise<TResult1 | TResult2> {
|
|
133
|
+
return this.completion.then(onfulfilled, onrejected);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
catch<TResult = never>(
|
|
137
|
+
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
|
|
138
|
+
): Promise<number | TResult> {
|
|
139
|
+
return this.completion.catch(onrejected);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
finally(onfinally?: (() => void) | null): Promise<number> {
|
|
143
|
+
return this.completion.finally(onfinally ?? undefined);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private invokeCancel(): Promise<void> {
|
|
147
|
+
if (this.cancellation) return this.cancellation;
|
|
148
|
+
if (!this.cancelBackend) return Promise.resolve();
|
|
149
|
+
const cancelBackend = this.cancelBackend;
|
|
150
|
+
this.cancellation = Promise.resolve()
|
|
151
|
+
.then(() => cancelBackend())
|
|
152
|
+
.then(() => undefined);
|
|
153
|
+
void this.cancellation.catch(() => undefined);
|
|
154
|
+
return this.cancellation;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private writeOutput(stream: "stdout" | "stderr", chunk: Buffer | string): void {
|
|
158
|
+
if (this.settled) return;
|
|
159
|
+
const decoder = stream === "stdout" ? this.stdoutDecoder : this.stderrDecoder;
|
|
160
|
+
const value = decoder.write(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
161
|
+
if (!value) return;
|
|
162
|
+
if (stream === "stdout") {
|
|
163
|
+
this.stdoutText += value;
|
|
164
|
+
this.stdoutStream.write(value);
|
|
165
|
+
} else {
|
|
166
|
+
this.stderrText += value;
|
|
167
|
+
this.stderrStream.write(value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async follow(command: SshCommand): Promise<number> {
|
|
172
|
+
this.cancelBackend = () => command.cancel();
|
|
173
|
+
if (this.abortController.signal.aborted) {
|
|
174
|
+
void this.invokeCancel().catch(() => undefined);
|
|
175
|
+
}
|
|
176
|
+
const stdout = this.pipe(command.stdout, (chunk) => this.writeOutput("stdout", chunk));
|
|
177
|
+
const stderr = this.pipe(command.stderr, (chunk) => this.writeOutput("stderr", chunk));
|
|
178
|
+
const exitCode = await command;
|
|
179
|
+
await Promise.all([stdout, stderr]);
|
|
180
|
+
return exitCode;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async pipe(stream: AsyncIterable<string>, write: (chunk: string) => void): Promise<void> {
|
|
184
|
+
for await (const chunk of stream) write(chunk);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private finish(exitCode: number): void {
|
|
188
|
+
if (this.settled) return;
|
|
189
|
+
this.settled = true;
|
|
190
|
+
this.exitCode = exitCode;
|
|
191
|
+
this.closeOutput();
|
|
192
|
+
this.resolveCompletion(exitCode);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private fail(error: unknown): void {
|
|
196
|
+
if (this.settled) return;
|
|
197
|
+
this.settled = true;
|
|
198
|
+
this.closeOutput();
|
|
199
|
+
this.rejectCompletion(error);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private closeOutput(): void {
|
|
203
|
+
const stdoutRemainder = this.stdoutDecoder.end();
|
|
204
|
+
const stderrRemainder = this.stderrDecoder.end();
|
|
205
|
+
if (stdoutRemainder) {
|
|
206
|
+
this.stdoutText += stdoutRemainder;
|
|
207
|
+
this.stdoutStream.write(stdoutRemainder);
|
|
208
|
+
}
|
|
209
|
+
if (stderrRemainder) {
|
|
210
|
+
this.stderrText += stderrRemainder;
|
|
211
|
+
this.stderrStream.write(stderrRemainder);
|
|
212
|
+
}
|
|
213
|
+
this.stdoutStream.end();
|
|
214
|
+
this.stderrStream.end();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Connection details shared by native OpenSSH and the guarded ssh2 fallback. */
|
|
219
|
+
export interface SshTransportOptions {
|
|
220
|
+
host: string;
|
|
221
|
+
connectHost: string;
|
|
222
|
+
port: number;
|
|
223
|
+
username: string;
|
|
224
|
+
privateKeyPath: string;
|
|
225
|
+
knownHostsPath: string;
|
|
226
|
+
connectTimeoutMs: number;
|
|
227
|
+
ssh2Client: Ssh2Client;
|
|
228
|
+
createSsh2CommandClient: () => Promise<Ssh2Client>;
|
|
229
|
+
transport?: SshTransportMode;
|
|
230
|
+
ssh2Patch?: Ssh2PatchMode;
|
|
231
|
+
sshCommand?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Selected command and remote TCP transport for one verified SSH gateway. */
|
|
235
|
+
export interface SshTransport {
|
|
236
|
+
readonly kind: "openssh" | "ssh2";
|
|
237
|
+
run(script: string, execution: SshCommandExecution): Promise<number>;
|
|
238
|
+
connect(remoteHost: string, remotePort: number): Promise<Duplex>;
|
|
239
|
+
close(): Promise<void>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Async lifecycle used by both native OpenSSH and ssh2 connection pools. */
|
|
243
|
+
export interface SshResourceLifecycle<T> {
|
|
244
|
+
create(): Promise<T>;
|
|
245
|
+
destroy(resource: T): Promise<void>;
|
|
246
|
+
test(resource: T): Promise<boolean>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Optional pool overrides used by transport tests and constrained runtimes. */
|
|
250
|
+
export type SshPoolOptions = Pick<
|
|
251
|
+
Options,
|
|
252
|
+
| "max"
|
|
253
|
+
| "acquireTimeoutMillis"
|
|
254
|
+
| "idleTimeoutMillis"
|
|
255
|
+
| "evictionRunIntervalMillis"
|
|
256
|
+
| "numTestsPerEvictionRun"
|
|
257
|
+
>;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Exclusive, single-operation SSH resource pool.
|
|
261
|
+
*
|
|
262
|
+
* The command pool keeps one authenticated resource warm. Forwarding uses a
|
|
263
|
+
* separate zero-minimum pool so long-lived WebSockets cannot consume the warm
|
|
264
|
+
* command connection. Every borrowed resource is destroyed after its command
|
|
265
|
+
* or forwarded stream because the Databricks Russh gateway may accept only one
|
|
266
|
+
* operation during a connection's lifetime.
|
|
267
|
+
*/
|
|
268
|
+
export abstract class PooledSshTransport<T> implements SshTransport {
|
|
269
|
+
abstract readonly kind: "openssh" | "ssh2";
|
|
270
|
+
|
|
271
|
+
private readonly commandPool: Pool<T>;
|
|
272
|
+
private readonly forwardingPool: Pool<T>;
|
|
273
|
+
private readonly activeResources = new Map<T, Pool<T>>();
|
|
274
|
+
private readonly seededResources: T[] = [];
|
|
275
|
+
private lastCreateError: unknown;
|
|
276
|
+
private starting?: Promise<void>;
|
|
277
|
+
private closing?: Promise<void>;
|
|
278
|
+
private closed = false;
|
|
279
|
+
|
|
280
|
+
protected constructor(
|
|
281
|
+
private readonly lifecycle: SshResourceLifecycle<T>,
|
|
282
|
+
options: SshPoolOptions = {},
|
|
283
|
+
) {
|
|
284
|
+
const factory: Factory<T> = {
|
|
285
|
+
create: async () => {
|
|
286
|
+
try {
|
|
287
|
+
const resource = this.seededResources.shift() ?? (await lifecycle.create());
|
|
288
|
+
this.lastCreateError = undefined;
|
|
289
|
+
return resource;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
this.lastCreateError = error;
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
destroy: (resource) => lifecycle.destroy(resource),
|
|
296
|
+
validate: async (resource) => {
|
|
297
|
+
try {
|
|
298
|
+
return await lifecycle.test(resource);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
const defaults: Options = {
|
|
305
|
+
max: options.max ?? SSH_POOL_MAX,
|
|
306
|
+
acquireTimeoutMillis: options.acquireTimeoutMillis ?? 15_000,
|
|
307
|
+
idleTimeoutMillis: options.idleTimeoutMillis ?? 30_000,
|
|
308
|
+
evictionRunIntervalMillis: options.evictionRunIntervalMillis ?? 10_000,
|
|
309
|
+
numTestsPerEvictionRun: options.numTestsPerEvictionRun ?? 3,
|
|
310
|
+
testOnBorrow: true,
|
|
311
|
+
autostart: false,
|
|
312
|
+
};
|
|
313
|
+
this.commandPool = createPool(factory, { ...defaults, min: 1 });
|
|
314
|
+
this.forwardingPool = createPool(factory, { ...defaults, min: 0 });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Starts both pools and waits until one tested command resource is warm. */
|
|
318
|
+
start(): Promise<void> {
|
|
319
|
+
if (this.closed) return Promise.reject(new Error(`${this.kind} SSH transport is closed`));
|
|
320
|
+
if (this.starting) return this.starting;
|
|
321
|
+
this.starting = this.startPools();
|
|
322
|
+
void this.starting
|
|
323
|
+
.catch(() => this.close())
|
|
324
|
+
.catch((error) =>
|
|
325
|
+
logger.warn("failed to close SSH transport after startup failure", {
|
|
326
|
+
kind: this.kind,
|
|
327
|
+
error: errorUtils.toError(error),
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
return this.starting;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async startPools(): Promise<void> {
|
|
334
|
+
if (this.closed) throw new Error(`${this.kind} SSH transport is closed`);
|
|
335
|
+
const resource = await this.lifecycle.create();
|
|
336
|
+
if (this.closed) {
|
|
337
|
+
await this.lifecycle.destroy(resource);
|
|
338
|
+
throw new Error(`${this.kind} SSH transport is closed`);
|
|
339
|
+
}
|
|
340
|
+
this.seededResources.push(resource);
|
|
341
|
+
this.commandPool.start();
|
|
342
|
+
this.forwardingPool.start();
|
|
343
|
+
try {
|
|
344
|
+
const resource = await this.commandPool.acquire();
|
|
345
|
+
await this.commandPool.release(resource);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
throw this.lastCreateError ?? error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async run(script: string, execution: SshCommandExecution): Promise<number> {
|
|
352
|
+
const resource = await this.acquire(this.commandPool);
|
|
353
|
+
try {
|
|
354
|
+
if (execution.signal.aborted) return 130;
|
|
355
|
+
return await this.runWithResource(resource, script, execution);
|
|
356
|
+
} finally {
|
|
357
|
+
await this.destroy(this.commandPool, resource);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async connect(remoteHost: string, remotePort: number): Promise<Duplex> {
|
|
362
|
+
const first = await this.acquire(this.forwardingPool);
|
|
363
|
+
try {
|
|
364
|
+
return await this.connectWithBorrowedResource(first, remoteHost, remotePort);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
await this.destroy(this.forwardingPool, first);
|
|
367
|
+
if (this.closed) throw error;
|
|
368
|
+
}
|
|
369
|
+
const retry = await this.acquire(this.forwardingPool);
|
|
370
|
+
try {
|
|
371
|
+
return await this.connectWithBorrowedResource(retry, remoteHost, remotePort);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
await this.destroy(this.forwardingPool, retry);
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async close(): Promise<void> {
|
|
379
|
+
if (this.closing) return this.closing;
|
|
380
|
+
this.closed = true;
|
|
381
|
+
this.closing = this.closePools();
|
|
382
|
+
return this.closing;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
protected abstract runWithResource(
|
|
386
|
+
resource: T,
|
|
387
|
+
script: string,
|
|
388
|
+
execution: SshCommandExecution,
|
|
389
|
+
): Promise<number>;
|
|
390
|
+
|
|
391
|
+
protected abstract connectWithResource(
|
|
392
|
+
resource: T,
|
|
393
|
+
remoteHost: string,
|
|
394
|
+
remotePort: number,
|
|
395
|
+
): Promise<Duplex>;
|
|
396
|
+
|
|
397
|
+
private async connectWithBorrowedResource(
|
|
398
|
+
resource: T,
|
|
399
|
+
remoteHost: string,
|
|
400
|
+
remotePort: number,
|
|
401
|
+
): Promise<Duplex> {
|
|
402
|
+
const stream = await this.connectWithResource(resource, remoteHost, remotePort);
|
|
403
|
+
let disposed = false;
|
|
404
|
+
const dispose = () => {
|
|
405
|
+
if (disposed) return;
|
|
406
|
+
disposed = true;
|
|
407
|
+
void this.destroy(this.forwardingPool, resource);
|
|
408
|
+
};
|
|
409
|
+
stream.once("close", dispose);
|
|
410
|
+
stream.once("error", dispose);
|
|
411
|
+
if (stream.destroyed) dispose();
|
|
412
|
+
return stream;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async acquire(pool: Pool<T>): Promise<T> {
|
|
416
|
+
if (this.closed) throw new Error(`${this.kind} SSH transport is closed`);
|
|
417
|
+
let resource: T;
|
|
418
|
+
try {
|
|
419
|
+
resource = await pool.acquire();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw this.lastCreateError ?? error;
|
|
422
|
+
}
|
|
423
|
+
this.activeResources.set(resource, pool);
|
|
424
|
+
if (this.closed) {
|
|
425
|
+
await this.destroy(pool, resource);
|
|
426
|
+
throw new Error(`${this.kind} SSH transport is closed`);
|
|
427
|
+
}
|
|
428
|
+
return resource;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async destroy(pool: Pool<T>, resource: T): Promise<void> {
|
|
432
|
+
if (this.activeResources.get(resource) !== pool) return;
|
|
433
|
+
this.activeResources.delete(resource);
|
|
434
|
+
try {
|
|
435
|
+
await pool.destroy(resource);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
logger.warn("failed to destroy pooled SSH resource", {
|
|
438
|
+
kind: this.kind,
|
|
439
|
+
error: errorUtils.toError(error),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async closePools(): Promise<void> {
|
|
445
|
+
await this.starting?.catch(() => undefined);
|
|
446
|
+
const commandDrain = this.commandPool.drain();
|
|
447
|
+
const forwardingDrain = this.forwardingPool.drain();
|
|
448
|
+
await Promise.all(
|
|
449
|
+
[...this.activeResources].map(([resource, pool]) => this.destroy(pool, resource)),
|
|
450
|
+
);
|
|
451
|
+
await Promise.all([commandDrain, forwardingDrain]);
|
|
452
|
+
await Promise.all([this.commandPool.clear(), this.forwardingPool.clear()]);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Injectable transport constructors used to isolate policy selection in tests. */
|
|
457
|
+
export interface SshTransportFactories {
|
|
458
|
+
supportsOpenSsh(command: string): Promise<boolean>;
|
|
459
|
+
createOpenSsh(options: SshTransportOptions, command: string): Promise<SshTransport>;
|
|
460
|
+
createSsh2(options: SshTransportOptions): Promise<SshTransport>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Select one SSH transport from resolved policy and keep it for the session.
|
|
465
|
+
* Auto prefers native OpenSSH and otherwise selects ssh2.
|
|
466
|
+
*/
|
|
467
|
+
export async function createSshTransport(
|
|
468
|
+
options: SshTransportOptions,
|
|
469
|
+
overrides: Partial<SshTransportFactories> = {},
|
|
470
|
+
): Promise<SshTransport> {
|
|
471
|
+
const factories = { ...defaultSshTransportFactories, ...overrides };
|
|
472
|
+
const sshCommand = options.sshCommand ?? "ssh";
|
|
473
|
+
const transportMode = options.transport ?? "auto";
|
|
474
|
+
if (transportMode === "ssh2") {
|
|
475
|
+
return factories.createSsh2(options);
|
|
476
|
+
}
|
|
477
|
+
const openSshSupported = await factories.supportsOpenSsh(sshCommand);
|
|
478
|
+
if (transportMode === "openssh" && !openSshSupported) {
|
|
479
|
+
throw new Error("Native OpenSSH with SOCKS5 forwarding is unavailable");
|
|
480
|
+
}
|
|
481
|
+
if (openSshSupported) {
|
|
482
|
+
return factories.createOpenSsh(options, sshCommand);
|
|
483
|
+
}
|
|
484
|
+
if (transportMode !== "auto") {
|
|
485
|
+
throw new Error(`Unsupported SSH transport: ${transportMode}`);
|
|
486
|
+
}
|
|
487
|
+
return factories.createSsh2(options);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const defaultSshTransportFactories: SshTransportFactories = {
|
|
491
|
+
supportsOpenSsh: async (command) => {
|
|
492
|
+
const { supportsNativeOpenSsh } = await import("./_ssh_openssh.ts");
|
|
493
|
+
return supportsNativeOpenSsh(command);
|
|
494
|
+
},
|
|
495
|
+
createOpenSsh: async (options, command) => {
|
|
496
|
+
const { OpenSshTransport } = await import("./_ssh_openssh.ts");
|
|
497
|
+
const transport = new OpenSshTransport(options, command);
|
|
498
|
+
try {
|
|
499
|
+
await transport.start();
|
|
500
|
+
return transport;
|
|
501
|
+
} catch (error) {
|
|
502
|
+
await transport.close();
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
createSsh2: async (options) => {
|
|
507
|
+
const { Ssh2Transport } = await import("./_ssh_ssh2.ts");
|
|
508
|
+
const transport = new Ssh2Transport(options);
|
|
509
|
+
try {
|
|
510
|
+
await transport.start();
|
|
511
|
+
return transport;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
await transport.close();
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Account that runs one remote command.
|
|
521
|
+
*
|
|
522
|
+
* `user` targets a locked, non-sudo Lakebox account. `root` is reserved for
|
|
523
|
+
* fixed package-owned bootstrap scripts and is never reachable from REST,
|
|
524
|
+
* agent tools, or service definitions.
|
|
525
|
+
*/
|
|
526
|
+
export type SshCommandIdentity = { kind: "user"; name: string; home: string } | { kind: "root" };
|
|
527
|
+
|
|
528
|
+
/** @internal Builds a remote command after clearing inherited machine credentials. */
|
|
529
|
+
export function sshCommandScript(
|
|
530
|
+
command: string,
|
|
531
|
+
args: string[],
|
|
532
|
+
cwd: string | undefined,
|
|
533
|
+
env: Record<string, string> | undefined,
|
|
534
|
+
identity: SshCommandIdentity,
|
|
535
|
+
): string {
|
|
536
|
+
assertSafeSshEnvironment(env);
|
|
537
|
+
const root = identity.kind === "root";
|
|
538
|
+
const home = root ? "/root" : identity.home;
|
|
539
|
+
const name = root ? "root" : identity.name;
|
|
540
|
+
const workingDirectory = cwd ?? home;
|
|
541
|
+
const invocation = [command, ...scrubLoginShellCommand(command, args)].map(shellQuote).join(" ");
|
|
542
|
+
const environment = Object.entries({
|
|
543
|
+
HOME: home,
|
|
544
|
+
USER: name,
|
|
545
|
+
LOGNAME: name,
|
|
546
|
+
SHELL: "/bin/bash",
|
|
547
|
+
PWD: workingDirectory,
|
|
548
|
+
PATH: root
|
|
549
|
+
? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
550
|
+
: `${home}/.local/bin:/usr/local/bin:/usr/bin:/bin`,
|
|
551
|
+
DATABRICKS_CONFIG_FILE: "/dev/null",
|
|
552
|
+
...env,
|
|
553
|
+
})
|
|
554
|
+
.map(([key, value]) => `${environmentName(key)}=${shellQuote(value)}`)
|
|
555
|
+
.join(" ");
|
|
556
|
+
const run = `cd -- ${shellQuote(workingDirectory)} && ${invocation}`;
|
|
557
|
+
const sudoArgs = root ? ["-n", "-H"] : ["-n", "-H", "-u", name];
|
|
558
|
+
const switched = ["sudo", ...sudoArgs, "--", "env", "-i"].map(shellQuote).join(" ");
|
|
559
|
+
return `${SSH_AUTH_ENVIRONMENT_SCRUB}\nexec ${switched} ${environment} '/bin/sh' '-c' ${shellQuote(run)} </dev/null`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function scrubLoginShellCommand(command: string, args: string[]): string[] {
|
|
563
|
+
const executable = command.split("/").at(-1);
|
|
564
|
+
if (!executable || !["sh", "bash", "zsh"].includes(executable) || args[0] !== "-lc") {
|
|
565
|
+
return args;
|
|
566
|
+
}
|
|
567
|
+
const [flags, script, ...rest] = args;
|
|
568
|
+
if (script === undefined) return args;
|
|
569
|
+
return [flags, `${SSH_AUTH_ENVIRONMENT_SCRUB}\n${script}`, ...rest];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const SSH_AUTH_ENVIRONMENT_SCRUB = `for __dbx_tools_name in $(env | sed -n 's/^\\([A-Za-z_][A-Za-z0-9_]*\\)=.*/\\1/p'); do
|
|
573
|
+
case "$__dbx_tools_name" in
|
|
574
|
+
DATABRICKS_*|SSH_AUTH_SOCK)
|
|
575
|
+
unset "$__dbx_tools_name"
|
|
576
|
+
;;
|
|
577
|
+
esac
|
|
578
|
+
done
|
|
579
|
+
export DATABRICKS_CONFIG_FILE=/dev/null`;
|
|
580
|
+
|
|
581
|
+
function assertSafeSshEnvironment(env: Readonly<Record<string, string>> | undefined): void {
|
|
582
|
+
for (const name of Object.keys(env ?? {})) {
|
|
583
|
+
if (
|
|
584
|
+
name === "HOME" ||
|
|
585
|
+
name === "USER" ||
|
|
586
|
+
name === "LOGNAME" ||
|
|
587
|
+
name === "SHELL" ||
|
|
588
|
+
name === "PWD" ||
|
|
589
|
+
name === "PATH" ||
|
|
590
|
+
name.startsWith("SUDO_") ||
|
|
591
|
+
name.startsWith("DATABRICKS_") ||
|
|
592
|
+
name === "SSH_AUTH_SOCK"
|
|
593
|
+
) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
"SSH environment overlays cannot replace the Lakebox identity or restore Databricks or SSH agent credentials.",
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function shellQuote(value: string): string {
|
|
602
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function environmentName(value: string): string {
|
|
606
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
607
|
+
throw new Error(`Invalid SSH environment variable name: ${value}`);
|
|
608
|
+
}
|
|
609
|
+
return value;
|
|
610
|
+
}
|