@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
@@ -1,201 +0,0 @@
1
- /**
2
- * The gate's HS256 session-signing key, persisted in AppKit's cache.
3
- *
4
- * The key decides whether a session COOKIE still verifies. Before this module the
5
- * fallback was `randomBytes(32)` per process, so every restart invalidated every
6
- * outstanding cookie and each signed-in user had to request a new code - painful
7
- * for a tunnel, which restarts whenever the app it wraps does. Now the key is
8
- * stored in the cache AppKit already configured (memory, or Lakebase when the
9
- * host wires a persistent `CacheStorage`), so with persistent storage a restart
10
- * keeps sessions alive for {@link KEY_TTL_SECONDS}.
11
- *
12
- * An explicitly configured `TUNNEL_AUTH_JWT_SECRET` still wins outright. That is
13
- * the right answer for a fleet: an operator-held secret needs no shared cache and
14
- * no convergence, and it survives a cache flush.
15
- *
16
- * ## get / generate / get
17
- *
18
- * Two instances booting at once both miss, so both would generate - and the loser
19
- * would sign cookies with a key the winner rejects. Resolution is a re-READ after
20
- * the write: whatever the cache holds afterwards is the key everyone adopts, so
21
- * the instances converge on ONE value instead of trusting the one they minted.
22
- * (`set` is not conditional in the cache API - there is no `setnx` to lean on -
23
- * and this runs once per process, so the extra round-trip is free.)
24
- *
25
- * A pathological interleave can still cost a key: if A writes between B's write
26
- * and B's re-read, B adopts A's key while A adopts its own. The cost is bounded -
27
- * a cookie minted in that window fails to verify and the holder signs in again -
28
- * and it cannot produce a key one instance TRUSTS but another rejects for longer
29
- * than the window itself. Set `TUNNEL_AUTH_JWT_SECRET` to remove the race
30
- * entirely.
31
- *
32
- * ## Forcing every session to end
33
- *
34
- * {@link resolveSessionCutoff} reads a date from `TUNNEL_AUTH_SESSION_CUTOFF` (or
35
- * `--session-cutoff`), and that date is part of the cache KEY. Moving it forward
36
- * makes every prior key unreachable, so every cookie signed against it stops
37
- * verifying - the log-everyone-out switch, without having to find and flush a
38
- * cache entry. The cutoff is also asserted against each token's `iat`, so a
39
- * cookie that predates it is refused even if it was signed with the key that is
40
- * somehow still current.
41
- *
42
- * @module
43
- */
44
-
45
- import { randomBytes } from "node:crypto";
46
- import { CacheManager } from "@databricks/appkit";
47
- import { env, log, object } from "@dbx-tools/shared-core";
48
- import { JWT_SECRET_ENV, SESSION_CUTOFF_ENV } from "./env.ts";
49
-
50
- const logger = log.logger("tunnel:signing-key");
51
-
52
- /**
53
- * How long a cached signing key lives: 30 days.
54
- *
55
- * This is the ceiling on how long a session cookie can stay valid across
56
- * restarts, so it is deliberately >= the default session TTL - a key that expired
57
- * before the cookies it signed would log everyone out for no reason.
58
- */
59
- export const KEY_TTL_SECONDS = 30 * 24 * 60 * 60;
60
-
61
- /** Cache-key prefix for the signing key, namespaced away from other cache use. */
62
- const KEY_PREFIX = "tunnel:auth:signing-key:";
63
-
64
- /** Bytes of entropy in a generated key (256-bit, matching HS256's hash width). */
65
- const KEY_BYTES = 32;
66
-
67
- /** What the cache stores: the key plus when it was minted, for observability. */
68
- interface StoredKey {
69
- /** Base64url of the raw key bytes. */
70
- secret: string;
71
- /** When this key was generated, ISO-8601. */
72
- createdAt: string;
73
- }
74
-
75
- /**
76
- * Resolve the force-clear cutoff as epoch MILLISECONDS, or `0` when unset.
77
- *
78
- * Accepts whatever `object.toDate` accepts - a `Date`, `2026-08-02`, an ISO
79
- * instant, epoch seconds or millis from `date +%s`, or a relative duration
80
- * (`-30d`, `7 days ago`), which is the spelling an operator reaching for this
81
- * usually wants: sign out everyone who signed in more than a month ago.
82
- *
83
- * An UNPARSEABLE value is ignored with a warning rather than throwing: this is
84
- * the switch that logs a fleet back in, and failing to boot over a typo is worse
85
- * than not rotating.
86
- */
87
- export function resolveSessionCutoff(configured?: string | number | Date): number {
88
- const raw = configured ?? env.text(SESSION_CUTOFF_ENV) ?? undefined;
89
- if (raw === undefined || raw === null || raw === "") return 0;
90
-
91
- const date = object.toDate(raw);
92
- if (!date) {
93
- logger.warn(`ignoring unparseable ${env.name(SESSION_CUTOFF_ENV)}`, { value: String(raw) });
94
- return 0;
95
- }
96
- return clampToPast(date.getTime());
97
- }
98
-
99
- /**
100
- * Hold the cutoff at "now", because a FUTURE cutoff would refuse the sessions it
101
- * is about to mint as well as the old ones - an app nobody can sign in to, from a
102
- * mistyped year, with the fix hidden behind understanding this flag. Clamped, a
103
- * future date means what an operator setting it always meant: clear everything
104
- * outstanding, then carry on.
105
- */
106
- function clampToPast(cutoffMs: number): number {
107
- const now = Date.now();
108
- if (cutoffMs <= now) return cutoffMs;
109
- logger.warn(`${env.name(SESSION_CUTOFF_ENV)} is in the future - clamping to now`, {
110
- configured: new Date(cutoffMs).toISOString(),
111
- });
112
- return now;
113
- }
114
-
115
- /** The cache key for one cutoff, so moving the cutoff orphans every earlier key. */
116
- function cacheKey(cutoffMs: number): string {
117
- return `${KEY_PREFIX}${cutoffMs}`;
118
- }
119
-
120
- function decode(stored: StoredKey): Uint8Array {
121
- return new Uint8Array(Buffer.from(stored.secret, "base64url"));
122
- }
123
-
124
- /**
125
- * Load the signing key for `cutoffMs` from the cache, minting and storing one
126
- * when absent. See the module docs for why the write is followed by a re-read.
127
- */
128
- async function loadFromCache(cutoffMs: number): Promise<Uint8Array> {
129
- const cache = CacheManager.getInstanceSync();
130
- const key = cacheKey(cutoffMs);
131
-
132
- const existing = await cache.get<StoredKey>(key);
133
- if (existing?.secret) {
134
- logger.info("reusing cached signing key", { createdAt: existing.createdAt });
135
- return decode(existing);
136
- }
137
-
138
- const minted: StoredKey = {
139
- secret: Buffer.from(randomBytes(KEY_BYTES)).toString("base64url"),
140
- createdAt: new Date().toISOString(),
141
- };
142
- await cache.set(key, minted, { ttl: KEY_TTL_SECONDS });
143
-
144
- // Re-read rather than trusting `minted`: another instance that raced this boot
145
- // may have written first, and adopting whatever is stored is what makes the two
146
- // converge on one key.
147
- const settled = (await cache.get<StoredKey>(key)) ?? minted;
148
- logger.info("stored a new signing key", {
149
- createdAt: settled.createdAt,
150
- ttlSeconds: KEY_TTL_SECONDS,
151
- adopted: settled.createdAt === minted.createdAt ? "own" : "concurrent-instance",
152
- });
153
- return decode(settled);
154
- }
155
-
156
- /** A resolved key plus the cutoff it is scoped to. */
157
- export interface SigningKey {
158
- key: Uint8Array;
159
- /** Force-clear cutoff in ms; `0` when unset. Tokens older than this are refused. */
160
- cutoffMs: number;
161
- }
162
-
163
- let pending: Promise<SigningKey> | undefined;
164
-
165
- /**
166
- * The signing key for this gate, resolved once per process.
167
- *
168
- * Resolved ONCE, so `configuredCutoff` is honoured only on the first call - the
169
- * plugin's `setup()` passes its resolved value there, before any request can
170
- * reach the lazy path.
171
- *
172
- * `TUNNEL_AUTH_JWT_SECRET` when set, else the cache-backed key. A cache that is
173
- * unavailable degrades to an ephemeral per-process key (the previous behaviour)
174
- * rather than refusing to sign: the key only validates an ALREADY-issued session,
175
- * so losing it costs sessions, never admission - a caller still needs a code
176
- * delivered to an allow-listed address.
177
- */
178
- export function signingKey(configuredCutoff?: string | number | Date): Promise<SigningKey> {
179
- pending ??= (async () => {
180
- const cutoffMs = resolveSessionCutoff(configuredCutoff);
181
- const configured = env.text(JWT_SECRET_ENV);
182
- if (configured) {
183
- return { key: new TextEncoder().encode(configured), cutoffMs };
184
- }
185
- try {
186
- return { key: await loadFromCache(cutoffMs), cutoffMs };
187
- } catch (error) {
188
- logger.warn(
189
- `no ${env.name(JWT_SECRET_ENV)} and the cache is unavailable - using an ephemeral per-process key; sessions will not survive a restart`,
190
- { error },
191
- );
192
- return { key: new Uint8Array(randomBytes(KEY_BYTES)), cutoffMs };
193
- }
194
- })();
195
- return pending;
196
- }
197
-
198
- /** Reset the per-process key (tests, or after changing the env in-process). */
199
- export function resetSigningKey(): void {
200
- pending = undefined;
201
- }