@dbx-tools/tunnel 0.6.120 → 0.6.124
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/index.ts +2 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -1
- package/lib/src/frp.d.ts +2 -0
- package/lib/src/frp.js +10 -1
- package/lib/src/interceptor.d.ts +12 -2
- package/lib/src/interceptor.js +57 -30
- package/lib/src/plugin.d.ts +1 -1
- package/lib/src/plugin.js +39 -26
- package/lib/src/portr.d.ts +2 -0
- package/lib/src/portr.js +9 -1
- package/lib/src/supervisor.d.ts +11 -0
- package/lib/src/supervisor.js +77 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/frp.ts +14 -0
- package/src/interceptor.ts +69 -28
- package/src/plugin.ts +41 -28
- package/src/portr.ts +9 -0
- package/src/supervisor.ts +89 -0
package/src/interceptor.ts
CHANGED
|
@@ -17,11 +17,10 @@
|
|
|
17
17
|
* @module
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { ChildProcess } from "node:child_process";
|
|
21
20
|
import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
|
|
22
21
|
import { log, object } from "@dbx-tools/shared-core";
|
|
23
|
-
import { installFrp, resolveFrpConfig,
|
|
24
|
-
import { installPortr, resolvePortrConfig,
|
|
22
|
+
import { installFrp, resolveFrpConfig, superviseFrp, writeFrpConfig } from "./frp.ts";
|
|
23
|
+
import { installPortr, resolvePortrConfig, supervisePortr, writePortrConfig } from "./portr.ts";
|
|
25
24
|
import { startPathProxy } from "./path-proxy.ts";
|
|
26
25
|
|
|
27
26
|
const logger = log.logger("tunnel:interceptor");
|
|
@@ -61,13 +60,49 @@ export interface TunnelInterceptorOptions {
|
|
|
61
60
|
/** Public tunnel clients supported by the interceptor and CLI. */
|
|
62
61
|
export type TunnelTransport = "portr" | "frp" | "both";
|
|
63
62
|
|
|
63
|
+
interface TunnelAuxiliary {
|
|
64
|
+
stop(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TunnelRuntime {
|
|
68
|
+
startPortr(config: NonNullable<ReturnType<typeof resolvePortrConfig>>): Promise<TunnelAuxiliary>;
|
|
69
|
+
startFrp(
|
|
70
|
+
config: NonNullable<ReturnType<typeof resolveFrpConfig>>,
|
|
71
|
+
ctx: InterceptorContext,
|
|
72
|
+
): Promise<TunnelAuxiliary>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const defaultRuntime: TunnelRuntime = {
|
|
76
|
+
async startPortr(config) {
|
|
77
|
+
const portrEnv = await installPortr();
|
|
78
|
+
await writePortrConfig(config, portrEnv);
|
|
79
|
+
return supervisePortr(config, portrEnv);
|
|
80
|
+
},
|
|
81
|
+
async startFrp(config, ctx) {
|
|
82
|
+
const pathProxy =
|
|
83
|
+
config.stripPrefix && config.path !== "/"
|
|
84
|
+
? await startPathProxy(config.targetPort, config.path)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (pathProxy) {
|
|
87
|
+
config.targetPort = pathProxy.port;
|
|
88
|
+
ctx.onLifecycle("shutdown", () => void pathProxy.close());
|
|
89
|
+
}
|
|
90
|
+
const frpEnv = await installFrp();
|
|
91
|
+
const configPath = await writeFrpConfig(config, frpEnv);
|
|
92
|
+
const supervisor = superviseFrp(config, frpEnv, configPath);
|
|
93
|
+
return {
|
|
94
|
+
stop() {
|
|
95
|
+
supervisor.stop();
|
|
96
|
+
void pathProxy?.close();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
64
102
|
/** Resolve and validate the tunnel transport selector. */
|
|
65
103
|
export function resolveTunnelTransport(transport?: string): TunnelTransport {
|
|
66
104
|
const resolved =
|
|
67
|
-
transport ??
|
|
68
|
-
process.env.DBX_TOOLS_TUNNEL_TRANSPORT ??
|
|
69
|
-
process.env.TUNNEL_TRANSPORT ??
|
|
70
|
-
"portr";
|
|
105
|
+
transport ?? process.env.DBX_TOOLS_TUNNEL_TRANSPORT ?? process.env.TUNNEL_TRANSPORT ?? "portr";
|
|
71
106
|
if (resolved === "portr" || resolved === "frp" || resolved === "both") return resolved;
|
|
72
107
|
throw new TypeError(`invalid tunnel transport: ${resolved} (expected portr, frp, or both)`);
|
|
73
108
|
}
|
|
@@ -92,7 +127,10 @@ function resolvePublicPort(port?: number): number {
|
|
|
92
127
|
* interceptor: tunnelInterceptor(),
|
|
93
128
|
* });
|
|
94
129
|
*/
|
|
95
|
-
export function tunnelInterceptor(
|
|
130
|
+
export function tunnelInterceptor(
|
|
131
|
+
options: TunnelInterceptorOptions = {},
|
|
132
|
+
runtime: TunnelRuntime = defaultRuntime,
|
|
133
|
+
): Interceptor {
|
|
96
134
|
return async (ctx: InterceptorContext): Promise<void> => {
|
|
97
135
|
if (ctx.env.databricksHost) {
|
|
98
136
|
process.env.DATABRICKS_HOST ??= ctx.env.databricksHost;
|
|
@@ -100,7 +138,7 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
|
|
|
100
138
|
|
|
101
139
|
const port = resolvePublicPort(options.port);
|
|
102
140
|
const transport = resolveTunnelTransport(options.transport);
|
|
103
|
-
const
|
|
141
|
+
const initializers: Array<Promise<TunnelAuxiliary>> = [];
|
|
104
142
|
if (transport === "portr" || transport === "both") {
|
|
105
143
|
const portrConfig = resolvePortrConfig({
|
|
106
144
|
publicDomain: options.publicDomain,
|
|
@@ -108,9 +146,7 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
|
|
|
108
146
|
port,
|
|
109
147
|
});
|
|
110
148
|
if (portrConfig) {
|
|
111
|
-
|
|
112
|
-
await writePortrConfig(portrConfig, portrEnv);
|
|
113
|
-
children.push(await startPortr(portrConfig, portrEnv));
|
|
149
|
+
initializers.push(runtime.startPortr(portrConfig));
|
|
114
150
|
} else {
|
|
115
151
|
logger.info("portr not configured (requires PORTR_TOKEN and TUNNEL_PUBLIC_DOMAIN)");
|
|
116
152
|
}
|
|
@@ -128,26 +164,31 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
|
|
|
128
164
|
port,
|
|
129
165
|
});
|
|
130
166
|
if (frpConfig) {
|
|
131
|
-
|
|
132
|
-
frpConfig.stripPrefix && frpConfig.path !== "/"
|
|
133
|
-
? await startPathProxy(port, frpConfig.path)
|
|
134
|
-
: undefined;
|
|
135
|
-
if (pathProxy) {
|
|
136
|
-
frpConfig.targetPort = pathProxy.port;
|
|
137
|
-
ctx.onLifecycle("shutdown", () => void pathProxy.close());
|
|
138
|
-
}
|
|
139
|
-
const frpEnv = await installFrp();
|
|
140
|
-
const configPath = await writeFrpConfig(frpConfig, frpEnv);
|
|
141
|
-
children.push(startFrp(frpConfig, frpEnv, configPath));
|
|
167
|
+
initializers.push(runtime.startFrp(frpConfig, ctx));
|
|
142
168
|
} else {
|
|
143
169
|
logger.info("frp not configured (requires TUNNEL_PUBLIC_DOMAIN)");
|
|
144
170
|
}
|
|
145
171
|
}
|
|
146
|
-
if (!
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
172
|
+
if (!initializers.length) return;
|
|
173
|
+
const auxiliaries: TunnelAuxiliary[] = [];
|
|
174
|
+
let stopped = false;
|
|
175
|
+
const stop = () => {
|
|
176
|
+
stopped = true;
|
|
177
|
+
for (const auxiliary of auxiliaries) auxiliary.stop();
|
|
178
|
+
};
|
|
179
|
+
ctx.onTeardown(stop);
|
|
180
|
+
ctx.onLifecycle("shutdown", stop);
|
|
181
|
+
void Promise.allSettled(initializers).then((results) => {
|
|
182
|
+
for (const result of results) {
|
|
183
|
+
if (result.status === "rejected") {
|
|
184
|
+
logger.error("tunnel failed to start", { error: result.reason });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (stopped) result.value.stop();
|
|
188
|
+
else auxiliaries.push(result.value);
|
|
189
|
+
}
|
|
190
|
+
if (!stopped && auxiliaries.length) logger.info(`tunnel bound: ${transport} -> :${port}`);
|
|
150
191
|
});
|
|
151
|
-
logger.info(`tunnel
|
|
192
|
+
logger.info(`tunnel initialization scheduled: ${transport} -> :${port}`);
|
|
152
193
|
};
|
|
153
194
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
ResourceType,
|
|
26
26
|
} from "@databricks/appkit";
|
|
27
27
|
import { plugin as appkitPlugin } from "@dbx-tools/appkit";
|
|
28
|
+
import { brand as appkitBrand } from "@dbx-tools/appkit";
|
|
28
29
|
import {
|
|
29
30
|
auth as passwordlessAuth,
|
|
30
31
|
storage as authStorage,
|
|
@@ -356,7 +357,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
356
357
|
} satisfies PluginManifest<"authGate">;
|
|
357
358
|
|
|
358
359
|
private resolved!: ResolvedAuthGateConfig;
|
|
359
|
-
private
|
|
360
|
+
private readonly runtimes = new Map<string, PasswordlessAuthRuntime>();
|
|
360
361
|
|
|
361
362
|
static getResourceRequirements(config: AuthGateConfig): ResourceRequirement[] {
|
|
362
363
|
if (authStorage.resolveAuthStorageConfig(config).mode !== "lakebase") return [];
|
|
@@ -378,32 +379,40 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
378
379
|
|
|
379
380
|
override async setup(): Promise<void> {
|
|
380
381
|
this.resolved = resolveAuthGateConfig(this.config);
|
|
382
|
+
if (!this.config.brandName && !coreConfig.text("AUTH_BRAND_NAME", TUNNEL_CONFIG)) {
|
|
383
|
+
this.resolved.brandName = appkitBrand.getBrandContext().name;
|
|
384
|
+
}
|
|
381
385
|
|
|
382
386
|
if (this.resolved.insecure) {
|
|
383
387
|
logger.warn("insecure mode - the tunnel runs OPEN with no auth gate");
|
|
384
388
|
} else {
|
|
385
389
|
const lakebasePlugin = appkitPlugin.instance(this.context, lakebase);
|
|
386
|
-
const storage = await authStorage.createAuthStorage(
|
|
387
|
-
this.resolved,
|
|
388
|
-
lakebasePlugin?.exports().pool,
|
|
389
|
-
);
|
|
390
390
|
const { key } = await signingKey(this.resolved.sessionCutoffMs);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
391
|
+
const origins = this.resolved.publicDomains.length
|
|
392
|
+
? this.resolved.publicDomains.map(authOrigin)
|
|
393
|
+
: [authOrigin(this.resolved.publicDomain)];
|
|
394
|
+
for (const origin of new Set(origins)) {
|
|
395
|
+
const storage = await authStorage.createAuthStorage(
|
|
396
|
+
this.resolved,
|
|
397
|
+
lakebasePlugin?.exports().pool,
|
|
398
|
+
);
|
|
399
|
+
this.runtimes.set(new URL(origin).host.toLowerCase(), await passwordlessAuth.createPasswordlessAuth({
|
|
400
|
+
storage,
|
|
401
|
+
baseURL: origin,
|
|
402
|
+
basePath: "/api/email/auth",
|
|
403
|
+
appName: this.resolved.brandName,
|
|
404
|
+
secret: Buffer.from(key).toString("base64url"),
|
|
405
|
+
sessionTtlSeconds: this.resolved.sessionTtlSeconds,
|
|
406
|
+
sessionCutoffMs: this.resolved.sessionCutoffMs,
|
|
407
|
+
logoutRedirectPath: this.resolved.logoutRedirectPath,
|
|
408
|
+
codeTtlSeconds: this.resolved.codeTtlSeconds,
|
|
409
|
+
maxAttempts: this.resolved.maxAttempts,
|
|
410
|
+
authorizeIdentity: (email) => this.authorizeIdentity(email),
|
|
411
|
+
sendCode: (email, code, options) => this.sendCode(email, code, options),
|
|
412
|
+
subject: this.resolved.subject,
|
|
413
|
+
message: this.resolved.message,
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
407
416
|
// `server()` is deferred, so it does not exist during this plugin's setup.
|
|
408
417
|
// At `setup:complete` the Express app exists but has not injected plugin
|
|
409
418
|
// routes or static handling yet, which is the one point the gate can mount
|
|
@@ -431,7 +440,8 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
431
440
|
}
|
|
432
441
|
|
|
433
442
|
async shutdown(): Promise<void> {
|
|
434
|
-
await this.runtime
|
|
443
|
+
await Promise.all([...this.runtimes.values()].map((runtime) => runtime.close()));
|
|
444
|
+
this.runtimes.clear();
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
/**
|
|
@@ -467,16 +477,19 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
|
|
|
467
477
|
}
|
|
468
478
|
|
|
469
479
|
override exports(): AuthGateApi {
|
|
470
|
-
const runtime =
|
|
480
|
+
const runtime = (headers?: Headers, request?: Request) => {
|
|
481
|
+
const host = (headers?.get("host") ?? (request ? new URL(request.url).host : "")).toLowerCase();
|
|
482
|
+
return this.runtimes.get(host) ?? this.runtimes.values().next().value;
|
|
483
|
+
};
|
|
471
484
|
return {
|
|
472
|
-
passkeysEnabled:
|
|
485
|
+
passkeysEnabled: [...this.runtimes.values()].some((entry) => entry.passkeysEnabled),
|
|
473
486
|
handler: (request) =>
|
|
474
|
-
runtime?.handler(request) ?? Promise.resolve(new Response("Not Found", { status: 404 })),
|
|
475
|
-
session: (headers) => runtime?.session(headers) ?? Promise.resolve(undefined),
|
|
487
|
+
runtime(request.headers, request)?.handler(request) ?? Promise.resolve(new Response("Not Found", { status: 404 })),
|
|
488
|
+
session: (headers) => runtime(headers)?.session(headers) ?? Promise.resolve(undefined),
|
|
476
489
|
status: (headers) =>
|
|
477
|
-
runtime?.status(headers) ??
|
|
490
|
+
runtime(headers)?.status(headers) ??
|
|
478
491
|
Promise.resolve({ authenticated: false, enabled: false, passkeysEnabled: false }),
|
|
479
|
-
close: () =>
|
|
492
|
+
close: () => this.shutdown(),
|
|
480
493
|
};
|
|
481
494
|
}
|
|
482
495
|
}
|
package/src/portr.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { promisify } from "node:util";
|
|
|
18
18
|
import { bin, config } from "@dbx-tools/core";
|
|
19
19
|
import { log } from "@dbx-tools/shared-core";
|
|
20
20
|
import { TUNNEL_CONFIG } from "./_config.ts";
|
|
21
|
+
import { superviseProcessForever, type ProcessSupervisor } from "./supervisor.ts";
|
|
21
22
|
|
|
22
23
|
const logger = log.logger("tunnel:portr");
|
|
23
24
|
const PORTR_VERSION = "1.0.15-sse.2";
|
|
@@ -141,3 +142,11 @@ export async function startPortr(
|
|
|
141
142
|
logger.info(`portr tunneling https://${config.subdomain}.${config.server} -> :${config.port}`);
|
|
142
143
|
return spawn("portr", ["start"], { env: childEnv, stdio: "inherit" });
|
|
143
144
|
}
|
|
145
|
+
|
|
146
|
+
export function supervisePortr(config: PortrConfig, childEnv: NodeJS.ProcessEnv): ProcessSupervisor {
|
|
147
|
+
return superviseProcessForever({
|
|
148
|
+
name: "portr",
|
|
149
|
+
logger,
|
|
150
|
+
start: () => startPortr(config, childEnv),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { async as asyncTools, type Logger } from "@dbx-tools/shared-core";
|
|
3
|
+
|
|
4
|
+
const STABLE_CONNECTION_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export interface ProcessSupervisor {
|
|
7
|
+
stop(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ProcessOutcome = {
|
|
11
|
+
code?: number | null;
|
|
12
|
+
signal?: NodeJS.Signals | null;
|
|
13
|
+
error?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function superviseProcessForever(options: {
|
|
17
|
+
name: string;
|
|
18
|
+
logger: Logger;
|
|
19
|
+
start: () => ChildProcess | Promise<ChildProcess>;
|
|
20
|
+
retryDelaysMs?: readonly number[];
|
|
21
|
+
}): ProcessSupervisor {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
let child: ChildProcess | undefined;
|
|
24
|
+
|
|
25
|
+
const stop = () => {
|
|
26
|
+
if (controller.signal.aborted) return;
|
|
27
|
+
controller.abort();
|
|
28
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
29
|
+
process.off("exit", onProcessExit);
|
|
30
|
+
};
|
|
31
|
+
const onProcessExit = () => {
|
|
32
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
33
|
+
};
|
|
34
|
+
process.once("exit", onProcessExit);
|
|
35
|
+
|
|
36
|
+
const run = async () => {
|
|
37
|
+
let failures = 0;
|
|
38
|
+
while (!controller.signal.aborted) {
|
|
39
|
+
const startedAt = Date.now();
|
|
40
|
+
let outcome: ProcessOutcome;
|
|
41
|
+
try {
|
|
42
|
+
child = await options.start();
|
|
43
|
+
outcome = await processOutcome(child, controller.signal);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
outcome = { error };
|
|
46
|
+
} finally {
|
|
47
|
+
child = undefined;
|
|
48
|
+
}
|
|
49
|
+
if (controller.signal.aborted) return;
|
|
50
|
+
if (Date.now() - startedAt >= STABLE_CONNECTION_MS) failures = 0;
|
|
51
|
+
const delayMs = asyncTools.boundedRetryDelay(failures++, options.retryDelaysMs);
|
|
52
|
+
options.logger.warn(`${options.name} stopped; retrying`, { ...outcome, delayMs });
|
|
53
|
+
try {
|
|
54
|
+
await asyncTools.sleep(delayMs, controller.signal);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
void run().catch((error) => options.logger.error(`${options.name} supervisor failed`, { error }));
|
|
62
|
+
return {
|
|
63
|
+
stop,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function processOutcome(child: ChildProcess, signal: AbortSignal): Promise<ProcessOutcome> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const finish = (outcome: ProcessOutcome) => {
|
|
70
|
+
child.off("exit", onExit);
|
|
71
|
+
child.off("error", onError);
|
|
72
|
+
signal.removeEventListener("abort", onAbort);
|
|
73
|
+
resolve(outcome);
|
|
74
|
+
};
|
|
75
|
+
const onExit = (code: number | null, exitSignal: NodeJS.Signals | null) =>
|
|
76
|
+
finish({ code, signal: exitSignal });
|
|
77
|
+
const onError = (error: Error) => {
|
|
78
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
79
|
+
finish({ error });
|
|
80
|
+
};
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
if (!child.killed) child.kill("SIGTERM");
|
|
83
|
+
finish({ signal: "SIGTERM" });
|
|
84
|
+
};
|
|
85
|
+
child.once("exit", onExit);
|
|
86
|
+
child.once("error", onError);
|
|
87
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
88
|
+
});
|
|
89
|
+
}
|