@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
|
-
**
|
|
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-
|
|
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));
|