@dutodev/cli 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -162,6 +194,12 @@ Outside an interactive terminal (pipe, CI) or with `--no-ui`, the CLI prints a p
162
194
 
163
195
  With every session, the CLI starts a local inspector: a browser page with requests arriving live, formatted JSON, and buttons to copy and replay. It is more comfortable than the terminal for large bodies.
164
196
 
197
+ - **Overview.** Each request shows who answered (your app or duto), total time and time to first byte, the data center, the client, the query, the cookies sent and set, and the redirect target.
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.
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.
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.
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.
165
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.
166
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.
167
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));