@linkshell/gateway 0.5.3 → 0.6.0
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/Dockerfile +8 -22
- package/README.md +99 -44
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -1
- package/dist/gateway/src/index.js +4 -863
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -5
- package/dist/gateway/src/relay.js +402 -529
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
- package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/direct.d.ts +22 -0
- package/dist/wire/src/direct.js +37 -0
- package/dist/wire/src/direct.js.map +1 -0
- package/dist/wire/src/index.d.ts +1 -0
- package/dist/wire/src/index.js +1 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/model.d.ts +287 -6
- package/dist/wire/src/model.js +33 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +1780 -44
- package/dist/wire/src/rpc.js +48 -3
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +356 -0
- package/dist/wire/src/updates.js +11 -1
- package/dist/wire/src/updates.js.map +1 -1
- package/package.json +12 -12
- package/src/accounts.ts +32 -0
- package/src/index.ts +4 -997
- package/src/main.ts +65 -0
- package/src/rate-limit.ts +72 -0
- package/src/relay.ts +435 -620
- package/src/serve.ts +130 -0
- package/src/store.ts +135 -0
- package/src/subscription.ts +51 -0
- package/dist/gateway/src/auth-middleware.d.ts +0 -44
- package/dist/gateway/src/auth-middleware.js +0 -204
- package/dist/gateway/src/auth-middleware.js.map +0 -1
- package/dist/gateway/src/cors.d.ts +0 -2
- package/dist/gateway/src/cors.js +0 -7
- package/dist/gateway/src/cors.js.map +0 -1
- package/dist/gateway/src/embedded.d.ts +0 -21
- package/dist/gateway/src/embedded.js +0 -438
- package/dist/gateway/src/embedded.js.map +0 -1
- package/dist/gateway/src/host-auth.d.ts +0 -32
- package/dist/gateway/src/host-auth.js +0 -78
- package/dist/gateway/src/host-auth.js.map +0 -1
- package/dist/gateway/src/pairings.d.ts +0 -38
- package/dist/gateway/src/pairings.js +0 -119
- package/dist/gateway/src/pairings.js.map +0 -1
- package/dist/gateway/src/presence.d.ts +0 -28
- package/dist/gateway/src/presence.js +0 -92
- package/dist/gateway/src/presence.js.map +0 -1
- package/dist/gateway/src/sessions.d.ts +0 -113
- package/dist/gateway/src/sessions.js +0 -352
- package/dist/gateway/src/sessions.js.map +0 -1
- package/dist/gateway/src/state-store.d.ts +0 -21
- package/dist/gateway/src/state-store.js +0 -103
- package/dist/gateway/src/state-store.js.map +0 -1
- package/dist/gateway/src/static-web.d.ts +0 -15
- package/dist/gateway/src/static-web.js +0 -129
- package/dist/gateway/src/static-web.js.map +0 -1
- package/dist/gateway/src/tokens.d.ts +0 -19
- package/dist/gateway/src/tokens.js +0 -151
- package/dist/gateway/src/tokens.js.map +0 -1
- package/dist/gateway/src/tunnel.d.ts +0 -61
- package/dist/gateway/src/tunnel.js +0 -576
- package/dist/gateway/src/tunnel.js.map +0 -1
- package/dist/gateway-v2/src/accounts.js.map +0 -1
- package/dist/gateway-v2/src/index.d.ts +0 -3
- package/dist/gateway-v2/src/index.js +0 -4
- package/dist/gateway-v2/src/index.js.map +0 -1
- package/dist/gateway-v2/src/main.js +0 -17
- package/dist/gateway-v2/src/main.js.map +0 -1
- package/dist/gateway-v2/src/server.d.ts +0 -84
- package/dist/gateway-v2/src/server.js +0 -426
- package/dist/gateway-v2/src/server.js.map +0 -1
- package/dist/gateway-v2/src/store.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/src/auth-middleware.ts +0 -266
- package/src/cors.ts +0 -8
- package/src/embedded.ts +0 -526
- package/src/host-auth.ts +0 -88
- package/src/pairings.ts +0 -133
- package/src/presence.ts +0 -118
- package/src/sessions.ts +0 -432
- package/src/state-store.ts +0 -140
- package/src/static-web.ts +0 -133
- package/src/tokens.ts +0 -159
- package/src/tunnel.ts +0 -644
- /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
- /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
- /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/tunnel.ts
DELETED
|
@@ -1,644 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type WebSocket from "ws";
|
|
4
|
-
import {
|
|
5
|
-
createEnvelope,
|
|
6
|
-
serializeEnvelope,
|
|
7
|
-
} from "@linkshell/protocol";
|
|
8
|
-
import type { SessionManager } from "./sessions.js";
|
|
9
|
-
import type { TokenManager } from "./tokens.js";
|
|
10
|
-
import { AUTH_REQUIRED } from "./auth-middleware.js";
|
|
11
|
-
|
|
12
|
-
const TUNNEL_TIMEOUT = 30_000;
|
|
13
|
-
const MAX_TUNNEL_BODY = 10 * 1024 * 1024; // 10MB
|
|
14
|
-
|
|
15
|
-
export interface PendingTunnelRequest {
|
|
16
|
-
res: ServerResponse;
|
|
17
|
-
headersSent: boolean;
|
|
18
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
19
|
-
tunnelCookie?: string;
|
|
20
|
-
sessionId: string;
|
|
21
|
-
port: number;
|
|
22
|
-
requestPath: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface PendingTunnelWs {
|
|
26
|
-
ws: WebSocket;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Module-level maps keyed by requestId
|
|
30
|
-
const pendingRequests = new Map<string, PendingTunnelRequest>();
|
|
31
|
-
const pendingWsSockets = new Map<string, PendingTunnelWs>();
|
|
32
|
-
|
|
33
|
-
// Track requestIds per session for cleanup on host disconnect
|
|
34
|
-
const sessionRequests = new Map<string, Set<string>>();
|
|
35
|
-
|
|
36
|
-
function trackRequest(sessionId: string, requestId: string): void {
|
|
37
|
-
let set = sessionRequests.get(sessionId);
|
|
38
|
-
if (!set) {
|
|
39
|
-
set = new Set();
|
|
40
|
-
sessionRequests.set(sessionId, set);
|
|
41
|
-
}
|
|
42
|
-
set.add(requestId);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function untrackRequest(sessionId: string, requestId: string): void {
|
|
46
|
-
const set = sessionRequests.get(sessionId);
|
|
47
|
-
if (set) {
|
|
48
|
-
set.delete(requestId);
|
|
49
|
-
if (set.size === 0) sessionRequests.delete(sessionId);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function extractToken(req: IncomingMessage, url: URL): string | null {
|
|
54
|
-
// Check Authorization header
|
|
55
|
-
const auth = req.headers.authorization;
|
|
56
|
-
if (auth) {
|
|
57
|
-
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
58
|
-
if (match?.[1]) return match[1];
|
|
59
|
-
}
|
|
60
|
-
// Check query param
|
|
61
|
-
const qToken = url.searchParams.get("token");
|
|
62
|
-
if (qToken) return qToken;
|
|
63
|
-
// Check cookie (token-only cookie)
|
|
64
|
-
const cookie = req.headers.cookie;
|
|
65
|
-
if (cookie) {
|
|
66
|
-
const match = cookie.match(/lsh_token=([^;]+)/);
|
|
67
|
-
if (match?.[1]) return match[1];
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function stripGatewayCookies(cookie: string): string | null {
|
|
73
|
-
const kept = cookie
|
|
74
|
-
.split(";")
|
|
75
|
-
.map((part) => part.trim())
|
|
76
|
-
.filter((part) => {
|
|
77
|
-
const name = part.split("=", 1)[0]?.trim();
|
|
78
|
-
return name !== "lsh_tunnel" && name !== "lsh_token";
|
|
79
|
-
});
|
|
80
|
-
return kept.length > 0 ? kept.join("; ") : null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function bindExplicitDeviceToken(
|
|
84
|
-
tokens: TokenManager,
|
|
85
|
-
sessionId: string,
|
|
86
|
-
url: URL,
|
|
87
|
-
preAuthToken?: string,
|
|
88
|
-
): string | null {
|
|
89
|
-
// Cookie fallback may pass a JWT as preAuthToken. Only bind the explicit
|
|
90
|
-
// browser/device token from the original tunnel URL after JWT ownership has
|
|
91
|
-
// been proven.
|
|
92
|
-
if (preAuthToken) return null;
|
|
93
|
-
const deviceToken = url.searchParams.get("token");
|
|
94
|
-
if (!deviceToken) return null;
|
|
95
|
-
const token = tokens.register(deviceToken);
|
|
96
|
-
return tokens.bind(token, sessionId) ? token : null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function forwardedUrl(path: string, url: URL): string {
|
|
100
|
-
const params = new URLSearchParams(url.searchParams);
|
|
101
|
-
params.delete("token");
|
|
102
|
-
params.delete("auth_token");
|
|
103
|
-
const query = params.toString();
|
|
104
|
-
return query ? `${path}?${query}` : path;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function stripGatewayAuthParams(pathname: string, searchParams: URLSearchParams, hash = ""): string {
|
|
108
|
-
const params = new URLSearchParams(searchParams);
|
|
109
|
-
params.delete("token");
|
|
110
|
-
params.delete("auth_token");
|
|
111
|
-
const query = params.toString();
|
|
112
|
-
return `${pathname}${query ? `?${query}` : ""}${hash}`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function rewriteTunnelLocation(
|
|
116
|
-
location: string,
|
|
117
|
-
input: { sessionId: string; port: number; requestPath: string },
|
|
118
|
-
): string {
|
|
119
|
-
try {
|
|
120
|
-
const parsed = new URL(location, `http://127.0.0.1:${input.port}${input.requestPath}`);
|
|
121
|
-
const isLocal =
|
|
122
|
-
parsed.hostname === "127.0.0.1" ||
|
|
123
|
-
parsed.hostname === "localhost" ||
|
|
124
|
-
parsed.hostname === "::1";
|
|
125
|
-
if (!isLocal || parsed.port !== String(input.port)) return location;
|
|
126
|
-
const target = stripGatewayAuthParams(parsed.pathname, parsed.searchParams, parsed.hash);
|
|
127
|
-
return `/tunnel/${encodeURIComponent(input.sessionId)}/${input.port}${target}`;
|
|
128
|
-
} catch {
|
|
129
|
-
return location;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function rewriteTunnelResponseHeaders(
|
|
134
|
-
headers: Record<string, string | string[]>,
|
|
135
|
-
input: { sessionId: string; port: number; requestPath: string },
|
|
136
|
-
): Record<string, string | string[]> {
|
|
137
|
-
const rewritten = { ...headers };
|
|
138
|
-
const locationKey = Object.keys(rewritten).find((key) => key.toLowerCase() === "location");
|
|
139
|
-
if (!locationKey) return rewritten;
|
|
140
|
-
|
|
141
|
-
const location = rewritten[locationKey];
|
|
142
|
-
if (typeof location === "string") {
|
|
143
|
-
rewritten[locationKey] = rewriteTunnelLocation(location, input);
|
|
144
|
-
}
|
|
145
|
-
return rewritten;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function tokenFromTunnelCookie(
|
|
149
|
-
req: IncomingMessage,
|
|
150
|
-
sessionId: string,
|
|
151
|
-
port: number,
|
|
152
|
-
): string | null {
|
|
153
|
-
const tunnelCookie = parseTunnelCookie(req);
|
|
154
|
-
if (!tunnelCookie) return null;
|
|
155
|
-
if (tunnelCookie.sessionId !== sessionId || tunnelCookie.port !== port) return null;
|
|
156
|
-
return tunnelCookie.token;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Parse lsh_tunnel cookie: "sessionId:port:token" */
|
|
160
|
-
export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; port: number; token: string } | null {
|
|
161
|
-
const cookie = req.headers.cookie;
|
|
162
|
-
if (!cookie) return null;
|
|
163
|
-
const match = cookie.match(/lsh_tunnel=([^;]+)/);
|
|
164
|
-
if (!match?.[1]) return null;
|
|
165
|
-
const parts = decodeURIComponent(match[1]).split(":");
|
|
166
|
-
if (parts.length < 3) return null;
|
|
167
|
-
const sessionId = parts[0]!;
|
|
168
|
-
const port = Number(parts[1]);
|
|
169
|
-
const token = parts.slice(2).join(":"); // token may contain colons
|
|
170
|
-
if (!sessionId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
|
|
171
|
-
return { sessionId, port, token };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function errorResponse(res: ServerResponse, status: number, message: string, diagnostic?: string): void {
|
|
175
|
-
if (res.headersSent) return;
|
|
176
|
-
res.writeHead(status, {
|
|
177
|
-
"content-type": "text/plain",
|
|
178
|
-
"cache-control": "no-store, private",
|
|
179
|
-
"cdn-cache-control": "no-store",
|
|
180
|
-
"surrogate-control": "no-store",
|
|
181
|
-
"vary": "authorization, cookie",
|
|
182
|
-
"access-control-allow-origin": "*",
|
|
183
|
-
...(diagnostic ? { "x-linkshell-tunnel-error": diagnostic } : {}),
|
|
184
|
-
});
|
|
185
|
-
res.end(message);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function isSecureRequest(req: IncomingMessage): boolean {
|
|
189
|
-
if ((req.socket as { encrypted?: boolean }).encrypted) return true;
|
|
190
|
-
const proto = req.headers["x-forwarded-proto"];
|
|
191
|
-
if (typeof proto === "string") {
|
|
192
|
-
return proto.split(",")[0]?.trim().toLowerCase() === "https";
|
|
193
|
-
}
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function parseTunnelPath(pathname: string): { sessionId: string; port: number; path: string } | null {
|
|
198
|
-
const match = pathname.match(/^\/tunnel\/([^/]+)\/(\d+)(\/.*)?$/);
|
|
199
|
-
if (!match) return null;
|
|
200
|
-
const port = Number(match[2]);
|
|
201
|
-
if (port < 1 || port > 65535) return null;
|
|
202
|
-
return {
|
|
203
|
-
sessionId: match[1]!,
|
|
204
|
-
port,
|
|
205
|
-
path: match[3] || "/",
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function hasTunnelReferer(req: IncomingMessage): boolean {
|
|
210
|
-
const referer = req.headers.referer;
|
|
211
|
-
if (typeof referer !== "string") return false;
|
|
212
|
-
try {
|
|
213
|
-
const ref = new URL(referer);
|
|
214
|
-
return ref.pathname.startsWith("/tunnel/");
|
|
215
|
-
} catch {
|
|
216
|
-
return false;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export function shouldUseTunnelCookieFallback(
|
|
221
|
-
req: IncomingMessage,
|
|
222
|
-
pathname: string,
|
|
223
|
-
isReservedPath: (pathname: string) => boolean = () => false,
|
|
224
|
-
): boolean {
|
|
225
|
-
if (pathname === "/" || isReservedPath(pathname)) return false;
|
|
226
|
-
|
|
227
|
-
// A stale Path=/ tunnel cookie must not hijack normal top-level document
|
|
228
|
-
// visits to the LinkShell web app. Subresources are allowed through the
|
|
229
|
-
// cookie fallback even when Referrer-Policy strips the /tunnel/... path; the
|
|
230
|
-
// real LinkShell shell clears this HttpOnly cookie on index.html responses,
|
|
231
|
-
// and concrete LinkShell assets are served before fallback.
|
|
232
|
-
const dest = req.headers["sec-fetch-dest"];
|
|
233
|
-
if (hasTunnelReferer(req)) return true;
|
|
234
|
-
if (!dest) return true;
|
|
235
|
-
if (dest === "iframe") return true;
|
|
236
|
-
if (dest !== "document") return true;
|
|
237
|
-
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function mergeTunnelSetCookie(
|
|
242
|
-
headers: Record<string, string | string[]>,
|
|
243
|
-
tunnelCookie?: string,
|
|
244
|
-
): Record<string, string | string[]> {
|
|
245
|
-
if (!tunnelCookie) return headers;
|
|
246
|
-
const merged = { ...headers };
|
|
247
|
-
const setCookieKey = Object.keys(merged).find((key) => key.toLowerCase() === "set-cookie");
|
|
248
|
-
const upstreamSetCookie = setCookieKey ? merged[setCookieKey] : undefined;
|
|
249
|
-
if (setCookieKey) delete merged[setCookieKey];
|
|
250
|
-
merged["set-cookie"] = upstreamSetCookie
|
|
251
|
-
? Array.isArray(upstreamSetCookie)
|
|
252
|
-
? [...upstreamSetCookie, tunnelCookie]
|
|
253
|
-
: [upstreamSetCookie, tunnelCookie]
|
|
254
|
-
: tunnelCookie;
|
|
255
|
-
return merged;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export async function handleTunnelRequest(
|
|
259
|
-
req: IncomingMessage,
|
|
260
|
-
res: ServerResponse,
|
|
261
|
-
sessions: SessionManager,
|
|
262
|
-
tokens: TokenManager,
|
|
263
|
-
parsed: { sessionId: string; port: number; path: string },
|
|
264
|
-
url: URL,
|
|
265
|
-
preAuthToken?: string,
|
|
266
|
-
): Promise<void> {
|
|
267
|
-
const { sessionId, port, path } = parsed;
|
|
268
|
-
|
|
269
|
-
// Auth: device token OR Supabase JWT (userId owns session)
|
|
270
|
-
let token = preAuthToken || extractToken(req, url) || tokenFromTunnelCookie(req, sessionId, port);
|
|
271
|
-
let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
|
|
272
|
-
let authOwns = false;
|
|
273
|
-
let authJwt: string | null = null;
|
|
274
|
-
let authDiagnostic = token ? "token_not_bound" : "missing_token";
|
|
275
|
-
if (!tokenOwns && AUTH_REQUIRED) {
|
|
276
|
-
// Try preAuthToken as JWT first (from cookie fallback), then from request headers/params
|
|
277
|
-
const jwtCandidate = preAuthToken || url.searchParams.get("auth_token") || (() => {
|
|
278
|
-
const auth = req.headers.authorization;
|
|
279
|
-
if (auth) { const m = auth.match(/^Bearer\s+(.+)$/i); if (m?.[1]) return m[1]; }
|
|
280
|
-
return null;
|
|
281
|
-
})();
|
|
282
|
-
if (jwtCandidate) {
|
|
283
|
-
try {
|
|
284
|
-
const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
|
|
285
|
-
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
|
|
286
|
-
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
|
|
287
|
-
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
288
|
-
headers: { Authorization: `Bearer ${jwtCandidate}`, apikey: SUPABASE_ANON_KEY },
|
|
289
|
-
signal: AbortSignal.timeout(5_000),
|
|
290
|
-
});
|
|
291
|
-
if (userRes.ok) {
|
|
292
|
-
const user = (await userRes.json()) as { id: string };
|
|
293
|
-
const session = sessions.get(sessionId);
|
|
294
|
-
if (user.id && session?.userId && user.id === session.userId) {
|
|
295
|
-
authOwns = true;
|
|
296
|
-
authJwt = jwtCandidate;
|
|
297
|
-
} else if (!session) {
|
|
298
|
-
authDiagnostic = "session_not_local";
|
|
299
|
-
} else if (!session.userId) {
|
|
300
|
-
authDiagnostic = "session_user_missing";
|
|
301
|
-
} else {
|
|
302
|
-
authDiagnostic = "session_user_mismatch";
|
|
303
|
-
}
|
|
304
|
-
} else {
|
|
305
|
-
authDiagnostic = "invalid_jwt";
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
} catch {
|
|
309
|
-
authDiagnostic = "jwt_check_failed";
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
if (!tokenOwns && authOwns) {
|
|
314
|
-
const boundToken = bindExplicitDeviceToken(tokens, sessionId, url, preAuthToken);
|
|
315
|
-
if (boundToken) {
|
|
316
|
-
token = boundToken;
|
|
317
|
-
tokenOwns = true;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (!tokenOwns && !authOwns) {
|
|
321
|
-
errorResponse(res, 401, "Unauthorized", authDiagnostic);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Set auth cookie for subsequent sub-resource requests
|
|
326
|
-
let tunnelCookie: string | undefined;
|
|
327
|
-
const cookieToken = tokenOwns ? token : authJwt;
|
|
328
|
-
if (cookieToken) {
|
|
329
|
-
const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
|
|
330
|
-
const secure = isSecureRequest(req) ? "; Secure" : "";
|
|
331
|
-
tunnelCookie = `lsh_tunnel=${cookieVal}; Path=/; Max-Age=600; HttpOnly${secure}; SameSite=Lax`;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Validate session & host
|
|
335
|
-
const session = sessions.get(sessionId);
|
|
336
|
-
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
337
|
-
errorResponse(res, 502, "Host not connected", !session ? "session_not_local" : "host_not_connected");
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const requestId = randomUUID();
|
|
342
|
-
const method = req.method ?? "GET";
|
|
343
|
-
|
|
344
|
-
// Read request body
|
|
345
|
-
let body: string | null = null;
|
|
346
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
347
|
-
const chunks: Buffer[] = [];
|
|
348
|
-
let size = 0;
|
|
349
|
-
for await (const chunk of req) {
|
|
350
|
-
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
351
|
-
size += buf.length;
|
|
352
|
-
if (size > MAX_TUNNEL_BODY) {
|
|
353
|
-
errorResponse(res, 413, "Request body too large");
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
chunks.push(buf);
|
|
357
|
-
}
|
|
358
|
-
if (chunks.length > 0) {
|
|
359
|
-
body = Buffer.concat(chunks).toString("base64");
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Build forwarded headers (strip hop-by-hop)
|
|
364
|
-
const headers: Record<string, string> = {};
|
|
365
|
-
const skipHeaders = new Set(["host", "connection", "upgrade", "transfer-encoding", "keep-alive"]);
|
|
366
|
-
for (const [key, val] of Object.entries(req.headers)) {
|
|
367
|
-
if (!skipHeaders.has(key) && typeof val === "string") {
|
|
368
|
-
if (key === "cookie") {
|
|
369
|
-
const cookie = stripGatewayCookies(val);
|
|
370
|
-
if (cookie) headers[key] = cookie;
|
|
371
|
-
} else {
|
|
372
|
-
headers[key] = val;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Reconstruct URL with query string. The gateway authenticates fallback
|
|
378
|
-
// subresources with lsh_tunnel; it must not mutate host-facing URLs with
|
|
379
|
-
// auth query params because that leaks credentials and poisons cache keys.
|
|
380
|
-
const fullUrl = forwardedUrl(path, url);
|
|
381
|
-
|
|
382
|
-
// Register pending request
|
|
383
|
-
const pending: PendingTunnelRequest = {
|
|
384
|
-
res,
|
|
385
|
-
headersSent: false,
|
|
386
|
-
timeout: setTimeout(() => {
|
|
387
|
-
pendingRequests.delete(requestId);
|
|
388
|
-
untrackRequest(sessionId, requestId);
|
|
389
|
-
errorResponse(res, 504, "Tunnel request timed out");
|
|
390
|
-
}, TUNNEL_TIMEOUT),
|
|
391
|
-
tunnelCookie,
|
|
392
|
-
sessionId,
|
|
393
|
-
port,
|
|
394
|
-
requestPath: path,
|
|
395
|
-
};
|
|
396
|
-
pendingRequests.set(requestId, pending);
|
|
397
|
-
trackRequest(sessionId, requestId);
|
|
398
|
-
|
|
399
|
-
// Send tunnel.request to host
|
|
400
|
-
const envelope = createEnvelope({
|
|
401
|
-
type: "tunnel.request",
|
|
402
|
-
sessionId,
|
|
403
|
-
payload: {
|
|
404
|
-
requestId,
|
|
405
|
-
method,
|
|
406
|
-
url: fullUrl,
|
|
407
|
-
headers,
|
|
408
|
-
body,
|
|
409
|
-
port,
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
session.host.socket.send(serializeEnvelope(envelope));
|
|
413
|
-
|
|
414
|
-
// Handle client disconnect
|
|
415
|
-
req.on("close", () => {
|
|
416
|
-
const p = pendingRequests.get(requestId);
|
|
417
|
-
if (p) {
|
|
418
|
-
clearTimeout(p.timeout);
|
|
419
|
-
pendingRequests.delete(requestId);
|
|
420
|
-
untrackRequest(sessionId, requestId);
|
|
421
|
-
}
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export function handleTunnelResponse(payload: {
|
|
426
|
-
requestId: string;
|
|
427
|
-
statusCode: number;
|
|
428
|
-
headers: Record<string, string>;
|
|
429
|
-
body: string;
|
|
430
|
-
isFinal: boolean;
|
|
431
|
-
}): void {
|
|
432
|
-
try {
|
|
433
|
-
const pending = pendingRequests.get(payload.requestId);
|
|
434
|
-
if (!pending) return;
|
|
435
|
-
|
|
436
|
-
if (!pending.headersSent) {
|
|
437
|
-
const responseHeaders: Record<string, string | string[]> = {
|
|
438
|
-
...rewriteTunnelResponseHeaders(payload.headers, pending),
|
|
439
|
-
"cache-control": "no-store, private",
|
|
440
|
-
"cdn-cache-control": "no-store",
|
|
441
|
-
"surrogate-control": "no-store",
|
|
442
|
-
"vary": "authorization, cookie",
|
|
443
|
-
"access-control-allow-origin": "*",
|
|
444
|
-
};
|
|
445
|
-
pending.res.writeHead(payload.statusCode, mergeTunnelSetCookie(responseHeaders, pending.tunnelCookie));
|
|
446
|
-
pending.headersSent = true;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (payload.body) {
|
|
450
|
-
pending.res.write(Buffer.from(payload.body, "base64"));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (payload.isFinal) {
|
|
454
|
-
clearTimeout(pending.timeout);
|
|
455
|
-
pendingRequests.delete(payload.requestId);
|
|
456
|
-
pending.res.end();
|
|
457
|
-
}
|
|
458
|
-
} catch {}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export function handleTunnelWsData(payload: {
|
|
462
|
-
requestId: string;
|
|
463
|
-
data: string;
|
|
464
|
-
isBinary: boolean;
|
|
465
|
-
}): void {
|
|
466
|
-
try {
|
|
467
|
-
const pending = pendingWsSockets.get(payload.requestId);
|
|
468
|
-
if (!pending || pending.ws.readyState !== 1) return;
|
|
469
|
-
const buf = Buffer.from(payload.data, "base64");
|
|
470
|
-
pending.ws.send(payload.isBinary ? buf : buf.toString("utf8"));
|
|
471
|
-
} catch {}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
export function handleTunnelWsClose(payload: {
|
|
475
|
-
requestId: string;
|
|
476
|
-
code?: number;
|
|
477
|
-
reason?: string;
|
|
478
|
-
}): void {
|
|
479
|
-
try {
|
|
480
|
-
const pending = pendingWsSockets.get(payload.requestId);
|
|
481
|
-
if (!pending) return;
|
|
482
|
-
pendingWsSockets.delete(payload.requestId);
|
|
483
|
-
if (pending.ws.readyState === 1) {
|
|
484
|
-
const code = typeof payload.code === "number" && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
|
|
485
|
-
pending.ws.close(code, payload.reason ?? "");
|
|
486
|
-
}
|
|
487
|
-
} catch {}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
export function registerTunnelWs(requestId: string, ws: WebSocket): void {
|
|
491
|
-
pendingWsSockets.set(requestId, { ws });
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
export function removeTunnelWs(requestId: string): void {
|
|
495
|
-
pendingWsSockets.delete(requestId);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function cleanupSessionTunnels(sessionId: string): void {
|
|
499
|
-
const requestIds = sessionRequests.get(sessionId);
|
|
500
|
-
if (!requestIds) return;
|
|
501
|
-
for (const rid of requestIds) {
|
|
502
|
-
const pending = pendingRequests.get(rid);
|
|
503
|
-
if (pending) {
|
|
504
|
-
clearTimeout(pending.timeout);
|
|
505
|
-
errorResponse(pending.res, 502, "Host disconnected");
|
|
506
|
-
pendingRequests.delete(rid);
|
|
507
|
-
}
|
|
508
|
-
const ws = pendingWsSockets.get(rid);
|
|
509
|
-
if (ws) {
|
|
510
|
-
ws.ws.close(1001, "Host disconnected");
|
|
511
|
-
pendingWsSockets.delete(rid);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
sessionRequests.delete(sessionId);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
export async function handleTunnelWsUpgrade(
|
|
518
|
-
ws: WebSocket,
|
|
519
|
-
parsed: { sessionId: string; port: number; path: string },
|
|
520
|
-
url: URL,
|
|
521
|
-
sessions: SessionManager,
|
|
522
|
-
tokens: TokenManager,
|
|
523
|
-
preAuthToken?: string,
|
|
524
|
-
): Promise<void> {
|
|
525
|
-
const { sessionId, port, path } = parsed;
|
|
526
|
-
|
|
527
|
-
// Auth: device token OR Supabase JWT (userId owns session)
|
|
528
|
-
let token = preAuthToken || url.searchParams.get("token");
|
|
529
|
-
let tokenOwns = Boolean(token && await tokens.ownsFresh(token, sessionId));
|
|
530
|
-
let authOwns = false;
|
|
531
|
-
let authDiagnostic = token ? "token_not_bound" : "missing_token";
|
|
532
|
-
if (!tokenOwns && AUTH_REQUIRED) {
|
|
533
|
-
// Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
|
|
534
|
-
const authToken = url.searchParams.get("auth_token") || token;
|
|
535
|
-
if (authToken) {
|
|
536
|
-
try {
|
|
537
|
-
const SUPABASE_URL = process.env.SUPABASE_URL ?? "";
|
|
538
|
-
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ?? "";
|
|
539
|
-
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
|
|
540
|
-
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
|
541
|
-
headers: { Authorization: `Bearer ${authToken}`, apikey: SUPABASE_ANON_KEY },
|
|
542
|
-
signal: AbortSignal.timeout(5_000),
|
|
543
|
-
});
|
|
544
|
-
if (userRes.ok) {
|
|
545
|
-
const user = (await userRes.json()) as { id: string };
|
|
546
|
-
const session = sessions.get(sessionId);
|
|
547
|
-
if (user.id && session?.userId && user.id === session.userId) {
|
|
548
|
-
authOwns = true;
|
|
549
|
-
} else if (!session) {
|
|
550
|
-
authDiagnostic = "session_not_local";
|
|
551
|
-
} else if (!session.userId) {
|
|
552
|
-
authDiagnostic = "session_user_missing";
|
|
553
|
-
} else {
|
|
554
|
-
authDiagnostic = "session_user_mismatch";
|
|
555
|
-
}
|
|
556
|
-
} else {
|
|
557
|
-
authDiagnostic = "invalid_jwt";
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
} catch {
|
|
561
|
-
authDiagnostic = "jwt_check_failed";
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
if (!tokenOwns && authOwns) {
|
|
566
|
-
const boundToken = bindExplicitDeviceToken(tokens, sessionId, url, preAuthToken);
|
|
567
|
-
if (boundToken) {
|
|
568
|
-
token = boundToken;
|
|
569
|
-
tokenOwns = true;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (!tokenOwns && !authOwns) {
|
|
573
|
-
ws.close(4001, `Unauthorized:${authDiagnostic}`);
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const session = sessions.get(sessionId);
|
|
578
|
-
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
579
|
-
ws.close(4002, "Host not connected");
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const requestId = randomUUID();
|
|
584
|
-
const fullUrl = forwardedUrl(path, url);
|
|
585
|
-
|
|
586
|
-
// Register this WS so host responses route here
|
|
587
|
-
registerTunnelWs(requestId, ws);
|
|
588
|
-
trackRequest(sessionId, requestId);
|
|
589
|
-
|
|
590
|
-
// Send tunnel.request with upgrade header to host
|
|
591
|
-
const envelope = createEnvelope({
|
|
592
|
-
type: "tunnel.request",
|
|
593
|
-
sessionId,
|
|
594
|
-
payload: {
|
|
595
|
-
requestId,
|
|
596
|
-
method: "GET",
|
|
597
|
-
url: fullUrl,
|
|
598
|
-
headers: { "upgrade": "websocket" },
|
|
599
|
-
body: null,
|
|
600
|
-
port,
|
|
601
|
-
},
|
|
602
|
-
});
|
|
603
|
-
session.host.socket.send(serializeEnvelope(envelope));
|
|
604
|
-
|
|
605
|
-
// Forward data from browser WS to host
|
|
606
|
-
ws.on("message", (data: Buffer | string) => {
|
|
607
|
-
try {
|
|
608
|
-
const s = sessions.get(sessionId);
|
|
609
|
-
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
610
|
-
const isBinary = typeof data !== "string";
|
|
611
|
-
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
612
|
-
const fwd = createEnvelope({
|
|
613
|
-
type: "tunnel.ws.data",
|
|
614
|
-
sessionId,
|
|
615
|
-
payload: {
|
|
616
|
-
requestId,
|
|
617
|
-
data: buf.toString("base64"),
|
|
618
|
-
isBinary,
|
|
619
|
-
},
|
|
620
|
-
});
|
|
621
|
-
s.host.socket.send(serializeEnvelope(fwd));
|
|
622
|
-
} catch {}
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
ws.on("close", (code, reason) => {
|
|
626
|
-
try {
|
|
627
|
-
removeTunnelWs(requestId);
|
|
628
|
-
untrackRequest(sessionId, requestId);
|
|
629
|
-
const s = sessions.get(sessionId);
|
|
630
|
-
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
631
|
-
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
632
|
-
const fwd = createEnvelope({
|
|
633
|
-
type: "tunnel.ws.close",
|
|
634
|
-
sessionId,
|
|
635
|
-
payload: {
|
|
636
|
-
requestId,
|
|
637
|
-
code: safeCode,
|
|
638
|
-
reason: reason?.toString() || "",
|
|
639
|
-
},
|
|
640
|
-
});
|
|
641
|
-
s.host.socket.send(serializeEnvelope(fwd));
|
|
642
|
-
} catch {}
|
|
643
|
-
});
|
|
644
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|