@linkshell/gateway 0.3.9 → 0.4.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 +1 -3
- package/README.md +13 -14
- package/dist/gateway/src/agent-permission-http.d.ts +74 -19
- package/dist/gateway/src/agent-permission-http.js +56 -16
- package/dist/gateway/src/agent-permission-http.js.map +1 -1
- package/dist/gateway/src/embedded.js +61 -153
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +98 -193
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +3 -3
- package/dist/gateway/src/pairings.js +5 -4
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +2 -2
- package/dist/gateway/src/relay.js +85 -161
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +28 -42
- package/dist/gateway/src/sessions.js +145 -200
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +6 -9
- package/dist/gateway/src/state-store.js +19 -26
- package/dist/gateway/src/state-store.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +7 -27
- package/dist/gateway/src/tokens.js +60 -86
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +13 -11
- package/dist/gateway/src/tunnel.js +36 -36
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +11978 -3423
- package/dist/shared-protocol/src/index.js +114 -163
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +11 -11
- package/src/agent-permission-http.ts +63 -20
- package/src/embedded.ts +60 -158
- package/src/index.ts +98 -199
- package/src/pairings.ts +7 -6
- package/src/relay.ts +97 -193
- package/src/sessions.ts +150 -213
- package/src/state-store.ts +25 -41
- package/src/tokens.ts +63 -109
- package/src/tunnel.ts +43 -49
package/src/embedded.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import type { IncomingMessage, ServerResponse, Server } from "node:http";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
5
4
|
import { WebSocketServer } from "ws";
|
|
6
5
|
import type WebSocket from "ws";
|
|
7
6
|
import {
|
|
@@ -10,7 +9,7 @@ import {
|
|
|
10
9
|
PROTOCOL_VERSION,
|
|
11
10
|
} from "@linkshell/protocol";
|
|
12
11
|
import { z, ZodError } from "zod";
|
|
13
|
-
import {
|
|
12
|
+
import { SessionManager } from "./sessions.js";
|
|
14
13
|
import { PairingManager } from "./pairings.js";
|
|
15
14
|
import { TokenManager } from "./tokens.js";
|
|
16
15
|
import { handleSocketMessage } from "./relay.js";
|
|
@@ -41,18 +40,14 @@ export interface EmbeddedGateway {
|
|
|
41
40
|
|
|
42
41
|
const PING_INTERVAL = 20_000;
|
|
43
42
|
const MAX_BODY_SIZE = 4096;
|
|
44
|
-
const MAX_WS_MESSAGE_SIZE =
|
|
45
|
-
process.env.MAX_WS_MESSAGE_SIZE ?? 16 * 1024 * 1024,
|
|
46
|
-
);
|
|
43
|
+
const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
|
|
47
44
|
|
|
48
45
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
49
46
|
|
|
50
|
-
const createPairingBody = z.object({
|
|
47
|
+
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
51
48
|
const claimPairingBody = z.object({
|
|
52
49
|
pairingCode: z.string().length(6),
|
|
53
50
|
deviceToken: z.string().min(1).optional(),
|
|
54
|
-
clientDeviceId: z.string().min(1).optional(),
|
|
55
|
-
clientName: z.string().min(1).optional(),
|
|
56
51
|
});
|
|
57
52
|
|
|
58
53
|
class BodyTooLargeError extends Error {}
|
|
@@ -111,11 +106,9 @@ export function startEmbeddedGateway(
|
|
|
111
106
|
}
|
|
112
107
|
}
|
|
113
108
|
|
|
114
|
-
const sessionManager = new
|
|
109
|
+
const sessionManager = new SessionManager();
|
|
115
110
|
const pairingManager = new PairingManager();
|
|
116
111
|
const tokenManager = new TokenManager();
|
|
117
|
-
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
|
|
118
|
-
eventLoopDelay.enable();
|
|
119
112
|
|
|
120
113
|
const server = createServer(async (req, res) => {
|
|
121
114
|
if (req.method === "OPTIONS") {
|
|
@@ -134,30 +127,7 @@ export function startEmbeddedGateway(
|
|
|
134
127
|
const method = req.method ?? "GET";
|
|
135
128
|
|
|
136
129
|
if (method === "GET" && url.pathname === "/healthz") {
|
|
137
|
-
|
|
138
|
-
const detailed = url.searchParams.get("detail") === "1" || url.searchParams.get("detailed") === "true";
|
|
139
|
-
json(res, 200, {
|
|
140
|
-
ok: true,
|
|
141
|
-
uptime: Math.round(process.uptime()),
|
|
142
|
-
memory: {
|
|
143
|
-
rss: memory.rss,
|
|
144
|
-
heapUsed: memory.heapUsed,
|
|
145
|
-
heapTotal: memory.heapTotal,
|
|
146
|
-
external: memory.external,
|
|
147
|
-
},
|
|
148
|
-
sessions: sessionManager.getStats(),
|
|
149
|
-
...(detailed ? {
|
|
150
|
-
eventLoop: {
|
|
151
|
-
delayMeanMs: Number((eventLoopDelay.mean / 1_000_000).toFixed(2)),
|
|
152
|
-
delayMaxMs: Number((eventLoopDelay.max / 1_000_000).toFixed(2)),
|
|
153
|
-
delayP99Ms: Number((eventLoopDelay.percentile(99) / 1_000_000).toFixed(2)),
|
|
154
|
-
},
|
|
155
|
-
websocket: {
|
|
156
|
-
maxPayloadBytes: MAX_WS_MESSAGE_SIZE,
|
|
157
|
-
perMessageDeflate: false,
|
|
158
|
-
},
|
|
159
|
-
} : {}),
|
|
160
|
-
});
|
|
130
|
+
json(res, 200, { ok: true });
|
|
161
131
|
return;
|
|
162
132
|
}
|
|
163
133
|
|
|
@@ -167,7 +137,7 @@ export function startEmbeddedGateway(
|
|
|
167
137
|
if (!parsed.success) {
|
|
168
138
|
json(res, 400, {
|
|
169
139
|
error: "invalid_payload",
|
|
170
|
-
message: parsed.error.
|
|
140
|
+
message: parsed.error.errors[0]?.message ?? "Invalid permission response payload",
|
|
171
141
|
});
|
|
172
142
|
return;
|
|
173
143
|
}
|
|
@@ -182,16 +152,16 @@ export function startEmbeddedGateway(
|
|
|
182
152
|
item.terminalId ? `${item.type}:${item.terminalId}` : item.type,
|
|
183
153
|
).join(",") ?? "none";
|
|
184
154
|
const ack = result.ack ? ` resolved=${result.ack.resolved} delivered=${result.ack.delivered}` : "";
|
|
185
|
-
log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol}
|
|
155
|
+
log(result.status === 200 ? "info" : "warn", `agent permission respond protocol=${body.protocol} session=${body.sessionId} request=${body.requestId} status=${result.status} forwarded=${forwarded}${ack}`);
|
|
186
156
|
json(res, result.status, result.body);
|
|
187
157
|
return;
|
|
188
158
|
}
|
|
189
159
|
|
|
190
160
|
if (method === "POST" && url.pathname === "/pairings") {
|
|
191
161
|
const body = createPairingBody.parse(await readJson(req));
|
|
192
|
-
const record = pairingManager.create(body.
|
|
162
|
+
const record = pairingManager.create(body.sessionId);
|
|
193
163
|
json(res, 201, {
|
|
194
|
-
|
|
164
|
+
sessionId: record.sessionId,
|
|
195
165
|
pairingCode: record.pairingCode,
|
|
196
166
|
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
197
167
|
});
|
|
@@ -206,19 +176,12 @@ export function startEmbeddedGateway(
|
|
|
206
176
|
return;
|
|
207
177
|
}
|
|
208
178
|
const token = tokenManager.register(body.deviceToken);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
clientName: body.clientName,
|
|
212
|
-
});
|
|
213
|
-
json(res, 200, {
|
|
214
|
-
hostDeviceId: result.hostDeviceId,
|
|
215
|
-
deviceToken: token,
|
|
216
|
-
authorizationId: authorization?.authorizationId,
|
|
217
|
-
});
|
|
179
|
+
tokenManager.bind(token, result.sessionId);
|
|
180
|
+
json(res, 200, { sessionId: result.sessionId, deviceToken: token });
|
|
218
181
|
return;
|
|
219
182
|
}
|
|
220
183
|
|
|
221
|
-
if (method === "GET" && url.pathname === "/
|
|
184
|
+
if (method === "GET" && url.pathname === "/sessions") {
|
|
222
185
|
const token = extractBearerToken(req);
|
|
223
186
|
if (!token || !tokenManager.validate(token)) {
|
|
224
187
|
json(res, 401, {
|
|
@@ -227,38 +190,33 @@ export function startEmbeddedGateway(
|
|
|
227
190
|
});
|
|
228
191
|
return;
|
|
229
192
|
}
|
|
230
|
-
const allowedIds = tokenManager.
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}),
|
|
251
|
-
authorizationId: tokenManager.getAuthorizationId(token, hostDeviceId) ?? null,
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
json(res, 200, { devices });
|
|
193
|
+
const allowedIds = tokenManager.getSessionIds(token);
|
|
194
|
+
const sessions = sessionManager
|
|
195
|
+
.listActive()
|
|
196
|
+
.filter((s) => allowedIds.has(s.id))
|
|
197
|
+
.map((s) => ({
|
|
198
|
+
id: s.id,
|
|
199
|
+
state: s.state,
|
|
200
|
+
hasHost: !!s.host && s.host.socket.readyState === s.host.socket.OPEN,
|
|
201
|
+
clientCount: s.clients.size,
|
|
202
|
+
controllerId: s.controllerId ?? null,
|
|
203
|
+
lastActivity: s.lastActivity,
|
|
204
|
+
createdAt: s.createdAt,
|
|
205
|
+
provider: s.provider ?? null,
|
|
206
|
+
machineId: s.machineId ?? null,
|
|
207
|
+
hostname: s.hostname ?? null,
|
|
208
|
+
platform: s.platform ?? null,
|
|
209
|
+
cwd: s.cwd ?? null,
|
|
210
|
+
projectName: s.projectName ?? null,
|
|
211
|
+
}));
|
|
212
|
+
json(res, 200, { sessions });
|
|
255
213
|
return;
|
|
256
214
|
}
|
|
257
215
|
|
|
258
|
-
const
|
|
259
|
-
if (method === "GET" &&
|
|
216
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
217
|
+
if (method === "GET" && sessionMatch) {
|
|
260
218
|
const token = extractBearerToken(req);
|
|
261
|
-
const targetId =
|
|
219
|
+
const targetId = sessionMatch[1]!;
|
|
262
220
|
if (!token || !tokenManager.owns(token, targetId)) {
|
|
263
221
|
json(res, 401, {
|
|
264
222
|
error: "unauthorized",
|
|
@@ -268,51 +226,10 @@ export function startEmbeddedGateway(
|
|
|
268
226
|
}
|
|
269
227
|
const summary = sessionManager.getSummary(targetId);
|
|
270
228
|
if (!summary) {
|
|
271
|
-
json(res,
|
|
272
|
-
id: targetId,
|
|
273
|
-
hostDeviceId: targetId,
|
|
274
|
-
state: "host_disconnected",
|
|
275
|
-
online: false,
|
|
276
|
-
hasHost: false,
|
|
277
|
-
clientCount: 0,
|
|
278
|
-
controllerId: null,
|
|
279
|
-
lastActivity: null,
|
|
280
|
-
createdAt: null,
|
|
281
|
-
bufferSize: 0,
|
|
282
|
-
machineId: null,
|
|
283
|
-
hostname: null,
|
|
284
|
-
platform: null,
|
|
285
|
-
cwd: null,
|
|
286
|
-
capabilities: [],
|
|
287
|
-
authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
json(res, 200, {
|
|
292
|
-
...summary,
|
|
293
|
-
authorizationId: tokenManager.getAuthorizationId(token, targetId) ?? null,
|
|
294
|
-
});
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const revokeMatch = url.pathname.match(/^\/devices\/([^/]+)\/authorizations\/([^/]+)$/);
|
|
299
|
-
if (method === "DELETE" && revokeMatch) {
|
|
300
|
-
const token = extractBearerToken(req);
|
|
301
|
-
const hostDeviceId = decodeURIComponent(revokeMatch[1]!);
|
|
302
|
-
const authorizationId = decodeURIComponent(revokeMatch[2]!);
|
|
303
|
-
if (
|
|
304
|
-
!token ||
|
|
305
|
-
tokenManager.getAuthorizationId(token, hostDeviceId) !== authorizationId ||
|
|
306
|
-
!tokenManager.revoke(token, hostDeviceId, authorizationId)
|
|
307
|
-
) {
|
|
308
|
-
json(res, 401, {
|
|
309
|
-
error: "unauthorized",
|
|
310
|
-
message: "Valid device authorization required",
|
|
311
|
-
});
|
|
229
|
+
json(res, 404, { error: "session_not_found" });
|
|
312
230
|
return;
|
|
313
231
|
}
|
|
314
|
-
|
|
315
|
-
json(res, 200, { ok: true });
|
|
232
|
+
json(res, 200, summary);
|
|
316
233
|
return;
|
|
317
234
|
}
|
|
318
235
|
|
|
@@ -338,7 +255,7 @@ export function startEmbeddedGateway(
|
|
|
338
255
|
const tunnelCookie = parseTunnelCookie(req);
|
|
339
256
|
if (tunnelCookie) {
|
|
340
257
|
const fallbackParsed = {
|
|
341
|
-
|
|
258
|
+
sessionId: tunnelCookie.sessionId,
|
|
342
259
|
port: tunnelCookie.port,
|
|
343
260
|
path: url.pathname,
|
|
344
261
|
};
|
|
@@ -351,7 +268,7 @@ export function startEmbeddedGateway(
|
|
|
351
268
|
if (err instanceof ZodError) {
|
|
352
269
|
json(res, 400, {
|
|
353
270
|
error: "invalid_message",
|
|
354
|
-
message: err.
|
|
271
|
+
message: err.errors[0]?.message ?? "Validation failed",
|
|
355
272
|
});
|
|
356
273
|
} else if (err instanceof BodyTooLargeError) {
|
|
357
274
|
json(res, 413, {
|
|
@@ -373,7 +290,6 @@ export function startEmbeddedGateway(
|
|
|
373
290
|
const wss = new WebSocketServer({
|
|
374
291
|
noServer: true,
|
|
375
292
|
maxPayload: MAX_WS_MESSAGE_SIZE,
|
|
376
|
-
perMessageDeflate: false,
|
|
377
293
|
});
|
|
378
294
|
|
|
379
295
|
server.on("upgrade", (request, socket, head) => {
|
|
@@ -392,7 +308,7 @@ export function startEmbeddedGateway(
|
|
|
392
308
|
const tunnelCookie = parseTunnelCookie(request);
|
|
393
309
|
if (tunnelCookie && url.pathname !== "/ws") {
|
|
394
310
|
const fallbackParsed = {
|
|
395
|
-
|
|
311
|
+
sessionId: tunnelCookie.sessionId,
|
|
396
312
|
port: tunnelCookie.port,
|
|
397
313
|
path: url.pathname,
|
|
398
314
|
};
|
|
@@ -415,50 +331,38 @@ export function startEmbeddedGateway(
|
|
|
415
331
|
wss.on(
|
|
416
332
|
"connection",
|
|
417
333
|
(socket: WebSocket, _request: IncomingMessage, url: URL) => {
|
|
418
|
-
const
|
|
334
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
419
335
|
const role = url.searchParams.get("role") as "host" | "client" | null;
|
|
420
336
|
|
|
421
|
-
if (!
|
|
422
|
-
socket.close(1008, "missing
|
|
337
|
+
if (!sessionId || !role || (role !== "host" && role !== "client")) {
|
|
338
|
+
socket.close(1008, "missing sessionId or role");
|
|
423
339
|
return;
|
|
424
340
|
}
|
|
425
341
|
|
|
426
342
|
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
427
343
|
|
|
428
|
-
let clientToken: string | undefined;
|
|
429
|
-
let clientAuthorizationId: string | undefined;
|
|
430
|
-
|
|
431
344
|
if (role === "client") {
|
|
432
345
|
const token = url.searchParams.get("token");
|
|
433
|
-
if (!token || !tokenManager.owns(token,
|
|
346
|
+
if (!token || !tokenManager.owns(token, sessionId)) {
|
|
434
347
|
socket.close(4001, "unauthorized");
|
|
435
348
|
return;
|
|
436
349
|
}
|
|
437
|
-
clientToken = token;
|
|
438
|
-
clientAuthorizationId = tokenManager.getAuthorizationId(token, hostDeviceId);
|
|
439
350
|
}
|
|
440
351
|
|
|
441
|
-
const device = {
|
|
442
|
-
socket,
|
|
443
|
-
role,
|
|
444
|
-
deviceId,
|
|
445
|
-
token: clientToken,
|
|
446
|
-
authorizationId: clientAuthorizationId,
|
|
447
|
-
connectedAt: Date.now(),
|
|
448
|
-
};
|
|
352
|
+
const device = { socket, role, deviceId, connectedAt: Date.now() };
|
|
449
353
|
|
|
450
354
|
if (role === "host") {
|
|
451
|
-
const existingSession = sessionManager.get(
|
|
355
|
+
const existingSession = sessionManager.get(sessionId);
|
|
452
356
|
const isReconnect =
|
|
453
357
|
existingSession &&
|
|
454
358
|
existingSession.clients.size > 0 &&
|
|
455
359
|
existingSession.state === "host_disconnected";
|
|
456
|
-
sessionManager.setHost(
|
|
360
|
+
sessionManager.setHost(sessionId, device);
|
|
457
361
|
if (isReconnect) {
|
|
458
362
|
const notification = serializeEnvelope(
|
|
459
363
|
createEnvelope({
|
|
460
|
-
type: "
|
|
461
|
-
|
|
364
|
+
type: "session.host_reconnected",
|
|
365
|
+
sessionId,
|
|
462
366
|
payload: {},
|
|
463
367
|
}),
|
|
464
368
|
);
|
|
@@ -468,14 +372,14 @@ export function startEmbeddedGateway(
|
|
|
468
372
|
}
|
|
469
373
|
}
|
|
470
374
|
} else {
|
|
471
|
-
sessionManager.addClient(
|
|
375
|
+
sessionManager.addClient(sessionId, device);
|
|
472
376
|
}
|
|
473
377
|
|
|
474
378
|
socket.send(
|
|
475
379
|
serializeEnvelope(
|
|
476
380
|
createEnvelope({
|
|
477
|
-
type: "
|
|
478
|
-
|
|
381
|
+
type: "session.connect",
|
|
382
|
+
sessionId,
|
|
479
383
|
payload: {
|
|
480
384
|
role,
|
|
481
385
|
clientName: deviceId,
|
|
@@ -495,18 +399,18 @@ export function startEmbeddedGateway(
|
|
|
495
399
|
socket,
|
|
496
400
|
data.toString(),
|
|
497
401
|
role,
|
|
498
|
-
|
|
402
|
+
sessionId,
|
|
499
403
|
deviceId,
|
|
500
404
|
sessionManager,
|
|
501
405
|
);
|
|
502
406
|
} catch (err) {
|
|
503
|
-
log("error", `unhandled websocket message error for
|
|
407
|
+
log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
504
408
|
if (socket.readyState === socket.OPEN) {
|
|
505
409
|
socket.send(
|
|
506
410
|
serializeEnvelope(
|
|
507
411
|
createEnvelope({
|
|
508
|
-
type: "
|
|
509
|
-
|
|
412
|
+
type: "session.error",
|
|
413
|
+
sessionId,
|
|
510
414
|
payload: {
|
|
511
415
|
code: "invalid_message",
|
|
512
416
|
message: "Failed to handle message",
|
|
@@ -521,13 +425,12 @@ export function startEmbeddedGateway(
|
|
|
521
425
|
socket.on("close", () => {
|
|
522
426
|
clearInterval(pingTimer);
|
|
523
427
|
if (role === "host") {
|
|
524
|
-
const result = sessionManager.removeHost(
|
|
525
|
-
cleanupSessionTunnels(hostDeviceId);
|
|
428
|
+
const result = sessionManager.removeHost(sessionId);
|
|
526
429
|
if (result) {
|
|
527
430
|
const notification = serializeEnvelope(
|
|
528
431
|
createEnvelope({
|
|
529
|
-
type: "
|
|
530
|
-
|
|
432
|
+
type: "session.host_disconnected",
|
|
433
|
+
sessionId,
|
|
531
434
|
payload: { reason: "host connection closed" },
|
|
532
435
|
}),
|
|
533
436
|
);
|
|
@@ -537,7 +440,7 @@ export function startEmbeddedGateway(
|
|
|
537
440
|
}
|
|
538
441
|
}
|
|
539
442
|
} else {
|
|
540
|
-
sessionManager.removeClient(
|
|
443
|
+
sessionManager.removeClient(sessionId, deviceId);
|
|
541
444
|
}
|
|
542
445
|
});
|
|
543
446
|
|
|
@@ -562,7 +465,6 @@ export function startEmbeddedGateway(
|
|
|
562
465
|
sessionManager.destroy();
|
|
563
466
|
pairingManager.destroy();
|
|
564
467
|
tokenManager.destroy();
|
|
565
|
-
eventLoopDelay.disable();
|
|
566
468
|
server.close(() => res());
|
|
567
469
|
}),
|
|
568
470
|
});
|