@dbx-tools/cli-tunnel 0.6.45
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/README.md +1 -0
- package/bin/dbx-tools-tunnel.ts +13 -0
- package/index.ts +20 -0
- package/lib/bin/dbx-tools-tunnel.d.ts +2 -0
- package/lib/bin/dbx-tools-tunnel.js +14 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +16 -0
- package/lib/src/allowlist.d.ts +27 -0
- package/lib/src/allowlist.js +82 -0
- package/lib/src/app.d.ts +31 -0
- package/lib/src/app.js +71 -0
- package/lib/src/cli.d.ts +28 -0
- package/lib/src/cli.js +147 -0
- package/lib/src/otp.d.ts +50 -0
- package/lib/src/otp.js +134 -0
- package/lib/src/plugin.d.ts +99 -0
- package/lib/src/plugin.js +123 -0
- package/lib/src/portr.d.ts +39 -0
- package/lib/src/portr.js +90 -0
- package/lib/src/proxy.d.ts +40 -0
- package/lib/src/proxy.js +204 -0
- package/lib/src/rate-limit.d.ts +35 -0
- package/lib/src/rate-limit.js +53 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +117 -0
- package/src/allowlist.ts +88 -0
- package/src/app.ts +82 -0
- package/src/cli.ts +176 -0
- package/src/otp.ts +150 -0
- package/src/plugin.ts +200 -0
- package/src/portr.ts +110 -0
- package/src/proxy.ts +243 -0
- package/src/rate-limit.ts +59 -0
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel gate reverse-proxy.
|
|
3
|
+
*
|
|
4
|
+
* Binds the PUBLIC port (the container's `DATABRICKS_APP_PORT`) and forwards to
|
|
5
|
+
* the real app on a private loopback port. It is the single front door for both
|
|
6
|
+
* traffic paths into the container:
|
|
7
|
+
*
|
|
8
|
+
* - **portr client** (same container, connects over LOOPBACK) - the public
|
|
9
|
+
* tunnel. This is what the gate protects.
|
|
10
|
+
* - **Databricks control plane / front door** (reaches the `0.0.0.0` port from
|
|
11
|
+
* a NON-loopback container-network address) - passed through UNGATED, per the
|
|
12
|
+
* rule "if it's not from the portr client, let it in".
|
|
13
|
+
*
|
|
14
|
+
* The distinguisher is the connection's source address: a loopback
|
|
15
|
+
* `req.socket.remoteAddress` is the portr client; anything else is the platform.
|
|
16
|
+
*
|
|
17
|
+
* For portr traffic the gate is a standard SPA gate: static assets + the login
|
|
18
|
+
* flow (`/api/email/auth/*`, answered IN-PROCESS by this proxy via the
|
|
19
|
+
* {@link AuthGateApi}) are open so the browser can load the client and render the
|
|
20
|
+
* `<AuthGate>`; every other `/api/*` needs a valid session cookie or gets 401.
|
|
21
|
+
* WebSocket upgrades are gated the same way and forwarded with `http-proxy-3`.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
27
|
+
import type { Socket } from "node:net";
|
|
28
|
+
import { http, json, log } from "@dbx-tools/shared-core";
|
|
29
|
+
import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
|
|
30
|
+
import ProxyModule from "http-proxy-3";
|
|
31
|
+
import type { AuthGateApi } from "./plugin.ts";
|
|
32
|
+
|
|
33
|
+
const logger = log.logger("tunnel:proxy");
|
|
34
|
+
|
|
35
|
+
/** Route prefix the login flow lives under (open, answered in-process). */
|
|
36
|
+
const AUTH_PREFIX = "/api/email/auth";
|
|
37
|
+
|
|
38
|
+
/** True when a socket address is loopback (the portr client, same container). */
|
|
39
|
+
function isLoopback(addr: string | undefined): boolean {
|
|
40
|
+
if (!addr) return false;
|
|
41
|
+
// Normalize IPv4-mapped IPv6 (`::ffff:127.0.0.1`) and bare IPv6 loopback.
|
|
42
|
+
const a = addr.replace(/^::ffff:/, "");
|
|
43
|
+
return a === "127.0.0.1" || a.startsWith("127.") || a === "::1" || a === "localhost";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the whole request body as text (small JSON payloads only). */
|
|
47
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let data = "";
|
|
50
|
+
req.on("data", (chunk) => (data += chunk));
|
|
51
|
+
req.on("end", () => resolve(data));
|
|
52
|
+
req.on("error", () => resolve(data));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sendJson(res: ServerResponse, status: number, body: unknown, setCookie?: string): void {
|
|
57
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
58
|
+
if (setCookie) headers["set-cookie"] = setCookie;
|
|
59
|
+
res.writeHead(status, headers);
|
|
60
|
+
res.end(JSON.stringify(body));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for {@link startProxy}. */
|
|
64
|
+
export interface ProxyOptions {
|
|
65
|
+
/** Public port to listen on (the container's DATABRICKS_APP_PORT). */
|
|
66
|
+
publicPort: number;
|
|
67
|
+
/** Private port the real app listens on (loopback). */
|
|
68
|
+
appPort: number;
|
|
69
|
+
/**
|
|
70
|
+
* The in-process gate API, or `undefined` to run OPEN (insecure mode): every
|
|
71
|
+
* request - including portr traffic - is forwarded ungated. Used when the
|
|
72
|
+
* operator passed `--insecure` / `TUNNEL_INSECURE=true`.
|
|
73
|
+
*/
|
|
74
|
+
gate?: AuthGateApi;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Start the gate proxy. Resolves once it is listening. */
|
|
78
|
+
export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise<void> {
|
|
79
|
+
const proxy = ProxyModule.createProxyServer({
|
|
80
|
+
target: { host: "127.0.0.1", port: appPort },
|
|
81
|
+
ws: true,
|
|
82
|
+
xfwd: true,
|
|
83
|
+
});
|
|
84
|
+
proxy.on("error", (err: Error, _req: unknown, res: unknown) => {
|
|
85
|
+
logger.warn("upstream proxy error", { error: err.message });
|
|
86
|
+
const r = res as ServerResponse | undefined;
|
|
87
|
+
if (r && "writeHead" in r && !r.headersSent) {
|
|
88
|
+
sendJson(r, 502, { error: "upstream unavailable" });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The session cookie for a verified email, as a Set-Cookie string. */
|
|
93
|
+
const sessionCookie = (token: string, maxAgeSeconds: number): string =>
|
|
94
|
+
[
|
|
95
|
+
`${SESSION_COOKIE_NAME}=${token}`,
|
|
96
|
+
"Path=/",
|
|
97
|
+
"HttpOnly",
|
|
98
|
+
"SameSite=Lax",
|
|
99
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
100
|
+
process.env.NODE_ENV === "production" ? "Secure" : "",
|
|
101
|
+
]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join("; ");
|
|
104
|
+
|
|
105
|
+
/** Client IP for rate-limiting: the portr-forwarded XFF, else the socket. */
|
|
106
|
+
const clientIp = (req: IncomingMessage): string => {
|
|
107
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
108
|
+
const first = Array.isArray(fwd) ? fwd[0] : fwd?.split(",")[0];
|
|
109
|
+
return (first ?? req.socket.remoteAddress ?? "unknown").trim();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remove the gate's session cookie from the `Cookie` header before forwarding,
|
|
114
|
+
* so the app never sees `dbx_auth` (it is the proxy's concern, not the app's).
|
|
115
|
+
* Preserves any other cookies. Removes the header entirely when it becomes empty.
|
|
116
|
+
*/
|
|
117
|
+
const stripSessionCookie = (req: IncomingMessage): void => {
|
|
118
|
+
const raw = req.headers.cookie;
|
|
119
|
+
if (!raw) return;
|
|
120
|
+
const kept = raw
|
|
121
|
+
.split(";")
|
|
122
|
+
.map((c) => c.trim())
|
|
123
|
+
.filter((c) => c && !c.startsWith(`${SESSION_COOKIE_NAME}=`));
|
|
124
|
+
if (kept.length) req.headers.cookie = kept.join("; ");
|
|
125
|
+
else delete req.headers.cookie;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Present an OTP-authenticated caller to the app the SAME way the Databricks
|
|
130
|
+
* front door does: set `x-forwarded-user` / `x-forwarded-email` to the verified
|
|
131
|
+
* address (AppKit reads `x-forwarded-user` for the OBO user id). Any inbound
|
|
132
|
+
* copies are overwritten so a client can't spoof identity through the gate.
|
|
133
|
+
*/
|
|
134
|
+
const injectIdentity = (req: IncomingMessage, email: string): void => {
|
|
135
|
+
req.headers["x-forwarded-user"] = email;
|
|
136
|
+
req.headers["x-forwarded-email"] = email;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const server = createServer(async (req, res) => {
|
|
140
|
+
const path = (req.url ?? "/").split("?")[0]!;
|
|
141
|
+
|
|
142
|
+
// Insecure/open mode (no gate) OR non-loopback traffic (the Databricks front
|
|
143
|
+
// door / control plane): forward ungated.
|
|
144
|
+
if (!gate || !isLoopback(req.socket.remoteAddress)) {
|
|
145
|
+
proxy.web(req, res);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- portr traffic: the gate applies ---
|
|
150
|
+
|
|
151
|
+
// The login flow is answered IN-PROCESS (the app has no server to forward to).
|
|
152
|
+
if (path === `${AUTH_PREFIX}/status`) {
|
|
153
|
+
const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
154
|
+
sendJson(res, 200, await gate.status(token));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (path === `${AUTH_PREFIX}/request` && req.method === "POST") {
|
|
158
|
+
const parsed = authRequestSchema.safeParse(json.parseRecord(await readBody(req)));
|
|
159
|
+
if (!parsed.success) return sendJson(res, 200, { ok: true }); // anti-enumeration
|
|
160
|
+
return sendJson(res, 200, await gate.request(parsed.data.email, clientIp(req)));
|
|
161
|
+
}
|
|
162
|
+
if (path === `${AUTH_PREFIX}/verify` && req.method === "POST") {
|
|
163
|
+
const parsed = authVerifySchema.safeParse(json.parseRecord(await readBody(req)));
|
|
164
|
+
if (!parsed.success) return sendJson(res, 200, { ok: false });
|
|
165
|
+
const result = await gate.verify(parsed.data.email, parsed.data.code, clientIp(req));
|
|
166
|
+
const cookie =
|
|
167
|
+
result.ok && result.token ? sessionCookie(result.token, gate.sessionTtlSeconds) : undefined;
|
|
168
|
+
return sendJson(
|
|
169
|
+
res,
|
|
170
|
+
200,
|
|
171
|
+
{ ok: result.ok, ...(result.retryAfter ? { retryAfter: result.retryAfter } : {}) },
|
|
172
|
+
cookie,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (path === `${AUTH_PREFIX}/logout` && req.method === "POST") {
|
|
176
|
+
return sendJson(
|
|
177
|
+
res,
|
|
178
|
+
200,
|
|
179
|
+
{ ok: true },
|
|
180
|
+
`${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Max-Age=0`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Anti-spoof: strip any inbound identity headers on portr traffic - only the
|
|
185
|
+
// gate may set them, below, for a verified session.
|
|
186
|
+
delete req.headers["x-forwarded-user"];
|
|
187
|
+
delete req.headers["x-forwarded-email"];
|
|
188
|
+
|
|
189
|
+
// Static (non-API) loads freely so the SPA + <AuthGate> can render. Strip the
|
|
190
|
+
// session cookie so it never leaks to the static handler.
|
|
191
|
+
if (!path.startsWith("/api/")) {
|
|
192
|
+
stripSessionCookie(req);
|
|
193
|
+
proxy.web(req, res);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Every other /api/* requires a valid session.
|
|
198
|
+
const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
199
|
+
const email = await gate.session(token);
|
|
200
|
+
if (email) {
|
|
201
|
+
// Present the OTP user like the Databricks front door, and drop the gate
|
|
202
|
+
// cookie so the app sees a clean, front-door-shaped request.
|
|
203
|
+
injectIdentity(req, email);
|
|
204
|
+
stripSessionCookie(req);
|
|
205
|
+
proxy.web(req, res);
|
|
206
|
+
} else {
|
|
207
|
+
sendJson(res, 401, { error: "authentication required", loginPath: AUTH_PREFIX });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// WebSocket upgrades: forward front-door traffic untouched; gate loopback
|
|
212
|
+
// (portr) API upgrades the same way as HTTP (strip cookie + inject identity).
|
|
213
|
+
server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
|
|
214
|
+
if (!gate || !isLoopback(req.socket.remoteAddress)) {
|
|
215
|
+
proxy.ws(req, socket, head);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
delete req.headers["x-forwarded-user"];
|
|
219
|
+
delete req.headers["x-forwarded-email"];
|
|
220
|
+
if (!(req.url ?? "").startsWith("/api/")) {
|
|
221
|
+
stripSessionCookie(req);
|
|
222
|
+
proxy.ws(req, socket, head);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const token = http.parseCookies(req.headers.cookie ?? null)[SESSION_COOKIE_NAME];
|
|
226
|
+
void gate.session(token).then((email) => {
|
|
227
|
+
if (!email) {
|
|
228
|
+
socket.destroy();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
injectIdentity(req, email);
|
|
232
|
+
stripSessionCookie(req);
|
|
233
|
+
proxy.ws(req, socket, head);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
server.listen(publicPort, "0.0.0.0", () => {
|
|
239
|
+
logger.info(`gate proxy on 0.0.0.0:${publicPort} -> app 127.0.0.1:${appPort}`);
|
|
240
|
+
resolve();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small in-memory fixed-window rate limiter for the email-OTP gate.
|
|
3
|
+
*
|
|
4
|
+
* Keyed by an arbitrary string (an email address or a client IP). Each key gets
|
|
5
|
+
* `max` hits per `windowMs`; the window resets on first use after it elapses.
|
|
6
|
+
* `hit()` returns whether the call is allowed and, when not, how many seconds
|
|
7
|
+
* until the window resets so a caller can surface a cooldown.
|
|
8
|
+
*
|
|
9
|
+
* In-memory is intentional and sufficient for a single-app-instance gate: an
|
|
10
|
+
* app behind a portr tunnel serves from one process. It is NOT a distributed
|
|
11
|
+
* limiter; a multi-replica deployment would need shared state. Entries are
|
|
12
|
+
* pruned lazily on access, so an idle key costs nothing after its window.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Window {
|
|
18
|
+
count: number;
|
|
19
|
+
resetAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A fixed-window rate limiter over string keys. */
|
|
23
|
+
export class RateLimiter {
|
|
24
|
+
private readonly windows = new Map<string, Window>();
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly max: number,
|
|
28
|
+
private readonly windowMs: number,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a hit for `key`. Returns `{ allowed }`, plus `retryAfter` (seconds)
|
|
33
|
+
* when the limit is exceeded. A limit of `<= 0` disables limiting (always
|
|
34
|
+
* allowed), which lets a config turn it off without special-casing callers.
|
|
35
|
+
*/
|
|
36
|
+
hit(key: string, now: number = Date.now()): { allowed: boolean; retryAfter?: number } {
|
|
37
|
+
if (this.max <= 0) return { allowed: true };
|
|
38
|
+
const existing = this.windows.get(key);
|
|
39
|
+
if (!existing || now >= existing.resetAt) {
|
|
40
|
+
this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
41
|
+
return { allowed: true };
|
|
42
|
+
}
|
|
43
|
+
if (existing.count < this.max) {
|
|
44
|
+
existing.count += 1;
|
|
45
|
+
return { allowed: true };
|
|
46
|
+
}
|
|
47
|
+
return { allowed: false, retryAfter: Math.ceil((existing.resetAt - now) / 1000) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Forget a key (e.g. clear a caller's window after a successful verify). */
|
|
51
|
+
reset(key: string): void {
|
|
52
|
+
this.windows.delete(key);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop every window (tests). */
|
|
56
|
+
clear(): void {
|
|
57
|
+
this.windows.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|