@webda/debug 4.0.0-beta.4 → 4.0.0-beta.6
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/lib/debugservice.service.d.ts +134 -23
- package/lib/debugservice.service.js +417 -73
- package/lib/debugservice.service.js.map +1 -1
- package/lib/introspection.d.ts +17 -0
- package/lib/introspection.js +52 -6
- package/lib/introspection.js.map +1 -1
- package/lib/security.d.ts +155 -0
- package/lib/security.js +217 -0
- package/lib/security.js.map +1 -0
- package/lib/tui/client.d.ts +4 -1
- package/lib/tui/client.js +10 -3
- package/lib/tui/client.js.map +1 -1
- package/lib/tui/tui.d.ts +2 -1
- package/lib/tui/tui.js +6 -3
- package/lib/tui/tui.js.map +1 -1
- package/package.json +17 -9
- package/webui/assets/favicon-CVPPk1M-.svg +1 -0
- package/webui/assets/index-BSEtev2p.js +51 -0
- package/webui/assets/index-Bw7FTaDv.css +1 -0
- package/webui/index.html +13 -20
- package/webui/app.js +0 -177
- package/webui/components/logs.js +0 -128
- package/webui/components/model-graph.js +0 -292
- package/webui/components/models.js +0 -213
- package/webui/components/operations.js +0 -315
- package/webui/components/requests.js +0 -264
- package/webui/components/schema-form.js +0 -268
- package/webui/components/services.js +0 -248
- package/webui/logo.svg +0 -1
- package/webui/styles.css +0 -685
- package/webui/vendor/htm.js +0 -1
- package/webui/vendor/preact-hooks.js +0 -2
- package/webui/vendor/preact.js +0 -2
- package/webui/webda.png +0 -0
|
@@ -32,15 +32,15 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
32
32
|
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
33
|
done = true;
|
|
34
34
|
};
|
|
35
|
-
import { Service, ServiceParameters, useDynamicService, useCoreEvents, useRouter } from "@webda/core";
|
|
35
|
+
import { Service, ServiceParameters, useDynamicService, useCoreEvents, useRouter, useApplication } from "@webda/core";
|
|
36
36
|
import { Command } from "@webda/core";
|
|
37
37
|
import { createServer } from "node:http";
|
|
38
|
-
import {
|
|
39
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
40
|
-
import { platform } from "node:os";
|
|
38
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
41
39
|
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { createRequire } from "node:module";
|
|
42
41
|
import { dirname, join, extname } from "node:path";
|
|
43
42
|
import { WebSocketServer, WebSocket } from "ws";
|
|
43
|
+
import { BOOTSTRAP_CODE_TTL, DEBUG_API_VERSION, HOSTED_DASHBOARD_URL, LOCAL_PAGE_CSP, WS_PROTOCOL, allowedOrigins, buildDebugUrl, debugOrigins, extractBearerToken, extractWsToken, generateToken, isAllowedHost, isAllowedOrigin, openInBrowser, resolveTelemetry, safeEqual } from "./security.js";
|
|
44
44
|
import { RequestLog } from "./requestlog.js";
|
|
45
45
|
import { LogBuffer } from "./logbuffer.js";
|
|
46
46
|
import { captureBody, normalizeHeaders } from "./bodycapture.js";
|
|
@@ -49,36 +49,61 @@ import { DebugTui } from "./tui/tui.js";
|
|
|
49
49
|
import { CancelablePromise } from "@webda/utils";
|
|
50
50
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
51
51
|
const WEBUI_DIR = join(__dirname, "..", "webui");
|
|
52
|
-
|
|
53
|
-
const ALLOWED_ORIGIN_EXACT = new Set([
|
|
54
|
-
"http://localhost:3000",
|
|
55
|
-
"http://127.0.0.1:3000",
|
|
56
|
-
"https://webda.io"
|
|
57
|
-
]);
|
|
52
|
+
export { isAllowedOrigin, allowedOrigins, DEBUG_API_VERSION } from "./security.js";
|
|
58
53
|
/**
|
|
59
|
-
*
|
|
54
|
+
* Page served at `/` in hosted mode: no token, no bundle, just directions.
|
|
60
55
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
* @param hostedBase - the hosted dashboard URL
|
|
57
|
+
* @returns the HTML
|
|
58
|
+
*/
|
|
59
|
+
function hostedPlaceholderPage(hostedBase) {
|
|
60
|
+
const base = hostedBase.replace(/[<>&"]/g, "");
|
|
61
|
+
return `<!doctype html>
|
|
62
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
63
|
+
<title>Webda debug server</title></head>
|
|
64
|
+
<body style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; line-height: 1.6">
|
|
65
|
+
<h1>Webda debug server</h1>
|
|
66
|
+
<p>This is the API of <code>webda debug --web</code>. The dashboard is hosted at
|
|
67
|
+
<a href="${base}">${base}</a>: open the URL printed by the command, it carries your session.</p>
|
|
68
|
+
<p>To serve the dashboard from here instead, restart with <code>webda debug --web --local</code>.</p>
|
|
69
|
+
</body></html>
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Version of this package, read once from its package.json.
|
|
74
|
+
* @returns the version string, or "unknown" when the file cannot be read
|
|
75
|
+
*/
|
|
76
|
+
function readDebugVersion() {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version || "unknown";
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return "unknown";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const DEBUG_VERSION = readDebugVersion();
|
|
85
|
+
/**
|
|
86
|
+
* Version of the framework the application runs on.
|
|
66
87
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
88
|
+
* Asks the application first, then reads the package.json of the `@webda/core`
|
|
89
|
+
* this package resolves (the application's one in a normal install).
|
|
90
|
+
* @returns the `@webda/core` version, or `undefined` when unavailable
|
|
69
91
|
*/
|
|
70
|
-
|
|
71
|
-
if (!origin)
|
|
72
|
-
return false;
|
|
73
|
-
if (ALLOWED_ORIGIN_EXACT.has(origin))
|
|
74
|
-
return true;
|
|
75
|
-
// Allow any https:// subdomain of webda.io
|
|
92
|
+
function frameworkVersion() {
|
|
76
93
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
94
|
+
const version = useApplication().getWebdaVersion();
|
|
95
|
+
if (version)
|
|
96
|
+
return version;
|
|
79
97
|
}
|
|
80
98
|
catch {
|
|
81
|
-
|
|
99
|
+
// application context unavailable, or getWebdaVersion failed
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const pkg = createRequire(import.meta.url).resolve("@webda/core/package.json");
|
|
103
|
+
return JSON.parse(readFileSync(pkg, "utf8")).version || undefined;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
/** Map file extensions to MIME types for static file serving. */
|
|
@@ -89,7 +114,12 @@ const MIME_TYPES = {
|
|
|
89
114
|
".json": "application/json; charset=utf-8",
|
|
90
115
|
".svg": "image/svg+xml",
|
|
91
116
|
".png": "image/png",
|
|
92
|
-
".ico": "image/x-icon"
|
|
117
|
+
".ico": "image/x-icon",
|
|
118
|
+
".webp": "image/webp",
|
|
119
|
+
".woff": "font/woff",
|
|
120
|
+
".woff2": "font/woff2",
|
|
121
|
+
".map": "application/json; charset=utf-8",
|
|
122
|
+
".txt": "text/plain; charset=utf-8"
|
|
93
123
|
};
|
|
94
124
|
/**
|
|
95
125
|
* Configuration parameters for the {@link DebugService}.
|
|
@@ -134,12 +164,60 @@ let DebugService = (() => {
|
|
|
134
164
|
this.requestLog = (__runInitializers(this, _instanceExtraInitializers), new RequestLog());
|
|
135
165
|
/** Ring buffer of application log entries */
|
|
136
166
|
this.logBuffer = new LogBuffer();
|
|
167
|
+
/** HTTP servers for the debug API (one per loopback address) */
|
|
168
|
+
this.servers = [];
|
|
137
169
|
/** Connected WebSocket clients */
|
|
138
170
|
this.clients = new Set();
|
|
139
171
|
/** Unsubscribe functions for core event listeners */
|
|
140
172
|
this.unsubscribers = [];
|
|
141
173
|
/** Timing map: requestId -> start timestamp */
|
|
142
174
|
this.timings = new Map();
|
|
175
|
+
/** Per-session token required on the API and the websocket */
|
|
176
|
+
this.token = generateToken();
|
|
177
|
+
/** Port the debug server actually listens on (resolved after listen) */
|
|
178
|
+
this.listeningPort = 0;
|
|
179
|
+
/** Whether the bundled dashboard is served (`--local`) */
|
|
180
|
+
this.localMode = false;
|
|
181
|
+
/** Origins allowed by CORS (computed at start from the environment) */
|
|
182
|
+
this.origins = allowedOrigins(process.env);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Port the debug server listens on (resolved after start).
|
|
186
|
+
* @returns the port
|
|
187
|
+
*/
|
|
188
|
+
getPort() {
|
|
189
|
+
return this.listeningPort;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Loopback addresses the debug server is bound to.
|
|
193
|
+
* @returns the addresses, e.g. `["127.0.0.1", "::1"]`
|
|
194
|
+
*/
|
|
195
|
+
getBoundAddresses() {
|
|
196
|
+
return this.servers
|
|
197
|
+
.map(server => server.address()?.address)
|
|
198
|
+
.filter((a) => !!a);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Issue a one-time bootstrap code for the local page (`--local`).
|
|
202
|
+
*
|
|
203
|
+
* The code is random, single-use and expires after {@link BOOTSTRAP_CODE_TTL};
|
|
204
|
+
* `POST /api/session` exchanges it for the session token.
|
|
205
|
+
* @param ttl - lifetime in milliseconds (tests use a negative value for an expired code)
|
|
206
|
+
* @returns the code
|
|
207
|
+
*/
|
|
208
|
+
issueBootstrapCode(ttl = BOOTSTRAP_CODE_TTL) {
|
|
209
|
+
const code = generateToken();
|
|
210
|
+
this.bootstrap = { code, expires: Date.now() + ttl };
|
|
211
|
+
return code;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Session token required on every `/api/*` request and websocket connection.
|
|
215
|
+
*
|
|
216
|
+
* It is generated when the service is created and never logged.
|
|
217
|
+
* @returns the token
|
|
218
|
+
*/
|
|
219
|
+
getToken() {
|
|
220
|
+
return this.token;
|
|
143
221
|
}
|
|
144
222
|
/**
|
|
145
223
|
* Resolve the typed parameters and the capture knobs (with safe defaults
|
|
@@ -161,6 +239,9 @@ let DebugService = (() => {
|
|
|
161
239
|
*/
|
|
162
240
|
resolve() {
|
|
163
241
|
super.resolve();
|
|
242
|
+
// The HTTP handler runs outside the application's async context, so the
|
|
243
|
+
// framework version is read here, where useApplication() is available.
|
|
244
|
+
this.frameworkVersion = frameworkVersion();
|
|
164
245
|
this.subscribeToEvents();
|
|
165
246
|
return this;
|
|
166
247
|
}
|
|
@@ -303,8 +384,7 @@ let DebugService = (() => {
|
|
|
303
384
|
catch {
|
|
304
385
|
respBuf = undefined;
|
|
305
386
|
}
|
|
306
|
-
const respContentType = (responseHeaders && (responseHeaders["Content-Type"] ?? responseHeaders["content-type"])) ||
|
|
307
|
-
undefined;
|
|
387
|
+
const respContentType = (responseHeaders && (responseHeaders["Content-Type"] ?? responseHeaders["content-type"])) || undefined;
|
|
308
388
|
if (respBuf !== undefined) {
|
|
309
389
|
details.responseBody = captureBody(respBuf, typeof respContentType === "string" ? respContentType : undefined, settings.bodyLimit, settings.binaryPreview);
|
|
310
390
|
}
|
|
@@ -327,7 +407,10 @@ let DebugService = (() => {
|
|
|
327
407
|
*
|
|
328
408
|
* @param port - Port for the debug dashboard API
|
|
329
409
|
* @param servePort - Port for the application HTTP server
|
|
330
|
-
* @param web - Disable TUI and
|
|
410
|
+
* @param web - Disable TUI and open the web dashboard instead
|
|
411
|
+
* @param local - Serve the bundled dashboard from the debug server instead of the hosted one
|
|
412
|
+
* @param open - Open the dashboard in the default browser (`--no-open` to disable)
|
|
413
|
+
* @param telemetry - Allow usage analytics on the hosted dashboard (`--no-telemetry` to disable)
|
|
331
414
|
* @returns a promise settled once the service stops or the TUI quits
|
|
332
415
|
*/
|
|
333
416
|
debug(
|
|
@@ -335,12 +418,18 @@ let DebugService = (() => {
|
|
|
335
418
|
port = 18181,
|
|
336
419
|
/** @alias s @description Application server port */
|
|
337
420
|
servePort = 18080,
|
|
338
|
-
/** @description Disable TUI and
|
|
339
|
-
web
|
|
421
|
+
/** @description Disable TUI and open the web dashboard (hosted on webda.io) */
|
|
422
|
+
web,
|
|
423
|
+
/** @description With --web: serve the bundled dashboard from the debug port instead of webda.io */
|
|
424
|
+
local,
|
|
425
|
+
/** @description With --web: open the dashboard in the browser (--no-open to only print the URL) */
|
|
426
|
+
open = true,
|
|
427
|
+
/** @description With --web: allow usage analytics on the hosted dashboard (--no-telemetry or WEBDA_TELEMETRY=0 to disable) */
|
|
428
|
+
telemetry = true) {
|
|
340
429
|
// Stays pending until the service stops or the TUI quits
|
|
341
430
|
return new CancelablePromise((resolve, reject) => {
|
|
342
431
|
this.running = resolve;
|
|
343
|
-
this.startDebug(port, servePort, web).catch(reject);
|
|
432
|
+
this.startDebug(port, servePort, web, local, open, telemetry).catch(reject);
|
|
344
433
|
}, async () => {
|
|
345
434
|
this.tui?.stop();
|
|
346
435
|
});
|
|
@@ -350,9 +439,12 @@ let DebugService = (() => {
|
|
|
350
439
|
*
|
|
351
440
|
* @param port - Port for the debug dashboard API
|
|
352
441
|
* @param servePort - Port for the application HTTP server
|
|
353
|
-
* @param web - Disable TUI and
|
|
442
|
+
* @param web - Disable TUI and open the web dashboard
|
|
443
|
+
* @param local - Serve the bundled dashboard instead of the hosted one
|
|
444
|
+
* @param open - Open the browser
|
|
445
|
+
* @param telemetry - Allow usage analytics on the hosted dashboard
|
|
354
446
|
*/
|
|
355
|
-
async startDebug(port, servePort, web) {
|
|
447
|
+
async startDebug(port, servePort, web, local, open = true, telemetry = true) {
|
|
356
448
|
// Start the main application server
|
|
357
449
|
const httpServer = useDynamicService("HttpServer");
|
|
358
450
|
if (httpServer?.start) {
|
|
@@ -360,23 +452,46 @@ let DebugService = (() => {
|
|
|
360
452
|
this.log("INFO", `Application server started on port ${servePort}`);
|
|
361
453
|
}
|
|
362
454
|
// Start the debug HTTP + WebSocket server
|
|
363
|
-
await this.startDebugServer(port);
|
|
364
|
-
this.log("INFO", `Debug dashboard API listening on
|
|
455
|
+
await this.startDebugServer(port, { local: !!(web && local) });
|
|
456
|
+
this.log("INFO", `Debug dashboard API listening on 127.0.0.1:${this.listeningPort}`);
|
|
365
457
|
// Launch TUI by default, unless --web is passed
|
|
366
458
|
if (!web) {
|
|
367
|
-
this.tui = new DebugTui(
|
|
459
|
+
this.tui = new DebugTui(this.listeningPort, this.token);
|
|
368
460
|
// Quitting the TUI ends the command
|
|
369
461
|
this.tui.onStop = () => this.settle();
|
|
370
462
|
await this.tui.start();
|
|
463
|
+
return;
|
|
371
464
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
465
|
+
const url = buildDebugUrl({
|
|
466
|
+
port: this.listeningPort,
|
|
467
|
+
token: this.token,
|
|
468
|
+
local,
|
|
469
|
+
code: local ? this.issueBootstrapCode() : undefined,
|
|
470
|
+
telemetry: resolveTelemetry(telemetry, process.env),
|
|
471
|
+
hostedBase: process.env.WEBDA_DEBUG_UI_URL
|
|
472
|
+
});
|
|
473
|
+
this.dashboardUrl = url;
|
|
474
|
+
this.printDashboardUrl(url);
|
|
475
|
+
// Headless callers (CI, Playwright's `webServer`, scripted smoke
|
|
476
|
+
// tests) set WEBDA_DEBUG_NO_BROWSER=1 to suppress the auto-open —
|
|
477
|
+
// they manage their own browser instance and don't want the local
|
|
478
|
+
// Chrome to pop a stray tab on every server start.
|
|
479
|
+
if (open && !process.env.WEBDA_DEBUG_NO_BROWSER) {
|
|
480
|
+
this.openBrowser(url);
|
|
378
481
|
}
|
|
379
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Print the dashboard URL for the user.
|
|
485
|
+
*
|
|
486
|
+
* The hosted URL carries the session token in its fragment, so it is written
|
|
487
|
+
* straight to stdout rather than through the logger: log output is captured
|
|
488
|
+
* by the log buffer, broadcast to the dashboard and may be persisted by other
|
|
489
|
+
* loggers.
|
|
490
|
+
* @param url - the dashboard URL
|
|
491
|
+
*/
|
|
492
|
+
printDashboardUrl(url) {
|
|
493
|
+
process.stdout.write(`\nWebda debug dashboard: ${url}\n\n`);
|
|
494
|
+
}
|
|
380
495
|
/**
|
|
381
496
|
* Settle the running debug command
|
|
382
497
|
*/
|
|
@@ -389,42 +504,144 @@ let DebugService = (() => {
|
|
|
389
504
|
* @param url - URL to open
|
|
390
505
|
*/
|
|
391
506
|
openBrowser(url) {
|
|
392
|
-
|
|
393
|
-
exec(`${cmd} ${url}`);
|
|
507
|
+
openInBrowser(url);
|
|
394
508
|
}
|
|
395
509
|
/**
|
|
396
510
|
* Create and start the debug HTTP server with WebSocket support.
|
|
397
|
-
*
|
|
511
|
+
*
|
|
512
|
+
* One server per loopback address: `127.0.0.1` and `::1`, so that no other
|
|
513
|
+
* local process can squat the IPv6 side of `localhost`. A port already taken
|
|
514
|
+
* on either address is an error; an unavailable IPv6 stack is tolerated.
|
|
515
|
+
* The websocket upgrade is handled by hand so that the `Host`, `Origin` and
|
|
516
|
+
* token checks run before `ws` completes the handshake.
|
|
517
|
+
* @param port - Port to listen on (0 for a random one)
|
|
518
|
+
* @param options - local mode
|
|
398
519
|
*/
|
|
399
|
-
async startDebugServer(port) {
|
|
400
|
-
this.
|
|
401
|
-
this.
|
|
520
|
+
async startDebugServer(port, options = {}) {
|
|
521
|
+
this.localMode = !!options.local;
|
|
522
|
+
this.origins = allowedOrigins(process.env);
|
|
523
|
+
this.wss = new WebSocketServer({
|
|
524
|
+
noServer: true,
|
|
525
|
+
handleProtocols: protocols => (protocols.has(WS_PROTOCOL) ? WS_PROTOCOL : false)
|
|
526
|
+
});
|
|
402
527
|
this.wss.on("connection", (ws) => {
|
|
403
528
|
this.clients.add(ws);
|
|
404
529
|
ws.on("close", () => this.clients.delete(ws));
|
|
405
530
|
ws.on("error", () => this.clients.delete(ws));
|
|
406
531
|
});
|
|
407
|
-
await
|
|
408
|
-
|
|
532
|
+
const v4 = await this.listen(port, "127.0.0.1");
|
|
533
|
+
this.server = v4;
|
|
534
|
+
this.listeningPort = v4.address().port;
|
|
535
|
+
try {
|
|
536
|
+
await this.listen(this.listeningPort, "::1");
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
if (err?.code === "EADDRINUSE") {
|
|
540
|
+
// Close without settling the command: the caller must see the failure
|
|
541
|
+
await this.closeServers();
|
|
542
|
+
throw new Error(`[::1]:${this.listeningPort} is already in use: refusing to start with half of localhost exposed`);
|
|
543
|
+
}
|
|
544
|
+
// EADDRNOTAVAIL / EAFNOSUPPORT: no IPv6 loopback on this host
|
|
545
|
+
this.log("WARN", `IPv6 loopback unavailable (${err?.code || err}), listening on 127.0.0.1 only`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Close every bound server.
|
|
550
|
+
*/
|
|
551
|
+
async closeServers() {
|
|
552
|
+
for (const server of this.servers) {
|
|
553
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
554
|
+
}
|
|
555
|
+
this.servers = [];
|
|
556
|
+
this.server = undefined;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Start one HTTP server on an address.
|
|
560
|
+
* @param port - the port
|
|
561
|
+
* @param address - the loopback address
|
|
562
|
+
* @returns the listening server
|
|
563
|
+
*/
|
|
564
|
+
listen(port, address) {
|
|
565
|
+
return new Promise((resolve, reject) => {
|
|
566
|
+
const server = createServer((req, res) => this.handleRequest(req, res));
|
|
567
|
+
server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head));
|
|
568
|
+
server.once("error", reject);
|
|
569
|
+
server.listen(port, address, () => {
|
|
570
|
+
server.removeListener("error", reject);
|
|
571
|
+
this.servers.push(server);
|
|
572
|
+
resolve(server);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Authenticate a websocket upgrade before handing it to `ws`.
|
|
578
|
+
* @param req - the upgrade request
|
|
579
|
+
* @param socket - the underlying socket
|
|
580
|
+
* @param head - the first packet of the upgraded stream
|
|
581
|
+
*/
|
|
582
|
+
handleUpgrade(req, socket, head) {
|
|
583
|
+
const reject = (status, reason) => {
|
|
584
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
585
|
+
socket.destroy();
|
|
586
|
+
};
|
|
587
|
+
if (!isAllowedHost(req.headers.host, this.listeningPort)) {
|
|
588
|
+
reject(403, "Forbidden");
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Browsers always send Origin on a websocket handshake: only the hosted
|
|
592
|
+
// dashboard and the debug origin itself may connect. No Origin = not a browser.
|
|
593
|
+
const origin = req.headers.origin;
|
|
594
|
+
if (origin && !this.isTrustedOrigin(origin)) {
|
|
595
|
+
reject(403, "Forbidden");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!safeEqual(extractWsToken(req.headers["sec-websocket-protocol"]), this.token)) {
|
|
599
|
+
reject(401, "Unauthorized");
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.wss.handleUpgrade(req, socket, head, ws => {
|
|
603
|
+
this.wss.emit("connection", ws, req);
|
|
409
604
|
});
|
|
410
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* Whether an Origin is the hosted dashboard, the dev server or the debug server itself.
|
|
608
|
+
* @param origin - the Origin header value
|
|
609
|
+
* @returns `true` when trusted
|
|
610
|
+
*/
|
|
611
|
+
isTrustedOrigin(origin) {
|
|
612
|
+
return isAllowedOrigin(origin, this.origins) || debugOrigins(this.listeningPort).includes(origin);
|
|
613
|
+
}
|
|
411
614
|
/**
|
|
412
615
|
* Route incoming HTTP requests to introspection handlers.
|
|
413
616
|
* @param req - Incoming HTTP request
|
|
414
617
|
* @param res - Server response
|
|
415
618
|
*/
|
|
416
619
|
handleRequest(req, res) {
|
|
620
|
+
// DNS rebinding: a page on evil.com whose DNS answer flips to 127.0.0.1
|
|
621
|
+
// still sends `Host: evil.com`; only loopback hosts are served.
|
|
622
|
+
if (!isAllowedHost(req.headers.host, this.listeningPort)) {
|
|
623
|
+
this.sendJson(res, { error: "Forbidden" }, 403);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
417
626
|
// CORS: echo the origin back only if it is on the allowlist.
|
|
418
627
|
// If there is no matching origin the browser will block cross-origin access,
|
|
419
628
|
// which is the desired behaviour. We also set Vary: Origin so that CDN /
|
|
420
629
|
// proxy caches do not serve a response with an allowed origin header to a
|
|
421
630
|
// different (disallowed) origin.
|
|
422
631
|
const origin = req.headers.origin;
|
|
423
|
-
|
|
632
|
+
const originAllowed = isAllowedOrigin(origin, this.origins);
|
|
633
|
+
// Vary on every response so that no cache serves one origin's answer to another
|
|
634
|
+
res.setHeader("Vary", "Origin");
|
|
635
|
+
if (originAllowed) {
|
|
424
636
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
425
|
-
res.setHeader("
|
|
426
|
-
res.setHeader("Access-Control-Allow-
|
|
427
|
-
res.setHeader("Access-Control-
|
|
637
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
638
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
639
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
640
|
+
// Private Network Access: the hosted dashboard (public) talks to
|
|
641
|
+
// localhost (local); Chrome asks for this on the preflight.
|
|
642
|
+
if (req.method === "OPTIONS" && req.headers["access-control-request-private-network"] === "true") {
|
|
643
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
644
|
+
}
|
|
428
645
|
}
|
|
429
646
|
if (req.method === "OPTIONS") {
|
|
430
647
|
res.writeHead(204);
|
|
@@ -433,10 +650,29 @@ let DebugService = (() => {
|
|
|
433
650
|
}
|
|
434
651
|
const url = req.url || "/";
|
|
435
652
|
const pathname = url.split("?")[0];
|
|
653
|
+
if (!pathname.startsWith("/api/")) {
|
|
654
|
+
this.serveStaticFile(pathname, req, res);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (pathname === "/api/session") {
|
|
658
|
+
this.handleSessionExchange(req, res);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// Every API response needs the session token; no data leaves without it.
|
|
662
|
+
if (!safeEqual(extractBearerToken(req.headers.authorization), this.token)) {
|
|
663
|
+
res.setHeader("WWW-Authenticate", 'Bearer realm="webda-debug"');
|
|
664
|
+
this.sendJson(res, { error: "Unauthorized" }, 401);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
436
667
|
try {
|
|
437
668
|
// Route to handlers
|
|
438
669
|
if (pathname === "/api/info") {
|
|
439
|
-
this.sendJson(res,
|
|
670
|
+
this.sendJson(res, {
|
|
671
|
+
...getAppInfo(),
|
|
672
|
+
debugApiVersion: DEBUG_API_VERSION,
|
|
673
|
+
debugVersion: DEBUG_VERSION,
|
|
674
|
+
frameworkVersion: this.frameworkVersion
|
|
675
|
+
});
|
|
440
676
|
}
|
|
441
677
|
else if (pathname === "/api/models") {
|
|
442
678
|
this.sendJson(res, getModels());
|
|
@@ -486,7 +722,7 @@ let DebugService = (() => {
|
|
|
486
722
|
this.sendJson(res, query ? this.logBuffer.search(query) : this.logBuffer.getEntries());
|
|
487
723
|
}
|
|
488
724
|
else {
|
|
489
|
-
this.
|
|
725
|
+
this.sendJson(res, { error: "Not found" }, 404);
|
|
490
726
|
}
|
|
491
727
|
}
|
|
492
728
|
catch (err) {
|
|
@@ -515,29 +751,136 @@ let DebugService = (() => {
|
|
|
515
751
|
}
|
|
516
752
|
}
|
|
517
753
|
/**
|
|
518
|
-
*
|
|
519
|
-
*
|
|
754
|
+
* `POST /api/session`: exchange the one-time bootstrap code for the session token (`--local` only).
|
|
755
|
+
*
|
|
756
|
+
* The code is printed in the local URL fragment; the page sends it once and
|
|
757
|
+
* keeps the token in memory. Accepted only from the debug origin itself (or
|
|
758
|
+
* without Origin, for non-browser clients); 404 in hosted mode so the route
|
|
759
|
+
* reveals nothing there. The code is consumed on a successful match only, and a
|
|
760
|
+
* reuse after it was spent is logged (the link was used by another client).
|
|
761
|
+
* @param req - the request
|
|
762
|
+
* @param res - the response
|
|
763
|
+
*/
|
|
764
|
+
handleSessionExchange(req, res) {
|
|
765
|
+
if (!this.localMode) {
|
|
766
|
+
this.sendJson(res, { error: "Not found" }, 404);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (req.method !== "POST") {
|
|
770
|
+
this.sendJson(res, { error: "Method not allowed" }, 405);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const origin = req.headers.origin;
|
|
774
|
+
if (origin && !debugOrigins(this.listeningPort).includes(origin)) {
|
|
775
|
+
this.sendJson(res, { error: "Forbidden" }, 403);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
let body = "";
|
|
779
|
+
req.on("data", chunk => {
|
|
780
|
+
body += chunk;
|
|
781
|
+
if (body.length > 4096)
|
|
782
|
+
req.destroy();
|
|
783
|
+
});
|
|
784
|
+
req.on("end", () => {
|
|
785
|
+
let code;
|
|
786
|
+
try {
|
|
787
|
+
code = JSON.parse(body || "null")?.code;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
code = undefined;
|
|
791
|
+
}
|
|
792
|
+
if (typeof code !== "string" || !code) {
|
|
793
|
+
this.sendJson(res, { error: "Missing code" }, 400);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const pending = this.bootstrap;
|
|
797
|
+
if (!pending) {
|
|
798
|
+
// Either never issued, or already exchanged: a second correct-format attempt
|
|
799
|
+
// after the exchange means another client used the link first
|
|
800
|
+
this.log("WARN", "Debug session link refused: it was already used by another client or has expired");
|
|
801
|
+
this.sendJson(res, { error: "Invalid or expired code" }, 403);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (pending.expires < Date.now()) {
|
|
805
|
+
this.bootstrap = undefined;
|
|
806
|
+
this.sendJson(res, { error: "Invalid or expired code" }, 403);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (!safeEqual(code, pending.code)) {
|
|
810
|
+
// A wrong guess (256-bit code: brute force is infeasible) must not burn the real one
|
|
811
|
+
this.sendJson(res, { error: "Invalid or expired code" }, 403);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Single use: consumed only on a successful match
|
|
815
|
+
this.bootstrap = undefined;
|
|
816
|
+
this.sendJson(res, { token: this.token, debugApiVersion: DEBUG_API_VERSION });
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Security headers of the pages and assets the debug server serves.
|
|
821
|
+
* @param res - the response
|
|
822
|
+
* @param html - whether the response is a page
|
|
823
|
+
*/
|
|
824
|
+
pageHeaders(res, html) {
|
|
825
|
+
res.setHeader("Cache-Control", "no-store");
|
|
826
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
827
|
+
if (html) {
|
|
828
|
+
res.setHeader("Content-Security-Policy", LOCAL_PAGE_CSP);
|
|
829
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
830
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Serve a static file from the webui directory (`--local`), or the hosted
|
|
835
|
+
* placeholder page.
|
|
836
|
+
*
|
|
837
|
+
* The page never carries the token: the local page obtains it through the
|
|
838
|
+
* one-time code exchange. Unknown paths fall back to index.html (SPA routing);
|
|
839
|
+
* directories are not found.
|
|
520
840
|
* @param pathname - Request pathname
|
|
841
|
+
* @param req - Incoming request
|
|
521
842
|
* @param res - Server response
|
|
522
843
|
*/
|
|
523
|
-
serveStaticFile(pathname, res) {
|
|
844
|
+
serveStaticFile(pathname, req, res) {
|
|
845
|
+
if (!this.localMode) {
|
|
846
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
847
|
+
this.pageHeaders(res, true);
|
|
848
|
+
res.writeHead(200, { "Content-Type": MIME_TYPES[".html"] });
|
|
849
|
+
res.end(hostedPlaceholderPage(process.env.WEBDA_DEBUG_UI_URL || HOSTED_DASHBOARD_URL));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
this.sendJson(res, { error: "Not found" }, 404);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
524
855
|
// Prevent directory traversal
|
|
525
856
|
const safePath = pathname.replace(/\.\./g, "").replace(/\/+/g, "/");
|
|
526
857
|
let filePath = join(WEBUI_DIR, safePath === "/" ? "index.html" : safePath);
|
|
527
|
-
//
|
|
528
|
-
|
|
858
|
+
// SPA fallback for unknown paths (not for existing directories such as /assets)
|
|
859
|
+
const isFile = (file) => {
|
|
860
|
+
try {
|
|
861
|
+
return statSync(file).isFile();
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
if (!isFile(filePath)) {
|
|
868
|
+
if (existsSync(filePath) || safePath.startsWith("/assets")) {
|
|
869
|
+
this.sendJson(res, { error: "Not found" }, 404);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
529
872
|
filePath = join(WEBUI_DIR, "index.html");
|
|
530
873
|
}
|
|
531
|
-
if (!
|
|
874
|
+
if (!isFile(filePath)) {
|
|
532
875
|
this.sendJson(res, { error: "Not found" }, 404);
|
|
533
876
|
return;
|
|
534
877
|
}
|
|
535
878
|
try {
|
|
536
|
-
const content = readFileSync(filePath);
|
|
537
879
|
const ext = extname(filePath);
|
|
538
880
|
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
881
|
+
this.pageHeaders(res, ext === ".html");
|
|
539
882
|
res.writeHead(200, { "Content-Type": mime });
|
|
540
|
-
res.end(
|
|
883
|
+
res.end(readFileSync(filePath));
|
|
541
884
|
}
|
|
542
885
|
catch {
|
|
543
886
|
this.sendJson(res, { error: "Internal server error" }, 500);
|
|
@@ -550,7 +893,7 @@ let DebugService = (() => {
|
|
|
550
893
|
* @param statusCode - HTTP status code
|
|
551
894
|
*/
|
|
552
895
|
sendJson(res, data, statusCode = 200) {
|
|
553
|
-
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
896
|
+
res.writeHead(statusCode, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
554
897
|
res.end(JSON.stringify(data));
|
|
555
898
|
}
|
|
556
899
|
/**
|
|
@@ -585,10 +928,8 @@ let DebugService = (() => {
|
|
|
585
928
|
this.wss.close();
|
|
586
929
|
this.wss = undefined;
|
|
587
930
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
this.server = undefined;
|
|
591
|
-
}
|
|
931
|
+
await this.closeServers();
|
|
932
|
+
this.bootstrap = undefined;
|
|
592
933
|
this.timings.clear();
|
|
593
934
|
this.tui?.stop();
|
|
594
935
|
this.tui = undefined;
|
|
@@ -598,7 +939,10 @@ let DebugService = (() => {
|
|
|
598
939
|
},
|
|
599
940
|
(() => {
|
|
600
941
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
601
|
-
_debug_decorators = [Command("debug", {
|
|
942
|
+
_debug_decorators = [Command("debug", {
|
|
943
|
+
description: "Start dev server with debug dashboard",
|
|
944
|
+
requires: ["router", "rest-domain", "http-server"]
|
|
945
|
+
})];
|
|
602
946
|
__esDecorate(_a, null, _debug_decorators, { kind: "method", name: "debug", static: false, private: false, access: { has: obj => "debug" in obj, get: obj => obj.debug }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
603
947
|
if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
604
948
|
})(),
|