@dutodev/cli 1.7.0 → 1.8.1
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 +21 -14
- package/dist/{chunk-QVJHHURM.js → chunk-QFM4MUED.js} +118 -25
- package/dist/{fullscreen-ZNFUDV2B.js → fullscreen-Q3RGW6NJ.js} +10 -2
- package/dist/index.js +241 -94
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Put a local service on the internet at a stable HTTPS URL, with one command:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
duto 3000 --name web # https://web-<user>.duto.
|
|
6
|
+
duto 3000 --name web # https://web-<user>.duto.page → localhost:3000
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
The URL stays the same from one session to the next. That makes it good for webhooks, login callbacks, testing on your phone, or showing someone your work without deploying.
|
|
@@ -20,8 +20,13 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
|
|
|
20
20
|
|
|
21
21
|
## Get started
|
|
22
22
|
|
|
23
|
-
1.
|
|
24
|
-
|
|
23
|
+
1. Connect the CLI to your account (once per machine). It opens your browser so you can approve it:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
duto login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. On CI and servers with no browser, create a token on the **Machines** page at [duto.dev](https://duto.dev) and pass it:
|
|
25
30
|
|
|
26
31
|
```
|
|
27
32
|
duto login <token>
|
|
@@ -30,11 +35,11 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
|
|
|
30
35
|
3. Open a tunnel:
|
|
31
36
|
|
|
32
37
|
```
|
|
33
|
-
duto 3000 # https://<user>.duto.
|
|
34
|
-
duto 3000 --name web # https://web-<user>.duto.
|
|
38
|
+
duto 3000 # https://<user>.duto.page
|
|
39
|
+
duto 3000 --name web # https://web-<user>.duto.page
|
|
35
40
|
```
|
|
36
41
|
|
|
37
|
-
Every name you use with `--name`
|
|
42
|
+
Every name you use with `--name` keeps the same URL, and only you can use it. Names take lowercase letters and numbers only, no hyphens. The account limit counts named tunnels online at once: closed ones do not count, and names unused for 30 days are released.
|
|
38
43
|
|
|
39
44
|
## Several tunnels: `duto.yml`
|
|
40
45
|
|
|
@@ -47,11 +52,13 @@ tunnels:
|
|
|
47
52
|
webhook: 4000
|
|
48
53
|
```
|
|
49
54
|
|
|
50
|
-
All tunnels start together, in one screen. The CLI looks for the file in the current folder and its parents, up to the git root. If you commit it, everyone on the team gets their own addresses (`web-<user>.duto.
|
|
55
|
+
All tunnels start together, in one screen. The CLI looks for the file in the current folder and its parents, up to the git root. If you commit it, everyone on the team gets their own addresses (`web-<user>.duto.page`) from the same file.
|
|
56
|
+
|
|
57
|
+
**Edit while it runs.** Save `duto.yml` and the change applies right away, without restarting: new tunnels open, removed ones close, and a new `port`, `host`, `headers`, `auth` or `allow` counts from the next request, on the same URL. Visitors signed in with a password stay signed in while their user is still in `auth`. A save with a mistake changes nothing: the tunnels keep the last valid config and the screen shows the error with its line until you save a fixed file. Tunnels opened with `n` on the screen are left alone. Saving `.env` or `.env.local` applies the file again with the new values.
|
|
51
58
|
|
|
52
59
|
### Host and headers
|
|
53
60
|
|
|
54
|
-
By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.
|
|
61
|
+
By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.page`, `X-Forwarded-Proto: https` and the Cloudflare headers (`CF-Connecting-IP` and others). That is enough for most apps. When your app decides things by domain, you have two tools per tunnel:
|
|
55
62
|
|
|
56
63
|
```yaml
|
|
57
64
|
tunnels:
|
|
@@ -77,7 +84,7 @@ tunnels:
|
|
|
77
84
|
**What `host:` does:**
|
|
78
85
|
|
|
79
86
|
- Your service receives the Host you set, and `X-Forwarded-Host` is not sent. To send it anyway, put it in `headers:`.
|
|
80
|
-
- Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.
|
|
87
|
+
- Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.page/login`. This also applies inside encoded parameters of the `Location`. To turn it off, set `rewrite_redirects: false` on the tunnel.
|
|
81
88
|
- Links in response bodies (HTML, JSON) are not rewritten. URLs your app builds from the Host only open on your machine.
|
|
82
89
|
- On the command line: `duto 8081 --host admin.myapp.test`, and `--no-rewrite-redirects` to turn the swap off.
|
|
83
90
|
|
|
@@ -89,7 +96,7 @@ Ways out, from simplest to most work:
|
|
|
89
96
|
2. If the app needs the local domain for other decisions and reads `X-Forwarded-Host`, pass the domain through `headers:` and keep the public Host.
|
|
90
97
|
3. Set the public callback URL in the app, if it lets you.
|
|
91
98
|
|
|
92
|
-
In every case, register `https://<name>-<user>.duto.
|
|
99
|
+
In every case, register `https://<name>-<user>.duto.page/<callback path>` as the callback URL at the provider. The comparison is usually exact, with no wildcards: each person on the team has their own address.
|
|
93
100
|
|
|
94
101
|
**What you cannot set.** Some headers belong to Cloudflare, to Duto or to the HTTP connection itself. `duto.yml` refuses them, with the line and what to do:
|
|
95
102
|
|
|
@@ -140,7 +147,7 @@ tunnels:
|
|
|
140
147
|
```
|
|
141
148
|
|
|
142
149
|
- `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.
|
|
150
|
+
- `${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
151
|
- 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
152
|
- 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
153
|
- 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}`.
|
|
@@ -151,12 +158,12 @@ People get in one of these ways:
|
|
|
151
158
|
|---|---|
|
|
152
159
|
| Person in a browser | Opens the URL, sees the "This tunnel is password protected" page and signs in with user and password |
|
|
153
160
|
| Phone | Scans the QR code (key `m`, see below) and gets in without typing |
|
|
154
|
-
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.
|
|
161
|
+
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.page/` (`Authorization: Basic`) |
|
|
155
162
|
| An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
|
|
156
163
|
|
|
157
164
|
Without credentials, a browser is sent to the sign-in page and other clients get `401`. After 10 wrong passwords from one address in 5 minutes, that address is locked out for the rest of the window (the sign-in page answers `429` with `Retry-After`, other clients keep getting `401`). After 100 wrong passwords from all addresses, every password attempt is locked out the same way. A browser that already signed in keeps working. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
|
|
158
165
|
|
|
159
|
-
**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.
|
|
166
|
+
**Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.page` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
|
|
160
167
|
|
|
161
168
|
**QR code.** With the full screen open, key `m` shows the QR of the selected tunnel in place of the request list. On a tunnel without a password, the QR points to the tunnel URL. On a tunnel with a password, it points to a link that signs in directly, as the first user in the `auth` list. That link is valid for 5 minutes and for one device only: once opened, or after it expires, it falls back to the sign-in page. On the QR screen, `g` generates a new link (and invalidates the previous one) and `esc` goes back to the list.
|
|
162
169
|
|
|
@@ -229,7 +236,7 @@ The page has the list on the left and the details on the right, with the tabs **
|
|
|
229
236
|
|
|
230
237
|
| Option | What it does |
|
|
231
238
|
|---|---|
|
|
232
|
-
| `--name <name>` | Fixed address `<name>-<user>.duto.
|
|
239
|
+
| `--name <name>` | Fixed address `<name>-<user>.duto.page` |
|
|
233
240
|
| `--host <host>` | Host the local service receives instead of the public one |
|
|
234
241
|
| `--no-rewrite-redirects` | With `--host`, keep redirects as the service sent them |
|
|
235
242
|
| `--auth <user:password>` | Ask for a username and password to open the address (repeatable) |
|
|
@@ -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.1";
|
|
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.1",
|
|
565
565
|
nodeVersion: process.version,
|
|
566
566
|
os: process.platform,
|
|
567
567
|
arch: process.arch
|
|
@@ -1729,7 +1729,7 @@ function redirectIssues(r, ctx) {
|
|
|
1729
1729
|
{
|
|
1730
1730
|
level: "warn",
|
|
1731
1731
|
title: "Redirect drops to http",
|
|
1732
|
-
detail: `Location is ${location}.
|
|
1732
|
+
detail: `Location is ${location}. Duto tunnels only serve https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
|
|
1733
1733
|
}
|
|
1734
1734
|
];
|
|
1735
1735
|
}
|
|
@@ -2246,7 +2246,6 @@ function sameHostOrigin(h) {
|
|
|
2246
2246
|
return host !== "" && bareHost(host) === bareHost(h.host ?? "");
|
|
2247
2247
|
}
|
|
2248
2248
|
var TunnelAuth = class {
|
|
2249
|
-
qrUser;
|
|
2250
2249
|
tunnel;
|
|
2251
2250
|
credentials;
|
|
2252
2251
|
allow;
|
|
@@ -2260,9 +2259,16 @@ var TunnelAuth = class {
|
|
|
2260
2259
|
this.allow = o.allow;
|
|
2261
2260
|
this.now = o.now ?? Date.now;
|
|
2262
2261
|
this.secret = o.secret ?? randomBytes2(32);
|
|
2263
|
-
this.qrUser = o.credentials[0]?.user ?? "";
|
|
2264
2262
|
this.failures = new FailedAttempts(this.now);
|
|
2265
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;
|
|
2271
|
+
}
|
|
2266
2272
|
access(h) {
|
|
2267
2273
|
if (this.allow) {
|
|
2268
2274
|
const ip = first2(h["cf-connecting-ip"]);
|
|
@@ -2653,7 +2659,9 @@ async function authRoute(req, res, route, auth) {
|
|
|
2653
2659
|
};
|
|
2654
2660
|
const { pathname } = url;
|
|
2655
2661
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
2656
|
-
|
|
2662
|
+
const next = safeNext(url.searchParams.get("next"));
|
|
2663
|
+
if (auth.access(req.headers).kind !== "denied") go(next);
|
|
2664
|
+
else page(200, next, null);
|
|
2657
2665
|
return true;
|
|
2658
2666
|
}
|
|
2659
2667
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
@@ -2983,9 +2991,9 @@ async function startProxy(o) {
|
|
|
2983
2991
|
|
|
2984
2992
|
// src/session/orphans.ts
|
|
2985
2993
|
import { spawnSync } from "child_process";
|
|
2986
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as
|
|
2987
|
-
import { join as
|
|
2988
|
-
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`);
|
|
2989
2997
|
function recordCloudflared(dir, cfPid, sessionPid = process.pid) {
|
|
2990
2998
|
try {
|
|
2991
2999
|
mkdirSync3(dir, { recursive: true });
|
|
@@ -3007,7 +3015,7 @@ var realProc = {
|
|
|
3007
3015
|
},
|
|
3008
3016
|
isCloudflared(pid) {
|
|
3009
3017
|
try {
|
|
3010
|
-
if (process.platform === "linux") return
|
|
3018
|
+
if (process.platform === "linux") return readFileSync4(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
|
|
3011
3019
|
if (process.platform === "darwin") return spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout.includes("cloudflared");
|
|
3012
3020
|
} catch {
|
|
3013
3021
|
return false;
|
|
@@ -3026,10 +3034,10 @@ function reapOrphans(dir, proc = realProc) {
|
|
|
3026
3034
|
let killed = 0;
|
|
3027
3035
|
for (const name of readdirSync(dir)) {
|
|
3028
3036
|
if (!/^cloudflared-\d+\.json$/.test(name)) continue;
|
|
3029
|
-
const path =
|
|
3037
|
+
const path = join5(dir, name);
|
|
3030
3038
|
let rec;
|
|
3031
3039
|
try {
|
|
3032
|
-
rec = JSON.parse(
|
|
3040
|
+
rec = JSON.parse(readFileSync4(path, "utf8"));
|
|
3033
3041
|
} catch {
|
|
3034
3042
|
rmSync2(path, { force: true });
|
|
3035
3043
|
continue;
|
|
@@ -3113,6 +3121,10 @@ var Session = class {
|
|
|
3113
3121
|
routes = /* @__PURE__ */ new Map();
|
|
3114
3122
|
specs = /* @__PURE__ */ new Map();
|
|
3115
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();
|
|
3116
3128
|
proxy = null;
|
|
3117
3129
|
child = null;
|
|
3118
3130
|
token = null;
|
|
@@ -3124,7 +3136,7 @@ var Session = class {
|
|
|
3124
3136
|
listeners = /* @__PURE__ */ new Set();
|
|
3125
3137
|
snapshot = null;
|
|
3126
3138
|
getState() {
|
|
3127
|
-
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 } };
|
|
3128
3140
|
return this.snapshot;
|
|
3129
3141
|
}
|
|
3130
3142
|
subscribe(fn) {
|
|
@@ -3136,12 +3148,17 @@ var Session = class {
|
|
|
3136
3148
|
this.proxy = await startProxy({ routes: this.routes, store: this.records });
|
|
3137
3149
|
this.tunnels = specs.map((s) => this.blank(s, false));
|
|
3138
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() {
|
|
3139
3158
|
const me = await this.o.api.me().catch(() => null);
|
|
3140
3159
|
if (me) this.account = me.username ?? me.email;
|
|
3141
3160
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
3142
|
-
|
|
3143
|
-
const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
|
|
3144
|
-
this.timer = setInterval(() => void this.beat(), every);
|
|
3161
|
+
await Promise.all([...this.tunnels].map((t) => this.open(t, existing)));
|
|
3145
3162
|
}
|
|
3146
3163
|
async add(spec) {
|
|
3147
3164
|
if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
|
|
@@ -3151,6 +3168,106 @@ var Session = class {
|
|
|
3151
3168
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
3152
3169
|
await this.open(t, existing);
|
|
3153
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
|
+
}
|
|
3154
3271
|
qrLink(key) {
|
|
3155
3272
|
const t = this.tunnels.find((x) => x.key === key);
|
|
3156
3273
|
if (!t?.url) return null;
|
|
@@ -3167,7 +3284,7 @@ var Session = class {
|
|
|
3167
3284
|
if (existing) return existing;
|
|
3168
3285
|
const spec = this.specs.get(key);
|
|
3169
3286
|
if (!spec?.auth?.length) return void 0;
|
|
3170
|
-
const allow =
|
|
3287
|
+
const allow = await this.allowFor(key, spec);
|
|
3171
3288
|
const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
|
|
3172
3289
|
this.persistentAuths.set(key, auth);
|
|
3173
3290
|
return auth;
|
|
@@ -3188,7 +3305,13 @@ var Session = class {
|
|
|
3188
3305
|
}
|
|
3189
3306
|
blank(spec, adHoc) {
|
|
3190
3307
|
this.specs.set(keyOf(spec), spec);
|
|
3191
|
-
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);
|
|
3192
3315
|
}
|
|
3193
3316
|
async open(t, existing, retrying = false) {
|
|
3194
3317
|
if (this.stopped) return;
|
|
@@ -3204,12 +3327,12 @@ var Session = class {
|
|
|
3204
3327
|
...t.name ? { name: t.name } : {},
|
|
3205
3328
|
proxyPort: this.proxy.port
|
|
3206
3329
|
});
|
|
3207
|
-
if (this.
|
|
3330
|
+
if (this.gone(t)) {
|
|
3208
3331
|
await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
|
|
3209
3332
|
return;
|
|
3210
3333
|
}
|
|
3211
3334
|
const auth = await this.authFor(t.key);
|
|
3212
|
-
if (this.
|
|
3335
|
+
if (this.gone(t)) {
|
|
3213
3336
|
await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
|
|
3214
3337
|
return;
|
|
3215
3338
|
}
|
|
@@ -3217,7 +3340,7 @@ var Session = class {
|
|
|
3217
3340
|
t.hostname = res.hostname;
|
|
3218
3341
|
t.endpointId = res.endpointId;
|
|
3219
3342
|
t.error = null;
|
|
3220
|
-
this.routes.set(res.hostname.toLowerCase(),
|
|
3343
|
+
this.routes.set(res.hostname.toLowerCase(), this.routeOf(t, auth));
|
|
3221
3344
|
t.status = await isPortListening(t.port) ? "live" : "no-listener";
|
|
3222
3345
|
this.ensureCloudflared(res.tunnelToken);
|
|
3223
3346
|
this.emit();
|
|
@@ -3334,39 +3457,60 @@ var Session = class {
|
|
|
3334
3457
|
}
|
|
3335
3458
|
};
|
|
3336
3459
|
|
|
3337
|
-
// src/
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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)}`);
|
|
3352
3488
|
}
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
);
|
|
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)}`);
|
|
3362
3498
|
}
|
|
3499
|
+
o.onChange(specs);
|
|
3363
3500
|
};
|
|
3364
|
-
const
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
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
|
+
}
|
|
3370
3514
|
};
|
|
3371
3515
|
}
|
|
3372
3516
|
|
|
@@ -3416,7 +3560,7 @@ function wantsUi(flags) {
|
|
|
3416
3560
|
if (process.platform === "win32") return process.argv.includes("--ui");
|
|
3417
3561
|
return true;
|
|
3418
3562
|
}
|
|
3419
|
-
async function runSession(specs, source, flags) {
|
|
3563
|
+
async function runSession(specs, source, flags, configPath) {
|
|
3420
3564
|
const config = loadConfig();
|
|
3421
3565
|
if (!config.authtoken) fail2(LOGIN_HINT);
|
|
3422
3566
|
const api = createApiClient({ baseUrl: apiUrl(), token: config.authtoken });
|
|
@@ -3427,7 +3571,7 @@ async function runSession(specs, source, flags) {
|
|
|
3427
3571
|
} catch (err) {
|
|
3428
3572
|
fail2(err instanceof Error ? err.message : String(err));
|
|
3429
3573
|
}
|
|
3430
|
-
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") });
|
|
3431
3575
|
let inspector = null;
|
|
3432
3576
|
let inspectorError;
|
|
3433
3577
|
if (flags.inspect !== false) {
|
|
@@ -3438,9 +3582,11 @@ async function runSession(specs, source, flags) {
|
|
|
3438
3582
|
}
|
|
3439
3583
|
}
|
|
3440
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;
|
|
3441
3586
|
if (wantsUi(flags)) {
|
|
3442
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3587
|
+
const { runFullscreen } = await import("./fullscreen-Q3RGW6NJ.js");
|
|
3443
3588
|
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
3589
|
+
watcher?.close();
|
|
3444
3590
|
await inspector?.close().catch(() => void 0);
|
|
3445
3591
|
process.exit(code);
|
|
3446
3592
|
}
|
|
@@ -3463,6 +3609,7 @@ async function runSession(specs, source, flags) {
|
|
|
3463
3609
|
});
|
|
3464
3610
|
await Promise.race([session.start(specs), stopped]);
|
|
3465
3611
|
await stopped;
|
|
3612
|
+
watcher?.close();
|
|
3466
3613
|
stopLog();
|
|
3467
3614
|
await session.stop();
|
|
3468
3615
|
await inspector?.close().catch(() => void 0);
|
|
@@ -3490,7 +3637,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
|
|
|
3490
3637
|
`);
|
|
3491
3638
|
process.exit(0);
|
|
3492
3639
|
}
|
|
3493
|
-
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);
|
|
3494
3641
|
}
|
|
3495
3642
|
const port = parsePort(rawPort);
|
|
3496
3643
|
const name = parseName(flags.name) ?? null;
|
|
@@ -3503,7 +3650,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
|
|
|
3503
3650
|
return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
|
|
3504
3651
|
}
|
|
3505
3652
|
var cli = cac("duto");
|
|
3506
|
-
cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.
|
|
3653
|
+
cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.page)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
|
|
3507
3654
|
cli.command("http <port>", "Same as duto <port> (for people coming from ngrok)").option("--name <name>", "Fixed address name").option("--host <host>", "Host the local service receives instead of the public one").option("--no-rewrite-redirects", "With --host: keep redirects as the service sent them").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
|
|
3508
3655
|
cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
|
|
3509
3656
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dutodev/cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Expose a local port at https://<name>-<user>.duto.
|
|
3
|
+
"version": "1.8.1",
|
|
4
|
+
"description": "Expose a local port at https://<name>-<user>.duto.page with one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -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",
|