@linkshell/gateway 0.2.12 → 0.2.13
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/package.json +1 -1
- package/src/embedded.ts +60 -15
- package/src/index.ts +59 -15
- package/src/tokens.ts +87 -0
package/package.json
CHANGED
package/src/embedded.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { z, ZodError } from "zod";
|
|
12
12
|
import { SessionManager } from "./sessions.js";
|
|
13
13
|
import { PairingManager } from "./pairings.js";
|
|
14
|
+
import { TokenManager } from "./tokens.js";
|
|
14
15
|
import { handleSocketMessage } from "./relay.js";
|
|
15
16
|
|
|
16
17
|
export interface EmbeddedGatewayOptions {
|
|
@@ -33,7 +34,10 @@ const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image upl
|
|
|
33
34
|
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
34
35
|
|
|
35
36
|
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
36
|
-
const claimPairingBody = z.object({
|
|
37
|
+
const claimPairingBody = z.object({
|
|
38
|
+
pairingCode: z.string().length(6),
|
|
39
|
+
deviceToken: z.string().min(1).optional(),
|
|
40
|
+
});
|
|
37
41
|
|
|
38
42
|
class BodyTooLargeError extends Error {}
|
|
39
43
|
|
|
@@ -66,6 +70,13 @@ function getClientIp(req: IncomingMessage): string {
|
|
|
66
70
|
return req.socket.remoteAddress ?? "unknown";
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
function extractBearerToken(req: IncomingMessage): string | null {
|
|
74
|
+
const auth = req.headers.authorization;
|
|
75
|
+
if (!auth) return null;
|
|
76
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
77
|
+
return match?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
/**
|
|
70
81
|
* Start an embedded gateway. Returns a handle to get URLs and close it.
|
|
71
82
|
* Used by CLI when no external --gateway is provided.
|
|
@@ -86,6 +97,7 @@ export function startEmbeddedGateway(
|
|
|
86
97
|
|
|
87
98
|
const sessionManager = new SessionManager();
|
|
88
99
|
const pairingManager = new PairingManager();
|
|
100
|
+
const tokenManager = new TokenManager();
|
|
89
101
|
|
|
90
102
|
const server = createServer(async (req, res) => {
|
|
91
103
|
if (req.method === "OPTIONS") {
|
|
@@ -126,30 +138,53 @@ export function startEmbeddedGateway(
|
|
|
126
138
|
json(res, result.status, { error: result.error });
|
|
127
139
|
return;
|
|
128
140
|
}
|
|
129
|
-
|
|
141
|
+
const token = tokenManager.register(body.deviceToken);
|
|
142
|
+
tokenManager.bind(token, result.sessionId);
|
|
143
|
+
json(res, 200, { sessionId: result.sessionId, deviceToken: token });
|
|
130
144
|
return;
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
if (method === "GET" && url.pathname === "/sessions") {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
const token = extractBearerToken(req);
|
|
149
|
+
if (!token || !tokenManager.validate(token)) {
|
|
150
|
+
json(res, 401, {
|
|
151
|
+
error: "unauthorized",
|
|
152
|
+
message: "Valid device token required",
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const allowedIds = tokenManager.getSessionIds(token);
|
|
157
|
+
const sessions = sessionManager
|
|
158
|
+
.listActive()
|
|
159
|
+
.filter((s) => allowedIds.has(s.id))
|
|
160
|
+
.map((s) => ({
|
|
161
|
+
id: s.id,
|
|
162
|
+
state: s.state,
|
|
163
|
+
hasHost: !!s.host,
|
|
164
|
+
clientCount: s.clients.size,
|
|
165
|
+
controllerId: s.controllerId ?? null,
|
|
166
|
+
lastActivity: s.lastActivity,
|
|
167
|
+
createdAt: s.createdAt,
|
|
168
|
+
provider: s.provider ?? null,
|
|
169
|
+
hostname: s.hostname ?? null,
|
|
170
|
+
platform: s.platform ?? null,
|
|
171
|
+
}));
|
|
146
172
|
json(res, 200, { sessions });
|
|
147
173
|
return;
|
|
148
174
|
}
|
|
149
175
|
|
|
150
176
|
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
151
177
|
if (method === "GET" && sessionMatch) {
|
|
152
|
-
const
|
|
178
|
+
const token = extractBearerToken(req);
|
|
179
|
+
const targetId = sessionMatch[1]!;
|
|
180
|
+
if (!token || !tokenManager.owns(token, targetId)) {
|
|
181
|
+
json(res, 401, {
|
|
182
|
+
error: "unauthorized",
|
|
183
|
+
message: "Valid device token required",
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const summary = sessionManager.getSummary(targetId);
|
|
153
188
|
if (!summary) {
|
|
154
189
|
json(res, 404, { error: "session_not_found" });
|
|
155
190
|
return;
|
|
@@ -221,6 +256,15 @@ export function startEmbeddedGateway(
|
|
|
221
256
|
}
|
|
222
257
|
|
|
223
258
|
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
259
|
+
|
|
260
|
+
if (role === "client") {
|
|
261
|
+
const token = url.searchParams.get("token");
|
|
262
|
+
if (!token || !tokenManager.owns(token, sessionId)) {
|
|
263
|
+
socket.close(4001, "unauthorized");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
224
268
|
const device = { socket, role, deviceId, connectedAt: Date.now() };
|
|
225
269
|
|
|
226
270
|
if (role === "host") {
|
|
@@ -318,6 +362,7 @@ export function startEmbeddedGateway(
|
|
|
318
362
|
wss.clients.forEach((ws) => ws.close(1001, "shutting down"));
|
|
319
363
|
sessionManager.destroy();
|
|
320
364
|
pairingManager.destroy();
|
|
365
|
+
tokenManager.destroy();
|
|
321
366
|
server.close(() => res());
|
|
322
367
|
}),
|
|
323
368
|
});
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { z, ZodError } from "zod";
|
|
12
12
|
import { SessionManager } from "./sessions.js";
|
|
13
13
|
import { PairingManager } from "./pairings.js";
|
|
14
|
+
import { TokenManager } from "./tokens.js";
|
|
14
15
|
import { handleSocketMessage } from "./relay.js";
|
|
15
16
|
|
|
16
17
|
const port = Number(process.env.PORT ?? 8787);
|
|
@@ -29,6 +30,7 @@ function log(level: "debug" | "info" | "warn" | "error", msg: string): void {
|
|
|
29
30
|
|
|
30
31
|
const sessionManager = new SessionManager();
|
|
31
32
|
const pairingManager = new PairingManager();
|
|
33
|
+
const tokenManager = new TokenManager();
|
|
32
34
|
|
|
33
35
|
const PING_INTERVAL = 20_000;
|
|
34
36
|
const MAX_BODY_SIZE = 4096;
|
|
@@ -93,6 +95,13 @@ function getClientIp(req: IncomingMessage): string {
|
|
|
93
95
|
return req.socket.remoteAddress ?? "unknown";
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
function extractBearerToken(req: IncomingMessage): string | null {
|
|
99
|
+
const auth = req.headers.authorization;
|
|
100
|
+
if (!auth) return null;
|
|
101
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
102
|
+
return match?.[1] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
// ── CORS ────────────────────────────────────────────────────────────
|
|
97
106
|
|
|
98
107
|
function setCors(res: ServerResponse): void {
|
|
@@ -105,7 +114,10 @@ function setCors(res: ServerResponse): void {
|
|
|
105
114
|
// ── HTTP API ────────────────────────────────────────────────────────
|
|
106
115
|
|
|
107
116
|
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
108
|
-
const claimPairingBody = z.object({
|
|
117
|
+
const claimPairingBody = z.object({
|
|
118
|
+
pairingCode: z.string().length(6),
|
|
119
|
+
deviceToken: z.string().min(1).optional(),
|
|
120
|
+
});
|
|
109
121
|
|
|
110
122
|
const server = createServer(async (req, res) => {
|
|
111
123
|
setCors(res);
|
|
@@ -183,24 +195,38 @@ async function handleRequest(
|
|
|
183
195
|
json(res, result.status, { error: result.error });
|
|
184
196
|
return;
|
|
185
197
|
}
|
|
186
|
-
|
|
198
|
+
const token = tokenManager.register(body.deviceToken);
|
|
199
|
+
tokenManager.bind(token, result.sessionId);
|
|
200
|
+
json(res, 200, { sessionId: result.sessionId, deviceToken: token });
|
|
187
201
|
return;
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
// Session list
|
|
191
205
|
if (method === "GET" && url.pathname === "/sessions") {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
206
|
+
const token = extractBearerToken(req);
|
|
207
|
+
if (!token || !tokenManager.validate(token)) {
|
|
208
|
+
json(res, 401, {
|
|
209
|
+
error: "unauthorized",
|
|
210
|
+
message: "Valid device token required",
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const allowedIds = tokenManager.getSessionIds(token);
|
|
215
|
+
const sessions = sessionManager
|
|
216
|
+
.listActive()
|
|
217
|
+
.filter((s) => allowedIds.has(s.id))
|
|
218
|
+
.map((s) => ({
|
|
219
|
+
id: s.id,
|
|
220
|
+
state: s.state,
|
|
221
|
+
hasHost: !!s.host,
|
|
222
|
+
clientCount: s.clients.size,
|
|
223
|
+
controllerId: s.controllerId ?? null,
|
|
224
|
+
lastActivity: s.lastActivity,
|
|
225
|
+
createdAt: s.createdAt,
|
|
226
|
+
provider: s.provider ?? null,
|
|
227
|
+
hostname: s.hostname ?? null,
|
|
228
|
+
platform: s.platform ?? null,
|
|
229
|
+
}));
|
|
204
230
|
json(res, 200, { sessions });
|
|
205
231
|
return;
|
|
206
232
|
}
|
|
@@ -208,7 +234,16 @@ async function handleRequest(
|
|
|
208
234
|
// Session detail
|
|
209
235
|
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
210
236
|
if (method === "GET" && sessionMatch) {
|
|
211
|
-
const
|
|
237
|
+
const token = extractBearerToken(req);
|
|
238
|
+
const targetId = sessionMatch[1]!;
|
|
239
|
+
if (!token || !tokenManager.owns(token, targetId)) {
|
|
240
|
+
json(res, 401, {
|
|
241
|
+
error: "unauthorized",
|
|
242
|
+
message: "Valid device token required",
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const summary = sessionManager.getSummary(targetId);
|
|
212
247
|
if (!summary) {
|
|
213
248
|
json(res, 404, { error: "session_not_found" });
|
|
214
249
|
return;
|
|
@@ -271,6 +306,14 @@ wss.on(
|
|
|
271
306
|
|
|
272
307
|
const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
|
|
273
308
|
|
|
309
|
+
if (role === "client") {
|
|
310
|
+
const token = url.searchParams.get("token");
|
|
311
|
+
if (!token || !tokenManager.owns(token, sessionId)) {
|
|
312
|
+
socket.close(4001, "unauthorized");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
274
317
|
const device = {
|
|
275
318
|
socket,
|
|
276
319
|
role,
|
|
@@ -397,6 +440,7 @@ function shutdown() {
|
|
|
397
440
|
wss.clients.forEach((ws) => ws.close(1001, "server shutting down"));
|
|
398
441
|
sessionManager.destroy();
|
|
399
442
|
pairingManager.destroy();
|
|
443
|
+
tokenManager.destroy();
|
|
400
444
|
server.close(() => {
|
|
401
445
|
process.stdout.write("[gateway] stopped\n");
|
|
402
446
|
process.exit(0);
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const CLEANUP_INTERVAL = 5 * 60_000;
|
|
4
|
+
const SESSION_TTL = 7 * 24 * 60 * 60_000; // 7 days — prune stale bindings
|
|
5
|
+
|
|
6
|
+
interface TokenRecord {
|
|
7
|
+
token: string;
|
|
8
|
+
sessionIds: Set<string>;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
lastUsedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TokenManager {
|
|
14
|
+
private tokens = new Map<string, TokenRecord>();
|
|
15
|
+
private sessionToToken = new Map<string, string>();
|
|
16
|
+
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
register(deviceToken?: string): string {
|
|
23
|
+
if (deviceToken && this.tokens.has(deviceToken)) {
|
|
24
|
+
const record = this.tokens.get(deviceToken)!;
|
|
25
|
+
record.lastUsedAt = Date.now();
|
|
26
|
+
return deviceToken;
|
|
27
|
+
}
|
|
28
|
+
const token = deviceToken || randomUUID();
|
|
29
|
+
this.tokens.set(token, {
|
|
30
|
+
token,
|
|
31
|
+
sessionIds: new Set(),
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
lastUsedAt: Date.now(),
|
|
34
|
+
});
|
|
35
|
+
return token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
bind(token: string, sessionId: string): boolean {
|
|
39
|
+
const record = this.tokens.get(token);
|
|
40
|
+
if (!record) return false;
|
|
41
|
+
record.sessionIds.add(sessionId);
|
|
42
|
+
record.lastUsedAt = Date.now();
|
|
43
|
+
this.sessionToToken.set(sessionId, token);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
validate(token: string): boolean {
|
|
48
|
+
const record = this.tokens.get(token);
|
|
49
|
+
if (!record) return false;
|
|
50
|
+
record.lastUsedAt = Date.now();
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
owns(token: string, sessionId: string): boolean {
|
|
55
|
+
const record = this.tokens.get(token);
|
|
56
|
+
if (!record) return false;
|
|
57
|
+
record.lastUsedAt = Date.now();
|
|
58
|
+
return record.sessionIds.has(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getSessionIds(token: string): Set<string> {
|
|
62
|
+
const record = this.tokens.get(token);
|
|
63
|
+
if (!record) return new Set();
|
|
64
|
+
record.lastUsedAt = Date.now();
|
|
65
|
+
return record.sessionIds;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getTokenForSession(sessionId: string): string | undefined {
|
|
69
|
+
return this.sessionToToken.get(sessionId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private cleanup(): void {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [token, record] of this.tokens) {
|
|
75
|
+
if (now - record.lastUsedAt > SESSION_TTL) {
|
|
76
|
+
for (const sid of record.sessionIds) {
|
|
77
|
+
this.sessionToToken.delete(sid);
|
|
78
|
+
}
|
|
79
|
+
this.tokens.delete(token);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
destroy(): void {
|
|
85
|
+
clearInterval(this.cleanupTimer);
|
|
86
|
+
}
|
|
87
|
+
}
|