@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/src/rest.ts ADDED
@@ -0,0 +1,264 @@
1
+ import { errorUtils, log, net } from "@dbx-tools/shared-core";
2
+ import { z } from "zod";
3
+
4
+ const LAKEBOX_ROOT = "/api/2.0/lakebox";
5
+ const LAKEBOXES_PATH = `${LAKEBOX_ROOT}/sandboxes`;
6
+ const SSH_KEYS_PATH = `${LAKEBOX_ROOT}/ssh-keys`;
7
+ const ORG_ID_HEADER = "X-Databricks-Org-Id";
8
+ const logger = log.logger("lakebox:rest");
9
+
10
+ export const LakeboxEntrySchema = z
11
+ .object({
12
+ sandboxId: z.string().describe("Lakebox identifier; also the SSH gateway username."),
13
+ status: z.string().describe("Upstream lifecycle status, such as Running or Stopped."),
14
+ gatewayHost: z.string().optional().describe("SSH gateway hostname."),
15
+ name: z.string().optional().describe("Display label."),
16
+ createTime: z.string().optional().describe("Creation time."),
17
+ lastStartTime: z.string().optional().describe("Last start time."),
18
+ idleTimeout: z.string().optional().describe("Platform idle timeout, such as 600s."),
19
+ noAutostop: z.boolean().optional().describe("Whether idle auto-stop is disabled."),
20
+ })
21
+ .passthrough()
22
+ .describe("Lakebox record returned by the Databricks Lakebox REST API.");
23
+ /** Lakebox record returned by the Databricks Lakebox REST API. */
24
+ export type LakeboxEntry = z.infer<typeof LakeboxEntrySchema>;
25
+
26
+ const LakeboxListSchema = z
27
+ .object({
28
+ sandboxes: z.array(LakeboxEntrySchema).default([]),
29
+ nextPageToken: z.string().optional(),
30
+ })
31
+ .passthrough();
32
+
33
+ export const LakeboxSshKeySchema = z
34
+ .object({
35
+ keyHash: z.string().describe("Registered public key hash."),
36
+ name: z.string().optional().describe("Key label."),
37
+ gatewayHost: z.string().optional().describe("Gateway hostname for this key."),
38
+ })
39
+ .passthrough()
40
+ .describe("Registered SSH key returned by the Databricks Lakebox REST API.");
41
+ /** Registered SSH key returned by the Databricks Lakebox REST API. */
42
+ export type LakeboxSshKey = z.infer<typeof LakeboxSshKeySchema>;
43
+
44
+ const LakeboxSshKeyListSchema = z
45
+ .object({ sshKeys: z.array(LakeboxSshKeySchema).default([]) })
46
+ .passthrough();
47
+
48
+ /**
49
+ * Workspace credentials for Lakebox REST calls.
50
+ *
51
+ * The Databricks SDK and AppKit `WorkspaceClient.config` satisfy this shape, as
52
+ * does a small adapter over `@dbx-tools/auth`. Lakebox lifecycle calls must use
53
+ * the app service principal, never request-scoped OBO credentials.
54
+ */
55
+ export interface LakeboxAuth {
56
+ /** Workspace URL. */
57
+ readonly host?: string;
58
+ /** Numeric workspace identifier, when known. */
59
+ readonly workspaceId?: string;
60
+ /** Resolves lazy configuration before `host` is read. */
61
+ ensureResolved?(): Promise<void>;
62
+ /** Adds authentication headers to one request. */
63
+ authenticate(headers: Headers): Promise<void>;
64
+ }
65
+
66
+ /** Error raised for a non-success Lakebox REST response. */
67
+ export class LakeboxApiError extends Error {
68
+ constructor(
69
+ message: string,
70
+ readonly status: number,
71
+ readonly errorCode?: string,
72
+ readonly requestId?: string,
73
+ ) {
74
+ super(message);
75
+ this.name = "LakeboxApiError";
76
+ }
77
+ }
78
+
79
+ type RequestMethod = "GET" | "POST" | "PATCH" | "DELETE";
80
+
81
+ /**
82
+ * REST client for Databricks Lakeboxes and registered SSH keys.
83
+ *
84
+ * Responses are validated before they reach callers. Diagnostics never include
85
+ * tokens or request headers.
86
+ */
87
+ export class LakeboxClient {
88
+ /** Creates a client for one workspace credential. */
89
+ constructor(private readonly auth: LakeboxAuth) {}
90
+
91
+ /** Returns the workspace identifier, or the workspace origin when the identifier is unknown. */
92
+ async workspaceScope(): Promise<string> {
93
+ await this.auth.ensureResolved?.();
94
+ return (
95
+ process.env.DATABRICKS_WORKSPACE_ID?.trim() ||
96
+ this.auth.workspaceId?.trim() ||
97
+ net.urlBuilder(this.auth.host ?? "")?.origin ||
98
+ "workspace"
99
+ );
100
+ }
101
+
102
+ /** Lists every Lakebox visible to the caller, following pagination. */
103
+ async list(signal?: AbortSignal): Promise<LakeboxEntry[]> {
104
+ const entries: LakeboxEntry[] = [];
105
+ let pageToken: string | undefined;
106
+ do {
107
+ const query = new URLSearchParams({ page_size: "100" });
108
+ if (pageToken) query.set("page_token", pageToken);
109
+ const response = LakeboxListSchema.parse(
110
+ await this.request("GET", `${LAKEBOXES_PATH}?${query}`, undefined, signal),
111
+ );
112
+ entries.push(...response.sandboxes);
113
+ pageToken = response.nextPageToken || undefined;
114
+ } while (pageToken);
115
+ return entries;
116
+ }
117
+
118
+ /** Returns one Lakebox by ID. */
119
+ async get(id: string, signal?: AbortSignal): Promise<LakeboxEntry> {
120
+ return LakeboxEntrySchema.parse(await this.request("GET", lakeboxPath(id), undefined, signal));
121
+ }
122
+
123
+ /** Creates a Lakebox with a Databricks-assigned display name. */
124
+ async create(signal?: AbortSignal): Promise<LakeboxEntry> {
125
+ return LakeboxEntrySchema.parse(
126
+ await this.request("POST", LAKEBOXES_PATH, { sandbox: {} }, signal),
127
+ );
128
+ }
129
+
130
+ /** Sets the platform idle timeout that stops the Lakebox after inactivity. */
131
+ async setIdleTimeout(id: string, seconds: number, signal?: AbortSignal): Promise<LakeboxEntry> {
132
+ return LakeboxEntrySchema.parse(
133
+ await this.request(
134
+ "PATCH",
135
+ lakeboxPath(id),
136
+ { sandbox_id: id, idle_timeout: `${seconds}s` },
137
+ signal,
138
+ ),
139
+ );
140
+ }
141
+
142
+ /** Permanently deletes one Lakebox and its persistent home. */
143
+ async delete(id: string, signal?: AbortSignal): Promise<void> {
144
+ await this.request("DELETE", lakeboxPath(id), undefined, signal);
145
+ }
146
+
147
+ /** Requests a start and returns the updated record. */
148
+ async start(id: string, signal?: AbortSignal): Promise<LakeboxEntry> {
149
+ return LakeboxEntrySchema.parse(
150
+ await this.request("POST", `${lakeboxPath(id)}/start`, { sandbox_id: id }, signal),
151
+ );
152
+ }
153
+
154
+ /** Requests a stop and returns the updated record. */
155
+ async stop(id: string, signal?: AbortSignal): Promise<LakeboxEntry> {
156
+ return LakeboxEntrySchema.parse(
157
+ await this.request("POST", `${lakeboxPath(id)}/stop`, { sandbox_id: id }, signal),
158
+ );
159
+ }
160
+
161
+ /** Lists SSH public keys registered by the caller. */
162
+ async listKeys(signal?: AbortSignal): Promise<LakeboxSshKey[]> {
163
+ return LakeboxSshKeyListSchema.parse(
164
+ await this.request("GET", SSH_KEYS_PATH, undefined, signal),
165
+ ).sshKeys;
166
+ }
167
+
168
+ /** Registers an SSH public key and returns the Databricks key record. */
169
+ async registerKey(publicKey: string, name: string, signal?: AbortSignal): Promise<LakeboxSshKey> {
170
+ return LakeboxSshKeySchema.parse(
171
+ await this.request("POST", SSH_KEYS_PATH, { public_key: publicKey, name }, signal),
172
+ );
173
+ }
174
+
175
+ private async request(
176
+ method: RequestMethod,
177
+ path: string,
178
+ payload: unknown,
179
+ signal?: AbortSignal,
180
+ ): Promise<unknown> {
181
+ await this.auth.ensureResolved?.();
182
+ const host = this.auth.host?.replace(/\/+$/, "");
183
+ if (!host) throw new Error("Lakebox REST requests require a Databricks workspace host");
184
+ const headers = new Headers({ Accept: "application/json" });
185
+ const workspaceId = this.auth.workspaceId;
186
+ if (workspaceId && workspaceId !== "none") headers.set(ORG_ID_HEADER, workspaceId);
187
+ if (payload !== undefined) headers.set("Content-Type", "application/json");
188
+ await this.auth.authenticate(headers);
189
+ const startedAt = Date.now();
190
+ const metadata = { method, path: path.split("?")[0], hasPayload: payload !== undefined };
191
+ logger.debug("lakebox REST request", metadata);
192
+ let response: Response;
193
+ try {
194
+ response = await fetch(`${host}${path}`, {
195
+ method,
196
+ headers,
197
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
198
+ ...(signal ? { signal } : {}),
199
+ });
200
+ } catch (error) {
201
+ logger.warn("lakebox REST request failed", {
202
+ ...metadata,
203
+ durationMs: Date.now() - startedAt,
204
+ error: errorUtils.toError(error),
205
+ });
206
+ throw error;
207
+ }
208
+ const text = await response.text();
209
+ const body = parseBody(text);
210
+ if (!response.ok) {
211
+ const record = z
212
+ .object({
213
+ message: z.string().optional(),
214
+ error_code: z.string().optional(),
215
+ request_id: z.string().optional(),
216
+ })
217
+ .passthrough()
218
+ .safeParse(body);
219
+ const details = record.success ? record.data : {};
220
+ const error = new LakeboxApiError(
221
+ details.message || `Lakebox request failed (${response.status})`,
222
+ response.status,
223
+ details.error_code,
224
+ details.request_id ?? response.headers.get("x-request-id") ?? undefined,
225
+ );
226
+ logger.warn("lakebox REST request failed", {
227
+ ...metadata,
228
+ durationMs: Date.now() - startedAt,
229
+ status: error.status,
230
+ errorCode: error.errorCode,
231
+ requestId: error.requestId,
232
+ });
233
+ throw error;
234
+ }
235
+ logger.debug("lakebox REST response", { ...metadata, durationMs: Date.now() - startedAt });
236
+ return body ?? {};
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Returns whether an API error indicates that Lakeboxes are unavailable in the
242
+ * workspace or region.
243
+ */
244
+ export function isLakeboxUnavailable(error: unknown): boolean {
245
+ if (!(error instanceof LakeboxApiError)) return false;
246
+ return (
247
+ error.status === 404 ||
248
+ error.status === 503 ||
249
+ /not available in your region/i.test(error.message)
250
+ );
251
+ }
252
+
253
+ function lakeboxPath(id: string): string {
254
+ return `${LAKEBOXES_PATH}/${encodeURIComponent(id)}`;
255
+ }
256
+
257
+ function parseBody(text: string): unknown {
258
+ if (!text.trim()) return undefined;
259
+ try {
260
+ return JSON.parse(text) as unknown;
261
+ } catch {
262
+ return { message: text.slice(0, 4_000) };
263
+ }
264
+ }
package/src/session.ts ADDED
@@ -0,0 +1,371 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import type { Duplex } from "node:stream";
4
+ import { errorUtils, log } from "@dbx-tools/shared-core";
5
+ import ssh2, { type Client as Ssh2Client } from "ssh2";
6
+ import { z } from "zod";
7
+ import {
8
+ createSshTransport,
9
+ SshCommand,
10
+ sshCommandScript,
11
+ type SshCommandIdentity,
12
+ type SshTransport,
13
+ } from "./_ssh.ts";
14
+ import type { Ssh2PatchMode, SshTransportMode } from "./transport.ts";
15
+
16
+ export { SshCommand, sshCommandScript, type SshCommandIdentity } from "./_ssh.ts";
17
+
18
+ const { Client } = ssh2;
19
+ const logger = log.logger("lakebox:ssh");
20
+ const SERVICE_DIRECT_SUFFIX = ".service-direct.cloud.databricks.com";
21
+
22
+ const DnsAnswerSchema = z
23
+ .object({
24
+ type: z.number().int().describe("DNS record type number."),
25
+ data: z.string().describe("DNS record value."),
26
+ })
27
+ .passthrough()
28
+ .describe("One DNS-over-HTTPS answer record.");
29
+
30
+ const DnsResponseSchema = z
31
+ .object({
32
+ Answer: z.array(DnsAnswerSchema).optional().describe("Resolved DNS answer records."),
33
+ })
34
+ .passthrough()
35
+ .describe("Google DNS-over-HTTPS JSON response.");
36
+
37
+ /** Connection, key, host-verification, and command settings for one SSH machine. */
38
+ export interface SshSessionOptions {
39
+ /** Databricks Lakebox gateway hostname. */
40
+ host: string;
41
+ /** SSH gateway port. */
42
+ port: number;
43
+ /** SSH username accepted by the gateway: the Lakebox ID. */
44
+ username: string;
45
+ /** In-memory private key used for the verified SSH handshake and ssh2 fallback. */
46
+ privateKey: string;
47
+ /** Private key file used by native OpenSSH. */
48
+ privateKeyPath: string;
49
+ /** Dedicated OpenSSH known-host file for this workspace, user, and gateway. */
50
+ knownHostsPath: string;
51
+ /** Connection timeout in milliseconds. */
52
+ connectTimeoutMs: number;
53
+ /** SSH transport selected for this session. */
54
+ sshTransport: SshTransportMode;
55
+ /** ssh2 missing-exec-reply patch policy. */
56
+ ssh2Patch: Ssh2PatchMode;
57
+ /** Previously trusted SHA-256 host-key fingerprint. */
58
+ knownHost?: string;
59
+ /** Persists the accepted host key for both ssh2 and native OpenSSH. */
60
+ rememberHost?: (fingerprint: string, key: Buffer) => Promise<void>;
61
+ /** Native OpenSSH executable. Defaults to `ssh` on `PATH`. */
62
+ sshCommand?: string;
63
+ /** Resolves public fallback addresses for a service-direct gateway. */
64
+ resolvePublicAddresses?: (host: string) => Promise<string[]>;
65
+ }
66
+
67
+ /**
68
+ * Verified SSH session that delegates commands and remote TCP connections to
69
+ * the transport selected by `src/_ssh.ts`.
70
+ *
71
+ * The initial ssh2 handshake verifies and persists the Databricks gateway host
72
+ * key. The selected transport then starts its pooled command and forwarding
73
+ * resources. Resolved transport and patch policy determine whether the verified
74
+ * client seeds the ssh2 pool or is replaced by native OpenSSH ControlMasters.
75
+ */
76
+ export class SshSession {
77
+ private readonly activeCommands = new Set<SshCommand>();
78
+ private readonly pendingClients = new Set<Ssh2Client>();
79
+ private transport?: SshTransport;
80
+ private connecting?: Promise<void>;
81
+ private closing?: Promise<void>;
82
+ private knownHost?: string;
83
+ private closed = false;
84
+
85
+ /** Creates a disconnected session. Call {@link connect} or another operation to connect. */
86
+ constructor(private readonly options: SshSessionOptions) {
87
+ this.knownHost = options.knownHost;
88
+ }
89
+
90
+ /**
91
+ * Verifies the gateway, resolves its public fallback when necessary, and
92
+ * selects the command and forwarding transport.
93
+ */
94
+ async connect(): Promise<void> {
95
+ if (this.closed) throw new Error("SSH session is closed");
96
+ if (this.transport) return;
97
+ if (this.connecting) return this.connecting;
98
+ this.connecting = this.connectWithFallback().catch((error) => {
99
+ this.connecting = undefined;
100
+ throw error;
101
+ });
102
+ return this.connecting;
103
+ }
104
+
105
+ private async connectWithFallback(): Promise<void> {
106
+ try {
107
+ await this.connectHost(this.options.host);
108
+ } catch (error) {
109
+ if (!shouldResolvePublicGateway(this.options.host, error)) throw error;
110
+ const resolveAddresses = this.options.resolvePublicAddresses ?? resolvePublicGatewayAddresses;
111
+ let addresses: string[];
112
+ try {
113
+ addresses = await resolveAddresses(this.options.host);
114
+ } catch (resolveError) {
115
+ logger.warn("public Lakebox gateway resolution failed", {
116
+ host: this.options.host,
117
+ error: errorUtils.toError(resolveError),
118
+ });
119
+ throw error;
120
+ }
121
+ logger.warn("private Lakebox gateway unreachable; retrying public addresses", {
122
+ host: this.options.host,
123
+ addressCount: addresses.length,
124
+ });
125
+ let lastError: unknown = error;
126
+ for (const address of addresses) {
127
+ try {
128
+ await this.connectHost(address);
129
+ return;
130
+ } catch (connectError) {
131
+ lastError = connectError;
132
+ }
133
+ }
134
+ throw lastError;
135
+ }
136
+ }
137
+
138
+ private async connectHost(connectHost: string): Promise<void> {
139
+ if (this.closed) throw new Error("SSH session is closed");
140
+ const client = await this.createVerifiedClient(connectHost, true);
141
+ if (this.closed) {
142
+ client.destroy();
143
+ throw new Error("SSH session is closed");
144
+ }
145
+ try {
146
+ const transport = await createSshTransport({
147
+ host: this.options.host,
148
+ connectHost,
149
+ port: this.options.port,
150
+ username: this.options.username,
151
+ privateKeyPath: this.options.privateKeyPath,
152
+ knownHostsPath: this.options.knownHostsPath,
153
+ connectTimeoutMs: this.options.connectTimeoutMs,
154
+ ssh2Client: client,
155
+ createSsh2CommandClient: () => this.createVerifiedClient(connectHost, false),
156
+ transport: this.options.sshTransport,
157
+ ssh2Patch: this.options.ssh2Patch,
158
+ ...(this.options.sshCommand ? { sshCommand: this.options.sshCommand } : {}),
159
+ });
160
+ if (this.closed) {
161
+ await transport.close();
162
+ throw new Error("SSH session is closed");
163
+ }
164
+ this.transport = transport;
165
+ this.connecting = undefined;
166
+ if (transport.kind === "openssh") client.destroy();
167
+ } catch (error) {
168
+ client.destroy();
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ private createVerifiedClient(connectHost: string, rememberHost: boolean): Promise<Ssh2Client> {
174
+ return new Promise((resolve, reject) => {
175
+ if (this.closed) {
176
+ reject(new Error("SSH session is closed"));
177
+ return;
178
+ }
179
+ const client = new Client();
180
+ this.pendingClients.add(client);
181
+ let verifiedHost: { fingerprint: string; key: Buffer } | undefined;
182
+ let settled = false;
183
+ const finishSettlement = () => {
184
+ this.pendingClients.delete(client);
185
+ };
186
+ client.once("ready", () => {
187
+ const finish = async () => {
188
+ if (!verifiedHost) throw new Error("SSH gateway did not provide a verified host key");
189
+ this.knownHost = verifiedHost.fingerprint;
190
+ if (rememberHost) {
191
+ await this.options.rememberHost?.(verifiedHost.fingerprint, verifiedHost.key);
192
+ }
193
+ settled = true;
194
+ finishSettlement();
195
+ resolve(client);
196
+ };
197
+ void finish().catch((error) => {
198
+ client.destroy();
199
+ if (settled) return;
200
+ settled = true;
201
+ finishSettlement();
202
+ reject(error);
203
+ });
204
+ });
205
+ client.once("error", (error) => {
206
+ if (settled) return;
207
+ settled = true;
208
+ finishSettlement();
209
+ reject(error);
210
+ });
211
+ client.once("close", () => {
212
+ if (settled) return;
213
+ settled = true;
214
+ finishSettlement();
215
+ reject(new Error("SSH gateway closed before authentication completed"));
216
+ });
217
+ try {
218
+ client.connect({
219
+ host: connectHost,
220
+ port: this.options.port,
221
+ username: this.options.username,
222
+ privateKey: this.options.privateKey,
223
+ readyTimeout: this.options.connectTimeoutMs,
224
+ keepaliveInterval: 15_000,
225
+ keepaliveCountMax: 3,
226
+ hostVerifier: (key: Buffer) => {
227
+ const fingerprint = createHash("sha256").update(key).digest("base64");
228
+ if (this.knownHost && fingerprint !== this.knownHost) return false;
229
+ verifiedHost = { fingerprint, key: Buffer.from(key) };
230
+ return true;
231
+ },
232
+ });
233
+ } catch (error) {
234
+ settled = true;
235
+ finishSettlement();
236
+ client.destroy();
237
+ reject(error);
238
+ }
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Starts one command as a Lakebox user and returns its live output and
244
+ * completion state immediately.
245
+ */
246
+ execute(
247
+ command: string,
248
+ args: string[],
249
+ options: {
250
+ user: { name: string; home: string };
251
+ cwd?: string;
252
+ env?: Record<string, string>;
253
+ timeoutMs: number;
254
+ signal?: AbortSignal;
255
+ },
256
+ ): SshCommand {
257
+ const { user, ...rest } = options;
258
+ return this.startCommand(command, args, rest, { kind: "user", ...user });
259
+ }
260
+
261
+ /**
262
+ * @internal Starts one fixed package-owned bootstrap command as root.
263
+ *
264
+ * Never route REST, agent-tool, service, or environment input here.
265
+ */
266
+ executePrivileged(
267
+ command: string,
268
+ args: string[],
269
+ options: { timeoutMs: number; signal?: AbortSignal },
270
+ ): SshCommand {
271
+ return this.startCommand(command, args, options, { kind: "root" });
272
+ }
273
+
274
+ private startCommand(
275
+ command: string,
276
+ args: string[],
277
+ options: {
278
+ cwd?: string;
279
+ env?: Record<string, string>;
280
+ timeoutMs: number;
281
+ signal?: AbortSignal;
282
+ },
283
+ identity: SshCommandIdentity,
284
+ ): SshCommand {
285
+ const script = sshCommandScript(command, args, options.cwd, options.env, identity);
286
+ const sshCommand = new SshCommand(command, args, options, async (execution) => {
287
+ await this.connect();
288
+ if (execution.signal.aborted) return 130;
289
+ const transport = this.transport;
290
+ if (!transport) throw new Error("SSH transport did not initialize");
291
+ return transport.run(script, execution);
292
+ });
293
+ this.activeCommands.add(sshCommand);
294
+ const remove = () => this.activeCommands.delete(sshCommand);
295
+ void sshCommand.result.then(remove, remove);
296
+ return sshCommand;
297
+ }
298
+
299
+ /** Opens a remote TCP connection through native SOCKS5 or ssh2 direct forwarding. */
300
+ async forwardOut(remoteHost: string, remotePort: number): Promise<Duplex> {
301
+ await this.connect();
302
+ const transport = this.transport;
303
+ if (!transport) throw new Error("SSH transport did not initialize");
304
+ return transport.connect(remoteHost, remotePort);
305
+ }
306
+
307
+ /** Cancels active commands, closes the selected transport, and clears connection state. */
308
+ close(): Promise<void> {
309
+ if (this.closing) return this.closing;
310
+ this.closed = true;
311
+ this.closing = this.closeResources();
312
+ return this.closing;
313
+ }
314
+
315
+ private async closeResources(): Promise<void> {
316
+ const cancellations = [...this.activeCommands].map((command) => command.cancel());
317
+ this.activeCommands.clear();
318
+ for (const client of this.pendingClients) client.destroy();
319
+ this.pendingClients.clear();
320
+ const transport = this.transport;
321
+ const connecting = this.connecting;
322
+ this.transport = undefined;
323
+ this.connecting = undefined;
324
+ const results = await Promise.allSettled([
325
+ ...cancellations,
326
+ ...(transport ? [transport.close()] : []),
327
+ ...(connecting ? [connecting] : []),
328
+ ]);
329
+ for (const result of results) {
330
+ if (result.status === "rejected") {
331
+ logger.warn("failed to close SSH session resource", {
332
+ error: errorUtils.toError(result.reason),
333
+ });
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Resolves public IPv4 addresses for a Databricks service-direct gateway.
341
+ *
342
+ * Returns an empty list for other hostnames. DNS-over-HTTPS requests time out
343
+ * after five seconds and reject invalid responses.
344
+ */
345
+ export async function resolvePublicGatewayAddresses(
346
+ host: string,
347
+ fetcher: typeof fetch = fetch,
348
+ ): Promise<string[]> {
349
+ if (!host.toLowerCase().endsWith(SERVICE_DIRECT_SUFFIX)) return [];
350
+ const response = await fetcher(
351
+ `https://dns.google/resolve?name=${encodeURIComponent(host)}&type=A`,
352
+ { headers: { Accept: "application/dns-json" }, signal: AbortSignal.timeout(5_000) },
353
+ );
354
+ if (!response.ok) throw new Error(`Public DNS resolution failed (${response.status})`);
355
+ const payload = DnsResponseSchema.parse(await response.json());
356
+ return [
357
+ ...new Set(
358
+ (payload.Answer ?? [])
359
+ .filter((answer) => answer.type === 1 && isIP(answer.data) === 4)
360
+ .map((answer) => answer.data),
361
+ ),
362
+ ];
363
+ }
364
+
365
+ function shouldResolvePublicGateway(host: string, error: unknown): boolean {
366
+ if (!host.toLowerCase().endsWith(SERVICE_DIRECT_SUFFIX)) return false;
367
+ const code =
368
+ (error as { code?: unknown; cause?: { code?: unknown } })?.code ??
369
+ (error as { cause?: { code?: unknown } })?.cause?.code;
370
+ return code === "EHOSTUNREACH" || code === "ENETUNREACH" || code === "ETIMEDOUT";
371
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+
3
+ export const SshTransportModeSchema = z
4
+ .string()
5
+ .trim()
6
+ .toLowerCase()
7
+ .pipe(z.enum(["auto", "openssh", "ssh2"]))
8
+ .default("auto")
9
+ .describe(
10
+ "SSH transport: auto prefers native OpenSSH with SOCKS5 forwarding and otherwise selects ssh2; explicit modes never switch.",
11
+ );
12
+ /** SSH transport selected for a Lakebox session. */
13
+ export type SshTransportMode = z.output<typeof SshTransportModeSchema>;
14
+
15
+ export const Ssh2PatchModeSchema = z
16
+ .string()
17
+ .trim()
18
+ .toLowerCase()
19
+ .pipe(z.enum(["auto", "true", "false"]))
20
+ .default("auto")
21
+ .describe(
22
+ "ssh2 missing-exec-reply workaround: auto patches only in development, true requires it, false never patches.",
23
+ );
24
+ /** Missing exec-reply patch policy applied when ssh2 is selected. */
25
+ export type Ssh2PatchMode = z.output<typeof Ssh2PatchModeSchema>;