@dbx-tools/cli 0.9.58 → 0.9.61

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 (90) hide show
  1. package/README.md +594 -57
  2. package/bin/dbx-genie.ts +13 -0
  3. package/bin/dbx-graphiti.ts +5 -0
  4. package/bin/dbx-lakebase-proxy.ts +4 -0
  5. package/bin/dbx-model-gateway.ts +5 -0
  6. package/index.ts +47 -1
  7. package/lib/bin/dbx-genie.d.ts +2 -0
  8. package/lib/bin/dbx-genie.js +10 -0
  9. package/lib/bin/dbx-graphiti.d.ts +2 -0
  10. package/lib/bin/dbx-graphiti.js +4 -0
  11. package/lib/bin/dbx-lakebase-proxy.d.ts +2 -0
  12. package/lib/bin/dbx-lakebase-proxy.js +4 -0
  13. package/lib/bin/dbx-model-gateway.d.ts +2 -0
  14. package/lib/bin/dbx-model-gateway.js +4 -0
  15. package/lib/index.d.ts +47 -1
  16. package/lib/index.js +34 -2
  17. package/lib/src/appkit/cli.d.ts +24 -0
  18. package/lib/src/appkit/cli.js +54 -0
  19. package/lib/src/appkit/env-export.d.ts +40 -0
  20. package/lib/src/appkit/env-export.js +99 -0
  21. package/lib/src/auth/cli.d.ts +20 -0
  22. package/lib/src/auth/cli.js +129 -0
  23. package/lib/src/auth/options.d.ts +45 -0
  24. package/lib/src/auth/options.js +68 -0
  25. package/lib/src/cli.d.ts +2 -0
  26. package/lib/src/cli.js +19 -9
  27. package/lib/src/genie-code/cli.d.ts +19 -0
  28. package/lib/src/genie-code/cli.js +36 -0
  29. package/lib/src/genie-code/config.d.ts +38 -0
  30. package/lib/src/genie-code/config.js +111 -0
  31. package/lib/src/genie-code/install.d.ts +21 -0
  32. package/lib/src/genie-code/install.js +69 -0
  33. package/lib/src/genie-code/options.d.ts +7 -0
  34. package/lib/src/genie-code/options.js +7 -0
  35. package/lib/src/genie-code/runner.d.ts +21 -0
  36. package/lib/src/genie-code/runner.js +90 -0
  37. package/lib/src/genie-code/runtime.d.ts +48 -0
  38. package/lib/src/genie-code/runtime.js +162 -0
  39. package/lib/src/graphiti/cli.d.ts +18 -0
  40. package/lib/src/graphiti/cli.js +43 -0
  41. package/lib/src/graphiti/options.d.ts +10 -0
  42. package/lib/src/graphiti/options.js +10 -0
  43. package/lib/src/lakebase-proxy/cancellation.d.ts +22 -0
  44. package/lib/src/lakebase-proxy/cancellation.js +100 -0
  45. package/lib/src/lakebase-proxy/cli.d.ts +13 -0
  46. package/lib/src/lakebase-proxy/cli.js +54 -0
  47. package/lib/src/lakebase-proxy/options.d.ts +23 -0
  48. package/lib/src/lakebase-proxy/options.js +41 -0
  49. package/lib/src/lakebase-proxy/protocol.d.ts +37 -0
  50. package/lib/src/lakebase-proxy/protocol.js +124 -0
  51. package/lib/src/lakebase-proxy/proxy.d.ts +22 -0
  52. package/lib/src/lakebase-proxy/proxy.js +200 -0
  53. package/lib/src/model-gateway/cli.d.ts +19 -0
  54. package/lib/src/model-gateway/cli.js +63 -0
  55. package/lib/src/model-gateway/server.d.ts +16 -0
  56. package/lib/src/model-gateway/server.js +83 -0
  57. package/lib/src/tunnel/app.d.ts +20 -0
  58. package/lib/src/tunnel/app.js +52 -0
  59. package/lib/src/tunnel/cli.d.ts +27 -0
  60. package/lib/src/tunnel/cli.js +190 -0
  61. package/lib/src/tunnel/options.d.ts +72 -0
  62. package/lib/src/tunnel/options.js +138 -0
  63. package/lib/src/tunnel/proxy.d.ts +51 -0
  64. package/lib/src/tunnel/proxy.js +143 -0
  65. package/lib/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +146 -9
  67. package/src/appkit/cli.ts +72 -0
  68. package/src/appkit/env-export.ts +124 -0
  69. package/src/auth/cli.ts +175 -0
  70. package/src/auth/options.ts +83 -0
  71. package/src/cli.ts +35 -8
  72. package/src/genie-code/cli.ts +58 -0
  73. package/src/genie-code/config.ts +161 -0
  74. package/src/genie-code/install.ts +103 -0
  75. package/src/genie-code/options.ts +14 -0
  76. package/src/genie-code/runner.ts +106 -0
  77. package/src/genie-code/runtime.ts +212 -0
  78. package/src/graphiti/cli.ts +75 -0
  79. package/src/graphiti/options.ts +20 -0
  80. package/src/lakebase-proxy/cancellation.ts +116 -0
  81. package/src/lakebase-proxy/cli.ts +80 -0
  82. package/src/lakebase-proxy/options.ts +56 -0
  83. package/src/lakebase-proxy/protocol.ts +170 -0
  84. package/src/lakebase-proxy/proxy.ts +256 -0
  85. package/src/model-gateway/cli.ts +93 -0
  86. package/src/model-gateway/server.ts +94 -0
  87. package/src/tunnel/app.ts +58 -0
  88. package/src/tunnel/cli.ts +218 -0
  89. package/src/tunnel/options.ts +169 -0
  90. package/src/tunnel/proxy.ts +194 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `dbx tunnel` - front a command with a public portr tunnel and passwordless gate.
