@linkshell/gateway 0.5.3 → 0.6.1

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.
Files changed (109) hide show
  1. package/Dockerfile +8 -22
  2. package/README.md +102 -44
  3. package/dist/gateway/src/accounts.js.map +1 -0
  4. package/dist/gateway/src/index.d.ts +4 -1
  5. package/dist/gateway/src/index.js +4 -863
  6. package/dist/gateway/src/index.js.map +1 -1
  7. package/dist/gateway/src/main.js +57 -0
  8. package/dist/gateway/src/main.js.map +1 -0
  9. package/dist/gateway/src/rate-limit.d.ts +32 -0
  10. package/dist/gateway/src/rate-limit.js +106 -0
  11. package/dist/gateway/src/rate-limit.js.map +1 -0
  12. package/dist/gateway/src/relay.d.ts +84 -5
  13. package/dist/gateway/src/relay.js +402 -529
  14. package/dist/gateway/src/relay.js.map +1 -1
  15. package/dist/gateway/src/serve.d.ts +34 -0
  16. package/dist/gateway/src/serve.js +115 -0
  17. package/dist/gateway/src/serve.js.map +1 -0
  18. package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
  19. package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
  20. package/dist/gateway/src/store.js.map +1 -0
  21. package/dist/gateway/src/subscription.d.ts +18 -0
  22. package/dist/gateway/src/subscription.js +46 -0
  23. package/dist/gateway/src/subscription.js.map +1 -0
  24. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  25. package/dist/wire/src/direct.d.ts +22 -0
  26. package/dist/wire/src/direct.js +37 -0
  27. package/dist/wire/src/direct.js.map +1 -0
  28. package/dist/wire/src/index.d.ts +1 -0
  29. package/dist/wire/src/index.js +1 -0
  30. package/dist/wire/src/index.js.map +1 -1
  31. package/dist/wire/src/model.d.ts +287 -6
  32. package/dist/wire/src/model.js +33 -0
  33. package/dist/wire/src/model.js.map +1 -1
  34. package/dist/wire/src/relay.d.ts +8 -8
  35. package/dist/wire/src/rpc.d.ts +1780 -44
  36. package/dist/wire/src/rpc.js +48 -3
  37. package/dist/wire/src/rpc.js.map +1 -1
  38. package/dist/wire/src/updates.d.ts +356 -0
  39. package/dist/wire/src/updates.js +11 -1
  40. package/dist/wire/src/updates.js.map +1 -1
  41. package/package.json +13 -13
  42. package/src/accounts.ts +32 -0
  43. package/src/index.ts +4 -997
  44. package/src/main.ts +65 -0
  45. package/src/rate-limit.ts +114 -0
  46. package/src/relay.ts +435 -620
  47. package/src/serve.ts +142 -0
  48. package/src/store.ts +135 -0
  49. package/src/subscription.ts +51 -0
  50. package/dist/gateway/src/auth-middleware.d.ts +0 -44
  51. package/dist/gateway/src/auth-middleware.js +0 -204
  52. package/dist/gateway/src/auth-middleware.js.map +0 -1
  53. package/dist/gateway/src/cors.d.ts +0 -2
  54. package/dist/gateway/src/cors.js +0 -7
  55. package/dist/gateway/src/cors.js.map +0 -1
  56. package/dist/gateway/src/embedded.d.ts +0 -21
  57. package/dist/gateway/src/embedded.js +0 -438
  58. package/dist/gateway/src/embedded.js.map +0 -1
  59. package/dist/gateway/src/host-auth.d.ts +0 -32
  60. package/dist/gateway/src/host-auth.js +0 -78
  61. package/dist/gateway/src/host-auth.js.map +0 -1
  62. package/dist/gateway/src/pairings.d.ts +0 -38
  63. package/dist/gateway/src/pairings.js +0 -119
  64. package/dist/gateway/src/pairings.js.map +0 -1
  65. package/dist/gateway/src/presence.d.ts +0 -28
  66. package/dist/gateway/src/presence.js +0 -92
  67. package/dist/gateway/src/presence.js.map +0 -1
  68. package/dist/gateway/src/sessions.d.ts +0 -113
  69. package/dist/gateway/src/sessions.js +0 -352
  70. package/dist/gateway/src/sessions.js.map +0 -1
  71. package/dist/gateway/src/state-store.d.ts +0 -21
  72. package/dist/gateway/src/state-store.js +0 -103
  73. package/dist/gateway/src/state-store.js.map +0 -1
  74. package/dist/gateway/src/static-web.d.ts +0 -15
  75. package/dist/gateway/src/static-web.js +0 -129
  76. package/dist/gateway/src/static-web.js.map +0 -1
  77. package/dist/gateway/src/tokens.d.ts +0 -19
  78. package/dist/gateway/src/tokens.js +0 -151
  79. package/dist/gateway/src/tokens.js.map +0 -1
  80. package/dist/gateway/src/tunnel.d.ts +0 -61
  81. package/dist/gateway/src/tunnel.js +0 -576
  82. package/dist/gateway/src/tunnel.js.map +0 -1
  83. package/dist/gateway-v2/src/accounts.js.map +0 -1
  84. package/dist/gateway-v2/src/index.d.ts +0 -3
  85. package/dist/gateway-v2/src/index.js +0 -4
  86. package/dist/gateway-v2/src/index.js.map +0 -1
  87. package/dist/gateway-v2/src/main.js +0 -17
  88. package/dist/gateway-v2/src/main.js.map +0 -1
  89. package/dist/gateway-v2/src/server.d.ts +0 -84
  90. package/dist/gateway-v2/src/server.js +0 -426
  91. package/dist/gateway-v2/src/server.js.map +0 -1
  92. package/dist/gateway-v2/src/store.js.map +0 -1
  93. package/dist/shared-protocol/src/index.d.ts +0 -17029
  94. package/dist/shared-protocol/src/index.js +0 -1064
  95. package/dist/shared-protocol/src/index.js.map +0 -1
  96. package/src/auth-middleware.ts +0 -266
  97. package/src/cors.ts +0 -8
  98. package/src/embedded.ts +0 -526
  99. package/src/host-auth.ts +0 -88
  100. package/src/pairings.ts +0 -133
  101. package/src/presence.ts +0 -118
  102. package/src/sessions.ts +0 -432
  103. package/src/state-store.ts +0 -140
  104. package/src/static-web.ts +0 -133
  105. package/src/tokens.ts +0 -159
  106. package/src/tunnel.ts +0 -644
  107. /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
  108. /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
  109. /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
@@ -1,864 +1,5 @@
1
- import { createServer } from "node:http";
2
- import { randomUUID } from "node:crypto";
3
- import { WebSocketServer } from "ws";
4
- import { createEnvelope, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
5
- import { z, ZodError } from "zod";
6
- import { createRequire } from "node:module";
7
- import { SessionManager } from "./sessions.js";
8
- import { PairingManager } from "./pairings.js";
9
- import { TokenManager } from "./tokens.js";
10
- import { HostAuthManager } from "./host-auth.js";
11
- import { createSupabaseStateStore } from "./state-store.js";
12
- import { handleSocketMessage, replayAgentToSocket } from "./relay.js";
13
- import { PresenceHub, startPresenceWatcher } from "./presence.js";
14
- import { parseTunnelPath, parseTunnelCookie, shouldUseTunnelCookieFallback, handleTunnelRequest, } from "./tunnel.js";
15
- import { AUTH_REQUIRED, SUPABASE_ANON_KEY, SUPABASE_URL, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId, canReadSessionDetail } from "./auth-middleware.js";
16
- import { mkdirSync } from "node:fs";
17
- import { dirname } from "node:path";
18
- import { Gateway as RelayGateway, supabaseVerifier } from "@linkshell/gateway-v2";
19
- import { RELAY_PATH } from "@linkshell/wire";
20
- import { setCors } from "./cors.js";
21
- import { serveWeb, serveWebAsset, webEnabled, webDistPath } from "./static-web.js";
22
- const port = Number(process.env.PORT ?? 8787);
23
- const logLevel = (process.env.LOG_LEVEL ?? "info");
24
- const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
25
- function log(level, msg) {
26
- if (LOG_LEVELS[level] >= LOG_LEVELS[logLevel]) {
27
- process.stdout.write(`[gateway:${level}] ${msg}\n`);
28
- }
29
- }
30
- const stateStore = createSupabaseStateStore();
31
- const sessionManager = new SessionManager();
32
- const pairingManager = new PairingManager(stateStore);
33
- const tokenManager = new TokenManager(stateStore);
34
- const presenceHub = new PresenceHub();
35
- sessionManager.onPresenceChange = (session) => presenceHub.notify(session);
36
- const hostAuthManager = new HostAuthManager();
37
- await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
38
- // Resolve our own package version so /healthz and the startup log report the
39
- // real deployed build (no more guessing which image Sealos is running).
40
- const GATEWAY_VERSION = (() => {
41
- const req = createRequire(import.meta.url);
42
- // The build emits to dist/gateway/src/index.js, so package.json is several
43
- // levels up — try a few candidates rather than assuming the layout.
44
- for (const rel of ["../package.json", "../../package.json", "../../../package.json"]) {
45
- try {
46
- const v = req(rel)?.version;
47
- if (typeof v === "string")
48
- return v;
49
- }
50
- catch {
51
- // try next candidate
52
- }
53
- }
54
- return "unknown";
55
- })();
56
- const PING_INTERVAL = 20_000;
57
- const MAX_BODY_SIZE = 4096;
58
- const MAX_WS_MESSAGE_SIZE = 50 * 1024 * 1024; // 50MB (supports base64 image uploads)
59
- const PAIRING_RATE_LIMIT_MAX = Number(process.env.PAIRING_RATE_LIMIT_MAX ?? 30);
60
- const PAIRING_RATE_LIMIT_WINDOW_MS = Number(process.env.PAIRING_RATE_LIMIT_WINDOW_MS ?? 60_000);
61
- const WS_CONNECT_RATE_LIMIT_MAX = Number(process.env.WS_CONNECT_RATE_LIMIT_MAX ?? 20);
62
- const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(process.env.WS_CONNECT_RATE_LIMIT_WINDOW_MS ?? 60_000);
63
- // ── Rate limiter ────────────────────────────────────────────────────
64
- class RateLimiter {
65
- maxHits;
66
- windowMs;
67
- hits = new Map();
68
- pruneTimer;
69
- constructor(maxHits, windowMs) {
70
- this.maxHits = maxHits;
71
- this.windowMs = windowMs;
72
- // Periodically evict expired windows so the map does not grow unbounded.
73
- this.pruneTimer = setInterval(() => this.prune(), Math.max(windowMs, 60_000));
74
- this.pruneTimer.unref?.();
75
- }
76
- allow(key) {
77
- const now = Date.now();
78
- const entry = this.hits.get(key);
79
- if (!entry || now >= entry.resetAt) {
80
- this.hits.set(key, { count: 1, resetAt: now + this.windowMs });
81
- return true;
82
- }
83
- entry.count++;
84
- return entry.count <= this.maxHits;
85
- }
86
- prune() {
87
- const now = Date.now();
88
- for (const [key, entry] of this.hits) {
89
- if (now >= entry.resetAt)
90
- this.hits.delete(key);
91
- }
92
- }
93
- destroy() {
94
- clearInterval(this.pruneTimer);
95
- }
96
- }
97
- const pairingLimiter = new RateLimiter(PAIRING_RATE_LIMIT_MAX, PAIRING_RATE_LIMIT_WINDOW_MS);
98
- const wsConnectLimiter = new RateLimiter(WS_CONNECT_RATE_LIMIT_MAX, WS_CONNECT_RATE_LIMIT_WINDOW_MS);
99
- // Gateway-wide cap on failed pairing claims per window, independent of the
100
- // per-IP creation limiter — mitigates distributed pairing-code guessing.
101
- const CLAIM_FAILURE_RATE_LIMIT_MAX = Number(process.env.CLAIM_FAILURE_RATE_LIMIT_MAX ?? 100);
102
- const CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS = Number(process.env.CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS ?? 60_000);
103
- const claimFailureLimiter = new RateLimiter(CLAIM_FAILURE_RATE_LIMIT_MAX, CLAIM_FAILURE_RATE_LIMIT_WINDOW_MS);
104
- function isLoopbackIp(ip) {
105
- const normalized = ip.trim().toLowerCase();
106
- return (normalized === "127.0.0.1" ||
107
- normalized === "::1" ||
108
- normalized === "::ffff:127.0.0.1");
109
- }
110
- function isRateLimitBypassed(ip) {
111
- return isLoopbackIp(ip);
112
- }
113
- /**
114
- * Trusted reverse proxies, parsed from TRUSTED_PROXIES (comma-separated IPs).
115
- * X-Forwarded-For is only honored when the immediate peer
116
- * (req.socket.remoteAddress) is one of these. Default empty = trust nobody,
117
- * so the socket address is always used and the header cannot be spoofed.
118
- * IPv6-mapped IPv4 (::ffff:1.2.3.4) is normalized to the bare IPv4 form.
119
- */
120
- function normalizeIp(ip) {
121
- const t = ip.trim().toLowerCase();
122
- if (t.startsWith("::ffff:"))
123
- return t.slice(7);
124
- return t;
125
- }
126
- function parseTrustedProxies() {
127
- const raw = process.env.TRUSTED_PROXIES ?? "";
128
- const set = new Set();
129
- for (const part of raw.split(",")) {
130
- const ip = normalizeIp(part);
131
- if (ip)
132
- set.add(ip);
133
- }
134
- return set;
135
- }
136
- const TRUSTED_PROXIES = parseTrustedProxies();
137
- function getClientIp(req) {
138
- const peer = req.socket.remoteAddress ?? "unknown";
139
- // Only honor X-Forwarded-For when the direct peer is a configured proxy;
140
- // otherwise the header is attacker-controlled and would defeat rate limits.
141
- if (TRUSTED_PROXIES.has(normalizeIp(peer))) {
142
- const forwarded = req.headers["x-forwarded-for"];
143
- if (typeof forwarded === "string") {
144
- const first = forwarded.split(",")[0]?.trim();
145
- if (first)
146
- return first;
147
- }
148
- }
149
- return peer;
150
- }
151
- function extractBearerToken(req) {
152
- const auth = req.headers.authorization;
153
- if (!auth)
154
- return null;
155
- const match = auth.match(/^Bearer\s+(.+)$/i);
156
- return match?.[1] ?? null;
157
- }
158
- // The device token proves session ownership. On a premium (AUTH_REQUIRED)
159
- // gateway the Authorization header is taken by the Supabase JWT, so the device
160
- // token rides in the `?token=` query param instead (same as the client WS URL).
161
- // Prefer the query param, fall back to the bearer for non-auth gateways.
162
- function extractDeviceToken(req) {
163
- const url = new URL(req.url ?? "/", "http://localhost");
164
- const q = url.searchParams.get("token");
165
- if (q)
166
- return q;
167
- return extractBearerToken(req);
168
- }
169
- // ── HTTP API ────────────────────────────────────────────────────────
170
- const createPairingBody = z.object({ sessionId: z.string().optional() });
171
- const claimPairingBody = z.object({
172
- pairingCode: z.string().length(6),
173
- deviceToken: z.string().min(1).optional(),
174
- });
175
- const server = createServer(async (req, res) => {
176
- setCors(res);
177
- if (req.method === "OPTIONS") {
178
- res.writeHead(204);
179
- res.end();
180
- return;
181
- }
182
- try {
183
- await handleRequest(req, res);
184
- }
185
- catch (err) {
186
- if (err instanceof ZodError) {
187
- json(res, 400, {
188
- error: "invalid_message",
189
- message: err.errors[0]?.message ?? "Validation failed",
190
- });
191
- }
192
- else if (err instanceof BodyTooLargeError) {
193
- json(res, 413, {
194
- error: "body_too_large",
195
- message: "Request body exceeds limit",
196
- });
197
- }
198
- else if (err instanceof SyntaxError) {
199
- json(res, 400, { error: "invalid_json", message: "Malformed JSON" });
200
- }
201
- else {
202
- process.stderr.write(`[gateway] unhandled error: ${err}\n`);
203
- json(res, 500, {
204
- error: "internal_error",
205
- message: "Internal server error",
206
- });
207
- }
208
- }
209
- });
210
- // API path prefixes that must NEVER be served the SPA / handled as a web route.
211
- // Everything else (incl. "/", client-side views) may fall through to the web UI,
212
- // which must be reachable BEFORE the AUTH_REQUIRED guard so users can load the
213
- // login page and authenticate in the first place.
214
- function isApiPath(pathname) {
215
- return (pathname === "/healthz" ||
216
- pathname === "/sessions" ||
217
- pathname.startsWith("/sessions/") ||
218
- pathname === "/pairings" ||
219
- pathname.startsWith("/pairings/") ||
220
- pathname.startsWith("/agent/") ||
221
- pathname.startsWith("/tunnel/"));
222
- }
223
- async function handleRequest(req, res) {
224
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
225
- const method = req.method ?? "GET";
226
- const ip = getClientIp(req);
227
- let authenticatedUserId;
228
- // Health check
229
- if (method === "GET" && url.pathname === "/healthz") {
230
- // memoryMb: what the process holds, to watch against the container's limit.
231
- json(res, 200, { ok: true, version: GATEWAY_VERSION, relay: relay.connected, memoryMb: Math.round(process.memoryUsage.rss() / 1048576) });
232
- return;
233
- }
234
- // Sessions owned by authenticated user (before AUTH_REQUIRED guard — uses its own auth)
235
- if (method === "GET" && url.pathname === "/sessions/mine") {
236
- const authResult = await validateRequest(req);
237
- if (!authResult || !authResult.userId) {
238
- json(res, 401, { error: "auth_required", message: "Authentication required" });
239
- return;
240
- }
241
- if (AUTH_REQUIRED && !authResult.subscribed) {
242
- json(res, 403, {
243
- error: "subscription_required",
244
- message: "Pro subscription required. Subscribe at https://itool.tech",
245
- });
246
- return;
247
- }
248
- const sessions = sessionManager
249
- .listActive()
250
- .filter((s) => s.userId === authResult.userId)
251
- .map((s) => sessionManager.getSummary(s.id))
252
- .filter(Boolean);
253
- json(res, 200, { sessions });
254
- return;
255
- }
256
- // Delete a session owned by authenticated user
257
- if (method === "DELETE" && url.pathname.startsWith("/sessions/")) {
258
- const sessionId = url.pathname.split("/")[2];
259
- if (!sessionId) {
260
- json(res, 400, { error: "missing_session_id" });
261
- return;
262
- }
263
- const authResult = await validateRequest(req);
264
- if (!authResult || !authResult.userId) {
265
- json(res, 401, { error: "auth_required", message: "Authentication required" });
266
- return;
267
- }
268
- if (AUTH_REQUIRED && !authResult.subscribed) {
269
- json(res, 403, {
270
- error: "subscription_required",
271
- message: "Pro subscription required. Subscribe at https://itool.tech",
272
- });
273
- return;
274
- }
275
- const session = sessionManager.get(sessionId);
276
- if (!session) {
277
- json(res, 404, { error: "not_found" });
278
- return;
279
- }
280
- if (session.userId && session.userId === authResult.userId) {
281
- sessionManager.forceDelete(sessionId);
282
- json(res, 200, { ok: true });
283
- return;
284
- }
285
- json(res, 403, { error: "forbidden", message: "You do not own this session" });
286
- return;
287
- }
288
- // Tunnel HTTP proxy (explicit path) — before AUTH_REQUIRED, tunnel has its own auth
289
- const tunnelParsed = parseTunnelPath(url.pathname);
290
- if (tunnelParsed) {
291
- await handleTunnelRequest(req, res, sessionManager, tokenManager, tunnelParsed, url);
292
- return;
293
- }
294
- // Tunnel fallback: cookie-based routing for sub-resources (e.g. /_next/static/...).
295
- // Placed BEFORE serveWeb so tunnel sub-resources are routed correctly even for
296
- // extensionless paths (SPA routes inside the tunneled app). Concrete
297
- // LinkShell web assets win first; missing absolute assets belong to the
298
- // previewed app and should still be proxied.
299
- const tunnelCookie = parseTunnelCookie(req);
300
- if (tunnelCookie && shouldUseTunnelCookieFallback(req, url.pathname, isApiPath)) {
301
- if (await serveWebAsset(req, res))
302
- return;
303
- const fallbackParsed = {
304
- sessionId: tunnelCookie.sessionId,
305
- port: tunnelCookie.port,
306
- path: url.pathname,
307
- };
308
- await handleTunnelRequest(req, res, sessionManager, tokenManager, fallbackParsed, url, tunnelCookie.token);
309
- return;
310
- }
311
- // Web UI (public): serve the built SPA + its static assets BEFORE the
312
- // AUTH_REQUIRED guard, so the login page itself is reachable on a premium
313
- // gateway. Only non-API GET/HEAD requests are eligible; API routes above and
314
- // below are matched explicitly and never reach here as web routes.
315
- if ((method === "GET" || method === "HEAD") && !isApiPath(url.pathname)) {
316
- if (await serveWeb(req, res))
317
- return;
318
- }
319
- // Auth check for premium gateway (skip healthz, /sessions/mine, tunnel)
320
- if (AUTH_REQUIRED) {
321
- const authResult = await requireAuth(req, res);
322
- if (!authResult)
323
- return; // response already sent
324
- authenticatedUserId = authResult.userId;
325
- }
326
- // Create pairing
327
- if (method === "POST" && url.pathname === "/pairings") {
328
- if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
329
- json(res, 429, { error: "rate_limited", message: "Too many requests" });
330
- return;
331
- }
332
- const body = createPairingBody.parse(await readJson(req));
333
- const record = pairingManager.create(body.sessionId);
334
- // Issue a secret host token bound to this session. Only the CLI that
335
- // created the pairing can later connect as role=host (see WS handler).
336
- const hostToken = hostAuthManager.issue(record.sessionId);
337
- json(res, 201, {
338
- sessionId: record.sessionId,
339
- pairingCode: record.pairingCode,
340
- hostToken,
341
- expiresAt: new Date(record.expiresAt).toISOString(),
342
- });
343
- return;
344
- }
345
- // Claim pairing
346
- if (method === "POST" && url.pathname === "/pairings/claim") {
347
- if (!isRateLimitBypassed(ip) && !pairingLimiter.allow(ip)) {
348
- json(res, 429, { error: "rate_limited", message: "Too many requests" });
349
- return;
350
- }
351
- // Gateway-wide cap on failed claims, independent of per-IP limiter, to
352
- // blunt distributed brute-forcing across many source IPs.
353
- if (!isRateLimitBypassed(ip) && !claimFailureLimiter.allow("global")) {
354
- json(res, 429, { error: "rate_limited", message: "Too many failed claims" });
355
- return;
356
- }
357
- const body = claimPairingBody.parse(await readJson(req));
358
- // Idempotent re-claim: if the requester's device token already owns the
359
- // session this code maps to, re-issue success instead of erroring with
360
- // already_claimed. Only a token that has PROVEN ownership takes this path,
361
- // so it can't be used to bypass brute-force protection. This lets a browser
362
- // that refreshes or re-enters its code reconnect smoothly.
363
- const peeked = pairingManager.peek(body.pairingCode);
364
- if (peeked && body.deviceToken && tokenManager.owns(body.deviceToken, peeked.sessionId)) {
365
- tokenManager.bind(body.deviceToken, peeked.sessionId); // refresh lastUsedAt
366
- json(res, 200, { sessionId: peeked.sessionId, deviceToken: body.deviceToken });
367
- return;
368
- }
369
- const result = pairingManager.claim(body.pairingCode);
370
- if ("error" in result) {
371
- json(res, result.status, { error: result.error });
372
- return;
373
- }
374
- const token = tokenManager.register(body.deviceToken);
375
- tokenManager.bind(token, result.sessionId);
376
- json(res, 200, { sessionId: result.sessionId, deviceToken: token });
377
- return;
378
- }
379
- // Session list
380
- if (method === "GET" && url.pathname === "/sessions") {
381
- const token = extractDeviceToken(req);
382
- const allowedIds = token && tokenManager.validate(token)
383
- ? tokenManager.getSessionIds(token)
384
- : new Set();
385
- const sessions = sessionManager
386
- .listActive()
387
- .filter((s) => allowedIds.has(s.id))
388
- .map((s) => sessionManager.getSummary(s.id))
389
- .filter(Boolean);
390
- json(res, 200, { sessions });
391
- return;
392
- }
393
- // Session detail
394
- const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
395
- if (method === "GET" && sessionMatch) {
396
- const token = extractDeviceToken(req);
397
- const targetId = sessionMatch[1];
398
- const session = sessionManager.get(targetId);
399
- const tokenOwns = Boolean(token && tokenManager.owns(token, targetId));
400
- if (!canReadSessionDetail({
401
- authRequired: AUTH_REQUIRED,
402
- authenticatedUserId,
403
- sessionUserId: session?.userId,
404
- tokenOwns,
405
- })) {
406
- json(res, 401, {
407
- error: "unauthorized",
408
- message: "Valid device token required",
409
- });
410
- return;
411
- }
412
- const summary = sessionManager.getSummary(targetId);
413
- if (!summary) {
414
- json(res, 404, { error: "session_not_found" });
415
- return;
416
- }
417
- json(res, 200, summary);
418
- return;
419
- }
420
- // Pairing status (for CLI polling)
421
- const pairingMatch = url.pathname.match(/^\/pairings\/(\d{6})\/status$/);
422
- if (method === "GET" && pairingMatch) {
423
- const result = pairingManager.getStatus(pairingMatch[1]);
424
- if ("error" in result) {
425
- json(res, result.httpStatus, { error: result.error });
426
- return;
427
- }
428
- json(res, 200, result);
429
- return;
430
- }
431
- // Web UI fallback: any remaining non-API GET (these are authenticated on a
432
- // premium gateway) falls back to the SPA. API-shaped paths that matched no
433
- // route 404 properly instead of being masked by the SPA shell.
434
- if ((method === "GET" || method === "HEAD") && !isApiPath(url.pathname)) {
435
- if (await serveWeb(req, res))
436
- return;
437
- }
438
- json(res, 404, { error: "not_found" });
439
- }
440
- // ── WebSocket ───────────────────────────────────────────────────────
441
- const wss = new WebSocketServer({
442
- noServer: true,
443
- maxPayload: MAX_WS_MESSAGE_SIZE,
444
- });
445
- // ── v2 relay (end-to-end encrypted, routes ciphertext only) ─────────
446
- // Served on the same port at RELAY_PATH. Pairings and machine/device keys are
447
- // kept in SQLite at RELAY_DATA_PATH (a persistent volume in production).
448
- const relayDataPath = process.env.RELAY_DATA_PATH ?? "./data/relay.db";
449
- mkdirSync(dirname(relayDataPath), { recursive: true });
450
- const relay = new RelayGateway({
451
- port: 0,
452
- databasePath: relayDataPath,
453
- verifyToken: SUPABASE_URL && SUPABASE_ANON_KEY ? supabaseVerifier(SUPABASE_URL, SUPABASE_ANON_KEY) : undefined,
454
- log: (message) => log("info", `[relay] ${message}`),
455
- // The official gateway is a subscription service: a computer needs an active
456
- // Pro account to use it (as with v1). Devices only reach computers they're
457
- // paired with or share an account with, so they need nothing more.
458
- admit: AUTH_REQUIRED
459
- ? async ({ role, userId }) => {
460
- if (role !== "machine")
461
- return undefined;
462
- if (!userId)
463
- return "官方网关需要登录 Pro 账号:在电脑上运行 linkshell login";
464
- const subscription = await checkSubscriptionByUserId(userId);
465
- // A lookup failure isn't a lapsed subscription; don't lock people out over it.
466
- if (subscription.status === "inactive")
467
- return "官方网关需要 Pro 订阅:https://itool.tech";
468
- if (subscription.status === "unknown")
469
- log("warn", `[relay] subscription check unavailable (${subscription.reason}); admitting ${userId}`);
470
- return undefined;
471
- }
472
- : undefined,
473
- });
474
- relay.attach();
475
- server.on("upgrade", (request, socket, head) => {
476
- const url = new URL(request.url ?? "/", `http://${request.headers.host}`);
477
- if (url.pathname === RELAY_PATH) {
478
- const ip = getClientIp(request);
479
- if (!isRateLimitBypassed(ip) && !wsConnectLimiter.allow(ip)) {
480
- socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
481
- socket.destroy();
482
- return;
483
- }
484
- relay.handleUpgrade(request, socket, head);
485
- return;
486
- }
487
- // Tunnel WebSocket upgrade (for HMR etc.)
488
- const tunnelParsed = parseTunnelPath(url.pathname);
489
- if (tunnelParsed) {
490
- const ip = getClientIp(request);
491
- if (!isRateLimitBypassed(ip) && !wsConnectLimiter.allow(ip)) {
492
- socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
493
- socket.destroy();
494
- return;
495
- }
496
- // Import dynamically to handle tunnel WS upgrade
497
- import("./tunnel.js").then(({ handleTunnelWsUpgrade }) => {
498
- const tunnelCookie = parseTunnelCookie(request);
499
- const cookieToken = tunnelCookie?.sessionId === tunnelParsed.sessionId && tunnelCookie.port === tunnelParsed.port
500
- ? tunnelCookie.token
501
- : undefined;
502
- wss.handleUpgrade(request, socket, head, async (ws) => {
503
- await handleTunnelWsUpgrade(ws, tunnelParsed, url, sessionManager, tokenManager, cookieToken);
504
- });
505
- });
506
- return;
507
- }
508
- // Tunnel WS fallback via cookie (for HMR paths like /_next/webpack-hmr)
509
- const tunnelCookie = parseTunnelCookie(request);
510
- if (tunnelCookie && shouldUseTunnelCookieFallback(request, url.pathname, isApiPath)) {
511
- const fallbackParsed = {
512
- sessionId: tunnelCookie.sessionId,
513
- port: tunnelCookie.port,
514
- path: url.pathname,
515
- };
516
- wss.handleUpgrade(request, socket, head, async (ws) => {
517
- const { handleTunnelWsUpgrade } = await import("./tunnel.js");
518
- await handleTunnelWsUpgrade(ws, fallbackParsed, url, sessionManager, tokenManager, tunnelCookie.token);
519
- });
520
- return;
521
- }
522
- if (url.pathname !== "/ws") {
523
- socket.destroy();
524
- return;
525
- }
526
- const ip = getClientIp(request);
527
- if (!isRateLimitBypassed(ip) && !wsConnectLimiter.allow(ip)) {
528
- socket.write("HTTP/1.1 429 Too Many Requests\r\n\r\n");
529
- socket.destroy();
530
- return;
531
- }
532
- // Auth check for premium gateway WebSocket connections
533
- if (AUTH_REQUIRED) {
534
- checkWsAuth(request).then((authResult) => {
535
- if (!authResult || !authResult.authenticated) {
536
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
537
- socket.destroy();
538
- return;
539
- }
540
- if (!authResult.subscribed) {
541
- 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");
542
- socket.destroy();
543
- return;
544
- }
545
- request.__authResult = authResult;
546
- wss.handleUpgrade(request, socket, head, (ws) => {
547
- wss.emit("connection", ws, request, url);
548
- });
549
- });
550
- return;
551
- }
552
- wss.handleUpgrade(request, socket, head, (ws) => {
553
- wss.emit("connection", ws, request, url);
554
- });
555
- });
556
- wss.on("connection", (socket, _request, url) => {
557
- const sessionId = url.searchParams.get("sessionId");
558
- const role = url.searchParams.get("role");
559
- if (role === "watcher") {
560
- const token = url.searchParams.get("token");
561
- const authResult = _request.__authResult;
562
- const tokenValid = Boolean(token && tokenManager.validate(token));
563
- const userId = authResult?.userId;
564
- if (!tokenValid && !userId) {
565
- socket.close(4001, "unauthorized");
566
- return;
567
- }
568
- startPresenceWatcher({
569
- socket,
570
- sessions: sessionManager,
571
- hub: presenceHub,
572
- sessionIds: () => token && tokenManager.validate(token)
573
- ? tokenManager.getSessionIds(token)
574
- : new Set(),
575
- userId,
576
- pingIntervalMs: PING_INTERVAL,
577
- });
578
- return;
579
- }
580
- if (!sessionId || !role || (role !== "host" && role !== "client")) {
581
- socket.close(1008, "missing sessionId or role");
582
- return;
583
- }
584
- const deviceId = url.searchParams.get("deviceId") ?? randomUUID();
585
- if (role === "client") {
586
- const token = url.searchParams.get("token");
587
- const authResult = _request.__authResult;
588
- const session = sessionManager.get(sessionId);
589
- // Allow if: device token owns session, OR auth user owns session
590
- const tokenOwns = token && tokenManager.owns(token, sessionId);
591
- const authOwns = AUTH_REQUIRED &&
592
- authResult?.userId &&
593
- session?.userId &&
594
- authResult.userId === session.userId;
595
- if (!tokenOwns && !authOwns) {
596
- socket.close(4001, "unauthorized");
597
- return;
598
- }
599
- if (!tokenOwns && authOwns && token) {
600
- tokenManager.register(token);
601
- tokenManager.bind(token, sessionId);
602
- log("info", `bound authenticated device token to session ${sessionId}`);
603
- }
604
- }
605
- const device = {
606
- socket,
607
- role,
608
- deviceId,
609
- connectedAt: Date.now(),
610
- };
611
- if (role === "host") {
612
- // Host-token check (trust-on-first-use). The first host to connect with a
613
- // token binds the session; after that, only that token is accepted — so a
614
- // third party who later learns the sessionId can't hijack the host role
615
- // and capture controller keystrokes or inject terminal output. A host that
616
- // presents NO token never creates a binding and is allowed as a legacy
617
- // host, keeping older CLIs (which don't send a token) working.
618
- const hdr = _request.headers["x-linkshell-host-token"];
619
- const providedHostToken = Array.isArray(hdr) ? hdr[0] : hdr;
620
- if (hostAuthManager.has(sessionId)) {
621
- if (!hostAuthManager.verify(sessionId, providedHostToken)) {
622
- log("warn", `rejected host connect with bad host token for ${sessionId}`);
623
- socket.close(4003, "host authentication failed");
624
- return;
625
- }
626
- }
627
- else if (providedHostToken) {
628
- // First host connect with a token (or after a gateway restart lost the
629
- // in-memory binding) — trust it and bind the session to it.
630
- hostAuthManager.adopt(sessionId, providedHostToken);
631
- }
632
- // else: legacy host without a token — allowed for backward compatibility.
633
- // Check if this is a reconnect (session already exists with clients)
634
- const existingSession = sessionManager.get(sessionId);
635
- const isReconnect = existingSession &&
636
- existingSession.clients.size > 0 &&
637
- existingSession.state === "host_disconnected";
638
- sessionManager.setHost(sessionId, device);
639
- // Associate userId from auth (for AUTH_REQUIRED gateways). Without this,
640
- // the session won't appear in GET /sessions/mine for the logged-in user.
641
- const authResult = _request.__authResult;
642
- if (authResult?.userId) {
643
- const session = sessionManager.get(sessionId);
644
- if (session)
645
- session.userId = authResult.userId;
646
- log("info", `host connected for ${sessionId} as user ${authResult.userId}`);
647
- }
648
- else if (AUTH_REQUIRED) {
649
- // Host connected WITHOUT a resolvable user (CLI not `linkshell login`'d,
650
- // or a different account). It won't show up in /sessions/mine.
651
- log("warn", `host connected for ${sessionId} with NO authenticated user — will be invisible to /sessions/mine (is the CLI logged in?)`);
652
- }
653
- if (isReconnect) {
654
- const notification = serializeEnvelope(createEnvelope({
655
- type: "session.host_reconnected",
656
- sessionId,
657
- payload: {},
658
- }));
659
- for (const [, client] of existingSession.clients) {
660
- if (client.socket.readyState === client.socket.OPEN) {
661
- client.socket.send(notification);
662
- }
663
- }
664
- }
665
- }
666
- else {
667
- sessionManager.addClient(sessionId, device);
668
- }
669
- // Send welcome with protocol version
670
- socket.send(serializeEnvelope(createEnvelope({
671
- type: "session.connect",
672
- sessionId,
673
- payload: {
674
- role,
675
- clientName: deviceId,
676
- protocolVersion: PROTOCOL_VERSION,
677
- },
678
- })));
679
- // If client just joined and host is not connected, notify immediately
680
- if (role === "client") {
681
- const sessionAfterJoin = sessionManager.get(sessionId);
682
- if (sessionAfterJoin) {
683
- const hostGone = !sessionAfterJoin.host ||
684
- sessionAfterJoin.state === "host_disconnected" ||
685
- sessionAfterJoin.host.socket.readyState !==
686
- sessionAfterJoin.host.socket.OPEN;
687
- if (hostGone) {
688
- socket.send(serializeEnvelope(createEnvelope({
689
- type: "session.host_disconnected",
690
- sessionId,
691
- payload: { reason: "host not connected" },
692
- })));
693
- }
694
- replayAgentToSocket(socket, sessionId, sessionManager);
695
- }
696
- }
697
- // Ping/pong for liveness — terminate connections that stop responding
698
- // (dead/half-open sockets) instead of leaking them. Liveness is proven by
699
- // EITHER a WS pong OR any inbound message: React Native's WebSocket does
700
- // not reliably auto-reply to server ping control frames, so a perfectly
701
- // healthy native client (terminal) would otherwise be terminated every
702
- // ~40s, silently dropping its keystrokes into the reconnect queue. Active
703
- // clients send a heartbeat every 15s (< PING_INTERVAL), so they always
704
- // refresh isAlive; only a genuinely silent socket gets reaped.
705
- const liveSocket = socket;
706
- liveSocket.isAlive = true;
707
- socket.on("pong", () => {
708
- liveSocket.isAlive = true;
709
- });
710
- const pingTimer = setInterval(() => {
711
- if (socket.readyState !== socket.OPEN)
712
- return;
713
- if (liveSocket.isAlive === false) {
714
- socket.terminate();
715
- return;
716
- }
717
- liveSocket.isAlive = false;
718
- socket.ping();
719
- }, PING_INTERVAL);
720
- socket.on("message", (data) => {
721
- // Any inbound traffic counts as proof of life (see ping/pong note above).
722
- liveSocket.isAlive = true;
723
- try {
724
- handleSocketMessage(socket, data.toString(), role, sessionId, deviceId, sessionManager);
725
- }
726
- catch (err) {
727
- log("error", `unhandled websocket message error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
728
- if (socket.readyState === socket.OPEN) {
729
- socket.send(serializeEnvelope(createEnvelope({
730
- type: "session.error",
731
- sessionId,
732
- payload: {
733
- code: "invalid_message",
734
- message: "Failed to handle message",
735
- },
736
- })));
737
- }
738
- }
739
- });
740
- socket.on("close", () => {
741
- clearInterval(pingTimer);
742
- if (role === "host") {
743
- const result = sessionManager.removeHost(sessionId);
744
- // Notify all clients that host disconnected
745
- if (result) {
746
- const notification = serializeEnvelope(createEnvelope({
747
- type: "session.host_disconnected",
748
- sessionId,
749
- payload: { reason: "host connection closed" },
750
- }));
751
- for (const [, client] of result.clients) {
752
- if (client.socket.readyState === client.socket.OPEN) {
753
- client.socket.send(notification);
754
- }
755
- }
756
- }
757
- }
758
- else {
759
- sessionManager.removeClient(sessionId, deviceId);
760
- }
761
- });
762
- socket.on("error", () => {
763
- // close will fire
764
- });
765
- });
766
- // ── Graceful shutdown ───────────────────────────────────────────────
767
- function shutdown() {
768
- process.stdout.write("[gateway] shutting down...\n");
769
- wss.clients.forEach((ws) => ws.close(1001, "server shutting down"));
770
- sessionManager.destroy();
771
- pairingManager.destroy();
772
- tokenManager.destroy();
773
- hostAuthManager.destroy();
774
- void relay.stop();
775
- server.close(() => {
776
- process.stdout.write("[gateway] stopped\n");
777
- process.exit(0);
778
- });
779
- // Force exit after 5s
780
- setTimeout(() => process.exit(1), 5000).unref();
781
- }
782
- process.on("SIGINT", shutdown);
783
- process.on("SIGTERM", shutdown);
784
- // ── Subscription expiry checker (AUTH_REQUIRED gateways only) ──────
785
- const SUB_CHECK_INTERVAL = 5 * 60_000; // 5 minutes
786
- if (AUTH_REQUIRED) {
787
- setInterval(async () => {
788
- for (const session of sessionManager.listActive()) {
789
- if (!session.userId || !session.host)
790
- continue;
791
- const subscription = await checkSubscriptionByUserId(session.userId);
792
- if (subscription.status === "unknown") {
793
- log("warn", `subscription check unknown for user ${session.userId}, keeping session ${session.id}${subscription.reason ? ` (${subscription.reason})` : ""}`);
794
- continue;
795
- }
796
- if (subscription.status === "inactive") {
797
- log("info", `subscription expired for user ${session.userId}, disconnecting session ${session.id}`);
798
- // Notify host
799
- try {
800
- session.host.socket.send(serializeEnvelope(createEnvelope({
801
- type: "session.error",
802
- sessionId: session.id,
803
- payload: {
804
- code: "subscription_expired",
805
- message: "Your Pro subscription has expired. Renew at https://itool.tech",
806
- },
807
- })));
808
- }
809
- catch { }
810
- // Close host connection
811
- session.host.socket.close(4003, "subscription_expired");
812
- // Notify clients
813
- for (const [, client] of session.clients) {
814
- try {
815
- client.socket.send(serializeEnvelope(createEnvelope({
816
- type: "session.error",
817
- sessionId: session.id,
818
- payload: {
819
- code: "subscription_expired",
820
- message: "Host subscription expired. Session ended.",
821
- },
822
- })));
823
- client.socket.close(4003, "subscription_expired");
824
- }
825
- catch { }
826
- }
827
- }
828
- }
829
- }, SUB_CHECK_INTERVAL);
830
- }
831
- // ── Start ───────────────────────────────────────────────────────────
832
- server.listen(port, () => {
833
- log("info", `LinkShell Gateway v${GATEWAY_VERSION}`);
834
- log("info", `listening on http://0.0.0.0:${port}`);
835
- log("info", `log level: ${logLevel}`);
836
- if (webEnabled()) {
837
- log("info", `web UI served from ${webDistPath()} (open http://0.0.0.0:${port}/)`);
838
- }
839
- else {
840
- log("info", `web UI not bundled (API-only); set WEB_DIST to enable`);
841
- }
842
- });
843
- // ── Helpers ─────────────────────────────────────────────────────────
844
- class BodyTooLargeError extends Error {
845
- }
846
- function json(res, status, body) {
847
- res.writeHead(status, { "content-type": "application/json" });
848
- res.end(JSON.stringify(body));
849
- }
850
- async function readJson(req) {
851
- const chunks = [];
852
- let size = 0;
853
- for await (const chunk of req) {
854
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
855
- size += buf.length;
856
- if (size > MAX_BODY_SIZE)
857
- throw new BodyTooLargeError();
858
- chunks.push(buf);
859
- }
860
- if (chunks.length === 0)
861
- return {};
862
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
863
- }
1
+ export { Gateway } from "./relay.js";
2
+ export { GatewayStore } from "./store.js";
3
+ export { supabaseVerifier } from "./accounts.js";
4
+ export { startGateway } from "./serve.js";
864
5
  //# sourceMappingURL=index.js.map