@dutodev/cli 1.4.0 → 1.6.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/dist/index.js
CHANGED
|
@@ -3,24 +3,31 @@ import {
|
|
|
3
3
|
C,
|
|
4
4
|
CLI_USER_AGENT,
|
|
5
5
|
CLI_VERSION_HEADER,
|
|
6
|
+
FRAMING_HEADERS,
|
|
6
7
|
MAX_NAME,
|
|
7
8
|
TRUNCATED_NOTE,
|
|
9
|
+
UNTRUSTED_VISITOR_HEADERS,
|
|
8
10
|
apiUrl,
|
|
9
11
|
bodyNote,
|
|
10
12
|
checkForUpdate,
|
|
13
|
+
describeChange,
|
|
11
14
|
dutoHome,
|
|
12
15
|
formatBytes,
|
|
13
16
|
formatDuration,
|
|
14
17
|
formatTime,
|
|
18
|
+
headerLabel,
|
|
19
|
+
isCredentialLike,
|
|
20
|
+
isValidHeaderValue,
|
|
15
21
|
loadConfig,
|
|
16
22
|
localLabel,
|
|
17
23
|
normalizeName,
|
|
18
24
|
placeOf,
|
|
25
|
+
reservedHeaderError,
|
|
19
26
|
saveConfig,
|
|
20
27
|
statusColor,
|
|
21
28
|
updateLine,
|
|
22
29
|
validateName
|
|
23
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-DUC4RSBW.js";
|
|
24
31
|
|
|
25
32
|
// src/ui/brand.ts
|
|
26
33
|
function colorOn(stream = process.stdout) {
|
|
@@ -36,8 +43,8 @@ var SGR = {
|
|
|
36
43
|
warn: "38;2;250;204;21",
|
|
37
44
|
err: "38;2;248;113;113"
|
|
38
45
|
};
|
|
39
|
-
function paint(tone,
|
|
40
|
-
return colorOn(stream) ? `\x1B[${SGR[tone]}m${
|
|
46
|
+
function paint(tone, text2, stream) {
|
|
47
|
+
return colorOn(stream) ? `\x1B[${SGR[tone]}m${text2}\x1B[0m` : text2;
|
|
41
48
|
}
|
|
42
49
|
var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
|
|
43
50
|
var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
|
|
@@ -50,7 +57,7 @@ import { join as join5, relative } from "path";
|
|
|
50
57
|
import cac from "cac";
|
|
51
58
|
|
|
52
59
|
// src/version.ts
|
|
53
|
-
var CLI_VERSION = "1.
|
|
60
|
+
var CLI_VERSION = "1.6.0";
|
|
54
61
|
|
|
55
62
|
// src/api.ts
|
|
56
63
|
var DutoApiError = class extends Error {
|
|
@@ -368,7 +375,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
|
|
|
368
375
|
var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
369
376
|
var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
|
|
370
377
|
var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
371
|
-
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
|
|
378
|
+
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
|
|
372
379
|
var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
|
|
373
380
|
function parseHost(raw) {
|
|
374
381
|
const host = raw.trim().toLowerCase();
|
|
@@ -383,14 +390,29 @@ function parseCredential(raw) {
|
|
|
383
390
|
if (password.length < 8 || password.length > 128) return { ok: false, reason: "password" };
|
|
384
391
|
return { ok: true, cred: { user, password } };
|
|
385
392
|
}
|
|
393
|
+
function expandEnv(text2, env, missing) {
|
|
394
|
+
return text2.replace(ENV_REF_RE, (_m, name) => {
|
|
395
|
+
const val = env[name];
|
|
396
|
+
if (val === void 0) throw missing(name);
|
|
397
|
+
return val;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function headerText(node) {
|
|
401
|
+
if (node === null) return "";
|
|
402
|
+
if (!isScalar(node)) return void 0;
|
|
403
|
+
if (node.value === null) return "";
|
|
404
|
+
if (typeof node.value === "string") return node.value;
|
|
405
|
+
if (typeof node.value === "number" || typeof node.value === "boolean") return node.source ?? String(node.value);
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
386
408
|
function portOf(value) {
|
|
387
409
|
const v = isMap(value) ? value.get("port") : isScalar(value) ? value.value : value;
|
|
388
410
|
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535 ? v : null;
|
|
389
411
|
}
|
|
390
|
-
function parseConfig(
|
|
412
|
+
function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
|
|
391
413
|
const fail3 = (message2, line) => new ConfigError(message2, line, file);
|
|
392
414
|
const lc = new LineCounter();
|
|
393
|
-
const doc = parseDocument(
|
|
415
|
+
const doc = parseDocument(text2, { lineCounter: lc, prettyErrors: false });
|
|
394
416
|
const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
|
|
395
417
|
if (doc.errors.length) {
|
|
396
418
|
const e = doc.errors[0];
|
|
@@ -401,9 +423,9 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
401
423
|
throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
|
|
402
424
|
}
|
|
403
425
|
const specs = [];
|
|
404
|
-
for (const
|
|
405
|
-
const raw = isScalar(
|
|
406
|
-
const line = lineAt(
|
|
426
|
+
for (const pair2 of tunnels.items) {
|
|
427
|
+
const raw = isScalar(pair2.key) ? String(pair2.key.value) : "";
|
|
428
|
+
const line = lineAt(pair2.key?.range?.[0]);
|
|
407
429
|
const name = normalizeName(raw);
|
|
408
430
|
const v = validateName(name);
|
|
409
431
|
if (!v.ok) {
|
|
@@ -412,14 +434,14 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
412
434
|
line
|
|
413
435
|
);
|
|
414
436
|
}
|
|
415
|
-
const port = portOf(
|
|
437
|
+
const port = portOf(pair2.value);
|
|
416
438
|
if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
|
|
417
439
|
const spec = { name, port };
|
|
418
|
-
if (isMap(
|
|
419
|
-
const body =
|
|
440
|
+
if (isMap(pair2.value)) {
|
|
441
|
+
const body = pair2.value;
|
|
420
442
|
for (const item of body.items) {
|
|
421
443
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
422
|
-
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
|
|
444
|
+
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, trust_forwarded, auth or allow)`, line);
|
|
423
445
|
}
|
|
424
446
|
const hostNode = body.get("host", true);
|
|
425
447
|
if (hostNode !== void 0) {
|
|
@@ -430,21 +452,22 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
430
452
|
const headersNode = body.get("headers", true);
|
|
431
453
|
if (headersNode !== void 0) {
|
|
432
454
|
if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
|
|
433
|
-
const headers =
|
|
455
|
+
const headers = /* @__PURE__ */ new Map();
|
|
434
456
|
for (const h of headersNode.items) {
|
|
435
457
|
const hName = isScalar(h.key) ? String(h.key.value) : "";
|
|
436
458
|
const hLine = lineAt(h.key?.range?.[0]);
|
|
437
|
-
if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
459
|
+
if (!HEADER_NAME_RE.test(hName) || hName === "__proto__") throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
438
460
|
if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
|
|
439
|
-
const
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
461
|
+
const reserved = reservedHeaderError(hName);
|
|
462
|
+
if (reserved) throw fail3(reserved, hLine);
|
|
463
|
+
if (headers.has(hName.toLowerCase())) throw fail3(`header ${hName} appears twice in "${raw}"`, hLine);
|
|
464
|
+
const text3 = headerText(h.value);
|
|
465
|
+
if (text3 === void 0) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
466
|
+
const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}")`, hLine));
|
|
467
|
+
if (!isValidHeaderValue(value)) throw fail3(`invalid character in the value of ${hName} in "${raw}". Use ASCII or Latin-1 text.`, hLine);
|
|
468
|
+
headers.set(hName.toLowerCase(), value);
|
|
446
469
|
}
|
|
447
|
-
spec.headers = headers;
|
|
470
|
+
spec.headers = Object.fromEntries(headers);
|
|
448
471
|
}
|
|
449
472
|
const rewriteNode = body.get("rewrite_redirects", true);
|
|
450
473
|
if (rewriteNode !== void 0) {
|
|
@@ -453,19 +476,21 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
453
476
|
if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
|
|
454
477
|
if (value === false) spec.rewriteRedirects = false;
|
|
455
478
|
}
|
|
479
|
+
const trustNode = body.get("trust_forwarded", true);
|
|
480
|
+
if (trustNode !== void 0) {
|
|
481
|
+
const value = isScalar(trustNode) ? trustNode.value : void 0;
|
|
482
|
+
if (typeof value !== "boolean") throw fail3(`trust_forwarded in "${raw}" must be true or false`, line);
|
|
483
|
+
if (value) spec.trustForwarded = true;
|
|
484
|
+
}
|
|
456
485
|
const authNode = body.get("auth", true);
|
|
457
486
|
if (authNode !== void 0) {
|
|
458
487
|
if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
|
|
459
488
|
const auth = [];
|
|
460
489
|
for (const item of authNode.items) {
|
|
461
490
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
464
|
-
const expanded =
|
|
465
|
-
const val = env[name2];
|
|
466
|
-
if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
|
|
467
|
-
return val;
|
|
468
|
-
});
|
|
491
|
+
const text3 = isScalar(item) && typeof item.value === "string" ? item.value : null;
|
|
492
|
+
if (text3 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
493
|
+
const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine));
|
|
469
494
|
const parsed = parseCredential(expanded);
|
|
470
495
|
if (!parsed.ok) {
|
|
471
496
|
if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
@@ -513,6 +538,19 @@ function findConfig(cwd) {
|
|
|
513
538
|
}
|
|
514
539
|
}
|
|
515
540
|
|
|
541
|
+
// src/session/warnings.ts
|
|
542
|
+
function joinNames(names) {
|
|
543
|
+
return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
544
|
+
}
|
|
545
|
+
function credentialWarnings(specs) {
|
|
546
|
+
return specs.flatMap((spec) => {
|
|
547
|
+
if (spec.auth?.length) return [];
|
|
548
|
+
const names = Object.entries(spec.headers ?? {}).filter(([name, value]) => value !== "" && isCredentialLike(name)).map(([name]) => headerLabel(name));
|
|
549
|
+
if (!names.length) return [];
|
|
550
|
+
return [`${spec.name ?? `localhost:${spec.port}`} injects ${joinNames(names)} and has no auth: anyone with the URL is signed in.`];
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
516
554
|
// src/login.ts
|
|
517
555
|
import { spawn } from "child_process";
|
|
518
556
|
import { hostname } from "os";
|
|
@@ -523,7 +561,7 @@ async function browserLogin(o) {
|
|
|
523
561
|
const start = await api.startLogin({
|
|
524
562
|
deviceId: o.deviceId,
|
|
525
563
|
deviceName: hostname(),
|
|
526
|
-
cliVersion: "1.
|
|
564
|
+
cliVersion: "1.6.0",
|
|
527
565
|
nodeVersion: process.version,
|
|
528
566
|
os: process.platform,
|
|
529
567
|
arch: process.arch
|
|
@@ -615,13 +653,78 @@ function edgeLabel(edge) {
|
|
|
615
653
|
return place ? "via " + edge.colo + " (" + place + ")" : "via " + edge.colo;
|
|
616
654
|
}
|
|
617
655
|
|
|
656
|
+
// src/inspector/filter.ts
|
|
657
|
+
function matchesFilter(item, text2) {
|
|
658
|
+
function compare(value, spec, unit) {
|
|
659
|
+
const m = /^(>=|<=|>|<|=)?(.+)$/.exec(spec);
|
|
660
|
+
if (!m) return null;
|
|
661
|
+
const limit = unit(m[2] ?? "");
|
|
662
|
+
if (limit === null) return null;
|
|
663
|
+
if (value === null) return false;
|
|
664
|
+
const op = m[1] || "=";
|
|
665
|
+
if (op === ">") return value > limit;
|
|
666
|
+
if (op === ">=") return value >= limit;
|
|
667
|
+
if (op === "<") return value < limit;
|
|
668
|
+
if (op === "<=") return value <= limit;
|
|
669
|
+
return value === limit;
|
|
670
|
+
}
|
|
671
|
+
function plainNumber(raw) {
|
|
672
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
673
|
+
}
|
|
674
|
+
function millis(raw) {
|
|
675
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(raw);
|
|
676
|
+
if (!m) return null;
|
|
677
|
+
return m[2] === "s" ? Number(m[1]) * 1e3 : Number(m[1]);
|
|
678
|
+
}
|
|
679
|
+
function status(spec) {
|
|
680
|
+
const cls = /^([1-5])xx$/.exec(spec);
|
|
681
|
+
if (cls) return item.status !== null && Math.floor(item.status / 100) === Number(cls[1]);
|
|
682
|
+
return compare(item.status, spec, plainNumber);
|
|
683
|
+
}
|
|
684
|
+
function kind(spec) {
|
|
685
|
+
const s = item.status;
|
|
686
|
+
if (spec === "error") return s !== null && s >= 400 || !!item.error;
|
|
687
|
+
if (spec === "redirect") return s !== null && s >= 300 && s < 400;
|
|
688
|
+
if (spec === "rewritten") return !!item.rewritten;
|
|
689
|
+
if (spec === "replay") return item.replayOf !== void 0;
|
|
690
|
+
if (spec === "ws") return item.websocket;
|
|
691
|
+
if (spec === "pending") return s === null && !item.error;
|
|
692
|
+
if (spec === "issue") return (item.issues ?? 0) > 0;
|
|
693
|
+
if (spec === "duto") return item.origin === "duto";
|
|
694
|
+
if (spec === "app") return item.origin === "app";
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
function field(key, value) {
|
|
698
|
+
const v = value.toLowerCase();
|
|
699
|
+
if (key === "status") return status(v);
|
|
700
|
+
if (key === "is") return kind(v);
|
|
701
|
+
if (key === "duration") return compare(item.durationMs, v, millis);
|
|
702
|
+
if (key === "method") return (item.websocket ? "ws" : item.method.toLowerCase()) === v;
|
|
703
|
+
if (key === "tunnel") return item.tunnel.toLowerCase() === v;
|
|
704
|
+
if (key === "path") return item.path.toLowerCase().indexOf(v) >= 0;
|
|
705
|
+
if (key === "client") return (item.client ?? "").toLowerCase() === v;
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const haystack = [item.tunnel, item.websocket ? "ws" : item.method, item.path, item.status === null ? "" : String(item.status), item.client ?? ""].join(" ").toLowerCase();
|
|
709
|
+
const terms = text2.trim().split(/\s+/).filter(Boolean);
|
|
710
|
+
for (const raw of terms) {
|
|
711
|
+
const negate = raw.length > 1 && raw.charAt(0) === "-";
|
|
712
|
+
const term = negate ? raw.slice(1) : raw;
|
|
713
|
+
const sep = term.indexOf(":");
|
|
714
|
+
let hit = sep > 0 ? field(term.slice(0, sep).toLowerCase(), term.slice(sep + 1)) : null;
|
|
715
|
+
if (hit === null) hit = haystack.indexOf(term.toLowerCase()) >= 0;
|
|
716
|
+
if (hit === negate) return false;
|
|
717
|
+
}
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
|
|
618
721
|
// src/inspector/jsonView.ts
|
|
619
722
|
function countLabel(n, one, many) {
|
|
620
723
|
return n + " " + (n === 1 ? one : many);
|
|
621
724
|
}
|
|
622
|
-
function jsonSyntaxError(
|
|
725
|
+
function jsonSyntaxError(text2) {
|
|
623
726
|
try {
|
|
624
|
-
JSON.parse(
|
|
727
|
+
JSON.parse(text2);
|
|
625
728
|
return null;
|
|
626
729
|
} catch (e) {
|
|
627
730
|
const message2 = String(e && e.message ? e.message : e);
|
|
@@ -631,65 +734,65 @@ function jsonSyntaxError(text) {
|
|
|
631
734
|
if (lc) {
|
|
632
735
|
let idx = 0;
|
|
633
736
|
for (let ln = 1; ln < Number(lc[1]); ln++) {
|
|
634
|
-
const nl =
|
|
635
|
-
if (nl < 0) return { position:
|
|
737
|
+
const nl = text2.indexOf("\n", idx);
|
|
738
|
+
if (nl < 0) return { position: text2.length, message: message2 };
|
|
636
739
|
idx = nl + 1;
|
|
637
740
|
}
|
|
638
|
-
return { position: Math.min(idx + Number(lc[2]) - 1,
|
|
741
|
+
return { position: Math.min(idx + Number(lc[2]) - 1, text2.length), message: message2 };
|
|
639
742
|
}
|
|
640
|
-
return { position:
|
|
743
|
+
return { position: text2.length, message: message2 };
|
|
641
744
|
}
|
|
642
745
|
}
|
|
643
|
-
function jsonTokens(
|
|
746
|
+
function jsonTokens(text2) {
|
|
644
747
|
const out = [];
|
|
645
748
|
let depth = 0;
|
|
646
749
|
let i = 0;
|
|
647
|
-
while (i <
|
|
648
|
-
const c =
|
|
750
|
+
while (i < text2.length) {
|
|
751
|
+
const c = text2.charAt(i);
|
|
649
752
|
let j = i + 1;
|
|
650
753
|
let kind;
|
|
651
754
|
if (c === " " || c === "\n" || c === " " || c === "\r") {
|
|
652
|
-
while (j <
|
|
755
|
+
while (j < text2.length && " \n \r".indexOf(text2.charAt(j)) >= 0) j++;
|
|
653
756
|
kind = "space";
|
|
654
757
|
} else if (c === '"') {
|
|
655
|
-
while (j <
|
|
656
|
-
j = Math.min(j + 1,
|
|
758
|
+
while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
|
|
759
|
+
j = Math.min(j + 1, text2.length);
|
|
657
760
|
let k = j;
|
|
658
|
-
while (k <
|
|
659
|
-
kind =
|
|
761
|
+
while (k < text2.length && " \n \r".indexOf(text2.charAt(k)) >= 0) k++;
|
|
762
|
+
kind = text2.charAt(k) === ":" ? "key" : "string";
|
|
660
763
|
} else if ("{}[],:".indexOf(c) >= 0) {
|
|
661
764
|
kind = "punct";
|
|
662
765
|
if (c === "}" || c === "]") depth = Math.max(0, depth - 1);
|
|
663
766
|
} else {
|
|
664
|
-
while (j <
|
|
665
|
-
const word =
|
|
767
|
+
while (j < text2.length && ' \n \r{}[],:"'.indexOf(text2.charAt(j)) < 0) j++;
|
|
768
|
+
const word = text2.slice(i, j);
|
|
666
769
|
kind = word === "true" || word === "false" || word === "null" ? "literal" : "number";
|
|
667
770
|
}
|
|
668
|
-
out.push({ kind, text:
|
|
771
|
+
out.push({ kind, text: text2.slice(i, j), depth });
|
|
669
772
|
if (kind === "punct" && (c === "{" || c === "[")) depth++;
|
|
670
773
|
i = j;
|
|
671
774
|
}
|
|
672
775
|
return out;
|
|
673
776
|
}
|
|
674
|
-
function jsonIndent(
|
|
777
|
+
function jsonIndent(text2) {
|
|
675
778
|
let out = "";
|
|
676
779
|
let depth = 0;
|
|
677
780
|
let i = 0;
|
|
678
781
|
const ws = " \n \r";
|
|
679
|
-
while (i <
|
|
680
|
-
const c =
|
|
782
|
+
while (i < text2.length) {
|
|
783
|
+
const c = text2.charAt(i);
|
|
681
784
|
if (ws.indexOf(c) >= 0) {
|
|
682
785
|
i++;
|
|
683
786
|
} else if (c === '"') {
|
|
684
787
|
let j = i + 1;
|
|
685
|
-
while (j <
|
|
686
|
-
out +=
|
|
788
|
+
while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
|
|
789
|
+
out += text2.slice(i, j + 1);
|
|
687
790
|
i = j + 1;
|
|
688
791
|
} else if (c === "{" || c === "[") {
|
|
689
792
|
let j = i + 1;
|
|
690
|
-
while (j <
|
|
691
|
-
if (
|
|
692
|
-
out += c +
|
|
793
|
+
while (j < text2.length && ws.indexOf(text2.charAt(j)) >= 0) j++;
|
|
794
|
+
if (text2.charAt(j) === (c === "{" ? "}" : "]")) {
|
|
795
|
+
out += c + text2.charAt(j);
|
|
693
796
|
i = j + 1;
|
|
694
797
|
} else {
|
|
695
798
|
depth++;
|
|
@@ -708,8 +811,8 @@ function jsonIndent(text) {
|
|
|
708
811
|
i++;
|
|
709
812
|
} else {
|
|
710
813
|
let j = i + 1;
|
|
711
|
-
while (j <
|
|
712
|
-
out +=
|
|
814
|
+
while (j < text2.length && (ws + '{}[],:"').indexOf(text2.charAt(j)) < 0) j++;
|
|
815
|
+
out += text2.slice(i, j);
|
|
713
816
|
i = j;
|
|
714
817
|
}
|
|
715
818
|
}
|
|
@@ -741,14 +844,14 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
741
844
|
.tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
|
|
742
845
|
.tunnels .k { color: ${C.strong}; }
|
|
743
846
|
.banner { padding: 6px 12px; background: ${BAR}; border-bottom: 1px solid ${C.err}; color: ${C.err}; }
|
|
744
|
-
.main { flex: 1; min-height: 0; display: grid; grid-template-columns:
|
|
847
|
+
.main { flex: 1; min-height: 0; display: grid; grid-template-columns: 400px 1fr; }
|
|
745
848
|
.side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
|
|
746
849
|
.search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
|
|
747
850
|
.search:focus { border-color: ${C.signal}; }
|
|
748
851
|
.search::placeholder { color: ${C.muted}; }
|
|
749
852
|
.list { flex: 1; min-height: 0; overflow-y: auto; outline: none; }
|
|
750
|
-
.row { display: grid; grid-template-columns: 14px
|
|
751
|
-
.list.multi .row { grid-template-columns: 14px 56px
|
|
853
|
+
.row { display: grid; grid-template-columns: 14px 56px minmax(0, 1fr) 34px 60px; gap: 8px; padding: 5px 12px; border-bottom: 1px solid ${ROW_LINE}; align-items: center; cursor: pointer; white-space: nowrap; }
|
|
854
|
+
.list.multi .row { grid-template-columns: 14px 56px 56px minmax(0, 1fr) 34px 60px; }
|
|
752
855
|
.row:hover { background: #141917; }
|
|
753
856
|
.row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
754
857
|
.row span { overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -756,7 +859,30 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
756
859
|
.m { font-weight: 700; }
|
|
757
860
|
.mut { color: ${C.muted}; }
|
|
758
861
|
.sig { color: ${C.signal}; }
|
|
759
|
-
.
|
|
862
|
+
.warn { color: ${C.warn}; }
|
|
863
|
+
.errc { color: ${C.err}; }
|
|
864
|
+
.row.inflow { box-shadow: inset 2px 0 0 ${C.muted}; }
|
|
865
|
+
.row.sel.inflow { box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
866
|
+
.to { color: ${C.muted}; }
|
|
867
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
|
|
868
|
+
.chip { border: 1px solid ${INPUT_LINE}; border-radius: 999px; padding: 1px 8px; color: ${C.muted}; }
|
|
869
|
+
.chip:hover { color: ${TEXT}; }
|
|
870
|
+
.chip.on { border-color: ${C.signal}; color: ${C.signal}; }
|
|
871
|
+
.chip b { font-weight: 400; color: ${TEXT}; }
|
|
872
|
+
.sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
|
|
873
|
+
.help { padding: 0 12px 8px; color: ${C.muted}; display: grid; gap: 2px; }
|
|
874
|
+
.help code { color: ${TEXT}; font: inherit; }
|
|
875
|
+
.ov { padding: 4px 0 12px; }
|
|
876
|
+
.issue { margin: 8px 12px 0; padding: 6px 10px; border-left: 2px solid ${C.warn}; background: #16140f; }
|
|
877
|
+
.issue.error { border-left-color: ${C.err}; background: #1a1112; }
|
|
878
|
+
.issue.info { border-left-color: ${C.muted}; background: #141716; }
|
|
879
|
+
.issue b { display: block; color: ${C.strong}; font-weight: 600; }
|
|
880
|
+
.issue span { color: ${C.muted}; }
|
|
881
|
+
.flow { margin: 6px 12px 0; display: grid; gap: 2px; max-width: 760px; }
|
|
882
|
+
.step { display: grid; grid-template-columns: 40px 52px minmax(0, 1fr) 40px; gap: 8px; text-align: left; padding: 2px 6px; border-radius: 4px; }
|
|
883
|
+
.step:hover { background: #141917; }
|
|
884
|
+
.step.cur { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
885
|
+
.step span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
760
886
|
.empty { padding: 12px; color: ${C.muted}; }
|
|
761
887
|
.detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
762
888
|
.pane { flex: 1; }
|
|
@@ -798,14 +924,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
|
798
924
|
var SCRIPT = String.raw`"use strict";
|
|
799
925
|
var countLabel = (${countLabel.toString()});
|
|
800
926
|
var edgeLabel = (${edgeLabel.toString()});
|
|
927
|
+
var matchesFilter = (${matchesFilter.toString()});
|
|
801
928
|
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
802
929
|
var jsonTokens = (${jsonTokens.toString()});
|
|
803
930
|
var jsonIndent = (${jsonIndent.toString()});
|
|
804
931
|
var COLORS = ${JSON.stringify(C)};
|
|
805
932
|
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
806
|
-
var
|
|
933
|
+
var CHIPS = [
|
|
934
|
+
{ label: "Errors", query: "is:error" },
|
|
935
|
+
{ label: "Redirects", query: "is:redirect" },
|
|
936
|
+
{ label: "Issues", query: "is:issue" },
|
|
937
|
+
{ label: "Slow", query: "duration:>1s" }
|
|
938
|
+
];
|
|
807
939
|
|
|
808
|
-
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "
|
|
940
|
+
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Overview", ended: false, clearedAfter: 0 };
|
|
809
941
|
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
810
942
|
var es = null, lost = false, replaying = false;
|
|
811
943
|
function fail(e) { if (!state.ended) console.error(e); }
|
|
@@ -857,14 +989,60 @@ function header(h, name) {
|
|
|
857
989
|
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
858
990
|
return "";
|
|
859
991
|
}
|
|
992
|
+
function kept() {
|
|
993
|
+
return state.items.filter(function (it) { return it.id > state.clearedAfter; });
|
|
994
|
+
}
|
|
860
995
|
function visible() {
|
|
861
|
-
var f = $("filter").value
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
996
|
+
var f = $("filter").value;
|
|
997
|
+
return kept().filter(function (it) { return matchesFilter(it, f); });
|
|
998
|
+
}
|
|
999
|
+
function setFilter(text) {
|
|
1000
|
+
$("filter").value = text;
|
|
1001
|
+
renderList();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function renderChips() {
|
|
1005
|
+
var box = $("chips");
|
|
1006
|
+
clear(box);
|
|
1007
|
+
var current = $("filter").value.trim();
|
|
1008
|
+
var items = kept();
|
|
1009
|
+
CHIPS.forEach(function (c) {
|
|
1010
|
+
var n = items.filter(function (it) { return matchesFilter(it, c.query); }).length;
|
|
1011
|
+
var b = el("button", "chip" + (current === c.query ? " on" : ""));
|
|
1012
|
+
b.type = "button";
|
|
1013
|
+
b.title = c.query;
|
|
1014
|
+
b.appendChild(document.createTextNode(c.label + " "));
|
|
1015
|
+
b.appendChild(el("b", "", n));
|
|
1016
|
+
b.addEventListener("click", function () { setFilter(current === c.query ? "" : c.query); });
|
|
1017
|
+
box.appendChild(b);
|
|
865
1018
|
});
|
|
866
1019
|
}
|
|
867
1020
|
|
|
1021
|
+
function itemById(id) {
|
|
1022
|
+
for (var i = 0; i < state.items.length; i++) if (state.items[i].id === id) return state.items[i];
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
function chainOf(id) {
|
|
1026
|
+
var ids = {};
|
|
1027
|
+
var start = itemById(id);
|
|
1028
|
+
if (!start || (start.prev === undefined && start.next === undefined)) return ids;
|
|
1029
|
+
var cur = start, guard = 0;
|
|
1030
|
+
while (cur && cur.prev !== undefined && guard++ < 50) cur = itemById(cur.prev);
|
|
1031
|
+
guard = 0;
|
|
1032
|
+
while (cur && guard++ < 50) {
|
|
1033
|
+
ids[cur.id] = true;
|
|
1034
|
+
cur = cur.next === undefined ? null : itemById(cur.next);
|
|
1035
|
+
}
|
|
1036
|
+
return ids;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function markOf(it) {
|
|
1040
|
+
if (it.issues > 0) return { text: "!", cls: it.origin === "duto" || it.error ? "errc" : "warn", title: it.issues === 1 ? "1 issue" : it.issues + " issues" };
|
|
1041
|
+
if (it.replayOf !== undefined) return { text: "↻", cls: "mut", title: "replay of #" + it.replayOf };
|
|
1042
|
+
if (it.rewritten) return { text: "↪", cls: "sig", title: "Location rewritten" };
|
|
1043
|
+
return { text: "", cls: "mut", title: "" };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
868
1046
|
function renderTop() {
|
|
869
1047
|
var n = state.items.length;
|
|
870
1048
|
$("count").textContent = countLabel(n, "request", "requests");
|
|
@@ -883,26 +1061,29 @@ function renderList() {
|
|
|
883
1061
|
var multi = state.tunnels.length > 1;
|
|
884
1062
|
list.classList.toggle("multi", multi);
|
|
885
1063
|
clear(list);
|
|
1064
|
+
renderChips();
|
|
1065
|
+
$("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
|
|
886
1066
|
var items = visible();
|
|
887
1067
|
if (items.length === 0) {
|
|
888
|
-
|
|
1068
|
+
var none = state.items.length === 0 ? "No requests yet." : kept().length === 0 ? "Cleared. New requests show up here." : "No requests match the filter.";
|
|
1069
|
+
list.appendChild(el("div", "empty", none));
|
|
889
1070
|
return;
|
|
890
1071
|
}
|
|
1072
|
+
var flow = state.selected === null ? {} : chainOf(state.selected);
|
|
891
1073
|
items.forEach(function (it) {
|
|
892
|
-
var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
|
|
1074
|
+
var row = el("div", "row" + (it.id === state.selected ? " sel" : "") + (flow[it.id] ? " inflow" : ""));
|
|
893
1075
|
row.setAttribute("role", "option");
|
|
894
1076
|
row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
|
|
895
1077
|
row.dataset.id = String(it.id);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
row.appendChild(mark);
|
|
901
|
-
} else row.appendChild(el("span", "mut", ""));
|
|
1078
|
+
var mark = markOf(it);
|
|
1079
|
+
var ms = el("span", mark.cls, mark.text);
|
|
1080
|
+
ms.title = mark.title;
|
|
1081
|
+
row.appendChild(ms);
|
|
902
1082
|
if (multi) row.appendChild(el("span", "mut", it.tunnel));
|
|
903
1083
|
row.appendChild(el("span", "m", methodOf(it)));
|
|
904
1084
|
var p = el("span", "", it.path);
|
|
905
|
-
p.title = it.path;
|
|
1085
|
+
p.title = it.location ? it.path + " → " + it.location : it.path;
|
|
1086
|
+
if (it.location) p.appendChild(el("span", "to", " → " + it.location));
|
|
906
1087
|
row.appendChild(p);
|
|
907
1088
|
var st = statusInfo(it);
|
|
908
1089
|
var s = el("span", "", st.text);
|
|
@@ -980,13 +1161,6 @@ function renderDetail() {
|
|
|
980
1161
|
if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
|
|
981
1162
|
head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
|
|
982
1163
|
if (d.error) head.appendChild(el("div", "msg err", d.error));
|
|
983
|
-
if (d.redirect) {
|
|
984
|
-
var moved = el("div", "redirect");
|
|
985
|
-
moved.appendChild(el("div", "sig", "↪ Location rewritten"));
|
|
986
|
-
moved.appendChild(el("div", "mut", d.redirect.from));
|
|
987
|
-
moved.appendChild(el("div", "", "→ " + d.redirect.to));
|
|
988
|
-
head.appendChild(moved);
|
|
989
|
-
}
|
|
990
1164
|
|
|
991
1165
|
var tabs = document.querySelectorAll(".tab");
|
|
992
1166
|
for (var i = 0; i < tabs.length; i++) {
|
|
@@ -1003,7 +1177,8 @@ function renderContent() {
|
|
|
1003
1177
|
var box = $("content");
|
|
1004
1178
|
var top = box.scrollTop;
|
|
1005
1179
|
clear(box);
|
|
1006
|
-
if (state.tab === "
|
|
1180
|
+
if (state.tab === "Overview") renderOverview(box, d);
|
|
1181
|
+
else if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
|
|
1007
1182
|
else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
|
|
1008
1183
|
else if (state.tab === "Response") {
|
|
1009
1184
|
box.appendChild(el("div", "sect", "Headers"));
|
|
@@ -1020,6 +1195,135 @@ function renderContent() {
|
|
|
1020
1195
|
box.scrollTop = top;
|
|
1021
1196
|
}
|
|
1022
1197
|
|
|
1198
|
+
function rows(pairs) {
|
|
1199
|
+
var t = el("table");
|
|
1200
|
+
pairs.forEach(function (p) {
|
|
1201
|
+
if (p[1] === null || p[1] === undefined || p[1] === "") return;
|
|
1202
|
+
var tr = el("tr");
|
|
1203
|
+
tr.appendChild(el("th", "", p[0]));
|
|
1204
|
+
tr.appendChild(el("td", "", p[1]));
|
|
1205
|
+
t.appendChild(tr);
|
|
1206
|
+
});
|
|
1207
|
+
return t;
|
|
1208
|
+
}
|
|
1209
|
+
function clock(ms) {
|
|
1210
|
+
var d = new Date(ms);
|
|
1211
|
+
function two(n) { return (n < 10 ? "0" : "") + n; }
|
|
1212
|
+
return two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
|
1213
|
+
}
|
|
1214
|
+
function answeredBy(d) {
|
|
1215
|
+
if (d.origin === null) return "waiting for an answer";
|
|
1216
|
+
if (d.origin === "duto") return d.access && d.access.kind === "denied" ? "duto (tunnel password)" : "duto (nothing answered on localhost)";
|
|
1217
|
+
var t = state.tunnels.filter(function (x) { return x.key === d.tunnel; })[0];
|
|
1218
|
+
return t && t.port ? "your app on localhost:" + t.port : "your app";
|
|
1219
|
+
}
|
|
1220
|
+
function accessText(a) {
|
|
1221
|
+
if (!a) return null;
|
|
1222
|
+
if (a.kind === "user") return "password, user " + a.user;
|
|
1223
|
+
if (a.kind === "allow") return "allowed: " + a.reason;
|
|
1224
|
+
return "denied";
|
|
1225
|
+
}
|
|
1226
|
+
function attrsText(attrs) {
|
|
1227
|
+
return Object.keys(attrs).map(function (k) { return attrs[k] === true ? k : k + "=" + attrs[k]; }).join("; ");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function renderOverview(box, d) {
|
|
1231
|
+
var ov = el("div", "ov");
|
|
1232
|
+
d.issues.forEach(function (i) {
|
|
1233
|
+
var card = el("div", "issue " + i.level);
|
|
1234
|
+
card.appendChild(el("b", "", i.title));
|
|
1235
|
+
card.appendChild(el("span", "", i.detail));
|
|
1236
|
+
ov.appendChild(card);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
if (d.flow.length > 0) {
|
|
1240
|
+
ov.appendChild(el("div", "sect", "Flow"));
|
|
1241
|
+
var flow = el("div", "flow");
|
|
1242
|
+
d.flow.forEach(function (s) {
|
|
1243
|
+
var b = el("button", "step" + (s.id === d.id ? " cur" : ""));
|
|
1244
|
+
b.type = "button";
|
|
1245
|
+
b.appendChild(el("span", "mut", "#" + s.id));
|
|
1246
|
+
b.appendChild(el("span", "m", s.method));
|
|
1247
|
+
b.appendChild(el("span", "", s.path));
|
|
1248
|
+
var st = statusInfo(s);
|
|
1249
|
+
var sp = el("span", "", st.text);
|
|
1250
|
+
sp.style.color = st.color;
|
|
1251
|
+
b.appendChild(sp);
|
|
1252
|
+
b.addEventListener("click", function () { select(s.id); });
|
|
1253
|
+
flow.appendChild(b);
|
|
1254
|
+
});
|
|
1255
|
+
ov.appendChild(flow);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
ov.appendChild(el("div", "sect", "Request"));
|
|
1259
|
+
var timing = d.durationMs === null || d.durationMs === undefined ? null : d.durationMs + " ms";
|
|
1260
|
+
ov.appendChild(rows([
|
|
1261
|
+
["Answered by", answeredBy(d)],
|
|
1262
|
+
["Started", clock(d.startedAt)],
|
|
1263
|
+
["Total time", timing],
|
|
1264
|
+
["First byte", d.ttfbMs === undefined ? null : d.ttfbMs + " ms"],
|
|
1265
|
+
["Size", d.status === null ? null : size(d.bytes)],
|
|
1266
|
+
["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null]
|
|
1267
|
+
]));
|
|
1268
|
+
|
|
1269
|
+
var changes = (d.changes || []).map(function (c) { return [c.label, c.value + " · " + c.reason]; });
|
|
1270
|
+
if (changes.length > 0 || d.redirect || d.access) {
|
|
1271
|
+
ov.appendChild(el("div", "sect", "Changed by Duto"));
|
|
1272
|
+
ov.appendChild(rows(changes.concat([
|
|
1273
|
+
["Location rewritten", d.redirect ? d.redirect.from + " → " + d.redirect.to : null],
|
|
1274
|
+
["Access", accessText(d.access)]
|
|
1275
|
+
])));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (d.location || d.redirect) {
|
|
1279
|
+
ov.appendChild(el("div", "sect", "Redirect"));
|
|
1280
|
+
ov.appendChild(rows([["Location", d.location || (d.redirect && d.redirect.to)]]));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
ov.appendChild(el("div", "sect", "Client"));
|
|
1284
|
+
ov.appendChild(rows([["IP", d.client.ip], ["Country", d.client.country], ["User agent", d.client.userAgent]]));
|
|
1285
|
+
|
|
1286
|
+
if (d.query.length > 0) {
|
|
1287
|
+
ov.appendChild(el("div", "sect", "Query"));
|
|
1288
|
+
ov.appendChild(rows(d.query.map(function (q) { return [q.name, q.value || " "]; })));
|
|
1289
|
+
}
|
|
1290
|
+
if (d.cookies.sent.length > 0) {
|
|
1291
|
+
ov.appendChild(el("div", "sect", "Cookies sent"));
|
|
1292
|
+
ov.appendChild(rows(d.cookies.sent.map(function (c) { return [c.name, c.value || " "]; })));
|
|
1293
|
+
}
|
|
1294
|
+
if (d.cookies.set.length > 0) {
|
|
1295
|
+
ov.appendChild(el("div", "sect", "Cookies set"));
|
|
1296
|
+
ov.appendChild(rows(d.cookies.set.map(function (c) {
|
|
1297
|
+
var a = attrsText(c.attrs);
|
|
1298
|
+
return [c.name, (c.value || "\"\"") + (a ? " · " + a : "")];
|
|
1299
|
+
})));
|
|
1300
|
+
}
|
|
1301
|
+
box.appendChild(ov);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function exportHar() {
|
|
1305
|
+
var ids = visible().map(function (it) { return it.id; });
|
|
1306
|
+
if (ids.length === 0) return showMsg("Nothing to export.", true);
|
|
1307
|
+
fetch("/api/har?ids=" + ids.join(","), { headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
|
|
1308
|
+
if (r.status === 403) { ended(); throw new Error("403"); }
|
|
1309
|
+
return r.blob();
|
|
1310
|
+
}).then(function (blob) {
|
|
1311
|
+
var a = document.createElement("a");
|
|
1312
|
+
a.href = URL.createObjectURL(blob);
|
|
1313
|
+
a.download = "duto-" + new Date().toISOString().replace(/[:.]/g, "-") + ".har";
|
|
1314
|
+
document.body.appendChild(a);
|
|
1315
|
+
a.click();
|
|
1316
|
+
a.remove();
|
|
1317
|
+
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
|
1318
|
+
}).catch(fail);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function toggleClear() {
|
|
1322
|
+
if (state.clearedAfter > 0) state.clearedAfter = 0;
|
|
1323
|
+
else state.clearedAfter = state.items.reduce(function (m, it) { return Math.max(m, it.id); }, 0);
|
|
1324
|
+
renderList();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1023
1327
|
function table(h) {
|
|
1024
1328
|
var keys = Object.keys(h || {});
|
|
1025
1329
|
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
@@ -1195,11 +1499,9 @@ function connect() {
|
|
|
1195
1499
|
banner("Disconnected from the session.");
|
|
1196
1500
|
if (es.readyState === 2) probe();
|
|
1197
1501
|
});
|
|
1198
|
-
es.addEventListener("changed", function (
|
|
1199
|
-
var ids = [];
|
|
1200
|
-
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1502
|
+
es.addEventListener("changed", function () {
|
|
1201
1503
|
loadList();
|
|
1202
|
-
if (state.selected !== null
|
|
1504
|
+
if (state.selected !== null) loadDetail(state.selected);
|
|
1203
1505
|
});
|
|
1204
1506
|
}
|
|
1205
1507
|
function probe() {
|
|
@@ -1219,6 +1521,13 @@ function init() {
|
|
|
1219
1521
|
});
|
|
1220
1522
|
}
|
|
1221
1523
|
$("filter").addEventListener("input", renderList);
|
|
1524
|
+
$("clear").addEventListener("click", toggleClear);
|
|
1525
|
+
$("har").addEventListener("click", exportHar);
|
|
1526
|
+
$("helpBtn").addEventListener("click", function () {
|
|
1527
|
+
var h = $("help");
|
|
1528
|
+
h.hidden = !h.hidden;
|
|
1529
|
+
$("helpBtn").setAttribute("aria-expanded", h.hidden ? "false" : "true");
|
|
1530
|
+
});
|
|
1222
1531
|
$("replay").addEventListener("click", replay);
|
|
1223
1532
|
$("copyBody").addEventListener("click", function () {
|
|
1224
1533
|
var b = state.detail ? copyable(state.detail) : { text: null };
|
|
@@ -1253,7 +1562,7 @@ function init() {
|
|
|
1253
1562
|
init();
|
|
1254
1563
|
`;
|
|
1255
1564
|
function inspectorPage(nonce) {
|
|
1256
|
-
const tabs = ["Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
|
|
1565
|
+
const tabs = ["Overview", "Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
|
|
1257
1566
|
return `<!doctype html>
|
|
1258
1567
|
<html lang="en">
|
|
1259
1568
|
<head>
|
|
@@ -1269,8 +1578,21 @@ function inspectorPage(nonce) {
|
|
|
1269
1578
|
<div class="banner" id="banner" role="status" hidden></div>
|
|
1270
1579
|
<div class="main">
|
|
1271
1580
|
<div class="side">
|
|
1272
|
-
<input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1581
|
+
<input class="search" id="filter" type="search" placeholder="filter\u2026 (status:4xx is:redirect)" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1582
|
+
<div class="chips" id="chips"></div>
|
|
1583
|
+
<div class="help" id="help" hidden>
|
|
1584
|
+
<span><code>status:404</code> <code>status:5xx</code> <code>status:>=400</code></span>
|
|
1585
|
+
<span><code>method:post</code> <code>path:/api</code> <code>tunnel:web</code> <code>client:1.2.3.4</code></span>
|
|
1586
|
+
<span><code>is:error</code> <code>is:redirect</code> <code>is:rewritten</code> <code>is:issue</code> <code>is:duto</code> <code>is:ws</code> <code>is:pending</code></span>
|
|
1587
|
+
<span><code>duration:>500ms</code> <code>duration:>=1s</code></span>
|
|
1588
|
+
<span>Start a term with <code>-</code> to exclude it. All terms must match.</span>
|
|
1589
|
+
</div>
|
|
1273
1590
|
<div class="list" id="list" role="listbox" aria-label="Requests"></div>
|
|
1591
|
+
<div class="sidebar-foot">
|
|
1592
|
+
<button type="button" class="btn" id="clear">Clear</button>
|
|
1593
|
+
<button type="button" class="btn" id="har">Export HAR</button>
|
|
1594
|
+
<button type="button" class="btn" id="helpBtn" aria-expanded="false" aria-controls="help">Filter syntax</button>
|
|
1595
|
+
</div>
|
|
1274
1596
|
</div>
|
|
1275
1597
|
<div class="detail">
|
|
1276
1598
|
<div class="empty" id="none">Select a request from the list.</div>
|
|
@@ -1311,7 +1633,7 @@ function replayBlock(r) {
|
|
|
1311
1633
|
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
|
|
1312
1634
|
return null;
|
|
1313
1635
|
}
|
|
1314
|
-
var HOP_BY_HOP_AND_BODY_HEADERS =
|
|
1636
|
+
var HOP_BY_HOP_AND_BODY_HEADERS = new Set(FRAMING_HEADERS);
|
|
1315
1637
|
function replayHeaders(r) {
|
|
1316
1638
|
const out = {};
|
|
1317
1639
|
for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
@@ -1320,8 +1642,27 @@ function replayHeaders(r) {
|
|
|
1320
1642
|
return out;
|
|
1321
1643
|
}
|
|
1322
1644
|
|
|
1645
|
+
// src/inspector/cookies.ts
|
|
1646
|
+
function pair(part) {
|
|
1647
|
+
const i = part.indexOf("=");
|
|
1648
|
+
return i < 0 ? [part.trim(), null] : [part.slice(0, i).trim(), part.slice(i + 1).trim()];
|
|
1649
|
+
}
|
|
1650
|
+
function parseSetCookie(line) {
|
|
1651
|
+
const [first3 = "", ...rest] = line.split(";");
|
|
1652
|
+
const [name, value] = pair(first3);
|
|
1653
|
+
const attrs = {};
|
|
1654
|
+
for (const part of rest) {
|
|
1655
|
+
const [key, v] = pair(part);
|
|
1656
|
+
if (key) attrs[key.toLowerCase()] = v ?? true;
|
|
1657
|
+
}
|
|
1658
|
+
return { name, value: value ?? "", attrs };
|
|
1659
|
+
}
|
|
1660
|
+
function parseCookieHeader(header) {
|
|
1661
|
+
return header.split(";").map((part) => pair(part)).filter(([name]) => name !== "").map(([name, value]) => ({ name, value: value ?? "" }));
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1323
1664
|
// src/inspector/curl.ts
|
|
1324
|
-
var SKIP = /* @__PURE__ */ new Set(["host",
|
|
1665
|
+
var SKIP = /* @__PURE__ */ new Set(["host", ...FRAMING_HEADERS]);
|
|
1325
1666
|
var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
|
|
1326
1667
|
var skip = (name) => {
|
|
1327
1668
|
const k = name.toLowerCase();
|
|
@@ -1340,6 +1681,209 @@ function toCurl(r, o) {
|
|
|
1340
1681
|
return parts.join(" \\\n ");
|
|
1341
1682
|
}
|
|
1342
1683
|
|
|
1684
|
+
// src/inspector/diagnose.ts
|
|
1685
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
|
1686
|
+
var LINKABLE = /html|javascript|json|css|xml/i;
|
|
1687
|
+
var LOCAL_LINK = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/gi;
|
|
1688
|
+
function isLocalHost(host) {
|
|
1689
|
+
const h = host.toLowerCase();
|
|
1690
|
+
return LOCAL_HOSTS.has(h) || h.endsWith(".localhost");
|
|
1691
|
+
}
|
|
1692
|
+
function absoluteUrl(value) {
|
|
1693
|
+
if (!/^https?:\/\//i.test(value)) return null;
|
|
1694
|
+
try {
|
|
1695
|
+
return new URL(value);
|
|
1696
|
+
} catch {
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function originOf(r) {
|
|
1701
|
+
if (r.error || r.access?.kind === "denied") return "duto";
|
|
1702
|
+
return r.status === null ? null : "app";
|
|
1703
|
+
}
|
|
1704
|
+
function upstreamIssues(r, ctx) {
|
|
1705
|
+
const out = [];
|
|
1706
|
+
if (r.error) {
|
|
1707
|
+
out.push({ level: "error", title: `Nothing is listening on localhost:${ctx.port ?? "?"}`, detail: `${r.error}. Start the server, then replay this request.` });
|
|
1708
|
+
}
|
|
1709
|
+
if (r.access?.kind === "denied") {
|
|
1710
|
+
out.push({ level: "info", title: "Blocked by the tunnel password", detail: "duto answered before the request reached your app." });
|
|
1711
|
+
}
|
|
1712
|
+
return out;
|
|
1713
|
+
}
|
|
1714
|
+
function redirectIssues(r, ctx) {
|
|
1715
|
+
const location = r.resHeaders.location;
|
|
1716
|
+
const url = location ? absoluteUrl(location) : null;
|
|
1717
|
+
if (!url) return [];
|
|
1718
|
+
if (isLocalHost(url.hostname)) {
|
|
1719
|
+
return [
|
|
1720
|
+
{
|
|
1721
|
+
level: "warn",
|
|
1722
|
+
title: "Redirect to a local address",
|
|
1723
|
+
detail: `Location is ${location}, which only opens on this machine. Build URLs from the request's Host header, or run duto with --host.`
|
|
1724
|
+
}
|
|
1725
|
+
];
|
|
1726
|
+
}
|
|
1727
|
+
if (url.protocol === "http:" && ctx.publicHost && url.hostname.toLowerCase() === ctx.publicHost) {
|
|
1728
|
+
return [
|
|
1729
|
+
{
|
|
1730
|
+
level: "warn",
|
|
1731
|
+
title: "Redirect drops to http",
|
|
1732
|
+
detail: `Location is ${location}. duto.dev only serves https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
|
|
1733
|
+
}
|
|
1734
|
+
];
|
|
1735
|
+
}
|
|
1736
|
+
return [];
|
|
1737
|
+
}
|
|
1738
|
+
function cookieIssues(r, ctx) {
|
|
1739
|
+
const out = [];
|
|
1740
|
+
for (const line of r.resSetCookies ?? []) {
|
|
1741
|
+
const c = parseSetCookie(line);
|
|
1742
|
+
const domain = typeof c.attrs.domain === "string" ? c.attrs.domain.replace(/^\./, "").toLowerCase() : null;
|
|
1743
|
+
const host = ctx.publicHost;
|
|
1744
|
+
if (domain && host && host !== domain && !host.endsWith(`.${domain}`)) {
|
|
1745
|
+
out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: `Domain=${c.attrs.domain} does not match ${host}. Remove Domain, or set it to ${host}.` });
|
|
1746
|
+
} else if (typeof c.attrs.samesite === "string" && c.attrs.samesite.toLowerCase() === "none" && c.attrs.secure === void 0) {
|
|
1747
|
+
out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: "SameSite=None needs Secure." });
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
return out;
|
|
1751
|
+
}
|
|
1752
|
+
function corsIssues(r, ctx) {
|
|
1753
|
+
const origin = r.reqHeaders.origin;
|
|
1754
|
+
if (!origin || r.status === null || ctx.publicHost && origin.toLowerCase() === `https://${ctx.publicHost}`) return [];
|
|
1755
|
+
const allowed = r.resHeaders["access-control-allow-origin"];
|
|
1756
|
+
const ok2 = allowed === "*" || allowed === origin;
|
|
1757
|
+
const wanted = r.reqHeaders["access-control-request-method"];
|
|
1758
|
+
if (r.method === "OPTIONS" && wanted) {
|
|
1759
|
+
if (r.status < 400 && ok2) return [];
|
|
1760
|
+
return [{ level: "error", title: "CORS preflight failed", detail: `The browser will not send the ${wanted} from ${origin}. Answer OPTIONS with 2xx and Access-Control-Allow-Origin.` }];
|
|
1761
|
+
}
|
|
1762
|
+
if (ok2) return [];
|
|
1763
|
+
const detail2 = allowed ? `Access-Control-Allow-Origin is ${allowed}, not ${origin}.` : `No Access-Control-Allow-Origin for ${origin}.`;
|
|
1764
|
+
return [{ level: "warn", title: "The browser blocks this response (CORS)", detail: detail2 }];
|
|
1765
|
+
}
|
|
1766
|
+
function bodyIssues(r) {
|
|
1767
|
+
if (!r.resBody || !LINKABLE.test(r.resHeaders["content-type"] ?? "")) return [];
|
|
1768
|
+
const links = r.resBody.match(LOCAL_LINK);
|
|
1769
|
+
if (!links) return [];
|
|
1770
|
+
const first3 = links[0];
|
|
1771
|
+
const detail2 = links.length === 1 ? `1 link points to ${first3}. It only opens on this machine.` : `${links.length} links point to ${first3}. They only open on this machine.`;
|
|
1772
|
+
return [{ level: "warn", title: "The response links to localhost", detail: detail2 }];
|
|
1773
|
+
}
|
|
1774
|
+
function diagnose(r, ctx) {
|
|
1775
|
+
return [...upstreamIssues(r, ctx), ...redirectIssues(r, ctx), ...cookieIssues(r, ctx), ...corsIssues(r, ctx), ...bodyIssues(r)];
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/inspector/flows.ts
|
|
1779
|
+
var FLOW_WINDOW_MS = 3e4;
|
|
1780
|
+
function clientOf(r) {
|
|
1781
|
+
const direct = r.reqHeaders["cf-connecting-ip"];
|
|
1782
|
+
if (direct) return direct;
|
|
1783
|
+
const forwarded = r.reqHeaders["x-forwarded-for"]?.split(",")[0]?.trim();
|
|
1784
|
+
return forwarded || null;
|
|
1785
|
+
}
|
|
1786
|
+
var clientKey = (r) => `${clientOf(r) ?? ""}|${r.reqHeaders["user-agent"] ?? ""}`;
|
|
1787
|
+
function redirectTarget(r, publicHost) {
|
|
1788
|
+
const location = r.resHeaders.location;
|
|
1789
|
+
if (!location || r.status === null || r.status < 300 || r.status >= 400) return null;
|
|
1790
|
+
const base = `https://${publicHost ?? "duto.invalid"}`;
|
|
1791
|
+
let url;
|
|
1792
|
+
try {
|
|
1793
|
+
url = new URL(location, base + r.path);
|
|
1794
|
+
} catch {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
if (url.origin !== base) return null;
|
|
1798
|
+
return url.pathname + url.search;
|
|
1799
|
+
}
|
|
1800
|
+
function linkFlows(records, publicHostOf) {
|
|
1801
|
+
const ordered = [...records].sort((a, b) => a.id - b.id);
|
|
1802
|
+
const links = /* @__PURE__ */ new Map();
|
|
1803
|
+
const link = (id, patch) => links.set(id, { ...links.get(id), ...patch });
|
|
1804
|
+
ordered.forEach((r, i) => {
|
|
1805
|
+
const target = redirectTarget(r, publicHostOf(r.tunnel));
|
|
1806
|
+
if (target === null) return;
|
|
1807
|
+
const key = clientKey(r);
|
|
1808
|
+
const next = ordered.slice(i + 1).find((n) => n.tunnel === r.tunnel && n.path === target && clientKey(n) === key && n.startedAt - r.startedAt <= FLOW_WINDOW_MS && links.get(n.id)?.prev === void 0);
|
|
1809
|
+
if (!next) return;
|
|
1810
|
+
link(r.id, { next: next.id });
|
|
1811
|
+
link(next.id, { prev: r.id });
|
|
1812
|
+
});
|
|
1813
|
+
return links;
|
|
1814
|
+
}
|
|
1815
|
+
function flowChain(id, links) {
|
|
1816
|
+
let first3 = id;
|
|
1817
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
1818
|
+
for (let p = links.get(first3)?.prev; p !== void 0 && !seen.has(p); p = links.get(first3)?.prev) {
|
|
1819
|
+
seen.add(p);
|
|
1820
|
+
first3 = p;
|
|
1821
|
+
}
|
|
1822
|
+
const chain = [first3];
|
|
1823
|
+
for (let n = links.get(first3)?.next; n !== void 0 && !chain.includes(n); n = links.get(n)?.next) chain.push(n);
|
|
1824
|
+
return chain;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/inspector/har.ts
|
|
1828
|
+
var HTTP = "HTTP/1.1";
|
|
1829
|
+
var HAR_MASK = "\u2022\u2022\u2022 (duto.yml)";
|
|
1830
|
+
function maskedNames(r) {
|
|
1831
|
+
return new Set((r.headerChanges ?? []).filter((c) => c.source === "headers" && c.kind !== "remove").map((c) => c.name));
|
|
1832
|
+
}
|
|
1833
|
+
var masked = (list, names) => list.map((h) => names.has(h.name.toLowerCase()) ? { ...h, value: HAR_MASK } : h);
|
|
1834
|
+
var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
|
|
1835
|
+
function harCookie(line) {
|
|
1836
|
+
const c = parseSetCookie(line);
|
|
1837
|
+
return {
|
|
1838
|
+
name: c.name,
|
|
1839
|
+
value: c.value,
|
|
1840
|
+
...typeof c.attrs.path === "string" && { path: c.attrs.path },
|
|
1841
|
+
...typeof c.attrs.domain === "string" && { domain: c.attrs.domain },
|
|
1842
|
+
...c.attrs.httponly !== void 0 && { httpOnly: true },
|
|
1843
|
+
...c.attrs.secure !== void 0 && { secure: true }
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
function entry(r, publicUrl) {
|
|
1847
|
+
const url = new URL(r.path, publicUrl ?? "http://localhost");
|
|
1848
|
+
const time = r.durationMs ?? 0;
|
|
1849
|
+
const wait = Math.min(r.ttfbMs ?? time, time);
|
|
1850
|
+
const reqType = r.reqHeaders["content-type"] ?? "";
|
|
1851
|
+
const secret = maskedNames(r);
|
|
1852
|
+
const cookies = parseCookieHeader(r.reqHeaders.cookie ?? "");
|
|
1853
|
+
return {
|
|
1854
|
+
startedDateTime: new Date(r.startedAt).toISOString(),
|
|
1855
|
+
time,
|
|
1856
|
+
request: {
|
|
1857
|
+
method: r.websocket ? "GET" : r.method,
|
|
1858
|
+
url: url.toString(),
|
|
1859
|
+
httpVersion: HTTP,
|
|
1860
|
+
headers: masked(headerList(r.reqHeaders), secret),
|
|
1861
|
+
queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
|
|
1862
|
+
cookies: secret.has("cookie") ? cookies.map((c) => ({ ...c, value: HAR_MASK })) : cookies,
|
|
1863
|
+
headersSize: -1,
|
|
1864
|
+
bodySize: r.reqBodyBytes,
|
|
1865
|
+
...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
|
|
1866
|
+
},
|
|
1867
|
+
response: {
|
|
1868
|
+
status: r.status ?? 0,
|
|
1869
|
+
statusText: "",
|
|
1870
|
+
httpVersion: HTTP,
|
|
1871
|
+
headers: headerList(r.resHeaders),
|
|
1872
|
+
cookies: (r.resSetCookies ?? []).map(harCookie),
|
|
1873
|
+
content: { size: r.bytes, mimeType: r.resHeaders["content-type"] ?? "", ...r.resBody !== null && { text: r.resBody } },
|
|
1874
|
+
redirectURL: r.resHeaders.location ?? "",
|
|
1875
|
+
headersSize: -1,
|
|
1876
|
+
bodySize: r.bytes
|
|
1877
|
+
},
|
|
1878
|
+
cache: {},
|
|
1879
|
+
timings: { send: 0, wait, receive: time - wait }
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
function toHar(records, publicUrlOf, version) {
|
|
1883
|
+
const entries = [...records].sort((a, b) => a.id - b.id).map((r) => entry(r, publicUrlOf(r.tunnel)));
|
|
1884
|
+
return { log: { version: "1.2", creator: { name: "duto", version }, entries } };
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1343
1887
|
// src/inspector/server.ts
|
|
1344
1888
|
var DEFAULT_PORT = 4141;
|
|
1345
1889
|
var PORT_TRIES = 20;
|
|
@@ -1350,7 +1894,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
|
|
|
1350
1894
|
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1351
1895
|
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1352
1896
|
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1353
|
-
function
|
|
1897
|
+
function hostOfUrl(url) {
|
|
1898
|
+
if (!url) return null;
|
|
1899
|
+
try {
|
|
1900
|
+
return new URL(url).hostname.toLowerCase();
|
|
1901
|
+
} catch {
|
|
1902
|
+
return null;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
function viewOf(session) {
|
|
1906
|
+
const tunnels = session.getState().tunnels;
|
|
1907
|
+
const find = (key) => tunnels.find((t) => t.key === key);
|
|
1908
|
+
const urlOf = (key) => find(key)?.url ?? null;
|
|
1909
|
+
const hostOf2 = (key) => hostOfUrl(urlOf(key));
|
|
1910
|
+
return {
|
|
1911
|
+
urlOf,
|
|
1912
|
+
context: (key) => ({ publicHost: hostOf2(key), port: find(key)?.port ?? null }),
|
|
1913
|
+
links: linkFlows(session.records.list(), hostOf2)
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
function isRedirect(r) {
|
|
1917
|
+
return r.status !== null && r.status >= 300 && r.status < 400 && !!r.resHeaders.location;
|
|
1918
|
+
}
|
|
1919
|
+
function listItem(r, view) {
|
|
1920
|
+
const link = view.links.get(r.id);
|
|
1354
1921
|
return {
|
|
1355
1922
|
id: r.id,
|
|
1356
1923
|
tunnel: r.tunnel,
|
|
@@ -1361,12 +1928,35 @@ function listItem(r) {
|
|
|
1361
1928
|
bytes: r.bytes,
|
|
1362
1929
|
startedAt: r.startedAt,
|
|
1363
1930
|
websocket: r.websocket,
|
|
1931
|
+
origin: originOf(r),
|
|
1932
|
+
issues: diagnose(r, view.context(r.tunnel)).length,
|
|
1933
|
+
client: clientOf(r),
|
|
1934
|
+
...r.ttfbMs !== void 0 && { ttfbMs: r.ttfbMs },
|
|
1935
|
+
...isRedirect(r) && { location: r.resHeaders.location },
|
|
1936
|
+
...link?.prev !== void 0 && { prev: link.prev },
|
|
1937
|
+
...link?.next !== void 0 && { next: link.next },
|
|
1364
1938
|
...r.replayOf !== void 0 && { replayOf: r.replayOf },
|
|
1365
1939
|
...r.error !== void 0 && { error: r.error },
|
|
1366
|
-
...r.redirect !== void 0 && { rewritten: true }
|
|
1940
|
+
...r.redirect !== void 0 && { rewritten: true },
|
|
1941
|
+
...r.access !== void 0 && { access: r.access }
|
|
1367
1942
|
};
|
|
1368
1943
|
}
|
|
1369
|
-
function
|
|
1944
|
+
function queryOf(path) {
|
|
1945
|
+
try {
|
|
1946
|
+
return [...new URL(path, "http://x").searchParams].map(([name, value]) => ({ name, value }));
|
|
1947
|
+
} catch {
|
|
1948
|
+
return [];
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
function flowOf(r, session, view) {
|
|
1952
|
+
const chain = flowChain(r.id, view.links);
|
|
1953
|
+
if (chain.length < 2) return [];
|
|
1954
|
+
return chain.flatMap((id) => {
|
|
1955
|
+
const step = session.records.get(id);
|
|
1956
|
+
return step ? [{ id, method: step.websocket ? "WS" : step.method, path: step.path, status: step.status }] : [];
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
function detail(r, session, view) {
|
|
1370
1960
|
const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
|
|
1371
1961
|
const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
|
|
1372
1962
|
let curl = null;
|
|
@@ -1377,9 +1967,15 @@ function detail(r, session) {
|
|
|
1377
1967
|
if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
|
|
1378
1968
|
}
|
|
1379
1969
|
return {
|
|
1380
|
-
...listItem(r),
|
|
1970
|
+
...listItem(r, view),
|
|
1381
1971
|
edge: edgeOf(r.reqHeaders),
|
|
1382
1972
|
redirect: r.redirect ?? null,
|
|
1973
|
+
changes: (r.headerChanges ?? []).map(describeChange),
|
|
1974
|
+
issues: diagnose(r, view.context(r.tunnel)),
|
|
1975
|
+
client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
|
|
1976
|
+
query: queryOf(r.path),
|
|
1977
|
+
cookies: { sent: parseCookieHeader(r.reqHeaders.cookie ?? ""), set: (r.resSetCookies ?? []).map(parseSetCookie) },
|
|
1978
|
+
flow: flowOf(r, session, view),
|
|
1383
1979
|
reqHeaders: r.reqHeaders,
|
|
1384
1980
|
resHeaders: r.resHeaders,
|
|
1385
1981
|
reqBody: r.reqBody,
|
|
@@ -1434,7 +2030,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1434
2030
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1435
2031
|
res.setHeader("X-Frame-Options", "DENY");
|
|
1436
2032
|
};
|
|
1437
|
-
const
|
|
2033
|
+
const text2 = (res, status, body) => {
|
|
1438
2034
|
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1439
2035
|
res.end(body);
|
|
1440
2036
|
};
|
|
@@ -1445,19 +2041,19 @@ data: ${JSON.stringify({ ids })}
|
|
|
1445
2041
|
const handle = (req, res) => {
|
|
1446
2042
|
base(res);
|
|
1447
2043
|
const host = first(req.headers.host)?.toLowerCase();
|
|
1448
|
-
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return
|
|
2044
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text2(res, 403, "Host not allowed.");
|
|
1449
2045
|
let url2;
|
|
1450
2046
|
try {
|
|
1451
2047
|
url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1452
2048
|
} catch {
|
|
1453
|
-
return
|
|
2049
|
+
return text2(res, 404, "Not found.");
|
|
1454
2050
|
}
|
|
1455
2051
|
const path = url2.pathname;
|
|
1456
2052
|
const method = req.method ?? "GET";
|
|
1457
2053
|
const queryToken = url2.searchParams.get("t") ?? "";
|
|
1458
2054
|
const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
|
|
1459
2055
|
if (method === "GET" && path === "/") {
|
|
1460
|
-
if (!constantTimeEqual(queryToken, token)) return
|
|
2056
|
+
if (!constantTimeEqual(queryToken, token)) return text2(res, 403, NO_TOKEN);
|
|
1461
2057
|
const nonce = randomBytes(16).toString("base64url");
|
|
1462
2058
|
res.writeHead(200, {
|
|
1463
2059
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -1467,8 +2063,8 @@ data: ${JSON.stringify({ ids })}
|
|
|
1467
2063
|
}
|
|
1468
2064
|
const isEvents = method === "GET" && path === "/api/events";
|
|
1469
2065
|
const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
|
|
1470
|
-
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
1471
|
-
if (!known) return
|
|
2066
|
+
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
2067
|
+
if (!known) return text2(res, 404, "Not found.");
|
|
1472
2068
|
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
1473
2069
|
if (isEvents) {
|
|
1474
2070
|
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
|
|
@@ -1479,21 +2075,31 @@ data: ${JSON.stringify({ ids })}
|
|
|
1479
2075
|
return;
|
|
1480
2076
|
}
|
|
1481
2077
|
if (path === "/api/session") {
|
|
1482
|
-
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
|
|
2078
|
+
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth, port: t.port })) });
|
|
2079
|
+
}
|
|
2080
|
+
if (path === "/api/requests") {
|
|
2081
|
+
const view = viewOf(session);
|
|
2082
|
+
return json(res, 200, { items: session.records.list().map((r2) => listItem(r2, view)) });
|
|
2083
|
+
}
|
|
2084
|
+
if (path === "/api/har") {
|
|
2085
|
+
const wanted = url2.searchParams.get("ids");
|
|
2086
|
+
const ids = wanted === null ? null : new Set(wanted.split(",").map(Number));
|
|
2087
|
+
const records = session.records.list().filter((r2) => !ids || ids.has(r2.id));
|
|
2088
|
+
res.setHeader("Content-Disposition", 'attachment; filename="duto.har"');
|
|
2089
|
+
return json(res, 200, toHar(records, viewOf(session).urlOf, CLI_VERSION));
|
|
1483
2090
|
}
|
|
1484
|
-
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1485
2091
|
const id = Number(detailId[1]);
|
|
1486
2092
|
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1487
2093
|
if (method === "POST") return json(res, 200, session.replay(id));
|
|
1488
2094
|
const r = session.records.get(id);
|
|
1489
2095
|
if (!r) return json(res, 404, { error: GONE });
|
|
1490
|
-
return json(res, 200, detail(r, session));
|
|
2096
|
+
return json(res, 200, detail(r, session, viewOf(session)));
|
|
1491
2097
|
};
|
|
1492
2098
|
const server = createServer((req, res) => {
|
|
1493
2099
|
try {
|
|
1494
2100
|
handle(req, res);
|
|
1495
2101
|
} catch {
|
|
1496
|
-
if (!res.headersSent)
|
|
2102
|
+
if (!res.headersSent) text2(res, 500, "Internal error.");
|
|
1497
2103
|
else res.end();
|
|
1498
2104
|
}
|
|
1499
2105
|
});
|
|
@@ -1710,6 +2316,62 @@ function cors401(origin) {
|
|
|
1710
2316
|
import { Agent, createServer as createServer2, request } from "http";
|
|
1711
2317
|
import { connect as connect2 } from "net";
|
|
1712
2318
|
|
|
2319
|
+
// src/session/delivery.ts
|
|
2320
|
+
function text(h, name) {
|
|
2321
|
+
if (!Object.hasOwn(h, name)) return void 0;
|
|
2322
|
+
const v = h[name];
|
|
2323
|
+
return Array.isArray(v) ? v.join(", ") : v;
|
|
2324
|
+
}
|
|
2325
|
+
function dropUntrustedForwarded(out, changes) {
|
|
2326
|
+
for (const name of UNTRUSTED_VISITOR_HEADERS) {
|
|
2327
|
+
const from = text(out, name);
|
|
2328
|
+
if (from === void 0) continue;
|
|
2329
|
+
delete out[name];
|
|
2330
|
+
changes.push({ kind: "remove", name, from, source: "visitor" });
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
function swapHost(out, host, changes) {
|
|
2334
|
+
changes.push({ kind: "replace", name: "host", from: out.host ?? "", to: host, source: "host" });
|
|
2335
|
+
out.host = host;
|
|
2336
|
+
const forwarded = text(out, "x-forwarded-host");
|
|
2337
|
+
if (forwarded === void 0) return;
|
|
2338
|
+
delete out["x-forwarded-host"];
|
|
2339
|
+
changes.push({ kind: "remove", name: "x-forwarded-host", from: forwarded, source: "host" });
|
|
2340
|
+
}
|
|
2341
|
+
function applyHeaders(out, headers, changes) {
|
|
2342
|
+
for (const [name, to] of Object.entries(headers)) {
|
|
2343
|
+
const from = text(out, name);
|
|
2344
|
+
if (to === "") {
|
|
2345
|
+
if (from === void 0) continue;
|
|
2346
|
+
delete out[name];
|
|
2347
|
+
changes.push({ kind: "remove", name, from, source: "headers" });
|
|
2348
|
+
} else {
|
|
2349
|
+
out[name] = to;
|
|
2350
|
+
changes.push(from === void 0 ? { kind: "add", name, to, source: "headers" } : { kind: "replace", name, from, to, source: "headers" });
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
function stripDutoCredentials(out, stripped, changes) {
|
|
2355
|
+
for (const name of ["x-duto-auth", "authorization"]) {
|
|
2356
|
+
const from = text(out, name);
|
|
2357
|
+
if (from !== void 0 && text(stripped, name) === void 0) changes.push({ kind: "remove", name, from, source: "duto" });
|
|
2358
|
+
}
|
|
2359
|
+
if (text(out, "cookie") !== text(stripped, "cookie")) changes.push({ kind: "remove", name: "cookie", from: TUNNEL_COOKIE, source: "duto" });
|
|
2360
|
+
}
|
|
2361
|
+
function deliver(h, route) {
|
|
2362
|
+
const changes = [];
|
|
2363
|
+
let out = { ...h };
|
|
2364
|
+
if (!route.trustForwarded) dropUntrustedForwarded(out, changes);
|
|
2365
|
+
if (route.host) swapHost(out, route.host, changes);
|
|
2366
|
+
if (route.headers) applyHeaders(out, route.headers, changes);
|
|
2367
|
+
if (route.auth) {
|
|
2368
|
+
const stripped = route.auth.strip(out);
|
|
2369
|
+
stripDutoCredentials(out, stripped, changes);
|
|
2370
|
+
out = stripped;
|
|
2371
|
+
}
|
|
2372
|
+
return changes.length ? { headers: out, changes } : { headers: h, changes };
|
|
2373
|
+
}
|
|
2374
|
+
|
|
1713
2375
|
// src/session/errorPage.ts
|
|
1714
2376
|
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
1715
2377
|
function wantsHtml(accept) {
|
|
@@ -1818,6 +2480,7 @@ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
|
|
|
1818
2480
|
var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
|
|
1819
2481
|
var MAX_BODY = 64 * 1024;
|
|
1820
2482
|
var LOCAL_AGENT = new Agent({ keepAlive: false });
|
|
2483
|
+
var errorText = (err) => err instanceof Error ? err.message || err.code || err.name : String(err);
|
|
1821
2484
|
function flat(h) {
|
|
1822
2485
|
const out = {};
|
|
1823
2486
|
for (const [k, v] of Object.entries(h)) if (v !== void 0) out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
@@ -1876,19 +2539,6 @@ async function localHost(port) {
|
|
|
1876
2539
|
hostCache.set(port, { host, at: Date.now() });
|
|
1877
2540
|
return host;
|
|
1878
2541
|
}
|
|
1879
|
-
function rewrite(h, route) {
|
|
1880
|
-
if (!route.host && !route.headers) return h;
|
|
1881
|
-
const out = { ...h };
|
|
1882
|
-
if (route.host) {
|
|
1883
|
-
out.host = route.host;
|
|
1884
|
-
delete out["x-forwarded-host"];
|
|
1885
|
-
}
|
|
1886
|
-
for (const [k, v] of Object.entries(route.headers ?? {})) {
|
|
1887
|
-
if (v === "") delete out[k];
|
|
1888
|
-
else out[k] = v;
|
|
1889
|
-
}
|
|
1890
|
-
return out;
|
|
1891
|
-
}
|
|
1892
2542
|
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1893
2543
|
function rewriteLocation(location, localHost2, publicHost) {
|
|
1894
2544
|
const host = escapeRe(localHost2.replace(/:\d+$/, ""));
|
|
@@ -1977,10 +2627,6 @@ function deny(req, res, html) {
|
|
|
1977
2627
|
res.end(body);
|
|
1978
2628
|
return { status, out, body };
|
|
1979
2629
|
}
|
|
1980
|
-
function delivered(h, route) {
|
|
1981
|
-
const out = rewrite(h, route);
|
|
1982
|
-
return route.auth ? route.auth.strip(out) : out;
|
|
1983
|
-
}
|
|
1984
2630
|
async function isUp(port) {
|
|
1985
2631
|
return await probe("127.0.0.1", port) || await probe("::1", port);
|
|
1986
2632
|
}
|
|
@@ -2022,7 +2668,7 @@ async function startProxy(o) {
|
|
|
2022
2668
|
await new Promise((r) => setTimeout(r, 50));
|
|
2023
2669
|
}
|
|
2024
2670
|
}
|
|
2025
|
-
function record(req, route, headers, websocket, access, noReplay) {
|
|
2671
|
+
function record(req, route, headers, changes, websocket, access, noReplay) {
|
|
2026
2672
|
const id = nextId++;
|
|
2027
2673
|
const started = Date.now();
|
|
2028
2674
|
o.store.add({
|
|
@@ -2046,12 +2692,15 @@ async function startProxy(o) {
|
|
|
2046
2692
|
truncated: false,
|
|
2047
2693
|
resTruncated: false,
|
|
2048
2694
|
...access && { access },
|
|
2049
|
-
...noReplay && { noReplay }
|
|
2695
|
+
...noReplay && { noReplay },
|
|
2696
|
+
...changes.length && { headerChanges: changes }
|
|
2050
2697
|
});
|
|
2051
2698
|
return { id, started };
|
|
2052
2699
|
}
|
|
2053
2700
|
function trackResponse(id, started, up, reqCap) {
|
|
2054
2701
|
const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
|
|
2702
|
+
const ttfbMs = Date.now() - started;
|
|
2703
|
+
const setCookies = up.headers["set-cookie"];
|
|
2055
2704
|
let bytes = 0;
|
|
2056
2705
|
up.on("data", (c) => {
|
|
2057
2706
|
bytes += c.length;
|
|
@@ -2062,8 +2711,10 @@ async function startProxy(o) {
|
|
|
2062
2711
|
() => o.store.update(id, {
|
|
2063
2712
|
status: up.statusCode ?? null,
|
|
2064
2713
|
durationMs: Date.now() - started,
|
|
2714
|
+
ttfbMs,
|
|
2065
2715
|
bytes,
|
|
2066
2716
|
resHeaders: flat(up.headers),
|
|
2717
|
+
...setCookies?.length && { resSetCookies: setCookies },
|
|
2067
2718
|
resBody: resCap.text(),
|
|
2068
2719
|
truncated: !!reqCap?.truncated || resCap.truncated,
|
|
2069
2720
|
resTruncated: resCap.truncated,
|
|
@@ -2100,11 +2751,18 @@ async function startProxy(o) {
|
|
|
2100
2751
|
reqComplete: true,
|
|
2101
2752
|
truncated: false,
|
|
2102
2753
|
resTruncated: false,
|
|
2103
|
-
replayOf: r.id
|
|
2754
|
+
replayOf: r.id,
|
|
2755
|
+
...r.headerChanges && { headerChanges: r.headerChanges }
|
|
2104
2756
|
});
|
|
2105
2757
|
void (async () => {
|
|
2106
2758
|
const host = await localHost(route.port);
|
|
2107
|
-
|
|
2759
|
+
let upstream;
|
|
2760
|
+
try {
|
|
2761
|
+
upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
|
|
2762
|
+
} catch (err) {
|
|
2763
|
+
o.store.update(newId, { status: 502, durationMs: Date.now() - started, error: errorText(err) });
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2108
2766
|
upstream.on("error", (err) => o.store.update(newId, { status: 503, durationMs: Date.now() - started, error: err.message || err.code || err.name }));
|
|
2109
2767
|
upstream.end(r.reqRaw ?? void 0);
|
|
2110
2768
|
})();
|
|
@@ -2130,42 +2788,55 @@ async function startProxy(o) {
|
|
|
2130
2788
|
}
|
|
2131
2789
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
2132
2790
|
const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
|
|
2133
|
-
const headers =
|
|
2791
|
+
const { headers, changes } = deliver(req.headers, route);
|
|
2134
2792
|
if (access?.kind === "denied") {
|
|
2135
2793
|
const html = wantsHtml(req.headers.accept);
|
|
2136
|
-
const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access, DENIED_NO_REPLAY);
|
|
2794
|
+
const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
|
|
2137
2795
|
const { status, out, body } = deny(req, res, html);
|
|
2138
2796
|
o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
|
|
2139
2797
|
return;
|
|
2140
2798
|
}
|
|
2141
|
-
const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
|
|
2799
|
+
const { id, started } = record(req, route, headers, changes, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
|
|
2142
2800
|
const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
|
|
2801
|
+
const recordArrival = () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() });
|
|
2143
2802
|
req.pause();
|
|
2144
2803
|
const host = await localHost(route.port);
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2804
|
+
let upstream;
|
|
2805
|
+
try {
|
|
2806
|
+
upstream = request(
|
|
2807
|
+
{ host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
|
|
2808
|
+
(up) => {
|
|
2809
|
+
if (preflight) {
|
|
2810
|
+
const out = preflightHeaders(up.headers);
|
|
2811
|
+
res.writeHead(up.statusCode ?? 502, out);
|
|
2812
|
+
res.end();
|
|
2813
|
+
up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
|
|
2814
|
+
up.resume();
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
|
|
2818
|
+
const from = up.headers.location;
|
|
2819
|
+
const to = rewriteLocation(from, route.host, hostOf(req));
|
|
2820
|
+
if (to !== from) {
|
|
2821
|
+
up.headers.location = to;
|
|
2822
|
+
o.store.update(id, { redirect: { from, to } });
|
|
2823
|
+
}
|
|
2162
2824
|
}
|
|
2825
|
+
res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
|
|
2826
|
+
trackResponse(id, started, up, reqCap);
|
|
2827
|
+
up.pipe(res);
|
|
2163
2828
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2829
|
+
);
|
|
2830
|
+
} catch (err) {
|
|
2831
|
+
req.on("data", (c) => reqCap.push(c));
|
|
2832
|
+
req.on("end", recordArrival);
|
|
2833
|
+
req.resume();
|
|
2834
|
+
const msg = `Duto could not forward this request: ${errorText(err)}`;
|
|
2835
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
2836
|
+
res.end(req.method === "HEAD" ? void 0 : msg);
|
|
2837
|
+
o.store.update(id, { status: 502, durationMs: Date.now() - started, bytes: Buffer.byteLength(msg), error: errorText(err) });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2169
2840
|
res.on("close", () => {
|
|
2170
2841
|
if (!res.writableFinished) upstream.destroy();
|
|
2171
2842
|
});
|
|
@@ -2180,7 +2851,7 @@ async function startProxy(o) {
|
|
|
2180
2851
|
} else res.destroy();
|
|
2181
2852
|
o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
|
|
2182
2853
|
});
|
|
2183
|
-
req.on("end",
|
|
2854
|
+
req.on("end", recordArrival);
|
|
2184
2855
|
if (preflight) {
|
|
2185
2856
|
req.resume();
|
|
2186
2857
|
upstream.end();
|
|
@@ -2194,8 +2865,8 @@ async function startProxy(o) {
|
|
|
2194
2865
|
const route = await routeFor(req);
|
|
2195
2866
|
if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
2196
2867
|
const access = route.auth?.access(req.headers);
|
|
2197
|
-
const headers =
|
|
2198
|
-
const { id, started } = record(req, route, headers, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
|
|
2868
|
+
const { headers, changes } = deliver(req.headers, route);
|
|
2869
|
+
const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
|
|
2199
2870
|
if (access?.kind === "denied") {
|
|
2200
2871
|
client.end(WS_DENIED);
|
|
2201
2872
|
o.store.update(id, { status: 401, durationMs: Date.now() - started });
|
|
@@ -2453,7 +3124,7 @@ var Session = class {
|
|
|
2453
3124
|
}
|
|
2454
3125
|
blank(spec, adHoc) {
|
|
2455
3126
|
this.specs.set(keyOf(spec), spec);
|
|
2456
|
-
return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
|
|
3127
|
+
return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
|
|
2457
3128
|
}
|
|
2458
3129
|
async open(t, existing, retrying = false) {
|
|
2459
3130
|
if (this.stopped) return;
|
|
@@ -2482,7 +3153,7 @@ var Session = class {
|
|
|
2482
3153
|
t.hostname = res.hostname;
|
|
2483
3154
|
t.endpointId = res.endpointId;
|
|
2484
3155
|
t.error = null;
|
|
2485
|
-
this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, auth });
|
|
3156
|
+
this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
|
|
2486
3157
|
t.status = await isPortListening(t.port) ? "live" : "no-listener";
|
|
2487
3158
|
this.ensureCloudflared(res.tunnelToken);
|
|
2488
3159
|
this.emit();
|
|
@@ -2664,8 +3335,8 @@ function listFlag(raw) {
|
|
|
2664
3335
|
}
|
|
2665
3336
|
function parseAuthFlags(raw) {
|
|
2666
3337
|
const out = [];
|
|
2667
|
-
for (const
|
|
2668
|
-
const r = parseCredential(
|
|
3338
|
+
for (const entry2 of listFlag(raw)) {
|
|
3339
|
+
const r = parseCredential(entry2);
|
|
2669
3340
|
if (!r.ok) fail2(r.reason === "password" ? "Password must be 8 to 128 characters" : r.reason === "user" ? "Invalid user: 1 to 128 characters, no spaces" : "Invalid credential: use user:password");
|
|
2670
3341
|
if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
|
|
2671
3342
|
out.push(r.cred);
|
|
@@ -2702,15 +3373,17 @@ async function runSession(specs, source, flags) {
|
|
|
2702
3373
|
inspectorError = err instanceof Error ? err.message : String(err);
|
|
2703
3374
|
}
|
|
2704
3375
|
}
|
|
3376
|
+
const warnings = credentialWarnings(specs);
|
|
2705
3377
|
if (wantsUi(flags)) {
|
|
2706
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
2707
|
-
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
|
|
3378
|
+
const { runFullscreen } = await import("./fullscreen-MSVJF43W.js");
|
|
3379
|
+
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
2708
3380
|
await inspector?.close().catch(() => void 0);
|
|
2709
3381
|
process.exit(code);
|
|
2710
3382
|
}
|
|
2711
3383
|
console.log(`${mark()} ${paint("muted", `${CLI_VERSION} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
|
|
2712
3384
|
if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
|
|
2713
3385
|
if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
|
|
3386
|
+
for (const w of warnings) console.log(warn(w));
|
|
2714
3387
|
void update.then((latest) => latest && console.log(warn(updateLine(latest))));
|
|
2715
3388
|
const stopLog = runPlain(session, (l) => console.log(l));
|
|
2716
3389
|
const stopped = new Promise((resolve) => {
|