@dbx-tools/cli-tunnel 0.6.59 → 0.6.86

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.
Files changed (45) hide show
  1. package/README.md +1 -364
  2. package/index.ts +2 -19
  3. package/lib/index.d.ts +2 -19
  4. package/lib/index.js +2 -15
  5. package/lib/src/app.d.ts +12 -123
  6. package/lib/src/app.js +22 -250
  7. package/lib/src/cli.d.ts +19 -22
  8. package/lib/src/cli.js +143 -128
  9. package/lib/src/options.d.ts +46 -0
  10. package/lib/src/options.js +51 -0
  11. package/lib/src/proxy.d.ts +27 -37
  12. package/lib/src/proxy.js +115 -220
  13. package/lib/tsconfig.tsbuildinfo +1 -1
  14. package/package.json +15 -81
  15. package/src/app.ts +20 -282
  16. package/src/cli.ts +152 -162
  17. package/src/options.ts +85 -0
  18. package/src/proxy.ts +139 -261
  19. package/bin/dbx-tools-tunnel.ts +0 -13
  20. package/lib/bin/dbx-tools-tunnel.d.ts +0 -2
  21. package/lib/bin/dbx-tools-tunnel.js +0 -14
  22. package/lib/src/allowlist.d.ts +0 -32
  23. package/lib/src/allowlist.js +0 -57
  24. package/lib/src/env.d.ts +0 -57
  25. package/lib/src/env.js +0 -60
  26. package/lib/src/headers.d.ts +0 -108
  27. package/lib/src/headers.js +0 -140
  28. package/lib/src/otp.d.ts +0 -49
  29. package/lib/src/otp.js +0 -124
  30. package/lib/src/plugin.d.ts +0 -147
  31. package/lib/src/plugin.js +0 -138
  32. package/lib/src/portr.d.ts +0 -40
  33. package/lib/src/portr.js +0 -93
  34. package/lib/src/rate-limit.d.ts +0 -35
  35. package/lib/src/rate-limit.js +0 -53
  36. package/lib/src/signing-key.d.ts +0 -86
  37. package/lib/src/signing-key.js +0 -170
  38. package/src/allowlist.ts +0 -60
  39. package/src/env.ts +0 -72
  40. package/src/headers.ts +0 -155
  41. package/src/otp.ts +0 -137
  42. package/src/plugin.ts +0 -269
  43. package/src/portr.ts +0 -113
  44. package/src/rate-limit.ts +0 -59
  45. package/src/signing-key.ts +0 -201
package/src/cli.ts CHANGED
@@ -1,201 +1,191 @@
1
1
  /**
2
- * `dbx-tools-tunnel` / `dbxt-tunnel` CLI.
2
+ * `dbx tunnel` - front a command with a public portr tunnel and an email-OTP gate.
3
3
  *
4
- * Wraps an app's start command with a public portr tunnel and an email-OTP
5
- * access gate. Everything after `--` is the REAL app start command:
4
+ * This is the WRAPPER path, and it exists for one case: a project that does not
5
+ * use `@dbx-tools/appkit`'s `createApp`, and therefore cannot register
6
+ * `tunnelInterceptor()` + the `authGate` plugin in-process. An AppKit app should
7
+ * still take the plugin path - one process, no proxy hop, no duplicated header
8
+ * handling.
6
9
  *
7
- * dbxt-tunnel --subject "Here's your OTP" --allow example.com -- bun src/server.ts
10
+ * The wrapper claims the PUBLIC port (`DATABRICKS_APP_PORT`, the port the
11
+ * platform and portr route to), moves the wrapped app to a private one, and
12
+ * reverse-proxies between them so the gate sits in front of traffic it would
13
+ * otherwise have no way to intercept. Everything else is delegated: the gate
14
+ * config comes from `plugin.resolveAuthGateConfig`, the portr lifecycle from
15
+ * `portr.*` - both the same functions the in-process path uses.
8
16
  *
9
- * Boot sequence:
10
- * 1. Pick a random PRIVATE port and spawn the app command with
11
- * `DATABRICKS_APP_PORT` set to it (so the app binds loopback-private). That
12
- * variable name is the Databricks Apps runtime contract; the gate itself is
13
- * platform-neutral and honours whatever port it finds there.
14
- * 2. Boot the tiny gate AppKit app (no server): inits `CacheManager` + the
15
- * email transport, yields the in-process gate API.
16
- * 3. Start the gate PROXY on the ORIGINAL public port, forwarding to the app.
17
- * 4. Install + run portr pointed at the public port (when a tunnel is
18
- * configured; otherwise the proxy still gates nothing and forwards).
19
- *
20
- * Supervision: the app child, portr child, and this process are tied together -
21
- * if ANY exits, everything comes down (concurrently-style `killOthers`).
22
- *
23
- * Options come from flags OR env; see the option definitions below.
17
+ * Ships no bin. `@dbx-tools/cli` mounts `buildProgram()` as `dbx tunnel` lazily,
18
+ * so `dbx dev` pays for none of this, and `--insecure` / `status` / `install`
19
+ * never load AppKit or the SMTP stack either (the gate app is behind a dynamic
20
+ * import).
24
21
  *
25
22
  * @module
26
23
  */