3
+ *
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.
9
+ *
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.
16
+ *
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).
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { createServer } from "node:net";
26
+ import { AppKitChildProcess } from "@dbx-tools/appkit/child-process";
27
+ import { addArgs, parseArgs } from "@dbx-tools/cli-args";
28
+ import { log } from "@dbx-tools/shared-core";
29
+ import { frp, interceptor, portr } from "@dbx-tools/tunnel";
30
+ import { Command, CommanderError } from "commander";
31
+
32
+ import { resolveTunnelOptions, TunnelOptionsSchema, type TunnelOptions } from "./options.ts";
33
+ import { startProxy } from "./proxy.ts";
34
+
35
+ export { CommanderError };
36
+
37
+ const logger = log.logger("tunnel");
38
+
39
+ /**
40
+ * A free loopback port, from the OS rather than a random guess: binding `0` and
41
+ * reading back what was assigned is the only way to know the port is actually
42
+ * available, so two tunnels can run side by side without colliding.
43
+ */
44
+ function freePort(): Promise<number> {
45
+ return new Promise((resolve, reject) => {
46
+ const probe = createServer();
47
+ probe.on("error", reject);
48
+ probe.listen(0, "127.0.0.1", () => {
49
+ const address = probe.address();
50
+ const port = typeof address === "object" && address ? address.port : 0;
51
+ probe.close(() => (port > 0 ? resolve(port) : reject(new Error("no free port"))));
52
+ });
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Tie the wrapper's lifetime to its children's, in both directions: a child that
58
+ * exits takes the wrapper down with its code, and a signal to the wrapper is
59
+ * forwarded before it leaves. Without this a crashed app leaves a portr tunnel
60
+ * serving a dead port, which looks like a hang rather than a failure.
61
+ */
62
+ function supervise(children: readonly AppKitChildProcess[]): void {
63
+ let stopping = false;
64
+ const stop = (code: number): void => {
65
+ if (stopping) return;
66
+ stopping = true;
67
+ void Promise.allSettled(children.map((child) => child.shutdown())).then(() => {
68
+ process.exit(code);
69
+ });
70
+ };
71
+ for (const child of children) {
72
+ void child.process?.on("exit", (code) => stop(code ?? 1));
73
+ }
74
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
75
+ process.on(signal, () => stop(0));
76
+ }
77
+ }
78
+
79
+ async function run(raw: TunnelOptions, command: readonly string[]): Promise<void> {
80
+ const [executable, ...args] = command;
81
+ const resolved = resolveTunnelOptions(raw);
82
+ const children: AppKitChildProcess[] = [];
83
+
84
+ // Two upstream modes:
85
+ // - WRAP: a command after `--`. The wrapper spawns it on a private loopback
86
+ // port and is the only thing that talks to it.
87
+ // - ATTACH: no command, but `--app-port` names an already-running upstream
88
+ // (e.g. a local reverse proxy). The gate fronts it without spawning a
89
+ // child. This is what lets the gate sit on an interface in front of a
90
+ // separately-supervised stack.
91
+ let appPort: number;
92
+ if (executable) {
93
+ appPort = resolved.appPort ?? (await freePort());
94
+ const app = new AppKitChildProcess(
95
+ [
96
+ executable,
97
+ args,
98
+ {
99
+ env: {
100
+ ...process.env,
101
+ DATABRICKS_APP_PORT: String(appPort),
102
+ PORT: String(appPort),
103
+ HOST: "127.0.0.1",
104
+ },
105
+ stdin: "inherit",
106
+ stdout: "inherit",
107
+ stderr: "inherit",
108
+ },
109
+ ],
110
+ { gracefulTimeoutMs: 10_000 },
111
+ );
112
+ void app.start();
113
+ children.push(app);
114
+ } else if (resolved.appPort) {
115
+ appPort = resolved.appPort;
116
+ logger.info("attaching gate to existing upstream", { appPort });
117
+ } else {
118
+ throw new CommanderError(
119
+ 1,
120
+ "tunnel.no-upstream",
121
+ "no command given (pass it after `--`) and no --app-port to attach to",
122
+ );
123
+ }
124
+
125
+ // Dynamic import: the gate is the only thing here that needs AppKit + SMTP, so
126
+ // an `--insecure` run never loads either.
127
+ const gate = resolved.gate.insecure
128
+ ? undefined
129
+ : await (
130
+ await import("./app.ts")
131
+ ).startGateApp({
132
+ ...resolved.gateConfig,
133
+ publicDomain: resolved.gate.publicDomain ?? `localhost:${resolved.publicPort}`,
134
+ });
135
+ if (!gate) logger.warn("running OPEN - no gate is in front of this tunnel");
136
+
137
+ await startProxy({
138
+ publicPort: resolved.publicPort,
139
+ appPort,
140
+ gate,
141
+ forwardHeaders: resolved.gate.forwardHeaders,
142
+ gatePaths: resolved.gate.gatePaths,
143
+ brandName: resolved.gate.brandName,
144
+ bindHosts: resolved.bindHosts,
145
+ });
146
+
147
+ if ((resolved.transport === "portr" || resolved.transport === "both") && resolved.portr) {
148
+ const portrEnv = await portr.installPortr();
149
+ await portr.writePortrConfig(resolved.portr, portrEnv);
150
+ children.push(await portr.startPortr(resolved.portr, portrEnv));
151
+ }
152
+ if ((resolved.transport === "frp" || resolved.transport === "both") && resolved.frp) {
153
+ const frpEnv = await frp.installFrp();
154
+ const configPath = await frp.writeFrpConfig(resolved.frp, frpEnv);
155
+ children.push(frp.startFrp(resolved.frp, frpEnv, configPath));
156
+ }
157
+ const activeTunnelCount = children.length - (executable ? 1 : 0);
158
+ if (!activeTunnelCount) {
159
+ logger.info("no selected public tunnel is configured", {
160
+ transport: resolved.transport,
161
+ publicPort: resolved.publicPort,
162
+ });
163
+ }
164
+ supervise(children);
165
+ }
166
+
167
+ /** The `dbx tunnel` program. No side effects until parsed. */
168
+ export function buildProgram(name = "dbx tunnel"): Command {
169
+ const program = addArgs(
170
+ new Command()
171
+ .name(name)
172
+ .description("Front a command with a public tunnel and passwordless auth"),
173
+ TunnelOptionsSchema,
174
+ );
175
+
176
+ // `run` is the DEFAULT action as well as a named subcommand, preserving the old
177
+ // wrapper's ergonomics (`dbx tunnel --allow x -- bun src/server.ts`) while
178
+ // leaving somewhere for `status` and `install` to live.
179
+ program
180
+ .argument("[command...]", "the command to wrap, after `--`")
181
+ .action(async (command: string[]) => {
182
+ await run(parseArgs(program, TunnelOptionsSchema), command);
183
+ });
184
+
185
+ const runCommand = addArgs(
186
+ program.command("run").description("Wrap a command (the default action)"),
187
+ TunnelOptionsSchema,
188
+ );
189
+ runCommand
190
+ .argument("<command...>", "the command to wrap, after `--`")
191
+ .action(async (command: string[]) => {
192
+ await run(parseArgs(runCommand, TunnelOptionsSchema), command);
193
+ });
194
+
195
+ // Worth its own command: the most common failure is a tunnel that silently
196
+ // does nothing because no token or domain resolved, and this prints exactly
197
+ // what would happen without starting anything.
198
+ const statusCommand = addArgs(
199
+ program.command("status").description("Resolve the configuration and print it"),
200
+ TunnelOptionsSchema,
201
+ );
202
+ statusCommand.action(() => {
203
+ const resolved = resolveTunnelOptions(parseArgs(statusCommand, TunnelOptionsSchema));
204
+ process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
205
+ });
206
+
207
+ program
208
+ .command("install")
209
+ .argument("[transport]", "portr, frp, or both", "portr")
210
+ .description("Install public tunnel client binaries and exit")
211
+ .action(async (transport: string) => {
212
+ const parsed = interceptor.TunnelTransportSchema.parse(transport);
213
+ if (parsed === "portr" || parsed === "both") await portr.installPortr();
214
+ if (parsed === "frp" || parsed === "both") await frp.installFrp();
215
+ });
216
+
217
+ return program;
218
+ }
@@ -0,0 +1,169 @@
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 { configUtils } from "@dbx-tools/core";
16
+ import { options as sharedOptions, stringUtils } from "@dbx-tools/shared-core";
17
+ import { type AuthGateConfig, frp, interceptor, plugin, portr } from "@dbx-tools/tunnel";
18
+ import { z } from "zod";
19
+
20
+ const text = (description: string) => z.string().trim().min(1).optional().describe(description);
21
+ const port = (description: string) =>
22
+ sharedOptions.tcpPortOrZeroSchema.optional().describe(description);
23
+ const list = (description: string) =>
24
+ z
25
+ .preprocess(
26
+ (value) => (typeof value === "string" ? stringUtils.parseList(value) : value),
27
+ z.array(z.string().trim().min(1)),
28
+ )
29
+ .optional()
30
+ .describe(description);
31
+
32
+ export const TunnelOptionsSchema = z
33
+ .object({
34
+ transport: interceptor.TunnelTransportSchema.optional()
35
+ .describe("Public tunnel transport.")
36
+ .meta({ env: "TUNNEL_TRANSPORT" }),
37
+ publicDomain: text("Public tunnel domain.").meta({ env: "TUNNEL_PUBLIC_DOMAIN" }),
38
+ subdomain: text("Portr subdomain."),
39
+ port: port("Public listener port.").meta({
40
+ env: sharedOptions.databricksEnvironmentNames.appPort,
41
+ }),
42
+ appPort: port("Private wrapped application port.").meta({
43
+ env: ["TUNNEL_APP_PORT", "APP_PORT"],
44
+ }),
45
+ allow: list("Email allow-list patterns.").meta({ env: "TUNNEL_AUTH_ALLOW" }),
46
+ subject: text("Verification email subject.").meta({ env: "TUNNEL_AUTH_SUBJECT" }),
47
+ brandName: text("Verification email brand name.").meta({ env: "TUNNEL_AUTH_BRAND_NAME" }),
48
+ message: text("Verification email message.").meta({ env: "TUNNEL_AUTH_MESSAGE" }),
49
+ sessionTtlSeconds: z.coerce
50
+ .number<number>()
51
+ .positive()
52
+ .optional()
53
+ .describe("Session lifetime in seconds.")
54
+ .meta({ env: "TUNNEL_AUTH_SESSION_TTL" }),
55
+ codeTtlSeconds: z.coerce
56
+ .number<number>()
57
+ .positive()
58
+ .optional()
59
+ .describe("One-time-code lifetime in seconds.")
60
+ .meta({ env: "TUNNEL_AUTH_CODE_TTL" }),
61
+ sessionCutoff: text("Invalidate sessions issued before this value.").meta({
62
+ env: "TUNNEL_AUTH_SESSION_CUTOFF",
63
+ }),
64
+ storage: z
65
+ .enum(["auto", "lakebase", "sqlite"])
66
+ .optional()
67
+ .describe("Authentication database mode.")
68
+ .meta({ env: "TUNNEL_AUTH_STORAGE" }),
69
+ sqlitePath: text("Local authentication SQLite file.").meta({
70
+ env: "TUNNEL_AUTH_SQLITE_PATH",
71
+ }),
72
+ forwardHeaders: list("Additional forwarded request header patterns.").meta({
73
+ env: "TUNNEL_FORWARD_HEADERS",
74
+ }),
75
+ gatePaths: list("Additional path prefixes requiring authentication.").meta({
76
+ env: "TUNNEL_GATE_PATHS",
77
+ }),
78
+ bindHosts: list("Interface IPs the gate listens on."),
79
+ insecure: z
80
+ .boolean()
81
+ .optional()
82
+ .describe("Run without an authentication gate.")
83
+ .meta({ env: "TUNNEL_INSECURE" }),
84
+ frpServer: text("FRP control host.").meta({ env: "FRP_SERVER" }),
85
+ frpPublicDomain: text("FRP public HTTP domain.").meta({
86
+ env: "TUNNEL_FRP_PUBLIC_DOMAIN",
87
+ }),
88
+ frpServerPort: port("FRP control port.").meta({ env: "FRP_SERVER_PORT" }),
89
+ frpProtocol: text("FRP transport protocol.").meta({ env: "FRP_PROTOCOL" }),
90
+ frpToken: text("FRP authentication token.").meta({ env: ["FRP_TOKEN", "TUNNEL_TOKEN"] }),
91
+ frpProxyName: text("FRP proxy registration name.").meta({ env: "FRP_PROXY_NAME" }),
92
+ })
93
+ .strict()
94
+ .describe("Public tunnel wrapper command-line options.");
95
+
96
+ export type TunnelOptions = z.output<typeof TunnelOptionsSchema>;
97
+
98
+ /** Fully resolved listener, gate, and transport settings used to start a tunnel. */
99
+ export interface ResolvedTunnelOptions {
100
+ /** The port the wrapper itself listens on - what portr forwards to. */
101
+ publicPort: number;
102
+ /** The private port the wrapped app is told to bind. Unset means "pick one". */
103
+ appPort?: number;
104
+ /** Interface IPs the gate listens on. Empty means the default (0.0.0.0). */
105
+ bindHosts: string[];
106
+ transport: interceptor.TunnelTransport;
107
+ /**
108
+ * The gate config as the `authGate` PLUGIN takes it - flags only, nothing
109
+ * resolved. Passed straight to the plugin so it applies its own fallbacks
110
+ * exactly once, in the one place that owns them.
111
+ */
112
+ gateConfig: AuthGateConfig;
113
+ /** The same config after the plugin's resolution, for `status` and for routing. */
114
+ gate: plugin.ResolvedAuthGateConfig;
115
+ portr: ReturnType<typeof portr.resolvePortrConfig>;
116
+ frp: ReturnType<typeof frp.resolveFrpConfig>;
117
+ }
118
+
119
+ /** Resolve CLI flag values through the owning tunnel and auth-gate configuration rules. */
120
+ export function resolveTunnelOptions(options: TunnelOptions): ResolvedTunnelOptions {
121
+ // The Databricks Apps runtime contract: the platform routes to
122
+ // DATABRICKS_APP_PORT, so the WRAPPER claims it and the wrapped app is moved
123
+ // to a private one.
124
+ const publicPort = configUtils.port(options.port, "DATABRICKS_APP_PORT", 8000);
125
+ const appPort = configUtils.port(options.appPort, "APP_PORT", 0, { prefix: "TUNNEL" });
126
+ const gateConfig: AuthGateConfig = {
127
+ allow: options.allow,
128
+ subject: options.subject,
129
+ brandName: options.brandName,
130
+ message: options.message,
131
+ // Coerced, not resolved: the plugin owns the env name and the default for
132
+ // these, so the flag is passed through as the `config` value it expects and
133
+ // only needs a string -> number nudge on the way.
134
+ sessionTtlSeconds: options.sessionTtlSeconds,
135
+ codeTtlSeconds: options.codeTtlSeconds,
136
+ sessionCutoff: options.sessionCutoff,
137
+ storage: options.storage,
138
+ sqlitePath: options.sqlitePath,
139
+ forwardHeaders: options.forwardHeaders,
140
+ gatePaths: options.gatePaths,
141
+ insecure: options.insecure,
142
+ publicDomain: options.publicDomain,
143
+ publicDomains: options.frpPublicDomain ? [options.frpPublicDomain] : undefined,
144
+ };
145
+ const gate = plugin.resolveAuthGateConfig(gateConfig);
146
+ const transport = interceptor.resolveTunnelTransport(options.transport);
147
+ return {
148
+ publicPort,
149
+ ...(appPort > 0 ? { appPort } : {}),
150
+ bindHosts: options.bindHosts ?? [],
151
+ transport,
152
+ gateConfig,
153
+ gate,
154
+ portr: portr.resolvePortrConfig({
155
+ publicDomain: gate.publicDomain,
156
+ subdomain: options.subdomain,
157
+ port: publicPort,
158
+ }),
159
+ frp: frp.resolveFrpConfig({
160
+ publicDomain: options.frpPublicDomain,
161
+ server: options.frpServer,
162
+ serverPort: options.frpServerPort,
163
+ protocol: options.frpProtocol,
164
+ token: options.frpToken,
165
+ proxyName: options.frpProxyName,
166
+ port: publicPort,
167
+ }),
168
+ };
169
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The reverse proxy that makes the wrapper path possible.
3
+ *
4
+ * The wrapper claims the PUBLIC port and the wrapped app runs as a child process
5
+ * on a private loopback port, so - unlike the in-process plugin - there is no
6
+ * middleware chain to insert the gate into. This proxy is that insertion point: it
7
+ * answers the login routes itself, applies the gate to everything else, and
8
+ * forwards what survives to the child.
9
+ *
10
+ * The gating DECISION is not reimplemented here. `@dbx-tools/tunnel`'s
11
+ * `gate.gateRequest` makes it - the same function the Express middleware calls -
12
+ * and this module only differs in how the outcome is written: a proxied request
13
+ * instead of `next()`, a `writeHead` instead of `res.json`. That is deliberate:
14
+ * two independent implementations of "which requests are gated and which headers
15
+ * are stripped" is the one way this package could become a security bug.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
+ import type { Socket } from "node:net";
22
+ import { log } from "@dbx-tools/shared-core";
23
+ import {
24
+ gate as tunnelGate,
25
+ headers as tunnelHeaders,
26
+ loginPage as tunnelLoginPage,
27
+ type AuthGateApi,
28
+ } from "@dbx-tools/tunnel";
29
+ import ProxyModule from "http-proxy-3";
30
+
31
+ const logger = log.logger("tunnel:proxy");
32
+
33
+ type UpgradeSocket = Socket & { destroySoon?: () => void };
34
+
35
+ /** Listener, upstream application, and optional authentication settings for the wrapper proxy. */
36
+ export interface ProxyOptions {
37
+ /** The port this proxy listens on - the port portr and the platform route to. */
38
+ publicPort: number;
39
+ /** The private loopback port the wrapped app listens on. */
40
+ appPort: number;
41
+ /** The gate handlers. Omitted (an `--insecure` run) forwards everything. */
42
+ gate?: AuthGateApi;
43
+ /** Extra `x-` headers tunnel traffic may forward. */
44
+ forwardHeaders?: readonly string[];
45
+ /** Path prefixes to gate beyond `/api/` (e.g. `/ws` for a WebSocket app). */
46
+ gatePaths?: readonly string[];
47
+ /** Brand name for the hosted login page shown on a denied browser navigation. */
48
+ brandName?: string;
49
+ /**
50
+ * Addresses to listen on. Defaults to a single `0.0.0.0` (every interface),
51
+ * the portr/platform case. Pass specific interface IPs to expose the gate on
52
+ * only those - e.g. an overlay/LAN address while loopback reaches the upstream
53
+ * directly and ungated.
54
+ */
55
+ bindHosts?: readonly string[];
56
+ }
57
+
58
+ function sendJson(
59
+ response: ServerResponse,
60
+ status: number,
61
+ body: unknown,
62
+ setCookie?: string,
63
+ ): void {
64
+ response.writeHead(status, {
65
+ "content-type": "application/json",
66
+ ...(setCookie ? { "set-cookie": setCookie } : {}),
67
+ });
68
+ response.end(JSON.stringify(body));
69
+ }
70
+
71
+ /**
72
+ * Hand every Better Auth and compatibility route to the shared gate runtime.
73
+ */
74
+ async function handleAuthRoute(
75
+ request: IncomingMessage,
76
+ response: ServerResponse,
77
+ path: string,
78
+ gate: AuthGateApi,
79
+ brandName: string,
80
+ ): Promise<boolean> {
81
+ if (!tunnelGate.isAuthPath(path)) return false;
82
+ if (tunnelGate.wantsHostedLogin(request)) {
83
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
84
+ response.end(
85
+ tunnelLoginPage.loginPageHtml({
86
+ brandName,
87
+ returnTo: tunnelGate.loginReturnTo(request),
88
+ }),
89
+ );
90
+ return true;
91
+ }
92
+ const result = await gate.handler(tunnelGate.webRequest(request));
93
+ await tunnelGate.sendWebResponse(response, result);
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Start the proxy and resolve once it is listening.
99
+ *
100
+ * `publicDomain` is intentionally NOT passed to `gateRequest`: on this path every
101
+ * request arrived on the public port, so it IS tunnel traffic by construction -
102
+ * whereas the in-process gate shares a port with the platform front door and has
103
+ * to tell them apart by `Host`. Passing the request's own host keeps the shared
104
+ * decision function's contract satisfied without weakening it.
105
+ */
106
+ export async function startProxy(options: ProxyOptions): Promise<void> {
107
+ const proxy = ProxyModule.createProxyServer({
108
+ target: `http://127.0.0.1:${options.appPort}`,
109
+ ws: true,
110
+ xfwd: true,
111
+ });
112
+ const headerPolicy = tunnelHeaders.toHeaderPolicy(options.forwardHeaders);
113
+ const gate = options.gate;
114
+
115
+ const decide = (request: IncomingMessage): Promise<tunnelGate.GateAction> =>
116
+ gate
117
+ ? tunnelGate.gateRequest(request, {
118
+ gate,
119
+ publicDomain: (request.headers.host ?? "").split(":")[0] || "localhost",
120
+ headerPolicy,
121
+ gatePaths: options.gatePaths,
122
+ })
123
+ : Promise.resolve<tunnelGate.GateAction>("pass");
124
+
125
+ const onRequest = (request: IncomingMessage, response: ServerResponse): void => {
126
+ void (async () => {
127
+ const path = (request.url ?? "/").split("?")[0] ?? "/";
128
+ const brandName = options.brandName ?? "this app";
129
+ if (gate && (await handleAuthRoute(request, response, path, gate, brandName))) return;
130
+ if ((await decide(request)) === "deny") {
131
+ // A denied browser navigation gets the hosted login page; anything else
132
+ // (an XHR, a /ws upgrade probe, a non-HTML fetch) gets the 401 JSON.
133
+ if (gate && tunnelGate.wantsLoginPage(request)) {
134
+ const html = tunnelLoginPage.loginPageHtml({
135
+ brandName,
136
+ returnTo: tunnelGate.requestReturnTo(request),
137
+ });
138
+ response.writeHead(401, { "content-type": "text/html; charset=utf-8" });
139
+ response.end(html);
140
+ } else {
141
+ sendJson(response, 401, tunnelGate.unauthorizedBody(request));
142
+ }
143
+ return;
144
+ }
145
+ proxy.web(request, response);
146
+ })().catch((error: unknown) => {
147
+ logger.error("proxy request failed", { error });
148
+ if (!response.headersSent) sendJson(response, 502, { error: "bad gateway" });
149
+ else response.end();
150
+ });
151
+ };
152
+
153
+ // A websocket upgrade cannot be answered with a 401 body, so a denied one is
154
+ // destroyed - the client sees the handshake fail, which is what a browser's
155
+ // WebSocket error handler expects.
156
+ const onUpgrade = (request: IncomingMessage, socket: Socket, head: Buffer): void => {
157
+ const upgradeSocket = socket as UpgradeSocket;
158
+ upgradeSocket.destroySoon ??= () => upgradeSocket.end();
159
+ const path = (request.url ?? "/").split("?", 1)[0] ?? "/";
160
+ if (tunnelGate.isAuthPath(path)) {
161
+ upgradeSocket.destroy();
162
+ return;
163
+ }
164
+ void decide(request)
165
+ .then((action) => {
166
+ if (action === "deny") upgradeSocket.destroy();
167
+ else proxy.ws(request, upgradeSocket, head);
168
+ })
169
+ .catch(() => upgradeSocket.destroy());
170
+ };
171
+
172
+ // One net.Server binds one address, so listen each host on its own server that
173
+ // shares the same handlers. Binding specific interface IPs (rather than the
174
+ // default 0.0.0.0) is what lets the gate front an overlay/LAN address while
175
+ // loopback reaches the upstream ungated.
176
+ const hosts = options.bindHosts?.length ? options.bindHosts : ["0.0.0.0"];
177
+ await Promise.all(
178
+ hosts.map(
179
+ (host) =>
180
+ new Promise<void>((resolve) => {
181
+ const server = createServer(onRequest);
182
+ server.on("upgrade", onUpgrade);
183
+ server.listen(options.publicPort, host, () => {
184
+ logger.info("proxy listening", {
185
+ host,
186
+ publicPort: options.publicPort,
187
+ appPort: options.appPort,
188
+ });
189
+ resolve();
190
+ });
191
+ }),
192
+ ),
193
+ );
194
+ }