@dutodev/cli 1.8.0 → 1.9.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
@@ -3,7 +3,7 @@
3
3
  Put a local service on the internet at a stable HTTPS URL, with one command:
4
4
 
5
5
  ```
6
- duto 3000 --name web # https://web-<user>.duto.dev → localhost:3000
6
+ duto 3000 --name web # https://web-<user>.duto.page → localhost:3000
7
7
  ```
8
8
 
9
9
  The URL stays the same from one session to the next. That makes it good for webhooks, login callbacks, testing on your phone, or showing someone your work without deploying.
@@ -20,8 +20,13 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
20
20
 
21
21
  ## Get started
22
22
 
23
- 1. At [duto.dev](https://duto.dev), open **Authtoken** and generate your token.
24
- 2. Connect the CLI to your account (once per machine):
23
+ 1. Connect the CLI to your account (once per machine). It opens your browser so you can approve it:
24
+
25
+ ```
26
+ duto login
27
+ ```
28
+
29
+ 2. On CI and servers with no browser, create a token on the **Machines** page at [duto.dev](https://duto.dev) and pass it:
25
30
 
26
31
  ```
27
32
  duto login <token>
@@ -30,8 +35,8 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
30
35
  3. Open a tunnel:
31
36
 
32
37
  ```
33
- duto 3000 # https://<user>.duto.dev
34
- duto 3000 --name web # https://web-<user>.duto.dev
38
+ duto 3000 # https://<user>.duto.page
39
+ duto 3000 --name web # https://web-<user>.duto.page
35
40
  ```
36
41
 
37
42
  Every name you use with `--name` keeps the same URL, and only you can use it. Names take lowercase letters and numbers only, no hyphens. The account limit counts named tunnels online at once: closed ones do not count, and names unused for 30 days are released.
@@ -47,13 +52,13 @@ tunnels:
47
52
  webhook: 4000
48
53
  ```
49
54
 
50
- All tunnels start together, in one screen. The CLI looks for the file in the current folder and its parents, up to the git root. If you commit it, everyone on the team gets their own addresses (`web-<user>.duto.dev`) from the same file.
55
+ All tunnels start together, in one screen. The CLI looks for the file in the current folder and its parents, up to the git root. If you commit it, everyone on the team gets their own addresses (`web-<user>.duto.page`) from the same file.
51
56
 
52
57
  **Edit while it runs.** Save `duto.yml` and the change applies right away, without restarting: new tunnels open, removed ones close, and a new `port`, `host`, `headers`, `auth` or `allow` counts from the next request, on the same URL. Visitors signed in with a password stay signed in while their user is still in `auth`. A save with a mistake changes nothing: the tunnels keep the last valid config and the screen shows the error with its line until you save a fixed file. Tunnels opened with `n` on the screen are left alone. Saving `.env` or `.env.local` applies the file again with the new values.
53
58
 
54
59
  ### Host and headers
55
60
 
56
- By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.dev`, `X-Forwarded-Proto: https` and the Cloudflare headers (`CF-Connecting-IP` and others). That is enough for most apps. When your app decides things by domain, you have two tools per tunnel:
61
+ By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.page`, `X-Forwarded-Proto: https` and the Cloudflare headers (`CF-Connecting-IP` and others). That is enough for most apps. When your app decides things by domain, you have two tools per tunnel:
57
62
 
58
63
  ```yaml
59
64
  tunnels:
@@ -79,7 +84,7 @@ tunnels:
79
84
  **What `host:` does:**
80
85
 
81
86
  - Your service receives the Host you set, and `X-Forwarded-Host` is not sent. To send it anyway, put it in `headers:`.
82
- - Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.dev/login`. This also applies inside encoded parameters of the `Location`. To turn it off, set `rewrite_redirects: false` on the tunnel.
87
+ - Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.page/login`. This also applies inside encoded parameters of the `Location`. To turn it off, set `rewrite_redirects: false` on the tunnel.
83
88
  - Links in response bodies (HTML, JSON) are not rewritten. URLs your app builds from the Host only open on your machine.
84
89
  - On the command line: `duto 8081 --host admin.myapp.test`, and `--no-rewrite-redirects` to turn the swap off.
85
90
 
@@ -91,7 +96,7 @@ Ways out, from simplest to most work:
91
96
  2. If the app needs the local domain for other decisions and reads `X-Forwarded-Host`, pass the domain through `headers:` and keep the public Host.
92
97
  3. Set the public callback URL in the app, if it lets you.
93
98
 
94
- 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.
99
+ In every case, register `https://<name>-<user>.duto.page/<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.
95
100
 
96
101
  **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:
97
102
 
@@ -153,12 +158,12 @@ People get in one of these ways:
153
158
  |---|---|
154
159
  | Person in a browser | Opens the URL, sees the "This tunnel is password protected" page and signs in with user and password |
155
160
  | Phone | Scans the QR code (key `m`, see below) and gets in without typing |
156
- | `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.dev/` (`Authorization: Basic`) |
161
+ | `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.page/` (`Authorization: Basic`) |
157
162
  | An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
158
163
 
159
164
  Without credentials, a browser is sent to the sign-in page and other clients get `401`. After 10 wrong passwords from one address in 5 minutes, that address is locked out for the rest of the window (the sign-in page answers `429` with `Retry-After`, other clients keep getting `401`). After 100 wrong passwords from all addresses, every password attempt is locked out the same way. A browser that already signed in keeps working. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
160
165
 
161
- **Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.dev` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
166
+ **Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.page` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
162
167
 
163
168
  **QR code.** With the full screen open, key `m` shows the QR of the selected tunnel in place of the request list. On a tunnel without a password, the QR points to the tunnel URL. On a tunnel with a password, it points to a link that signs in directly, as the first user in the `auth` list. That link is valid for 5 minutes and for one device only: once opened, or after it expires, it falls back to the sign-in page. On the QR screen, `g` generates a new link (and invalidates the previous one) and `esc` goes back to the list.
164
169
 
@@ -231,7 +236,7 @@ The page has the list on the left and the details on the right, with the tabs **
231
236
 
232
237
  | Option | What it does |
233
238
  |---|---|
234
- | `--name <name>` | Fixed address `<name>-<user>.duto.dev` |
239
+ | `--name <name>` | Fixed address `<name>-<user>.duto.page` |
235
240
  | `--host <host>` | Host the local service receives instead of the public one |
236
241
  | `--no-rewrite-redirects` | With `--host`, keep redirects as the service sent them |
237
242
  | `--auth <user:password>` | Ask for a username and password to open the address (repeatable) |
@@ -644,6 +644,8 @@ function reasonOf(c) {
644
644
  if (c.source === "headers") return c.kind === "remove" ? 'headers: ""' : "headers:";
645
645
  if (c.source === "host") return "host:";
646
646
  if (c.source === "visitor") return "from the visitor; set trust_forwarded: true to keep it";
647
+ if (c.source === "isolation" && c.name === "set-cookie") return "Domain=duto.page reaches every user's tunnel; kept on this address";
648
+ if (c.source === "isolation") return "from another user's tunnel; only SameSite=None cookies go through";
647
649
  return "Duto credential";
648
650
  }
649
651
  function valueOf(c) {
@@ -16,7 +16,7 @@ import {
16
16
  tunnelColor,
17
17
  updateLine,
18
18
  validateName
19
- } from "./chunk-QFM4MUED.js";
19
+ } from "./chunk-QMQWGCIE.js";
20
20
 
21
21
  // src/ui/fullscreen.tsx
22
22
  import { render } from "ink";
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  updateLine,
29
29
  validateName,
30
30
  warn
31
- } from "./chunk-QFM4MUED.js";
31
+ } from "./chunk-QMQWGCIE.js";
32
32
 