27
24
 
28
25
  import { type ChildProcess, spawn } from "node:child_process";
29
- import { env, log } from "@dbx-tools/shared-core";
26
+ import { createServer } from "node:net";
27
+ import { log } from "@dbx-tools/shared-core";
28
+ import { portr } from "@dbx-tools/tunnel";
30
29
  import { Command, CommanderError } from "commander";
31
- import { startGateApp } from "./app.ts";
32
- import { FORWARD_HEADERS_ENV, INSECURE_ENV } from "./env.ts";
33
- import type { AuthGateConfig } from "./plugin.ts";
34
- import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
30
+ import { resolveTunnelOptions, type TunnelOptions } from "./options.ts";
35
31
  import { startProxy } from "./proxy.ts";
36
32
 
37
33
  export { CommanderError };
38
34
 
39
35
  const logger = log.logger("tunnel");
40
36
 
41
- /** A random ephemeral port for the app to bind (the proxy fronts the public one). */
42
- function randomPort(): number {
43
- return 20000 + Math.floor(Math.random() * 20000);
44
- }
37
+ /** How long a child gets to exit on SIGTERM before the wrapper leaves anyway. */
38
+ const SHUTDOWN_GRACE_MS = 3_000;
45
39
 
46
- interface TunnelOpts {
47
- subject?: string;
48
- allow?: string;
49
- subdomain?: string;
50
- publicDomain?: string;
51
- brandName?: string;
52
- message?: string;
53
- sessionTtl?: string;
54
- codeTtl?: string;
55
- sessionCutoff?: string;
56
- insecure?: boolean;
57
- forwardHeaders?: string;
40
+ /**
41
+ * A free loopback port, from the OS rather than a random guess: binding `0` and
42
+ * reading back what was assigned is the only way to know the port is actually
43
+ * available, so two tunnels can run side by side without colliding.
44
+ */
45
+ function freePort(): Promise<number> {
46
+ return new Promise((resolve, reject) => {
47
+ const probe = createServer();
48
+ probe.on("error", reject);
49
+ probe.listen(0, "127.0.0.1", () => {
50
+ const address = probe.address();
51
+ const port = typeof address === "object" && address ? address.port : 0;
52
+ probe.close(() => (port > 0 ? resolve(port) : reject(new Error("no free port"))));
53
+ });
54
+ });
58
55
  }
59
56
 
60
- /** Build the commander program. `--` separates flags from the app start command. */
61
- function program(): Command {
62
- return new Command()
63
- .name("dbx-tools-tunnel")
64
- .description("Front an app with a public portr tunnel + email-OTP gate")
65
- .option("--subject <text>", "Subject line for the code email (env TUNNEL_AUTH_SUBJECT)")
66
- .option(
67
- "--allow <patterns>",
68
- "Comma/space-separated allow-list: domain / glob / /regex/ (env TUNNEL_AUTH_ALLOW)",
69
- )
70
- .option("--subdomain <name>", "portr subdomain (else derived from TUNNEL_PUBLIC_DOMAIN)")
71
- .option("--public-domain <host>", "portr <subdomain>.<server> (env TUNNEL_PUBLIC_DOMAIN)")
72
- .option(
73
- "--brand-name <name>",
74
- "Display name in the code email copy (env TUNNEL_AUTH_BRAND_NAME; defaults to the brand context name)",
75
- )
76
- .option("--message <text>", "Line shown above the code in the email (env TUNNEL_AUTH_MESSAGE)")
77
- .option("--session-ttl <seconds>", "Session lifetime (env TUNNEL_AUTH_SESSION_TTL)")
78
- .option("--code-ttl <seconds>", "One-time-code lifetime (env TUNNEL_AUTH_CODE_TTL)")
79
- .option(
80
- "--session-cutoff <when>",
81
- "Invalidate sessions issued before this point, signing everyone out: a date, ISO instant, epoch seconds, or relative duration like -30d (env TUNNEL_AUTH_SESSION_CUTOFF)",
82
- )
83
- .option(
84
- "--forward-headers <patterns>",
85
- "Extra x- request headers tunnel traffic may forward: literal / glob / /regex/ (env TUNNEL_FORWARD_HEADERS)",
86
- )
87
- .option(
88
- "--insecure",
89
- "Run the tunnel OPEN with no gate (env TUNNEL_INSECURE=true). Otherwise the CLI fails fast when email SMTP is not configured.",
90
- )
91
- .allowExcessArguments(true)
92
- .helpOption("-h, --help", "Show help");
57
+ /**
58
+ * Every gate/portr flag. Declared on the root command AND on `run`/`status` so
59
+ * `dbx tunnel --allow x -- cmd` and `dbx tunnel run --allow x -- cmd` behave
60
+ * identically - commander does not inherit options downward.
61
+ */
62
+ function addOptions(command: Command): Command {
63
+ return command
64
+ .option("--public-domain <host>", "portr public domain (<subdomain>.<server>)")
65
+ .option("--subdomain <name>", "portr subdomain (else derived from the public domain)")
66
+ .option("--port <port>", "public port the wrapper listens on")
67
+ .option("--app-port <port>", "private port the wrapped app is told to bind")
68
+ .option("--allow <patterns...>", "email allow-list (domain / glob / /regex/)")
69
+ .option("--subject <text>", "verification email subject")
70
+ .option("--brand-name <name>", "verification email brand name")
71
+ .option("--message <text>", "verification email message")
72
+ .option("--session-ttl <seconds>", "session lifetime")
73
+ .option("--code-ttl <seconds>", "one-time-code lifetime")
74
+ .option("--session-cutoff <when>", "invalidate every session issued before this")
75
+ .option("--forward-headers <patterns...>", "extra x- headers tunnel traffic may forward")
76
+ .option("--insecure", "run open, with no gate");
93
77
  }
94
78
 
95
- /** Tie a child's exit to full teardown: any exit brings the whole tunnel down. */
96
- function superviseExit(children: ChildProcess[]): void {
97
- let shuttingDown = false;
98
- const shutdown = (code: number): void => {
99
- if (shuttingDown) return;
100
- shuttingDown = true;
101
- for (const child of children) {
102
- if (!child.killed) child.kill("SIGTERM");
103
- }
104
- // Give children a moment, then exit with the first non-zero code seen.
105
- setTimeout(() => process.exit(code), 3000).unref();
79
+ /**
80
+ * Tie the wrapper's lifetime to its children's, in both directions: a child that
81
+ * exits takes the wrapper down with its code, and a signal to the wrapper is
82
+ * forwarded before it leaves. Without this a crashed app leaves a portr tunnel
83
+ * serving a dead port, which looks like a hang rather than a failure.
84
+ */
85
+ function supervise(children: readonly ChildProcess[]): void {
86
+ let stopping = false;
87
+ const stop = (code: number): void => {
88
+ if (stopping) return;
89
+ stopping = true;
90
+ for (const child of children) if (!child.killed) child.kill("SIGTERM");
91
+ setTimeout(() => process.exit(code), SHUTDOWN_GRACE_MS).unref();
106
92
  };
107
- for (const child of children) {
108
- child.on("exit", (code) => {
109
- logger.warn(`child exited (${code ?? "signal"}); bringing tunnel down`);
110
- shutdown(code ?? 1);
111
- });
112
- }
113
- for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
114
- process.on(sig, () => shutdown(0));
93
+ for (const child of children) child.on("exit", (code) => stop(code ?? 1));
94
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
95
+ process.on(signal, () => stop(0));
115
96
  }
116
97
  }
117
98
 
118
- /** Parse argv and run the tunnel. */
119
- export async function runCli(argv: string[]): Promise<void> {
120
- // Split flags from the wrapped command at the first `--`.
121
- const sep = argv.indexOf("--");
122
- const flags = sep >= 0 ? argv.slice(0, sep) : argv;
123
- const command = sep >= 0 ? argv.slice(sep + 1) : [];
124
-
125
- const prog = program();
126
- prog.parse(flags);
127
- const opts = prog.opts<TunnelOpts>();
128
-
129
- if (command.length === 0) {
130
- throw new CommanderError(1, "tunnel.no-command", "no start command given after `--`");
99
+ async function run(raw: TunnelOptions, command: readonly string[]): Promise<void> {
100
+ const [executable, ...args] = command;
101
+ if (!executable) {
102
+ throw new CommanderError(1, "tunnel.no-command", "no command given - pass it after `--`");
131
103
  }
132
-
133
- const publicPort = Number(process.env.DATABRICKS_APP_PORT ?? 8000);
134
- const appPort = randomPort();
135
-
136
- const gateConfig: AuthGateConfig = {
137
- allow: opts.allow,
138
- subject: opts.subject,
139
- brandName: opts.brandName,
140
- message: opts.message,
141
- sessionTtlSeconds: opts.sessionTtl ? Number(opts.sessionTtl) : undefined,
142
- codeTtlSeconds: opts.codeTtl ? Number(opts.codeTtl) : undefined,
143
- sessionCutoff: opts.sessionCutoff,
144
- };
145
-
146
- // 1. Spawn the wrapped app with the PRIVATE port. It binds loopback; only the
147
- // proxy reaches it.
148
- logger.info(`spawning app on private port ${appPort}: ${command.join(" ")}`);
149
- const [cmd, ...args] = command;
150
- const app = spawn(cmd!, args, {
151
- env: { ...process.env, DATABRICKS_APP_PORT: String(appPort), HOST: "127.0.0.1" },
104
+ const resolved = resolveTunnelOptions(raw);
105
+
106
+ // The wrapped app must not be reachable from outside: it binds loopback on a
107
+ // private port, and the wrapper is the only thing that talks to it. An
108
+ // unresolved `--app-port` becomes a free ephemeral port rather than a fixed
109
+ // guess, so two tunnels can run side by side.
110
+ const appPort = resolved.appPort ?? (await freePort());
111
+ const app = spawn(executable, args, {
112
+ env: {
113
+ ...process.env,
114
+ DATABRICKS_APP_PORT: String(appPort),
115
+ PORT: String(appPort),
116
+ HOST: "127.0.0.1",
117
+ },
152
118
  stdio: "inherit",
153
119
  });
120
+ const children: ChildProcess[] = [app];
154
121
 
155
- // 2. Boot the gate app (cache + email transport + gate API). `startGateApp`
156
- // FAILS FAST when email can't send codes (no SMTP). Insecure mode
157
- // (`--insecure` / TUNNEL_INSECURE) skips the gate and runs the tunnel open.
158
- const insecure = env.boolean(opts.insecure, INSECURE_ENV) ?? false;
159
- let gate: Awaited<ReturnType<typeof startGateApp>> | undefined;
160
- if (insecure) {
161
- logger.warn("insecure mode - tunnel runs OPEN with no email-OTP gate");
162
- } else {
163
- try {
164
- gate = await startGateApp(gateConfig);
165
- } catch (error) {
166
- // Fail fast: don't silently expose an ungated tunnel. The operator must fix
167
- // SMTP or explicitly opt into `--insecure`.
168
- logger.error("cannot start the OTP gate", { error: (error as Error).message });
169
- throw error;
170
- }
171
- }
122
+ // Dynamic import: the gate is the only thing here that needs AppKit + SMTP, so
123
+ // an `--insecure` run never loads either.
124
+ const gate = resolved.gate.insecure
125
+ ? undefined
126
+ : await (await import("./app.ts")).startGateApp(resolved.gateConfig);
127
+ if (!gate) logger.warn("running OPEN - no gate is in front of this tunnel");
172
128
 
173
- // 3. Start the gate proxy on the public port (open when `gate` is undefined).
174
- // `forwardHeaders` only ADDS to the built-in allow-list; see `./headers.ts`.
175
129
  await startProxy({
176
- publicPort,
130
+ publicPort: resolved.publicPort,
177
131
  appPort,
178
132
  gate,
179
- forwardHeaders: env.list(opts.forwardHeaders, FORWARD_HEADERS_ENV),
133
+ forwardHeaders: resolved.gate.forwardHeaders,
180
134
  });
181
135
 
182
- // 4. Install + run portr when a tunnel is configured.
183
- const portrConfig = resolvePortrConfig({
184
- publicDomain: opts.publicDomain,
185
- subdomain: opts.subdomain,
186
- port: publicPort,
187
- });
188
- const children: ChildProcess[] = [app];
189
- if (portrConfig) {
190
- const portrEnv = installPortr();
191
- writePortrConfig(portrConfig, portrEnv);
192
- children.push(startPortr(portrConfig, portrEnv));
136
+ if (resolved.portr) {
137
+ const portrEnv = await portr.installPortr();
138
+ await portr.writePortrConfig(resolved.portr, portrEnv);
139
+ children.push(await portr.startPortr(resolved.portr, portrEnv));
193
140
  } else {
194
- logger.info(
195
- "no PORTR_TOKEN/TUNNEL_PUBLIC_DOMAIN - serving the gate proxy without a public tunnel",
196
- );
141
+ logger.info("no PORTR_TOKEN / TUNNEL_PUBLIC_DOMAIN - serving locally only", {
142
+ publicPort: resolved.publicPort,
143
+ });
197
144
  }
145
+ supervise(children);
146
+ }
147
+
148
+ /** The `dbx tunnel` program. No side effects until parsed. */
149
+ export function buildProgram(name = "dbx tunnel"): Command {
150
+ const program = addOptions(
151
+ new Command()
152
+ .name(name)
153
+ .description("Front a command with a portr tunnel and an email-OTP gate"),
154
+ );
155
+
156
+ // `run` is the DEFAULT action as well as a named subcommand, preserving the old
157
+ // wrapper's ergonomics (`dbx tunnel --allow x -- bun src/server.ts`) while
158
+ // leaving somewhere for `status` and `install` to live.
159
+ program
160
+ .argument("[command...]", "the command to wrap, after `--`")
161
+ .action(async (command: string[], _options: TunnelOptions, cmd: Command) => {
162
+ await run(cmd.opts<TunnelOptions>(), command);
163
+ });
164
+
165
+ addOptions(
166
+ program.command("run").description("Wrap a command (the default action)"),
167
+ )
168
+ .argument("<command...>", "the command to wrap, after `--`")
169
+ .action(async (command: string[], _options: TunnelOptions, cmd: Command) => {
170
+ await run(cmd.opts<TunnelOptions>(), command);
171
+ });
172
+
173
+ // Worth its own command: the most common failure is a tunnel that silently
174
+ // does nothing because no token or domain resolved, and this prints exactly
175
+ // what would happen without starting anything.
176
+ addOptions(
177
+ program.command("status").description("Resolve the configuration and print it"),
178
+ ).action((_options: TunnelOptions, cmd: Command) => {
179
+ const resolved = resolveTunnelOptions(cmd.opts<TunnelOptions>());
180
+ process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
181
+ });
182
+
183
+ program
184
+ .command("install")
185
+ .description("Install the portr binary and exit")
186
+ .action(async () => {
187
+ await portr.installPortr();
188
+ });
198
189
 
199
- // Any child exit (or a signal) tears the whole thing down.
200
- superviseExit(children);
190
+ return program;
201
191
  }
package/src/options.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Flag -> config -> default resolution for `dbx tunnel`.
3
+ *
4
+ * The point of this module is that it does almost NOTHING itself: every gate
5
+ * setting is handed straight to `@dbx-tools/tunnel`'s own
6
+ * `plugin.resolveAuthGateConfig`, which is the same function the in-process
7
+ * plugin path calls. The CLI therefore cannot drift from the plugin on a default,
8
+ * an env name, or a coercion rule - a flag is just a value passed where the
9
+ * plugin's `config` object would go, and `config.*` fills the rest in from the
10
+ * environment, a `.env` file, or `databricks.yml`.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { config } from "@dbx-tools/core";
16
+ import { object } from "@dbx-tools/shared-core";
17
+ import { type AuthGateConfig, plugin, portr } from "@dbx-tools/tunnel";
18
+
19
+ /** Raw commander flag values (every numeric flag arrives as a string). */
20
+ export interface TunnelOptions {
21
+ publicDomain?: string;
22
+ subdomain?: string;
23
+ port?: string | number;
24
+ appPort?: string | number;
25
+ allow?: string[];
26
+ subject?: string;
27
+ brandName?: string;
28
+ message?: string;
29
+ sessionTtl?: string | number;
30
+ codeTtl?: string | number;
31
+ sessionCutoff?: string;
32
+ forwardHeaders?: string[];
33
+ insecure?: boolean;
34
+ }
35
+
36
+ export interface ResolvedTunnelOptions {
37
+ /** The port the wrapper itself listens on - what portr forwards to. */
38
+ publicPort: number;
39
+ /** The private port the wrapped app is told to bind. Unset means "pick one". */
40
+ appPort?: number;
41
+ /**
42
+ * The gate config as the `authGate` PLUGIN takes it - flags only, nothing
43
+ * resolved. Passed straight to the plugin so it applies its own fallbacks
44
+ * exactly once, in the one place that owns them.
45
+ */
46
+ gateConfig: AuthGateConfig;
47
+ /** The same config after the plugin's resolution, for `status` and for routing. */
48
+ gate: plugin.ResolvedAuthGateConfig;
49
+ portr: ReturnType<typeof portr.resolvePortrConfig>;
50
+ }
51
+
52
+ export function resolveTunnelOptions(options: TunnelOptions): ResolvedTunnelOptions {
53
+ // The Databricks Apps runtime contract: the platform routes to
54
+ // DATABRICKS_APP_PORT, so the WRAPPER claims it and the wrapped app is moved
55
+ // to a private one.
56
+ const publicPort = config.positiveInt(options.port, "DATABRICKS_APP_PORT", 8000);
57
+ const appPort = config.positiveInt(options.appPort, "APP_PORT", 0, { prefix: "TUNNEL" });
58
+ const gateConfig: AuthGateConfig = {
59
+ allow: options.allow,
60
+ subject: options.subject,
61
+ brandName: options.brandName,
62
+ message: options.message,
63
+ // Coerced, not resolved: the plugin owns the env name and the default for
64
+ // these, so the flag is passed through as the `config` value it expects and
65
+ // only needs a string -> number nudge on the way.
66
+ sessionTtlSeconds: object.toNumber(options.sessionTtl),
67
+ codeTtlSeconds: object.toNumber(options.codeTtl),
68
+ sessionCutoff: options.sessionCutoff,
69
+ forwardHeaders: options.forwardHeaders,
70
+ insecure: options.insecure,
71
+ publicDomain: options.publicDomain,
72
+ };
73
+ const gate = plugin.resolveAuthGateConfig(gateConfig);
74
+ return {
75
+ publicPort,
76
+ ...(appPort > 0 ? { appPort } : {}),
77
+ gateConfig,
78
+ gate,
79
+ portr: portr.resolvePortrConfig({
80
+ publicDomain: gate.publicDomain,
81
+ subdomain: options.subdomain,
82
+ port: publicPort,
83
+ }),
84
+ };
85
+ }