@dutodev/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- **About `X-Forwarded-*`.** `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` 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 these headers 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. On the other hand, if an app reads `X-Forwarded-Host` without that check, any client can forge it in production. Make sure the proxy in front overwrites or removes that header.
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
- - **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.
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-4SIBC52A.js";
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 App({ session, version, system, onExit, inspector = null, inspectorError, update = null }) {
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(inspectorError ? { text: `Inspector failed to start: ${inspectorError}`, tone: "warn" } : null);
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-4SIBC52A.js";
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, text, stream) {
40
- return colorOn(stream) ? `\x1B[${SGR[tone]}m${text}\x1B[0m` : text;
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.5.0";
60
+ var CLI_VERSION = "1.6.0";
54
61
 
55
62
  // src/api.ts
56
63
  var DutoApiError = class extends Error {
@@ -368,7 +375,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
368
375
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
369
376
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
370
377
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
371
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
378
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
372
379
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
373
380
  function parseHost(raw) {
374
381
  const host = raw.trim().toLowerCase();
@@ -383,14 +390,29 @@ function parseCredential(raw) {
383
390
  if (password.length < 8 || password.length > 128) return { ok: false, reason: "password" };
384
391
  return { ok: true, cred: { user, password } };
385
392
  }
393
+ function expandEnv(text2, env, missing) {
394
+ return text2.replace(ENV_REF_RE, (_m, name) => {
395
+ const val = env[name];
396
+ if (val === void 0) throw missing(name);
397
+ return val;
398
+ });
399
+ }
400
+ function headerText(node) {
401
+ if (node === null) return "";
402
+ if (!isScalar(node)) return void 0;
403
+ if (node.value === null) return "";
404
+ if (typeof node.value === "string") return node.value;
405
+ if (typeof node.value === "number" || typeof node.value === "boolean") return node.source ?? String(node.value);
406
+ return void 0;
407
+ }
386
408
  function portOf(value) {
387
409
  const v = isMap(value) ? value.get("port") : isScalar(value) ? value.value : value;
388
410
  return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535 ? v : null;
389
411
  }
390
- function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
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(text, { lineCounter: lc, prettyErrors: false });
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 value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
440
- if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
441
- throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
442
- }
443
- const str = value === null ? "" : String(value);
444
- if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
445
- headers[hName.toLowerCase()] = str;
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 text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
463
- if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
464
- const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
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.5.0",
564
+ cliVersion: "1.6.0",
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, text) {
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 = text.trim().split(/\s+/).filter(Boolean);
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(text) {
725
+ function jsonSyntaxError(text2) {
688
726
  try {
689
- JSON.parse(text);
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 = text.indexOf("\n", idx);
700
- if (nl < 0) return { position: text.length, message: message2 };
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, text.length), message: message2 };
741
+ return { position: Math.min(idx + Number(lc[2]) - 1, text2.length), message: message2 };
704
742
  }
705
- return { position: text.length, message: message2 };
743
+ return { position: text2.length, message: message2 };
706
744
  }
707
745
  }