33
33
  // src/index.ts
34
34
  import { hostname as hostname2 } from "os";
@@ -36,7 +36,7 @@ import { join as join7, relative } from "path";
36
36
  import cac from "cac";
37
37
 
38
38
  // src/version.ts
39
- var CLI_VERSION = "1.8.0";
39
+ var CLI_VERSION = "1.9.0";
40
40
 
41
41
  // src/api.ts
42
42
  var DutoApiError = class extends Error {
@@ -561,7 +561,7 @@ async function browserLogin(o) {
561
561
  const start = await api.startLogin({
562
562
  deviceId: o.deviceId,
563
563
  deviceName: hostname(),
564
- cliVersion: "1.8.0",
564
+ cliVersion: "1.9.0",
565
565
  nodeVersion: process.version,
566
566
  os: process.platform,
567
567
  arch: process.arch
@@ -1729,7 +1729,7 @@ function redirectIssues(r, ctx) {
1729
1729
  {
1730
1730
  level: "warn",
1731
1731
  title: "Redirect drops to http",
1732
- detail: `Location is ${location}. duto.dev only serves https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
1732
+ detail: `Location is ${location}. Duto tunnels only serve https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
1733
1733
  }
1734
1734
  ];
1735
1735
  }
@@ -2436,6 +2436,89 @@ function deliver(h, route) {
2436
2436
  return changes.length ? { headers: out, changes } : { headers: h, changes };
2437
2437
  }
2438
2438
 
2439
+ // src/session/siteIsolation.ts
2440
+ function firstLabel(host) {
2441
+ return host.split(".")[0] ?? "";
2442
+ }
2443
+ function parentDomain(host) {
2444
+ return host.slice(host.indexOf(".") + 1).toLowerCase();
2445
+ }
2446
+ function tunnelOwner(host) {
2447
+ const label = firstLabel(host.toLowerCase());
2448
+ return label.slice(label.lastIndexOf("-") + 1);
2449
+ }
2450
+ function originHost(value) {
2451
+ if (!value) return null;
2452
+ try {
2453
+ const url = new URL(value);
2454
+ return { origin: url.origin, host: url.hostname.toLowerCase() };
2455
+ } catch {
2456
+ return null;
2457
+ }
2458
+ }
2459
+ function isSiblingTunnel(host, publicHost) {
2460
+ const parent = parentDomain(publicHost);
2461
+ if (!host.endsWith(`.${parent}`)) return false;
2462
+ return !host.slice(0, -parent.length - 1).includes(".");
2463
+ }
2464
+ function crossUserOrigin(h, publicHost) {
2465
+ const site = h["sec-fetch-site"];
2466
+ if (site !== void 0 && site !== "same-site") return null;
2467
+ const from = originHost(h.origin) ?? originHost(h.referer);
2468
+ if (!from || !isSiblingTunnel(from.host, publicHost)) return null;
2469
+ return tunnelOwner(from.host) === tunnelOwner(publicHost) ? null : from.origin;
2470
+ }
2471
+ function scopeSetCookies(lines, publicHost) {
2472
+ const parent = parentDomain(publicHost);
2473
+ const changes = [];
2474
+ const scoped = lines.map((line) => {
2475
+ const parts = line.split(";");
2476
+ const kept = parts.filter((part, i) => {
2477
+ if (i === 0) return true;
2478
+ const [key = "", value = ""] = part.split("=");
2479
+ const shared = key.trim().toLowerCase() === "domain" && value.trim().replace(/^\./, "").toLowerCase() === parent;
2480
+ if (shared) changes.push({ kind: "remove", name: "set-cookie", from: `${parseSetCookie(line).name}: ${part.trim()}`, source: "isolation" });
2481
+ return !shared;
2482
+ });
2483
+ return kept.length === parts.length ? line : kept.join(";");
2484
+ });
2485
+ return { lines: scoped, changes };
2486
+ }
2487
+ var SameSiteMemory = class {
2488
+ byName = /* @__PURE__ */ new Map();
2489
+ learn(setCookies) {
2490
+ for (const line of setCookies) {
2491
+ const c = parseSetCookie(line);
2492
+ const value = typeof c.attrs.samesite === "string" ? c.attrs.samesite.toLowerCase() : "lax";
2493
+ this.byName.set(c.name, value === "none" || value === "strict" ? value : "lax");
2494
+ }
2495
+ }
2496
+ sentCrossSite(name, navigation) {
2497
+ const sameSite = this.byName.get(name) ?? "lax";
2498
+ return sameSite === "none" || navigation && sameSite === "lax";
2499
+ }
2500
+ };
2501
+ function isNavigation(h, method) {
2502
+ if (method !== "GET" && method !== "HEAD") return false;
2503
+ const mode = h["sec-fetch-mode"];
2504
+ return mode === "navigate" || mode === void 0 && h.origin === void 0;
2505
+ }
2506
+ function isolateCookies(h, publicHost, memory, method = "GET") {
2507
+ const cookie = h.cookie;
2508
+ const origin = cookie ? crossUserOrigin(h, publicHost) : null;
2509
+ if (!cookie || !origin) return { headers: h, changes: [] };
2510
+ const navigation = isNavigation(h, method);
2511
+ const pairs = cookie.split(";").map((p) => p.trim()).filter(Boolean);
2512
+ const nameOf = (pair2) => pair2.split("=")[0].trim();
2513
+ const kept = pairs.filter((p) => memory.sentCrossSite(nameOf(p), navigation));
2514
+ const removed = pairs.filter((p) => !kept.includes(p)).map(nameOf);
2515
+ if (removed.length === 0) return { headers: h, changes: [] };
2516
+ const headers = { ...h };
2517
+ if (kept.length) headers.cookie = kept.join("; ");
2518
+ else delete headers.cookie;
2519
+ return { headers, changes: [{ kind: "remove", name: "cookie", from: `${removed.join(", ")} (from ${origin})`, source: "isolation" }] };
2520
+ }
2521
+
2439
2522
  // src/session/errorPage.ts
2440
2523
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2441
2524
  function wantsHtml(accept) {
@@ -2731,6 +2814,26 @@ function waitForService(res, port, checkMs, pingMs) {
2731
2814
  async function startProxy(o) {
2732
2815
  const max = o.maxBody ?? MAX_BODY;
2733
2816
  let nextId = 1;
2817
+ const sameSite = /* @__PURE__ */ new Map();
2818
+ function memoryOf(route) {
2819
+ let memory = sameSite.get(route.tunnel);
2820
+ if (!memory) sameSite.set(route.tunnel, memory = new SameSiteMemory());
2821
+ return memory;
2822
+ }
2823
+ function deliverFor(req, route) {
2824
+ const delivered = deliver(req.headers, route);
2825
+ const isolated = isolateCookies(delivered.headers, hostOf(req), memoryOf(route), req.method ?? "GET");
2826
+ return isolated.changes.length ? { headers: isolated.headers, changes: [...delivered.changes, ...isolated.changes] } : delivered;
2827
+ }
2828
+ function scopeResponseCookies(id, req, route, up) {
2829
+ const lines = up.headers["set-cookie"];
2830
+ if (!lines?.length) return;
2831
+ memoryOf(route).learn(lines);
2832
+ const scoped = scopeSetCookies(lines, hostOf(req));
2833
+ if (!scoped.changes.length) return;
2834
+ up.headers["set-cookie"] = scoped.lines;
2835
+ o.store.update(id, { headerChanges: [...o.store.get(id)?.headerChanges ?? [], ...scoped.changes] });
2836
+ }
2734
2837
  const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
2735
2838
  async function routeFor(req) {
2736
2839
  const host = hostOf(req);
@@ -2860,7 +2963,7 @@ async function startProxy(o) {
2860
2963
  }
2861
2964
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
2862
2965
  const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
2863
- const { headers, changes } = deliver(req.headers, route);
2966
+ const { headers, changes } = deliverFor(req, route);
2864
2967
  if (access?.kind === "denied") {
2865
2968
  const html = wantsHtml(req.headers.accept);
2866
2969
  const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
@@ -2894,6 +2997,7 @@ async function startProxy(o) {
2894
2997
  o.store.update(id, { redirect: { from, to } });
2895
2998
  }
2896
2999
  }
3000
+ scopeResponseCookies(id, req, route, up);
2897
3001
  res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2898
3002
  trackResponse(id, started, up, reqCap);
2899
3003
  up.pipe(res);
@@ -2937,7 +3041,7 @@ async function startProxy(o) {
2937
3041
  const route = await routeFor(req);
2938
3042
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
2939
3043
  const access = route.auth?.access(req.headers);
2940
- const { headers, changes } = deliver(req.headers, route);
3044
+ const { headers, changes } = deliverFor(req, route);
2941
3045
  const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
2942
3046
  if (access?.kind === "denied") {
2943
3047
  client.end(WS_DENIED);
@@ -3584,7 +3688,7 @@ async function runSession(specs, source, flags, configPath) {
3584
3688
  const warnings = credentialWarnings(specs);
3585
3689
  const watcher = configPath ? watchConfig({ path: configPath, onChange: (next) => void session.apply(next).catch((err) => session.configFailed(err instanceof Error ? err.message : String(err))), onError: (error) => session.configFailed(error) }) : null;
3586
3690
  if (wantsUi(flags)) {
3587
- const { runFullscreen } = await import("./fullscreen-Q3RGW6NJ.js");
3691
+ const { runFullscreen } = await import("./fullscreen-GM4ZV6FW.js");
3588
3692
  const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
3589
3693
  watcher?.close();
3590
3694
  await inspector?.close().catch(() => void 0);
@@ -3650,7 +3754,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
3650
3754
  return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
3651
3755
  }
3652
3756
  var cli = cac("duto");
3653
- cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.dev)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
3757
+ cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.page)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
3654
3758
  cli.command("http <port>", "Same as duto <port> (for people coming from ngrok)").option("--name <name>", "Fixed address name").option("--host <host>", "Host the local service receives instead of the public one").option("--no-rewrite-redirects", "With --host: keep redirects as the service sent them").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
3655
3759
  cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
3656
3760
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.8.0",
4
- "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
3
+ "version": "1.9.0",
4
+ "description": "Expose a local port at https://<name>-<user>.duto.page with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {