@linkshell/gateway 0.2.21 → 0.2.23
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/dist/gateway/src/auth-middleware.d.ts +31 -0
- package/dist/gateway/src/auth-middleware.js +154 -0
- package/dist/gateway/src/auth-middleware.js.map +1 -0
- package/dist/gateway/src/index.js +92 -0
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +2 -0
- package/dist/gateway/src/sessions.js +2 -0
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/tunnel.js +77 -61
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +70 -36
- package/dist/shared-protocol/src/index.js +10 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth-middleware.ts +195 -0
- package/src/index.ts +104 -0
- package/src/sessions.ts +4 -0
- package/src/tunnel.ts +69 -58
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
handleTunnelRequest,
|
|
20
20
|
cleanupSessionTunnels,
|
|
21
21
|
} from "./tunnel.js";
|
|
22
|
+
import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId } from "./auth-middleware.js";
|
|
22
23
|
|
|
23
24
|
const port = Number(process.env.PORT ?? 8787);
|
|
24
25
|
const logLevel = (process.env.LOG_LEVEL ?? "info") as
|
|
@@ -173,6 +174,28 @@ async function handleRequest(
|
|
|
173
174
|
return;
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
// Sessions owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
|
|
178
|
+
if (method === "GET" && url.pathname === "/sessions/mine") {
|
|
179
|
+
const authResult = await validateRequest(req);
|
|
180
|
+
if (!authResult || !authResult.userId) {
|
|
181
|
+
json(res, 401, { error: "auth_required", message: "Authentication required" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const sessions = sessionManager
|
|
185
|
+
.listActive()
|
|
186
|
+
.filter((s) => s.userId === authResult.userId)
|
|
187
|
+
.map((s) => sessionManager.getSummary(s.id))
|
|
188
|
+
.filter(Boolean);
|
|
189
|
+
json(res, 200, { sessions });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Auth check for premium gateway (skip healthz and /sessions/mine)
|
|
194
|
+
if (AUTH_REQUIRED) {
|
|
195
|
+
const authResult = await requireAuth(req, res);
|
|
196
|
+
if (!authResult) return; // response already sent
|
|
197
|
+
}
|
|
198
|
+
|
|
176
199
|
// Create pairing
|
|
177
200
|
if (method === "POST" && url.pathname === "/pairings") {
|
|
178
201
|
if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
|
|
@@ -345,6 +368,27 @@ server.on("upgrade", (request, socket, head) => {
|
|
|
345
368
|
return;
|
|
346
369
|
}
|
|
347
370
|
|
|
371
|
+
// Auth check for premium gateway WebSocket connections
|
|
372
|
+
if (AUTH_REQUIRED) {
|
|
373
|
+
checkWsAuth(request).then((authResult) => {
|
|
374
|
+
if (!authResult || !authResult.authenticated) {
|
|
375
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
376
|
+
socket.destroy();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!authResult.subscribed) {
|
|
380
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nPro subscription required. Subscribe at https://itool.tech\r\n");
|
|
381
|
+
socket.destroy();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
(request as any).__authResult = authResult;
|
|
385
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
386
|
+
wss.emit("connection", ws, request, url);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
348
392
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
349
393
|
wss.emit("connection", ws, request, url);
|
|
350
394
|
});
|
|
@@ -386,6 +430,15 @@ wss.on(
|
|
|
386
430
|
existingSession.clients.size > 0 &&
|
|
387
431
|
existingSession.state === "host_disconnected";
|
|
388
432
|
sessionManager.setHost(sessionId, device);
|
|
433
|
+
|
|
434
|
+
// Associate userId from auth (for AUTH_REQUIRED gateways)
|
|
435
|
+
const authResult = (_request as any).__authResult as
|
|
436
|
+
| { userId?: string }
|
|
437
|
+
| undefined;
|
|
438
|
+
if (authResult?.userId) {
|
|
439
|
+
const session = sessionManager.get(sessionId);
|
|
440
|
+
if (session) session.userId = authResult.userId;
|
|
441
|
+
}
|
|
389
442
|
if (isReconnect) {
|
|
390
443
|
const notification = serializeEnvelope(
|
|
391
444
|
createEnvelope({
|
|
@@ -509,6 +562,57 @@ function shutdown() {
|
|
|
509
562
|
process.on("SIGINT", shutdown);
|
|
510
563
|
process.on("SIGTERM", shutdown);
|
|
511
564
|
|
|
565
|
+
// ── Subscription expiry checker (AUTH_REQUIRED gateways only) ──────
|
|
566
|
+
|
|
567
|
+
const SUB_CHECK_INTERVAL = 5 * 60_000; // 5 minutes
|
|
568
|
+
|
|
569
|
+
if (AUTH_REQUIRED) {
|
|
570
|
+
setInterval(async () => {
|
|
571
|
+
for (const session of sessionManager.listActive()) {
|
|
572
|
+
if (!session.userId || !session.host) continue;
|
|
573
|
+
const stillSubscribed = await checkSubscriptionByUserId(session.userId);
|
|
574
|
+
if (!stillSubscribed) {
|
|
575
|
+
log("info", `subscription expired for user ${session.userId}, disconnecting session ${session.id}`);
|
|
576
|
+
// Notify host
|
|
577
|
+
try {
|
|
578
|
+
session.host.socket.send(
|
|
579
|
+
serializeEnvelope(
|
|
580
|
+
createEnvelope({
|
|
581
|
+
type: "session.error",
|
|
582
|
+
sessionId: session.id,
|
|
583
|
+
payload: {
|
|
584
|
+
code: "subscription_expired",
|
|
585
|
+
message: "Your Pro subscription has expired. Renew at https://itool.tech",
|
|
586
|
+
},
|
|
587
|
+
}),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
} catch {}
|
|
591
|
+
// Close host connection
|
|
592
|
+
session.host.socket.close(4003, "subscription_expired");
|
|
593
|
+
// Notify clients
|
|
594
|
+
for (const [, client] of session.clients) {
|
|
595
|
+
try {
|
|
596
|
+
client.socket.send(
|
|
597
|
+
serializeEnvelope(
|
|
598
|
+
createEnvelope({
|
|
599
|
+
type: "session.error",
|
|
600
|
+
sessionId: session.id,
|
|
601
|
+
payload: {
|
|
602
|
+
code: "subscription_expired",
|
|
603
|
+
message: "Host subscription expired. Session ended.",
|
|
604
|
+
},
|
|
605
|
+
}),
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
client.socket.close(4003, "subscription_expired");
|
|
609
|
+
} catch {}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}, SUB_CHECK_INTERVAL);
|
|
614
|
+
}
|
|
615
|
+
|
|
512
616
|
// ── Start ───────────────────────────────────────────────────────────
|
|
513
617
|
|
|
514
618
|
server.listen(port, () => {
|
package/src/sessions.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface Session {
|
|
|
27
27
|
platform: string | undefined;
|
|
28
28
|
cwd: string | undefined;
|
|
29
29
|
projectName: string | undefined;
|
|
30
|
+
// Auth: user who owns this session (set on AUTH_REQUIRED gateways)
|
|
31
|
+
userId: string | undefined;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const OUTPUT_BUFFER_CAPACITY = 200;
|
|
@@ -60,6 +62,7 @@ export class SessionManager {
|
|
|
60
62
|
platform: undefined,
|
|
61
63
|
cwd: undefined,
|
|
62
64
|
projectName: undefined,
|
|
65
|
+
userId: undefined,
|
|
63
66
|
};
|
|
64
67
|
this.sessions.set(sessionId, session);
|
|
65
68
|
}
|
|
@@ -194,6 +197,7 @@ export class SessionManager {
|
|
|
194
197
|
platform: session.platform ?? null,
|
|
195
198
|
cwd: session.cwd ?? null,
|
|
196
199
|
projectName: session.projectName ?? null,
|
|
200
|
+
userId: session.userId ?? null,
|
|
197
201
|
};
|
|
198
202
|
}
|
|
199
203
|
|
package/src/tunnel.ts
CHANGED
|
@@ -209,29 +209,29 @@ export function handleTunnelResponse(payload: {
|
|
|
209
209
|
body: string;
|
|
210
210
|
isFinal: boolean;
|
|
211
211
|
}): void {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
212
|
+
try {
|
|
213
|
+
const pending = pendingRequests.get(payload.requestId);
|
|
214
|
+
if (!pending) return;
|
|
215
|
+
|
|
216
|
+
if (!pending.headersSent) {
|
|
217
|
+
const responseHeaders: Record<string, string> = {
|
|
218
|
+
...payload.headers,
|
|
219
|
+
"access-control-allow-origin": "*",
|
|
220
|
+
};
|
|
221
|
+
pending.res.writeHead(payload.statusCode, responseHeaders);
|
|
222
|
+
pending.headersSent = true;
|
|
223
|
+
}
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
225
|
+
if (payload.body) {
|
|
226
|
+
pending.res.write(Buffer.from(payload.body, "base64"));
|
|
227
|
+
}
|
|
229
228
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
229
|
+
if (payload.isFinal) {
|
|
230
|
+
clearTimeout(pending.timeout);
|
|
231
|
+
pendingRequests.delete(payload.requestId);
|
|
232
|
+
pending.res.end();
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
export function handleTunnelWsData(payload: {
|
|
@@ -239,10 +239,12 @@ export function handleTunnelWsData(payload: {
|
|
|
239
239
|
data: string;
|
|
240
240
|
isBinary: boolean;
|
|
241
241
|
}): void {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
try {
|
|
243
|
+
const pending = pendingWsSockets.get(payload.requestId);
|
|
244
|
+
if (!pending || pending.ws.readyState !== 1) return;
|
|
245
|
+
const buf = Buffer.from(payload.data, "base64");
|
|
246
|
+
pending.ws.send(payload.isBinary ? buf : buf.toString("utf8"));
|
|
247
|
+
} catch {}
|
|
246
248
|
}
|
|
247
249
|
|
|
248
250
|
export function handleTunnelWsClose(payload: {
|
|
@@ -250,11 +252,15 @@ export function handleTunnelWsClose(payload: {
|
|
|
250
252
|
code?: number;
|
|
251
253
|
reason?: string;
|
|
252
254
|
}): void {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
try {
|
|
256
|
+
const pending = pendingWsSockets.get(payload.requestId);
|
|
257
|
+
if (!pending) return;
|
|
258
|
+
pendingWsSockets.delete(payload.requestId);
|
|
259
|
+
if (pending.ws.readyState === 1) {
|
|
260
|
+
const code = typeof payload.code === "number" && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
|
|
261
|
+
pending.ws.close(code, payload.reason ?? "");
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
export function registerTunnelWs(requestId: string, ws: WebSocket): void {
|
|
@@ -330,36 +336,41 @@ export function handleTunnelWsUpgrade(
|
|
|
330
336
|
|
|
331
337
|
// Forward data from browser WS to host
|
|
332
338
|
ws.on("message", (data: Buffer | string) => {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
339
|
+
try {
|
|
340
|
+
const s = sessions.get(sessionId);
|
|
341
|
+
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
342
|
+
const isBinary = typeof data !== "string";
|
|
343
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
344
|
+
const fwd = createEnvelope({
|
|
345
|
+
type: "tunnel.ws.data",
|
|
346
|
+
sessionId,
|
|
347
|
+
payload: {
|
|
348
|
+
requestId,
|
|
349
|
+
data: buf.toString("base64"),
|
|
350
|
+
isBinary,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
s.host.socket.send(serializeEnvelope(fwd));
|
|
354
|
+
} catch {}
|
|
347
355
|
});
|
|
348
356
|
|
|
349
357
|
ws.on("close", (code, reason) => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
358
|
+
try {
|
|
359
|
+
removeTunnelWs(requestId);
|
|
360
|
+
untrackRequest(sessionId, requestId);
|
|
361
|
+
const s = sessions.get(sessionId);
|
|
362
|
+
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
363
|
+
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
364
|
+
const fwd = createEnvelope({
|
|
365
|
+
type: "tunnel.ws.close",
|
|
366
|
+
sessionId,
|
|
367
|
+
payload: {
|
|
368
|
+
requestId,
|
|
369
|
+
code: safeCode,
|
|
370
|
+
reason: reason?.toString() || "",
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
s.host.socket.send(serializeEnvelope(fwd));
|
|
374
|
+
} catch {}
|
|
364
375
|
});
|
|
365
376
|
}
|