@dbx-tools/tunnel 0.6.105 → 0.6.106

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/src/gate.ts CHANGED
@@ -47,7 +47,7 @@ export interface GateOptions {
47
47
  * inert (everything passes through) - a tunnel with no public domain gates
48
48
  * nothing.
49
49
  */
50
- publicDomain?: string;
50
+ publicDomain?: string | readonly string[];
51
51
  /**
52
52
  * Extra `x-` request headers tunnel traffic may forward (unioned with the
53
53
  * built-in allow-list). Every other `x-` header is stripped from tunnel traffic.
@@ -64,14 +64,18 @@ export interface GateOptions {
64
64
  }
65
65
 
66
66
  /**
67
- * True when the request's `Host` is the tunnel's public domain - i.e. it came in
68
- * over portr. Case-insensitive; the optional `:port` is ignored. When no public
69
- * domain is configured, nothing is tunnel traffic.
67
+ * True when the request's `Host` is one of the tunnel's public domains - i.e. it
68
+ * came in over Portr or FRP. Case-insensitive; the optional `:port` is ignored.
69
+ * When no public domain is configured, nothing is tunnel traffic.
70
70
  */
71
- export function isTunnelHost(req: IncomingMessage, publicDomain: string | undefined): boolean {
71
+ export function isTunnelHost(
72
+ req: IncomingMessage,
73
+ publicDomain: string | readonly string[] | undefined,
74
+ ): boolean {
72
75
  if (!publicDomain) return false;
73
76
  const host = (req.headers.host ?? "").toLowerCase().split(":")[0];
74
- return host === publicDomain.toLowerCase().split(":")[0];
77
+ const domains = typeof publicDomain === "string" ? [publicDomain] : publicDomain;
78
+ return domains.some((domain) => host === domain.toLowerCase().split(":")[0]);
75
79
  }
76
80
 
77
81
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `tunnelInterceptor()` - the {@link Interceptor} that fronts an app with a public
3
- * portr tunnel, consuming the {@link InterceptorContext} `@dbx-tools/appkit`'s
3
+ * portr and/or frp tunnel, consuming the {@link InterceptorContext} `@dbx-tools/appkit`'s
4
4
  * `createApp` hands it.
5
5
  *
6
6
  * The tunnel runs IN-PROCESS: the APP is the main process and hands this interceptor
@@ -17,15 +17,19 @@
17
17
  * @module
18
18
  */
19
19
 
20
+ import type { ChildProcess } from "node:child_process";
20
21
  import type { Interceptor, InterceptorContext } from "@dbx-tools/appkit";
21
22
  import { log, object } from "@dbx-tools/shared-core";
23
+ import { installFrp, resolveFrpConfig, startFrp, writeFrpConfig } from "./frp.ts";
22
24
  import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
23
25
 
24
26
  const logger = log.logger("tunnel:interceptor");
25
27
 
26
28
  /** Options for {@link tunnelInterceptor} (each also resolvable from env). */
27
29
  export interface TunnelInterceptorOptions {
28
- /** portr `<subdomain>.<server>` to serve on. Env `TUNNEL_PUBLIC_DOMAIN`. */
30
+ /** Tunnel clients to run. Env `DBX_TOOLS_TUNNEL_TRANSPORT`; defaults to `portr`. */
31
+ transport?: TunnelTransport;
32
+ /** Public host to serve on. Env `TUNNEL_PUBLIC_DOMAIN`. */
29
33
  publicDomain?: string;
30
34
  /** portr subdomain (else derived from {@link publicDomain}). */
31
35
  subdomain?: string;
@@ -35,6 +39,32 @@ export interface TunnelInterceptorOptions {
35
39
  * (then `8000`), which is the port the platform routes to.
36
40
  */
37
41
  port?: number;
42
+ /** frps control host; defaults to the FRP public domain. Env `FRP_SERVER`. */
43
+ frpServer?: string;
44
+ /** FRP public HTTP host. Env `TUNNEL_FRP_PUBLIC_DOMAIN`. */
45
+ frpPublicDomain?: string;
46
+ /** frps control port. Env `FRP_SERVER_PORT`; defaults to `443`. */
47
+ frpServerPort?: number;
48
+ /** frpc transport protocol. Env `FRP_PROTOCOL`; defaults to `wss`. */
49
+ frpProtocol?: string;
50
+ /** frps auth token. Env `FRP_TOKEN` (or `TUNNEL_TOKEN`). */
51
+ frpToken?: string;
52
+ /** frp proxy registration name. Env `FRP_PROXY_NAME`. */
53
+ frpProxyName?: string;
54
+ }
55
+
56
+ /** Public tunnel clients supported by the interceptor and CLI. */
57
+ export type TunnelTransport = "portr" | "frp" | "both";
58
+
59
+ /** Resolve and validate the tunnel transport selector. */
60
+ export function resolveTunnelTransport(transport?: string): TunnelTransport {
61
+ const resolved =
62
+ transport ??
63
+ process.env.DBX_TOOLS_TUNNEL_TRANSPORT ??
64
+ process.env.TUNNEL_TRANSPORT ??
65
+ "portr";
66
+ if (resolved === "portr" || resolved === "frp" || resolved === "both") return resolved;
67
+ throw new TypeError(`invalid tunnel transport: ${resolved} (expected portr, frp, or both)`);
38
68
  }
39
69
 
40
70
  /** Resolve the public port portr should target: explicit, else the Apps contract. */
@@ -45,9 +75,8 @@ function resolvePublicPort(port?: number): number {
45
75
  /**
46
76
  * Build the tunnel {@link Interceptor}. Pass it to `createApp({ interceptor })`.
47
77
  *
48
- * A no-op (logs and returns) when no portr tunnel is configured - no `PORTR_TOKEN`
49
- * or no resolvable `<subdomain>.<server>` - so an app can register it
50
- * unconditionally and only actually tunnels where the deployment wired portr.
78
+ * A no-op (logs and returns) when none of the selected tunnel clients resolve
79
+ * usable configuration, so an app can register it unconditionally.
51
80
  *
52
81
  * @example
53
82
  * import { appkit } from "@dbx-tools/appkit";
@@ -65,25 +94,45 @@ export function tunnelInterceptor(options: TunnelInterceptorOptions = {}): Inter
65
94
  }
66
95
 
67
96
  const port = resolvePublicPort(options.port);
68
- const portrConfig = resolvePortrConfig({
69
- publicDomain: options.publicDomain,
70
- subdomain: options.subdomain,
71
- port,
72
- });
73
- if (!portrConfig) {
74
- logger.info("no PORTR_TOKEN/TUNNEL_PUBLIC_DOMAIN - the app runs without a public tunnel");
75
- return;
97
+ const transport = resolveTunnelTransport(options.transport);
98
+ const children: ChildProcess[] = [];
99
+ if (transport === "portr" || transport === "both") {
100
+ const portrConfig = resolvePortrConfig({
101
+ publicDomain: options.publicDomain,
102
+ subdomain: options.subdomain,
103
+ port,
104
+ });
105
+ if (portrConfig) {
106
+ const portrEnv = await installPortr();
107
+ await writePortrConfig(portrConfig, portrEnv);
108
+ children.push(await startPortr(portrConfig, portrEnv));
109
+ } else {
110
+ logger.info("portr not configured (requires PORTR_TOKEN and TUNNEL_PUBLIC_DOMAIN)");
111
+ }
76
112
  }
77
-
78
- const portrEnv = await installPortr();
79
- await writePortrConfig(portrConfig, portrEnv);
80
- const portr = await startPortr(portrConfig, portrEnv);
81
- ctx.bindProcess(portr);
82
-
113
+ if (transport === "frp" || transport === "both") {
114
+ const frpConfig = resolveFrpConfig({
115
+ publicDomain: options.frpPublicDomain,
116
+ server: options.frpServer,
117
+ serverPort: options.frpServerPort,
118
+ protocol: options.frpProtocol,
119
+ token: options.frpToken,
120
+ proxyName: options.frpProxyName,
121
+ port,
122
+ });
123
+ if (frpConfig) {
124
+ const frpEnv = await installFrp();
125
+ const configPath = await writeFrpConfig(frpConfig, frpEnv);
126
+ children.push(startFrp(frpConfig, frpEnv, configPath));
127
+ } else {
128
+ logger.info("frp not configured (requires TUNNEL_PUBLIC_DOMAIN)");
129
+ }
130
+ }
131
+ if (!children.length) return;
132
+ for (const child of children) ctx.bindProcess(child);
83
133
  ctx.onLifecycle("shutdown", () => {
84
- if (!portr.killed) portr.kill("SIGTERM");
134
+ for (const child of children) if (!child.killed) child.kill("SIGTERM");
85
135
  });
86
-
87
- logger.info(`tunnel bound: portr -> :${port} (${portrConfig.subdomain}.${portrConfig.server})`);
136
+ logger.info(`tunnel bound: ${transport} -> :${port}`);
88
137
  };
89
138
  }
package/src/plugin.ts CHANGED
@@ -166,6 +166,8 @@ export interface AuthGateConfig extends BasePluginConfig, AuthStorageConfig {
166
166
  * traffic).
167
167
  */
168
168
  publicDomain?: string;
169
+ /** Additional public tunnel domains accepted by the gate. */
170
+ publicDomains?: string | string[];
169
171
  /**
170
172
  * Extra `x-` request headers tunnel traffic may forward (literal / glob /
171
173
  * `/regex/`), unioned with the built-in allow-list. Env `TUNNEL_FORWARD_HEADERS`.
@@ -213,8 +215,10 @@ export interface ResolvedAuthGateConfig {
213
215
  sessionCutoffMs: number;
214
216
  /** Same-origin path returned after logout. */
215
217
  logoutRedirectPath: string;
216
- /** Public domain that identifies portr traffic by `Host`; `undefined` = inert. */
218
+ /** Primary public domain; `undefined` when Portr is not configured. */
217
219
  publicDomain?: string;
220
+ /** All public tunnel domains accepted by the gate. */
221
+ publicDomains: string[];
218
222
  /** Extra `x-` headers tunnel traffic may forward (unioned with the defaults). */
219
223
  forwardHeaders: string[];
220
224
  /** Path prefixes to gate beyond `/api/` (e.g. `/ws`). */
@@ -249,6 +253,8 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
249
253
  (coreConfig.text("AUTH_STORAGE", TUNNEL_CONFIG) as AuthStorageMode | undefined),
250
254
  sqlitePath: config.sqlitePath ?? coreConfig.text("AUTH_SQLITE_PATH", TUNNEL_CONFIG),
251
255
  });
256
+ const publicDomain = coreConfig.string(config.publicDomain, "PUBLIC_DOMAIN", TUNNEL_CONFIG);
257
+ const frpPublicDomain = coreConfig.text("FRP_PUBLIC_DOMAIN", TUNNEL_CONFIG);
252
258
  return {
253
259
  // Both sources are unioned rather than one overriding: a deployment-wide
254
260
  // TUNNEL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
@@ -280,7 +286,16 @@ export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateC
280
286
  coreConfig.string(config.logoutRedirectPath, "AUTH_LOGOUT_REDIRECT", TUNNEL_CONFIG) ??
281
287
  DEFAULTS.logoutRedirectPath,
282
288
  ),
283
- publicDomain: coreConfig.string(config.publicDomain, "PUBLIC_DOMAIN", TUNNEL_CONFIG),
289
+ publicDomain,
290
+ publicDomains: [
291
+ ...new Set(
292
+ [
293
+ publicDomain,
294
+ frpPublicDomain,
295
+ ...string.parseList(config.publicDomains),
296
+ ].filter((value): value is string => !!value),
297
+ ),
298
+ ],
284
299
  forwardHeaders: [
285
300
  ...string.parseList(config.forwardHeaders),
286
301
  ...string.parseList(coreConfig.text("FORWARD_HEADERS", TUNNEL_CONFIG)),
@@ -404,7 +419,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
404
419
  logger.info("ready", {
405
420
  patterns: this.resolved.allow.length,
406
421
  sessionTtlSeconds: this.resolved.sessionTtlSeconds,
407
- publicDomain: this.resolved.publicDomain ?? null,
422
+ publicDomains: this.resolved.publicDomains,
408
423
  insecure: this.resolved.insecure,
409
424
  storage: this.resolved.storage,
410
425
  logoutRedirectPath: this.resolved.logoutRedirectPath,
@@ -434,7 +449,7 @@ export class AuthGatePlugin extends Plugin<AuthGateConfig> {
434
449
  }
435
450
  mountGateOnContext(context, {
436
451
  gate: this.exports(),
437
- publicDomain: this.resolved.publicDomain,
452
+ publicDomain: this.resolved.publicDomains,
438
453
  forwardHeaders: this.resolved.forwardHeaders,
439
454
  gatePaths: this.resolved.gatePaths,
440
455
  });