708
- function jsonTokens(text) {
746
+ function jsonTokens(text2) {
709
747
  const out = [];
710
748
  let depth = 0;
711
749
  let i = 0;
712
- while (i < text.length) {
713
- const c = text.charAt(i);
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 < text.length && " \n \r".indexOf(text.charAt(j)) >= 0) 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 < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
721
- j = Math.min(j + 1, text.length);
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 < text.length && " \n \r".indexOf(text.charAt(k)) >= 0) k++;
724
- kind = text.charAt(k) === ":" ? "key" : "string";
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 < text.length && ' \n \r{}[],:"'.indexOf(text.charAt(j)) < 0) j++;
730
- const word = text.slice(i, j);
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: text.slice(i, j), depth });
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(text) {
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 < text.length) {
745
- const c = text.charAt(i);
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 < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
751
- out += text.slice(i, j + 1);
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 < text.length && ws.indexOf(text.charAt(j)) >= 0) j++;
756
- if (text.charAt(j) === (c === "{" ? "}" : "]")) {
757
- out += c + text.charAt(j);
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 < text.length && (ws + '{}[],:"').indexOf(text.charAt(j)) < 0) j++;
777
- out += text.slice(i, j);
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 = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
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", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
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();
@@ -1783,6 +1826,11 @@ function flowChain(id, links) {
1783
1826
 
1784
1827
  // src/inspector/har.ts
1785
1828
  var HTTP = "HTTP/1.1";
1829
+ var HAR_MASK = "\u2022\u2022\u2022 (duto.yml)";
1830
+ function maskedNames(r) {
1831
+ return new Set((r.headerChanges ?? []).filter((c) => c.source === "headers" && c.kind !== "remove").map((c) => c.name));
1832
+ }
1833
+ var masked = (list, names) => list.map((h) => names.has(h.name.toLowerCase()) ? { ...h, value: HAR_MASK } : h);
1786
1834
  var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
1787
1835
  function harCookie(line) {
1788
1836
  const c = parseSetCookie(line);
@@ -1800,6 +1848,8 @@ function entry(r, publicUrl) {
1800
1848
  const time = r.durationMs ?? 0;
1801
1849
  const wait = Math.min(r.ttfbMs ?? time, time);
1802
1850
  const reqType = r.reqHeaders["content-type"] ?? "";
1851
+ const secret = maskedNames(r);
1852
+ const cookies = parseCookieHeader(r.reqHeaders.cookie ?? "");
1803
1853
  return {
1804
1854
  startedDateTime: new Date(r.startedAt).toISOString(),
1805
1855
  time,
@@ -1807,9 +1857,9 @@ function entry(r, publicUrl) {
1807
1857
  method: r.websocket ? "GET" : r.method,
1808
1858
  url: url.toString(),
1809
1859
  httpVersion: HTTP,
1810
- headers: headerList(r.reqHeaders),
1860
+ headers: masked(headerList(r.reqHeaders), secret),
1811
1861
  queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
1812
- cookies: parseCookieHeader(r.reqHeaders.cookie ?? ""),
1862
+ cookies: secret.has("cookie") ? cookies.map((c) => ({ ...c, value: HAR_MASK })) : cookies,
1813
1863
  headersSize: -1,
1814
1864
  bodySize: r.reqBodyBytes,
1815
1865
  ...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
@@ -1920,6 +1970,7 @@ function detail(r, session, view) {
1920
1970
  ...listItem(r, view),
1921
1971
  edge: edgeOf(r.reqHeaders),
1922
1972
  redirect: r.redirect ?? null,
1973
+ changes: (r.headerChanges ?? []).map(describeChange),
1923
1974
  issues: diagnose(r, view.context(r.tunnel)),
1924
1975
  client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
1925
1976
  query: queryOf(r.path),
@@ -1979,7 +2030,7 @@ data: ${JSON.stringify({ ids })}
1979
2030
  res.setHeader("Referrer-Policy", "no-referrer");
1980
2031
  res.setHeader("X-Frame-Options", "DENY");
1981
2032
  };
1982
- const text = (res, status, body) => {
2033
+ const text2 = (res, status, body) => {
1983
2034
  res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
1984
2035
  res.end(body);
1985
2036
  };
@@ -1990,19 +2041,19 @@ data: ${JSON.stringify({ ids })}
1990
2041
  const handle = (req, res) => {
1991
2042
  base(res);
1992
2043
  const host = first(req.headers.host)?.toLowerCase();
1993
- if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
2044
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text2(res, 403, "Host not allowed.");
1994
2045
  let url2;
1995
2046
  try {
1996
2047
  url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1997
2048
  } catch {
1998
- return text(res, 404, "Not found.");
2049
+ return text2(res, 404, "Not found.");
1999
2050
  }
2000
2051
  const path = url2.pathname;
2001
2052
  const method = req.method ?? "GET";
2002
2053
  const queryToken = url2.searchParams.get("t") ?? "";
2003
2054
  const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
2004
2055
  if (method === "GET" && path === "/") {
2005
- if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
2056
+ if (!constantTimeEqual(queryToken, token)) return text2(res, 403, NO_TOKEN);
2006
2057
  const nonce = randomBytes(16).toString("base64url");
2007
2058
  res.writeHead(200, {
2008
2059
  "Content-Type": "text/html; charset=utf-8",
@@ -2013,7 +2064,7 @@ data: ${JSON.stringify({ ids })}
2013
2064
  const isEvents = method === "GET" && path === "/api/events";
2014
2065
  const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
2015
2066
  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 text(res, 404, "Not found.");
2067
+ if (!known) return text2(res, 404, "Not found.");
2017
2068
  if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
2018
2069
  if (isEvents) {
2019
2070
  res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
@@ -2048,7 +2099,7 @@ data: ${JSON.stringify({ ids })}
2048
2099
  try {
2049
2100
  handle(req, res);
2050
2101
  } catch {
2051
- if (!res.headersSent) text(res, 500, "Internal error.");
2102
+ if (!res.headersSent) text2(res, 500, "Internal error.");
2052
2103
  else res.end();
2053
2104
  }
2054
2105
  });
@@ -2265,6 +2316,62 @@ function cors401(origin) {
2265
2316
  import { Agent, createServer as createServer2, request } from "http";
2266
2317
  import { connect as connect2 } from "net";
2267
2318
 
2319
+ // src/session/delivery.ts
2320
+ function text(h, name) {
2321
+ if (!Object.hasOwn(h, name)) return void 0;
2322
+ const v = h[name];
2323
+ return Array.isArray(v) ? v.join(", ") : v;
2324
+ }
2325
+ function dropUntrustedForwarded(out, changes) {
2326
+ for (const name of UNTRUSTED_VISITOR_HEADERS) {
2327
+ const from = text(out, name);
2328
+ if (from === void 0) continue;
2329
+ delete out[name];
2330
+ changes.push({ kind: "remove", name, from, source: "visitor" });
2331
+ }
2332
+ }
2333
+ function swapHost(out, host, changes) {
2334
+ changes.push({ kind: "replace", name: "host", from: out.host ?? "", to: host, source: "host" });
2335
+ out.host = host;
2336
+ const forwarded = text(out, "x-forwarded-host");
2337
+ if (forwarded === void 0) return;
2338
+ delete out["x-forwarded-host"];
2339
+ changes.push({ kind: "remove", name: "x-forwarded-host", from: forwarded, source: "host" });
2340
+ }
2341
+ function applyHeaders(out, headers, changes) {
2342
+ for (const [name, to] of Object.entries(headers)) {
2343
+ const from = text(out, name);
2344
+ if (to === "") {
2345
+ if (from === void 0) continue;
2346
+ delete out[name];
2347
+ changes.push({ kind: "remove", name, from, source: "headers" });
2348
+ } else {
2349
+ out[name] = to;
2350
+ changes.push(from === void 0 ? { kind: "add", name, to, source: "headers" } : { kind: "replace", name, from, to, source: "headers" });
2351
+ }
2352
+ }
2353
+ }
2354
+ function stripDutoCredentials(out, stripped, changes) {
2355
+ for (const name of ["x-duto-auth", "authorization"]) {
2356
+ const from = text(out, name);
2357
+ if (from !== void 0 && text(stripped, name) === void 0) changes.push({ kind: "remove", name, from, source: "duto" });
2358
+ }
2359
+ if (text(out, "cookie") !== text(stripped, "cookie")) changes.push({ kind: "remove", name: "cookie", from: TUNNEL_COOKIE, source: "duto" });
2360
+ }
2361
+ function deliver(h, route) {
2362
+ const changes = [];
2363
+ let out = { ...h };
2364
+ if (!route.trustForwarded) dropUntrustedForwarded(out, changes);
2365
+ if (route.host) swapHost(out, route.host, changes);
2366
+ if (route.headers) applyHeaders(out, route.headers, changes);
2367
+ if (route.auth) {
2368
+ const stripped = route.auth.strip(out);
2369
+ stripDutoCredentials(out, stripped, changes);
2370
+ out = stripped;
2371
+ }
2372
+ return changes.length ? { headers: out, changes } : { headers: h, changes };
2373
+ }
2374
+
2268
2375
  // src/session/errorPage.ts
2269
2376
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2270
2377
  function wantsHtml(accept) {
@@ -2373,6 +2480,7 @@ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
2373
2480
  var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
2374
2481
  var MAX_BODY = 64 * 1024;
2375
2482
  var LOCAL_AGENT = new Agent({ keepAlive: false });
2483
+ var errorText = (err) => err instanceof Error ? err.message || err.code || err.name : String(err);
2376
2484
  function flat(h) {
2377
2485
  const out = {};
2378
2486
  for (const [k, v] of Object.entries(h)) if (v !== void 0) out[k] = Array.isArray(v) ? v.join(", ") : v;
@@ -2431,19 +2539,6 @@ async function localHost(port) {
2431
2539
  hostCache.set(port, { host, at: Date.now() });
2432
2540
  return host;
2433
2541
  }
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
2542
  var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2448
2543
  function rewriteLocation(location, localHost2, publicHost) {
2449
2544
  const host = escapeRe(localHost2.replace(/:\d+$/, ""));
@@ -2532,10 +2627,6 @@ function deny(req, res, html) {
2532
2627
  res.end(body);
2533
2628
  return { status, out, body };
2534
2629
  }
2535
- function delivered(h, route) {
2536
- const out = rewrite(h, route);
2537
- return route.auth ? route.auth.strip(out) : out;
2538
- }
2539
2630
  async function isUp(port) {
2540
2631
  return await probe("127.0.0.1", port) || await probe("::1", port);
2541
2632
  }
@@ -2577,7 +2668,7 @@ async function startProxy(o) {
2577
2668
  await new Promise((r) => setTimeout(r, 50));
2578
2669
  }
2579
2670
  }
2580
- function record(req, route, headers, websocket, access, noReplay) {
2671
+ function record(req, route, headers, changes, websocket, access, noReplay) {
2581
2672
  const id = nextId++;
2582
2673
  const started = Date.now();
2583
2674
  o.store.add({
@@ -2601,7 +2692,8 @@ async function startProxy(o) {
2601
2692
  truncated: false,
2602
2693
  resTruncated: false,
2603
2694
  ...access && { access },
2604
- ...noReplay && { noReplay }
2695
+ ...noReplay && { noReplay },
2696
+ ...changes.length && { headerChanges: changes }
2605
2697
  });
2606
2698
  return { id, started };
2607
2699
  }
@@ -2659,11 +2751,18 @@ async function startProxy(o) {
2659
2751
  reqComplete: true,
2660
2752
  truncated: false,
2661
2753
  resTruncated: false,
2662
- replayOf: r.id
2754
+ replayOf: r.id,
2755
+ ...r.headerChanges && { headerChanges: r.headerChanges }
2663
2756
  });
2664
2757
  void (async () => {
2665
2758
  const host = await localHost(route.port);
2666
- const upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
2759
+ let upstream;
2760
+ try {
2761
+ upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
2762
+ } catch (err) {
2763
+ o.store.update(newId, { status: 502, durationMs: Date.now() - started, error: errorText(err) });
2764
+ return;
2765
+ }
2667
2766
  upstream.on("error", (err) => o.store.update(newId, { status: 503, durationMs: Date.now() - started, error: err.message || err.code || err.name }));
2668
2767
  upstream.end(r.reqRaw ?? void 0);
2669
2768
  })();
@@ -2689,42 +2788,55 @@ async function startProxy(o) {
2689
2788
  }
2690
2789
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
2691
2790
  const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
2692
- const headers = delivered(req.headers, route);
2791
+ const { headers, changes } = deliver(req.headers, route);
2693
2792
  if (access?.kind === "denied") {
2694
2793
  const html = wantsHtml(req.headers.accept);
2695
- const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access, DENIED_NO_REPLAY);
2794
+ const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
2696
2795
  const { status, out, body } = deny(req, res, html);
2697
2796
  o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
2698
2797
  return;
2699
2798
  }
2700
- const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
2799
+ const { id, started } = record(req, route, headers, changes, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
2701
2800
  const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
2801
+ const recordArrival = () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() });
2702
2802
  req.pause();
2703
2803
  const host = await localHost(route.port);
2704
- const upstream = request(
2705
- { host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
2706
- (up) => {
2707
- if (preflight) {
2708
- const out = preflightHeaders(up.headers);
2709
- res.writeHead(up.statusCode ?? 502, out);
2710
- res.end();
2711
- up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
2712
- up.resume();
2713
- return;
2714
- }
2715
- if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
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 } });
2804
+ let upstream;
2805
+ try {
2806
+ upstream = request(
2807
+ { host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
2808
+ (up) => {
2809
+ if (preflight) {
2810
+ const out = preflightHeaders(up.headers);
2811
+ res.writeHead(up.statusCode ?? 502, out);
2812
+ res.end();
2813
+ up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
2814
+ up.resume();
2815
+ return;
2816
+ }
2817
+ if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
2818
+ const from = up.headers.location;
2819
+ const to = rewriteLocation(from, route.host, hostOf(req));
2820
+ if (to !== from) {
2821
+ up.headers.location = to;
2822
+ o.store.update(id, { redirect: { from, to } });
2823
+ }
2721
2824
  }
2825
+ res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2826
+ trackResponse(id, started, up, reqCap);
2827
+ up.pipe(res);
2722
2828
  }
2723
- res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2724
- trackResponse(id, started, up, reqCap);
2725
- up.pipe(res);
2726
- }
2727
- );
2829
+ );
2830
+ } catch (err) {
2831
+ req.on("data", (c) => reqCap.push(c));
2832
+ req.on("end", recordArrival);
2833
+ req.resume();
2834
+ const msg = `Duto could not forward this request: ${errorText(err)}`;
2835
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
2836
+ res.end(req.method === "HEAD" ? void 0 : msg);
2837
+ o.store.update(id, { status: 502, durationMs: Date.now() - started, bytes: Buffer.byteLength(msg), error: errorText(err) });
2838
+ return;
2839
+ }
2728
2840
  res.on("close", () => {
2729
2841
  if (!res.writableFinished) upstream.destroy();
2730
2842
  });
@@ -2739,7 +2851,7 @@ async function startProxy(o) {
2739
2851
  } else res.destroy();
2740
2852
  o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
2741
2853
  });
2742
- req.on("end", () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() }));
2854
+ req.on("end", recordArrival);
2743
2855
  if (preflight) {
2744
2856
  req.resume();
2745
2857
  upstream.end();
@@ -2753,8 +2865,8 @@ async function startProxy(o) {
2753
2865
  const route = await routeFor(req);
2754
2866
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
2755
2867
  const access = route.auth?.access(req.headers);
2756
- const headers = delivered(req.headers, route);
2757
- const { id, started } = record(req, route, headers, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
2868
+ const { headers, changes } = deliver(req.headers, route);
2869
+ const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
2758
2870
  if (access?.kind === "denied") {
2759
2871
  client.end(WS_DENIED);
2760
2872
  o.store.update(id, { status: 401, durationMs: Date.now() - started });
@@ -3012,7 +3124,7 @@ var Session = class {
3012
3124
  }
3013
3125
  blank(spec, adHoc) {
3014
3126
  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 };
3127
+ return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3016
3128
  }
3017
3129
  async open(t, existing, retrying = false) {
3018
3130
  if (this.stopped) return;
@@ -3041,7 +3153,7 @@ var Session = class {
3041
3153
  t.hostname = res.hostname;
3042
3154
  t.endpointId = res.endpointId;
3043
3155
  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 });
3156
+ this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
3045
3157
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
3046
3158
  this.ensureCloudflared(res.tunnelToken);
3047
3159
  this.emit();
@@ -3261,15 +3373,17 @@ async function runSession(specs, source, flags) {
3261
3373
  inspectorError = err instanceof Error ? err.message : String(err);
3262
3374
  }
3263
3375
  }
3376
+ const warnings = credentialWarnings(specs);
3264
3377
  if (wantsUi(flags)) {
3265
- const { runFullscreen } = await import("./fullscreen-MZMQ6ADN.js");
3266
- const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
3378
+ const { runFullscreen } = await import("./fullscreen-MSVJF43W.js");
3379
+ const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
3267
3380
  await inspector?.close().catch(() => void 0);
3268
3381
  process.exit(code);
3269
3382
  }
3270
3383
  console.log(`${mark()} ${paint("muted", `${CLI_VERSION} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
3271
3384
  if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
3272
3385
  if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
3386
+ for (const w of warnings) console.log(warn(w));
3273
3387
  void update.then((latest) => latest && console.log(warn(updateLine(latest))));
3274
3388
  const stopLog = runPlain(session, (l) => console.log(l));
3275
3389
  const stopped = new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",