@linkshell/gateway 0.4.2 → 0.4.4

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 (38) hide show
  1. package/Dockerfile +11 -2
  2. package/dist/gateway/src/agent-permission-http.d.ts +18 -18
  3. package/dist/gateway/src/auth-middleware.js +37 -1
  4. package/dist/gateway/src/auth-middleware.js.map +1 -1
  5. package/dist/gateway/src/embedded.js +14 -2
  6. package/dist/gateway/src/embedded.js.map +1 -1
  7. package/dist/gateway/src/host-auth.d.ts +24 -0
  8. package/dist/gateway/src/host-auth.js +72 -0
  9. package/dist/gateway/src/host-auth.js.map +1 -0
  10. package/dist/gateway/src/index.js +146 -7
  11. package/dist/gateway/src/index.js.map +1 -1
  12. package/dist/gateway/src/pairings.d.ts +8 -0
  13. package/dist/gateway/src/pairings.js +26 -1
  14. package/dist/gateway/src/pairings.js.map +1 -1
  15. package/dist/gateway/src/relay.js +17 -4
  16. package/dist/gateway/src/relay.js.map +1 -1
  17. package/dist/gateway/src/sessions.d.ts +1 -0
  18. package/dist/gateway/src/sessions.js +24 -2
  19. package/dist/gateway/src/sessions.js.map +1 -1
  20. package/dist/gateway/src/static-web.d.ts +9 -0
  21. package/dist/gateway/src/static-web.js +97 -0
  22. package/dist/gateway/src/static-web.js.map +1 -0
  23. package/dist/gateway/src/tunnel.js +1 -1
  24. package/dist/gateway/src/tunnel.js.map +1 -1
  25. package/dist/gateway/tsconfig.tsbuildinfo +1 -1
  26. package/dist/shared-protocol/src/index.d.ts +5112 -3588
  27. package/dist/shared-protocol/src/index.js +81 -13
  28. package/dist/shared-protocol/src/index.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/auth-middleware.ts +46 -1
  31. package/src/embedded.ts +13 -1
  32. package/src/host-auth.ts +82 -0
  33. package/src/index.ts +160 -7
  34. package/src/pairings.ts +27 -1
  35. package/src/relay.ts +18 -3
  36. package/src/sessions.ts +25 -2
  37. package/src/static-web.ts +101 -0
  38. package/src/tunnel.ts +1 -1
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 {
@@ -26,6 +27,7 @@ import {
26
27
  } from "./tunnel.js";
27
28
  import { AUTH_REQUIRED, requireAuth, checkWsAuth, validateRequest, checkSubscriptionByUserId, canReadSessionDetail } from "./auth-middleware.js";
28
29
  import { setCors } from "./cors.js";
30
+ import { serveWeb, webEnabled, webDistPath } from "./static-web.js";
29
31
 
30
32
  const port = Number(process.env.PORT ?? 8787);
31
33
  const logLevel = (process.env.LOG_LEVEL ?? "info") as
@@ -45,6 +47,7 @@ const stateStore = createSupabaseStateStore();
45
47
  const sessionManager = new SessionManager();
46
48
  const pairingManager = new PairingManager(stateStore);
47
49
  const tokenManager = new TokenManager(stateStore);
50
+ const hostAuthManager = new HostAuthManager();
48
51
  await Promise.all([pairingManager.hydrate(), tokenManager.hydrate()]);
49
52
 
50
53
  const PING_INTERVAL = 20_000;
@@ -65,10 +68,18 @@ const WS_CONNECT_RATE_LIMIT_WINDOW_MS = Number(
65
68
 
66
69
  class RateLimiter {
67
70
  private hits = new Map<string, { count: number; resetAt: number }>();
71
+ private pruneTimer: ReturnType<typeof setInterval>;
68
72
  constructor(
69
73
  private maxHits: number,
70
74
  private windowMs: number,
71
- ) {}
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
+ }
72
83
 
73
84
  allow(key: string): boolean {
74
85
  const now = Date.now();
@@ -80,6 +91,17 @@ class RateLimiter {
80
91
  entry.count++;
81
92
  return entry.count <= this.maxHits;
82
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
+ }
83
105
  }
84
106
 
85
107
  const pairingLimiter = new RateLimiter(
@@ -90,6 +112,18 @@ const wsConnectLimiter = new RateLimiter(
90
112
  WS_CONNECT_RATE_LIMIT_MAX,
91
113
  WS_CONNECT_RATE_LIMIT_WINDOW_MS,
92
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
+ );
93
127
 
94
128
  function isLoopbackIp(ip: string): boolean {
95
129
  const normalized = ip.trim().toLowerCase();
@@ -104,10 +138,43 @@ function isRateLimitBypassed(ip: string): boolean {
104
138
  return isLoopbackIp(ip);
105
139
  }
106
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
+
107
166
  function getClientIp(req: IncomingMessage): string {
108
- const forwarded = req.headers["x-forwarded-for"];
109
- if (typeof forwarded === "string") return forwarded.split(",")[0]!.trim();
110
- return req.socket.remoteAddress ?? "unknown";
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;
111
178
  }
112
179
 
113
180
  function extractBearerToken(req: IncomingMessage): string | null {
@@ -159,6 +226,22 @@ const server = createServer(async (req, res) => {
159
226
  }
160
227
  });
161
228
 
229
+ // API path prefixes that must NEVER be served the SPA / handled as a web route.
230
+ // Everything else (incl. "/", client-side views) may fall through to the web UI,
231
+ // which must be reachable BEFORE the AUTH_REQUIRED guard so users can load the
232
+ // login page and authenticate in the first place.
233
+ function isApiPath(pathname: string): boolean {
234
+ return (
235
+ pathname === "/healthz" ||
236
+ pathname === "/sessions" ||
237
+ pathname.startsWith("/sessions/") ||
238
+ pathname === "/pairings" ||
239
+ pathname.startsWith("/pairings/") ||
240
+ pathname.startsWith("/agent/") ||
241
+ pathname.startsWith("/tunnel/")
242
+ );
243
+ }
244
+
162
245
  async function handleRequest(
163
246
  req: IncomingMessage,
164
247
  res: ServerResponse,
@@ -276,6 +359,14 @@ async function handleRequest(
276
359
  return;
277
360
  }
278
361
 
362
+ // Web UI (public): serve the built SPA + its static assets BEFORE the
363
+ // AUTH_REQUIRED guard, so the login page itself is reachable on a premium
364
+ // gateway. Only non-API GET/HEAD requests are eligible; API routes above and
365
+ // below are matched explicitly and never reach here as web routes.
366
+ if ((method === "GET" || method === "HEAD") && !isApiPath(url.pathname)) {
367
+ if (await serveWeb(req, res)) return;
368
+ }
369
+
279
370
  // Auth check for premium gateway (skip healthz, /sessions/mine, tunnel)
280
371
  if (AUTH_REQUIRED) {
281
372
  const authResult = await requireAuth(req, res);
@@ -291,9 +382,13 @@ async function handleRequest(
291
382
  }
292
383
  const body = createPairingBody.parse(await readJson(req));
293
384
  const record = pairingManager.create(body.sessionId);
385
+ // Issue a secret host token bound to this session. Only the CLI that
386
+ // created the pairing can later connect as role=host (see WS handler).
387
+ const hostToken = hostAuthManager.issue(record.sessionId);
294
388
  json(res, 201, {
295
389
  sessionId: record.sessionId,
296
390
  pairingCode: record.pairingCode,
391
+ hostToken,
297
392
  expiresAt: new Date(record.expiresAt).toISOString(),
298
393
  });
299
394
  return;
@@ -305,7 +400,24 @@ async function handleRequest(
305
400
  json(res, 429, { error: "rate_limited", message: "Too many requests" });
306
401
  return;
307
402
  }
403
+ // Gateway-wide cap on failed claims, independent of per-IP limiter, to
404
+ // blunt distributed brute-forcing across many source IPs.
405
+ if (!isRateLimitBypassed(ip) && !claimFailureLimiter.allow("global")) {
406
+ json(res, 429, { error: "rate_limited", message: "Too many failed claims" });
407
+ return;
408
+ }
308
409
  const body = claimPairingBody.parse(await readJson(req));
410
+ // Idempotent re-claim: if the requester's device token already owns the
411
+ // session this code maps to, re-issue success instead of erroring with
412
+ // already_claimed. Only a token that has PROVEN ownership takes this path,
413
+ // so it can't be used to bypass brute-force protection. This lets a browser
414
+ // that refreshes or re-enters its code reconnect smoothly.
415
+ const peeked = pairingManager.peek(body.pairingCode);
416
+ if (peeked && body.deviceToken && tokenManager.owns(body.deviceToken, peeked.sessionId)) {
417
+ tokenManager.bind(body.deviceToken, peeked.sessionId); // refresh lastUsedAt
418
+ json(res, 200, { sessionId: peeked.sessionId, deviceToken: body.deviceToken });
419
+ return;
420
+ }
309
421
  const result = pairingManager.claim(body.pairingCode);
310
422
  if ("error" in result) {
311
423
  json(res, result.status, { error: result.error });
@@ -385,6 +497,13 @@ async function handleRequest(
385
497
  return;
386
498
  }
387
499
 
500
+ // Web UI fallback: any remaining non-API GET (these are authenticated on a
501
+ // premium gateway) falls back to the SPA. API-shaped paths that matched no
502
+ // route 404 properly instead of being masked by the SPA shell.
503
+ if ((method === "GET" || method === "HEAD") && !isApiPath(url.pathname)) {
504
+ if (await serveWeb(req, res)) return;
505
+ }
506
+
388
507
  json(res, 404, { error: "not_found" });
389
508
  }
390
509
 
@@ -518,6 +637,24 @@ wss.on(
518
637
  };
519
638
 
520
639
  if (role === "host") {
640
+ // Verify the host token issued when this session's pairing was created.
641
+ // Without this, anyone who learns a sessionId could connect as host and
642
+ // capture controller keystrokes or inject terminal output.
643
+ const hdr = _request.headers["x-linkshell-host-token"];
644
+ const providedHostToken = Array.isArray(hdr) ? hdr[0] : hdr;
645
+ if (hostAuthManager.has(sessionId)) {
646
+ if (!hostAuthManager.verify(sessionId, providedHostToken)) {
647
+ log("warn", `rejected host connect with bad host token for ${sessionId}`);
648
+ socket.close(4003, "host authentication failed");
649
+ return;
650
+ }
651
+ } else if (providedHostToken) {
652
+ // No binding yet (e.g. gateway restarted and lost in-memory state) —
653
+ // trust the first host token presented for this session.
654
+ hostAuthManager.adopt(sessionId, providedHostToken);
655
+ }
656
+ // else: legacy host without a token — allowed for backward compatibility.
657
+
521
658
  // Check if this is a reconnect (session already exists with clients)
522
659
  const existingSession = sessionManager.get(sessionId);
523
660
  const isReconnect =
@@ -590,11 +727,21 @@ wss.on(
590
727
  }
591
728
  }
592
729
 
593
- // Ping/pong for liveness
730
+ // Ping/pong for liveness — terminate connections that stop responding
731
+ // to pings (dead/half-open sockets) instead of leaking them.
732
+ const liveSocket = socket as WebSocket & { isAlive?: boolean };
733
+ liveSocket.isAlive = true;
734
+ socket.on("pong", () => {
735
+ liveSocket.isAlive = true;
736
+ });
594
737
  const pingTimer = setInterval(() => {
595
- if (socket.readyState === socket.OPEN) {
596
- socket.ping();
738
+ if (socket.readyState !== socket.OPEN) return;
739
+ if (liveSocket.isAlive === false) {
740
+ socket.terminate();
741
+ return;
597
742
  }
743
+ liveSocket.isAlive = false;
744
+ socket.ping();
598
745
  }, PING_INTERVAL);
599
746
 
600
747
  socket.on("message", (data: WebSocket.RawData) => {
@@ -664,6 +811,7 @@ function shutdown() {
664
811
  sessionManager.destroy();
665
812
  pairingManager.destroy();
666
813
  tokenManager.destroy();
814
+ hostAuthManager.destroy();
667
815
  server.close(() => {
668
816
  process.stdout.write("[gateway] stopped\n");
669
817
  process.exit(0);
@@ -736,6 +884,11 @@ server.listen(port, () => {
736
884
  log("info", `LinkShell Gateway v0.1.0`);
737
885
  log("info", `listening on http://0.0.0.0:${port}`);
738
886
  log("info", `log level: ${logLevel}`);
887
+ if (webEnabled()) {
888
+ log("info", `web UI served from ${webDistPath()} (open http://0.0.0.0:${port}/)`);
889
+ } else {
890
+ log("info", `web UI not bundled (API-only); set WEB_DIST to enable`);
891
+ }
739
892
  });
740
893
 
741
894
  // ── 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
- this.pairings.set(record.pairingCode, record);
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,6 +1,7 @@
1
1
  import type WebSocket from "ws";
2
2
  import {
3
3
  agentV2MessageRoute,
4
+ isProtocolVersionCompatible,
4
5
  parseEnvelope,
5
6
  parseTypedPayload,
6
7
  protocolMessageSchemas,
@@ -117,6 +118,16 @@ function handleHostMessage(
117
118
  case "session.connect": {
118
119
  // Extract metadata from host's connect message
119
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
+ }
120
131
  if (p.provider || p.machineId || p.hostname || p.platform || p.cwd || p.projectName) {
121
132
  sessions.setMetadata(
122
133
  session.id,
@@ -355,15 +366,19 @@ function handleClientMessage(
355
366
  }
356
367
  }
357
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
+
358
373
  function broadcastToClients(
359
374
  session: ReturnType<SessionManager["get"]> & {},
360
375
  envelope: Envelope,
361
376
  ): void {
362
377
  const data = serializeEnvelope(envelope);
363
378
  for (const [, client] of session.clients) {
364
- if (client.socket.readyState === client.socket.OPEN) {
365
- client.socket.send(data);
366
- }
379
+ if (client.socket.readyState !== client.socket.OPEN) continue;
380
+ if (client.socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) continue;
381
+ client.socket.send(data);
367
382
  }
368
383
  }
369
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
- if (buf.length > OUTPUT_BUFFER_CAPACITY) {
143
- buf.shift();
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
 
@@ -0,0 +1,101 @@
1
+ import { existsSync } from "node:fs";
2
+ import { stat, readFile } from "node:fs/promises";
3
+ import { resolve, join, extname, normalize } from "node:path";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+
6
+ // Serves the built web-dashboard SPA from the gateway, so a single deployment
7
+ // (and single origin) hosts both the API/WebSocket and the UI. The web is
8
+ // optional: if WEB_DIST doesn't exist (e.g. local dev using the Vite dev
9
+ // server), the gateway runs API-only and this is a no-op.
10
+
11
+ const WEB_DIST = process.env.WEB_DIST ?? resolve(process.cwd(), "web");
12
+ const hasWeb = existsSync(join(WEB_DIST, "index.html"));
13
+
14
+ const CONTENT_TYPES: Record<string, string> = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".mjs": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".json": "application/json; charset=utf-8",
20
+ ".svg": "image/svg+xml",
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".ico": "image/x-icon",
26
+ ".webp": "image/webp",
27
+ ".woff": "font/woff",
28
+ ".woff2": "font/woff2",
29
+ ".ttf": "font/ttf",
30
+ ".map": "application/json; charset=utf-8",
31
+ ".txt": "text/plain; charset=utf-8",
32
+ };
33
+
34
+ export function webEnabled(): boolean {
35
+ return hasWeb;
36
+ }
37
+
38
+ export function webDistPath(): string {
39
+ return WEB_DIST;
40
+ }
41
+
42
+ /**
43
+ * Try to serve a GET/HEAD request from the web dist. Returns true if handled.
44
+ * Place AFTER all API routes so it never shadows them. Unmatched non-asset
45
+ * paths fall back to index.html (SPA client-side routing).
46
+ */
47
+ export async function serveWeb(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
48
+ if (!hasWeb) return false;
49
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
50
+
51
+ const url = new URL(req.url ?? "/", "http://localhost");
52
+ let pathname = decodeURIComponent(url.pathname);
53
+
54
+ // Path-traversal guard: normalize, then confine to WEB_DIST.
55
+ const candidate = resolve(WEB_DIST, "." + normalize(pathname));
56
+ const inRoot = candidate === WEB_DIST || candidate.startsWith(WEB_DIST + "/");
57
+
58
+ let filePath: string | null = null;
59
+ if (inRoot && pathname !== "/") {
60
+ try {
61
+ const s = await stat(candidate);
62
+ if (s.isFile()) filePath = candidate;
63
+ } catch {
64
+ // not a real file
65
+ }
66
+ }
67
+
68
+ // SPA fallback: serve index.html for "/" and for any path without a file
69
+ // extension (a client-side route). Missing assets (with an extension) 404.
70
+ if (!filePath) {
71
+ if (pathname === "/" || extname(pathname) === "") {
72
+ filePath = join(WEB_DIST, "index.html");
73
+ } else {
74
+ return false; // let the caller 404 the missing asset
75
+ }
76
+ }
77
+
78
+ try {
79
+ const ext = extname(filePath).toLowerCase();
80
+ const type = CONTENT_TYPES[ext] ?? "application/octet-stream";
81
+ res.setHeader("Content-Type", type);
82
+ // Hashed assets (Vite emits content-hashed filenames) can cache hard;
83
+ // index.html must never be cached so deploys take effect immediately.
84
+ if (filePath.endsWith("index.html")) {
85
+ res.setHeader("Cache-Control", "no-cache");
86
+ } else {
87
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
88
+ }
89
+ if (req.method === "HEAD") {
90
+ res.writeHead(200);
91
+ res.end();
92
+ return true;
93
+ }
94
+ const body = await readFile(filePath);
95
+ res.writeHead(200);
96
+ res.end(body);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
package/src/tunnel.ts CHANGED
@@ -154,7 +154,7 @@ export async function handleTunnelRequest(
154
154
  const cookieToken = tokenOwns ? token : authJwt;
155
155
  if (cookieToken) {
156
156
  const cookieVal = encodeURIComponent(`${sessionId}:${port}:${cookieToken}`);
157
- res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
157
+ res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; Secure; SameSite=Lax`);
158
158
  }
159
159
 
160
160
  // Validate session & host