@dutodev/cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/index.js +610 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,6 +162,11 @@ Outside an interactive terminal (pipe, CI) or with `--no-ui`, the CLI prints a p
|
|
|
162
162
|
|
|
163
163
|
With every session, the CLI starts a local inspector: a browser page with requests arriving live, formatted JSON, and buttons to copy and replay. It is more comfortable than the terminal for large bodies.
|
|
164
164
|
|
|
165
|
+
- **Overview.** Each request shows who answered (your app or duto), total time and time to first byte, the data center, the client, the query, the cookies sent and set, and the redirect target.
|
|
166
|
+
- **Issues.** The inspector flags what usually breaks a tunnel: nothing listening on the port, a redirect to `localhost` or down to `http`, a cookie the browser drops (`Domain` mismatch, `SameSite=None` without `Secure`), a CORS response or preflight the browser blocks, and response bodies that link to `localhost`. Each one says what to change.
|
|
167
|
+
- **Flows.** A redirect is linked to the request the same browser made next, so a login or OAuth round trip reads as one chain you can step through.
|
|
168
|
+
- **Filters.** Type terms like `status:4xx`, `status:>=500`, `method:post`, `path:/api`, `is:redirect`, `is:issue`, `is:duto` or `duration:>500ms`. Start a term with `-` to exclude it. The buttons under the filter (Errors, Redirects, Issues, Slow) apply the common ones.
|
|
169
|
+
- **Clear and export.** Clear hides what is already listed, so you can reproduce a flow from a clean list. Export HAR saves the listed requests for Chrome DevTools or any HAR viewer.
|
|
165
170
|
- **How to open it.** In the full screen, key `i` opens the browser at the right address, and the screen shows `inspector http://127.0.0.1:<port>`. In plain mode (pipe, CI, `--no-ui`), the CLI prints `inspector: <url>?t=<token>`; copy that whole address.
|
|
166
171
|
- **Port.** The default is `127.0.0.1:4141`. If it is taken, the CLI tries the next ones up to 4161 and then any free port. The inspector listens on `127.0.0.1` only: other machines on your network cannot reach it.
|
|
167
172
|
- **Token.** The address carries a session token (`?t=...`). It exists so other sites open in your browser cannot read or replay your requests. That is why the full screen does not show the token; use key `i`.
|
package/dist/index.js
CHANGED
|
@@ -50,7 +50,7 @@ import { join as join5, relative } from "path";
|
|
|
50
50
|
import cac from "cac";
|
|
51
51
|
|
|
52
52
|
// src/version.ts
|
|
53
|
-
var CLI_VERSION = "1.
|
|
53
|
+
var CLI_VERSION = "1.5.0";
|
|
54
54
|
|
|
55
55
|
// src/api.ts
|
|
56
56
|
var DutoApiError = class extends Error {
|
|
@@ -401,9 +401,9 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
401
401
|
throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
|
|
402
402
|
}
|
|
403
403
|
const specs = [];
|
|
404
|
-
for (const
|
|
405
|
-
const raw = isScalar(
|
|
406
|
-
const line = lineAt(
|
|
404
|
+
for (const pair2 of tunnels.items) {
|
|
405
|
+
const raw = isScalar(pair2.key) ? String(pair2.key.value) : "";
|
|
406
|
+
const line = lineAt(pair2.key?.range?.[0]);
|
|
407
407
|
const name = normalizeName(raw);
|
|
408
408
|
const v = validateName(name);
|
|
409
409
|
if (!v.ok) {
|
|
@@ -412,11 +412,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
412
412
|
line
|
|
413
413
|
);
|
|
414
414
|
}
|
|
415
|
-
const port = portOf(
|
|
415
|
+
const port = portOf(pair2.value);
|
|
416
416
|
if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
|
|
417
417
|
const spec = { name, port };
|
|
418
|
-
if (isMap(
|
|
419
|
-
const body =
|
|
418
|
+
if (isMap(pair2.value)) {
|
|
419
|
+
const body = pair2.value;
|
|
420
420
|
for (const item of body.items) {
|
|
421
421
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
422
422
|
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
|
|
@@ -523,7 +523,7 @@ async function browserLogin(o) {
|
|
|
523
523
|
const start = await api.startLogin({
|
|
524
524
|
deviceId: o.deviceId,
|
|
525
525
|
deviceName: hostname(),
|
|
526
|
-
cliVersion: "1.
|
|
526
|
+
cliVersion: "1.5.0",
|
|
527
527
|
nodeVersion: process.version,
|
|
528
528
|
os: process.platform,
|
|
529
529
|
arch: process.arch
|
|
@@ -615,6 +615,71 @@ function edgeLabel(edge) {
|
|
|
615
615
|
return place ? "via " + edge.colo + " (" + place + ")" : "via " + edge.colo;
|
|
616
616
|
}
|
|
617
617
|
|
|
618
|
+
// src/inspector/filter.ts
|
|
619
|
+
function matchesFilter(item, text) {
|
|
620
|
+
function compare(value, spec, unit) {
|
|
621
|
+
const m = /^(>=|<=|>|<|=)?(.+)$/.exec(spec);
|
|
622
|
+
if (!m) return null;
|
|
623
|
+
const limit = unit(m[2] ?? "");
|
|
624
|
+
if (limit === null) return null;
|
|
625
|
+
if (value === null) return false;
|
|
626
|
+
const op = m[1] || "=";
|
|
627
|
+
if (op === ">") return value > limit;
|
|
628
|
+
if (op === ">=") return value >= limit;
|
|
629
|
+
if (op === "<") return value < limit;
|
|
630
|
+
if (op === "<=") return value <= limit;
|
|
631
|
+
return value === limit;
|
|
632
|
+
}
|
|
633
|
+
function plainNumber(raw) {
|
|
634
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
635
|
+
}
|
|
636
|
+
function millis(raw) {
|
|
637
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(raw);
|
|
638
|
+
if (!m) return null;
|
|
639
|
+
return m[2] === "s" ? Number(m[1]) * 1e3 : Number(m[1]);
|
|
640
|
+
}
|
|
641
|
+
function status(spec) {
|
|
642
|
+
const cls = /^([1-5])xx$/.exec(spec);
|
|
643
|
+
if (cls) return item.status !== null && Math.floor(item.status / 100) === Number(cls[1]);
|
|
644
|
+
return compare(item.status, spec, plainNumber);
|
|
645
|
+
}
|
|
646
|
+
function kind(spec) {
|
|
647
|
+
const s = item.status;
|
|
648
|
+
if (spec === "error") return s !== null && s >= 400 || !!item.error;
|
|
649
|
+
if (spec === "redirect") return s !== null && s >= 300 && s < 400;
|
|
650
|
+
if (spec === "rewritten") return !!item.rewritten;
|
|
651
|
+
if (spec === "replay") return item.replayOf !== void 0;
|
|
652
|
+
if (spec === "ws") return item.websocket;
|
|
653
|
+
if (spec === "pending") return s === null && !item.error;
|
|
654
|
+
if (spec === "issue") return (item.issues ?? 0) > 0;
|
|
655
|
+
if (spec === "duto") return item.origin === "duto";
|
|
656
|
+
if (spec === "app") return item.origin === "app";
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
function field(key, value) {
|
|
660
|
+
const v = value.toLowerCase();
|
|
661
|
+
if (key === "status") return status(v);
|
|
662
|
+
if (key === "is") return kind(v);
|
|
663
|
+
if (key === "duration") return compare(item.durationMs, v, millis);
|
|
664
|
+
if (key === "method") return (item.websocket ? "ws" : item.method.toLowerCase()) === v;
|
|
665
|
+
if (key === "tunnel") return item.tunnel.toLowerCase() === v;
|
|
666
|
+
if (key === "path") return item.path.toLowerCase().indexOf(v) >= 0;
|
|
667
|
+
if (key === "client") return (item.client ?? "").toLowerCase() === v;
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
const haystack = [item.tunnel, item.websocket ? "ws" : item.method, item.path, item.status === null ? "" : String(item.status), item.client ?? ""].join(" ").toLowerCase();
|
|
671
|
+
const terms = text.trim().split(/\s+/).filter(Boolean);
|
|
672
|
+
for (const raw of terms) {
|
|
673
|
+
const negate = raw.length > 1 && raw.charAt(0) === "-";
|
|
674
|
+
const term = negate ? raw.slice(1) : raw;
|
|
675
|
+
const sep = term.indexOf(":");
|
|
676
|
+
let hit = sep > 0 ? field(term.slice(0, sep).toLowerCase(), term.slice(sep + 1)) : null;
|
|
677
|
+
if (hit === null) hit = haystack.indexOf(term.toLowerCase()) >= 0;
|
|
678
|
+
if (hit === negate) return false;
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
618
683
|
// src/inspector/jsonView.ts
|
|
619
684
|
function countLabel(n, one, many) {
|
|
620
685
|
return n + " " + (n === 1 ? one : many);
|
|
@@ -741,14 +806,14 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
741
806
|
.tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
|
|
742
807
|
.tunnels .k { color: ${C.strong}; }
|
|
743
808
|
.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:
|
|
809
|
+
.main { flex: 1; min-height: 0; display: grid; grid-template-columns: 400px 1fr; }
|
|
745
810
|
.side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
|
|
746
811
|
.search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
|
|
747
812
|
.search:focus { border-color: ${C.signal}; }
|
|
748
813
|
.search::placeholder { color: ${C.muted}; }
|
|
749
814
|
.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
|
|
815
|
+
.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; }
|
|
816
|
+
.list.multi .row { grid-template-columns: 14px 56px 56px minmax(0, 1fr) 34px 60px; }
|
|
752
817
|
.row:hover { background: #141917; }
|
|
753
818
|
.row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
754
819
|
.row span { overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -756,7 +821,30 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
756
821
|
.m { font-weight: 700; }
|
|
757
822
|
.mut { color: ${C.muted}; }
|
|
758
823
|
.sig { color: ${C.signal}; }
|
|
759
|
-
.
|
|
824
|
+
.warn { color: ${C.warn}; }
|
|
825
|
+
.errc { color: ${C.err}; }
|
|
826
|
+
.row.inflow { box-shadow: inset 2px 0 0 ${C.muted}; }
|
|
827
|
+
.row.sel.inflow { box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
828
|
+
.to { color: ${C.muted}; }
|
|
829
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
|
|
830
|
+
.chip { border: 1px solid ${INPUT_LINE}; border-radius: 999px; padding: 1px 8px; color: ${C.muted}; }
|
|
831
|
+
.chip:hover { color: ${TEXT}; }
|
|
832
|
+
.chip.on { border-color: ${C.signal}; color: ${C.signal}; }
|
|
833
|
+
.chip b { font-weight: 400; color: ${TEXT}; }
|
|
834
|
+
.sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
|
|
835
|
+
.help { padding: 0 12px 8px; color: ${C.muted}; display: grid; gap: 2px; }
|
|
836
|
+
.help code { color: ${TEXT}; font: inherit; }
|
|
837
|
+
.ov { padding: 4px 0 12px; }
|
|
838
|
+
.issue { margin: 8px 12px 0; padding: 6px 10px; border-left: 2px solid ${C.warn}; background: #16140f; }
|
|
839
|
+
.issue.error { border-left-color: ${C.err}; background: #1a1112; }
|
|
840
|
+
.issue.info { border-left-color: ${C.muted}; background: #141716; }
|
|
841
|
+
.issue b { display: block; color: ${C.strong}; font-weight: 600; }
|
|
842
|
+
.issue span { color: ${C.muted}; }
|
|
843
|
+
.flow { margin: 6px 12px 0; display: grid; gap: 2px; max-width: 760px; }
|
|
844
|
+
.step { display: grid; grid-template-columns: 40px 52px minmax(0, 1fr) 40px; gap: 8px; text-align: left; padding: 2px 6px; border-radius: 4px; }
|
|
845
|
+
.step:hover { background: #141917; }
|
|
846
|
+
.step.cur { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
847
|
+
.step span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
760
848
|
.empty { padding: 12px; color: ${C.muted}; }
|
|
761
849
|
.detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
762
850
|
.pane { flex: 1; }
|
|
@@ -798,14 +886,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
|
798
886
|
var SCRIPT = String.raw`"use strict";
|
|
799
887
|
var countLabel = (${countLabel.toString()});
|
|
800
888
|
var edgeLabel = (${edgeLabel.toString()});
|
|
889
|
+
var matchesFilter = (${matchesFilter.toString()});
|
|
801
890
|
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
802
891
|
var jsonTokens = (${jsonTokens.toString()});
|
|
803
892
|
var jsonIndent = (${jsonIndent.toString()});
|
|
804
893
|
var COLORS = ${JSON.stringify(C)};
|
|
805
894
|
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
806
|
-
var
|
|
895
|
+
var CHIPS = [
|
|
896
|
+
{ label: "Errors", query: "is:error" },
|
|
897
|
+
{ label: "Redirects", query: "is:redirect" },
|
|
898
|
+
{ label: "Issues", query: "is:issue" },
|
|
899
|
+
{ label: "Slow", query: "duration:>1s" }
|
|
900
|
+
];
|
|
807
901
|
|
|
808
|
-
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "
|
|
902
|
+
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Overview", ended: false, clearedAfter: 0 };
|
|
809
903
|
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
810
904
|
var es = null, lost = false, replaying = false;
|
|
811
905
|
function fail(e) { if (!state.ended) console.error(e); }
|
|
@@ -857,14 +951,60 @@ function header(h, name) {
|
|
|
857
951
|
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
858
952
|
return "";
|
|
859
953
|
}
|
|
954
|
+
function kept() {
|
|
955
|
+
return state.items.filter(function (it) { return it.id > state.clearedAfter; });
|
|
956
|
+
}
|
|
860
957
|
function visible() {
|
|
861
|
-
var f = $("filter").value
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
958
|
+
var f = $("filter").value;
|
|
959
|
+
return kept().filter(function (it) { return matchesFilter(it, f); });
|
|
960
|
+
}
|
|
961
|
+
function setFilter(text) {
|
|
962
|
+
$("filter").value = text;
|
|
963
|
+
renderList();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function renderChips() {
|
|
967
|
+
var box = $("chips");
|
|
968
|
+
clear(box);
|
|
969
|
+
var current = $("filter").value.trim();
|
|
970
|
+
var items = kept();
|
|
971
|
+
CHIPS.forEach(function (c) {
|
|
972
|
+
var n = items.filter(function (it) { return matchesFilter(it, c.query); }).length;
|
|
973
|
+
var b = el("button", "chip" + (current === c.query ? " on" : ""));
|
|
974
|
+
b.type = "button";
|
|
975
|
+
b.title = c.query;
|
|
976
|
+
b.appendChild(document.createTextNode(c.label + " "));
|
|
977
|
+
b.appendChild(el("b", "", n));
|
|
978
|
+
b.addEventListener("click", function () { setFilter(current === c.query ? "" : c.query); });
|
|
979
|
+
box.appendChild(b);
|
|
865
980
|
});
|
|
866
981
|
}
|
|
867
982
|
|
|
983
|
+
function itemById(id) {
|
|
984
|
+
for (var i = 0; i < state.items.length; i++) if (state.items[i].id === id) return state.items[i];
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
function chainOf(id) {
|
|
988
|
+
var ids = {};
|
|
989
|
+
var start = itemById(id);
|
|
990
|
+
if (!start || (start.prev === undefined && start.next === undefined)) return ids;
|
|
991
|
+
var cur = start, guard = 0;
|
|
992
|
+
while (cur && cur.prev !== undefined && guard++ < 50) cur = itemById(cur.prev);
|
|
993
|
+
guard = 0;
|
|
994
|
+
while (cur && guard++ < 50) {
|
|
995
|
+
ids[cur.id] = true;
|
|
996
|
+
cur = cur.next === undefined ? null : itemById(cur.next);
|
|
997
|
+
}
|
|
998
|
+
return ids;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function markOf(it) {
|
|
1002
|
+
if (it.issues > 0) return { text: "!", cls: it.origin === "duto" || it.error ? "errc" : "warn", title: it.issues === 1 ? "1 issue" : it.issues + " issues" };
|
|
1003
|
+
if (it.replayOf !== undefined) return { text: "↻", cls: "mut", title: "replay of #" + it.replayOf };
|
|
1004
|
+
if (it.rewritten) return { text: "↪", cls: "sig", title: "Location rewritten" };
|
|
1005
|
+
return { text: "", cls: "mut", title: "" };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
868
1008
|
function renderTop() {
|
|
869
1009
|
var n = state.items.length;
|
|
870
1010
|
$("count").textContent = countLabel(n, "request", "requests");
|
|
@@ -883,26 +1023,29 @@ function renderList() {
|
|
|
883
1023
|
var multi = state.tunnels.length > 1;
|
|
884
1024
|
list.classList.toggle("multi", multi);
|
|
885
1025
|
clear(list);
|
|
1026
|
+
renderChips();
|
|
1027
|
+
$("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
|
|
886
1028
|
var items = visible();
|
|
887
1029
|
if (items.length === 0) {
|
|
888
|
-
|
|
1030
|
+
var none = state.items.length === 0 ? "No requests yet." : kept().length === 0 ? "Cleared. New requests show up here." : "No requests match the filter.";
|
|
1031
|
+
list.appendChild(el("div", "empty", none));
|
|
889
1032
|
return;
|
|
890
1033
|
}
|
|
1034
|
+
var flow = state.selected === null ? {} : chainOf(state.selected);
|
|
891
1035
|
items.forEach(function (it) {
|
|
892
|
-
var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
|
|
1036
|
+
var row = el("div", "row" + (it.id === state.selected ? " sel" : "") + (flow[it.id] ? " inflow" : ""));
|
|
893
1037
|
row.setAttribute("role", "option");
|
|
894
1038
|
row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
|
|
895
1039
|
row.dataset.id = String(it.id);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
row.appendChild(mark);
|
|
901
|
-
} else row.appendChild(el("span", "mut", ""));
|
|
1040
|
+
var mark = markOf(it);
|
|
1041
|
+
var ms = el("span", mark.cls, mark.text);
|
|
1042
|
+
ms.title = mark.title;
|
|
1043
|
+
row.appendChild(ms);
|
|
902
1044
|
if (multi) row.appendChild(el("span", "mut", it.tunnel));
|
|
903
1045
|
row.appendChild(el("span", "m", methodOf(it)));
|
|
904
1046
|
var p = el("span", "", it.path);
|
|
905
|
-
p.title = it.path;
|
|
1047
|
+
p.title = it.location ? it.path + " → " + it.location : it.path;
|
|
1048
|
+
if (it.location) p.appendChild(el("span", "to", " → " + it.location));
|
|
906
1049
|
row.appendChild(p);
|
|
907
1050
|
var st = statusInfo(it);
|
|
908
1051
|
var s = el("span", "", st.text);
|
|
@@ -980,13 +1123,6 @@ function renderDetail() {
|
|
|
980
1123
|
if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
|
|
981
1124
|
head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
|
|
982
1125
|
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
1126
|
|
|
991
1127
|
var tabs = document.querySelectorAll(".tab");
|
|
992
1128
|
for (var i = 0; i < tabs.length; i++) {
|
|
@@ -1003,7 +1139,8 @@ function renderContent() {
|
|
|
1003
1139
|
var box = $("content");
|
|
1004
1140
|
var top = box.scrollTop;
|
|
1005
1141
|
clear(box);
|
|
1006
|
-
if (state.tab === "
|
|
1142
|
+
if (state.tab === "Overview") renderOverview(box, d);
|
|
1143
|
+
else if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
|
|
1007
1144
|
else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
|
|
1008
1145
|
else if (state.tab === "Response") {
|
|
1009
1146
|
box.appendChild(el("div", "sect", "Headers"));
|
|
@@ -1020,6 +1157,130 @@ function renderContent() {
|
|
|
1020
1157
|
box.scrollTop = top;
|
|
1021
1158
|
}
|
|
1022
1159
|
|
|
1160
|
+
function rows(pairs) {
|
|
1161
|
+
var t = el("table");
|
|
1162
|
+
pairs.forEach(function (p) {
|
|
1163
|
+
if (p[1] === null || p[1] === undefined || p[1] === "") return;
|
|
1164
|
+
var tr = el("tr");
|
|
1165
|
+
tr.appendChild(el("th", "", p[0]));
|
|
1166
|
+
tr.appendChild(el("td", "", p[1]));
|
|
1167
|
+
t.appendChild(tr);
|
|
1168
|
+
});
|
|
1169
|
+
return t;
|
|
1170
|
+
}
|
|
1171
|
+
function clock(ms) {
|
|
1172
|
+
var d = new Date(ms);
|
|
1173
|
+
function two(n) { return (n < 10 ? "0" : "") + n; }
|
|
1174
|
+
return two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()) + "." + String(d.getMilliseconds()).padStart(3, "0");
|
|
1175
|
+
}
|
|
1176
|
+
function answeredBy(d) {
|
|
1177
|
+
if (d.origin === null) return "waiting for an answer";
|
|
1178
|
+
if (d.origin === "duto") return d.access && d.access.kind === "denied" ? "duto (tunnel password)" : "duto (nothing answered on localhost)";
|
|
1179
|
+
var t = state.tunnels.filter(function (x) { return x.key === d.tunnel; })[0];
|
|
1180
|
+
return t && t.port ? "your app on localhost:" + t.port : "your app";
|
|
1181
|
+
}
|
|
1182
|
+
function accessText(a) {
|
|
1183
|
+
if (!a) return null;
|
|
1184
|
+
if (a.kind === "user") return "password, user " + a.user;
|
|
1185
|
+
if (a.kind === "allow") return "allowed: " + a.reason;
|
|
1186
|
+
return "denied";
|
|
1187
|
+
}
|
|
1188
|
+
function attrsText(attrs) {
|
|
1189
|
+
return Object.keys(attrs).map(function (k) { return attrs[k] === true ? k : k + "=" + attrs[k]; }).join("; ");
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function renderOverview(box, d) {
|
|
1193
|
+
var ov = el("div", "ov");
|
|
1194
|
+
d.issues.forEach(function (i) {
|
|
1195
|
+
var card = el("div", "issue " + i.level);
|
|
1196
|
+
card.appendChild(el("b", "", i.title));
|
|
1197
|
+
card.appendChild(el("span", "", i.detail));
|
|
1198
|
+
ov.appendChild(card);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
if (d.flow.length > 0) {
|
|
1202
|
+
ov.appendChild(el("div", "sect", "Flow"));
|
|
1203
|
+
var flow = el("div", "flow");
|
|
1204
|
+
d.flow.forEach(function (s) {
|
|
1205
|
+
var b = el("button", "step" + (s.id === d.id ? " cur" : ""));
|
|
1206
|
+
b.type = "button";
|
|
1207
|
+
b.appendChild(el("span", "mut", "#" + s.id));
|
|
1208
|
+
b.appendChild(el("span", "m", s.method));
|
|
1209
|
+
b.appendChild(el("span", "", s.path));
|
|
1210
|
+
var st = statusInfo(s);
|
|
1211
|
+
var sp = el("span", "", st.text);
|
|
1212
|
+
sp.style.color = st.color;
|
|
1213
|
+
b.appendChild(sp);
|
|
1214
|
+
b.addEventListener("click", function () { select(s.id); });
|
|
1215
|
+
flow.appendChild(b);
|
|
1216
|
+
});
|
|
1217
|
+
ov.appendChild(flow);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
ov.appendChild(el("div", "sect", "Request"));
|
|
1221
|
+
var timing = d.durationMs === null || d.durationMs === undefined ? null : d.durationMs + " ms";
|
|
1222
|
+
ov.appendChild(rows([
|
|
1223
|
+
["Answered by", answeredBy(d)],
|
|
1224
|
+
["Started", clock(d.startedAt)],
|
|
1225
|
+
["Total time", timing],
|
|
1226
|
+
["First byte", d.ttfbMs === undefined ? null : d.ttfbMs + " ms"],
|
|
1227
|
+
["Size", d.status === null ? null : size(d.bytes)],
|
|
1228
|
+
["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null],
|
|
1229
|
+
["Access", accessText(d.access)]
|
|
1230
|
+
]));
|
|
1231
|
+
|
|
1232
|
+
if (d.location || d.redirect) {
|
|
1233
|
+
ov.appendChild(el("div", "sect", "Redirect"));
|
|
1234
|
+
ov.appendChild(rows([
|
|
1235
|
+
["Location", d.location || (d.redirect && d.redirect.to)],
|
|
1236
|
+
["Location rewritten", d.redirect ? d.redirect.from + " → " + d.redirect.to : null]
|
|
1237
|
+
]));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
ov.appendChild(el("div", "sect", "Client"));
|
|
1241
|
+
ov.appendChild(rows([["IP", d.client.ip], ["Country", d.client.country], ["User agent", d.client.userAgent]]));
|
|
1242
|
+
|
|
1243
|
+
if (d.query.length > 0) {
|
|
1244
|
+
ov.appendChild(el("div", "sect", "Query"));
|
|
1245
|
+
ov.appendChild(rows(d.query.map(function (q) { return [q.name, q.value || " "]; })));
|
|
1246
|
+
}
|
|
1247
|
+
if (d.cookies.sent.length > 0) {
|
|
1248
|
+
ov.appendChild(el("div", "sect", "Cookies sent"));
|
|
1249
|
+
ov.appendChild(rows(d.cookies.sent.map(function (c) { return [c.name, c.value || " "]; })));
|
|
1250
|
+
}
|
|
1251
|
+
if (d.cookies.set.length > 0) {
|
|
1252
|
+
ov.appendChild(el("div", "sect", "Cookies set"));
|
|
1253
|
+
ov.appendChild(rows(d.cookies.set.map(function (c) {
|
|
1254
|
+
var a = attrsText(c.attrs);
|
|
1255
|
+
return [c.name, (c.value || "\"\"") + (a ? " · " + a : "")];
|
|
1256
|
+
})));
|
|
1257
|
+
}
|
|
1258
|
+
box.appendChild(ov);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function exportHar() {
|
|
1262
|
+
var ids = visible().map(function (it) { return it.id; });
|
|
1263
|
+
if (ids.length === 0) return showMsg("Nothing to export.", true);
|
|
1264
|
+
fetch("/api/har?ids=" + ids.join(","), { headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
|
|
1265
|
+
if (r.status === 403) { ended(); throw new Error("403"); }
|
|
1266
|
+
return r.blob();
|
|
1267
|
+
}).then(function (blob) {
|
|
1268
|
+
var a = document.createElement("a");
|
|
1269
|
+
a.href = URL.createObjectURL(blob);
|
|
1270
|
+
a.download = "duto-" + new Date().toISOString().replace(/[:.]/g, "-") + ".har";
|
|
1271
|
+
document.body.appendChild(a);
|
|
1272
|
+
a.click();
|
|
1273
|
+
a.remove();
|
|
1274
|
+
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
|
1275
|
+
}).catch(fail);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function toggleClear() {
|
|
1279
|
+
if (state.clearedAfter > 0) state.clearedAfter = 0;
|
|
1280
|
+
else state.clearedAfter = state.items.reduce(function (m, it) { return Math.max(m, it.id); }, 0);
|
|
1281
|
+
renderList();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1023
1284
|
function table(h) {
|
|
1024
1285
|
var keys = Object.keys(h || {});
|
|
1025
1286
|
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
@@ -1195,11 +1456,9 @@ function connect() {
|
|
|
1195
1456
|
banner("Disconnected from the session.");
|
|
1196
1457
|
if (es.readyState === 2) probe();
|
|
1197
1458
|
});
|
|
1198
|
-
es.addEventListener("changed", function (
|
|
1199
|
-
var ids = [];
|
|
1200
|
-
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1459
|
+
es.addEventListener("changed", function () {
|
|
1201
1460
|
loadList();
|
|
1202
|
-
if (state.selected !== null
|
|
1461
|
+
if (state.selected !== null) loadDetail(state.selected);
|
|
1203
1462
|
});
|
|
1204
1463
|
}
|
|
1205
1464
|
function probe() {
|
|
@@ -1219,6 +1478,13 @@ function init() {
|
|
|
1219
1478
|
});
|
|
1220
1479
|
}
|
|
1221
1480
|
$("filter").addEventListener("input", renderList);
|
|
1481
|
+
$("clear").addEventListener("click", toggleClear);
|
|
1482
|
+
$("har").addEventListener("click", exportHar);
|
|
1483
|
+
$("helpBtn").addEventListener("click", function () {
|
|
1484
|
+
var h = $("help");
|
|
1485
|
+
h.hidden = !h.hidden;
|
|
1486
|
+
$("helpBtn").setAttribute("aria-expanded", h.hidden ? "false" : "true");
|
|
1487
|
+
});
|
|
1222
1488
|
$("replay").addEventListener("click", replay);
|
|
1223
1489
|
$("copyBody").addEventListener("click", function () {
|
|
1224
1490
|
var b = state.detail ? copyable(state.detail) : { text: null };
|
|
@@ -1253,7 +1519,7 @@ function init() {
|
|
|
1253
1519
|
init();
|
|
1254
1520
|
`;
|
|
1255
1521
|
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("");
|
|
1522
|
+
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
1523
|
return `<!doctype html>
|
|
1258
1524
|
<html lang="en">
|
|
1259
1525
|
<head>
|
|
@@ -1269,8 +1535,21 @@ function inspectorPage(nonce) {
|
|
|
1269
1535
|
<div class="banner" id="banner" role="status" hidden></div>
|
|
1270
1536
|
<div class="main">
|
|
1271
1537
|
<div class="side">
|
|
1272
|
-
<input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1538
|
+
<input class="search" id="filter" type="search" placeholder="filter\u2026 (status:4xx is:redirect)" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1539
|
+
<div class="chips" id="chips"></div>
|
|
1540
|
+
<div class="help" id="help" hidden>
|
|
1541
|
+
<span><code>status:404</code> <code>status:5xx</code> <code>status:>=400</code></span>
|
|
1542
|
+
<span><code>method:post</code> <code>path:/api</code> <code>tunnel:web</code> <code>client:1.2.3.4</code></span>
|
|
1543
|
+
<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>
|
|
1544
|
+
<span><code>duration:>500ms</code> <code>duration:>=1s</code></span>
|
|
1545
|
+
<span>Start a term with <code>-</code> to exclude it. All terms must match.</span>
|
|
1546
|
+
</div>
|
|
1273
1547
|
<div class="list" id="list" role="listbox" aria-label="Requests"></div>
|
|
1548
|
+
<div class="sidebar-foot">
|
|
1549
|
+
<button type="button" class="btn" id="clear">Clear</button>
|
|
1550
|
+
<button type="button" class="btn" id="har">Export HAR</button>
|
|
1551
|
+
<button type="button" class="btn" id="helpBtn" aria-expanded="false" aria-controls="help">Filter syntax</button>
|
|
1552
|
+
</div>
|
|
1274
1553
|
</div>
|
|
1275
1554
|
<div class="detail">
|
|
1276
1555
|
<div class="empty" id="none">Select a request from the list.</div>
|
|
@@ -1320,6 +1599,25 @@ function replayHeaders(r) {
|
|
|
1320
1599
|
return out;
|
|
1321
1600
|
}
|
|
1322
1601
|
|
|
1602
|
+
// src/inspector/cookies.ts
|
|
1603
|
+
function pair(part) {
|
|
1604
|
+
const i = part.indexOf("=");
|
|
1605
|
+
return i < 0 ? [part.trim(), null] : [part.slice(0, i).trim(), part.slice(i + 1).trim()];
|
|
1606
|
+
}
|
|
1607
|
+
function parseSetCookie(line) {
|
|
1608
|
+
const [first3 = "", ...rest] = line.split(";");
|
|
1609
|
+
const [name, value] = pair(first3);
|
|
1610
|
+
const attrs = {};
|
|
1611
|
+
for (const part of rest) {
|
|
1612
|
+
const [key, v] = pair(part);
|
|
1613
|
+
if (key) attrs[key.toLowerCase()] = v ?? true;
|
|
1614
|
+
}
|
|
1615
|
+
return { name, value: value ?? "", attrs };
|
|
1616
|
+
}
|
|
1617
|
+
function parseCookieHeader(header) {
|
|
1618
|
+
return header.split(";").map((part) => pair(part)).filter(([name]) => name !== "").map(([name, value]) => ({ name, value: value ?? "" }));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1323
1621
|
// src/inspector/curl.ts
|
|
1324
1622
|
var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1325
1623
|
var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
|
|
@@ -1340,6 +1638,202 @@ function toCurl(r, o) {
|
|
|
1340
1638
|
return parts.join(" \\\n ");
|
|
1341
1639
|
}
|
|
1342
1640
|
|
|
1641
|
+
// src/inspector/diagnose.ts
|
|
1642
|
+
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
|
1643
|
+
var LINKABLE = /html|javascript|json|css|xml/i;
|
|
1644
|
+
var LOCAL_LINK = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/gi;
|
|
1645
|
+
function isLocalHost(host) {
|
|
1646
|
+
const h = host.toLowerCase();
|
|
1647
|
+
return LOCAL_HOSTS.has(h) || h.endsWith(".localhost");
|
|
1648
|
+
}
|
|
1649
|
+
function absoluteUrl(value) {
|
|
1650
|
+
if (!/^https?:\/\//i.test(value)) return null;
|
|
1651
|
+
try {
|
|
1652
|
+
return new URL(value);
|
|
1653
|
+
} catch {
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
function originOf(r) {
|
|
1658
|
+
if (r.error || r.access?.kind === "denied") return "duto";
|
|
1659
|
+
return r.status === null ? null : "app";
|
|
1660
|
+
}
|
|
1661
|
+
function upstreamIssues(r, ctx) {
|
|
1662
|
+
const out = [];
|
|
1663
|
+
if (r.error) {
|
|
1664
|
+
out.push({ level: "error", title: `Nothing is listening on localhost:${ctx.port ?? "?"}`, detail: `${r.error}. Start the server, then replay this request.` });
|
|
1665
|
+
}
|
|
1666
|
+
if (r.access?.kind === "denied") {
|
|
1667
|
+
out.push({ level: "info", title: "Blocked by the tunnel password", detail: "duto answered before the request reached your app." });
|
|
1668
|
+
}
|
|
1669
|
+
return out;
|
|
1670
|
+
}
|
|
1671
|
+
function redirectIssues(r, ctx) {
|
|
1672
|
+
const location = r.resHeaders.location;
|
|
1673
|
+
const url = location ? absoluteUrl(location) : null;
|
|
1674
|
+
if (!url) return [];
|
|
1675
|
+
if (isLocalHost(url.hostname)) {
|
|
1676
|
+
return [
|
|
1677
|
+
{
|
|
1678
|
+
level: "warn",
|
|
1679
|
+
title: "Redirect to a local address",
|
|
1680
|
+
detail: `Location is ${location}, which only opens on this machine. Build URLs from the request's Host header, or run duto with --host.`
|
|
1681
|
+
}
|
|
1682
|
+
];
|
|
1683
|
+
}
|
|
1684
|
+
if (url.protocol === "http:" && ctx.publicHost && url.hostname.toLowerCase() === ctx.publicHost) {
|
|
1685
|
+
return [
|
|
1686
|
+
{
|
|
1687
|
+
level: "warn",
|
|
1688
|
+
title: "Redirect drops to http",
|
|
1689
|
+
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.`
|
|
1690
|
+
}
|
|
1691
|
+
];
|
|
1692
|
+
}
|
|
1693
|
+
return [];
|
|
1694
|
+
}
|
|
1695
|
+
function cookieIssues(r, ctx) {
|
|
1696
|
+
const out = [];
|
|
1697
|
+
for (const line of r.resSetCookies ?? []) {
|
|
1698
|
+
const c = parseSetCookie(line);
|
|
1699
|
+
const domain = typeof c.attrs.domain === "string" ? c.attrs.domain.replace(/^\./, "").toLowerCase() : null;
|
|
1700
|
+
const host = ctx.publicHost;
|
|
1701
|
+
if (domain && host && host !== domain && !host.endsWith(`.${domain}`)) {
|
|
1702
|
+
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}.` });
|
|
1703
|
+
} else if (typeof c.attrs.samesite === "string" && c.attrs.samesite.toLowerCase() === "none" && c.attrs.secure === void 0) {
|
|
1704
|
+
out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: "SameSite=None needs Secure." });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return out;
|
|
1708
|
+
}
|
|
1709
|
+
function corsIssues(r, ctx) {
|
|
1710
|
+
const origin = r.reqHeaders.origin;
|
|
1711
|
+
if (!origin || r.status === null || ctx.publicHost && origin.toLowerCase() === `https://${ctx.publicHost}`) return [];
|
|
1712
|
+
const allowed = r.resHeaders["access-control-allow-origin"];
|
|
1713
|
+
const ok2 = allowed === "*" || allowed === origin;
|
|
1714
|
+
const wanted = r.reqHeaders["access-control-request-method"];
|
|
1715
|
+
if (r.method === "OPTIONS" && wanted) {
|
|
1716
|
+
if (r.status < 400 && ok2) return [];
|
|
1717
|
+
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.` }];
|
|
1718
|
+
}
|
|
1719
|
+
if (ok2) return [];
|
|
1720
|
+
const detail2 = allowed ? `Access-Control-Allow-Origin is ${allowed}, not ${origin}.` : `No Access-Control-Allow-Origin for ${origin}.`;
|
|
1721
|
+
return [{ level: "warn", title: "The browser blocks this response (CORS)", detail: detail2 }];
|
|
1722
|
+
}
|
|
1723
|
+
function bodyIssues(r) {
|
|
1724
|
+
if (!r.resBody || !LINKABLE.test(r.resHeaders["content-type"] ?? "")) return [];
|
|
1725
|
+
const links = r.resBody.match(LOCAL_LINK);
|
|
1726
|
+
if (!links) return [];
|
|
1727
|
+
const first3 = links[0];
|
|
1728
|
+
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.`;
|
|
1729
|
+
return [{ level: "warn", title: "The response links to localhost", detail: detail2 }];
|
|
1730
|
+
}
|
|
1731
|
+
function diagnose(r, ctx) {
|
|
1732
|
+
return [...upstreamIssues(r, ctx), ...redirectIssues(r, ctx), ...cookieIssues(r, ctx), ...corsIssues(r, ctx), ...bodyIssues(r)];
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// src/inspector/flows.ts
|
|
1736
|
+
var FLOW_WINDOW_MS = 3e4;
|
|
1737
|
+
function clientOf(r) {
|
|
1738
|
+
const direct = r.reqHeaders["cf-connecting-ip"];
|
|
1739
|
+
if (direct) return direct;
|
|
1740
|
+
const forwarded = r.reqHeaders["x-forwarded-for"]?.split(",")[0]?.trim();
|
|
1741
|
+
return forwarded || null;
|
|
1742
|
+
}
|
|
1743
|
+
var clientKey = (r) => `${clientOf(r) ?? ""}|${r.reqHeaders["user-agent"] ?? ""}`;
|
|
1744
|
+
function redirectTarget(r, publicHost) {
|
|
1745
|
+
const location = r.resHeaders.location;
|
|
1746
|
+
if (!location || r.status === null || r.status < 300 || r.status >= 400) return null;
|
|
1747
|
+
const base = `https://${publicHost ?? "duto.invalid"}`;
|
|
1748
|
+
let url;
|
|
1749
|
+
try {
|
|
1750
|
+
url = new URL(location, base + r.path);
|
|
1751
|
+
} catch {
|
|
1752
|
+
return null;
|
|
1753
|
+
}
|
|
1754
|
+
if (url.origin !== base) return null;
|
|
1755
|
+
return url.pathname + url.search;
|
|
1756
|
+
}
|
|
1757
|
+
function linkFlows(records, publicHostOf) {
|
|
1758
|
+
const ordered = [...records].sort((a, b) => a.id - b.id);
|
|
1759
|
+
const links = /* @__PURE__ */ new Map();
|
|
1760
|
+
const link = (id, patch) => links.set(id, { ...links.get(id), ...patch });
|
|
1761
|
+
ordered.forEach((r, i) => {
|
|
1762
|
+
const target = redirectTarget(r, publicHostOf(r.tunnel));
|
|
1763
|
+
if (target === null) return;
|
|
1764
|
+
const key = clientKey(r);
|
|
1765
|
+
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);
|
|
1766
|
+
if (!next) return;
|
|
1767
|
+
link(r.id, { next: next.id });
|
|
1768
|
+
link(next.id, { prev: r.id });
|
|
1769
|
+
});
|
|
1770
|
+
return links;
|
|
1771
|
+
}
|
|
1772
|
+
function flowChain(id, links) {
|
|
1773
|
+
let first3 = id;
|
|
1774
|
+
const seen = /* @__PURE__ */ new Set([id]);
|
|
1775
|
+
for (let p = links.get(first3)?.prev; p !== void 0 && !seen.has(p); p = links.get(first3)?.prev) {
|
|
1776
|
+
seen.add(p);
|
|
1777
|
+
first3 = p;
|
|
1778
|
+
}
|
|
1779
|
+
const chain = [first3];
|
|
1780
|
+
for (let n = links.get(first3)?.next; n !== void 0 && !chain.includes(n); n = links.get(n)?.next) chain.push(n);
|
|
1781
|
+
return chain;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/inspector/har.ts
|
|
1785
|
+
var HTTP = "HTTP/1.1";
|
|
1786
|
+
var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
|
|
1787
|
+
function harCookie(line) {
|
|
1788
|
+
const c = parseSetCookie(line);
|
|
1789
|
+
return {
|
|
1790
|
+
name: c.name,
|
|
1791
|
+
value: c.value,
|
|
1792
|
+
...typeof c.attrs.path === "string" && { path: c.attrs.path },
|
|
1793
|
+
...typeof c.attrs.domain === "string" && { domain: c.attrs.domain },
|
|
1794
|
+
...c.attrs.httponly !== void 0 && { httpOnly: true },
|
|
1795
|
+
...c.attrs.secure !== void 0 && { secure: true }
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
function entry(r, publicUrl) {
|
|
1799
|
+
const url = new URL(r.path, publicUrl ?? "http://localhost");
|
|
1800
|
+
const time = r.durationMs ?? 0;
|
|
1801
|
+
const wait = Math.min(r.ttfbMs ?? time, time);
|
|
1802
|
+
const reqType = r.reqHeaders["content-type"] ?? "";
|
|
1803
|
+
return {
|
|
1804
|
+
startedDateTime: new Date(r.startedAt).toISOString(),
|
|
1805
|
+
time,
|
|
1806
|
+
request: {
|
|
1807
|
+
method: r.websocket ? "GET" : r.method,
|
|
1808
|
+
url: url.toString(),
|
|
1809
|
+
httpVersion: HTTP,
|
|
1810
|
+
headers: headerList(r.reqHeaders),
|
|
1811
|
+
queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
|
|
1812
|
+
cookies: parseCookieHeader(r.reqHeaders.cookie ?? ""),
|
|
1813
|
+
headersSize: -1,
|
|
1814
|
+
bodySize: r.reqBodyBytes,
|
|
1815
|
+
...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
|
|
1816
|
+
},
|
|
1817
|
+
response: {
|
|
1818
|
+
status: r.status ?? 0,
|
|
1819
|
+
statusText: "",
|
|
1820
|
+
httpVersion: HTTP,
|
|
1821
|
+
headers: headerList(r.resHeaders),
|
|
1822
|
+
cookies: (r.resSetCookies ?? []).map(harCookie),
|
|
1823
|
+
content: { size: r.bytes, mimeType: r.resHeaders["content-type"] ?? "", ...r.resBody !== null && { text: r.resBody } },
|
|
1824
|
+
redirectURL: r.resHeaders.location ?? "",
|
|
1825
|
+
headersSize: -1,
|
|
1826
|
+
bodySize: r.bytes
|
|
1827
|
+
},
|
|
1828
|
+
cache: {},
|
|
1829
|
+
timings: { send: 0, wait, receive: time - wait }
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
function toHar(records, publicUrlOf, version) {
|
|
1833
|
+
const entries = [...records].sort((a, b) => a.id - b.id).map((r) => entry(r, publicUrlOf(r.tunnel)));
|
|
1834
|
+
return { log: { version: "1.2", creator: { name: "duto", version }, entries } };
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1343
1837
|
// src/inspector/server.ts
|
|
1344
1838
|
var DEFAULT_PORT = 4141;
|
|
1345
1839
|
var PORT_TRIES = 20;
|
|
@@ -1350,7 +1844,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
|
|
|
1350
1844
|
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1351
1845
|
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1352
1846
|
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1353
|
-
function
|
|
1847
|
+
function hostOfUrl(url) {
|
|
1848
|
+
if (!url) return null;
|
|
1849
|
+
try {
|
|
1850
|
+
return new URL(url).hostname.toLowerCase();
|
|
1851
|
+
} catch {
|
|
1852
|
+
return null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
function viewOf(session) {
|
|
1856
|
+
const tunnels = session.getState().tunnels;
|
|
1857
|
+
const find = (key) => tunnels.find((t) => t.key === key);
|
|
1858
|
+
const urlOf = (key) => find(key)?.url ?? null;
|
|
1859
|
+
const hostOf2 = (key) => hostOfUrl(urlOf(key));
|
|
1860
|
+
return {
|
|
1861
|
+
urlOf,
|
|
1862
|
+
context: (key) => ({ publicHost: hostOf2(key), port: find(key)?.port ?? null }),
|
|
1863
|
+
links: linkFlows(session.records.list(), hostOf2)
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
function isRedirect(r) {
|
|
1867
|
+
return r.status !== null && r.status >= 300 && r.status < 400 && !!r.resHeaders.location;
|
|
1868
|
+
}
|
|
1869
|
+
function listItem(r, view) {
|
|
1870
|
+
const link = view.links.get(r.id);
|
|
1354
1871
|
return {
|
|
1355
1872
|
id: r.id,
|
|
1356
1873
|
tunnel: r.tunnel,
|
|
@@ -1361,12 +1878,35 @@ function listItem(r) {
|
|
|
1361
1878
|
bytes: r.bytes,
|
|
1362
1879
|
startedAt: r.startedAt,
|
|
1363
1880
|
websocket: r.websocket,
|
|
1881
|
+
origin: originOf(r),
|
|
1882
|
+
issues: diagnose(r, view.context(r.tunnel)).length,
|
|
1883
|
+
client: clientOf(r),
|
|
1884
|
+
...r.ttfbMs !== void 0 && { ttfbMs: r.ttfbMs },
|
|
1885
|
+
...isRedirect(r) && { location: r.resHeaders.location },
|
|
1886
|
+
...link?.prev !== void 0 && { prev: link.prev },
|
|
1887
|
+
...link?.next !== void 0 && { next: link.next },
|
|
1364
1888
|
...r.replayOf !== void 0 && { replayOf: r.replayOf },
|
|
1365
1889
|
...r.error !== void 0 && { error: r.error },
|
|
1366
|
-
...r.redirect !== void 0 && { rewritten: true }
|
|
1890
|
+
...r.redirect !== void 0 && { rewritten: true },
|
|
1891
|
+
...r.access !== void 0 && { access: r.access }
|
|
1367
1892
|
};
|
|
1368
1893
|
}
|
|
1369
|
-
function
|
|
1894
|
+
function queryOf(path) {
|
|
1895
|
+
try {
|
|
1896
|
+
return [...new URL(path, "http://x").searchParams].map(([name, value]) => ({ name, value }));
|
|
1897
|
+
} catch {
|
|
1898
|
+
return [];
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
function flowOf(r, session, view) {
|
|
1902
|
+
const chain = flowChain(r.id, view.links);
|
|
1903
|
+
if (chain.length < 2) return [];
|
|
1904
|
+
return chain.flatMap((id) => {
|
|
1905
|
+
const step = session.records.get(id);
|
|
1906
|
+
return step ? [{ id, method: step.websocket ? "WS" : step.method, path: step.path, status: step.status }] : [];
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
function detail(r, session, view) {
|
|
1370
1910
|
const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
|
|
1371
1911
|
const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
|
|
1372
1912
|
let curl = null;
|
|
@@ -1377,9 +1917,14 @@ function detail(r, session) {
|
|
|
1377
1917
|
if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
|
|
1378
1918
|
}
|
|
1379
1919
|
return {
|
|
1380
|
-
...listItem(r),
|
|
1920
|
+
...listItem(r, view),
|
|
1381
1921
|
edge: edgeOf(r.reqHeaders),
|
|
1382
1922
|
redirect: r.redirect ?? null,
|
|
1923
|
+
issues: diagnose(r, view.context(r.tunnel)),
|
|
1924
|
+
client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
|
|
1925
|
+
query: queryOf(r.path),
|
|
1926
|
+
cookies: { sent: parseCookieHeader(r.reqHeaders.cookie ?? ""), set: (r.resSetCookies ?? []).map(parseSetCookie) },
|
|
1927
|
+
flow: flowOf(r, session, view),
|
|
1383
1928
|
reqHeaders: r.reqHeaders,
|
|
1384
1929
|
resHeaders: r.resHeaders,
|
|
1385
1930
|
reqBody: r.reqBody,
|
|
@@ -1467,7 +2012,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1467
2012
|
}
|
|
1468
2013
|
const isEvents = method === "GET" && path === "/api/events";
|
|
1469
2014
|
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];
|
|
2015
|
+
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
1471
2016
|
if (!known) return text(res, 404, "Not found.");
|
|
1472
2017
|
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
1473
2018
|
if (isEvents) {
|
|
@@ -1479,15 +2024,25 @@ data: ${JSON.stringify({ ids })}
|
|
|
1479
2024
|
return;
|
|
1480
2025
|
}
|
|
1481
2026
|
if (path === "/api/session") {
|
|
1482
|
-
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
|
|
2027
|
+
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth, port: t.port })) });
|
|
2028
|
+
}
|
|
2029
|
+
if (path === "/api/requests") {
|
|
2030
|
+
const view = viewOf(session);
|
|
2031
|
+
return json(res, 200, { items: session.records.list().map((r2) => listItem(r2, view)) });
|
|
2032
|
+
}
|
|
2033
|
+
if (path === "/api/har") {
|
|
2034
|
+
const wanted = url2.searchParams.get("ids");
|
|
2035
|
+
const ids = wanted === null ? null : new Set(wanted.split(",").map(Number));
|
|
2036
|
+
const records = session.records.list().filter((r2) => !ids || ids.has(r2.id));
|
|
2037
|
+
res.setHeader("Content-Disposition", 'attachment; filename="duto.har"');
|
|
2038
|
+
return json(res, 200, toHar(records, viewOf(session).urlOf, CLI_VERSION));
|
|
1483
2039
|
}
|
|
1484
|
-
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1485
2040
|
const id = Number(detailId[1]);
|
|
1486
2041
|
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1487
2042
|
if (method === "POST") return json(res, 200, session.replay(id));
|
|
1488
2043
|
const r = session.records.get(id);
|
|
1489
2044
|
if (!r) return json(res, 404, { error: GONE });
|
|
1490
|
-
return json(res, 200, detail(r, session));
|
|
2045
|
+
return json(res, 200, detail(r, session, viewOf(session)));
|
|
1491
2046
|
};
|
|
1492
2047
|
const server = createServer((req, res) => {
|
|
1493
2048
|
try {
|
|
@@ -2052,6 +2607,8 @@ async function startProxy(o) {
|
|
|
2052
2607
|
}
|
|
2053
2608
|
function trackResponse(id, started, up, reqCap) {
|
|
2054
2609
|
const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
|
|
2610
|
+
const ttfbMs = Date.now() - started;
|
|
2611
|
+
const setCookies = up.headers["set-cookie"];
|
|
2055
2612
|
let bytes = 0;
|
|
2056
2613
|
up.on("data", (c) => {
|
|
2057
2614
|
bytes += c.length;
|
|
@@ -2062,8 +2619,10 @@ async function startProxy(o) {
|
|
|
2062
2619
|
() => o.store.update(id, {
|
|
2063
2620
|
status: up.statusCode ?? null,
|
|
2064
2621
|
durationMs: Date.now() - started,
|
|
2622
|
+
ttfbMs,
|
|
2065
2623
|
bytes,
|
|
2066
2624
|
resHeaders: flat(up.headers),
|
|
2625
|
+
...setCookies?.length && { resSetCookies: setCookies },
|
|
2067
2626
|
resBody: resCap.text(),
|
|
2068
2627
|
truncated: !!reqCap?.truncated || resCap.truncated,
|
|
2069
2628
|
resTruncated: resCap.truncated,
|
|
@@ -2664,8 +3223,8 @@ function listFlag(raw) {
|
|
|
2664
3223
|
}
|
|
2665
3224
|
function parseAuthFlags(raw) {
|
|
2666
3225
|
const out = [];
|
|
2667
|
-
for (const
|
|
2668
|
-
const r = parseCredential(
|
|
3226
|
+
for (const entry2 of listFlag(raw)) {
|
|
3227
|
+
const r = parseCredential(entry2);
|
|
2669
3228
|
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
3229
|
if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
|
|
2671
3230
|
out.push(r.cred);
|