@dutodev/cli 1.5.0 → 1.6.1
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
CHANGED
|
@@ -91,7 +91,39 @@ Ways out, from simplest to most work:
|
|
|
91
91
|
|
|
92
92
|
In every case, register `https://<name>-<user>.duto.dev/<callback path>` as the callback URL at the provider. The comparison is usually exact, with no wildcards: each person on the team has their own address.
|
|
93
93
|
|
|
94
|
-
**
|
|
94
|
+
**What you cannot set.** Some headers belong to Cloudflare, to Duto or to the HTTP connection itself. `duto.yml` refuses them, with the line and what to do:
|
|
95
|
+
|
|
96
|
+
| Headers | Who sets them | In `headers:` |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `CF-*` (`CF-Connecting-IP`, `CF-IPCountry`, `CF-Ray`) | Cloudflare | error |
|
|
99
|
+
| `X-Forwarded-For` | Cloudflare: the client IP | error |
|
|
100
|
+
| `X-Duto-*` | Duto | error; never reaches your app |
|
|
101
|
+
| `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `Expect`, `TE`, `Trailer`, `Proxy-Connection` | Duto, per connection | error |
|
|
102
|
+
| `Host` | `host:` | error, use `host:` |
|
|
103
|
+
| `X-Forwarded-Host`, `-Proto`, `-Port`, `-Prefix` | you | replaces the value; `""` removes it |
|
|
104
|
+
| everything else | you | replaces the value; `""` removes it |
|
|
105
|
+
|
|
106
|
+
Values must be ASCII or Latin-1 text. `${VAR}` reads from an environment variable, so tokens stay out of git:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
headers:
|
|
110
|
+
Authorization: Bearer ${API_TOKEN}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
If a tunnel without `auth:` injects a credential (`Authorization`, `Cookie`, `X-Api-Key`, `*-Token`…), the CLI warns at start: anyone with the URL is signed in as that credential.
|
|
114
|
+
|
|
115
|
+
**Forged `X-Forwarded-*`.** Cloudflare sets `X-Forwarded-Proto` and `X-Forwarded-For`, but nobody sets `X-Forwarded-Host`, `X-Forwarded-Port` or `X-Forwarded-Prefix`: any value that arrives was written by the client. Duto removes those three by default, so an app that trusts them cannot be fooled. When a reverse proxy you trust sits in front of the tunnel (a Vercel rewrite, a staging nginx, a company gateway), or when you test with `curl -H 'X-Forwarded-Host: acme.test'`, let them through:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
tunnels:
|
|
119
|
+
shop:
|
|
120
|
+
port: 8080
|
|
121
|
+
trust_forwarded: true
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Values in `headers:` always win. With `host:`, `X-Forwarded-Host` is still not sent.
|
|
125
|
+
|
|
126
|
+
**About `X-Forwarded-*`.** These headers are an old convention between reverse proxies for telling the app what the client actually asked for. The standard version is the `Forwarded` header (RFC 7239), which is less used in practice. Frameworks usually read them only when the proxy is marked as trusted (`trusted proxies`, `trust proxy`, `USE_X_FORWARDED_HOST`), so what `headers:` does depends on how your app is configured.
|
|
95
127
|
|
|
96
128
|
## Password-protect a tunnel
|
|
97
129
|
|
|
@@ -166,7 +198,8 @@ With every session, the CLI starts a local inspector: a browser page with reques
|
|
|
166
198
|
- **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
199
|
- **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
200
|
- **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
|
-
- **
|
|
201
|
+
- **Changed by Duto.** Each request lists what Duto changed before your app got it, and why: headers from `headers:`, the Host from `host:`, forged `X-Forwarded-*` from the client, Duto's own credentials, the rewritten `Location` and how the tunnel password let it in. The headers shown are the ones your app received.
|
|
202
|
+
- **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. Values that came from `headers:` are masked in the HAR (`••• (duto.yml)`), so you can share it.
|
|
170
203
|
- **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.
|
|
171
204
|
- **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.
|
|
172
205
|
- **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`.
|
|
@@ -408,6 +408,41 @@ function compareVersions(a, b) {
|
|
|
408
408
|
return 0;
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
// ../shared/src/headers.ts
|
|
412
|
+
var FRAMING_HEADERS = ["content-length", "transfer-encoding", "connection", "keep-alive", "upgrade", "expect", "te", "trailer", "proxy-connection"];
|
|
413
|
+
var FORWARDED_ADDRESS_HEADERS = ["x-forwarded-host", "x-forwarded-proto", "x-forwarded-port", "x-forwarded-prefix"];
|
|
414
|
+
var UNTRUSTED_VISITOR_HEADERS = ["x-forwarded-host", "x-forwarded-port", "x-forwarded-prefix"];
|
|
415
|
+
var CREDENTIAL_NAMES = ["authorization", "cookie", "proxy-authorization", "x-forwarded-user", "remote-user", "x-remote-user"];
|
|
416
|
+
var CREDENTIAL_SUFFIXES = ["-token", "-api-key", "-apikey", "-secret"];
|
|
417
|
+
var VALID_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
418
|
+
function headerGroup(name) {
|
|
419
|
+
const n = name.toLowerCase();
|
|
420
|
+
if (n.startsWith("cf-")) return "cloudflare";
|
|
421
|
+
if (n.startsWith("x-duto-")) return "duto";
|
|
422
|
+
if (FRAMING_HEADERS.includes(n)) return "framing";
|
|
423
|
+
if (n === "host") return "host";
|
|
424
|
+
if (n === "x-forwarded-for") return "client-ip";
|
|
425
|
+
if (FORWARDED_ADDRESS_HEADERS.includes(n)) return "forwarded-address";
|
|
426
|
+
return "other";
|
|
427
|
+
}
|
|
428
|
+
var RESERVED_REASONS = {
|
|
429
|
+
cloudflare: "is set by Cloudflare",
|
|
430
|
+
duto: "is reserved for Duto",
|
|
431
|
+
framing: "is set by Duto",
|
|
432
|
+
"client-ip": "carries the client IP and is set by Cloudflare"
|
|
433
|
+
};
|
|
434
|
+
function reservedHeaderError(name) {
|
|
435
|
+
const reason = RESERVED_REASONS[headerGroup(name)];
|
|
436
|
+
return reason ? `${name} ${reason}. Remove it from headers.` : null;
|
|
437
|
+
}
|
|
438
|
+
function isValidHeaderValue(value) {
|
|
439
|
+
return VALID_VALUE.test(value);
|
|
440
|
+
}
|
|
441
|
+
function isCredentialLike(name) {
|
|
442
|
+
const n = name.toLowerCase();
|
|
443
|
+
return CREDENTIAL_NAMES.includes(n) || CREDENTIAL_SUFFIXES.some((s) => n.endsWith(s));
|
|
444
|
+
}
|
|
445
|
+
|
|
411
446
|
// src/config.ts
|
|
412
447
|
import { randomUUID } from "crypto";
|
|
413
448
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -489,6 +524,26 @@ function updateLine(latest) {
|
|
|
489
524
|
return `duto ${latest} is available. Update: ${CLI_UPDATE_COMMAND}`;
|
|
490
525
|
}
|
|
491
526
|
|
|
527
|
+
// src/inspector/changes.ts
|
|
528
|
+
var SIGNS = { add: "+", replace: "~", remove: "\u2212" };
|
|
529
|
+
function headerLabel(name) {
|
|
530
|
+
return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("-");
|
|
531
|
+
}
|
|
532
|
+
function reasonOf(c) {
|
|
533
|
+
if (c.source === "headers") return c.kind === "remove" ? 'headers: ""' : "headers:";
|
|
534
|
+
if (c.source === "host") return "host:";
|
|
535
|
+
if (c.source === "visitor") return "from the visitor; set trust_forwarded: true to keep it";
|
|
536
|
+
return "Duto credential";
|
|
537
|
+
}
|
|
538
|
+
function valueOf(c) {
|
|
539
|
+
if (c.kind === "replace") return `${c.from ?? ""} \u2192 ${c.to ?? ""}`;
|
|
540
|
+
return (c.kind === "add" ? c.to : c.from) ?? "";
|
|
541
|
+
}
|
|
542
|
+
function describeChange(c) {
|
|
543
|
+
const sign = c.kind === "replace" && c.source === "host" ? " " : SIGNS[c.kind];
|
|
544
|
+
return { label: `${sign} ${headerLabel(c.name)}`, value: valueOf(c), reason: reasonOf(c) };
|
|
545
|
+
}
|
|
546
|
+
|
|
492
547
|
// src/ui/theme.ts
|
|
493
548
|
var C = {
|
|
494
549
|
signal: "#2dd4bf",
|
|
@@ -552,10 +607,17 @@ export {
|
|
|
552
607
|
placeOf,
|
|
553
608
|
CLI_VERSION_HEADER,
|
|
554
609
|
CLI_USER_AGENT,
|
|
610
|
+
FRAMING_HEADERS,
|
|
611
|
+
UNTRUSTED_VISITOR_HEADERS,
|
|
612
|
+
reservedHeaderError,
|
|
613
|
+
isValidHeaderValue,
|
|
614
|
+
isCredentialLike,
|
|
555
615
|
dutoHome,
|
|
556
616
|
apiUrl,
|
|
557
617
|
saveConfig,
|
|
558
618
|
loadConfig,
|
|
619
|
+
headerLabel,
|
|
620
|
+
describeChange,
|
|
559
621
|
C,
|
|
560
622
|
tunnelColor,
|
|
561
623
|
fit,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
MAX_NAME,
|
|
5
5
|
TRUNCATED_NOTE,
|
|
6
6
|
bodyNote,
|
|
7
|
+
describeChange,
|
|
7
8
|
fit,
|
|
8
9
|
formatBytes,
|
|
9
10
|
formatDuration,
|
|
@@ -14,7 +15,7 @@ import {
|
|
|
14
15
|
tunnelColor,
|
|
15
16
|
updateLine,
|
|
16
17
|
validateName
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-DUC4RSBW.js";
|
|
18
19
|
|
|
19
20
|
// src/ui/fullscreen.tsx
|
|
20
21
|
import { render } from "ink";
|
|
@@ -87,6 +88,18 @@ function Detail({ record }) {
|
|
|
87
88
|
record.redirect.to
|
|
88
89
|
] })
|
|
89
90
|
] }),
|
|
91
|
+
record.headerChanges && record.headerChanges.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
|
|
92
|
+
/* @__PURE__ */ jsx(Text, { color: C.signal, children: "Changed by Duto" }),
|
|
93
|
+
record.headerChanges.map(describeChange).map((c, i) => /* @__PURE__ */ jsxs(Text, { wrap: "truncate-end", children: [
|
|
94
|
+
c.label,
|
|
95
|
+
" ",
|
|
96
|
+
c.value,
|
|
97
|
+
/* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
|
|
98
|
+
" ",
|
|
99
|
+
c.reason
|
|
100
|
+
] })
|
|
101
|
+
] }, i))
|
|
102
|
+
] }),
|
|
90
103
|
/* @__PURE__ */ jsx(Section, { title: "Request", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated, note: bodyNote(record, "req") }),
|
|
91
104
|
/* @__PURE__ */ jsx(Section, { title: "Response", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated, note: bodyNote(record, "res") }),
|
|
92
105
|
/* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
|
|
@@ -299,7 +312,11 @@ var KEYS = [
|
|
|
299
312
|
["tab", "focus"],
|
|
300
313
|
["q", "quit"]
|
|
301
314
|
];
|
|
302
|
-
function
|
|
315
|
+
function startupMessage(inspectorError, warnings) {
|
|
316
|
+
const lines = [...inspectorError ? [`Inspector failed to start: ${inspectorError}`] : [], ...warnings];
|
|
317
|
+
return lines.length ? { text: lines.join(" "), tone: "warn" } : null;
|
|
318
|
+
}
|
|
319
|
+
function App({ session, version, system, onExit, inspector = null, inspectorError, warnings = [], update = null }) {
|
|
303
320
|
const subState = useCallback((fn) => session.subscribe(fn), [session]);
|
|
304
321
|
const subRecords = useCallback((fn) => session.records.subscribe(fn), [session]);
|
|
305
322
|
const state = useSyncExternalStore(subState, () => session.getState());
|
|
@@ -312,7 +329,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
|
|
|
312
329
|
const [focus, setFocus] = useState("requests");
|
|
313
330
|
const [selReq, setSelReq] = useState(0);
|
|
314
331
|
const [selTun, setSelTun] = useState(0);
|
|
315
|
-
const [message, setMessage] = useState(
|
|
332
|
+
const [message, setMessage] = useState(() => startupMessage(inspectorError, warnings));
|
|
316
333
|
const [now, setNow] = useState(() => Date.now());
|
|
317
334
|
const quitting = useRef(false);
|
|
318
335
|
const records = useMemo(() => {
|
|
@@ -564,7 +581,7 @@ var realSystem = {
|
|
|
564
581
|
|
|
565
582
|
// src/ui/fullscreen.tsx
|
|
566
583
|
import { jsx as jsx7 } from "react/jsx-runtime";
|
|
567
|
-
function runFullscreen(session, specs, version, inspector, inspectorError, update = Promise.resolve(null)) {
|
|
584
|
+
function runFullscreen(session, specs, version, inspector, inspectorError, update = Promise.resolve(null), warnings = []) {
|
|
568
585
|
return withFullscreen(
|
|
569
586
|
process.stdout,
|
|
570
587
|
(signal) => new Promise((resolve) => {
|
|
@@ -575,7 +592,7 @@ function runFullscreen(session, specs, version, inspector, inspectorError, updat
|
|
|
575
592
|
app.unmount();
|
|
576
593
|
resolve(code);
|
|
577
594
|
};
|
|
578
|
-
const view = (latest) => /* @__PURE__ */ jsx7(App, { session, version, system: realSystem, inspector, inspectorError, update: latest, onExit: () => finish(0) });
|
|
595
|
+
const view = (latest) => /* @__PURE__ */ jsx7(App, { session, version, system: realSystem, inspector, inspectorError, warnings, update: latest, onExit: () => finish(0) });
|
|
579
596
|
const app = render(view(null), { exitOnCtrlC: false });
|
|
580
597
|
void update.then((latest) => {
|
|
581
598
|
if (latest && !done) app.rerender(view(latest));
|
package/dist/index.js
CHANGED
|
@@ -3,24 +3,31 @@ import {
|
|
|
3
3
|
C,
|
|
4
4
|
CLI_USER_AGENT,
|
|
5
5
|
CLI_VERSION_HEADER,
|
|
6
|
+
FRAMING_HEADERS,
|
|
6
7
|
MAX_NAME,
|
|
7
8
|
TRUNCATED_NOTE,
|
|
9
|
+
UNTRUSTED_VISITOR_HEADERS,
|
|
8
10
|
apiUrl,
|
|
9
11
|
bodyNote,
|
|
10
12
|
checkForUpdate,
|
|
13
|
+
describeChange,
|
|
11
14
|
dutoHome,
|
|
12
15
|
formatBytes,
|
|
13
16
|
formatDuration,
|
|
14
17
|
formatTime,
|
|
18
|
+
headerLabel,
|
|
19
|
+
isCredentialLike,
|
|
20
|
+
isValidHeaderValue,
|
|
15
21
|
loadConfig,
|
|
16
22
|
localLabel,
|
|
17
23
|
normalizeName,
|
|
18
24
|
placeOf,
|
|
25
|
+
reservedHeaderError,
|
|
19
26
|
saveConfig,
|
|
20
27
|
statusColor,
|
|
21
28
|
updateLine,
|
|
22
29
|
validateName
|
|
23
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-DUC4RSBW.js";
|
|
24
31
|
|
|
25
32
|
// src/ui/brand.ts
|
|
26
33
|
function colorOn(stream = process.stdout) {
|
|
@@ -36,8 +43,8 @@ var SGR = {
|
|
|
36
43
|
warn: "38;2;250;204;21",
|
|
37
44
|
err: "38;2;248;113;113"
|
|
38
45
|
};
|
|
39
|
-
function paint(tone,
|
|
40
|
-
return colorOn(stream) ? `\x1B[${SGR[tone]}m${
|
|
46
|
+
function paint(tone, text2, stream) {
|
|
47
|
+
return colorOn(stream) ? `\x1B[${SGR[tone]}m${text2}\x1B[0m` : text2;
|
|
41
48
|
}
|
|
42
49
|
var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
|
|
43
50
|
var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
|
|
@@ -50,7 +57,7 @@ import { join as join5, relative } from "path";
|
|
|
50
57
|
import cac from "cac";
|
|
51
58
|
|
|
52
59
|
// src/version.ts
|
|
53
|
-
var CLI_VERSION = "1.
|
|
60
|
+
var CLI_VERSION = "1.6.1";
|
|
54
61
|
|
|
55
62
|
// src/api.ts
|
|
56
63
|
var DutoApiError = class extends Error {
|
|
@@ -368,7 +375,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
|
|
|
368
375
|
var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
369
376
|
var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
|
|
370
377
|
var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
371
|
-
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
|
|
378
|
+
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
|
|
372
379
|
var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
|
|
373
380
|
function parseHost(raw) {
|
|
374
381
|
const host = raw.trim().toLowerCase();
|
|
@@ -383,14 +390,29 @@ function parseCredential(raw) {
|
|
|
383
390
|
if (password.length < 8 || password.length > 128) return { ok: false, reason: "password" };
|
|
384
391
|
return { ok: true, cred: { user, password } };
|
|
385
392
|
}
|
|
393
|
+
function expandEnv(text2, env, missing) {
|
|
394
|
+
return text2.replace(ENV_REF_RE, (_m, name) => {
|
|
395
|
+
const val = env[name];
|
|
396
|
+
if (val === void 0) throw missing(name);
|
|
397
|
+
return val;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function headerText(node) {
|
|
401
|
+
if (node === null) return "";
|
|
402
|
+
if (!isScalar(node)) return void 0;
|
|
403
|
+
if (node.value === null) return "";
|
|
404
|
+
if (typeof node.value === "string") return node.value;
|
|
405
|
+
if (typeof node.value === "number" || typeof node.value === "boolean") return node.source ?? String(node.value);
|
|
406
|
+
return void 0;
|
|
407
|
+
}
|
|
386
408
|
function portOf(value) {
|
|
387
409
|
const v = isMap(value) ? value.get("port") : isScalar(value) ? value.value : value;
|
|
388
410
|
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535 ? v : null;
|
|
389
411
|
}
|
|
390
|
-
function parseConfig(
|
|
412
|
+
function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
|
|
391
413
|
const fail3 = (message2, line) => new ConfigError(message2, line, file);
|
|
392
414
|
const lc = new LineCounter();
|
|
393
|
-
const doc = parseDocument(
|
|
415
|
+
const doc = parseDocument(text2, { lineCounter: lc, prettyErrors: false });
|
|
394
416
|
const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
|
|
395
417
|
if (doc.errors.length) {
|
|
396
418
|
const e = doc.errors[0];
|
|
@@ -419,7 +441,7 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
419
441
|
const body = pair2.value;
|
|
420
442
|
for (const item of body.items) {
|
|
421
443
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
422
|
-
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
|
|
444
|
+
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, trust_forwarded, auth or allow)`, line);
|
|
423
445
|
}
|
|
424
446
|
const hostNode = body.get("host", true);
|
|
425
447
|
if (hostNode !== void 0) {
|
|
@@ -430,21 +452,22 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
430
452
|
const headersNode = body.get("headers", true);
|
|
431
453
|
if (headersNode !== void 0) {
|
|
432
454
|
if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
|
|
433
|
-
const headers =
|
|
455
|
+
const headers = /* @__PURE__ */ new Map();
|
|
434
456
|
for (const h of headersNode.items) {
|
|
435
457
|
const hName = isScalar(h.key) ? String(h.key.value) : "";
|
|
436
458
|
const hLine = lineAt(h.key?.range?.[0]);
|
|
437
|
-
if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
459
|
+
if (!HEADER_NAME_RE.test(hName) || hName === "__proto__") throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
438
460
|
if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
|
|
439
|
-
const
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
461
|
+
const reserved = reservedHeaderError(hName);
|
|
462
|
+
if (reserved) throw fail3(reserved, hLine);
|
|
463
|
+
if (headers.has(hName.toLowerCase())) throw fail3(`header ${hName} appears twice in "${raw}"`, hLine);
|
|
464
|
+
const text3 = headerText(h.value);
|
|
465
|
+
if (text3 === void 0) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
466
|
+
const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}")`, hLine));
|
|
467
|
+
if (!isValidHeaderValue(value)) throw fail3(`invalid character in the value of ${hName} in "${raw}". Use ASCII or Latin-1 text.`, hLine);
|
|
468
|
+
headers.set(hName.toLowerCase(), value);
|
|
446
469
|
}
|
|
447
|
-
spec.headers = headers;
|
|
470
|
+
spec.headers = Object.fromEntries(headers);
|
|
448
471
|
}
|
|
449
472
|
const rewriteNode = body.get("rewrite_redirects", true);
|
|
450
473
|
if (rewriteNode !== void 0) {
|
|
@@ -453,19 +476,21 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
453
476
|
if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
|
|
454
477
|
if (value === false) spec.rewriteRedirects = false;
|
|
455
478
|
}
|
|
479
|
+
const trustNode = body.get("trust_forwarded", true);
|
|
480
|
+
if (trustNode !== void 0) {
|
|
481
|
+
const value = isScalar(trustNode) ? trustNode.value : void 0;
|
|
482
|
+
if (typeof value !== "boolean") throw fail3(`trust_forwarded in "${raw}" must be true or false`, line);
|
|
483
|
+
if (value) spec.trustForwarded = true;
|
|
484
|
+
}
|
|
456
485
|
const authNode = body.get("auth", true);
|
|
457
486
|
if (authNode !== void 0) {
|
|
458
487
|
if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
|
|
459
488
|
const auth = [];
|
|
460
489
|
for (const item of authNode.items) {
|
|
461
490
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
464
|
-
const expanded =
|
|
465
|
-
const val = env[name2];
|
|
466
|
-
if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
|
|
467
|
-
return val;
|
|
468
|
-
});
|
|
491
|
+
const text3 = isScalar(item) && typeof item.value === "string" ? item.value : null;
|
|
492
|
+
if (text3 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
493
|
+
const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine));
|
|
469
494
|
const parsed = parseCredential(expanded);
|
|
470
495
|
if (!parsed.ok) {
|
|
471
496
|
if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
@@ -513,6 +538,19 @@ function findConfig(cwd) {
|
|
|
513
538
|
}
|
|
514
539
|
}
|
|
515
540
|
|
|
541
|
+
// src/session/warnings.ts
|
|
542
|
+
function joinNames(names) {
|
|
543
|
+
return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
544
|
+
}
|
|
545
|
+
function credentialWarnings(specs) {
|
|
546
|
+
return specs.flatMap((spec) => {
|
|
547
|
+
if (spec.auth?.length) return [];
|
|
548
|
+
const names = Object.entries(spec.headers ?? {}).filter(([name, value]) => value !== "" && isCredentialLike(name)).map(([name]) => headerLabel(name));
|
|
549
|
+
if (!names.length) return [];
|
|
550
|
+
return [`${spec.name ?? `localhost:${spec.port}`} injects ${joinNames(names)} and has no auth: anyone with the URL is signed in.`];
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
516
554
|
// src/login.ts
|
|
517
555
|
import { spawn } from "child_process";
|
|
518
556
|
import { hostname } from "os";
|
|
@@ -523,7 +561,7 @@ async function browserLogin(o) {
|
|
|
523
561
|
const start = await api.startLogin({
|
|
524
562
|
deviceId: o.deviceId,
|
|
525
563
|
deviceName: hostname(),
|
|
526
|
-
cliVersion: "1.
|
|
564
|
+
cliVersion: "1.6.1",
|
|
527
565
|
nodeVersion: process.version,
|
|
528
566
|
os: process.platform,
|
|
529
567
|
arch: process.arch
|
|
@@ -616,7 +654,7 @@ function edgeLabel(edge) {
|
|
|
616
654
|
}
|
|
617
655
|
|
|
618
656
|
// src/inspector/filter.ts
|
|
619
|
-
function matchesFilter(item,
|
|
657
|
+
function matchesFilter(item, text2) {
|
|
620
658
|
function compare(value, spec, unit) {
|
|
621
659
|
const m = /^(>=|<=|>|<|=)?(.+)$/.exec(spec);
|
|
622
660
|
if (!m) return null;
|
|
@@ -668,7 +706,7 @@ function matchesFilter(item, text) {
|
|
|
668
706
|
return null;
|
|
669
707
|
}
|
|
670
708
|
const haystack = [item.tunnel, item.websocket ? "ws" : item.method, item.path, item.status === null ? "" : String(item.status), item.client ?? ""].join(" ").toLowerCase();
|
|
671
|
-
const terms =
|
|
709
|
+
const terms = text2.trim().split(/\s+/).filter(Boolean);
|
|
672
710
|
for (const raw of terms) {
|
|
673
711
|
const negate = raw.length > 1 && raw.charAt(0) === "-";
|
|
674
712
|
const term = negate ? raw.slice(1) : raw;
|
|
@@ -684,9 +722,9 @@ function matchesFilter(item, text) {
|
|
|
684
722
|
function countLabel(n, one, many) {
|
|
685
723
|
return n + " " + (n === 1 ? one : many);
|
|
686
724
|
}
|
|
687
|
-
function jsonSyntaxError(
|
|
725
|
+
function jsonSyntaxError(text2) {
|
|
688
726
|
try {
|
|
689
|
-
JSON.parse(
|
|
727
|
+
JSON.parse(text2);
|
|
690
728
|
return null;
|
|
691
729
|
} catch (e) {
|
|
692
730
|
const message2 = String(e && e.message ? e.message : e);
|
|
@@ -696,65 +734,65 @@ function jsonSyntaxError(text) {
|
|
|
696
734
|
if (lc) {
|
|
697
735
|
let idx = 0;
|
|
698
736
|
for (let ln = 1; ln < Number(lc[1]); ln++) {
|
|
699
|
-
const nl =
|
|
700
|
-
if (nl < 0) return { position:
|
|
737
|
+
const nl = text2.indexOf("\n", idx);
|
|
738
|
+
if (nl < 0) return { position: text2.length, message: message2 };
|
|
701
739
|
idx = nl + 1;
|
|
702
740
|
}
|
|
703
|
-
return { position: Math.min(idx + Number(lc[2]) - 1,
|
|
741
|
+
return { position: Math.min(idx + Number(lc[2]) - 1, text2.length), message: message2 };
|
|
704
742
|
}
|
|
705
|
-
return { position:
|
|
743
|
+
return { position: text2.length, message: message2 };
|
|
706
744
|
}
|
|
707
745
|
}
|
|
708
|
-
function jsonTokens(
|
|
746
|
+
function jsonTokens(text2) {
|
|
709
747
|
const out = [];
|
|
710
748
|
let depth = 0;
|
|
711
749
|
let i = 0;
|
|
712
|
-
while (i <
|
|
713
|
-
const c =
|
|
750
|
+
while (i < text2.length) {
|
|
751
|
+
const c = text2.charAt(i);
|
|
714
752
|
let j = i + 1;
|
|
715
753
|
let kind;
|
|
716
754
|
if (c === " " || c === "\n" || c === " " || c === "\r") {
|
|
717
|
-
while (j <
|
|
755
|
+
while (j < text2.length && " \n \r".indexOf(text2.charAt(j)) >= 0) j++;
|
|
718
756
|
kind = "space";
|
|
719
757
|
} else if (c === '"') {
|
|
720
|
-
while (j <
|
|
721
|
-
j = Math.min(j + 1,
|
|
758
|
+
while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
|
|
759
|
+
j = Math.min(j + 1, text2.length);
|
|
722
760
|
let k = j;
|
|
723
|
-
while (k <
|
|
724
|
-
kind =
|
|
761
|
+
while (k < text2.length && " \n \r".indexOf(text2.charAt(k)) >= 0) k++;
|
|
762
|
+
kind = text2.charAt(k) === ":" ? "key" : "string";
|
|
725
763
|
} else if ("{}[],:".indexOf(c) >= 0) {
|
|
726
764
|
kind = "punct";
|
|
727
765
|
if (c === "}" || c === "]") depth = Math.max(0, depth - 1);
|
|
728
766
|
} else {
|
|
729
|
-
while (j <
|
|
730
|
-
const word =
|
|
767
|
+
while (j < text2.length && ' \n \r{}[],:"'.indexOf(text2.charAt(j)) < 0) j++;
|
|
768
|
+
const word = text2.slice(i, j);
|
|
731
769
|
kind = word === "true" || word === "false" || word === "null" ? "literal" : "number";
|
|
732
770
|
}
|
|
733
|
-
out.push({ kind, text:
|
|
771
|
+
out.push({ kind, text: text2.slice(i, j), depth });
|
|
734
772
|
if (kind === "punct" && (c === "{" || c === "[")) depth++;
|
|
735
773
|
i = j;
|
|
736
774
|
}
|
|
737
775
|
return out;
|
|
738
776
|
}
|
|
739
|
-
function jsonIndent(
|
|
777
|
+
function jsonIndent(text2) {
|
|
740
778
|
let out = "";
|
|
741
779
|
let depth = 0;
|
|
742
780
|
let i = 0;
|
|
743
781
|
const ws = " \n \r";
|
|
744
|
-
while (i <
|
|
745
|
-
const c =
|
|
782
|
+
while (i < text2.length) {
|
|
783
|
+
const c = text2.charAt(i);
|
|
746
784
|
if (ws.indexOf(c) >= 0) {
|
|
747
785
|
i++;
|
|
748
786
|
} else if (c === '"') {
|
|
749
787
|
let j = i + 1;
|
|
750
|
-
while (j <
|
|
751
|
-
out +=
|
|
788
|
+
while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
|
|
789
|
+
out += text2.slice(i, j + 1);
|
|
752
790
|
i = j + 1;
|
|
753
791
|
} else if (c === "{" || c === "[") {
|
|
754
792
|
let j = i + 1;
|
|
755
|
-
while (j <
|
|
756
|
-
if (
|
|
757
|
-
out += c +
|
|
793
|
+
while (j < text2.length && ws.indexOf(text2.charAt(j)) >= 0) j++;
|
|
794
|
+
if (text2.charAt(j) === (c === "{" ? "}" : "]")) {
|
|
795
|
+
out += c + text2.charAt(j);
|
|
758
796
|
i = j + 1;
|
|
759
797
|
} else {
|
|
760
798
|
depth++;
|
|
@@ -773,8 +811,8 @@ function jsonIndent(text) {
|
|
|
773
811
|
i++;
|
|
774
812
|
} else {
|
|
775
813
|
let j = i + 1;
|
|
776
|
-
while (j <
|
|
777
|
-
out +=
|
|
814
|
+
while (j < text2.length && (ws + '{}[],:"').indexOf(text2.charAt(j)) < 0) j++;
|
|
815
|
+
out += text2.slice(i, j);
|
|
778
816
|
i = j;
|
|
779
817
|
}
|
|
780
818
|
}
|
|
@@ -1225,16 +1263,21 @@ function renderOverview(box, d) {
|
|
|
1225
1263
|
["Total time", timing],
|
|
1226
1264
|
["First byte", d.ttfbMs === undefined ? null : d.ttfbMs + " ms"],
|
|
1227
1265
|
["Size", d.status === null ? null : size(d.bytes)],
|
|
1228
|
-
["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null]
|
|
1229
|
-
["Access", accessText(d.access)]
|
|
1266
|
+
["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null]
|
|
1230
1267
|
]));
|
|
1231
1268
|
|
|
1269
|
+
var changes = (d.changes || []).map(function (c) { return [c.label, c.value + " · " + c.reason]; });
|
|
1270
|
+
if (changes.length > 0 || d.redirect || d.access) {
|
|
1271
|
+
ov.appendChild(el("div", "sect", "Changed by Duto"));
|
|
1272
|
+
ov.appendChild(rows(changes.concat([
|
|
1273
|
+
["Location rewritten", d.redirect ? d.redirect.from + " → " + d.redirect.to : null],
|
|
1274
|
+
["Access", accessText(d.access)]
|
|
1275
|
+
])));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1232
1278
|
if (d.location || d.redirect) {
|
|
1233
1279
|
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
|
-
]));
|
|
1280
|
+
ov.appendChild(rows([["Location", d.location || (d.redirect && d.redirect.to)]]));
|
|
1238
1281
|
}
|
|
1239
1282
|
|
|
1240
1283
|
ov.appendChild(el("div", "sect", "Client"));
|
|
@@ -1590,7 +1633,7 @@ function replayBlock(r) {
|
|
|
1590
1633
|
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
|
|
1591
1634
|
return null;
|
|
1592
1635
|
}
|
|
1593
|
-
var HOP_BY_HOP_AND_BODY_HEADERS =
|
|
1636
|
+
var HOP_BY_HOP_AND_BODY_HEADERS = new Set(FRAMING_HEADERS);
|
|
1594
1637
|
function replayHeaders(r) {
|
|
1595
1638
|
const out = {};
|
|
1596
1639
|
for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
@@ -1619,7 +1662,7 @@ function parseCookieHeader(header) {
|
|
|
1619
1662
|
}
|
|
1620
1663
|
|
|
1621
1664
|
// src/inspector/curl.ts
|
|
1622
|
-
var SKIP = /* @__PURE__ */ new Set(["host",
|
|
1665
|
+
var SKIP = /* @__PURE__ */ new Set(["host", ...FRAMING_HEADERS]);
|
|
1623
1666
|
var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
|
|
1624
1667
|
var skip = (name) => {
|
|
1625
1668
|
const k = name.toLowerCase();
|
|
@@ -1706,9 +1749,12 @@ function cookieIssues(r, ctx) {
|
|
|
1706
1749
|
}
|
|
1707
1750
|
return out;
|
|
1708
1751
|
}
|
|
1752
|
+
function isPublicOrigin(origin, publicHost) {
|
|
1753
|
+
return publicHost !== null && absoluteUrl(origin)?.host === publicHost.toLowerCase();
|
|
1754
|
+
}
|
|
1709
1755
|
function corsIssues(r, ctx) {
|
|
1710
1756
|
const origin = r.reqHeaders.origin;
|
|
1711
|
-
if (!origin || r.status === null ||
|
|
1757
|
+
if (!origin || r.status === null || r.websocket || isPublicOrigin(origin, ctx.publicHost)) return [];
|
|
1712
1758
|
const allowed = r.resHeaders["access-control-allow-origin"];
|
|
1713
1759
|
const ok2 = allowed === "*" || allowed === origin;
|
|
1714
1760
|
const wanted = r.reqHeaders["access-control-request-method"];
|
|
@@ -1783,6 +1829,11 @@ function flowChain(id, links) {
|
|
|
1783
1829
|
|
|
1784
1830
|
// src/inspector/har.ts
|
|
1785
1831
|
var HTTP = "HTTP/1.1";
|
|
1832
|
+
var HAR_MASK = "\u2022\u2022\u2022 (duto.yml)";
|
|
1833
|
+
function maskedNames(r) {
|
|
1834
|
+
return new Set((r.headerChanges ?? []).filter((c) => c.source === "headers" && c.kind !== "remove").map((c) => c.name));
|
|
1835
|
+
}
|
|
1836
|
+
var masked = (list, names) => list.map((h) => names.has(h.name.toLowerCase()) ? { ...h, value: HAR_MASK } : h);
|
|
1786
1837
|
var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
|
|
1787
1838
|
function harCookie(line) {
|
|
1788
1839
|
const c = parseSetCookie(line);
|
|
@@ -1800,6 +1851,8 @@ function entry(r, publicUrl) {
|
|
|
1800
1851
|
const time = r.durationMs ?? 0;
|
|
1801
1852
|
const wait = Math.min(r.ttfbMs ?? time, time);
|
|
1802
1853
|
const reqType = r.reqHeaders["content-type"] ?? "";
|
|
1854
|
+
const secret = maskedNames(r);
|
|
1855
|
+
const cookies = parseCookieHeader(r.reqHeaders.cookie ?? "");
|
|
1803
1856
|
return {
|
|
1804
1857
|
startedDateTime: new Date(r.startedAt).toISOString(),
|
|
1805
1858
|
time,
|
|
@@ -1807,9 +1860,9 @@ function entry(r, publicUrl) {
|
|
|
1807
1860
|
method: r.websocket ? "GET" : r.method,
|
|
1808
1861
|
url: url.toString(),
|
|
1809
1862
|
httpVersion: HTTP,
|
|
1810
|
-
headers: headerList(r.reqHeaders),
|
|
1863
|
+
headers: masked(headerList(r.reqHeaders), secret),
|
|
1811
1864
|
queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
|
|
1812
|
-
cookies:
|
|
1865
|
+
cookies: secret.has("cookie") ? cookies.map((c) => ({ ...c, value: HAR_MASK })) : cookies,
|
|
1813
1866
|
headersSize: -1,
|
|
1814
1867
|
bodySize: r.reqBodyBytes,
|
|
1815
1868
|
...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
|
|
@@ -1920,6 +1973,7 @@ function detail(r, session, view) {
|
|
|
1920
1973
|
...listItem(r, view),
|
|
1921
1974
|
edge: edgeOf(r.reqHeaders),
|
|
1922
1975
|
redirect: r.redirect ?? null,
|
|
1976
|
+
changes: (r.headerChanges ?? []).map(describeChange),
|
|
1923
1977
|
issues: diagnose(r, view.context(r.tunnel)),
|
|
1924
1978
|
client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
|
|
1925
1979
|
query: queryOf(r.path),
|
|
@@ -1979,7 +2033,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1979
2033
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1980
2034
|
res.setHeader("X-Frame-Options", "DENY");
|
|
1981
2035
|
};
|
|
1982
|
-
const
|
|
2036
|
+
const text2 = (res, status, body) => {
|
|
1983
2037
|
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1984
2038
|
res.end(body);
|
|
1985
2039
|
};
|
|
@@ -1990,19 +2044,19 @@ data: ${JSON.stringify({ ids })}
|
|
|
1990
2044
|
const handle = (req, res) => {
|
|
1991
2045
|
base(res);
|
|
1992
2046
|
const host = first(req.headers.host)?.toLowerCase();
|
|
1993
|
-
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return
|
|
2047
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text2(res, 403, "Host not allowed.");
|
|
1994
2048
|
let url2;
|
|
1995
2049
|
try {
|
|
1996
2050
|
url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1997
2051
|
} catch {
|
|
1998
|
-
return
|
|
2052
|
+
return text2(res, 404, "Not found.");
|
|
1999
2053
|
}
|
|
2000
2054
|
const path = url2.pathname;
|
|
2001
2055
|
const method = req.method ?? "GET";
|
|
2002
2056
|
const queryToken = url2.searchParams.get("t") ?? "";
|
|
2003
2057
|
const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
|
|
2004
2058
|
if (method === "GET" && path === "/") {
|
|
2005
|
-
if (!constantTimeEqual(queryToken, token)) return
|
|
2059
|
+
if (!constantTimeEqual(queryToken, token)) return text2(res, 403, NO_TOKEN);
|
|
2006
2060
|
const nonce = randomBytes(16).toString("base64url");
|
|
2007
2061
|
res.writeHead(200, {
|
|
2008
2062
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -2013,7 +2067,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
2013
2067
|
const isEvents = method === "GET" && path === "/api/events";
|
|
2014
2068
|
const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
|
|
2015
2069
|
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
2016
|
-
if (!known) return
|
|
2070
|
+
if (!known) return text2(res, 404, "Not found.");
|
|
2017
2071
|
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
2018
2072
|
if (isEvents) {
|
|
2019
2073
|
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
|
|
@@ -2048,7 +2102,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
2048
2102
|
try {
|
|
2049
2103
|
handle(req, res);
|
|
2050
2104
|
} catch {
|
|
2051
|
-
if (!res.headersSent)
|
|
2105
|
+
if (!res.headersSent) text2(res, 500, "Internal error.");
|
|
2052
2106
|
else res.end();
|
|
2053
2107
|
}
|
|
2054
2108
|
});
|
|
@@ -2265,6 +2319,62 @@ function cors401(origin) {
|
|
|
2265
2319
|
import { Agent, createServer as createServer2, request } from "http";
|
|
2266
2320
|
import { connect as connect2 } from "net";
|
|
2267
2321
|
|
|
2322
|
+
// src/session/delivery.ts
|
|
2323
|
+
function text(h, name) {
|
|
2324
|
+
if (!Object.hasOwn(h, name)) return void 0;
|
|
2325
|
+
const v = h[name];
|
|
2326
|
+
return Array.isArray(v) ? v.join(", ") : v;
|
|
2327
|
+
}
|
|
2328
|
+
function dropUntrustedForwarded(out, changes) {
|
|
2329
|
+
for (const name of UNTRUSTED_VISITOR_HEADERS) {
|
|
2330
|
+
const from = text(out, name);
|
|
2331
|
+
if (from === void 0) continue;
|
|
2332
|
+
delete out[name];
|
|
2333
|
+
changes.push({ kind: "remove", name, from, source: "visitor" });
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
function swapHost(out, host, changes) {
|
|
2337
|
+
changes.push({ kind: "replace", name: "host", from: out.host ?? "", to: host, source: "host" });
|
|
2338
|
+
out.host = host;
|
|
2339
|
+
const forwarded = text(out, "x-forwarded-host");
|
|
2340
|
+
if (forwarded === void 0) return;
|
|
2341
|
+
delete out["x-forwarded-host"];
|
|
2342
|
+
changes.push({ kind: "remove", name: "x-forwarded-host", from: forwarded, source: "host" });
|
|
2343
|
+
}
|
|
2344
|
+
function applyHeaders(out, headers, changes) {
|
|
2345
|
+
for (const [name, to] of Object.entries(headers)) {
|
|
2346
|
+
const from = text(out, name);
|
|
2347
|
+
if (to === "") {
|
|
2348
|
+
if (from === void 0) continue;
|
|
2349
|
+
delete out[name];
|
|
2350
|
+
changes.push({ kind: "remove", name, from, source: "headers" });
|
|
2351
|
+
} else {
|
|
2352
|
+
out[name] = to;
|
|
2353
|
+
changes.push(from === void 0 ? { kind: "add", name, to, source: "headers" } : { kind: "replace", name, from, to, source: "headers" });
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
function stripDutoCredentials(out, stripped, changes) {
|
|
2358
|
+
for (const name of ["x-duto-auth", "authorization"]) {
|
|
2359
|
+
const from = text(out, name);
|
|
2360
|
+
if (from !== void 0 && text(stripped, name) === void 0) changes.push({ kind: "remove", name, from, source: "duto" });
|
|
2361
|
+
}
|
|
2362
|
+
if (text(out, "cookie") !== text(stripped, "cookie")) changes.push({ kind: "remove", name: "cookie", from: TUNNEL_COOKIE, source: "duto" });
|
|
2363
|
+
}
|
|
2364
|
+
function deliver(h, route) {
|
|
2365
|
+
const changes = [];
|
|
2366
|
+
let out = { ...h };
|
|
2367
|
+
if (!route.trustForwarded) dropUntrustedForwarded(out, changes);
|
|
2368
|
+
if (route.host) swapHost(out, route.host, changes);
|
|
2369
|
+
if (route.headers) applyHeaders(out, route.headers, changes);
|
|
2370
|
+
if (route.auth) {
|
|
2371
|
+
const stripped = route.auth.strip(out);
|
|
2372
|
+
stripDutoCredentials(out, stripped, changes);
|
|
2373
|
+
out = stripped;
|
|
2374
|
+
}
|
|
2375
|
+
return changes.length ? { headers: out, changes } : { headers: h, changes };
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2268
2378
|
// src/session/errorPage.ts
|
|
2269
2379
|
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2270
2380
|
function wantsHtml(accept) {
|
|
@@ -2373,6 +2483,7 @@ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
|
|
|
2373
2483
|
var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
|
|
2374
2484
|
var MAX_BODY = 64 * 1024;
|
|
2375
2485
|
var LOCAL_AGENT = new Agent({ keepAlive: false });
|
|
2486
|
+
var errorText = (err) => err instanceof Error ? err.message || err.code || err.name : String(err);
|
|
2376
2487
|
function flat(h) {
|
|
2377
2488
|
const out = {};
|
|
2378
2489
|
for (const [k, v] of Object.entries(h)) if (v !== void 0) out[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
@@ -2431,19 +2542,6 @@ async function localHost(port) {
|
|
|
2431
2542
|
hostCache.set(port, { host, at: Date.now() });
|
|
2432
2543
|
return host;
|
|
2433
2544
|
}
|
|
2434
|
-
function rewrite(h, route) {
|
|
2435
|
-
if (!route.host && !route.headers) return h;
|
|
2436
|
-
const out = { ...h };
|
|
2437
|
-
if (route.host) {
|
|
2438
|
-
out.host = route.host;
|
|
2439
|
-
delete out["x-forwarded-host"];
|
|
2440
|
-
}
|
|
2441
|
-
for (const [k, v] of Object.entries(route.headers ?? {})) {
|
|
2442
|
-
if (v === "") delete out[k];
|
|
2443
|
-
else out[k] = v;
|
|
2444
|
-
}
|
|
2445
|
-
return out;
|
|
2446
|
-
}
|
|
2447
2545
|
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2448
2546
|
function rewriteLocation(location, localHost2, publicHost) {
|
|
2449
2547
|
const host = escapeRe(localHost2.replace(/:\d+$/, ""));
|
|
@@ -2532,10 +2630,6 @@ function deny(req, res, html) {
|
|
|
2532
2630
|
res.end(body);
|
|
2533
2631
|
return { status, out, body };
|
|
2534
2632
|
}
|
|
2535
|
-
function delivered(h, route) {
|
|
2536
|
-
const out = rewrite(h, route);
|
|
2537
|
-
return route.auth ? route.auth.strip(out) : out;
|
|
2538
|
-
}
|
|
2539
2633
|
async function isUp(port) {
|
|
2540
2634
|
return await probe("127.0.0.1", port) || await probe("::1", port);
|
|
2541
2635
|
}
|
|
@@ -2577,7 +2671,7 @@ async function startProxy(o) {
|
|
|
2577
2671
|
await new Promise((r) => setTimeout(r, 50));
|
|
2578
2672
|
}
|
|
2579
2673
|
}
|
|
2580
|
-
function record(req, route, headers, websocket, access, noReplay) {
|
|
2674
|
+
function record(req, route, headers, changes, websocket, access, noReplay) {
|
|
2581
2675
|
const id = nextId++;
|
|
2582
2676
|
const started = Date.now();
|
|
2583
2677
|
o.store.add({
|
|
@@ -2601,7 +2695,8 @@ async function startProxy(o) {
|
|
|
2601
2695
|
truncated: false,
|
|
2602
2696
|
resTruncated: false,
|
|
2603
2697
|
...access && { access },
|
|
2604
|
-
...noReplay && { noReplay }
|
|
2698
|
+
...noReplay && { noReplay },
|
|
2699
|
+
...changes.length && { headerChanges: changes }
|
|
2605
2700
|
});
|
|
2606
2701
|
return { id, started };
|
|
2607
2702
|
}
|
|
@@ -2659,11 +2754,18 @@ async function startProxy(o) {
|
|
|
2659
2754
|
reqComplete: true,
|
|
2660
2755
|
truncated: false,
|
|
2661
2756
|
resTruncated: false,
|
|
2662
|
-
replayOf: r.id
|
|
2757
|
+
replayOf: r.id,
|
|
2758
|
+
...r.headerChanges && { headerChanges: r.headerChanges }
|
|
2663
2759
|
});
|
|
2664
2760
|
void (async () => {
|
|
2665
2761
|
const host = await localHost(route.port);
|
|
2666
|
-
|
|
2762
|
+
let upstream;
|
|
2763
|
+
try {
|
|
2764
|
+
upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
|
|
2765
|
+
} catch (err) {
|
|
2766
|
+
o.store.update(newId, { status: 502, durationMs: Date.now() - started, error: errorText(err) });
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2667
2769
|
upstream.on("error", (err) => o.store.update(newId, { status: 503, durationMs: Date.now() - started, error: err.message || err.code || err.name }));
|
|
2668
2770
|
upstream.end(r.reqRaw ?? void 0);
|
|
2669
2771
|
})();
|
|
@@ -2689,42 +2791,55 @@ async function startProxy(o) {
|
|
|
2689
2791
|
}
|
|
2690
2792
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
2691
2793
|
const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
|
|
2692
|
-
const headers =
|
|
2794
|
+
const { headers, changes } = deliver(req.headers, route);
|
|
2693
2795
|
if (access?.kind === "denied") {
|
|
2694
2796
|
const html = wantsHtml(req.headers.accept);
|
|
2695
|
-
const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access, DENIED_NO_REPLAY);
|
|
2797
|
+
const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
|
|
2696
2798
|
const { status, out, body } = deny(req, res, html);
|
|
2697
2799
|
o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
|
|
2698
2800
|
return;
|
|
2699
2801
|
}
|
|
2700
|
-
const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
|
|
2802
|
+
const { id, started } = record(req, route, headers, changes, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
|
|
2701
2803
|
const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
|
|
2804
|
+
const recordArrival = () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() });
|
|
2702
2805
|
req.pause();
|
|
2703
2806
|
const host = await localHost(route.port);
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2807
|
+
let upstream;
|
|
2808
|
+
try {
|
|
2809
|
+
upstream = request(
|
|
2810
|
+
{ host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
|
|
2811
|
+
(up) => {
|
|
2812
|
+
if (preflight) {
|
|
2813
|
+
const out = preflightHeaders(up.headers);
|
|
2814
|
+
res.writeHead(up.statusCode ?? 502, out);
|
|
2815
|
+
res.end();
|
|
2816
|
+
up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
|
|
2817
|
+
up.resume();
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
|
|
2821
|
+
const from = up.headers.location;
|
|
2822
|
+
const to = rewriteLocation(from, route.host, hostOf(req));
|
|
2823
|
+
if (to !== from) {
|
|
2824
|
+
up.headers.location = to;
|
|
2825
|
+
o.store.update(id, { redirect: { from, to } });
|
|
2826
|
+
}
|
|
2721
2827
|
}
|
|
2828
|
+
res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
|
|
2829
|
+
trackResponse(id, started, up, reqCap);
|
|
2830
|
+
up.pipe(res);
|
|
2722
2831
|
}
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2832
|
+
);
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
req.on("data", (c) => reqCap.push(c));
|
|
2835
|
+
req.on("end", recordArrival);
|
|
2836
|
+
req.resume();
|
|
2837
|
+
const msg = `Duto could not forward this request: ${errorText(err)}`;
|
|
2838
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
2839
|
+
res.end(req.method === "HEAD" ? void 0 : msg);
|
|
2840
|
+
o.store.update(id, { status: 502, durationMs: Date.now() - started, bytes: Buffer.byteLength(msg), error: errorText(err) });
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2728
2843
|
res.on("close", () => {
|
|
2729
2844
|
if (!res.writableFinished) upstream.destroy();
|
|
2730
2845
|
});
|
|
@@ -2739,7 +2854,7 @@ async function startProxy(o) {
|
|
|
2739
2854
|
} else res.destroy();
|
|
2740
2855
|
o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
|
|
2741
2856
|
});
|
|
2742
|
-
req.on("end",
|
|
2857
|
+
req.on("end", recordArrival);
|
|
2743
2858
|
if (preflight) {
|
|
2744
2859
|
req.resume();
|
|
2745
2860
|
upstream.end();
|
|
@@ -2753,8 +2868,8 @@ async function startProxy(o) {
|
|
|
2753
2868
|
const route = await routeFor(req);
|
|
2754
2869
|
if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
2755
2870
|
const access = route.auth?.access(req.headers);
|
|
2756
|
-
const headers =
|
|
2757
|
-
const { id, started } = record(req, route, headers, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
|
|
2871
|
+
const { headers, changes } = deliver(req.headers, route);
|
|
2872
|
+
const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
|
|
2758
2873
|
if (access?.kind === "denied") {
|
|
2759
2874
|
client.end(WS_DENIED);
|
|
2760
2875
|
o.store.update(id, { status: 401, durationMs: Date.now() - started });
|
|
@@ -3012,7 +3127,7 @@ var Session = class {
|
|
|
3012
3127
|
}
|
|
3013
3128
|
blank(spec, adHoc) {
|
|
3014
3129
|
this.specs.set(keyOf(spec), spec);
|
|
3015
|
-
return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
|
|
3130
|
+
return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
|
|
3016
3131
|
}
|
|
3017
3132
|
async open(t, existing, retrying = false) {
|
|
3018
3133
|
if (this.stopped) return;
|
|
@@ -3041,7 +3156,7 @@ var Session = class {
|
|
|
3041
3156
|
t.hostname = res.hostname;
|
|
3042
3157
|
t.endpointId = res.endpointId;
|
|
3043
3158
|
t.error = null;
|
|
3044
|
-
this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, auth });
|
|
3159
|
+
this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
|
|
3045
3160
|
t.status = await isPortListening(t.port) ? "live" : "no-listener";
|
|
3046
3161
|
this.ensureCloudflared(res.tunnelToken);
|
|
3047
3162
|
this.emit();
|
|
@@ -3261,15 +3376,17 @@ async function runSession(specs, source, flags) {
|
|
|
3261
3376
|
inspectorError = err instanceof Error ? err.message : String(err);
|
|
3262
3377
|
}
|
|
3263
3378
|
}
|
|
3379
|
+
const warnings = credentialWarnings(specs);
|
|
3264
3380
|
if (wantsUi(flags)) {
|
|
3265
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3266
|
-
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
|
|
3381
|
+
const { runFullscreen } = await import("./fullscreen-MSVJF43W.js");
|
|
3382
|
+
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
3267
3383
|
await inspector?.close().catch(() => void 0);
|
|
3268
3384
|
process.exit(code);
|
|
3269
3385
|
}
|
|
3270
3386
|
console.log(`${mark()} ${paint("muted", `${CLI_VERSION} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
|
|
3271
3387
|
if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
|
|
3272
3388
|
if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
|
|
3389
|
+
for (const w of warnings) console.log(warn(w));
|
|
3273
3390
|
void update.then((latest) => latest && console.log(warn(updateLine(latest))));
|
|
3274
3391
|
const stopLog = runPlain(session, (l) => console.log(l));
|
|
3275
3392
|
const stopped = new Promise((resolve) => {
|