@betterportal/tunnels 1.0.24 → 1.0.26
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/lib/.bsb/clients/service-tunnels-client.d.ts +133 -0
- package/lib/.bsb/clients/service-tunnels-client.d.ts.map +1 -0
- package/lib/.bsb/clients/service-tunnels-client.js +102 -0
- package/lib/.bsb/clients/service-tunnels-client.js.map +1 -0
- package/lib/.bsb/clients/service-tunnels-proxy.d.ts +43 -0
- package/lib/.bsb/clients/service-tunnels-proxy.d.ts.map +1 -0
- package/lib/.bsb/clients/service-tunnels-proxy.js +47 -0
- package/lib/.bsb/clients/service-tunnels-proxy.js.map +1 -0
- package/lib/auth.d.ts +12 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/auth.js +59 -0
- package/lib/auth.js.map +1 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +406 -0
- package/lib/cli.js.map +1 -0
- package/lib/generated/prisma/client.d.ts +1 -0
- package/lib/generated/prisma/client.js +5 -0
- package/lib/generated/prisma/default.d.ts +1 -0
- package/lib/generated/prisma/default.js +5 -0
- package/lib/generated/prisma/edge.d.ts +1 -0
- package/lib/generated/prisma/edge.js +278 -0
- package/lib/generated/prisma/index-browser.js +304 -0
- package/lib/generated/prisma/index.d.ts +13885 -0
- package/lib/generated/prisma/index.js +278 -0
- package/lib/generated/prisma/package.json +144 -0
- package/lib/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/lib/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/lib/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/lib/generated/prisma/runtime/client.d.ts +3386 -0
- package/lib/generated/prisma/runtime/client.js +86 -0
- package/lib/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/lib/generated/prisma/runtime/index-browser.js +6 -0
- package/lib/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/lib/generated/prisma/schema.prisma +142 -0
- package/lib/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/lib/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/registry.d.ts +3 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/registry.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/registry.js +152 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/registry.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/route-runtime.d.ts +9 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/route-runtime.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/route-runtime.js +6 -0
- package/lib/plugins/service-tunnels-admin/.bp-generated/route-runtime.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/assets/brand/bettertunnels-horizontal-light.svg +18 -0
- package/lib/plugins/service-tunnels-admin/assets/brand/bettertunnels-square-dark-1024x1024.png +0 -0
- package/lib/plugins/service-tunnels-admin/assets/brand/favicon.svg +12 -0
- package/lib/plugins/service-tunnels-admin/betterportal-logo.png +0 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/GET.d.ts +20 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/GET.js +51 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/_theme.bootstrap1/GET.d.ts +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/_theme.bootstrap1/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/_theme.bootstrap1/GET.js +6 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/_theme.bootstrap1/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/index.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/index.js +12 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/cli-auth/verify/index.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/GET.d.ts +40 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/GET.js +57 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/GET.d.ts +6 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/GET.js +30 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/index.stream.d.ts +6 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/index.stream.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/index.stream.js +9 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/_theme.bootstrap1/index.stream.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/index.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/index.js +14 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/dashboard/index.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/GET.d.ts +25 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/GET.js +34 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/_theme.bootstrap1/GET.d.ts +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/_theme.bootstrap1/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/_theme.bootstrap1/GET.js +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/_theme.bootstrap1/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/index.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/index.js +12 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/downloads/index.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/GET.d.ts +35 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/GET.js +52 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/_theme.bootstrap1/GET.d.ts +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/_theme.bootstrap1/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/_theme.bootstrap1/GET.js +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/_theme.bootstrap1/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/index.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/index.js +12 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/landing/index.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/GET.d.ts +39 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/GET.js +46 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/_theme.bootstrap1/GET.d.ts +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/_theme.bootstrap1/GET.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/_theme.bootstrap1/GET.js +5 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/_theme.bootstrap1/GET.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/index.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/index.js +14 -0
- package/lib/plugins/service-tunnels-admin/bp-routes/tunnels/index.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/brand-assets.d.ts +7 -0
- package/lib/plugins/service-tunnels-admin/brand-assets.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/brand-assets.js +24 -0
- package/lib/plugins/service-tunnels-admin/brand-assets.js.map +1 -0
- package/lib/plugins/service-tunnels-admin/index.d.ts +64 -0
- package/lib/plugins/service-tunnels-admin/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-admin/index.js +116 -0
- package/lib/plugins/service-tunnels-admin/index.js.map +1 -0
- package/lib/plugins/service-tunnels-client/ids.d.ts +6 -0
- package/lib/plugins/service-tunnels-client/ids.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-client/ids.js +17 -0
- package/lib/plugins/service-tunnels-client/ids.js.map +1 -0
- package/lib/plugins/service-tunnels-client/index.d.ts +166 -0
- package/lib/plugins/service-tunnels-client/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-client/index.js +733 -0
- package/lib/plugins/service-tunnels-client/index.js.map +1 -0
- package/lib/plugins/service-tunnels-client/registry.d.ts +39 -0
- package/lib/plugins/service-tunnels-client/registry.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-client/registry.js +20 -0
- package/lib/plugins/service-tunnels-client/registry.js.map +1 -0
- package/lib/plugins/service-tunnels-client/schemas.d.ts +91 -0
- package/lib/plugins/service-tunnels-client/schemas.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-client/schemas.js +73 -0
- package/lib/plugins/service-tunnels-client/schemas.js.map +1 -0
- package/lib/plugins/service-tunnels-proxy/http.d.ts +5 -0
- package/lib/plugins/service-tunnels-proxy/http.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-proxy/http.js +125 -0
- package/lib/plugins/service-tunnels-proxy/http.js.map +1 -0
- package/lib/plugins/service-tunnels-proxy/index.d.ts +85 -0
- package/lib/plugins/service-tunnels-proxy/index.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-proxy/index.js +288 -0
- package/lib/plugins/service-tunnels-proxy/index.js.map +1 -0
- package/lib/plugins/service-tunnels-proxy/verification.d.ts +31 -0
- package/lib/plugins/service-tunnels-proxy/verification.d.ts.map +1 -0
- package/lib/plugins/service-tunnels-proxy/verification.js +211 -0
- package/lib/plugins/service-tunnels-proxy/verification.js.map +1 -0
- package/lib/prisma.d.ts +7 -0
- package/lib/prisma.d.ts.map +1 -0
- package/lib/prisma.js +149 -0
- package/lib/prisma.js.map +1 -0
- package/lib/schemas/service-tunnels-admin.json +129 -0
- package/lib/schemas/service-tunnels-admin.plugin.json +140 -0
- package/lib/schemas/service-tunnels-client.json +320 -0
- package/lib/schemas/service-tunnels-client.plugin.json +97 -0
- package/lib/schemas/service-tunnels-proxy.json +160 -0
- package/lib/schemas/service-tunnels-proxy.plugin.json +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import { BSBService, bsb, optional, createConfigSchema, createEventSchemas, createFireAndForgetEvent, createReturnableEvent } from "@bsb/base";
|
|
2
|
+
import * as av from "anyvali";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { H3, toNodeHandler } from "h3";
|
|
7
|
+
import { WebSocketServer } from "ws";
|
|
8
|
+
import { TunnelCreateSchema, ClientFrameSchema, AuthStartRequestSchema, AuthStartResponseSchema, AuthStatusResponseSchema } from "./schemas.js";
|
|
9
|
+
import { buildTunnelSubdomain, hashValue, randomPrefix } from "./ids.js";
|
|
10
|
+
import { prisma } from "../../prisma.js";
|
|
11
|
+
import { initializePrisma } from "../../prisma.js";
|
|
12
|
+
import { AUTH_SESSION_TTL_MS, DEVICE_TOKEN_TTL_MS, bearerToken, hashSecret, normalizeIpRange, randomToken, shortBrowserKey, verifySecret } from "../../auth.js";
|
|
13
|
+
import { TunnelRegistry, tunnelStatusAfterDisconnect } from "./registry.js";
|
|
14
|
+
import TunnelWebClient from "../../.bsb/clients/service-tunnels-proxy.js";
|
|
15
|
+
export const Config = createConfigSchema({
|
|
16
|
+
name: "service-tunnels-client",
|
|
17
|
+
description: "Client websocket/API service",
|
|
18
|
+
tags: ["service", "websocket", "tunnel"]
|
|
19
|
+
}, av.object({
|
|
20
|
+
database: av.object({
|
|
21
|
+
connectionString: av.string().minLength(1).describe("PostgreSQL connection string")
|
|
22
|
+
}, { unknownKeys: "strip" }).describe("Database configuration"),
|
|
23
|
+
port: av.number().default(8081).describe("Client API listener port"),
|
|
24
|
+
domain: av.string().default("tunnels.betterportal.dev").describe("Default public tunnel domain"),
|
|
25
|
+
publicUrl: av.string().default("https://tunnels.betterportal.dev").describe("Public base URL"),
|
|
26
|
+
authAppBaseUrl: av.string().default("https://betterportal.cloud").describe("BetterPortal app URL used for CLI auth"),
|
|
27
|
+
authVerifyPath: av.string().default("/cli-auth/verify").describe("BetterPortal CLI auth verify route")
|
|
28
|
+
}).describe("service-tunnels-client config"));
|
|
29
|
+
export const EventSchemas = createEventSchemas({
|
|
30
|
+
emitEvents: {},
|
|
31
|
+
onEvents: {
|
|
32
|
+
"ws.toOrigin": createFireAndForgetEvent(bsb.object({
|
|
33
|
+
publicServerId: bsb.string({ description: "Web service instance that owns the public websocket" }),
|
|
34
|
+
publicSocketId: bsb.string({ description: "Web service local websocket id" }),
|
|
35
|
+
hostname: bsb.string({ description: "Public tunnel hostname" }),
|
|
36
|
+
path: bsb.string({ description: "Websocket path and query" }),
|
|
37
|
+
frameType: bsb.string({ description: "msg, ack, or event" }),
|
|
38
|
+
event: optional(bsb.string({ description: "open or close" })),
|
|
39
|
+
body: optional(bsb.string({ description: "Base64 websocket payload" }))
|
|
40
|
+
}, "Public websocket frame to origin"), "Relay a public websocket frame to the connected tunnel client"),
|
|
41
|
+
"proxy.metrics": createFireAndForgetEvent(bsb.object({
|
|
42
|
+
hostname: bsb.string({ description: "Public tunnel hostname" }),
|
|
43
|
+
requestId: bsb.string({ description: "Request id" }),
|
|
44
|
+
totalMs: bsb.number({ description: "Public request total duration" }),
|
|
45
|
+
clientApiRoundtripMs: optional(bsb.number({ description: "Client API to CLI roundtrip duration" })),
|
|
46
|
+
cliOverheadMs: optional(bsb.number({ description: "CLI work outside origin read duration" })),
|
|
47
|
+
originMs: optional(bsb.number({ description: "CLI to origin/read duration" })),
|
|
48
|
+
internalServerMs: optional(bsb.number({ description: "Remaining server-side duration" }))
|
|
49
|
+
}, "Proxy request metrics"), "Send final proxy request timings to the connected tunnel client")
|
|
50
|
+
},
|
|
51
|
+
emitReturnableEvents: {},
|
|
52
|
+
onReturnableEvents: {
|
|
53
|
+
"proxy.request": createReturnableEvent(bsb.object({
|
|
54
|
+
hostname: bsb.string({ description: "Public request hostname" }),
|
|
55
|
+
method: bsb.string({ description: "HTTP method" }),
|
|
56
|
+
path: bsb.string({ description: "Path and query" }),
|
|
57
|
+
headers: bsb.record(bsb.string({ description: "Header name" }), bsb.string({ description: "Header value" }), "Headers"),
|
|
58
|
+
body: optional(bsb.string({ description: "Base64 encoded request body" })),
|
|
59
|
+
webStartedAt: bsb.number({ description: "Web service request start timestamp" })
|
|
60
|
+
}, "Proxy request"), bsb.object({
|
|
61
|
+
requestId: bsb.string({ description: "Request id" }),
|
|
62
|
+
ownerServerId: bsb.string({ description: "Client API server id" }),
|
|
63
|
+
status: bsb.number({ description: "HTTP status" }),
|
|
64
|
+
headers: bsb.record(bsb.string({ description: "Header name" }), bsb.string({ description: "Header value" }), "Headers"),
|
|
65
|
+
body: bsb.string({ description: "Base64 encoded response body" }),
|
|
66
|
+
webStartedAt: bsb.number({ description: "Web service request start timestamp" }),
|
|
67
|
+
clientApiRoundtripMs: bsb.number({ description: "Client API to CLI roundtrip duration" }),
|
|
68
|
+
cliOverheadMs: optional(bsb.number({ description: "CLI work outside origin read duration" })),
|
|
69
|
+
originMs: optional(bsb.number({ description: "Origin/read duration" }))
|
|
70
|
+
}, "Proxy response"), "Proxy an HTTP request through a connected tunnel client", 60)
|
|
71
|
+
},
|
|
72
|
+
emitBroadcast: {},
|
|
73
|
+
onBroadcast: {}
|
|
74
|
+
});
|
|
75
|
+
export class Plugin extends BSBService {
|
|
76
|
+
static Config = Config;
|
|
77
|
+
static EventSchemas = EventSchemas;
|
|
78
|
+
initBeforePlugins = [];
|
|
79
|
+
initAfterPlugins = [];
|
|
80
|
+
runBeforePlugins = [];
|
|
81
|
+
runAfterPlugins = [];
|
|
82
|
+
registry = new TunnelRegistry();
|
|
83
|
+
app;
|
|
84
|
+
server;
|
|
85
|
+
wss;
|
|
86
|
+
web;
|
|
87
|
+
pluginPackageVersion = "0.0.0";
|
|
88
|
+
constructor(cfg) {
|
|
89
|
+
super(cfg);
|
|
90
|
+
this.app = new H3();
|
|
91
|
+
this.server = createServer(toNodeHandler(this.app));
|
|
92
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
93
|
+
this.web = new TunnelWebClient(this);
|
|
94
|
+
}
|
|
95
|
+
async init(obs) {
|
|
96
|
+
await initializePrisma(this.config.database.connectionString, obs);
|
|
97
|
+
this.pluginPackageVersion = await loadPluginPackageVersion(this.packageCwd);
|
|
98
|
+
const now = new Date();
|
|
99
|
+
const [expired, disconnected] = await prisma.$transaction([
|
|
100
|
+
prisma.tunnel.updateMany({
|
|
101
|
+
where: { status: "active", expiresAt: { lte: now } },
|
|
102
|
+
data: { status: "expired" }
|
|
103
|
+
}),
|
|
104
|
+
// ponytail: single client-api replica; add owner heartbeats before horizontal scaling.
|
|
105
|
+
prisma.tunnel.updateMany({
|
|
106
|
+
where: { status: "active" },
|
|
107
|
+
data: { status: "disconnected" }
|
|
108
|
+
})
|
|
109
|
+
]);
|
|
110
|
+
obs.log.info("CLIENT SESSION reconciled expired={expired} disconnected={disconnected}", {
|
|
111
|
+
expired: expired.count,
|
|
112
|
+
disconnected: disconnected.count
|
|
113
|
+
});
|
|
114
|
+
obs.log.info("init {plugin}", { plugin: this.pluginName });
|
|
115
|
+
await this.events.onReturnableEvent("proxy.request", obs, async (_handlerObs, input) => {
|
|
116
|
+
_handlerObs.log.info("CLIENT API proxy.request {method} {hostname}{path}", {
|
|
117
|
+
method: input.method,
|
|
118
|
+
hostname: input.hostname,
|
|
119
|
+
path: input.path
|
|
120
|
+
});
|
|
121
|
+
const tunnel = this.registry.get(input.hostname);
|
|
122
|
+
const unavailable = {
|
|
123
|
+
requestId: crypto.randomUUID(),
|
|
124
|
+
ownerServerId: this.appId,
|
|
125
|
+
status: 503,
|
|
126
|
+
headers: {},
|
|
127
|
+
body: Buffer.from("Tunnel unavailable").toString("base64"),
|
|
128
|
+
webStartedAt: input.webStartedAt,
|
|
129
|
+
clientApiRoundtripMs: 0
|
|
130
|
+
};
|
|
131
|
+
if (!tunnel)
|
|
132
|
+
return unavailable;
|
|
133
|
+
if (tunnel.ws.readyState !== tunnel.ws.OPEN)
|
|
134
|
+
return unavailable;
|
|
135
|
+
return this.proxyRequest(tunnel, input.method, input.path, input.headers, input.body ?? "", input.webStartedAt);
|
|
136
|
+
});
|
|
137
|
+
await this.events.onEventSpecific("proxy.metrics", this.appId, obs, async (_handlerObs, input) => {
|
|
138
|
+
const tunnel = this.registry.get(input.hostname);
|
|
139
|
+
if (!tunnel || tunnel.ws.readyState !== tunnel.ws.OPEN)
|
|
140
|
+
return;
|
|
141
|
+
tunnel.ws.send(JSON.stringify({
|
|
142
|
+
type: "request.metrics",
|
|
143
|
+
requestId: input.requestId,
|
|
144
|
+
totalMs: input.totalMs,
|
|
145
|
+
clientApiRoundtripMs: input.clientApiRoundtripMs,
|
|
146
|
+
cliOverheadMs: input.cliOverheadMs,
|
|
147
|
+
originMs: input.originMs,
|
|
148
|
+
internalServerMs: input.internalServerMs
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
await this.events.onEventSpecific("ws.toOrigin", this.appId, obs, async (_handlerObs, input) => {
|
|
152
|
+
const tunnel = this.registry.get(input.hostname);
|
|
153
|
+
if (!tunnel || tunnel.ws.readyState !== tunnel.ws.OPEN) {
|
|
154
|
+
await this.web.wsFromOriginSpecific(input.publicServerId, _handlerObs, {
|
|
155
|
+
publicSocketId: input.publicSocketId,
|
|
156
|
+
frameType: "event",
|
|
157
|
+
event: "close"
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
tunnel.ws.send(JSON.stringify({
|
|
162
|
+
type: "ws.toOrigin",
|
|
163
|
+
publicServerId: input.publicServerId,
|
|
164
|
+
publicSocketId: input.publicSocketId,
|
|
165
|
+
path: input.path,
|
|
166
|
+
frameType: input.frameType,
|
|
167
|
+
message: input.event,
|
|
168
|
+
body: input.body
|
|
169
|
+
}));
|
|
170
|
+
});
|
|
171
|
+
const domain = this.config.domain;
|
|
172
|
+
const publicUrl = this.config.publicUrl;
|
|
173
|
+
this.app.get("/health", () => new Response("ok\n", {
|
|
174
|
+
headers: {
|
|
175
|
+
"content-type": "text/plain; charset=utf-8",
|
|
176
|
+
"x-bettertunnels-version": this.pluginPackageVersion
|
|
177
|
+
}
|
|
178
|
+
}));
|
|
179
|
+
this.app.get("/api/client/status", async (event) => {
|
|
180
|
+
const token = bearerToken(event.req.headers);
|
|
181
|
+
const auth = await validateDeviceToken(token, proxyClientIpFromHeaders(event.req.headers), event.req.headers.get("user-agent") ?? "");
|
|
182
|
+
if (!auth)
|
|
183
|
+
return json({ status: "unauthenticated" }, 401);
|
|
184
|
+
return json({
|
|
185
|
+
status: "authenticated",
|
|
186
|
+
serverVersion: this.pluginPackageVersion,
|
|
187
|
+
tenantId: auth.tenantId,
|
|
188
|
+
bpUserSubject: auth.bpUserSubject,
|
|
189
|
+
tokenExpiresAt: auth.expiresAt.toISOString(),
|
|
190
|
+
limits: {
|
|
191
|
+
tunnelTtlHours: 24,
|
|
192
|
+
requestTimeoutSeconds: 300,
|
|
193
|
+
idleTimeoutSeconds: 60,
|
|
194
|
+
customPrefixes: true
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
this.app.post("/api/client/auth/start", async (event) => {
|
|
199
|
+
const body = AuthStartRequestSchema.parse(await safeJson(event.req));
|
|
200
|
+
const browserKey = shortBrowserKey();
|
|
201
|
+
const pollSecret = randomToken();
|
|
202
|
+
const expiresAt = new Date(Date.now() + AUTH_SESSION_TTL_MS);
|
|
203
|
+
const authBase = body.authAppBaseUrl ?? this.config.authAppBaseUrl;
|
|
204
|
+
const browserUrl = buildAuthUrl(authBase, this.config.authVerifyPath, browserKey);
|
|
205
|
+
const clientIp = proxyClientIpFromHeaders(event.req.headers);
|
|
206
|
+
const session = await prisma.clientAuthSession.create({
|
|
207
|
+
data: {
|
|
208
|
+
browserKeyHash: hashSecret(browserKey),
|
|
209
|
+
pollSecretHash: hashSecret(pollSecret),
|
|
210
|
+
clientIpHash: hashSecret(clientIp),
|
|
211
|
+
userAgentHash: hashSecret(event.req.headers.get("user-agent") ?? ""),
|
|
212
|
+
expiresAt
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const url = new URL(browserUrl);
|
|
216
|
+
url.searchParams.set("session", session.id);
|
|
217
|
+
url.searchParams.set("key", browserKey);
|
|
218
|
+
return json(AuthStartResponseSchema.parse({
|
|
219
|
+
sessionId: session.id,
|
|
220
|
+
pollSecret,
|
|
221
|
+
browserUrl: url.toString(),
|
|
222
|
+
expiresAt: expiresAt.toISOString()
|
|
223
|
+
}));
|
|
224
|
+
});
|
|
225
|
+
this.app.get("/api/client/auth/status", async (event) => {
|
|
226
|
+
const url = new URL(event.req.url);
|
|
227
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
228
|
+
const pollSecret = bearerToken(event.req.headers) ?? "";
|
|
229
|
+
if (!sessionId || !pollSecret) {
|
|
230
|
+
return json(AuthStatusResponseSchema.parse({ status: "invalid", message: "Missing auth session credentials." }), 401);
|
|
231
|
+
}
|
|
232
|
+
const session = await prisma.clientAuthSession.findUnique({ where: { id: sessionId } });
|
|
233
|
+
if (!session || !verifySecret(pollSecret, session.pollSecretHash)) {
|
|
234
|
+
return json(AuthStatusResponseSchema.parse({ status: "invalid", message: "Invalid auth session." }), 401);
|
|
235
|
+
}
|
|
236
|
+
if (session.expiresAt <= new Date() && session.status === "pending") {
|
|
237
|
+
await prisma.clientAuthSession.update({ where: { id: session.id }, data: { status: "expired" } });
|
|
238
|
+
return json(AuthStatusResponseSchema.parse({ status: "expired", message: "Authentication session expired." }), 410);
|
|
239
|
+
}
|
|
240
|
+
if (session.status === "pending") {
|
|
241
|
+
return json(AuthStatusResponseSchema.parse({ status: "pending" }));
|
|
242
|
+
}
|
|
243
|
+
if (session.status !== "approved" || !session.tenantId || !session.bpUserSubject) {
|
|
244
|
+
return json(AuthStatusResponseSchema.parse({ status: session.status, message: "Authentication session is not approved." }), 409);
|
|
245
|
+
}
|
|
246
|
+
const clientIp = proxyClientIpFromHeaders(event.req.headers);
|
|
247
|
+
const ipRange = normalizeIpRange(clientIp);
|
|
248
|
+
if (!ipRange) {
|
|
249
|
+
return json(AuthStatusResponseSchema.parse({ status: "invalid", message: "Unable to bind token to public IP range." }), 400);
|
|
250
|
+
}
|
|
251
|
+
const deviceToken = `bt_${randomToken(32)}`;
|
|
252
|
+
const tokenExpiresAt = new Date(Date.now() + DEVICE_TOKEN_TTL_MS);
|
|
253
|
+
await prisma.$transaction([
|
|
254
|
+
prisma.clientDeviceToken.create({
|
|
255
|
+
data: {
|
|
256
|
+
tenantId: session.tenantId,
|
|
257
|
+
bpUserSubject: session.bpUserSubject,
|
|
258
|
+
tokenHash: hashSecret(deviceToken),
|
|
259
|
+
name: "BetterTunnels CLI",
|
|
260
|
+
ipRangeHash: hashSecret(ipRange.cidr),
|
|
261
|
+
ipFamily: ipRange.family,
|
|
262
|
+
userAgentHash: hashSecret(event.req.headers.get("user-agent") ?? ""),
|
|
263
|
+
expiresAt: tokenExpiresAt
|
|
264
|
+
}
|
|
265
|
+
}),
|
|
266
|
+
prisma.clientAuthSession.update({
|
|
267
|
+
where: { id: session.id },
|
|
268
|
+
data: { status: "consumed", consumedAt: new Date() }
|
|
269
|
+
})
|
|
270
|
+
]);
|
|
271
|
+
return json(AuthStatusResponseSchema.parse({
|
|
272
|
+
status: "approved",
|
|
273
|
+
token: deviceToken,
|
|
274
|
+
expiresAt: tokenExpiresAt.toISOString(),
|
|
275
|
+
tenantId: session.tenantId,
|
|
276
|
+
bpUserSubject: session.bpUserSubject,
|
|
277
|
+
bpUserEmail: session.bpUserEmail ?? undefined
|
|
278
|
+
}));
|
|
279
|
+
});
|
|
280
|
+
this.server.on("upgrade", (request, socket, head) => {
|
|
281
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
282
|
+
if (url.pathname !== "/api/client/ws") {
|
|
283
|
+
socket.destroy();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
|
287
|
+
obs.log.info("CLIENT WS upgrade {path}", { path: url.pathname });
|
|
288
|
+
void this.handleTunnelSocket(ws, url, request.headers["user-agent"] ?? "", proxyClientIp(request.headers), domain, publicUrl, obs);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async run(obs) {
|
|
293
|
+
const port = this.config.port;
|
|
294
|
+
await new Promise((resolve) => {
|
|
295
|
+
this.server.listen(port, "0.0.0.0", () => resolve());
|
|
296
|
+
});
|
|
297
|
+
obs.log.info(`client-api listening on port ${port}`);
|
|
298
|
+
}
|
|
299
|
+
async dispose() {
|
|
300
|
+
for (const tunnel of this.registry.values()) {
|
|
301
|
+
clearTimeout(tunnel.expiryTimer);
|
|
302
|
+
tunnel.ws.close(1001, "server shutting down");
|
|
303
|
+
}
|
|
304
|
+
await prisma.tunnel.updateMany({
|
|
305
|
+
where: { status: "active", ownerServerId: this.appId },
|
|
306
|
+
data: { status: "disconnected" }
|
|
307
|
+
});
|
|
308
|
+
this.wss.close();
|
|
309
|
+
await new Promise((resolve, reject) => {
|
|
310
|
+
this.server.close((error) => error ? reject(error) : resolve());
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async handleTunnelSocket(ws, url, userAgent, clientIp, domain, publicUrl, obs) {
|
|
314
|
+
const input = TunnelCreateSchema.parse({
|
|
315
|
+
sessionId: url.searchParams.get("sessionId") ?? "",
|
|
316
|
+
targetHost: url.searchParams.get("targetHost") ?? "127.0.0.1",
|
|
317
|
+
targetPort: Number(url.searchParams.get("targetPort") ?? "0"),
|
|
318
|
+
clientVersion: url.searchParams.get("clientVersion") ?? undefined,
|
|
319
|
+
authenticated: url.searchParams.get("authenticated") === "true",
|
|
320
|
+
prefix: url.searchParams.get("prefix") ?? undefined,
|
|
321
|
+
validation: url.searchParams.get("validation") ?? undefined,
|
|
322
|
+
token: url.searchParams.get("token") ?? undefined
|
|
323
|
+
});
|
|
324
|
+
const versionIssue = incompatibleMajor(input.clientVersion, this.pluginPackageVersion);
|
|
325
|
+
if (versionIssue) {
|
|
326
|
+
ws.close(1008, versionIssue);
|
|
327
|
+
obs.log.warn("CLIENT SESSION rejected incompatible version client={clientVersion} server={serverVersion} from {clientIp}", {
|
|
328
|
+
clientVersion: input.clientVersion ?? "unknown",
|
|
329
|
+
serverVersion: this.pluginPackageVersion,
|
|
330
|
+
clientIp
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const authContext = input.authenticated
|
|
335
|
+
? await validateDeviceToken(input.token, clientIp, Array.isArray(userAgent) ? userAgent.join(" ") : userAgent)
|
|
336
|
+
: undefined;
|
|
337
|
+
if (input.authenticated && !authContext) {
|
|
338
|
+
ws.close(1008, "reauth required");
|
|
339
|
+
obs.log.warn("CLIENT SESSION rejected authenticated tunnel from {clientIp}", { clientIp });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const authenticated = !!authContext;
|
|
343
|
+
const validation = authenticated && input.validation === "ip" ? "ip" : "cookie";
|
|
344
|
+
let subdomain;
|
|
345
|
+
if (!(authenticated && input.prefix)) {
|
|
346
|
+
// Reuse the prior subdomain for this session so reconnects keep the same public URL.
|
|
347
|
+
const prior = await prisma.tunnel.findFirst({
|
|
348
|
+
where: {
|
|
349
|
+
sessionId: input.sessionId,
|
|
350
|
+
targetHost: input.targetHost,
|
|
351
|
+
targetPort: input.targetPort,
|
|
352
|
+
expiresAt: { gt: new Date() }
|
|
353
|
+
},
|
|
354
|
+
orderBy: { expiresAt: "desc" }
|
|
355
|
+
});
|
|
356
|
+
subdomain = prior?.subdomain;
|
|
357
|
+
}
|
|
358
|
+
if (!subdomain) {
|
|
359
|
+
const prefix = authenticated && input.prefix ? input.prefix : randomPrefix();
|
|
360
|
+
subdomain = buildTunnelSubdomain(prefix, input.targetPort, clientIp);
|
|
361
|
+
}
|
|
362
|
+
const expiresAt = new Date(Date.now() + (authenticated ? 24 * 60 * 60 * 1000 : 6 * 60 * 60 * 1000));
|
|
363
|
+
const sessionObs = this.createTrace("bt.client.session", {
|
|
364
|
+
"bt.client.session_id": input.sessionId,
|
|
365
|
+
"bt.tunnel.subdomain": subdomain,
|
|
366
|
+
"bt.tunnel.authenticated": authenticated,
|
|
367
|
+
"client.address": clientIp
|
|
368
|
+
});
|
|
369
|
+
sessionObs.log.info("CLIENT SESSION connect {subdomain} from {clientIp}", {
|
|
370
|
+
subdomain,
|
|
371
|
+
clientIp
|
|
372
|
+
});
|
|
373
|
+
const session = await prisma.clientSession.upsert({
|
|
374
|
+
where: { id: input.sessionId },
|
|
375
|
+
create: {
|
|
376
|
+
id: input.sessionId,
|
|
377
|
+
accountId: authContext?.accountId,
|
|
378
|
+
userAgent: Array.isArray(userAgent) ? userAgent.join(" ") : userAgent,
|
|
379
|
+
ipHash: hashValue(clientIp),
|
|
380
|
+
expiresAt
|
|
381
|
+
},
|
|
382
|
+
update: { expiresAt }
|
|
383
|
+
});
|
|
384
|
+
const tunnelRow = await prisma.tunnel.upsert({
|
|
385
|
+
where: { subdomain },
|
|
386
|
+
create: {
|
|
387
|
+
sessionId: session.id,
|
|
388
|
+
subdomain,
|
|
389
|
+
ownerServerId: this.appId,
|
|
390
|
+
targetHost: input.targetHost,
|
|
391
|
+
targetPort: input.targetPort,
|
|
392
|
+
authenticated,
|
|
393
|
+
validation,
|
|
394
|
+
accountId: authContext?.accountId,
|
|
395
|
+
status: "active",
|
|
396
|
+
expiresAt
|
|
397
|
+
},
|
|
398
|
+
update: {
|
|
399
|
+
sessionId: session.id,
|
|
400
|
+
ownerServerId: this.appId,
|
|
401
|
+
targetHost: input.targetHost,
|
|
402
|
+
targetPort: input.targetPort,
|
|
403
|
+
authenticated,
|
|
404
|
+
validation,
|
|
405
|
+
accountId: authContext?.accountId,
|
|
406
|
+
status: "active",
|
|
407
|
+
expiresAt
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
const expiryTimer = setTimeout(() => {
|
|
411
|
+
if (this.registry.get(subdomain)?.ws === ws) {
|
|
412
|
+
ws.close(1008, "tunnel expired");
|
|
413
|
+
}
|
|
414
|
+
}, Math.max(0, expiresAt.getTime() - Date.now()));
|
|
415
|
+
expiryTimer.unref();
|
|
416
|
+
const tunnel = {
|
|
417
|
+
id: tunnelRow.id,
|
|
418
|
+
subdomain,
|
|
419
|
+
targetHost: input.targetHost,
|
|
420
|
+
targetPort: input.targetPort,
|
|
421
|
+
authenticated,
|
|
422
|
+
expiresAt,
|
|
423
|
+
ws,
|
|
424
|
+
expiryTimer,
|
|
425
|
+
pending: new Map(),
|
|
426
|
+
sessionObs
|
|
427
|
+
};
|
|
428
|
+
this.registry.set(tunnel);
|
|
429
|
+
sessionObs.log.info("CLIENT TUNNEL active {subdomain} -> {host}:{port}", {
|
|
430
|
+
subdomain,
|
|
431
|
+
host: input.targetHost,
|
|
432
|
+
port: input.targetPort
|
|
433
|
+
});
|
|
434
|
+
ws.send(JSON.stringify({
|
|
435
|
+
type: "tunnel.ready",
|
|
436
|
+
publicUrl: publicUrl.replace(/\/$/, "").replace(`://${domain}`, `://${subdomain}.${domain}`),
|
|
437
|
+
subdomain,
|
|
438
|
+
expiresAt: expiresAt.toISOString(),
|
|
439
|
+
serverVersion: this.pluginPackageVersion
|
|
440
|
+
}));
|
|
441
|
+
ws.on("message", (raw) => this.handleClientFrame(tunnel, raw.toString()));
|
|
442
|
+
ws.on("close", (code, reason) => {
|
|
443
|
+
clearTimeout(tunnel.expiryTimer);
|
|
444
|
+
sessionObs.log.warn("CLIENT TUNNEL disconnected {subdomain}", { subdomain });
|
|
445
|
+
for (const [requestId, pending] of tunnel.pending.entries()) {
|
|
446
|
+
this.failPending(tunnel, requestId, pending, new Error("Tunnel client disconnected."));
|
|
447
|
+
}
|
|
448
|
+
// A reconnect may already own this subdomain; only tear down if this socket is still the registered one.
|
|
449
|
+
if (this.registry.get(subdomain)?.ws === ws) {
|
|
450
|
+
this.registry.delete(subdomain);
|
|
451
|
+
void prisma.tunnel.update({
|
|
452
|
+
where: { id: tunnel.id },
|
|
453
|
+
data: { status: tunnelStatusAfterDisconnect(tunnel.expiresAt) }
|
|
454
|
+
}).catch(() => undefined);
|
|
455
|
+
}
|
|
456
|
+
sessionObs.end({ "bt.client.disconnect": true });
|
|
457
|
+
sessionObs.log.warn("CLIENT TUNNEL socket closed {subdomain} code={code} reason={reason}", {
|
|
458
|
+
subdomain,
|
|
459
|
+
code,
|
|
460
|
+
reason: reason.toString()
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
ws.on("error", (error) => {
|
|
464
|
+
sessionObs.log.error("CLIENT TUNNEL socket error {subdomain}: {message}", {
|
|
465
|
+
subdomain,
|
|
466
|
+
message: error.message
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async proxyRequest(tunnel, method, path, headers, body, webStartedAt) {
|
|
471
|
+
const requestId = crypto.randomUUID();
|
|
472
|
+
const clientApiSentAt = Date.now();
|
|
473
|
+
const timeoutMs = tunnel.authenticated ? 5 * 60 * 1000 : 60 * 1000;
|
|
474
|
+
const idleTimeoutMs = tunnel.authenticated ? 60 * 1000 : 30 * 1000;
|
|
475
|
+
const requestObs = tunnel.sessionObs.startSpan("bt.client.proxy", {
|
|
476
|
+
"bt.tunnel.id": tunnel.id,
|
|
477
|
+
"bt.tunnel.subdomain": tunnel.subdomain,
|
|
478
|
+
"http.request.method": method,
|
|
479
|
+
"url.path": path
|
|
480
|
+
});
|
|
481
|
+
requestObs.log.info("CLIENT PROXY start {method} {path} via {subdomain}", {
|
|
482
|
+
method,
|
|
483
|
+
path,
|
|
484
|
+
subdomain: tunnel.subdomain
|
|
485
|
+
});
|
|
486
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
487
|
+
const pending = {
|
|
488
|
+
resolve: (response) => {
|
|
489
|
+
void response.arrayBuffer().then((arrayBuffer) => {
|
|
490
|
+
const responseHeaders = {};
|
|
491
|
+
response.headers.forEach((value, key) => {
|
|
492
|
+
responseHeaders[key] = value;
|
|
493
|
+
});
|
|
494
|
+
const output = {
|
|
495
|
+
requestId,
|
|
496
|
+
ownerServerId: this.appId,
|
|
497
|
+
status: response.status,
|
|
498
|
+
headers: responseHeaders,
|
|
499
|
+
body: Buffer.from(arrayBuffer).toString("base64"),
|
|
500
|
+
webStartedAt: pending.webStartedAt,
|
|
501
|
+
clientApiRoundtripMs: pending.clientApiRoundtripMs ?? Date.now() - pending.clientApiSentAt,
|
|
502
|
+
cliOverheadMs: pending.cliOverheadMs,
|
|
503
|
+
originMs: pending.originMs
|
|
504
|
+
};
|
|
505
|
+
requestObs.log.info("CLIENT PROXY complete {method} {path} -> {status}", {
|
|
506
|
+
method,
|
|
507
|
+
path,
|
|
508
|
+
status: response.status
|
|
509
|
+
});
|
|
510
|
+
requestObs.end({ "http.response.status_code": response.status });
|
|
511
|
+
resolve(output);
|
|
512
|
+
}).catch(reject);
|
|
513
|
+
},
|
|
514
|
+
reject: (error) => {
|
|
515
|
+
requestObs.error(error);
|
|
516
|
+
requestObs.end({ "status": "failed" });
|
|
517
|
+
reject(error);
|
|
518
|
+
},
|
|
519
|
+
chunks: [],
|
|
520
|
+
webStartedAt,
|
|
521
|
+
clientApiSentAt,
|
|
522
|
+
totalTimer: setTimeout(() => this.failPending(tunnel, requestId, pending, new Error("Local app timed out.")), timeoutMs),
|
|
523
|
+
firstByteTimer: setTimeout(() => this.failPending(tunnel, requestId, pending, new Error("Local app did not acknowledge request within 5s.")), 5 * 1000),
|
|
524
|
+
idleTimeoutMs
|
|
525
|
+
};
|
|
526
|
+
tunnel.pending.set(requestId, pending);
|
|
527
|
+
});
|
|
528
|
+
tunnel.ws.send(JSON.stringify({
|
|
529
|
+
type: "request.start",
|
|
530
|
+
requestId,
|
|
531
|
+
method,
|
|
532
|
+
path,
|
|
533
|
+
headers,
|
|
534
|
+
body,
|
|
535
|
+
webStartedAt,
|
|
536
|
+
clientApiSentAt
|
|
537
|
+
}));
|
|
538
|
+
return responsePromise;
|
|
539
|
+
}
|
|
540
|
+
handleClientFrame(tunnel, raw) {
|
|
541
|
+
let frame;
|
|
542
|
+
try {
|
|
543
|
+
frame = ClientFrameSchema.parse(JSON.parse(raw));
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
tunnel.sessionObs.error(error instanceof Error ? error : new Error(String(error)));
|
|
547
|
+
tunnel.ws.close(1002, "invalid frame");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (frame.type === "ws.fromOrigin" && frame.publicServerId && frame.publicSocketId && frame.frameType) {
|
|
551
|
+
void this.web.wsFromOriginSpecific(frame.publicServerId, tunnel.sessionObs, {
|
|
552
|
+
publicSocketId: frame.publicSocketId,
|
|
553
|
+
frameType: frame.frameType,
|
|
554
|
+
event: frame.message,
|
|
555
|
+
body: frame.body
|
|
556
|
+
});
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (!frame.requestId)
|
|
560
|
+
return;
|
|
561
|
+
const pending = tunnel.pending.get(frame.requestId);
|
|
562
|
+
if (!pending)
|
|
563
|
+
return;
|
|
564
|
+
if (frame.type === "response.start") {
|
|
565
|
+
clearTimeout(pending.firstByteTimer);
|
|
566
|
+
this.resetIdleTimer(tunnel, frame.requestId, pending);
|
|
567
|
+
pending.status = frame.status ?? 200;
|
|
568
|
+
pending.headers = frame.headers ?? {};
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (frame.type === "response.body" && frame.body) {
|
|
572
|
+
this.resetIdleTimer(tunnel, frame.requestId, pending);
|
|
573
|
+
pending.chunks.push(frame.body);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (frame.type === "response.end") {
|
|
577
|
+
pending.clientApiRoundtripMs = Date.now() - pending.clientApiSentAt;
|
|
578
|
+
pending.cliOverheadMs = frame.cliOverheadMs;
|
|
579
|
+
pending.originMs = frame.originMs;
|
|
580
|
+
this.clearPendingTimers(pending);
|
|
581
|
+
tunnel.pending.delete(frame.requestId);
|
|
582
|
+
const buffer = Buffer.concat(pending.chunks.map((chunk) => Buffer.from(chunk, "base64")));
|
|
583
|
+
try {
|
|
584
|
+
pending.resolve(buildProxyResponse(buffer, pending.status ?? 200, stripResponseHeaders(pending.headers ?? {})));
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (frame.type === "error") {
|
|
592
|
+
this.failPending(tunnel, frame.requestId, pending, new Error(frame.message ?? "Tunnel client error."));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
resetIdleTimer(tunnel, requestId, pending) {
|
|
596
|
+
if (pending.idleTimer)
|
|
597
|
+
clearTimeout(pending.idleTimer);
|
|
598
|
+
pending.idleTimer = setTimeout(() => {
|
|
599
|
+
this.failPending(tunnel, requestId, pending, new Error("Local app response stalled."));
|
|
600
|
+
}, pending.idleTimeoutMs);
|
|
601
|
+
}
|
|
602
|
+
failPending(tunnel, requestId, pending, error) {
|
|
603
|
+
if (!tunnel.pending.has(requestId))
|
|
604
|
+
return;
|
|
605
|
+
this.clearPendingTimers(pending);
|
|
606
|
+
tunnel.pending.delete(requestId);
|
|
607
|
+
pending.reject(error);
|
|
608
|
+
}
|
|
609
|
+
clearPendingTimers(pending) {
|
|
610
|
+
clearTimeout(pending.totalTimer);
|
|
611
|
+
clearTimeout(pending.firstByteTimer);
|
|
612
|
+
if (pending.idleTimer)
|
|
613
|
+
clearTimeout(pending.idleTimer);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function incompatibleMajor(clientVersion, serverVersion) {
|
|
617
|
+
const client = majorVersion(clientVersion);
|
|
618
|
+
const server = majorVersion(serverVersion);
|
|
619
|
+
if (client === undefined || server === undefined)
|
|
620
|
+
return undefined;
|
|
621
|
+
if (client !== server) {
|
|
622
|
+
return `BetterTunnels CLI major version ${client} is incompatible with server major version ${server}. Update the CLI.`;
|
|
623
|
+
}
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
function majorVersion(value) {
|
|
627
|
+
const normalized = value?.trim().replace(/^v/, "");
|
|
628
|
+
if (!normalized || normalized === "dev")
|
|
629
|
+
return undefined;
|
|
630
|
+
const major = Number.parseInt(normalized.split(".")[0] ?? "", 10);
|
|
631
|
+
return Number.isFinite(major) ? major : undefined;
|
|
632
|
+
}
|
|
633
|
+
async function safeJson(request) {
|
|
634
|
+
try {
|
|
635
|
+
return await request.json();
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return {};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function loadPluginPackageVersion(packageCwd) {
|
|
642
|
+
try {
|
|
643
|
+
const raw = await readFile(join(packageCwd, "package.json"), "utf8");
|
|
644
|
+
const parsed = JSON.parse(raw);
|
|
645
|
+
return typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0";
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return "0.0.0";
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Response() throws on null-body statuses with a body, and on statuses outside 200-599.
|
|
652
|
+
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
653
|
+
export function buildProxyResponse(body, status, headers) {
|
|
654
|
+
const safeStatus = status >= 200 && status <= 599 ? status : 502;
|
|
655
|
+
return new Response(NULL_BODY_STATUSES.has(safeStatus) ? null : new Uint8Array(body), { status: safeStatus, headers });
|
|
656
|
+
}
|
|
657
|
+
function json(value, status = 200) {
|
|
658
|
+
return new Response(JSON.stringify(value), {
|
|
659
|
+
status,
|
|
660
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
function buildAuthUrl(base, path, browserKey) {
|
|
664
|
+
const url = new URL(path.startsWith("/") ? path : `/${path}`, base);
|
|
665
|
+
url.searchParams.set("key", browserKey);
|
|
666
|
+
return url.toString();
|
|
667
|
+
}
|
|
668
|
+
async function validateDeviceToken(token, clientIp, userAgent) {
|
|
669
|
+
if (!token)
|
|
670
|
+
return undefined;
|
|
671
|
+
const ipRange = normalizeIpRange(clientIp);
|
|
672
|
+
if (!ipRange)
|
|
673
|
+
return undefined;
|
|
674
|
+
const row = await prisma.clientDeviceToken.findUnique({
|
|
675
|
+
where: { tokenHash: hashSecret(token) }
|
|
676
|
+
});
|
|
677
|
+
if (!row || row.revokedAt || row.expiresAt <= new Date())
|
|
678
|
+
return undefined;
|
|
679
|
+
if (row.ipFamily !== ipRange.family || row.ipRangeHash !== hashSecret(ipRange.cidr))
|
|
680
|
+
return undefined;
|
|
681
|
+
if (row.userAgentHash && row.userAgentHash !== hashSecret(userAgent))
|
|
682
|
+
return undefined;
|
|
683
|
+
await prisma.clientDeviceToken.update({
|
|
684
|
+
where: { id: row.id },
|
|
685
|
+
data: { lastUsedAt: new Date() }
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
accountId: row.accountId ?? undefined,
|
|
689
|
+
tenantId: row.tenantId,
|
|
690
|
+
bpUserSubject: row.bpUserSubject,
|
|
691
|
+
expiresAt: row.expiresAt
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function proxyClientIp(headers) {
|
|
695
|
+
const forwardedFor = firstPublicForwardedIp(firstHeader(headers["x-forwarded-for"]));
|
|
696
|
+
if (forwardedFor)
|
|
697
|
+
return forwardedFor;
|
|
698
|
+
return firstHeader(headers["x-real-ip"])?.trim() || "unknown";
|
|
699
|
+
}
|
|
700
|
+
function proxyClientIpFromHeaders(headers) {
|
|
701
|
+
const forwardedFor = firstPublicForwardedIp(headers.get("x-forwarded-for") ?? undefined);
|
|
702
|
+
if (forwardedFor)
|
|
703
|
+
return forwardedFor;
|
|
704
|
+
return headers.get("x-real-ip")?.trim() || "unknown";
|
|
705
|
+
}
|
|
706
|
+
function firstHeader(value) {
|
|
707
|
+
return Array.isArray(value) ? value[0] : value;
|
|
708
|
+
}
|
|
709
|
+
function firstPublicForwardedIp(value) {
|
|
710
|
+
return value?.split(",").map((ip) => ip.trim()).find((ip) => ip && !isPrivateIp(ip));
|
|
711
|
+
}
|
|
712
|
+
function isPrivateIp(ip) {
|
|
713
|
+
return ip === "unknown"
|
|
714
|
+
|| ip.startsWith("10.")
|
|
715
|
+
|| ip.startsWith("192.168.")
|
|
716
|
+
|| /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)
|
|
717
|
+
|| ip.startsWith("127.")
|
|
718
|
+
|| ip.startsWith("::1")
|
|
719
|
+
|| ip.startsWith("fc")
|
|
720
|
+
|| ip.startsWith("fd")
|
|
721
|
+
|| ip.startsWith("fe80:");
|
|
722
|
+
}
|
|
723
|
+
function stripResponseHeaders(headers) {
|
|
724
|
+
const out = new Headers();
|
|
725
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
726
|
+
const key = name.toLowerCase();
|
|
727
|
+
if (key === "set-cookie" || key === "connection" || key === "transfer-encoding")
|
|
728
|
+
continue;
|
|
729
|
+
out.set(name, value);
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
//# sourceMappingURL=index.js.map
|