termbridge 0.1.1 → 0.3.0
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/README.md +1 -1
- package/dist/bin.js +145 -22
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
- package/ui/dist/assets/index-B6CAkCwJ.css +1 -0
- package/ui/dist/assets/index-JYV1Fxtz.js +103 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-B5s06ZPI.css +0 -1
- package/ui/dist/assets/index-lqvA-zyT.js +0 -103
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Visit `https://termbridge.dev` for the full docs, architecture notes, and troubl
|
|
|
24
24
|
## CLI usage
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
termbridge --port
|
|
27
|
+
termbridge --port 8080 --session dev --kill-on-exit --no-qr --tunnel cloudflare
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Flags:
|
package/dist/bin.js
CHANGED
|
@@ -1788,6 +1788,22 @@ var parseArgs = (argv) => {
|
|
|
1788
1788
|
options.port = port;
|
|
1789
1789
|
continue;
|
|
1790
1790
|
}
|
|
1791
|
+
if (current === "--proxy") {
|
|
1792
|
+
const proxy = parseNumber(args.shift());
|
|
1793
|
+
if (!proxy || proxy <= 0) {
|
|
1794
|
+
throw new Error("invalid proxy port");
|
|
1795
|
+
}
|
|
1796
|
+
options.proxy = proxy;
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
if (current === "--dev-proxy-url") {
|
|
1800
|
+
const url = args.shift();
|
|
1801
|
+
if (!url) {
|
|
1802
|
+
throw new Error("missing dev proxy URL");
|
|
1803
|
+
}
|
|
1804
|
+
options.devProxyUrl = url;
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1791
1807
|
if (current === "--session") {
|
|
1792
1808
|
const session = args.shift();
|
|
1793
1809
|
if (!session) {
|
|
@@ -1825,6 +1841,7 @@ Usage:
|
|
|
1825
1841
|
|
|
1826
1842
|
Options:
|
|
1827
1843
|
--port <port> Bind the local server to a fixed port
|
|
1844
|
+
--proxy <port> Proxy a local dev server (e.g., Vite) through termbridge
|
|
1828
1845
|
--session <name> Use a specific tmux session name
|
|
1829
1846
|
--kill-on-exit Kill the tmux session when the CLI exits
|
|
1830
1847
|
--no-qr Disable QR code output
|
|
@@ -2089,6 +2106,7 @@ import { randomBytes, createHash } from "crypto";
|
|
|
2089
2106
|
var SESSION_COOKIE_NAME = "termbridge_session";
|
|
2090
2107
|
var hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
2091
2108
|
var createSessionId = () => randomBytes(18).toString("base64url");
|
|
2109
|
+
var createCsrfToken = () => randomBytes(24).toString("base64url");
|
|
2092
2110
|
var parseCookies = (cookieHeader) => {
|
|
2093
2111
|
const cookies = {};
|
|
2094
2112
|
if (!cookieHeader) {
|
|
@@ -2136,6 +2154,7 @@ var createAuth = ({
|
|
|
2136
2154
|
record.consumed = true;
|
|
2137
2155
|
const session = {
|
|
2138
2156
|
id: createSessionId(),
|
|
2157
|
+
csrfToken: createCsrfToken(),
|
|
2139
2158
|
createdAt: clock(),
|
|
2140
2159
|
lastSeen: clock()
|
|
2141
2160
|
};
|
|
@@ -2179,12 +2198,20 @@ var createAuth = ({
|
|
|
2179
2198
|
}
|
|
2180
2199
|
return parts.join("; ");
|
|
2181
2200
|
};
|
|
2201
|
+
const verifyCsrfToken = (sessionId, csrfToken) => {
|
|
2202
|
+
const session = sessions.get(sessionId);
|
|
2203
|
+
if (!session) {
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
return session.csrfToken === csrfToken;
|
|
2207
|
+
};
|
|
2182
2208
|
return {
|
|
2183
2209
|
issueToken,
|
|
2184
2210
|
redeemToken,
|
|
2185
2211
|
getSession,
|
|
2186
2212
|
getSessionFromRequest,
|
|
2187
|
-
createSessionCookie
|
|
2213
|
+
createSessionCookie,
|
|
2214
|
+
verifyCsrfToken
|
|
2188
2215
|
};
|
|
2189
2216
|
};
|
|
2190
2217
|
|
|
@@ -2236,9 +2263,9 @@ var createTerminalRegistry = () => {
|
|
|
2236
2263
|
};
|
|
2237
2264
|
|
|
2238
2265
|
// src/server/server.ts
|
|
2239
|
-
import { createServer as createHttpServer } from "http";
|
|
2266
|
+
import { createServer as createHttpServer, request as httpRequest } from "http";
|
|
2240
2267
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
2241
|
-
import { WebSocketServer } from "ws";
|
|
2268
|
+
import { WebSocketServer, WebSocket as WsWebSocket } from "ws";
|
|
2242
2269
|
|
|
2243
2270
|
// ../packages/shared/src/index.ts
|
|
2244
2271
|
var TERMINAL_CONTROL_KEYS = [
|
|
@@ -2303,6 +2330,9 @@ var createStaticHandler = (uiDistPath, basePath) => {
|
|
|
2303
2330
|
};
|
|
2304
2331
|
|
|
2305
2332
|
// src/server/server.ts
|
|
2333
|
+
var MAX_HTTP_BODY_SIZE = 64 * 1024;
|
|
2334
|
+
var MAX_WS_MESSAGE_SIZE = 1024 * 1024;
|
|
2335
|
+
var MAX_INPUT_LENGTH = 64 * 1024;
|
|
2306
2336
|
var jsonResponse = (response, status, payload) => {
|
|
2307
2337
|
const body = JSON.stringify(payload);
|
|
2308
2338
|
response.statusCode = status;
|
|
@@ -2312,7 +2342,12 @@ var jsonResponse = (response, status, payload) => {
|
|
|
2312
2342
|
};
|
|
2313
2343
|
var readJsonBody = async (request) => {
|
|
2314
2344
|
const chunks = [];
|
|
2345
|
+
let totalSize = 0;
|
|
2315
2346
|
for await (const chunk of request) {
|
|
2347
|
+
totalSize += chunk.length;
|
|
2348
|
+
if (totalSize > MAX_HTTP_BODY_SIZE) {
|
|
2349
|
+
return "too_large";
|
|
2350
|
+
}
|
|
2316
2351
|
chunks.push(chunk);
|
|
2317
2352
|
}
|
|
2318
2353
|
const body = Buffer.concat(chunks).toString("utf8").trim();
|
|
@@ -2338,21 +2373,28 @@ var isAllowedOrigin = (origin, host) => {
|
|
|
2338
2373
|
};
|
|
2339
2374
|
var allowedControlKeys = new Set(TERMINAL_CONTROL_KEYS);
|
|
2340
2375
|
var parseClientMessage = (payload) => {
|
|
2376
|
+
const size = typeof payload === "string" ? payload.length : payload.byteLength;
|
|
2377
|
+
if (size > MAX_WS_MESSAGE_SIZE) {
|
|
2378
|
+
return { ok: false, error: "too_large" };
|
|
2379
|
+
}
|
|
2341
2380
|
const text = typeof payload === "string" ? payload : payload.toString();
|
|
2342
2381
|
try {
|
|
2343
2382
|
const parsed = JSON.parse(text);
|
|
2344
2383
|
if (parsed.type === "input" && typeof parsed.data === "string") {
|
|
2345
|
-
|
|
2384
|
+
if (parsed.data.length > MAX_INPUT_LENGTH) {
|
|
2385
|
+
return { ok: false, error: "too_large" };
|
|
2386
|
+
}
|
|
2387
|
+
return { ok: true, message: parsed };
|
|
2346
2388
|
}
|
|
2347
2389
|
if (parsed.type === "resize" && typeof parsed.cols === "number" && typeof parsed.rows === "number") {
|
|
2348
|
-
return parsed;
|
|
2390
|
+
return { ok: true, message: parsed };
|
|
2349
2391
|
}
|
|
2350
2392
|
if (parsed.type === "control" && allowedControlKeys.has(parsed.key)) {
|
|
2351
|
-
return parsed;
|
|
2393
|
+
return { ok: true, message: parsed };
|
|
2352
2394
|
}
|
|
2353
|
-
return
|
|
2395
|
+
return { ok: false, error: "invalid" };
|
|
2354
2396
|
} catch {
|
|
2355
|
-
return
|
|
2397
|
+
return { ok: false, error: "invalid" };
|
|
2356
2398
|
}
|
|
2357
2399
|
};
|
|
2358
2400
|
var sendWsMessage = (socket, message) => {
|
|
@@ -2360,24 +2402,44 @@ var sendWsMessage = (socket, message) => {
|
|
|
2360
2402
|
};
|
|
2361
2403
|
var createSessionName = () => `termbridge-${randomBytes3(4).toString("hex")}`;
|
|
2362
2404
|
var createAppServer = (deps) => {
|
|
2363
|
-
const staticHandler = createStaticHandler(deps.uiDistPath, "/app");
|
|
2405
|
+
const staticHandler = createStaticHandler(deps.uiDistPath, "/__tb/app");
|
|
2364
2406
|
const wss = new WebSocketServer({ noServer: true });
|
|
2365
2407
|
const connectionInfo = /* @__PURE__ */ new WeakMap();
|
|
2408
|
+
const proxyRequest = (request, response, targetPath, search) => {
|
|
2409
|
+
const targetUrl = `http://localhost:${deps.proxyPort}${targetPath}${search}`;
|
|
2410
|
+
const proxyHeaders = { ...request.headers };
|
|
2411
|
+
delete proxyHeaders.cookie;
|
|
2412
|
+
delete proxyHeaders.host;
|
|
2413
|
+
proxyHeaders.host = `localhost:${deps.proxyPort}`;
|
|
2414
|
+
const proxyReq = httpRequest(
|
|
2415
|
+
targetUrl,
|
|
2416
|
+
{ method: request.method, headers: proxyHeaders },
|
|
2417
|
+
(proxyRes) => {
|
|
2418
|
+
response.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
2419
|
+
proxyRes.pipe(response);
|
|
2420
|
+
}
|
|
2421
|
+
);
|
|
2422
|
+
proxyReq.on("error", () => {
|
|
2423
|
+
response.statusCode = 502;
|
|
2424
|
+
response.end("proxy error");
|
|
2425
|
+
});
|
|
2426
|
+
request.pipe(proxyReq);
|
|
2427
|
+
};
|
|
2366
2428
|
const server = createHttpServer(async (request, response) => {
|
|
2367
2429
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
2368
|
-
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
2430
|
+
if (request.method === "GET" && url.pathname === "/__tb/healthz") {
|
|
2369
2431
|
response.statusCode = 200;
|
|
2370
2432
|
response.end("ok");
|
|
2371
2433
|
return;
|
|
2372
2434
|
}
|
|
2373
|
-
if (request.method === "GET" && url.pathname === "/") {
|
|
2435
|
+
if (request.method === "GET" && url.pathname === "/" && !deps.proxyPort) {
|
|
2374
2436
|
response.statusCode = 302;
|
|
2375
|
-
response.setHeader("Location", "/app");
|
|
2437
|
+
response.setHeader("Location", "/__tb/app");
|
|
2376
2438
|
response.end();
|
|
2377
2439
|
return;
|
|
2378
2440
|
}
|
|
2379
|
-
if (request.method === "GET" && url.pathname.startsWith("/s/")) {
|
|
2380
|
-
const token = url.pathname.slice(
|
|
2441
|
+
if (request.method === "GET" && url.pathname.startsWith("/__tb/s/")) {
|
|
2442
|
+
const token = url.pathname.slice("/__tb/s/".length);
|
|
2381
2443
|
const ip = getIp(request);
|
|
2382
2444
|
if (!deps.redemptionLimiter.allow(ip)) {
|
|
2383
2445
|
response.statusCode = 429;
|
|
@@ -2392,11 +2454,26 @@ var createAppServer = (deps) => {
|
|
|
2392
2454
|
}
|
|
2393
2455
|
response.statusCode = 302;
|
|
2394
2456
|
response.setHeader("Set-Cookie", deps.auth.createSessionCookie(session.id));
|
|
2395
|
-
response.setHeader("Location", "/app");
|
|
2457
|
+
response.setHeader("Location", "/__tb/app");
|
|
2396
2458
|
response.end();
|
|
2397
2459
|
return;
|
|
2398
2460
|
}
|
|
2399
|
-
if (url.pathname === "/api/
|
|
2461
|
+
if (url.pathname === "/__tb/api/csrf") {
|
|
2462
|
+
const session = deps.auth.getSessionFromRequest(request);
|
|
2463
|
+
if (!session) {
|
|
2464
|
+
response.statusCode = 401;
|
|
2465
|
+
response.end("unauthorized");
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (request.method === "GET") {
|
|
2469
|
+
jsonResponse(response, 200, { csrfToken: session.csrfToken });
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
response.statusCode = 404;
|
|
2473
|
+
response.end("not found");
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
if (url.pathname === "/__tb/api/terminals") {
|
|
2400
2477
|
const session = deps.auth.getSessionFromRequest(request);
|
|
2401
2478
|
if (!session) {
|
|
2402
2479
|
response.statusCode = 401;
|
|
@@ -2410,6 +2487,11 @@ var createAppServer = (deps) => {
|
|
|
2410
2487
|
}
|
|
2411
2488
|
if (request.method === "POST") {
|
|
2412
2489
|
const body = await readJsonBody(request);
|
|
2490
|
+
if (body === "too_large") {
|
|
2491
|
+
response.statusCode = 413;
|
|
2492
|
+
response.end("request body too large");
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2413
2495
|
if (body && typeof body !== "object") {
|
|
2414
2496
|
response.statusCode = 400;
|
|
2415
2497
|
response.end("invalid body");
|
|
@@ -2422,15 +2504,48 @@ var createAppServer = (deps) => {
|
|
|
2422
2504
|
return;
|
|
2423
2505
|
}
|
|
2424
2506
|
}
|
|
2507
|
+
if (request.method === "GET" && url.pathname === "/__tb/api/config") {
|
|
2508
|
+
const session = deps.auth.getSessionFromRequest(request);
|
|
2509
|
+
if (!session) {
|
|
2510
|
+
response.statusCode = 401;
|
|
2511
|
+
response.end("unauthorized");
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
jsonResponse(response, 200, {
|
|
2515
|
+
proxyPort: deps.proxyPort ?? null,
|
|
2516
|
+
devProxyUrl: deps.devProxyUrl ?? null
|
|
2517
|
+
});
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2425
2520
|
const handled = await staticHandler(request, response);
|
|
2426
2521
|
if (!handled) {
|
|
2522
|
+
if (deps.proxyPort && deps.auth.getSessionFromRequest(request)) {
|
|
2523
|
+
proxyRequest(request, response, url.pathname, url.search);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2427
2526
|
response.statusCode = 404;
|
|
2428
2527
|
response.end("not found");
|
|
2429
2528
|
}
|
|
2430
2529
|
});
|
|
2431
2530
|
server.on("upgrade", (request, socket, head) => {
|
|
2432
2531
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
2433
|
-
if (!url.pathname.startsWith("/ws/terminal/")) {
|
|
2532
|
+
if (!url.pathname.startsWith("/__tb/ws/terminal/")) {
|
|
2533
|
+
if (deps.proxyPort && deps.auth.getSessionFromRequest(request)) {
|
|
2534
|
+
const targetUrl = `ws://localhost:${deps.proxyPort}${url.pathname}${url.search}`;
|
|
2535
|
+
const proxyWs = new WsWebSocket(targetUrl);
|
|
2536
|
+
proxyWs.on("open", () => {
|
|
2537
|
+
wss.handleUpgrade(request, socket, head, (clientWs) => {
|
|
2538
|
+
clientWs.on("message", (data) => proxyWs.send(data));
|
|
2539
|
+
proxyWs.on("message", (data) => clientWs.send(data));
|
|
2540
|
+
clientWs.on("close", () => proxyWs.close());
|
|
2541
|
+
proxyWs.on("close", () => clientWs.close());
|
|
2542
|
+
});
|
|
2543
|
+
});
|
|
2544
|
+
proxyWs.on("error", () => {
|
|
2545
|
+
socket.destroy();
|
|
2546
|
+
});
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2434
2549
|
socket.destroy();
|
|
2435
2550
|
return;
|
|
2436
2551
|
}
|
|
@@ -2453,6 +2568,11 @@ var createAppServer = (deps) => {
|
|
|
2453
2568
|
socket.destroy();
|
|
2454
2569
|
return;
|
|
2455
2570
|
}
|
|
2571
|
+
const csrfToken = url.searchParams.get("csrf");
|
|
2572
|
+
if (!csrfToken || !deps.auth.verifyCsrfToken(session.id, csrfToken)) {
|
|
2573
|
+
socket.destroy();
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2456
2576
|
const record = deps.terminalRegistry.get(terminalId);
|
|
2457
2577
|
if (!record) {
|
|
2458
2578
|
socket.destroy();
|
|
@@ -2470,15 +2590,16 @@ var createAppServer = (deps) => {
|
|
|
2470
2590
|
sendWsMessage(socket, { type: "output", data });
|
|
2471
2591
|
});
|
|
2472
2592
|
socket.on("message", (payload) => {
|
|
2473
|
-
const
|
|
2474
|
-
if (!
|
|
2593
|
+
const result = parseClientMessage(payload);
|
|
2594
|
+
if (!result.ok) {
|
|
2475
2595
|
sendWsMessage(socket, {
|
|
2476
2596
|
type: "status",
|
|
2477
2597
|
state: "error",
|
|
2478
|
-
message: "invalid payload"
|
|
2598
|
+
message: result.error === "too_large" ? "message too large" : "invalid payload"
|
|
2479
2599
|
});
|
|
2480
2600
|
return;
|
|
2481
2601
|
}
|
|
2602
|
+
const message = result.message;
|
|
2482
2603
|
if (message.type === "input") {
|
|
2483
2604
|
void deps.terminalBackend.write(info.sessionName, message.data);
|
|
2484
2605
|
return;
|
|
@@ -2564,7 +2685,9 @@ var startCommand = async (options, deps = {}) => {
|
|
|
2564
2685
|
uiDistPath: resolveUiDistPath(),
|
|
2565
2686
|
auth,
|
|
2566
2687
|
terminalRegistry,
|
|
2567
|
-
terminalBackend
|
|
2688
|
+
terminalBackend,
|
|
2689
|
+
proxyPort: options.proxy,
|
|
2690
|
+
devProxyUrl: options.devProxyUrl
|
|
2568
2691
|
});
|
|
2569
2692
|
const started = await server.listen(options.port ?? 0);
|
|
2570
2693
|
const localUrl = `http://127.0.0.1:${started.port}`;
|
|
@@ -2589,7 +2712,7 @@ var startCommand = async (options, deps = {}) => {
|
|
|
2589
2712
|
await started.close();
|
|
2590
2713
|
throw error;
|
|
2591
2714
|
}
|
|
2592
|
-
const redeemUrl = `${publicUrl}/s/${token}`;
|
|
2715
|
+
const redeemUrl = `${publicUrl}/__tb/s/${token}`;
|
|
2593
2716
|
logger.info(`Local server: ${localUrl}`);
|
|
2594
2717
|
logger.info(`Tunnel URL: ${redeemUrl}`);
|
|
2595
2718
|
if (!options.noQr && deps.qr) {
|