@dutodev/cli 1.3.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/{fullscreen-AI6PC7RL.js → fullscreen-MZMQ6ADN.js} +16 -2
- package/dist/index.js +621 -41
- 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`.
|
|
@@ -76,6 +76,17 @@ function Detail({ record }) {
|
|
|
76
76
|
record.replayOf
|
|
77
77
|
] }),
|
|
78
78
|
record.error && /* @__PURE__ */ jsx(Text, { color: C.err, children: record.error }),
|
|
79
|
+
record.redirect && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
80
|
+
/* @__PURE__ */ jsx(Text, { color: C.signal, children: "\u21AA Location rewritten" }),
|
|
81
|
+
/* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
|
|
82
|
+
" ",
|
|
83
|
+
record.redirect.from
|
|
84
|
+
] }),
|
|
85
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
86
|
+
" \u2192 ",
|
|
87
|
+
record.redirect.to
|
|
88
|
+
] })
|
|
89
|
+
] }),
|
|
79
90
|
/* @__PURE__ */ jsx(Section, { title: "Request", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated, note: bodyNote(record, "req") }),
|
|
80
91
|
/* @__PURE__ */ jsx(Section, { title: "Response", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated, note: bodyNote(record, "res") }),
|
|
81
92
|
/* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
|
|
@@ -169,6 +180,7 @@ function QrView({ tunnel, link, now }) {
|
|
|
169
180
|
// src/ui/RequestList.tsx
|
|
170
181
|
import { Box as Box3, Text as Text4 } from "ink";
|
|
171
182
|
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
183
|
+
var REWRITTEN = " \u21AA rewritten";
|
|
172
184
|
function accessLabel(a) {
|
|
173
185
|
if (!a) return null;
|
|
174
186
|
if (a.kind === "user") return { text: a.user, color: C.muted };
|
|
@@ -180,7 +192,8 @@ function RequestList(props) {
|
|
|
180
192
|
const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
|
|
181
193
|
const visible = records.slice(start, start + height);
|
|
182
194
|
const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
|
|
183
|
-
const
|
|
195
|
+
const redirectW = visible.some((r) => r.redirect) ? REWRITTEN.length : 0;
|
|
196
|
+
const pathW = Math.max(10, width - 54 - accessW - redirectW);
|
|
184
197
|
const title = props.filter ? `REQUESTS \xB7 filter: ${props.filter} ` : "REQUESTS ";
|
|
185
198
|
return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
|
|
186
199
|
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
|
|
@@ -204,7 +217,8 @@ function RequestList(props) {
|
|
|
204
217
|
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
|
|
205
218
|
" ",
|
|
206
219
|
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) }),
|
|
207
|
-
acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null
|
|
220
|
+
acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null,
|
|
221
|
+
r.redirect ? /* @__PURE__ */ jsx4(Text4, { color: C.signal, children: REWRITTEN }) : null
|
|
208
222
|
] }, r.id);
|
|
209
223
|
})
|
|
210
224
|
] });
|
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,20 +806,45 @@ 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; }
|
|
755
820
|
.row .dur { text-align: right; }
|
|
756
821
|
.m { font-weight: 700; }
|
|
757
822
|
.mut { color: ${C.muted}; }
|
|
823
|
+
.sig { color: ${C.signal}; }
|
|
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; }
|
|
758
848
|
.empty { padding: 12px; color: ${C.muted}; }
|
|
759
849
|
.detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
760
850
|
.pane { flex: 1; }
|
|
@@ -796,14 +886,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
|
796
886
|
var SCRIPT = String.raw`"use strict";
|
|
797
887
|
var countLabel = (${countLabel.toString()});
|
|
798
888
|
var edgeLabel = (${edgeLabel.toString()});
|
|
889
|
+
var matchesFilter = (${matchesFilter.toString()});
|
|
799
890
|
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
800
891
|
var jsonTokens = (${jsonTokens.toString()});
|
|
801
892
|
var jsonIndent = (${jsonIndent.toString()});
|
|
802
893
|
var COLORS = ${JSON.stringify(C)};
|
|
803
894
|
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
804
|
-
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
|
+
];
|
|
805
901
|
|
|
806
|
-
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 };
|
|
807
903
|
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
808
904
|
var es = null, lost = false, replaying = false;
|
|
809
905
|
function fail(e) { if (!state.ended) console.error(e); }
|
|
@@ -855,14 +951,60 @@ function header(h, name) {
|
|
|
855
951
|
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
856
952
|
return "";
|
|
857
953
|
}
|
|
954
|
+
function kept() {
|
|
955
|
+
return state.items.filter(function (it) { return it.id > state.clearedAfter; });
|
|
956
|
+
}
|
|
858
957
|
function visible() {
|
|
859
|
-
var f = $("filter").value
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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);
|
|
863
980
|
});
|
|
864
981
|
}
|
|
865
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
|
+
|
|
866
1008
|
function renderTop() {
|
|
867
1009
|
var n = state.items.length;
|
|
868
1010
|
$("count").textContent = countLabel(n, "request", "requests");
|
|
@@ -881,21 +1023,29 @@ function renderList() {
|
|
|
881
1023
|
var multi = state.tunnels.length > 1;
|
|
882
1024
|
list.classList.toggle("multi", multi);
|
|
883
1025
|
clear(list);
|
|
1026
|
+
renderChips();
|
|
1027
|
+
$("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
|
|
884
1028
|
var items = visible();
|
|
885
1029
|
if (items.length === 0) {
|
|
886
|
-
|
|
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));
|
|
887
1032
|
return;
|
|
888
1033
|
}
|
|
1034
|
+
var flow = state.selected === null ? {} : chainOf(state.selected);
|
|
889
1035
|
items.forEach(function (it) {
|
|
890
|
-
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" : ""));
|
|
891
1037
|
row.setAttribute("role", "option");
|
|
892
1038
|
row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
|
|
893
1039
|
row.dataset.id = String(it.id);
|
|
894
|
-
|
|
1040
|
+
var mark = markOf(it);
|
|
1041
|
+
var ms = el("span", mark.cls, mark.text);
|
|
1042
|
+
ms.title = mark.title;
|
|
1043
|
+
row.appendChild(ms);
|
|
895
1044
|
if (multi) row.appendChild(el("span", "mut", it.tunnel));
|
|
896
1045
|
row.appendChild(el("span", "m", methodOf(it)));
|
|
897
1046
|
var p = el("span", "", it.path);
|
|
898
|
-
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));
|
|
899
1049
|
row.appendChild(p);
|
|
900
1050
|
var st = statusInfo(it);
|
|
901
1051
|
var s = el("span", "", st.text);
|
|
@@ -989,7 +1139,8 @@ function renderContent() {
|
|
|
989
1139
|
var box = $("content");
|
|
990
1140
|
var top = box.scrollTop;
|
|
991
1141
|
clear(box);
|
|
992
|
-
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);
|
|
993
1144
|
else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
|
|
994
1145
|
else if (state.tab === "Response") {
|
|
995
1146
|
box.appendChild(el("div", "sect", "Headers"));
|
|
@@ -1006,6 +1157,130 @@ function renderContent() {
|
|
|
1006
1157
|
box.scrollTop = top;
|
|
1007
1158
|
}
|
|
1008
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
|
+
|
|
1009
1284
|
function table(h) {
|
|
1010
1285
|
var keys = Object.keys(h || {});
|
|
1011
1286
|
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
@@ -1181,11 +1456,9 @@ function connect() {
|
|
|
1181
1456
|
banner("Disconnected from the session.");
|
|
1182
1457
|
if (es.readyState === 2) probe();
|
|
1183
1458
|
});
|
|
1184
|
-
es.addEventListener("changed", function (
|
|
1185
|
-
var ids = [];
|
|
1186
|
-
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1459
|
+
es.addEventListener("changed", function () {
|
|
1187
1460
|
loadList();
|
|
1188
|
-
if (state.selected !== null
|
|
1461
|
+
if (state.selected !== null) loadDetail(state.selected);
|
|
1189
1462
|
});
|
|
1190
1463
|
}
|
|
1191
1464
|
function probe() {
|
|
@@ -1205,6 +1478,13 @@ function init() {
|
|
|
1205
1478
|
});
|
|
1206
1479
|
}
|
|
1207
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
|
+
});
|
|
1208
1488
|
$("replay").addEventListener("click", replay);
|
|
1209
1489
|
$("copyBody").addEventListener("click", function () {
|
|
1210
1490
|
var b = state.detail ? copyable(state.detail) : { text: null };
|
|
@@ -1239,7 +1519,7 @@ function init() {
|
|
|
1239
1519
|
init();
|
|
1240
1520
|
`;
|
|
1241
1521
|
function inspectorPage(nonce) {
|
|
1242
|
-
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("");
|
|
1243
1523
|
return `<!doctype html>
|
|
1244
1524
|
<html lang="en">
|
|
1245
1525
|
<head>
|
|
@@ -1255,8 +1535,21 @@ function inspectorPage(nonce) {
|
|
|
1255
1535
|
<div class="banner" id="banner" role="status" hidden></div>
|
|
1256
1536
|
<div class="main">
|
|
1257
1537
|
<div class="side">
|
|
1258
|
-
<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>
|
|
1259
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>
|
|
1260
1553
|
</div>
|
|
1261
1554
|
<div class="detail">
|
|
1262
1555
|
<div class="empty" id="none">Select a request from the list.</div>
|
|
@@ -1306,6 +1599,25 @@ function replayHeaders(r) {
|
|
|
1306
1599
|
return out;
|
|
1307
1600
|
}
|
|
1308
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
|
+
|
|
1309
1621
|
// src/inspector/curl.ts
|
|
1310
1622
|
var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1311
1623
|
var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
|
|
@@ -1326,6 +1638,202 @@ function toCurl(r, o) {
|
|
|
1326
1638
|
return parts.join(" \\\n ");
|
|
1327
1639
|
}
|
|
1328
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
|
+
|
|
1329
1837
|
// src/inspector/server.ts
|
|
1330
1838
|
var DEFAULT_PORT = 4141;
|
|
1331
1839
|
var PORT_TRIES = 20;
|
|
@@ -1336,7 +1844,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
|
|
|
1336
1844
|
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1337
1845
|
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1338
1846
|
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1339
|
-
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);
|
|
1340
1871
|
return {
|
|
1341
1872
|
id: r.id,
|
|
1342
1873
|
tunnel: r.tunnel,
|
|
@@ -1347,11 +1878,35 @@ function listItem(r) {
|
|
|
1347
1878
|
bytes: r.bytes,
|
|
1348
1879
|
startedAt: r.startedAt,
|
|
1349
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 },
|
|
1350
1888
|
...r.replayOf !== void 0 && { replayOf: r.replayOf },
|
|
1351
|
-
...r.error !== void 0 && { error: r.error }
|
|
1889
|
+
...r.error !== void 0 && { error: r.error },
|
|
1890
|
+
...r.redirect !== void 0 && { rewritten: true },
|
|
1891
|
+
...r.access !== void 0 && { access: r.access }
|
|
1352
1892
|
};
|
|
1353
1893
|
}
|
|
1354
|
-
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) {
|
|
1355
1910
|
const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
|
|
1356
1911
|
const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
|
|
1357
1912
|
let curl = null;
|
|
@@ -1362,8 +1917,14 @@ function detail(r, session) {
|
|
|
1362
1917
|
if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
|
|
1363
1918
|
}
|
|
1364
1919
|
return {
|
|
1365
|
-
...listItem(r),
|
|
1920
|
+
...listItem(r, view),
|
|
1366
1921
|
edge: edgeOf(r.reqHeaders),
|
|
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),
|
|
1367
1928
|
reqHeaders: r.reqHeaders,
|
|
1368
1929
|
resHeaders: r.resHeaders,
|
|
1369
1930
|
reqBody: r.reqBody,
|
|
@@ -1451,7 +2012,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1451
2012
|
}
|
|
1452
2013
|
const isEvents = method === "GET" && path === "/api/events";
|
|
1453
2014
|
const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
|
|
1454
|
-
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];
|
|
1455
2016
|
if (!known) return text(res, 404, "Not found.");
|
|
1456
2017
|
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
1457
2018
|
if (isEvents) {
|
|
@@ -1463,15 +2024,25 @@ data: ${JSON.stringify({ ids })}
|
|
|
1463
2024
|
return;
|
|
1464
2025
|
}
|
|
1465
2026
|
if (path === "/api/session") {
|
|
1466
|
-
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));
|
|
1467
2039
|
}
|
|
1468
|
-
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1469
2040
|
const id = Number(detailId[1]);
|
|
1470
2041
|
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1471
2042
|
if (method === "POST") return json(res, 200, session.replay(id));
|
|
1472
2043
|
const r = session.records.get(id);
|
|
1473
2044
|
if (!r) return json(res, 404, { error: GONE });
|
|
1474
|
-
return json(res, 200, detail(r, session));
|
|
2045
|
+
return json(res, 200, detail(r, session, viewOf(session)));
|
|
1475
2046
|
};
|
|
1476
2047
|
const server = createServer((req, res) => {
|
|
1477
2048
|
try {
|
|
@@ -2036,6 +2607,8 @@ async function startProxy(o) {
|
|
|
2036
2607
|
}
|
|
2037
2608
|
function trackResponse(id, started, up, reqCap) {
|
|
2038
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"];
|
|
2039
2612
|
let bytes = 0;
|
|
2040
2613
|
up.on("data", (c) => {
|
|
2041
2614
|
bytes += c.length;
|
|
@@ -2046,8 +2619,10 @@ async function startProxy(o) {
|
|
|
2046
2619
|
() => o.store.update(id, {
|
|
2047
2620
|
status: up.statusCode ?? null,
|
|
2048
2621
|
durationMs: Date.now() - started,
|
|
2622
|
+
ttfbMs,
|
|
2049
2623
|
bytes,
|
|
2050
2624
|
resHeaders: flat(up.headers),
|
|
2625
|
+
...setCookies?.length && { resSetCookies: setCookies },
|
|
2051
2626
|
resBody: resCap.text(),
|
|
2052
2627
|
truncated: !!reqCap?.truncated || resCap.truncated,
|
|
2053
2628
|
resTruncated: resCap.truncated,
|
|
@@ -2138,7 +2713,12 @@ async function startProxy(o) {
|
|
|
2138
2713
|
return;
|
|
2139
2714
|
}
|
|
2140
2715
|
if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
|
|
2141
|
-
|
|
2716
|
+
const from = up.headers.location;
|
|
2717
|
+
const to = rewriteLocation(from, route.host, hostOf(req));
|
|
2718
|
+
if (to !== from) {
|
|
2719
|
+
up.headers.location = to;
|
|
2720
|
+
o.store.update(id, { redirect: { from, to } });
|
|
2721
|
+
}
|
|
2142
2722
|
}
|
|
2143
2723
|
res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
|
|
2144
2724
|
trackResponse(id, started, up, reqCap);
|
|
@@ -2601,7 +3181,7 @@ function runPlain(session, out) {
|
|
|
2601
3181
|
printed.add(r.id);
|
|
2602
3182
|
const method = r.websocket ? "WS" : r.method;
|
|
2603
3183
|
out(
|
|
2604
|
-
`${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}`
|
|
3184
|
+
`${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
|
|
2605
3185
|
);
|
|
2606
3186
|
}
|
|
2607
3187
|
};
|
|
@@ -2643,8 +3223,8 @@ function listFlag(raw) {
|
|
|
2643
3223
|
}
|
|
2644
3224
|
function parseAuthFlags(raw) {
|
|
2645
3225
|
const out = [];
|
|
2646
|
-
for (const
|
|
2647
|
-
const r = parseCredential(
|
|
3226
|
+
for (const entry2 of listFlag(raw)) {
|
|
3227
|
+
const r = parseCredential(entry2);
|
|
2648
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");
|
|
2649
3229
|
if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
|
|
2650
3230
|
out.push(r.cred);
|
|
@@ -2682,7 +3262,7 @@ async function runSession(specs, source, flags) {
|
|
|
2682
3262
|
}
|
|
2683
3263
|
}
|
|
2684
3264
|
if (wantsUi(flags)) {
|
|
2685
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3265
|
+
const { runFullscreen } = await import("./fullscreen-MZMQ6ADN.js");
|
|
2686
3266
|
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
|
|
2687
3267
|
await inspector?.close().catch(() => void 0);
|
|
2688
3268
|
process.exit(code);
|