@linkshell/gateway 0.4.2 → 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/agent-permission-http.d.ts +18 -18
- package/dist/gateway/src/auth-middleware.js +37 -1
- package/dist/gateway/src/auth-middleware.js.map +1 -1
- 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 +122 -7
- 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 +17 -4
- 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 +5112 -3588
- package/dist/shared-protocol/src/index.js +81 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +2 -2
- package/src/auth-middleware.ts +46 -1
- package/src/embedded.ts +13 -1
- package/src/host-auth.ts +82 -0
- package/src/index.ts +133 -7
- package/src/pairings.ts +27 -1
- package/src/relay.ts +18 -3
- package/src/sessions.ts +25 -2
- package/src/static-web.ts +101 -0
- 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
|
|
109
|
-
|
|
110
|
-
|
|
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 {
|
|
@@ -291,9 +358,13 @@ async function handleRequest(
|
|
|
291
358
|
}
|
|
292
359
|
const body = createPairingBody.parse(await readJson(req));
|
|
293
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);
|
|
294
364
|
json(res, 201, {
|
|
295
365
|
sessionId: record.sessionId,
|
|
296
366
|
pairingCode: record.pairingCode,
|
|
367
|
+
hostToken,
|
|
297
368
|
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
298
369
|
});
|
|
299
370
|
return;
|
|
@@ -305,7 +376,24 @@ async function handleRequest(
|
|
|
305
376
|
json(res, 429, { error: "rate_limited", message: "Too many requests" });
|
|
306
377
|
return;
|
|
307
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
|
+
}
|
|
308
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
|
+
}
|
|
309
397
|
const result = pairingManager.claim(body.pairingCode);
|
|
310
398
|
if ("error" in result) {
|
|
311
399
|
json(res, result.status, { error: result.error });
|
|
@@ -385,6 +473,10 @@ async function handleRequest(
|
|
|
385
473
|
return;
|
|
386
474
|
}
|
|
387
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
|
+
|
|
388
480
|
json(res, 404, { error: "not_found" });
|
|
389
481
|
}
|
|
390
482
|
|
|
@@ -518,6 +610,24 @@ wss.on(
|
|
|
518
610
|
};
|
|
519
611
|
|
|
520
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
|
+
|
|
521
631
|
// Check if this is a reconnect (session already exists with clients)
|
|
522
632
|
const existingSession = sessionManager.get(sessionId);
|
|
523
633
|
const isReconnect =
|
|
@@ -590,11 +700,21 @@ wss.on(
|
|
|
590
700
|
}
|
|
591
701
|
}
|
|
592
702
|
|
|
593
|
-
// 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
|
+
});
|
|
594
710
|
const pingTimer = setInterval(() => {
|
|
595
|
-
if (socket.readyState
|
|
596
|
-
|
|
711
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
712
|
+
if (liveSocket.isAlive === false) {
|
|
713
|
+
socket.terminate();
|
|
714
|
+
return;
|
|
597
715
|
}
|
|
716
|
+
liveSocket.isAlive = false;
|
|
717
|
+
socket.ping();
|
|
598
718
|
}, PING_INTERVAL);
|
|
599
719
|
|
|
600
720
|
socket.on("message", (data: WebSocket.RawData) => {
|
|
@@ -664,6 +784,7 @@ function shutdown() {
|
|
|
664
784
|
sessionManager.destroy();
|
|
665
785
|
pairingManager.destroy();
|
|
666
786
|
tokenManager.destroy();
|
|
787
|
+
hostAuthManager.destroy();
|
|
667
788
|
server.close(() => {
|
|
668
789
|
process.stdout.write("[gateway] stopped\n");
|
|
669
790
|
process.exit(0);
|
|
@@ -736,6 +857,11 @@ server.listen(port, () => {
|
|
|
736
857
|
log("info", `LinkShell Gateway v0.1.0`);
|
|
737
858
|
log("info", `listening on http://0.0.0.0:${port}`);
|
|
738
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
|
+
}
|
|
739
865
|
});
|
|
740
866
|
|
|
741
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,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
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|