@dutodev/cli 1.6.1 → 1.8.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
@@ -34,7 +34,7 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
34
34
  duto 3000 --name web # https://web-<user>.duto.dev
35
35
  ```
36
36
 
37
- Every name you use with `--name` is reserved for you and keeps the same URL. Names take lowercase letters and numbers only, no hyphens.
37
+ 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.
38
38
 
39
39
  ## Several tunnels: `duto.yml`
40
40
 
@@ -49,6 +49,8 @@ tunnels:
49
49
 
50
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.
51
51
 
52
+ **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
+
52
54
  ### Host and headers
53
55
 
54
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:
@@ -140,7 +142,7 @@ tunnels:
140
142
  ```
141
143
 
142
144
  - `auth` is a list of `user:password`. Use one pair per person. The first `:` separates the user from the password; the password is 8 to 128 characters.
143
- - `${VAR}` reads an environment variable. Always use it: that keeps the password out of git along with `duto.yml`. If the variable does not exist, the CLI stops and prints the line number. The name can only have uppercase letters, digits and `_` (`${ANA_PASSWORD}`); anything else, like `${password}`, is left as text and becomes part of the password.
145
+ - `${VAR}` reads an environment variable, or the `.env` and `.env.local` next to `duto.yml` (`.env.local` wins over `.env`; a variable exported in the shell wins over both). Always use it: that keeps the password out of git along with `duto.yml`. If the variable does not exist, the CLI stops and prints the line number. The name can only have uppercase letters, digits and `_` (`${ANA_PASSWORD}`); anything else, like `${password}`, is left as text and becomes part of the password.
144
146
  - In a one-line list in brackets, `${VAR}` needs quotes (`auth: ["client:${CLIENT_PASSWORD}"]`), because `{` is special in YAML. In a block list, as above, it does not.
145
147
  - On the command line, for a single tunnel: `duto 3000 --auth client:my-password1 --allow stripe`. Both options can be repeated. They only work with a port; with a `duto.yml`, put them in the file.
146
148
  - Careful: a password passed to `--auth` ends up in your shell history and is visible to anyone who runs `ps` on the machine. For anything beyond a quick test, use `duto.yml` with `${VAR}`.
@@ -154,7 +156,7 @@ People get in one of these ways:
154
156
  | `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.dev/` (`Authorization: Basic`) |
155
157
  | An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
156
158
 
157
- Without credentials, a browser is sent to the sign-in page and other clients get `401`. 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.
159
+ 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.
158
160
 
159
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.
160
162
 
@@ -1,5 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/ui/brand.ts
4
+ function colorOn(stream = process.stdout) {
5
+ if (process.env.NO_COLOR) return false;
6
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
7
+ return Boolean(stream.isTTY);
8
+ }
9
+ var SGR = {
10
+ signal: "38;2;45;212;191",
11
+ muted: "38;2;138;143;139",
12
+ strong: "1",
13
+ ok: "38;2;74;222;128",
14
+ warn: "38;2;250;204;21",
15
+ err: "38;2;248;113;113"
16
+ };
17
+ function paint(tone, text, stream) {
18
+ return colorOn(stream) ? `\x1B[${SGR[tone]}m${text}\x1B[0m` : text;
19
+ }
20
+ var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
21
+ var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
22
+ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
23
+ var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
24
+
3
25
  // ../shared/src/names.ts
4
26
  var EMAIL_SENDING_SUBDOMAINS = ["send"];
