@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`
|
|
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-
|
|
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
|
-
|
|
16
|
-
formatDuration,
|
|
17
|
-
formatTime,
|
|
15
|
+
fail,
|
|
18
16
|
headerLabel,
|
|
19
17
|
isCredentialLike,
|
|
20
18
|
isValidHeaderValue,
|
|
21
19
|
loadConfig,
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
364
|
-
import { dirname as dirname2, join as
|
|
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(
|
|
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 =
|
|
532
|
-
return { path, tunnels: parseConfig(
|
|
531
|
+
const path = join4(dir, present[0]);
|
|
532
|
+
return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0], configEnv(dir)) };
|
|
533
533
|
}
|
|
534
|
-
if (existsSync2(
|
|
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.
|
|
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.
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
2611
|
-
|
|
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
|
|
2926
|
-
import { join as
|
|
2927
|
-
var fileOf = (dir, cfPid) =>
|
|
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
|
|
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 =
|
|
3037
|
+
const path = join5(dir, name);
|
|
2969
3038
|
let rec;
|
|
2970
3039
|
try {
|
|
2971
|
-
rec = JSON.parse(
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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.
|
|
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.
|
|
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(),
|
|
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/
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
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
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
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
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
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:
|
|
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-
|
|
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.
|
|
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",
|