@linkshell/gateway 0.4.1 → 0.4.3
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 +11 -2
- package/dist/gateway/src/auth-middleware.d.ts +7 -0
- package/dist/gateway/src/auth-middleware.js +53 -9
- package/dist/gateway/src/auth-middleware.js.map +1 -1
- package/dist/gateway/src/cors.d.ts +2 -0
- package/dist/gateway/src/cors.js +7 -0
- package/dist/gateway/src/cors.js.map +1 -0
- package/dist/gateway/src/embedded.js +14 -2
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/host-auth.d.ts +24 -0
- package/dist/gateway/src/host-auth.js +72 -0
- package/dist/gateway/src/host-auth.js.map +1 -0
- package/dist/gateway/src/index.js +148 -16
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +8 -0
- package/dist/gateway/src/pairings.js +26 -1
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.js +38 -21
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +1 -0
- package/dist/gateway/src/sessions.js +24 -2
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/static-web.d.ts +9 -0
- package/dist/gateway/src/static-web.js +97 -0
- package/dist/gateway/src/static-web.js.map +1 -0
- package/dist/gateway/src/tunnel.js +1 -1
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +3730 -2180
- package/dist/shared-protocol/src/index.js +127 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth-middleware.ts +68 -8
- package/src/cors.ts +8 -0
- package/src/embedded.ts +13 -1
- package/src/host-auth.ts +82 -0
- package/src/index.ts +159 -18
- package/src/pairings.ts +27 -1
- package/src/relay.ts +41 -20
- package/src/sessions.ts +25 -2
- package/src/static-web.ts +101 -0
- package/src/tunnel.ts +1 -1
package/src/host-auth.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const CLEANUP_INTERVAL = 5 * 60_000;
|
|
4
|
+
const HOST_TOKEN_TTL = 7 * 24 * 60 * 60_000; // 7 days
|
|
5
|
+
|
|
6
|
+
interface HostBinding {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
hostToken: string;
|
|
9
|
+
lastUsedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeEqual(a: string, b: string): boolean {
|
|
13
|
+
const ab = Buffer.from(a);
|
|
14
|
+
const bb = Buffer.from(b);
|
|
15
|
+
if (ab.length !== bb.length) return false;
|
|
16
|
+
return timingSafeEqual(ab, bb);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Binds a session to the secret host token issued when its pairing was created,
|
|
21
|
+
* so only the original host (the CLI that created the pairing) can connect as
|
|
22
|
+
* `role=host`. Without this, anyone who learns a sessionId could connect as host
|
|
23
|
+
* and capture every controller keystroke or inject terminal output.
|
|
24
|
+
*
|
|
25
|
+
* Kept separate from PairingManager because a host connection long outlives the
|
|
26
|
+
* pairing code's 10-minute TTL (and reconnects days later), so the binding needs
|
|
27
|
+
* its own longer lifetime.
|
|
28
|
+
*/
|
|
29
|
+
export class HostAuthManager {
|
|
30
|
+
private bindings = new Map<string, HostBinding>();
|
|
31
|
+
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
32
|
+
|
|
33
|
+
constructor() {
|
|
34
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
35
|
+
this.cleanupTimer.unref?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Issue a fresh host token for a session (called at pairing creation). */
|
|
39
|
+
issue(sessionId: string): string {
|
|
40
|
+
const hostToken = randomUUID();
|
|
41
|
+
this.bindings.set(sessionId, { sessionId, hostToken, lastUsedAt: Date.now() });
|
|
42
|
+
return hostToken;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Trust-on-first-use: register a host-provided token when no binding exists
|
|
46
|
+
* yet (e.g. after a gateway restart lost the in-memory binding). */
|
|
47
|
+
adopt(sessionId: string, hostToken: string): void {
|
|
48
|
+
this.bindings.set(sessionId, { sessionId, hostToken, lastUsedAt: Date.now() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
has(sessionId: string): boolean {
|
|
52
|
+
const binding = this.bindings.get(sessionId);
|
|
53
|
+
if (!binding) return false;
|
|
54
|
+
if (Date.now() - binding.lastUsedAt > HOST_TOKEN_TTL) {
|
|
55
|
+
this.bindings.delete(sessionId);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
verify(sessionId: string, hostToken: string | undefined): boolean {
|
|
62
|
+
if (!hostToken) return false;
|
|
63
|
+
const binding = this.bindings.get(sessionId);
|
|
64
|
+
if (!binding) return false;
|
|
65
|
+
if (!safeEqual(binding.hostToken, hostToken)) return false;
|
|
66
|
+
binding.lastUsedAt = Date.now();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private cleanup(): void {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const [sessionId, binding] of this.bindings) {
|
|
73
|
+
if (now - binding.lastUsedAt > HOST_TOKEN_TTL) {
|
|
74
|
+
this.bindings.delete(sessionId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
destroy(): void {
|
|
80
|
+
clearInterval(this.cleanupTimer);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { z, ZodError } from "zod";
|
|
|
12
12
|
import { SessionManager } from "./sessions.js";
|
|
13
13
|
import { PairingManager } from "./pairings.js";
|
|
14
14
|
import { TokenManager } from "./tokens.js";
|
|
15
|
+
import { HostAuthManager } from "./host-auth.js";
|
|
15
16
|
import { createSupabaseStateStore } from "./state-store.js";
|
|
16
17
|
import { handleSocketMessage } from "./relay.js";
|
|
17
18
|
import {
|
|
@@ -24,7 +25,9 @@ import {
|
|
|
24
25
|
handleTunnelRequest,
|
|
25
26
|
cleanupSessionTunnels,
|
|
26
27
|
} from "./tunnel.js";
|
|
27
|
-
import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId } from "./auth-middleware.js";
|
|
28
|
+
import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId, canReadSessionDetail } from "./auth-middleware.js";
|
|
29
|
+
import { setCors } from "./cors.js";
|
|
30
|
+
import { serveWeb, webEnabled, webDistPath } from "./static-web.js";
|
|
28
31
|
|
|
29
32
|
const port = Number(process.env.PORT ?? 8787);
|
|
30
33
|
const logLevel = (process.env.LOG_LEVEL ?? "info") as
|
|
@@ -44,6 +47,7 @@ const stateStore = createSupabaseStateStore();
|
|
|
44
47
|
const sessionManager = new SessionManager();
|
|
45
48
|
const pairingManager = new PairingManager(stateStore);
|
|
46
49
|
const tokenManager = new TokenManager(stateStore);
|
|
50
|
+
const hostAuthManager = new HostAuthManager();
|
|
47
51
|
await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
|
|
48
52
|
|
|
49
53
|
const PING_INTERVAL = 20_000;
|
|
@@ -64,10 +68,18 @@ const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(
|
|
|
64
68
|
|
|
65
69
|
class RateLimiter {
|
|
66
70
|
private hits = new Map<string, { count: number; resetAt: number }>();
|
|
71
|
+
private pruneTimer: ReturnType<typeof setInterval>;
|
|
67
72
|
constructor(
|
|
68
73
|
private maxHits: number,
|
|
69
74
|
private windowMs: number,
|
|
70
|
-
) {
|
|
75
|
+
) {
|
|
76
|
+
// Periodically evict expired windows so the map does not grow unbounded.
|
|
77
|
+
this.pruneTimer = setInterval(
|
|
78
|
+
() => this.prune(),
|
|
79
|
+
Math.max(windowMs, 60_000),
|
|
80
|
+
);
|
|
81
|
+
this.pruneTimer.unref?.();
|
|
82
|
+
}
|
|
71
83
|
|
|
72
84
|
allow(key: string): boolean {
|
|
73
85
|
const now = Date.now();
|
|
@@ -79,6 +91,17 @@ class RateLimiter {
|
|
|
79
91
|
entry.count++;
|
|
80
92
|
return entry.count <= this.maxHits;
|
|
81
93
|
}
|
|
94
|
+
|
|
95
|
+
private prune(): void {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
for (const [key, entry] of this.hits) {
|
|
98
|
+
if (now >= entry.resetAt) this.hits.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
destroy(): void {
|
|
103
|
+
clearInterval(this.pruneTimer);
|
|
104
|
+
}
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
const pairingLimiter = new RateLimiter(
|
|
@@ -89,6 +112,18 @@ const wsConnectLimiter = new RateLimiter(
|
|
|
89
112
|
WS_CONNECT_RATE_LIMIT_MAX,
|
|
90
113
|
WS_CONNECT_RATE_LIMIT_WINDOW_MS,
|
|
91
114
|
);
|
|
115
|
+
// Gateway-wide cap on failed pairing claims per window, independent of the
|
|
116
|
+
// per-IP creation limiter — mitigates distributed pairing-code guessing.
|
|
117
|
+
const CLAIM_FAILURE_RATE_LIMIT_MAX = Number(
|
|
118
|
+
process.env.CLAIM_FAILURE_RATE_LIMIT_MAX ?? 100,
|
|
119
|
+
);
|
|
120
|
+
const CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS = Number(
|
|
121
|
+
process.env.CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS ?? 60_000,
|
|
122
|
+
);
|
|
123
|
+
const claimFailureLimiter = new RateLimiter(
|
|
124
|
+
CLAIM_FAILURE_RATE_LIMIT_MAX,
|
|
125
|
+
CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS,
|
|
126
|
+
);
|
|
92
127
|
|
|
93
128
|
function isLoopbackIp(ip: string): boolean {
|
|
94
129
|
const normalized = ip.trim().toLowerCase();
|
|
@@ -103,10 +138,43 @@ function isRateLimitBypassed(ip: string): boolean {
|
|
|
103
138
|
return isLoopbackIp(ip);
|
|
104
139
|
}
|
|
105
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Trusted reverse proxies, parsed from TRUSTED_PROXIES (comma-separated IPs).
|
|
143
|
+
* X-Forwarded-For is only honored when the immediate peer
|
|
144
|
+
* (req.socket.remoteAddress) is one of these. Default empty = trust nobody,
|
|
145
|
+
* so the socket address is always used and the header cannot be spoofed.
|
|
146
|
+
* IPv6-mapped IPv4 (::ffff:1.2.3.4) is normalized to the bare IPv4 form.
|
|
147
|
+
*/
|
|
148
|
+
function normalizeIp(ip: string): string {
|
|
149
|
+
const t = ip.trim().toLowerCase();
|
|
150
|
+
if (t.startsWith("::ffff:")) return t.slice(7);
|
|
151
|
+
return t;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseTrustedProxies(): Set<string> {
|
|
155
|
+
const raw = process.env.TRUSTED_PROXIES ?? "";
|
|
156
|
+
const set = new Set<string>();
|
|
157
|
+
for (const part of raw.split(",")) {
|
|
158
|
+
const ip = normalizeIp(part);
|
|
159
|
+
if (ip) set.add(ip);
|
|
160
|
+
}
|
|
161
|
+
return set;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const TRUSTED_PROXIES = parseTrustedProxies();
|
|
165
|
+
|
|
106
166
|
function getClientIp(req: IncomingMessage): string {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
const peer = req.socket.remoteAddress ?? "unknown";
|
|
168
|
+
// Only honor X-Forwarded-For when the direct peer is a configured proxy;
|
|
169
|
+
// otherwise the header is attacker-controlled and would defeat rate limits.
|
|
170
|
+
if (TRUSTED_PROXIES.has(normalizeIp(peer))) {
|
|
171
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
172
|
+
if (typeof forwarded === "string") {
|
|
173
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
174
|
+
if (first) return first;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return peer;
|
|
110
178
|
}
|
|
111
179
|
|
|
112
180
|
function extractBearerToken(req: IncomingMessage): string | null {
|
|
@@ -116,15 +184,6 @@ function extractBearerToken(req: IncomingMessage): string | null {
|
|
|
116
184
|
return match?.[1] ?? null;
|
|
117
185
|
}
|
|
118
186
|
|
|
119
|
-
// ── CORS ────────────────────────────────────────────────────────────
|
|
120
|
-
|
|
121
|
-
function setCors(res: ServerResponse): void {
|
|
122
|
-
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
123
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
124
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
125
|
-
res.setHeader("Access-Control-Max-Age", "86400");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
187
|
// ── HTTP API ────────────────────────────────────────────────────────
|
|
129
188
|
|
|
130
189
|
const createPairingBody = z.object({ sessionId: z.string().optional() });
|
|
@@ -174,6 +233,7 @@ async function handleRequest(
|
|
|
174
233
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
175
234
|
const method = req.method ?? "GET";
|
|
176
235
|
const ip = getClientIp(req);
|
|
236
|
+
let authenticatedUserId: string | undefined;
|
|
177
237
|
|
|
178
238
|
// Health check
|
|
179
239
|
if (method === "GET" && url.pathname === "/healthz") {
|
|
@@ -188,6 +248,13 @@ async function handleRequest(
|
|
|
188
248
|
json(res, 401, { error: "auth_required", message: "Authentication required" });
|
|
189
249
|
return;
|
|
190
250
|
}
|
|
251
|
+
if (AUTH_REQUIRED && !authResult.subscribed) {
|
|
252
|
+
json(res, 403, {
|
|
253
|
+
error: "subscription_required",
|
|
254
|
+
message: "Pro subscription required. Subscribe at https://itool.tech",
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
191
258
|
const sessions = sessionManager
|
|
192
259
|
.listActive()
|
|
193
260
|
.filter((s) => s.userId === authResult.userId)
|
|
@@ -209,6 +276,13 @@ async function handleRequest(
|
|
|
209
276
|
json(res, 401, { error: "auth_required", message: "Authentication required" });
|
|
210
277
|
return;
|
|
211
278
|
}
|
|
279
|
+
if (AUTH_REQUIRED && !authResult.subscribed) {
|
|
280
|
+
json(res, 403, {
|
|
281
|
+
error: "subscription_required",
|
|
282
|
+
message: "Pro subscription required. Subscribe at https://itool.tech",
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
212
286
|
const session = sessionManager.get(sessionId);
|
|
213
287
|
if (!session) {
|
|
214
288
|
json(res, 404, { error: "not_found" });
|
|
@@ -273,6 +347,7 @@ async function handleRequest(
|
|
|
273
347
|
if (AUTH_REQUIRED) {
|
|
274
348
|
const authResult = await requireAuth(req, res);
|
|
275
349
|
if (!authResult) return; // response already sent
|
|
350
|
+
authenticatedUserId = authResult.userId;
|
|
276
351
|
}
|
|
277
352
|
|
|
278
353
|
// Create pairing
|
|
@@ -283,9 +358,13 @@ async function handleRequest(
|
|
|
283
358
|
}
|
|
284
359
|
const body = createPairingBody.parse(await readJson(req));
|
|
285
360
|
const record = pairingManager.create(body.sessionId);
|
|
361
|
+
// Issue a secret host token bound to this session. Only the CLI that
|
|
362
|
+
// created the pairing can later connect as role=host (see WS handler).
|
|
363
|
+
const hostToken = hostAuthManager.issue(record.sessionId);
|
|
286
364
|
json(res, 201, {
|
|
287
365
|
sessionId: record.sessionId,
|
|
288
366
|
pairingCode: record.pairingCode,
|
|
367
|
+
hostToken,
|
|
289
368
|
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
290
369
|
});
|
|
291
370
|
return;
|
|
@@ -297,7 +376,24 @@ async function handleRequest(
|
|
|
297
376
|
json(res, 429, { error: "rate_limited", message: "Too many requests" });
|
|
298
377
|
return;
|
|
299
378
|
}
|
|
379
|
+
// Gateway-wide cap on failed claims, independent of per-IP limiter, to
|
|
380
|
+
// blunt distributed brute-forcing across many source IPs.
|
|
381
|
+
if (!isRateLimitBypassed(ip) && !claimFailureLimiter.allow("global")) {
|
|
382
|
+
json(res, 429, { error: "rate_limited", message: "Too many failed claims" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
300
385
|
const body = claimPairingBody.parse(await readJson(req));
|
|
386
|
+
// Idempotent re-claim: if the requester's device token already owns the
|
|
387
|
+
// session this code maps to, re-issue success instead of erroring with
|
|
388
|
+
// already_claimed. Only a token that has PROVEN ownership takes this path,
|
|
389
|
+
// so it can't be used to bypass brute-force protection. This lets a browser
|
|
390
|
+
// that refreshes or re-enters its code reconnect smoothly.
|
|
391
|
+
const peeked = pairingManager.peek(body.pairingCode);
|
|
392
|
+
if (peeked && body.deviceToken && tokenManager.owns(body.deviceToken, peeked.sessionId)) {
|
|
393
|
+
tokenManager.bind(body.deviceToken, peeked.sessionId); // refresh lastUsedAt
|
|
394
|
+
json(res, 200, { sessionId: peeked.sessionId, deviceToken: body.deviceToken });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
301
397
|
const result = pairingManager.claim(body.pairingCode);
|
|
302
398
|
if ("error" in result) {
|
|
303
399
|
json(res, result.status, { error: result.error });
|
|
@@ -342,7 +438,14 @@ async function handleRequest(
|
|
|
342
438
|
if (method === "GET" && sessionMatch) {
|
|
343
439
|
const token = extractBearerToken(req);
|
|
344
440
|
const targetId = sessionMatch[1]!;
|
|
345
|
-
|
|
441
|
+
const session = sessionManager.get(targetId);
|
|
442
|
+
const tokenOwns = Boolean(token && tokenManager.owns(token, targetId));
|
|
443
|
+
if (!canReadSessionDetail({
|
|
444
|
+
authRequired: AUTH_REQUIRED,
|
|
445
|
+
authenticatedUserId,
|
|
446
|
+
sessionUserId: session?.userId,
|
|
447
|
+
tokenOwns,
|
|
448
|
+
})) {
|
|
346
449
|
json(res, 401, {
|
|
347
450
|
error: "unauthorized",
|
|
348
451
|
message: "Valid device token required",
|
|
@@ -370,6 +473,10 @@ async function handleRequest(
|
|
|
370
473
|
return;
|
|
371
474
|
}
|
|
372
475
|
|
|
476
|
+
// Web UI: serve the built SPA for any unmatched GET (after all API routes, so
|
|
477
|
+
// it never shadows them). No-op when WEB_DIST isn't present (API-only mode).
|
|
478
|
+
if (await serveWeb(req, res)) return;
|
|
479
|
+
|
|
373
480
|
json(res, 404, { error: "not_found" });
|
|
374
481
|
}
|
|
375
482
|
|
|
@@ -503,6 +610,24 @@ wss.on(
|
|
|
503
610
|
};
|
|
504
611
|
|
|
505
612
|
if (role === "host") {
|
|
613
|
+
// Verify the host token issued when this session's pairing was created.
|
|
614
|
+
// Without this, anyone who learns a sessionId could connect as host and
|
|
615
|
+
// capture controller keystrokes or inject terminal output.
|
|
616
|
+
const hdr = _request.headers["x-linkshell-host-token"];
|
|
617
|
+
const providedHostToken = Array.isArray(hdr) ? hdr[0] : hdr;
|
|
618
|
+
if (hostAuthManager.has(sessionId)) {
|
|
619
|
+
if (!hostAuthManager.verify(sessionId, providedHostToken)) {
|
|
620
|
+
log("warn", `rejected host connect with bad host token for ${sessionId}`);
|
|
621
|
+
socket.close(4003, "host authentication failed");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
} else if (providedHostToken) {
|
|
625
|
+
// No binding yet (e.g. gateway restarted and lost in-memory state) —
|
|
626
|
+
// trust the first host token presented for this session.
|
|
627
|
+
hostAuthManager.adopt(sessionId, providedHostToken);
|
|
628
|
+
}
|
|
629
|
+
// else: legacy host without a token — allowed for backward compatibility.
|
|
630
|
+
|
|
506
631
|
// Check if this is a reconnect (session already exists with clients)
|
|
507
632
|
const existingSession = sessionManager.get(sessionId);
|
|
508
633
|
const isReconnect =
|
|
@@ -575,11 +700,21 @@ wss.on(
|
|
|
575
700
|
}
|
|
576
701
|
}
|
|
577
702
|
|
|
578
|
-
// Ping/pong for liveness
|
|
703
|
+
// Ping/pong for liveness — terminate connections that stop responding
|
|
704
|
+
// to pings (dead/half-open sockets) instead of leaking them.
|
|
705
|
+
const liveSocket = socket as WebSocket & { isAlive?: boolean };
|
|
706
|
+
liveSocket.isAlive = true;
|
|
707
|
+
socket.on("pong", () => {
|
|
708
|
+
liveSocket.isAlive = true;
|
|
709
|
+
});
|
|
579
710
|
const pingTimer = setInterval(() => {
|
|
580
|
-
if (socket.readyState
|
|
581
|
-
|
|
711
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
712
|
+
if (liveSocket.isAlive === false) {
|
|
713
|
+
socket.terminate();
|
|
714
|
+
return;
|
|
582
715
|
}
|
|
716
|
+
liveSocket.isAlive = false;
|
|
717
|
+
socket.ping();
|
|
583
718
|
}, PING_INTERVAL);
|
|
584
719
|
|
|
585
720
|
socket.on("message", (data: WebSocket.RawData) => {
|
|
@@ -649,6 +784,7 @@ function shutdown() {
|
|
|
649
784
|
sessionManager.destroy();
|
|
650
785
|
pairingManager.destroy();
|
|
651
786
|
tokenManager.destroy();
|
|
787
|
+
hostAuthManager.destroy();
|
|
652
788
|
server.close(() => {
|
|
653
789
|
process.stdout.write("[gateway] stopped\n");
|
|
654
790
|
process.exit(0);
|
|
@@ -721,6 +857,11 @@ server.listen(port, () => {
|
|
|
721
857
|
log("info", `LinkShell Gateway v0.1.0`);
|
|
722
858
|
log("info", `listening on http://0.0.0.0:${port}`);
|
|
723
859
|
log("info", `log level: ${logLevel}`);
|
|
860
|
+
if (webEnabled()) {
|
|
861
|
+
log("info", `web UI served from ${webDistPath()} (open http://0.0.0.0:${port}/)`);
|
|
862
|
+
} else {
|
|
863
|
+
log("info", `web UI not bundled (API-only); set WEB_DIST to enable`);
|
|
864
|
+
}
|
|
724
865
|
});
|
|
725
866
|
|
|
726
867
|
// ── Helpers ─────────────────────────────────────────────────────────
|
package/src/pairings.ts
CHANGED
|
@@ -6,10 +6,15 @@ export interface PairingRecord {
|
|
|
6
6
|
pairingCode: string;
|
|
7
7
|
expiresAt: number; // unix ms
|
|
8
8
|
claimed: boolean;
|
|
9
|
+
failedAttempts: number; // failed claim attempts against this code
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const PAIRING_TTL = Number(process.env.PAIRING_TTL_MS ?? 10 * 60_000); // 10 minutes
|
|
12
13
|
const CLEANUP_INTERVAL = 60_000;
|
|
14
|
+
// Invalidate a code after this many failed claim attempts to cap brute-forcing.
|
|
15
|
+
const MAX_FAILED_CLAIM_ATTEMPTS = Number(
|
|
16
|
+
process.env.PAIRING_MAX_FAILED_ATTEMPTS ?? 5,
|
|
17
|
+
);
|
|
13
18
|
|
|
14
19
|
export class PairingManager {
|
|
15
20
|
private pairings = new Map<string, PairingRecord>();
|
|
@@ -29,7 +34,8 @@ export class PairingManager {
|
|
|
29
34
|
void this.store.deletePairing(record.pairingCode).catch(() => {});
|
|
30
35
|
continue;
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
// Stored records predate the failedAttempts field; default it.
|
|
38
|
+
this.pairings.set(record.pairingCode, { ...record, failedAttempts: 0 });
|
|
33
39
|
}
|
|
34
40
|
} catch (err) {
|
|
35
41
|
process.stderr.write(`[gateway] pairing store hydrate failed, using memory only: ${err}\n`);
|
|
@@ -44,6 +50,7 @@ export class PairingManager {
|
|
|
44
50
|
pairingCode: code,
|
|
45
51
|
expiresAt: Date.now() + PAIRING_TTL,
|
|
46
52
|
claimed: false,
|
|
53
|
+
failedAttempts: 0,
|
|
47
54
|
};
|
|
48
55
|
this.pairings.set(code, record);
|
|
49
56
|
this.persist(record);
|
|
@@ -61,6 +68,15 @@ export class PairingManager {
|
|
|
61
68
|
return { error: "pairing_expired", status: 410 };
|
|
62
69
|
}
|
|
63
70
|
if (record.claimed) {
|
|
71
|
+
// Count repeated claim attempts on an existing code; once the cap is
|
|
72
|
+
// exceeded, invalidate it so it can no longer be targeted.
|
|
73
|
+
record.failedAttempts += 1;
|
|
74
|
+
if (record.failedAttempts >= MAX_FAILED_CLAIM_ATTEMPTS) {
|
|
75
|
+
this.pairings.delete(pairingCode);
|
|
76
|
+
void this.store?.deletePairing(pairingCode).catch(() => {});
|
|
77
|
+
} else {
|
|
78
|
+
this.persist(record);
|
|
79
|
+
}
|
|
64
80
|
return { error: "pairing_already_claimed", status: 409 };
|
|
65
81
|
}
|
|
66
82
|
record.claimed = true;
|
|
@@ -68,6 +84,16 @@ export class PairingManager {
|
|
|
68
84
|
return record;
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
/** Look up a code's session without consuming an attempt or mutating state.
|
|
88
|
+
* Lets the claim endpoint stay idempotent for a device that already owns the
|
|
89
|
+
* mapped session (it re-issues instead of erroring with already_claimed). */
|
|
90
|
+
peek(pairingCode: string): { sessionId: string; claimed: boolean } | null {
|
|
91
|
+
const record = this.pairings.get(pairingCode);
|
|
92
|
+
if (!record) return null;
|
|
93
|
+
if (record.expiresAt < Date.now()) return null;
|
|
94
|
+
return { sessionId: record.sessionId, claimed: record.claimed };
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
getStatus(pairingCode: string): { status: string; expiresAt: number; sessionId: string } | { error: string; httpStatus: number } {
|
|
72
98
|
const record = this.pairings.get(pairingCode);
|
|
73
99
|
if (!record) {
|
package/src/relay.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type WebSocket from "ws";
|
|
2
2
|
import {
|
|
3
|
+
agentV2MessageRoute,
|
|
4
|
+
isProtocolVersionCompatible,
|
|
3
5
|
parseEnvelope,
|
|
4
6
|
parseTypedPayload,
|
|
5
7
|
protocolMessageSchemas,
|
|
@@ -49,7 +51,7 @@ export function handleSocketMessage(
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
try {
|
|
52
|
-
if (
|
|
54
|
+
if (shouldValidatePayloadAtGateway(envelope.type)) {
|
|
53
55
|
envelope = {
|
|
54
56
|
...envelope,
|
|
55
57
|
payload: parseTypedPayload(envelope.type, envelope.payload),
|
|
@@ -79,6 +81,11 @@ function isProtocolMessageType(type: string): type is ProtocolMessageType {
|
|
|
79
81
|
return Object.prototype.hasOwnProperty.call(protocolMessageSchemas, type);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
function shouldValidatePayloadAtGateway(type: string): type is ProtocolMessageType {
|
|
85
|
+
if (agentV2MessageRoute(type) !== null) return false;
|
|
86
|
+
return isProtocolMessageType(type);
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
function sendSessionError(
|
|
83
90
|
socket: WebSocket,
|
|
84
91
|
sessionId: string,
|
|
@@ -102,10 +109,25 @@ function handleHostMessage(
|
|
|
102
109
|
session: ReturnType<SessionManager["get"]> & {},
|
|
103
110
|
sessions: SessionManager,
|
|
104
111
|
): void {
|
|
112
|
+
if (agentV2MessageRoute(envelope.type) === "host_to_client") {
|
|
113
|
+
broadcastToClients(session, envelope);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
switch (envelope.type) {
|
|
106
118
|
case "session.connect": {
|
|
107
119
|
// Extract metadata from host's connect message
|
|
108
120
|
const p = parseTypedPayload("session.connect", envelope.payload);
|
|
121
|
+
// Non-breaking version negotiation: warn (never disconnect) when a host
|
|
122
|
+
// advertises an incompatible protocol version. CLI and app update
|
|
123
|
+
// independently, so an out-of-date peer must keep working in degraded
|
|
124
|
+
// mode rather than being rejected.
|
|
125
|
+
if (!isProtocolVersionCompatible(p.protocolVersion)) {
|
|
126
|
+
process.stderr.write(
|
|
127
|
+
`[gateway] host on session ${session.id} advertises protocol v${p.protocolVersion}, ` +
|
|
128
|
+
`which is older than the minimum compatible version — continuing in degraded mode\n`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
109
131
|
if (p.provider || p.machineId || p.hostname || p.platform || p.cwd || p.projectName) {
|
|
110
132
|
sessions.setMetadata(
|
|
111
133
|
session.id,
|
|
@@ -177,13 +199,6 @@ function handleHostMessage(
|
|
|
177
199
|
case "agent.update":
|
|
178
200
|
case "agent.permission.request":
|
|
179
201
|
case "agent.snapshot":
|
|
180
|
-
case "agent.v2.capabilities":
|
|
181
|
-
case "agent.v2.conversation.opened":
|
|
182
|
-
case "agent.v2.conversation.list.result":
|
|
183
|
-
case "agent.v2.event":
|
|
184
|
-
case "agent.v2.snapshot":
|
|
185
|
-
case "agent.v2.permission.request":
|
|
186
|
-
case "agent.v2.notice":
|
|
187
202
|
// Multi-terminal: host → clients
|
|
188
203
|
case "terminal.spawned":
|
|
189
204
|
case "terminal.list":
|
|
@@ -226,6 +241,17 @@ function handleClientMessage(
|
|
|
226
241
|
return false;
|
|
227
242
|
};
|
|
228
243
|
|
|
244
|
+
const agentRoute = agentV2MessageRoute(envelope.type);
|
|
245
|
+
if (agentRoute === "client_write") {
|
|
246
|
+
if (!requireController()) return;
|
|
247
|
+
sendToHost(session, envelope);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (agentRoute === "client_read") {
|
|
251
|
+
sendToHost(session, envelope);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
229
255
|
switch (envelope.type) {
|
|
230
256
|
case "terminal.input": {
|
|
231
257
|
if (!requireController()) return;
|
|
@@ -316,12 +342,6 @@ function handleClientMessage(
|
|
|
316
342
|
case "agent.prompt":
|
|
317
343
|
case "agent.cancel":
|
|
318
344
|
case "agent.permission.response":
|
|
319
|
-
case "agent.v2.conversation.open":
|
|
320
|
-
case "agent.v2.prompt":
|
|
321
|
-
case "agent.v2.command.execute":
|
|
322
|
-
case "agent.v2.cancel":
|
|
323
|
-
case "agent.v2.permission.respond":
|
|
324
|
-
case "agent.v2.structured_input.respond":
|
|
325
345
|
// Multi-terminal write ops: client → host (require controller)
|
|
326
346
|
case "terminal.spawn":
|
|
327
347
|
case "terminal.kill":
|
|
@@ -338,9 +358,6 @@ function handleClientMessage(
|
|
|
338
358
|
case "terminal.history.request":
|
|
339
359
|
case "agent.initialize":
|
|
340
360
|
case "agent.session.list":
|
|
341
|
-
case "agent.v2.capabilities.request":
|
|
342
|
-
case "agent.v2.conversation.list":
|
|
343
|
-
case "agent.v2.snapshot.request":
|
|
344
361
|
sendToHost(session, envelope);
|
|
345
362
|
break;
|
|
346
363
|
default:
|
|
@@ -349,15 +366,19 @@ function handleClientMessage(
|
|
|
349
366
|
}
|
|
350
367
|
}
|
|
351
368
|
|
|
369
|
+
/** Skip clients whose send buffer exceeds this, so one slow/stalled client
|
|
370
|
+
* can't grow gateway memory without bound (backpressure). */
|
|
371
|
+
const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024; // 8MB
|
|
372
|
+
|
|
352
373
|
function broadcastToClients(
|
|
353
374
|
session: ReturnType<SessionManager["get"]> & {},
|
|
354
375
|
envelope: Envelope,
|
|
355
376
|
): void {
|
|
356
377
|
const data = serializeEnvelope(envelope);
|
|
357
378
|
for (const [, client] of session.clients) {
|
|
358
|
-
if (client.socket.readyState
|
|
359
|
-
|
|
360
|
-
|
|
379
|
+
if (client.socket.readyState !== client.socket.OPEN) continue;
|
|
380
|
+
if (client.socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) continue;
|
|
381
|
+
client.socket.send(data);
|
|
361
382
|
}
|
|
362
383
|
}
|
|
363
384
|
|
package/src/sessions.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface Session {
|
|
|
19
19
|
lastActivity: number;
|
|
20
20
|
createdAt: number;
|
|
21
21
|
outputBuffers: Map<string, Envelope[]>; // keyed by terminalId
|
|
22
|
+
outputBufferBytes: Map<string, number>; // approx byte size per terminalId buffer
|
|
22
23
|
lastStatusByTerminal: Map<string, Envelope>; // last terminal.status per terminal
|
|
23
24
|
hostDisconnectedAt: number | undefined;
|
|
24
25
|
// Metadata from host's session.connect
|
|
@@ -33,9 +34,21 @@ export interface Session {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const OUTPUT_BUFFER_CAPACITY = 200;
|
|
37
|
+
const OUTPUT_BUFFER_MAX_BYTES = 8 * 1024 * 1024; // 8MB per terminal buffer
|
|
36
38
|
const HOST_RECONNECT_WINDOW = 60_000; // 60s
|
|
37
39
|
const CLEANUP_INTERVAL = 30_000;
|
|
38
40
|
|
|
41
|
+
/** Approximate the wire byte size of an envelope for buffer accounting. */
|
|
42
|
+
function approxEnvelopeBytes(envelope: Envelope): number {
|
|
43
|
+
const data = (envelope.payload as { data?: unknown } | undefined)?.data;
|
|
44
|
+
if (typeof data === "string") return data.length;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.stringify(envelope).length;
|
|
47
|
+
} catch {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
export class SessionManager {
|
|
40
53
|
private sessions = new Map<string, Session>();
|
|
41
54
|
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
@@ -56,6 +69,7 @@ export class SessionManager {
|
|
|
56
69
|
lastActivity: Date.now(),
|
|
57
70
|
createdAt: Date.now(),
|
|
58
71
|
outputBuffers: new Map(),
|
|
72
|
+
outputBufferBytes: new Map(),
|
|
59
73
|
lastStatusByTerminal: new Map(),
|
|
60
74
|
hostDisconnectedAt: undefined,
|
|
61
75
|
provider: undefined,
|
|
@@ -137,11 +151,20 @@ export class SessionManager {
|
|
|
137
151
|
if (!buf) {
|
|
138
152
|
buf = [];
|
|
139
153
|
session.outputBuffers.set(tid, buf);
|
|
154
|
+
session.outputBufferBytes.set(tid, 0);
|
|
140
155
|
}
|
|
141
156
|
buf.push(envelope);
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
let bytes = (session.outputBufferBytes.get(tid) ?? 0) + approxEnvelopeBytes(envelope);
|
|
158
|
+
// Evict oldest until under BOTH the count cap and the byte cap, so a
|
|
159
|
+
// malicious/abnormal host can't pin unbounded memory via large frames.
|
|
160
|
+
while (
|
|
161
|
+
buf.length > OUTPUT_BUFFER_CAPACITY ||
|
|
162
|
+
(bytes > OUTPUT_BUFFER_MAX_BYTES && buf.length > 1)
|
|
163
|
+
) {
|
|
164
|
+
const removed = buf.shift();
|
|
165
|
+
if (removed) bytes -= approxEnvelopeBytes(removed);
|
|
144
166
|
}
|
|
167
|
+
session.outputBufferBytes.set(tid, Math.max(0, bytes));
|
|
145
168
|
session.lastActivity = Date.now();
|
|
146
169
|
}
|
|
147
170
|
|