5
27
  var RESERVED_NAMES = [
@@ -477,6 +499,95 @@ function loadConfig() {
477
499
  return fresh;
478
500
  }
479
501
 
502
+ // src/ui/format.ts
503
+ function statusColor(status) {
504
+ if (status === null) return "muted";
505
+ if (status === 101) return "signal";
506
+ if (status >= 500) return "err";
507
+ if (status >= 400) return "warn";
508
+ if (status >= 300) return "muted";
509
+ return "ok";
510
+ }
511
+ function formatDuration(ms) {
512
+ return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
513
+ }
514
+ function formatBytes(n) {
515
+ if (n < 1024) return `${n} B`;
516
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
517
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
518
+ }
519
+ function formatTime(ms) {
520
+ const d = new Date(ms);
521
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
522
+ }
523
+ function localLabel(t) {
524
+ return t.host ? `localhost:${t.port} (Host: ${t.host})` : `localhost:${t.port}`;
525
+ }
526
+
527
+ // src/ui/plain.ts
528
+ var KEEPING_LAST_CONFIG = "Tunnels keep the last valid config.";
529
+ function runPlain(session, out) {
530
+ const announced = /* @__PURE__ */ new Map();
531
+ const failed = /* @__PURE__ */ new Set();
532
+ const printed = /* @__PURE__ */ new Set();
533
+ let configError = null;
534
+ let reloads = 0;
535
+ const onTunnels = (state) => {
536
+ const keys = new Set(state.tunnels.map((t) => t.key));
537
+ for (const key of [...announced.keys()]) {
538
+ if (keys.has(key)) continue;
539
+ announced.delete(key);
540
+ out(`${paint("muted", "\u25CB")} ${key} closed`);
541
+ }
542
+ for (const key of [...failed]) if (!keys.has(key)) failed.delete(key);
543
+ for (const t of state.tunnels) {
544
+ if ((t.status === "live" || t.status === "no-listener") && t.url) {
545
+ const line = `${paint("signal", "\u25CF")} ${t.key} ${t.url}${t.auth ? " \u{1F512}" : ""} \u2192 ${localLabel(t)}`;
546
+ if (announced.get(t.key) !== line) {
547
+ announced.set(t.key, line);
548
+ out(line);
549
+ }
550
+ }
551
+ if (t.status !== "failed") failed.delete(t.key);
552
+ if (t.status === "failed" && t.error && !failed.has(t.key)) {
553
+ failed.add(t.key);
554
+ out(`${paint("err", "\u2716")} ${t.key} ${t.error}`);
555
+ }
556
+ }
557
+ };
558
+ const onConfig = (state) => {
559
+ const { error, warnings } = state.config;
560
+ if (error && error !== configError) out(`${paint("err", "\u2716")} ${error} ${KEEPING_LAST_CONFIG}`);
561
+ configError = error;
562
+ if (state.config.reloads === reloads) return;
563
+ reloads = state.config.reloads;
564
+ out(`${paint("signal", "\u21BB")} ${state.source} reloaded`);
565
+ for (const w of warnings) out(paint("warn", w));
566
+ };
567
+ const onState = () => {
568
+ const state = session.getState();
569
+ onTunnels(state);
570
+ onConfig(state);
571
+ };
572
+ const onRecords = () => {
573
+ for (const r of [...session.records.list()].reverse()) {
574
+ if (printed.has(r.id) || r.status === null || r.durationMs === null) continue;
575
+ printed.add(r.id);
576
+ const method = r.websocket ? "WS" : r.method;
577
+ out(
578
+ `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
579
+ );
580
+ }
581
+ };
582
+ const offState = session.subscribe(onState);
583
+ const offRecords = session.records.subscribe(onRecords);
584
+ onState();
585
+ return () => {
586
+ offState();
587
+ offRecords();
588
+ };
589
+ }
590
+
480
591
  // src/updates.ts
481
592
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
482
593
  import { join as join2 } from "path";
@@ -575,32 +686,12 @@ function bodyNote(r, side) {
575
686
  return null;
576
687
  }
577
688
 
578
- // src/ui/format.ts
579
- function statusColor(status) {
580
- if (status === null) return "muted";
581
- if (status === 101) return "signal";
582
- if (status >= 500) return "err";
583
- if (status >= 400) return "warn";
584
- if (status >= 300) return "muted";
585
- return "ok";
586
- }
587
- function formatDuration(ms) {
588
- return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
589
- }
590
- function formatBytes(n) {
591
- if (n < 1024) return `${n} B`;
592
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
593
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
594
- }
595
- function formatTime(ms) {
596
- const d = new Date(ms);
597
- return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
598
- }
599
- function localLabel(t) {
600
- return t.host ? `localhost:${t.port} (Host: ${t.host})` : `localhost:${t.port}`;
601
- }
602
-
603
689
  export {
690
+ paint,
691
+ mark,
692
+ ok,
693
+ fail,
694
+ warn,
604
695
  MAX_NAME,
605
696
  normalizeName,
606
697
  validateName,
@@ -628,6 +719,8 @@ export {
628
719
  formatBytes,
629
720
  formatTime,
630
721
  localLabel,
722
+ KEEPING_LAST_CONFIG,
723
+ runPlain,
631
724
  checkForUpdate,
632
725
  updateLine
633
726
  };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  C,
4
+ KEEPING_LAST_CONFIG,
4
5
  MAX_NAME,
5
6
  TRUNCATED_NOTE,
6
7
  bodyNote,
@@ -15,7 +16,7 @@ import {
15
16
  tunnelColor,
16
17
  updateLine,
17
18
  validateName
18
- } from "./chunk-DUC4RSBW.js";
19
+ } from "./chunk-QFM4MUED.js";
19
20
 
20
21
  // src/ui/fullscreen.tsx
21
22
  import { render } from "ink";
@@ -348,6 +349,12 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
348
349
  const t = setTimeout(() => setMessage(null), 4e3);
349
350
  return () => clearTimeout(t);
350
351
  }, [message]);
352
+ const reloads = state.config.reloads;
353
+ const reloadWarnings = state.config.warnings;
354
+ useEffect(() => {
355
+ if (reloads === 0) return;
356
+ setMessage({ text: [`\u21BB ${state.source} reloaded`, ...reloadWarnings].join(" "), tone: reloadWarnings.length ? "warn" : "signal" });
357
+ }, [reloads]);
351
358
  const inQr = mode.kind === "qr";
352
359
  useEffect(() => {
353
360
  if (!inQr) return;
@@ -467,7 +474,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
467
474
  });
468
475
  const live = state.tunnels.filter((t) => t.status === "live").length;
469
476
  const conn = state.connection === "online" ? { text: "connected \u25CF", color: C.signal } : state.connection === "connecting" ? { text: "connecting\u2026", color: C.muted } : { text: "reconnecting\u2026", color: C.warn };
470
- const listHeight = Math.max(3, rows - state.tunnels.length - (inspector ? 10 : 9) - (update ? 1 : 0));
477
+ const listHeight = Math.max(3, rows - state.tunnels.length - (inspector ? 10 : 9) - (update ? 1 : 0) - (state.config.error ? 1 : 0));
471
478
  const detailRecord = mode.kind === "detail" ? all.find((r) => r.id === mode.id) : void 0;
472
479
  const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
473
480
  return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
@@ -493,6 +500,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
493
500
  /* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
494
501
  ] }),
495
502
  update && /* @__PURE__ */ jsx6(Text6, { color: C.warn, children: updateLine(update) }),
503
+ state.config.error && /* @__PURE__ */ jsx6(Text6, { color: C.err, children: `${state.config.error} ${KEEPING_LAST_CONFIG}` }),
496
504
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
497
505
  /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
498
506
  inspector && /* @__PURE__ */ jsx6(Text6, { color: C.muted, children: `inspector ${inspector.url}` }),
package/dist/index.js CHANGED
@@ -12,52 +12,31 @@ import {
12
12
  checkForUpdate,
13
13
  describeChange,
14
14
  dutoHome,
15
- formatBytes,
16
- formatDuration,
17
- formatTime,
15
+ fail,
18
16
  headerLabel,
19
17
  isCredentialLike,
20
18
  isValidHeaderValue,
21
19
  loadConfig,
22
- localLabel,
20
+ mark,
23
21
  normalizeName,
22
+ ok,
23
+ paint,
24
24
  placeOf,
25
25
  reservedHeaderError,
26
+ runPlain,
26
27
  saveConfig,
27
- statusColor,
28
28
  updateLine,
29
- validateName
30
- } from "./chunk-DUC4RSBW.js";
31
-
32
- // src/ui/brand.ts
33
- function colorOn(stream = process.stdout) {
34
- if (process.env.NO_COLOR) return false;
35
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
36
- return Boolean(stream.isTTY);
37
- }
38
- var SGR = {
39
- signal: "38;2;45;212;191",
40
- muted: "38;2;138;143;139",
41
- strong: "1",
42
- ok: "38;2;74;222;128",
43
- warn: "38;2;250;204;21",
44
- err: "38;2;248;113;113"
45
- };
46
- function paint(tone, text2, stream) {
47
- return colorOn(stream) ? `\x1B[${SGR[tone]}m${text2}\x1B[0m` : text2;
48
- }
49
- var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
50
- var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
51
- var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
52
- var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
29
+ validateName,
30
+ warn
31
+ } from "./chunk-QFM4MUED.js";
53
32
 
54
33
  // src/index.ts
55
34
  import { hostname as hostname2 } from "os";
56
- import { join as join5, relative } from "path";
35
+ import { join as join7, relative } from "path";
57
36
  import cac from "cac";
58
37
 
59
38
  // src/version.ts
60
- var CLI_VERSION = "1.6.1";
39
+ var CLI_VERSION = "1.8.0";
61
40
 
62
41
  // src/api.ts
63
42
  var DutoApiError = class extends Error {
@@ -360,9 +339,29 @@ async function resolveAllow(entries, o = {}) {
360
339
  }
361
340
 
362
341
  // src/session/config.ts
363
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
364
- import { dirname as dirname2, join as join3 } from "path";
342
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
343
+ import { dirname as dirname2, join as join4 } from "path";
365
344
  import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
345
+
346
+ // src/session/envFiles.ts
347
+ import { readFileSync as readFileSync2 } from "fs";
348
+ import { join as join3 } from "path";
349
+ import { parse } from "dotenv";
350
+ var ENV_FILES = [".env", ".env.local"];
351
+ function readEnvFile(path) {
352
+ try {
353
+ return parse(readFileSync2(path, "utf8"));
354
+ } catch (err) {
355
+ if (err.code === "ENOENT") return {};
356
+ throw err;
357
+ }
358
+ }
359
+ function configEnv(dir, shell = process.env) {
360
+ const files = ENV_FILES.reduce((env, file) => ({ ...env, ...readEnvFile(join3(dir, file)) }), {});
361
+ return { ...files, ...shell };
362
+ }
363
+
364
+ // src/session/config.ts
366
365
  var ConfigError = class extends Error {
367
366
  constructor(message2, line, file = CONFIG_FILES[0]) {
368
367
  super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
@@ -376,6 +375,7 @@ var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
376
375
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
377
376
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
378
377
  var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
378
+ var SET_IT = `Add it to .env next to ${CONFIG_FILES[0]} or export it in the shell.`;
379
379
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
380
380
  function parseHost(raw) {
381
381
  const host = raw.trim().toLowerCase();
@@ -463,7 +463,7 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
463
463
  if (headers.has(hName.toLowerCase())) throw fail3(`header ${hName} appears twice in "${raw}"`, hLine);
464
464
  const text3 = headerText(h.value);
465
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));
466
+ const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}"). ${SET_IT}`, hLine));
467
467
  if (!isValidHeaderValue(value)) throw fail3(`invalid character in the value of ${hName} in "${raw}". Use ASCII or Latin-1 text.`, hLine);
468
468
  headers.set(hName.toLowerCase(), value);
469
469
  }
@@ -490,7 +490,7 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
490
490
  const iLine = lineAt(item.range?.[0]) ?? line;
491
491
  const text3 = isScalar(item) && typeof item.value === "string" ? item.value : null;
492
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));
493
+ const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}"). ${SET_IT}`, iLine));
494
494
  const parsed = parseCredential(expanded);
495
495
  if (!parsed.ok) {
496
496
  if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
@@ -525,13 +525,13 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
525
525
  function findConfig(cwd) {
526
526
  let dir = cwd;
527
527
  for (; ; ) {
528
- const present = CONFIG_FILES.filter((f) => existsSync2(join3(dir, f)));
528
+ const present = CONFIG_FILES.filter((f) => existsSync2(join4(dir, f)));
529
529
  if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
530
530
  if (present.length === 1) {
531
- const path = join3(dir, present[0]);
532
- return { path, tunnels: parseConfig(readFileSync2(path, "utf8"), present[0]) };
531
+ const path = join4(dir, present[0]);
532
+ return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0], configEnv(dir)) };
533
533
  }
534
- if (existsSync2(join3(dir, ".git"))) return null;
534
+ if (existsSync2(join4(dir, ".git"))) return null;
535
535
  const up = dirname2(dir);
536
536
  if (up === dir) return null;
537
537
  dir = up;
@@ -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.6.1",
564
+ cliVersion: "1.8.0",
565
565
  nodeVersion: process.version,
566
566
  os: process.platform,
567
567
  arch: process.arch
@@ -2175,6 +2175,48 @@ async function isPortListening(port, timeoutMs = 500) {
2175
2175
 
2176
2176
  // src/session/auth.ts
2177
2177
  import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
2178
+
2179
+ // src/session/attempts.ts
2180
+ var ATTEMPT_WINDOW_MS = 5 * 6e4;
2181
+ var MAX_FAILURES_PER_CLIENT = 10;
2182
+ var MAX_FAILURES_TOTAL = 100;
2183
+ var SWEEP_ABOVE_CLIENTS = 1e4;
2184
+ function msUntilBelow(times, max, now) {
2185
+ if (times.length < max) return 0;
2186
+ return Math.max(0, times[times.length - max] + ATTEMPT_WINDOW_MS - now);
2187
+ }
2188
+ var FailedAttempts = class {
2189
+ constructor(now) {
2190
+ this.now = now;
2191
+ }
2192
+ now;
2193
+ byClient = /* @__PURE__ */ new Map();
2194
+ total = [];
2195
+ lockedFor(client) {
2196
+ const now = this.now();
2197
+ this.total = this.fresh(this.total, now);
2198
+ const mine = this.fresh(this.byClient.get(client) ?? [], now);
2199
+ if (mine.length) this.byClient.set(client, mine);
2200
+ else this.byClient.delete(client);
2201
+ return Math.max(msUntilBelow(mine, MAX_FAILURES_PER_CLIENT, now), msUntilBelow(this.total, MAX_FAILURES_TOTAL, now));
2202
+ }
2203
+ record(client) {
2204
+ const now = this.now();
2205
+ this.total = [...this.fresh(this.total, now), now].slice(-MAX_FAILURES_TOTAL);
2206
+ this.byClient.set(client, [...this.fresh(this.byClient.get(client) ?? [], now), now].slice(-MAX_FAILURES_PER_CLIENT));
2207
+ if (this.byClient.size > SWEEP_ABOVE_CLIENTS) this.sweep(now);
2208
+ }
2209
+ fresh(times, now) {
2210
+ return times.filter((t) => t > now - ATTEMPT_WINDOW_MS);
2211
+ }
2212
+ sweep(now) {
2213
+ for (const [client, times] of this.byClient) {
2214
+ if (!this.fresh(times, now).length) this.byClient.delete(client);
2215
+ }
2216
+ }
2217
+ };
2218
+
2219
+ // src/session/auth.ts
2178
2220
  var TUNNEL_COOKIE = "__Host-duto_tunnel";
2179
2221
  var SEC_FETCH_SITE_SAME_SITE = "same-site";
2180
2222
  var QR_TTL_MS = 5 * 6e4;
@@ -2189,6 +2231,7 @@ function parseBasic(value) {
2189
2231
  return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
2190
2232
  }
2191
2233
  var first2 = (v) => Array.isArray(v) ? v[0] : v;
2234
+ var clientOf2 = (h) => first2(h["cf-connecting-ip"]) ?? "unknown";
2192
2235
  var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
2193
2236
  function sameHostOrigin(h) {
2194
2237
  if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
@@ -2203,20 +2246,28 @@ function sameHostOrigin(h) {
2203
2246
  return host !== "" && bareHost(host) === bareHost(h.host ?? "");
2204
2247
  }
2205
2248
  var TunnelAuth = class {
2206
- qrUser;
2207
2249
  tunnel;
2208
2250
  credentials;
2209
2251
  allow;
2210
2252
  now;
2211
2253
  secret;
2212
2254
  qr = null;
2255
+ failures;
2213
2256
  constructor(o) {
2214
2257
  this.tunnel = o.tunnel;
2215
2258
  this.credentials = o.credentials;
2216
2259
  this.allow = o.allow;
2217
2260
  this.now = o.now ?? Date.now;
2218
2261
  this.secret = o.secret ?? randomBytes2(32);
2219
- this.qrUser = o.credentials[0]?.user ?? "";
2262
+ this.failures = new FailedAttempts(this.now);
2263
+ }
2264
+ get qrUser() {
2265
+ return this.credentials[0]?.user ?? "";
2266
+ }
2267
+ update(o) {
2268
+ if (o.credentials[0]?.user !== this.qrUser) this.qr = null;
2269
+ this.credentials = o.credentials;
2270
+ this.allow = o.allow;
2220
2271
  }
2221
2272
  access(h) {
2222
2273
  if (this.allow) {
@@ -2228,9 +2279,19 @@ var TunnelAuth = class {
2228
2279
  if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
2229
2280
  const dutoAuthHeader = first2(h["x-duto-auth"]);
2230
2281
  const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
2231
- if (candidate && this.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
2282
+ if (candidate && this.attemptPassword(h, candidate.user, candidate.password) === "ok") return { kind: "user", user: candidate.user };
2232
2283
  return { kind: "denied" };
2233
2284
  }
2285
+ lockedFor(h) {
2286
+ return this.failures.lockedFor(clientOf2(h));
2287
+ }
2288
+ attemptPassword(h, user, password) {
2289
+ const client = clientOf2(h);
2290
+ if (this.failures.lockedFor(client) > 0) return "locked";
2291
+ if (this.checkPassword(user, password)) return "ok";
2292
+ this.failures.record(client);
2293
+ return "wrong";
2294
+ }
2234
2295
  checkPassword(user, password) {
2235
2296
  let ok2 = false;
2236
2297
  for (const c of this.credentials) {
@@ -2455,6 +2516,10 @@ function safeNext(raw) {
2455
2516
  if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
2456
2517
  return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
2457
2518
  }
2519
+ var ERROR_TEXT = {
2520
+ wrong: "Wrong username or password.",
2521
+ locked: "Too many wrong passwords. Wait a few minutes and try again."
2522
+ };
2458
2523
  function loginPage(o) {
2459
2524
  return pageShell(
2460
2525
  "Sign in \xB7 duto",
@@ -2468,7 +2533,7 @@ function loginPage(o) {
2468
2533
  <input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
2469
2534
  <label for="password">Password</label>
2470
2535
  <input id="password" name="password" type="password" autocomplete="current-password" required>
2471
- ${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
2536
+ ${o.error ? `<p class="error" role="alert">${ERROR_TEXT[o.error]}</p>
2472
2537
  ` : ""}<button type="submit">Sign in</button>
2473
2538
  </form>
2474
2539
  <div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
@@ -2584,8 +2649,8 @@ function readForm(req, max) {
2584
2649
  }
2585
2650
  async function authRoute(req, res, route, auth) {
2586
2651
  const url = new URL(req.url ?? "/", "http://duto");
2587
- const page = (status, next, error) => {
2588
- res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
2652
+ const page = (status, next, error, extra = {}) => {
2653
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra });
2589
2654
  res.end(loginPage({ tunnel: route.tunnel, next, error }));
2590
2655
  };
2591
2656
  const go = (location, cookie) => {
@@ -2594,7 +2659,9 @@ async function authRoute(req, res, route, auth) {
2594
2659
  };
2595
2660
  const { pathname } = url;
2596
2661
  if (pathname === LOGIN_PATH && req.method === "GET") {
2597
- page(200, safeNext(url.searchParams.get("next")), false);
2662
+ const next = safeNext(url.searchParams.get("next"));
2663
+ if (auth.access(req.headers).kind !== "denied") go(next);
2664
+ else page(200, next, null);
2598
2665
  return true;
2599
2666
  }
2600
2667
  if (pathname === LOGIN_PATH && req.method === "POST") {
@@ -2607,8 +2674,10 @@ async function authRoute(req, res, route, auth) {
2607
2674
  const form = new URLSearchParams(raw);
2608
2675
  const user = form.get("username") ?? "";
2609
2676
  const next = safeNext(form.get("next"));
2610
- if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
2611
- else page(401, next, true);
2677
+ const attempt = auth.attemptPassword(req.headers, user, form.get("password") ?? "");
2678
+ if (attempt === "ok") go(next, auth.loginCookie(user));
2679
+ else if (attempt === "locked") page(429, next, "locked", { "retry-after": String(Math.ceil(auth.lockedFor(req.headers) / 1e3)) });
2680
+ else page(401, next, "wrong");
2612
2681
  return true;
2613
2682
  }
2614
2683
  if (pathname === LOGOUT_PATH && req.method === "GET") {
@@ -2922,9 +2991,9 @@ async function startProxy(o) {
2922
2991
 
2923
2992
  // src/session/orphans.ts
2924
2993
  import { spawnSync } from "child_process";
2925
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2926
- import { join as join4 } from "path";
2927
- var fileOf = (dir, cfPid) => join4(dir, `cloudflared-${cfPid}.json`);
2994
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2995
+ import { join as join5 } from "path";
2996
+ var fileOf = (dir, cfPid) => join5(dir, `cloudflared-${cfPid}.json`);
2928
2997
  function recordCloudflared(dir, cfPid, sessionPid = process.pid) {
2929
2998
  try {
2930
2999
  mkdirSync3(dir, { recursive: true });
@@ -2946,7 +3015,7 @@ var realProc = {
2946
3015
  },
2947
3016
  isCloudflared(pid) {
2948
3017
  try {
2949
- if (process.platform === "linux") return readFileSync3(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
3018
+ if (process.platform === "linux") return readFileSync4(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
2950
3019
  if (process.platform === "darwin") return spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout.includes("cloudflared");
2951
3020
  } catch {
2952
3021
  return false;
@@ -2965,10 +3034,10 @@ function reapOrphans(dir, proc = realProc) {
2965
3034
  let killed = 0;
2966
3035
  for (const name of readdirSync(dir)) {
2967
3036
  if (!/^cloudflared-\d+\.json$/.test(name)) continue;
2968
- const path = join4(dir, name);
3037
+ const path = join5(dir, name);
2969
3038
  let rec;
2970
3039
  try {
2971
- rec = JSON.parse(readFileSync3(path, "utf8"));
3040
+ rec = JSON.parse(readFileSync4(path, "utf8"));
2972
3041
  } catch {
2973
3042
  rmSync2(path, { force: true });
2974
3043
  continue;
@@ -3052,6 +3121,10 @@ var Session = class {
3052
3121
  routes = /* @__PURE__ */ new Map();
3053
3122
  specs = /* @__PURE__ */ new Map();
3054
3123
  persistentAuths = /* @__PURE__ */ new Map();
3124
+ allows = /* @__PURE__ */ new Map();
3125
+ config = { error: null, warnings: [], reloads: 0 };
3126
+ applying = Promise.resolve();
3127
+ background = /* @__PURE__ */ new Set();
3055
3128
  proxy = null;
3056
3129
  child = null;
3057
3130
  token = null;
@@ -3063,7 +3136,7 @@ var Session = class {
3063
3136
  listeners = /* @__PURE__ */ new Set();
3064
3137
  snapshot = null;
3065
3138
  getState() {
3066
- this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
3139
+ this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })), config: { ...this.config } };
3067
3140
  return this.snapshot;
3068
3141
  }
3069
3142
  subscribe(fn) {
@@ -3075,12 +3148,17 @@ var Session = class {
3075
3148
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
3076
3149
  this.tunnels = specs.map((s) => this.blank(s, false));
3077
3150
  this.emit();
3151
+ const opening = this.openAll();
3152
+ this.applying = opening.catch(() => void 0);
3153
+ await opening;
3154
+ const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
3155
+ this.timer = setInterval(() => void this.beat(), every);
3156
+ }
3157
+ async openAll() {
3078
3158
  const me = await this.o.api.me().catch(() => null);
3079
3159
  if (me) this.account = me.username ?? me.email;
3080
3160
  const existing = await this.o.api.endpoints().catch(() => []);
3081
- for (const t of this.tunnels) await this.open(t, existing);
3082
- const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
3083
- this.timer = setInterval(() => void this.beat(), every);
3161
+ await Promise.all([...this.tunnels].map((t) => this.open(t, existing)));
3084
3162
  }
3085
3163
  async add(spec) {
3086
3164
  if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
@@ -3090,6 +3168,106 @@ var Session = class {
3090
3168
  const existing = await this.o.api.endpoints().catch(() => []);
3091
3169
  await this.open(t, existing);
3092
3170
  }
3171
+ apply(specs) {
3172
+ const run = this.applying.then(() => this.reconcile(specs));
3173
+ this.applying = run.catch(() => void 0);
3174
+ return run;
3175
+ }
3176
+ async settled() {
3177
+ await this.applying;
3178
+ while (this.background.size) await Promise.all([...this.background]);
3179
+ }
3180
+ inBackground(work) {
3181
+ const run = work().catch(() => void 0);
3182
+ this.background.add(run);
3183
+ void run.finally(() => this.background.delete(run));
3184
+ }
3185
+ configFailed(error) {
3186
+ this.config = { ...this.config, error };
3187
+ this.emit();
3188
+ }
3189
+ async reconcile(specs) {
3190
+ if (this.stopped) return;
3191
+ const wanted = new Set(specs.map(keyOf));
3192
+ for (const t of this.tunnels.filter((x) => !x.adHoc && !wanted.has(x.key))) this.remove(t);
3193
+ const fresh = [];
3194
+ for (const spec of specs) {
3195
+ if (this.stopped) return;
3196
+ const t = this.tunnels.find((x) => x.key === keyOf(spec));
3197
+ if (t) {
3198
+ await this.update(t, spec);
3199
+ continue;
3200
+ }
3201
+ const added = this.blank(spec, false);
3202
+ this.tunnels.push(added);
3203
+ fresh.push(added);
3204
+ }
3205
+ if (fresh.length) this.inBackground(() => this.openFresh(fresh));
3206
+ this.config = { error: null, warnings: credentialWarnings(specs), reloads: this.config.reloads + 1 };
3207
+ this.emit();
3208
+ }
3209
+ async openFresh(tunnels) {
3210
+ const existing = await this.o.api.endpoints().catch(() => []);
3211
+ await Promise.all(tunnels.filter((t) => !this.gone(t)).map((t) => this.open(t, existing)));
3212
+ }
3213
+ remove(t) {
3214
+ this.tunnels = this.tunnels.filter((x) => x !== t);
3215
+ this.forget(t);
3216
+ this.emit();
3217
+ const endpointId = t.endpointId;
3218
+ if (endpointId) this.inBackground(async () => void await withTimeout(this.o.api.closeTunnel(endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS));
3219
+ }
3220
+ forget(t) {
3221
+ if (t.hostname) this.routes.delete(t.hostname.toLowerCase());
3222
+ this.specs.delete(t.key);
3223
+ this.persistentAuths.delete(t.key);
3224
+ this.allows.delete(t.key);
3225
+ }
3226
+ async update(t, spec) {
3227
+ const before = JSON.stringify(this.specs.get(t.key));
3228
+ const changed = before !== JSON.stringify(spec);
3229
+ const portChanged = t.port !== spec.port;
3230
+ this.specs.set(t.key, spec);
3231
+ const runtime = { url: t.url, hostname: t.hostname, endpointId: t.endpointId, status: t.status, error: t.error, requests: t.requests, errors: t.errors };
3232
+ for (const k of Object.keys(t)) delete t[k];
3233
+ Object.assign(t, this.describe(spec), { adHoc: false }, runtime);
3234
+ const auth = await this.refreshAuth(t.key);
3235
+ if (t.status === "failed") {
3236
+ if (changed) this.inBackground(() => this.open(t, null));
3237
+ return;
3238
+ }
3239
+ if (t.hostname) this.routes.set(t.hostname.toLowerCase(), this.routeOf(t, auth));
3240
+ this.emit();
3241
+ if (portChanged && t.endpointId) this.inBackground(() => this.open(t, null));
3242
+ }
3243
+ routeOf(t, auth) {
3244
+ return { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth };
3245
+ }
3246
+ async allowFor(key, spec) {
3247
+ if (!spec.allow?.length) {
3248
+ this.allows.delete(key);
3249
+ return void 0;
3250
+ }
3251
+ const entries = JSON.stringify(spec.allow);
3252
+ const cached = this.allows.get(key);
3253
+ if (cached?.entries === entries) return cached.list;
3254
+ const list = await (this.o.resolveAllow ?? resolveAllow)(spec.allow);
3255
+ this.allows.set(key, { entries, list });
3256
+ return list;
3257
+ }
3258
+ async refreshAuth(key) {
3259
+ const spec = this.specs.get(key);
3260
+ const existing = this.persistentAuths.get(key);
3261
+ if (!spec?.auth?.length) {
3262
+ this.persistentAuths.delete(key);
3263
+ this.allows.delete(key);
3264
+ return void 0;
3265
+ }
3266
+ const allow = await this.allowFor(key, spec);
3267
+ if (!existing) return this.authFor(key);
3268
+ existing.update({ credentials: spec.auth, allow });
3269
+ return existing;
3270
+ }
3093
3271
  qrLink(key) {
3094
3272
  const t = this.tunnels.find((x) => x.key === key);
3095
3273
  if (!t?.url) return null;
@@ -3106,7 +3284,7 @@ var Session = class {
3106
3284
  if (existing) return existing;
3107
3285
  const spec = this.specs.get(key);
3108
3286
  if (!spec?.auth?.length) return void 0;
3109
- const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
3287
+ const allow = await this.allowFor(key, spec);
3110
3288
  const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
3111
3289
  this.persistentAuths.set(key, auth);
3112
3290
  return auth;
@@ -3127,7 +3305,13 @@ var Session = class {
3127
3305
  }
3128
3306
  blank(spec, adHoc) {
3129
3307
  this.specs.set(keyOf(spec), spec);
3130
- return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3308
+ return { ...this.describe(spec), url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3309
+ }
3310
+ describe(spec) {
3311
+ 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 } : {} };
3312
+ }
3313
+ gone(t) {
3314
+ return this.stopped || !this.tunnels.includes(t);
3131
3315
  }
3132
3316
  async open(t, existing, retrying = false) {
3133
3317
  if (this.stopped) return;
@@ -3143,12 +3327,12 @@ var Session = class {
3143
3327
  ...t.name ? { name: t.name } : {},
3144
3328
  proxyPort: this.proxy.port
3145
3329
  });
3146
- if (this.stopped) {
3330
+ if (this.gone(t)) {
3147
3331
  await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
3148
3332
  return;
3149
3333
  }
3150
3334
  const auth = await this.authFor(t.key);
3151
- if (this.stopped) {
3335
+ if (this.gone(t)) {
3152
3336
  await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
3153
3337
  return;
3154
3338
  }
@@ -3156,7 +3340,7 @@ var Session = class {
3156
3340
  t.hostname = res.hostname;
3157
3341
  t.endpointId = res.endpointId;
3158
3342
  t.error = null;
3159
- this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
3343
+ this.routes.set(res.hostname.toLowerCase(), this.routeOf(t, auth));
3160
3344
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
3161
3345
  this.ensureCloudflared(res.tunnelToken);
3162
3346
  this.emit();
@@ -3273,39 +3457,60 @@ var Session = class {
3273
3457
  }
3274
3458
  };
3275
3459
 
3276
- // src/ui/plain.ts
3277
- function runPlain(session, out) {
3278
- const announced = /* @__PURE__ */ new Set();
3279
- const failed = /* @__PURE__ */ new Set();
3280
- const printed = /* @__PURE__ */ new Set();
3281
- const onState = () => {
3282
- for (const t of session.getState().tunnels) {
3283
- if ((t.status === "live" || t.status === "no-listener") && t.url && !announced.has(t.key)) {
3284
- announced.add(t.key);
3285
- out(`${paint("signal", "\u25CF")} ${t.key} ${t.url}${t.auth ? " \u{1F512}" : ""} \u2192 ${localLabel(t)}`);
3286
- }
3287
- if (t.status === "failed" && t.error && !failed.has(t.key)) {
3288
- failed.add(t.key);
3289
- out(`${paint("err", "\u2716")} ${t.key} ${t.error}`);
3290
- }
3460
+ // src/session/watch.ts
3461
+ import { readFileSync as readFileSync5, watch } from "fs";
3462
+ import { basename, dirname as dirname3, join as join6 } from "path";
3463
+ var RELOAD_DEBOUNCE_MS = 150;
3464
+ function readText(path) {
3465
+ try {
3466
+ return readFileSync5(path, "utf8");
3467
+ } catch (err) {
3468
+ if (err.code === "ENOENT") return null;
3469
+ throw err;
3470
+ }
3471
+ }
3472
+ function watchConfig(o) {
3473
+ const file = basename(o.path);
3474
+ const dir = dirname3(o.path);
3475
+ const watched = /* @__PURE__ */ new Set([file, ...ENV_FILES]);
3476
+ const snapshot = () => [readText(o.path), ...ENV_FILES.map((f) => readText(join6(dir, f)))].join("\0");
3477
+ let lastSeen = snapshot();
3478
+ let timer = null;
3479
+ const reload = () => {
3480
+ timer = null;
3481
+ let text2;
3482
+ let seen;
3483
+ try {
3484
+ text2 = readText(o.path);
3485
+ seen = snapshot();
3486
+ } catch (err) {
3487
+ return o.onError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3291
3488
  }
3292
- };
3293
- const onRecords = () => {
3294
- for (const r of [...session.records.list()].reverse()) {
3295
- if (printed.has(r.id) || r.status === null || r.durationMs === null) continue;
3296
- printed.add(r.id);
3297
- const method = r.websocket ? "WS" : r.method;
3298
- out(
3299
- `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
3300
- );
3489
+ if (seen === lastSeen) return;
3490
+ lastSeen = seen;
3491
+ if (text2 === null) return o.onError(`${file} was deleted.`);
3492
+ let specs;
3493
+ try {
3494
+ specs = parseConfig(text2, file, configEnv(dir));
3495
+ } catch (err) {
3496
+ if (err instanceof ConfigError) return o.onError(err.message);
3497
+ return o.onError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3301
3498
  }
3499
+ o.onChange(specs);
3302
3500
  };
3303
- const offState = session.subscribe(onState);
3304
- const offRecords = session.records.subscribe(onRecords);
3305
- onState();
3306
- return () => {
3307
- offState();
3308
- offRecords();
3501
+ const schedule = () => {
3502
+ if (timer) clearTimeout(timer);
3503
+ timer = setTimeout(reload, o.debounceMs ?? RELOAD_DEBOUNCE_MS);
3504
+ };
3505
+ const watcher = watch(dir, (_event, name) => {
3506
+ if (name === null || watched.has(name)) schedule();
3507
+ });
3508
+ watcher.on("error", () => void 0);
3509
+ return {
3510
+ close() {
3511
+ if (timer) clearTimeout(timer);
3512
+ watcher.close();
3513
+ }
3309
3514
  };
3310
3515
  }
3311
3516
 
@@ -3355,7 +3560,7 @@ function wantsUi(flags) {
3355
3560
  if (process.platform === "win32") return process.argv.includes("--ui");
3356
3561
  return true;
3357
3562
  }
3358
- async function runSession(specs, source, flags) {
3563
+ async function runSession(specs, source, flags, configPath) {
3359
3564
  const config = loadConfig();
3360
3565
  if (!config.authtoken) fail2(LOGIN_HINT);
3361
3566
  const api = createApiClient({ baseUrl: apiUrl(), token: config.authtoken });
@@ -3366,7 +3571,7 @@ async function runSession(specs, source, flags) {
3366
3571
  } catch (err) {
3367
3572
  fail2(err instanceof Error ? err.message : String(err));
3368
3573
  }
3369
- const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join5(dutoHome(), "run") });
3574
+ const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join7(dutoHome(), "run") });
3370
3575
  let inspector = null;
3371
3576
  let inspectorError;
3372
3577
  if (flags.inspect !== false) {
@@ -3377,9 +3582,11 @@ async function runSession(specs, source, flags) {
3377
3582
  }
3378
3583
  }
3379
3584
  const warnings = credentialWarnings(specs);
3585
+ 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;
3380
3586
  if (wantsUi(flags)) {
3381
- const { runFullscreen } = await import("./fullscreen-MSVJF43W.js");
3587
+ const { runFullscreen } = await import("./fullscreen-Q3RGW6NJ.js");
3382
3588
  const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
3589
+ watcher?.close();
3383
3590
  await inspector?.close().catch(() => void 0);
3384
3591
  process.exit(code);
3385
3592
  }
@@ -3402,6 +3609,7 @@ async function runSession(specs, source, flags) {
3402
3609
  });
3403
3610
  await Promise.race([session.start(specs), stopped]);
3404
3611
  await stopped;
3612
+ watcher?.close();
3405
3613
  stopLog();
3406
3614
  await session.stop();
3407
3615
  await inspector?.close().catch(() => void 0);
@@ -3429,7 +3637,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
3429
3637
  `);
3430
3638
  process.exit(0);
3431
3639
  }
3432
- return runSession(found.tunnels, relative(process.cwd(), found.path) || "duto.yml", flags);
3640
+ return runSession(found.tunnels, relative(process.cwd(), found.path) || "duto.yml", flags, found.path);
3433
3641
  }
3434
3642
  const port = parsePort(rawPort);
3435
3643
  const name = parseName(flags.name) ?? null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.6.1",
3
+ "version": "1.8.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",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "cac": "^6.7.14",
32
+ "dotenv": "17.4.2",
32
33
  "ink": "^5.2.1",
33
34
  "qrcode-generator": "^1.5.2",
34
35
  "react": "^18.3.1",