@dutodev/cli 0.7.0 → 1.1.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 +116 -116
- package/dist/{chunk-74IJFYGT.js → chunk-76YCOMXH.js} +8 -7
- package/dist/{fullscreen-YK365XSO.js → fullscreen-ECEH3R2V.js} +54 -53
- package/dist/index.js +364 -254
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
C,
|
|
4
|
+
MAX_NAME,
|
|
4
5
|
TRUNCATED_NOTE,
|
|
5
6
|
bodyNote,
|
|
6
7
|
formatBytes,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
normalizeName,
|
|
11
12
|
statusColor,
|
|
12
13
|
validateName
|
|
13
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-76YCOMXH.js";
|
|
14
15
|
|
|
15
16
|
// src/ui/brand.ts
|
|
16
17
|
function colorOn(stream = process.stdout) {
|
|
@@ -20,7 +21,6 @@ function colorOn(stream = process.stdout) {
|
|
|
20
21
|
}
|
|
21
22
|
var SGR = {
|
|
22
23
|
signal: "38;2;45;212;191",
|
|
23
|
-
// #2dd4bf
|
|
24
24
|
muted: "38;2;138;143;139",
|
|
25
25
|
strong: "1",
|
|
26
26
|
ok: "38;2;74;222;128",
|
|
@@ -36,7 +36,7 @@ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
|
|
|
36
36
|
var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
|
|
37
37
|
|
|
38
38
|
// src/index.ts
|
|
39
|
-
import { hostname } from "os";
|
|
39
|
+
import { hostname as hostname2 } from "os";
|
|
40
40
|
import { join as join6, relative } from "path";
|
|
41
41
|
import cac from "cac";
|
|
42
42
|
|
|
@@ -52,9 +52,10 @@ var DutoApiError = class extends Error {
|
|
|
52
52
|
code;
|
|
53
53
|
};
|
|
54
54
|
var MESSAGES = {
|
|
55
|
-
unauthorized: "
|
|
56
|
-
suspended: "
|
|
57
|
-
network: "
|
|
55
|
+
unauthorized: "This machine is not logged in. Run: duto login",
|
|
56
|
+
suspended: "Your account is suspended.",
|
|
57
|
+
network: "Could not reach duto.dev. Check your connection.",
|
|
58
|
+
loginExpired: "The login code expired. Run duto login again."
|
|
58
59
|
};
|
|
59
60
|
function createApiClient(o) {
|
|
60
61
|
const doFetch = o.fetchImpl ?? fetch;
|
|
@@ -64,7 +65,7 @@ function createApiClient(o) {
|
|
|
64
65
|
res = await doFetch(`${o.baseUrl}${path}`, {
|
|
65
66
|
method,
|
|
66
67
|
headers: {
|
|
67
|
-
authorization: `Bearer ${o.token}
|
|
68
|
+
...o.token === void 0 ? {} : { authorization: `Bearer ${o.token}` },
|
|
68
69
|
...body === void 0 ? {} : { "content-type": "application/json" },
|
|
69
70
|
"user-agent": "duto-cli"
|
|
70
71
|
},
|
|
@@ -80,7 +81,7 @@ function createApiClient(o) {
|
|
|
80
81
|
const code = data.error ?? "http_error";
|
|
81
82
|
if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
|
|
82
83
|
if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
|
|
83
|
-
throw new DutoApiError(data.message ?? `
|
|
84
|
+
throw new DutoApiError(data.message ?? `duto.dev returned HTTP ${res.status}.`, res.status, code);
|
|
84
85
|
}
|
|
85
86
|
async function json(method, path, body) {
|
|
86
87
|
const res = await call(method, path, body);
|
|
@@ -99,6 +100,12 @@ function createApiClient(o) {
|
|
|
99
100
|
const res = await call("DELETE", `/api/tunnels/${encodeURIComponent(id)}`);
|
|
100
101
|
if (!res.ok && res.status !== 404) await fail3(res);
|
|
101
102
|
},
|
|
103
|
+
startLogin: (r) => json("POST", "/api/cli/login", r),
|
|
104
|
+
pollLogin: (deviceCode) => json("POST", "/api/cli/login/poll", { deviceCode }),
|
|
105
|
+
async revokeSelf() {
|
|
106
|
+
const res = await call("DELETE", "/api/cli/token");
|
|
107
|
+
if (!res.ok && res.status !== 401 && res.status !== 404) await fail3(res);
|
|
108
|
+
},
|
|
102
109
|
me: () => json("GET", "/api/me"),
|
|
103
110
|
endpoints: () => json("GET", "/api/endpoints")
|
|
104
111
|
};
|
|
@@ -169,7 +176,7 @@ function assetFor(platform, arch) {
|
|
|
169
176
|
case "win32/x64":
|
|
170
177
|
return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
|
|
171
178
|
default:
|
|
172
|
-
throw new Error(`
|
|
179
|
+
throw new Error(`Unsupported platform: ${key}`);
|
|
173
180
|
}
|
|
174
181
|
}
|
|
175
182
|
function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
|
|
@@ -184,13 +191,13 @@ async function ensureCloudflared(opts = {}) {
|
|
|
184
191
|
opts.onDownload?.();
|
|
185
192
|
mkdirSync2(binDir, { recursive: true });
|
|
186
193
|
const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
|
|
187
|
-
if (!res.ok) throw new Error(`
|
|
194
|
+
if (!res.ok) throw new Error(`Could not download cloudflared (HTTP ${res.status}).`);
|
|
188
195
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
189
196
|
const expected = (opts.checksums ?? CHECKSUMS)[asset];
|
|
190
197
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
191
198
|
if (!expected || actual !== expected) {
|
|
192
199
|
rmSync(target, { force: true });
|
|
193
|
-
throw new Error("
|
|
200
|
+
throw new Error("cloudflared checksum mismatch. Try again; if it keeps failing, update duto.");
|
|
194
201
|
}
|
|
195
202
|
const tmp = mkdtempSync(join2(binDir, ".download-"));
|
|
196
203
|
try {
|
|
@@ -217,13 +224,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
|
|
|
217
224
|
...opts,
|
|
218
225
|
onDownload: () => {
|
|
219
226
|
downloading = true;
|
|
220
|
-
write("\u2193
|
|
227
|
+
write("\u2193 Downloading cloudflared (first run only)... ");
|
|
221
228
|
}
|
|
222
229
|
});
|
|
223
230
|
if (downloading) write("ok\n");
|
|
224
231
|
return path;
|
|
225
232
|
} catch (err) {
|
|
226
|
-
if (downloading) write("
|
|
233
|
+
if (downloading) write("failed\n");
|
|
227
234
|
throw err;
|
|
228
235
|
}
|
|
229
236
|
}
|
|
@@ -274,8 +281,8 @@ var SOURCES = {
|
|
|
274
281
|
github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
|
|
275
282
|
};
|
|
276
283
|
function splitCidr(raw) {
|
|
277
|
-
const [ip, p, ...
|
|
278
|
-
if (!ip ||
|
|
284
|
+
const [ip, p, ...extra] = raw.split("/");
|
|
285
|
+
if (!ip || extra.length) return null;
|
|
279
286
|
const v = isIP(ip);
|
|
280
287
|
if (!v) return null;
|
|
281
288
|
const max = v === 4 ? 32 : 128;
|
|
@@ -311,9 +318,9 @@ async function fetchRanges(name, f) {
|
|
|
311
318
|
const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
|
|
312
319
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
313
320
|
const list = src.pick(await res.json());
|
|
314
|
-
if (!Array.isArray(list)) throw new Error("
|
|
321
|
+
if (!Array.isArray(list)) throw new Error("unexpected format");
|
|
315
322
|
const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
|
|
316
|
-
if (!out.length) throw new Error("
|
|
323
|
+
if (!out.length) throw new Error("empty list");
|
|
317
324
|
return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
|
|
318
325
|
}
|
|
319
326
|
async function resolveAllow(entries, o = {}) {
|
|
@@ -325,7 +332,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
325
332
|
const cached = readCache(cacheFile);
|
|
326
333
|
const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
|
|
327
334
|
const lists = {};
|
|
328
|
-
|
|
335
|
+
const coversEveryName = fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length);
|
|
336
|
+
if (fresh && coversEveryName) {
|
|
329
337
|
for (const n of names) lists[n] = fresh.lists[n];
|
|
330
338
|
} else {
|
|
331
339
|
const net = {};
|
|
@@ -336,7 +344,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
336
344
|
lists[name] = ALLOW_SNAPSHOT[name];
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
|
-
|
|
347
|
+
const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
|
|
348
|
+
if (everyListFromNetwork) {
|
|
340
349
|
try {
|
|
341
350
|
mkdirSync3(dirname(cacheFile), { recursive: true });
|
|
342
351
|
writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
|
|
@@ -366,7 +375,7 @@ import { dirname as dirname2, join as join4 } from "path";
|
|
|
366
375
|
import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
|
|
367
376
|
var ConfigError = class extends Error {
|
|
368
377
|
constructor(message2, line, file = CONFIG_FILES[0]) {
|
|
369
|
-
super(!file ? message2 : line ? `${file},
|
|
378
|
+
super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
|
|
370
379
|
this.line = line;
|
|
371
380
|
this.name = "ConfigError";
|
|
372
381
|
}
|
|
@@ -376,7 +385,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
|
|
|
376
385
|
var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
377
386
|
var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
|
|
378
387
|
var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
379
|
-
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "
|
|
388
|
+
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
|
|
380
389
|
var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
|
|
381
390
|
function parseHost(raw) {
|
|
382
391
|
const host = raw.trim().toLowerCase();
|
|
@@ -402,11 +411,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
402
411
|
const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
|
|
403
412
|
if (doc.errors.length) {
|
|
404
413
|
const e = doc.errors[0];
|
|
405
|
-
throw fail3("YAML
|
|
414
|
+
throw fail3("invalid YAML. Check the indentation and the colons.", lineAt(e.pos[0]));
|
|
406
415
|
}
|
|
407
416
|
const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
|
|
408
417
|
if (!isMap(tunnels) || tunnels.items.length === 0) {
|
|
409
|
-
throw fail3('declare
|
|
418
|
+
throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
|
|
410
419
|
}
|
|
411
420
|
const specs = [];
|
|
412
421
|
for (const pair of tunnels.items) {
|
|
@@ -416,40 +425,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
416
425
|
const v = validateName(name);
|
|
417
426
|
if (!v.ok) {
|
|
418
427
|
throw fail3(
|
|
419
|
-
v.reason === "reserved" ? `
|
|
428
|
+
v.reason === "reserved" ? `"${raw}" is reserved. Pick another name.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
|
|
420
429
|
line
|
|
421
430
|
);
|
|
422
431
|
}
|
|
423
432
|
const port = portOf(pair.value);
|
|
424
|
-
if (port === null) throw fail3(`
|
|
433
|
+
if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
|
|
425
434
|
const spec = { name, port };
|
|
426
435
|
if (isMap(pair.value)) {
|
|
427
436
|
const body = pair.value;
|
|
428
437
|
for (const item of body.items) {
|
|
429
438
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
430
|
-
if (!TUNNEL_KEYS.includes(key)) throw fail3(`
|
|
439
|
+
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
|
|
431
440
|
}
|
|
432
441
|
const hostNode = body.get("host", true);
|
|
433
442
|
if (hostNode !== void 0) {
|
|
434
443
|
const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
|
|
435
|
-
if (!host) throw fail3(`host
|
|
444
|
+
if (!host) throw fail3(`invalid host for "${raw}": use only the domain, like myapp.test`, line);
|
|
436
445
|
spec.host = host;
|
|
437
446
|
}
|
|
438
447
|
const headersNode = body.get("headers", true);
|
|
439
448
|
if (headersNode !== void 0) {
|
|
440
|
-
if (!isMap(headersNode)) throw fail3(`headers
|
|
449
|
+
if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
|
|
441
450
|
const headers = {};
|
|
442
451
|
for (const h of headersNode.items) {
|
|
443
452
|
const hName = isScalar(h.key) ? String(h.key.value) : "";
|
|
444
453
|
const hLine = lineAt(h.key?.range?.[0]);
|
|
445
|
-
if (!HEADER_NAME_RE.test(hName)) throw fail3(`
|
|
446
|
-
if (hName.toLowerCase() === "host") throw fail3(`
|
|
454
|
+
if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
455
|
+
if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
|
|
447
456
|
const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
|
|
448
457
|
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
449
|
-
throw fail3(`
|
|
458
|
+
throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
450
459
|
}
|
|
451
460
|
const str = value === null ? "" : String(value);
|
|
452
|
-
if (/[\r\n]/.test(str)) throw fail3(`
|
|
461
|
+
if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
453
462
|
headers[hName.toLowerCase()] = str;
|
|
454
463
|
}
|
|
455
464
|
spec.headers = headers;
|
|
@@ -457,50 +466,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
457
466
|
const rewriteNode = body.get("rewrite_redirects", true);
|
|
458
467
|
if (rewriteNode !== void 0) {
|
|
459
468
|
const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
|
|
460
|
-
if (typeof value !== "boolean") throw fail3(`rewrite_redirects
|
|
461
|
-
if (!spec.host) throw fail3(`rewrite_redirects
|
|
469
|
+
if (typeof value !== "boolean") throw fail3(`rewrite_redirects in "${raw}" must be true or false`, line);
|
|
470
|
+
if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
|
|
462
471
|
if (value === false) spec.rewriteRedirects = false;
|
|
463
472
|
}
|
|
464
473
|
const authNode = body.get("auth", true);
|
|
465
474
|
if (authNode !== void 0) {
|
|
466
|
-
if (!isSeq(authNode)) throw fail3(`auth
|
|
475
|
+
if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
|
|
467
476
|
const auth = [];
|
|
468
477
|
for (const item of authNode.items) {
|
|
469
478
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
470
479
|
const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
|
|
471
|
-
if (text2 === null) throw fail3(`
|
|
480
|
+
if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
472
481
|
const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
|
|
473
482
|
const val = env[name2];
|
|
474
|
-
if (val === void 0) throw fail3(`
|
|
483
|
+
if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
|
|
475
484
|
return val;
|
|
476
485
|
});
|
|
477
486
|
const parsed = parseCredential(expanded);
|
|
478
487
|
if (!parsed.ok) {
|
|
479
|
-
if (parsed.reason === "format") throw fail3(`
|
|
480
|
-
if (parsed.reason === "user") throw fail3(`
|
|
481
|
-
throw fail3(`
|
|
488
|
+
if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
489
|
+
if (parsed.reason === "user") throw fail3(`invalid user in "${raw}": 1 to 128 characters, no spaces`, iLine);
|
|
490
|
+
throw fail3(`password for "${expanded.slice(0, expanded.indexOf(":"))}" in "${raw}" must be 8 to 128 characters`, iLine);
|
|
482
491
|
}
|
|
483
|
-
if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`
|
|
492
|
+
if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`user "${parsed.cred.user}" appears twice in "${raw}"`, iLine);
|
|
484
493
|
auth.push(parsed.cred);
|
|
485
494
|
}
|
|
486
|
-
if (!auth.length) throw fail3(`auth
|
|
495
|
+
if (!auth.length) throw fail3(`auth in "${raw}" cannot be empty`, line);
|
|
487
496
|
spec.auth = auth;
|
|
488
497
|
}
|
|
489
|
-
const allowNode = body.get("
|
|
498
|
+
const allowNode = body.get("allow", true);
|
|
490
499
|
if (allowNode !== void 0) {
|
|
491
|
-
if (!isSeq(allowNode)) throw fail3(`
|
|
492
|
-
if (!spec.auth) throw fail3(`
|
|
500
|
+
if (!isSeq(allowNode)) throw fail3(`allow in "${raw}" must be a list`, line);
|
|
501
|
+
if (!spec.auth) throw fail3(`allow in "${raw}" only works together with auth:`, line);
|
|
493
502
|
const allow = [];
|
|
494
503
|
for (const item of allowNode.items) {
|
|
495
504
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
496
505
|
const value = isScalar(item) ? String(item.value) : "";
|
|
497
|
-
if (!parseAllowEntry(value)) throw fail3(`
|
|
506
|
+
if (!parseAllowEntry(value)) throw fail3(`allow in "${raw}": "${value}" is not a known name (stripe, github) or an IP range`, iLine);
|
|
498
507
|
allow.push(value);
|
|
499
508
|
}
|
|
500
509
|
if (allow.length) spec.allow = allow;
|
|
501
510
|
}
|
|
502
511
|
}
|
|
503
|
-
if (specs.some((s) => s.name === name)) throw fail3(`
|
|
512
|
+
if (specs.some((s) => s.name === name)) throw fail3(`the name "${raw}" appears twice`, line);
|
|
504
513
|
specs.push(spec);
|
|
505
514
|
}
|
|
506
515
|
return specs;
|
|
@@ -509,7 +518,7 @@ function findConfig(cwd) {
|
|
|
509
518
|
let dir = cwd;
|
|
510
519
|
for (; ; ) {
|
|
511
520
|
const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
|
|
512
|
-
if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join("
|
|
521
|
+
if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
|
|
513
522
|
if (present.length === 1) {
|
|
514
523
|
const path = join4(dir, present[0]);
|
|
515
524
|
return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
|
|
@@ -521,7 +530,96 @@ function findConfig(cwd) {
|
|
|
521
530
|
}
|
|
522
531
|
}
|
|
523
532
|
|
|
533
|
+
// src/login.ts
|
|
534
|
+
import { spawn } from "child_process";
|
|
535
|
+
import { hostname } from "os";
|
|
536
|
+
var SLOW_DOWN_MS = 5e3;
|
|
537
|
+
var MAX_NETWORK_ERRORS_IN_A_ROW = 3;
|
|
538
|
+
async function browserLogin(o) {
|
|
539
|
+
const api = createApiClient({ baseUrl: o.baseUrl, fetchImpl: o.fetchImpl });
|
|
540
|
+
const start = await api.startLogin({
|
|
541
|
+
deviceId: o.deviceId,
|
|
542
|
+
deviceName: hostname(),
|
|
543
|
+
cliVersion: "1.1.0",
|
|
544
|
+
nodeVersion: process.version,
|
|
545
|
+
os: process.platform,
|
|
546
|
+
arch: process.arch
|
|
547
|
+
});
|
|
548
|
+
o.out(` Visit ${start.verificationUrl}`);
|
|
549
|
+
tryOpen(o.open, start.verificationUrl);
|
|
550
|
+
let waitMs = start.interval * 1e3;
|
|
551
|
+
let networkErrors = 0;
|
|
552
|
+
for (; ; ) {
|
|
553
|
+
await o.sleep(waitMs);
|
|
554
|
+
let poll;
|
|
555
|
+
try {
|
|
556
|
+
poll = await api.pollLogin(start.deviceCode);
|
|
557
|
+
networkErrors = 0;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
const isNetwork = err instanceof DutoApiError && err.code === "network";
|
|
560
|
+
if (!isNetwork || ++networkErrors > MAX_NETWORK_ERRORS_IN_A_ROW) throw err;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (poll.status === "approved") return { token: poll.token, username: poll.username };
|
|
564
|
+
if (poll.status === "expired") throw new DutoApiError(MESSAGES.loginExpired, 410, "expired");
|
|
565
|
+
if (poll.status === "slow_down") waitMs += SLOW_DOWN_MS;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function tryOpen(open, url) {
|
|
569
|
+
try {
|
|
570
|
+
open(url);
|
|
571
|
+
} catch {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function openCommand(url) {
|
|
576
|
+
if (process.platform === "darwin") return { command: "open", args: [url] };
|
|
577
|
+
if (process.platform === "win32") return { command: "cmd", args: ["/c", "start", '""', url] };
|
|
578
|
+
return { command: "xdg-open", args: [url] };
|
|
579
|
+
}
|
|
580
|
+
function loggedInLine(username) {
|
|
581
|
+
return ok(`Logged in as ${username}.`);
|
|
582
|
+
}
|
|
583
|
+
function loginSuccessLines(username) {
|
|
584
|
+
return [
|
|
585
|
+
loggedInLine(username),
|
|
586
|
+
"",
|
|
587
|
+
"To open a tunnel, run `duto 3000`, or `duto` in a folder with a duto.yml."
|
|
588
|
+
];
|
|
589
|
+
}
|
|
590
|
+
function openBrowser(url) {
|
|
591
|
+
try {
|
|
592
|
+
const { command, args } = openCommand(url);
|
|
593
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
594
|
+
child.on("error", () => {
|
|
595
|
+
});
|
|
596
|
+
child.unref();
|
|
597
|
+
} catch {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
602
|
+
function startSpinner(label, stream = process.stderr) {
|
|
603
|
+
if (!stream.isTTY) {
|
|
604
|
+
stream.write(`${label}
|
|
605
|
+
`);
|
|
606
|
+
return () => {
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
let frame = 0;
|
|
610
|
+
const draw = () => stream.write(`\r${FRAMES[frame++ % FRAMES.length]} ${label}`);
|
|
611
|
+
draw();
|
|
612
|
+
const timer = setInterval(draw, 80);
|
|
613
|
+
return () => {
|
|
614
|
+
clearInterval(timer);
|
|
615
|
+
stream.write("\r\x1B[K");
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
524
619
|
// src/inspector/jsonView.ts
|
|
620
|
+
function countLabel(n, one, many) {
|
|
621
|
+
return n + " " + (n === 1 ? one : many);
|
|
622
|
+
}
|
|
525
623
|
function jsonSyntaxError(text) {
|
|
526
624
|
try {
|
|
527
625
|
JSON.parse(text);
|
|
@@ -697,14 +795,15 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
|
697
795
|
}
|
|
698
796
|
`;
|
|
699
797
|
var SCRIPT = String.raw`"use strict";
|
|
798
|
+
var countLabel = (${countLabel.toString()});
|
|
700
799
|
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
701
800
|
var jsonTokens = (${jsonTokens.toString()});
|
|
702
801
|
var jsonIndent = (${jsonIndent.toString()});
|
|
703
802
|
var COLORS = ${JSON.stringify(C)};
|
|
704
803
|
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
705
|
-
var TABS = ["
|
|
804
|
+
var TABS = ["Body", "Headers", "Response", "Raw"];
|
|
706
805
|
|
|
707
|
-
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "
|
|
806
|
+
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
|
|
708
807
|
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
709
808
|
var es = null, lost = false, replaying = false;
|
|
710
809
|
function fail(e) { if (!state.ended) console.error(e); }
|
|
@@ -725,12 +824,12 @@ function banner(text) {
|
|
|
725
824
|
}
|
|
726
825
|
function ended() {
|
|
727
826
|
state.ended = true;
|
|
728
|
-
banner("
|
|
827
|
+
banner("Session ended. Press i in duto to open the inspector again.");
|
|
729
828
|
if (es) es.close();
|
|
730
829
|
}
|
|
731
830
|
|
|
732
831
|
function api(path, method) {
|
|
733
|
-
return fetch(path, { method: method || "GET", headers: { "X-Duto-
|
|
832
|
+
return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
|
|
734
833
|
if (r.status === 403) { ended(); throw new Error("403"); }
|
|
735
834
|
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
|
736
835
|
});
|
|
@@ -742,22 +841,20 @@ function statusInfo(it) {
|
|
|
742
841
|
var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
|
|
743
842
|
return { text: String(s), color: color };
|
|
744
843
|
}
|
|
745
|
-
if (it.error) return { text: "
|
|
844
|
+
if (it.error) return { text: "error", color: COLORS.err };
|
|
746
845
|
return { text: "…", color: COLORS.muted };
|
|
747
846
|
}
|
|
748
847
|
function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
|
|
749
848
|
function size(n) {
|
|
750
849
|
if (n < 1024) return n + " B";
|
|
751
|
-
if (n < 1024 * 1024) return (n / 1024).toFixed(1)
|
|
752
|
-
return (n / 1024 / 1024).toFixed(1)
|
|
850
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
851
|
+
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
753
852
|
}
|
|
754
853
|
function methodOf(it) { return it.websocket ? "WS" : it.method; }
|
|
755
854
|
function header(h, name) {
|
|
756
855
|
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
757
856
|
return "";
|
|
758
857
|
}
|
|
759
|
-
|
|
760
|
-
// Mesma regra do filtro da tela do terminal.
|
|
761
858
|
function visible() {
|
|
762
859
|
var f = $("filter").value.trim().toLowerCase();
|
|
763
860
|
if (!f) return state.items;
|
|
@@ -768,13 +865,13 @@ function visible() {
|
|
|
768
865
|
|
|
769
866
|
function renderTop() {
|
|
770
867
|
var n = state.items.length;
|
|
771
|
-
$("count").textContent =
|
|
868
|
+
$("count").textContent = countLabel(n, "request", "requests");
|
|
772
869
|
var box = $("tunnels");
|
|
773
870
|
clear(box);
|
|
774
871
|
state.tunnels.forEach(function (t) {
|
|
775
872
|
var s = el("span");
|
|
776
873
|
s.appendChild(el("span", "k", t.key));
|
|
777
|
-
s.appendChild(document.createTextNode(" " + (t.url || "
|
|
874
|
+
s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
|
|
778
875
|
box.appendChild(s);
|
|
779
876
|
});
|
|
780
877
|
}
|
|
@@ -786,7 +883,7 @@ function renderList() {
|
|
|
786
883
|
clear(list);
|
|
787
884
|
var items = visible();
|
|
788
885
|
if (items.length === 0) {
|
|
789
|
-
list.appendChild(el("div", "empty", state.items.length === 0 ? "
|
|
886
|
+
list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
|
|
790
887
|
return;
|
|
791
888
|
}
|
|
792
889
|
items.forEach(function (it) {
|
|
@@ -812,7 +909,7 @@ function renderList() {
|
|
|
812
909
|
|
|
813
910
|
function loadList() {
|
|
814
911
|
var seq = ++listSeq;
|
|
815
|
-
return Promise.all([api("/api/
|
|
912
|
+
return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
|
|
816
913
|
if (seq !== listSeq) return;
|
|
817
914
|
state.items = rs[0].body.items || [];
|
|
818
915
|
state.tunnels = rs[1].body.tunnels || [];
|
|
@@ -837,13 +934,13 @@ function select(id) {
|
|
|
837
934
|
|
|
838
935
|
function loadDetail(id) {
|
|
839
936
|
var seq = ++detailSeq;
|
|
840
|
-
return api("/api/
|
|
937
|
+
return api("/api/requests/" + id).then(function (r) {
|
|
841
938
|
if (seq !== detailSeq || state.selected !== id) return;
|
|
842
939
|
var key = JSON.stringify(r.body);
|
|
843
|
-
if (key === state.detailKey) return;
|
|
940
|
+
if (key === state.detailKey) return;
|
|
844
941
|
state.detailKey = key;
|
|
845
942
|
state.detail = r.status === 200 ? r.body : null;
|
|
846
|
-
if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "
|
|
943
|
+
if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
|
|
847
944
|
else renderDetail();
|
|
848
945
|
}).catch(fail);
|
|
849
946
|
}
|
|
@@ -872,7 +969,7 @@ function renderDetail() {
|
|
|
872
969
|
if (duration(d)) parts.push(duration(d));
|
|
873
970
|
parts.push(size(d.bytes));
|
|
874
971
|
parts.push(d.tunnel);
|
|
875
|
-
if (d.replayOf !== undefined) parts.push("↻
|
|
972
|
+
if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
|
|
876
973
|
head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
|
|
877
974
|
if (d.error) head.appendChild(el("div", "msg err", d.error));
|
|
878
975
|
|
|
@@ -891,12 +988,12 @@ function renderContent() {
|
|
|
891
988
|
var box = $("content");
|
|
892
989
|
var top = box.scrollTop;
|
|
893
990
|
clear(box);
|
|
894
|
-
if (state.tab === "
|
|
895
|
-
else if (state.tab === "
|
|
896
|
-
else if (state.tab === "
|
|
897
|
-
box.appendChild(el("div", "sect", "
|
|
991
|
+
if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
|
|
992
|
+
else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
|
|
993
|
+
else if (state.tab === "Response") {
|
|
994
|
+
box.appendChild(el("div", "sect", "Headers"));
|
|
898
995
|
box.appendChild(table(d.resHeaders));
|
|
899
|
-
box.appendChild(el("div", "sect", "
|
|
996
|
+
box.appendChild(el("div", "sect", "Body"));
|
|
900
997
|
renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
|
|
901
998
|
} else {
|
|
902
999
|
var lines = [d.method + " " + d.path + " HTTP/1.1"];
|
|
@@ -910,7 +1007,7 @@ function renderContent() {
|
|
|
910
1007
|
|
|
911
1008
|
function table(h) {
|
|
912
1009
|
var keys = Object.keys(h || {});
|
|
913
|
-
if (keys.length === 0) return el("div", "note", "
|
|
1010
|
+
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
914
1011
|
var t = el("table");
|
|
915
1012
|
keys.forEach(function (k) {
|
|
916
1013
|
var tr = el("tr");
|
|
@@ -927,7 +1024,7 @@ function renderBody(box, body, note, truncNote, headers) {
|
|
|
927
1024
|
return;
|
|
928
1025
|
}
|
|
929
1026
|
if (body === null || body === undefined) {
|
|
930
|
-
box.appendChild(el("div", "note", "
|
|
1027
|
+
box.appendChild(el("div", "note", "No body."));
|
|
931
1028
|
} else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
|
|
932
1029
|
var err = jsonSyntaxError(body);
|
|
933
1030
|
if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
|
|
@@ -947,8 +1044,6 @@ function renderBody(box, body, note, truncNote, headers) {
|
|
|
947
1044
|
}
|
|
948
1045
|
|
|
949
1046
|
var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
|
|
950
|
-
|
|
951
|
-
// JSON colorido; clicar no { ou [ recolhe para "{ ▸ N campos }".
|
|
952
1047
|
function jsonTree(text) {
|
|
953
1048
|
var pre = el("pre");
|
|
954
1049
|
var stack = [{ node: pre }];
|
|
@@ -973,8 +1068,8 @@ function jsonTree(text) {
|
|
|
973
1068
|
var n = c.has ? c.count + 1 : 0;
|
|
974
1069
|
if (n > 0) {
|
|
975
1070
|
c.wrap.dataset.n = String(n);
|
|
976
|
-
c.open.title = "
|
|
977
|
-
c.sum.textContent = " ▸ " +
|
|
1071
|
+
c.open.title = "collapse or expand";
|
|
1072
|
+
c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
|
|
978
1073
|
} else c.open.className = "";
|
|
979
1074
|
c.wrap.appendChild(document.createTextNode(t.text));
|
|
980
1075
|
return;
|
|
@@ -1006,13 +1101,11 @@ function renderButtons() {
|
|
|
1006
1101
|
var notes = [];
|
|
1007
1102
|
if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
|
|
1008
1103
|
var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
|
|
1009
|
-
if (d.curl !== null && tunnel && tunnel.auth) notes.push("
|
|
1104
|
+
if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
|
|
1010
1105
|
$("curlNote").textContent = notes.join(" ");
|
|
1011
1106
|
}
|
|
1012
|
-
|
|
1013
|
-
// "Copiar corpo" copia o corpo da aba ativa: a resposta na aba Resposta, senão a requisição.
|
|
1014
1107
|
function copyable(d) {
|
|
1015
|
-
return state.tab === "
|
|
1108
|
+
return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
|
|
1016
1109
|
}
|
|
1017
1110
|
|
|
1018
1111
|
function showMsg(text, isErr) {
|
|
@@ -1031,10 +1124,10 @@ function hideMsg() {
|
|
|
1031
1124
|
|
|
1032
1125
|
function copy(text) {
|
|
1033
1126
|
navigator.clipboard.writeText(text).then(function () {
|
|
1034
|
-
showMsg("
|
|
1127
|
+
showMsg("Copied.", false);
|
|
1035
1128
|
msgTimer = setTimeout(hideMsg, 2000);
|
|
1036
1129
|
}, function () {
|
|
1037
|
-
showMsg("
|
|
1130
|
+
showMsg("Could not copy.", true);
|
|
1038
1131
|
});
|
|
1039
1132
|
}
|
|
1040
1133
|
|
|
@@ -1045,14 +1138,14 @@ function replay() {
|
|
|
1045
1138
|
if (replaying) return;
|
|
1046
1139
|
replaying = true;
|
|
1047
1140
|
$("replay").disabled = true;
|
|
1048
|
-
api("/api/
|
|
1141
|
+
api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
|
|
1049
1142
|
replaying = false;
|
|
1050
1143
|
if (state.detail) renderButtons();
|
|
1051
1144
|
if (r.body && r.body.ok) {
|
|
1052
1145
|
hideMsg();
|
|
1053
1146
|
return loadList().then(function () { return select(r.body.id); });
|
|
1054
1147
|
}
|
|
1055
|
-
showMsg(r.body && r.body.reason ? r.body.reason : "
|
|
1148
|
+
showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
|
|
1056
1149
|
}).catch(function (e) {
|
|
1057
1150
|
replaying = false;
|
|
1058
1151
|
if (state.detail) renderButtons();
|
|
@@ -1071,11 +1164,10 @@ function move(delta) {
|
|
|
1071
1164
|
|
|
1072
1165
|
function connect() {
|
|
1073
1166
|
if (state.ended) return;
|
|
1074
|
-
es = new EventSource("/api/
|
|
1167
|
+
es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
|
|
1075
1168
|
es.addEventListener("open", function () {
|
|
1076
1169
|
if (state.ended) return;
|
|
1077
1170
|
banner("");
|
|
1078
|
-
// Reconectou: o que mudou no meio do caminho não chegou por evento.
|
|
1079
1171
|
if (lost) {
|
|
1080
1172
|
lost = false;
|
|
1081
1173
|
loadList();
|
|
@@ -1085,22 +1177,18 @@ function connect() {
|
|
|
1085
1177
|
es.addEventListener("error", function () {
|
|
1086
1178
|
if (state.ended) return;
|
|
1087
1179
|
lost = true;
|
|
1088
|
-
banner("
|
|
1089
|
-
// Resposta não 200 (ex.: 403 de outra sessão) fecha o EventSource de vez.
|
|
1180
|
+
banner("Disconnected from the session.");
|
|
1090
1181
|
if (es.readyState === 2) probe();
|
|
1091
1182
|
});
|
|
1092
|
-
es.addEventListener("
|
|
1183
|
+
es.addEventListener("changed", function (ev) {
|
|
1093
1184
|
var ids = [];
|
|
1094
1185
|
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1095
1186
|
loadList();
|
|
1096
|
-
// Lista vazia de ids: algo mudou fora da assinatura; busca o detalhe de novo também.
|
|
1097
1187
|
if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
|
|
1098
1188
|
});
|
|
1099
1189
|
}
|
|
1100
|
-
|
|
1101
|
-
// EventSource fechado: a API diz se a sessão acabou (403 → ended) ou se dá para reconectar.
|
|
1102
1190
|
function probe() {
|
|
1103
|
-
api("/api/
|
|
1191
|
+
api("/api/session").then(function () {
|
|
1104
1192
|
if (!state.ended) setTimeout(connect, 3000);
|
|
1105
1193
|
}, function () {
|
|
1106
1194
|
if (!state.ended) setTimeout(connect, 3000);
|
|
@@ -1150,35 +1238,35 @@ function init() {
|
|
|
1150
1238
|
init();
|
|
1151
1239
|
`;
|
|
1152
1240
|
function inspectorPage(nonce) {
|
|
1153
|
-
const tabs = ["
|
|
1241
|
+
const tabs = ["Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
|
|
1154
1242
|
return `<!doctype html>
|
|
1155
|
-
<html lang="
|
|
1243
|
+
<html lang="en">
|
|
1156
1244
|
<head>
|
|
1157
1245
|
<meta charset="utf-8">
|
|
1158
1246
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1159
|
-
<title>duto \xB7
|
|
1247
|
+
<title>duto \xB7 inspector</title>
|
|
1160
1248
|
<link rel="icon" href="data:,">
|
|
1161
1249
|
<style nonce="${nonce}">${STYLE}</style>
|
|
1162
1250
|
</head>
|
|
1163
1251
|
<body>
|
|
1164
1252
|
<div class="app">
|
|
1165
|
-
<div class="top"><span><b>\u25B0 duto</b> \xB7
|
|
1253
|
+
<div class="top"><span><b>\u25B0 duto</b> \xB7 inspector \xB7 <span id="count">0 requests</span></span><span class="tunnels" id="tunnels"></span></div>
|
|
1166
1254
|
<div class="banner" id="banner" role="status" hidden></div>
|
|
1167
1255
|
<div class="main">
|
|
1168
1256
|
<div class="side">
|
|
1169
|
-
<input class="search" id="filter" type="search" placeholder="
|
|
1170
|
-
<div class="list" id="list" role="listbox" aria-label="
|
|
1257
|
+
<input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1258
|
+
<div class="list" id="list" role="listbox" aria-label="Requests"></div>
|
|
1171
1259
|
</div>
|
|
1172
1260
|
<div class="detail">
|
|
1173
|
-
<div class="empty" id="none">
|
|
1261
|
+
<div class="empty" id="none">Select a request from the list.</div>
|
|
1174
1262
|
<div class="detail pane" id="detail" hidden>
|
|
1175
1263
|
<div class="head" id="head"></div>
|
|
1176
1264
|
<div class="tabs" role="tablist">${tabs}</div>
|
|
1177
1265
|
<div class="content" id="content"></div>
|
|
1178
1266
|
<div class="btns">
|
|
1179
|
-
<span><button type="button" class="btn pri" id="replay">\u21BB
|
|
1180
|
-
<span><button type="button" class="btn" id="copyBody">
|
|
1181
|
-
<span><button type="button" class="btn" id="copyCurl">
|
|
1267
|
+
<span><button type="button" class="btn pri" id="replay">\u21BB Replay</button></span>
|
|
1268
|
+
<span><button type="button" class="btn" id="copyBody">Copy body</button></span>
|
|
1269
|
+
<span><button type="button" class="btn" id="copyCurl">Copy as curl</button></span>
|
|
1182
1270
|
<span class="msg" id="curlNote"></span>
|
|
1183
1271
|
<span class="msg" id="msg" role="status" hidden></span>
|
|
1184
1272
|
</div>
|
|
@@ -1197,21 +1285,21 @@ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto"
|
|
|
1197
1285
|
import { createServer } from "http";
|
|
1198
1286
|
|
|
1199
1287
|
// src/session/replay.ts
|
|
1200
|
-
var tunnelClosedReason = (tunnel2) => `
|
|
1288
|
+
var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
|
|
1201
1289
|
function replayBlock(r) {
|
|
1202
|
-
if (!r) return "
|
|
1203
|
-
if (r.websocket) return "WebSocket
|
|
1290
|
+
if (!r) return "That request is no longer in the list.";
|
|
1291
|
+
if (r.websocket) return "WebSocket requests cannot be replayed.";
|
|
1204
1292
|
if (r.noReplay) return r.noReplay;
|
|
1205
|
-
if (r.access?.kind === "denied") return "
|
|
1206
|
-
if (!r.reqComplete) return "
|
|
1207
|
-
if (r.reqTruncated) return "
|
|
1208
|
-
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "
|
|
1293
|
+
if (r.access?.kind === "denied") return "Blocked by the tunnel password; cannot replay.";
|
|
1294
|
+
if (!r.reqComplete) return "The request has not fully arrived yet.";
|
|
1295
|
+
if (r.reqTruncated) return "Body over 64 KB; cannot replay.";
|
|
1296
|
+
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
|
|
1209
1297
|
return null;
|
|
1210
1298
|
}
|
|
1211
|
-
var
|
|
1299
|
+
var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1212
1300
|
function replayHeaders(r) {
|
|
1213
1301
|
const out = {};
|
|
1214
|
-
for (const [k, v] of Object.entries(r.reqHeaders)) if (!
|
|
1302
|
+
for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
1215
1303
|
const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
|
|
1216
1304
|
if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
|
|
1217
1305
|
return out;
|
|
@@ -1232,7 +1320,7 @@ function toCurl(r, o) {
|
|
|
1232
1320
|
for (const [k, v] of Object.entries(r.reqHeaders)) {
|
|
1233
1321
|
if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
|
|
1234
1322
|
}
|
|
1235
|
-
if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:
|
|
1323
|
+
if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
|
|
1236
1324
|
if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
|
|
1237
1325
|
return parts.join(" \\\n ");
|
|
1238
1326
|
}
|
|
@@ -1240,12 +1328,12 @@ function toCurl(r, o) {
|
|
|
1240
1328
|
// src/inspector/server.ts
|
|
1241
1329
|
var DEFAULT_PORT = 4141;
|
|
1242
1330
|
var PORT_TRIES = 20;
|
|
1243
|
-
var NO_TOKEN = "
|
|
1244
|
-
var GONE = "
|
|
1245
|
-
var NO_URL = "
|
|
1246
|
-
var CURL_BYTES = "
|
|
1331
|
+
var NO_TOKEN = "Open the inspector with the i key in the duto screen, or with the address printed in the terminal.";
|
|
1332
|
+
var GONE = "That request is no longer in the list.";
|
|
1333
|
+
var NO_URL = "The tunnel has no public URL yet.";
|
|
1334
|
+
var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 bytes).";
|
|
1247
1335
|
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1248
|
-
var
|
|
1336
|
+
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1249
1337
|
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1250
1338
|
function listItem(r) {
|
|
1251
1339
|
return {
|
|
@@ -1308,7 +1396,7 @@ async function startInspector(o) {
|
|
|
1308
1396
|
if (seen.get(r.id) !== s) ids.push(r.id);
|
|
1309
1397
|
}
|
|
1310
1398
|
seen = next;
|
|
1311
|
-
const msg = `event:
|
|
1399
|
+
const msg = `event: changed
|
|
1312
1400
|
data: ${JSON.stringify({ ids })}
|
|
1313
1401
|
|
|
1314
1402
|
`;
|
|
@@ -1339,19 +1427,19 @@ data: ${JSON.stringify({ ids })}
|
|
|
1339
1427
|
const handle = (req, res) => {
|
|
1340
1428
|
base(res);
|
|
1341
1429
|
const host = first(req.headers.host)?.toLowerCase();
|
|
1342
|
-
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host
|
|
1430
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
|
|
1343
1431
|
let url2;
|
|
1344
1432
|
try {
|
|
1345
1433
|
url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1346
1434
|
} catch {
|
|
1347
|
-
return text(res, 404, "
|
|
1435
|
+
return text(res, 404, "Not found.");
|
|
1348
1436
|
}
|
|
1349
1437
|
const path = url2.pathname;
|
|
1350
1438
|
const method = req.method ?? "GET";
|
|
1351
1439
|
const queryToken = url2.searchParams.get("t") ?? "";
|
|
1352
|
-
const headerToken = first(req.headers["x-duto-
|
|
1440
|
+
const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
|
|
1353
1441
|
if (method === "GET" && path === "/") {
|
|
1354
|
-
if (!
|
|
1442
|
+
if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
|
|
1355
1443
|
const nonce = randomBytes(16).toString("base64url");
|
|
1356
1444
|
res.writeHead(200, {
|
|
1357
1445
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -1359,11 +1447,11 @@ data: ${JSON.stringify({ ids })}
|
|
|
1359
1447
|
});
|
|
1360
1448
|
return void res.end(o.page(nonce));
|
|
1361
1449
|
}
|
|
1362
|
-
const isEvents = method === "GET" && path === "/api/
|
|
1363
|
-
const detailId = /^\/api\/
|
|
1364
|
-
const known = method === "GET" && (path === "/api/
|
|
1365
|
-
if (!known) return text(res, 404, "
|
|
1366
|
-
if (!
|
|
1450
|
+
const isEvents = method === "GET" && path === "/api/events";
|
|
1451
|
+
const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
|
|
1452
|
+
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
1453
|
+
if (!known) return text(res, 404, "Not found.");
|
|
1454
|
+
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
1367
1455
|
if (isEvents) {
|
|
1368
1456
|
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
|
|
1369
1457
|
res.write(": duto\n\n");
|
|
@@ -1372,10 +1460,10 @@ data: ${JSON.stringify({ ids })}
|
|
|
1372
1460
|
res.on("close", () => clients.delete(res));
|
|
1373
1461
|
return;
|
|
1374
1462
|
}
|
|
1375
|
-
if (path === "/api/
|
|
1463
|
+
if (path === "/api/session") {
|
|
1376
1464
|
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
|
|
1377
1465
|
}
|
|
1378
|
-
if (path === "/api/
|
|
1466
|
+
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1379
1467
|
const id = Number(detailId[1]);
|
|
1380
1468
|
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1381
1469
|
if (method === "POST") return json(res, 200, session.replay(id));
|
|
@@ -1387,7 +1475,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1387
1475
|
try {
|
|
1388
1476
|
handle(req, res);
|
|
1389
1477
|
} catch {
|
|
1390
|
-
if (!res.headersSent) text(res, 500, "
|
|
1478
|
+
if (!res.headersSent) text(res, 500, "Internal error.");
|
|
1391
1479
|
else res.end();
|
|
1392
1480
|
}
|
|
1393
1481
|
});
|
|
@@ -1437,7 +1525,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1437
1525
|
}
|
|
1438
1526
|
|
|
1439
1527
|
// src/session/session.ts
|
|
1440
|
-
import { spawn } from "child_process";
|
|
1528
|
+
import { spawn as spawn2 } from "child_process";
|
|
1441
1529
|
|
|
1442
1530
|
// src/portcheck.ts
|
|
1443
1531
|
import { connect } from "net";
|
|
@@ -1460,10 +1548,11 @@ async function isPortListening(port, timeoutMs = 500) {
|
|
|
1460
1548
|
|
|
1461
1549
|
// src/session/auth.ts
|
|
1462
1550
|
import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1463
|
-
var TUNNEL_COOKIE = "__Host-
|
|
1551
|
+
var TUNNEL_COOKIE = "__Host-duto_tunnel";
|
|
1552
|
+
var SEC_FETCH_SITE_SAME_SITE = "same-site";
|
|
1464
1553
|
var QR_TTL_MS = 5 * 6e4;
|
|
1465
1554
|
var sha2 = (s) => createHash3("sha256").update(s).digest();
|
|
1466
|
-
var
|
|
1555
|
+
var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
|
|
1467
1556
|
function splitPair(raw) {
|
|
1468
1557
|
const i = raw.indexOf(":");
|
|
1469
1558
|
return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
|
|
@@ -1475,7 +1564,7 @@ function parseBasic(value) {
|
|
|
1475
1564
|
var first2 = (v) => Array.isArray(v) ? v[0] : v;
|
|
1476
1565
|
var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
|
|
1477
1566
|
function sameHostOrigin(h) {
|
|
1478
|
-
if (first2(h["sec-fetch-site"])?.toLowerCase() ===
|
|
1567
|
+
if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
|
|
1479
1568
|
const origin = first2(h.origin);
|
|
1480
1569
|
if (origin === void 0) return true;
|
|
1481
1570
|
let host;
|
|
@@ -1510,16 +1599,16 @@ var TunnelAuth = class {
|
|
|
1510
1599
|
}
|
|
1511
1600
|
const cookieUser = this.cookieUser(h.cookie);
|
|
1512
1601
|
if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
|
|
1513
|
-
const
|
|
1514
|
-
const
|
|
1515
|
-
if (
|
|
1602
|
+
const dutoAuthHeader = first2(h["x-duto-auth"]);
|
|
1603
|
+
const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
|
|
1604
|
+
if (candidate && this.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
|
|
1516
1605
|
return { kind: "denied" };
|
|
1517
1606
|
}
|
|
1518
1607
|
checkPassword(user, password) {
|
|
1519
1608
|
let ok2 = false;
|
|
1520
1609
|
for (const c of this.credentials) {
|
|
1521
|
-
const userOk =
|
|
1522
|
-
const passOk =
|
|
1610
|
+
const userOk = constantTimeEqual2(c.user, user);
|
|
1611
|
+
const passOk = constantTimeEqual2(c.password, password);
|
|
1523
1612
|
if (userOk && passOk) ok2 = true;
|
|
1524
1613
|
}
|
|
1525
1614
|
return ok2;
|
|
@@ -1536,7 +1625,7 @@ var TunnelAuth = class {
|
|
|
1536
1625
|
}
|
|
1537
1626
|
consumeQrToken(token) {
|
|
1538
1627
|
const qr = this.qr;
|
|
1539
|
-
if (!qr || !
|
|
1628
|
+
if (!qr || !constantTimeEqual2(qr.token, token)) return false;
|
|
1540
1629
|
this.qr = null;
|
|
1541
1630
|
return this.now() <= qr.expiresAt;
|
|
1542
1631
|
}
|
|
@@ -1554,7 +1643,6 @@ var TunnelAuth = class {
|
|
|
1554
1643
|
}
|
|
1555
1644
|
return out;
|
|
1556
1645
|
}
|
|
1557
|
-
// Valor: usuário em base64url + "." + HMAC(túnel, usuário).
|
|
1558
1646
|
cookieValue(user) {
|
|
1559
1647
|
return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
|
|
1560
1648
|
}
|
|
@@ -1567,10 +1655,10 @@ var TunnelAuth = class {
|
|
|
1567
1655
|
for (const p of list) {
|
|
1568
1656
|
const item = p.trim();
|
|
1569
1657
|
if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
|
|
1570
|
-
const [b64, sig2, ...
|
|
1571
|
-
if (!b64 || !sig2 ||
|
|
1658
|
+
const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
|
|
1659
|
+
if (!b64 || !sig2 || extra.length) continue;
|
|
1572
1660
|
const user = Buffer.from(b64, "base64url").toString("utf8");
|
|
1573
|
-
if (
|
|
1661
|
+
if (constantTimeEqual2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
|
|
1574
1662
|
}
|
|
1575
1663
|
return null;
|
|
1576
1664
|
}
|
|
@@ -1622,14 +1710,14 @@ var WAIT_SCRIPT = `
|
|
|
1622
1710
|
}
|
|
1623
1711
|
function again() { delay = Math.min(delay * 2, 30000); poll(); }
|
|
1624
1712
|
if (!window.EventSource) return poll();
|
|
1625
|
-
var fails = 0, es = new EventSource("/__duto/
|
|
1713
|
+
var fails = 0, es = new EventSource("/__duto/wait");
|
|
1626
1714
|
es.addEventListener("up", function () { es.close(); location.reload(); });
|
|
1627
1715
|
es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
|
|
1628
1716
|
})();
|
|
1629
1717
|
</script>`;
|
|
1630
1718
|
function pageShell(title, body, css = "") {
|
|
1631
1719
|
return `<!doctype html>
|
|
1632
|
-
<html lang="
|
|
1720
|
+
<html lang="en">
|
|
1633
1721
|
<head>
|
|
1634
1722
|
<meta charset="utf-8">
|
|
1635
1723
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -1661,13 +1749,13 @@ function unavailablePage(o) {
|
|
|
1661
1749
|
const tunnel2 = esc(o.tunnel);
|
|
1662
1750
|
const local = `localhost:${o.port}`;
|
|
1663
1751
|
return pageShell(
|
|
1664
|
-
`
|
|
1752
|
+
`Nothing responding on ${local} \xB7 duto`,
|
|
1665
1753
|
`<main>
|
|
1666
1754
|
<div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
|
|
1667
|
-
<h1>
|
|
1668
|
-
<p>
|
|
1669
|
-
<p>
|
|
1670
|
-
<div class="retry">${o.retry ? `<b>
|
|
1755
|
+
<h1>Nothing is responding on <code>${local}</code></h1>
|
|
1756
|
+
<p>The tunnel <code>${tunnel2}</code> is live, but your local service did not respond. Start the server on the machine that opened the tunnel.</p>
|
|
1757
|
+
<p>If you were only sent this link, let whoever shared it know.</p>
|
|
1758
|
+
<div class="retry">${o.retry ? `<b>Waiting for the service.</b> This page reloads when it responds.<noscript> Reload once the service is up.</noscript>` : "Reload the page once the service is up."}</div>
|
|
1671
1759
|
</main>${o.retry ? WAIT_SCRIPT : ""}`
|
|
1672
1760
|
);
|
|
1673
1761
|
}
|
|
@@ -1680,35 +1768,35 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
|
|
|
1680
1768
|
.error{margin:14px 0 0;color:#b42318;font-weight:600}
|
|
1681
1769
|
@media (prefers-color-scheme:dark){.error{color:#f97066}}
|
|
1682
1770
|
`;
|
|
1683
|
-
function
|
|
1771
|
+
function safeNext(raw) {
|
|
1684
1772
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
1685
1773
|
return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
|
|
1686
1774
|
}
|
|
1687
1775
|
function loginPage(o) {
|
|
1688
1776
|
return pageShell(
|
|
1689
|
-
"
|
|
1777
|
+
"Sign in \xB7 duto",
|
|
1690
1778
|
`<main>
|
|
1691
1779
|
<div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
|
|
1692
|
-
<h1>
|
|
1693
|
-
<p>
|
|
1694
|
-
<form method="post" action="/__duto/
|
|
1695
|
-
<input type="hidden" name="
|
|
1696
|
-
<label for="
|
|
1697
|
-
<input id="
|
|
1698
|
-
<label for="
|
|
1699
|
-
<input id="
|
|
1700
|
-
${o.error ? `<p class="error" role="alert">
|
|
1701
|
-
` : ""}<button type="submit">
|
|
1780
|
+
<h1>This tunnel is password protected</h1>
|
|
1781
|
+
<p>Ask whoever sent you the link for access.</p>
|
|
1782
|
+
<form method="post" action="/__duto/login">
|
|
1783
|
+
<input type="hidden" name="next" value="${esc(o.next)}">
|
|
1784
|
+
<label for="username">Username</label>
|
|
1785
|
+
<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
|
1786
|
+
<label for="password">Password</label>
|
|
1787
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
1788
|
+
${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
|
|
1789
|
+
` : ""}<button type="submit">Sign in</button>
|
|
1702
1790
|
</form>
|
|
1703
|
-
<div class="retry">
|
|
1791
|
+
<div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
|
|
1704
1792
|
</main>`,
|
|
1705
1793
|
CSS
|
|
1706
1794
|
);
|
|
1707
1795
|
}
|
|
1708
1796
|
|
|
1709
1797
|
// src/session/proxy.ts
|
|
1710
|
-
var DENIED_NO_REPLAY = "
|
|
1711
|
-
var PREFLIGHT_NO_REPLAY = "
|
|
1798
|
+
var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
|
|
1799
|
+
var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
|
|
1712
1800
|
var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
|
|
1713
1801
|
var MAX_BODY = 64 * 1024;
|
|
1714
1802
|
var LOCAL_AGENT = new Agent({ keepAlive: false });
|
|
@@ -1726,7 +1814,6 @@ var Capture = class {
|
|
|
1726
1814
|
max;
|
|
1727
1815
|
chunks = [];
|
|
1728
1816
|
size = 0;
|
|
1729
|
-
/** Bytes vistos, capturados ou não. */
|
|
1730
1817
|
total = 0;
|
|
1731
1818
|
truncated = false;
|
|
1732
1819
|
push(c) {
|
|
@@ -1749,6 +1836,7 @@ var Capture = class {
|
|
|
1749
1836
|
return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
|
|
1750
1837
|
}
|
|
1751
1838
|
};
|
|
1839
|
+
var HOST_CACHE_MS = 5e3;
|
|
1752
1840
|
var hostCache = /* @__PURE__ */ new Map();
|
|
1753
1841
|
function probe(host, port) {
|
|
1754
1842
|
return new Promise((resolve) => {
|
|
@@ -1764,7 +1852,7 @@ function probe(host, port) {
|
|
|
1764
1852
|
}
|
|
1765
1853
|
async function localHost(port) {
|
|
1766
1854
|
const hit = hostCache.get(port);
|
|
1767
|
-
if (hit && Date.now() - hit.at <
|
|
1855
|
+
if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
|
|
1768
1856
|
const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
|
|
1769
1857
|
if (!host) return "127.0.0.1";
|
|
1770
1858
|
hostCache.set(port, { host, at: Date.now() });
|
|
@@ -1793,13 +1881,16 @@ function hostOf(req) {
|
|
|
1793
1881
|
return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
1794
1882
|
}
|
|
1795
1883
|
var DUTO_PREFIX = "/__duto/";
|
|
1796
|
-
var WAIT_PATH = "/__duto/
|
|
1884
|
+
var WAIT_PATH = "/__duto/wait";
|
|
1797
1885
|
var WAIT_MAX_MS = 10 * 6e4;
|
|
1798
|
-
var
|
|
1799
|
-
var
|
|
1886
|
+
var DEFAULT_ROUTE_WAIT_MS = 3e3;
|
|
1887
|
+
var DEFAULT_WAIT_CHECK_MS = 1e3;
|
|
1888
|
+
var SSE_KEEPALIVE_MS = 25e3;
|
|
1889
|
+
var LOGIN_PATH = "/__duto/login";
|
|
1890
|
+
var LOGOUT_PATH = "/__duto/logout";
|
|
1800
1891
|
var QR_PREFIX = "/__duto/qr/";
|
|
1801
1892
|
var MAX_FORM = 8 * 1024;
|
|
1802
|
-
var DENIED_TEXT = "
|
|
1893
|
+
var DENIED_TEXT = "This tunnel is password protected. Send Authorization: Basic or X-Duto-Auth: user:password.";
|
|
1803
1894
|
var WS_DENIED = 'HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="duto"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n';
|
|
1804
1895
|
function readForm(req, max) {
|
|
1805
1896
|
return new Promise((resolve) => {
|
|
@@ -1822,9 +1913,9 @@ function readForm(req, max) {
|
|
|
1822
1913
|
}
|
|
1823
1914
|
async function authRoute(req, res, route, auth) {
|
|
1824
1915
|
const url = new URL(req.url ?? "/", "http://duto");
|
|
1825
|
-
const page = (status,
|
|
1916
|
+
const page = (status, next, error) => {
|
|
1826
1917
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1827
|
-
res.end(loginPage({ tunnel: route.tunnel,
|
|
1918
|
+
res.end(loginPage({ tunnel: route.tunnel, next, error }));
|
|
1828
1919
|
};
|
|
1829
1920
|
const go = (location, cookie) => {
|
|
1830
1921
|
res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
|
|
@@ -1832,21 +1923,21 @@ async function authRoute(req, res, route, auth) {
|
|
|
1832
1923
|
};
|
|
1833
1924
|
const { pathname } = url;
|
|
1834
1925
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
1835
|
-
page(200,
|
|
1926
|
+
page(200, safeNext(url.searchParams.get("next")), false);
|
|
1836
1927
|
return true;
|
|
1837
1928
|
}
|
|
1838
1929
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
1839
1930
|
const raw = await readForm(req, MAX_FORM);
|
|
1840
1931
|
if (raw === null) {
|
|
1841
1932
|
res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
|
|
1842
|
-
res.end("
|
|
1933
|
+
res.end("Form too large");
|
|
1843
1934
|
return true;
|
|
1844
1935
|
}
|
|
1845
1936
|
const form = new URLSearchParams(raw);
|
|
1846
|
-
const user = form.get("
|
|
1847
|
-
const
|
|
1848
|
-
if (auth.checkPassword(user, form.get("
|
|
1849
|
-
else page(401,
|
|
1937
|
+
const user = form.get("username") ?? "";
|
|
1938
|
+
const next = safeNext(form.get("next"));
|
|
1939
|
+
if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
|
|
1940
|
+
else page(401, next, true);
|
|
1850
1941
|
return true;
|
|
1851
1942
|
}
|
|
1852
1943
|
if (pathname === LOGOUT_PATH && req.method === "GET") {
|
|
@@ -1862,7 +1953,7 @@ async function authRoute(req, res, route, auth) {
|
|
|
1862
1953
|
}
|
|
1863
1954
|
function deny(req, res, html) {
|
|
1864
1955
|
const status = html ? 303 : 401;
|
|
1865
|
-
const out = html ? { location: `${LOGIN_PATH}?
|
|
1956
|
+
const out = html ? { location: `${LOGIN_PATH}?next=${encodeURIComponent(req.url ?? "/")}`, "cache-control": "no-store" } : { "www-authenticate": 'Basic realm="duto"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...cors401(req.headers.origin) };
|
|
1866
1957
|
const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
|
|
1867
1958
|
res.writeHead(status, out);
|
|
1868
1959
|
res.end(body);
|
|
@@ -1904,7 +1995,7 @@ function waitForService(res, port, checkMs, pingMs) {
|
|
|
1904
1995
|
async function startProxy(o) {
|
|
1905
1996
|
const max = o.maxBody ?? MAX_BODY;
|
|
1906
1997
|
let nextId = 1;
|
|
1907
|
-
const routeWaitMs = o.routeWaitMs ??
|
|
1998
|
+
const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
|
|
1908
1999
|
async function routeFor(req) {
|
|
1909
2000
|
const host = hostOf(req);
|
|
1910
2001
|
for (let waited = 0; ; waited += 50) {
|
|
@@ -1965,7 +2056,7 @@ async function startProxy(o) {
|
|
|
1965
2056
|
function replay(id) {
|
|
1966
2057
|
const r = o.store.get(id);
|
|
1967
2058
|
const blocked = replayBlock(r);
|
|
1968
|
-
if (blocked || !r) return { ok: false, reason: blocked ?? "
|
|
2059
|
+
if (blocked || !r) return { ok: false, reason: blocked ?? "That request is no longer in the list." };
|
|
1969
2060
|
const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
|
|
1970
2061
|
if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
|
|
1971
2062
|
const newId = nextId++;
|
|
@@ -2006,17 +2097,17 @@ async function startProxy(o) {
|
|
|
2006
2097
|
const route = await routeFor(req);
|
|
2007
2098
|
if (!route) {
|
|
2008
2099
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2009
|
-
res.end("
|
|
2100
|
+
res.end("Tunnel not found in this session");
|
|
2010
2101
|
return;
|
|
2011
2102
|
}
|
|
2012
2103
|
if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
|
|
2013
2104
|
if (req.url === WAIT_PATH && req.method === "GET") {
|
|
2014
2105
|
if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
|
|
2015
|
-
return waitForService(res, route.port, o.waitCheckMs ??
|
|
2106
|
+
return waitForService(res, route.port, o.waitCheckMs ?? DEFAULT_WAIT_CHECK_MS, o.waitPingMs ?? SSE_KEEPALIVE_MS);
|
|
2016
2107
|
}
|
|
2017
2108
|
if (route.auth && await authRoute(req, res, route, route.auth)) return;
|
|
2018
2109
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2019
|
-
res.end("
|
|
2110
|
+
res.end("Reserved Duto route");
|
|
2020
2111
|
return;
|
|
2021
2112
|
}
|
|
2022
2113
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
@@ -2059,7 +2150,7 @@ async function startProxy(o) {
|
|
|
2059
2150
|
req.resume();
|
|
2060
2151
|
if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
|
|
2061
2152
|
const html = wantsHtml(req.headers.accept);
|
|
2062
|
-
const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `
|
|
2153
|
+
const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nothing is listening on localhost:${route.port}. Start the server and try again.`;
|
|
2063
2154
|
if (!res.headersSent) {
|
|
2064
2155
|
res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
|
|
2065
2156
|
res.end(req.method === "HEAD" ? void 0 : msg);
|
|
@@ -2223,7 +2314,6 @@ var RecordStore = class {
|
|
|
2223
2314
|
get(id) {
|
|
2224
2315
|
return this.items.find((x) => x.id === id);
|
|
2225
2316
|
}
|
|
2226
|
-
/** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
2227
2317
|
list() {
|
|
2228
2318
|
this.snapshot ??= [...this.items].reverse();
|
|
2229
2319
|
return this.snapshot;
|
|
@@ -2243,8 +2333,8 @@ var READY = "Registered tunnel connection";
|
|
|
2243
2333
|
var CLOSE_TIMEOUT_MS = 3e3;
|
|
2244
2334
|
var HEARTBEAT_MS = 3e4;
|
|
2245
2335
|
var RESTART_CF_MS = 2e3;
|
|
2246
|
-
var ALREADY_OPEN = "
|
|
2247
|
-
var
|
|
2336
|
+
var ALREADY_OPEN = "Already open in another session on this machine";
|
|
2337
|
+
var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
|
|
2248
2338
|
function message(err) {
|
|
2249
2339
|
return err instanceof Error ? err.message : String(err);
|
|
2250
2340
|
}
|
|
@@ -2252,7 +2342,7 @@ function withTimeout(p, ms) {
|
|
|
2252
2342
|
return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
|
|
2253
2343
|
}
|
|
2254
2344
|
function keyOf(spec) {
|
|
2255
|
-
return spec.name ?? "(
|
|
2345
|
+
return spec.name ?? "(default)";
|
|
2256
2346
|
}
|
|
2257
2347
|
var Session = class {
|
|
2258
2348
|
constructor(o) {
|
|
@@ -2264,8 +2354,7 @@ var Session = class {
|
|
|
2264
2354
|
tunnels = [];
|
|
2265
2355
|
routes = /* @__PURE__ */ new Map();
|
|
2266
2356
|
specs = /* @__PURE__ */ new Map();
|
|
2267
|
-
|
|
2268
|
-
auths = /* @__PURE__ */ new Map();
|
|
2357
|
+
persistentAuths = /* @__PURE__ */ new Map();
|
|
2269
2358
|
proxy = null;
|
|
2270
2359
|
child = null;
|
|
2271
2360
|
token = null;
|
|
@@ -2276,7 +2365,6 @@ var Session = class {
|
|
|
2276
2365
|
connection = "connecting";
|
|
2277
2366
|
listeners = /* @__PURE__ */ new Set();
|
|
2278
2367
|
snapshot = null;
|
|
2279
|
-
/** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
2280
2368
|
getState() {
|
|
2281
2369
|
this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
|
|
2282
2370
|
return this.snapshot;
|
|
@@ -2285,23 +2373,18 @@ var Session = class {
|
|
|
2285
2373
|
this.listeners.add(fn);
|
|
2286
2374
|
return () => this.listeners.delete(fn);
|
|
2287
2375
|
}
|
|
2288
|
-
/** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
|
|
2289
2376
|
async start(specs) {
|
|
2290
2377
|
if (this.o.runDir) reapOrphans(this.o.runDir);
|
|
2291
2378
|
this.proxy = await startProxy({ routes: this.routes, store: this.records });
|
|
2292
2379
|
this.tunnels = specs.map((s) => this.blank(s, false));
|
|
2293
2380
|
this.emit();
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
this.account = me.username ?? me.email;
|
|
2297
|
-
} catch {
|
|
2298
|
-
}
|
|
2381
|
+
const me = await this.o.api.me().catch(() => null);
|
|
2382
|
+
if (me) this.account = me.username ?? me.email;
|
|
2299
2383
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
2300
2384
|
for (const t of this.tunnels) await this.open(t, existing);
|
|
2301
2385
|
const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
|
|
2302
2386
|
this.timer = setInterval(() => void this.beat(), every);
|
|
2303
2387
|
}
|
|
2304
|
-
/** Túnel avulso, só nesta sessão. */
|
|
2305
2388
|
async add(spec) {
|
|
2306
2389
|
if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
|
|
2307
2390
|
const t = this.blank(spec, true);
|
|
@@ -2310,33 +2393,29 @@ var Session = class {
|
|
|
2310
2393
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
2311
2394
|
await this.open(t, existing);
|
|
2312
2395
|
}
|
|
2313
|
-
/** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
|
|
2314
2396
|
qrLink(key) {
|
|
2315
2397
|
const t = this.tunnels.find((x) => x.key === key);
|
|
2316
2398
|
if (!t?.url) return null;
|
|
2317
|
-
const auth = this.
|
|
2399
|
+
const auth = this.persistentAuths.get(key);
|
|
2318
2400
|
if (!auth) return { url: t.url, expiresAt: null };
|
|
2319
2401
|
const { token, expiresAt } = auth.issueQrToken();
|
|
2320
2402
|
return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
|
|
2321
2403
|
}
|
|
2322
|
-
/** Primeiro usuário configurado do túnel, ou null quando ele não tem senha. */
|
|
2323
2404
|
authUser(key) {
|
|
2324
|
-
return this.
|
|
2405
|
+
return this.persistentAuths.get(key)?.qrUser ?? null;
|
|
2325
2406
|
}
|
|
2326
|
-
/** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
|
|
2327
2407
|
async authFor(key) {
|
|
2328
|
-
const existing = this.
|
|
2408
|
+
const existing = this.persistentAuths.get(key);
|
|
2329
2409
|
if (existing) return existing;
|
|
2330
2410
|
const spec = this.specs.get(key);
|
|
2331
2411
|
if (!spec?.auth?.length) return void 0;
|
|
2332
2412
|
const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
|
|
2333
2413
|
const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
|
|
2334
|
-
this.
|
|
2414
|
+
this.persistentAuths.set(key, auth);
|
|
2335
2415
|
return auth;
|
|
2336
2416
|
}
|
|
2337
|
-
/** Reenvia uma requisição da lista ao serviço local. */
|
|
2338
2417
|
replay(id) {
|
|
2339
|
-
return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "
|
|
2418
|
+
return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Session has not started yet." };
|
|
2340
2419
|
}
|
|
2341
2420
|
async stop() {
|
|
2342
2421
|
if (this.stopped) return;
|
|
@@ -2355,10 +2434,10 @@ var Session = class {
|
|
|
2355
2434
|
}
|
|
2356
2435
|
async open(t, existing, retrying = false) {
|
|
2357
2436
|
if (this.stopped) return;
|
|
2358
|
-
const
|
|
2359
|
-
(e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt <
|
|
2437
|
+
const takenByLiveSession = existing?.some(
|
|
2438
|
+
(e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt < STALE_AFTER_MISSED_HEARTBEATS_MS
|
|
2360
2439
|
);
|
|
2361
|
-
if (
|
|
2440
|
+
if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
|
|
2362
2441
|
try {
|
|
2363
2442
|
const res = await this.o.api.openTunnel({
|
|
2364
2443
|
deviceId: this.o.deviceId,
|
|
@@ -2405,7 +2484,7 @@ var Session = class {
|
|
|
2405
2484
|
if (this.child && this.token === token) return;
|
|
2406
2485
|
if (this.child) this.child.kill("SIGTERM");
|
|
2407
2486
|
this.token = token;
|
|
2408
|
-
const c =
|
|
2487
|
+
const c = spawn2(this.o.cloudflaredPath, ["tunnel", "--no-autoupdate", "run"], {
|
|
2409
2488
|
env: { ...process.env, TUNNEL_TOKEN: token },
|
|
2410
2489
|
stdio: ["ignore", "ignore", "pipe"]
|
|
2411
2490
|
});
|
|
@@ -2534,7 +2613,7 @@ function runPlain(session, out) {
|
|
|
2534
2613
|
}
|
|
2535
2614
|
|
|
2536
2615
|
// src/index.ts
|
|
2537
|
-
var LOGIN_HINT = "
|
|
2616
|
+
var LOGIN_HINT = "Log in first: duto login";
|
|
2538
2617
|
function fail2(message2) {
|
|
2539
2618
|
console.error(fail(message2));
|
|
2540
2619
|
process.exit(1);
|
|
@@ -2542,19 +2621,19 @@ function fail2(message2) {
|
|
|
2542
2621
|
function parsePort(raw) {
|
|
2543
2622
|
const s = String(raw ?? "");
|
|
2544
2623
|
const n = Number(s);
|
|
2545
|
-
if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`
|
|
2624
|
+
if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Invalid port: ${s} (use 1\u201365535)`);
|
|
2546
2625
|
return n;
|
|
2547
2626
|
}
|
|
2548
2627
|
function parseName(raw) {
|
|
2549
2628
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
2550
2629
|
const name = normalizeName(String(raw));
|
|
2551
2630
|
const v = validateName(name);
|
|
2552
|
-
if (!v.ok) fail2(v.reason === "reserved" ? `
|
|
2631
|
+
if (!v.ok) fail2(v.reason === "reserved" ? `"${name}" is reserved. Pick another name.` : `Invalid name. Use only lowercase letters and digits (no hyphens), up to ${MAX_NAME} characters.`);
|
|
2553
2632
|
return name;
|
|
2554
2633
|
}
|
|
2555
2634
|
function parseHostFlag(raw) {
|
|
2556
2635
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
2557
|
-
return parseHost(String(raw)) ?? fail2("
|
|
2636
|
+
return parseHost(String(raw)) ?? fail2("Invalid host: use the domain only, like myapp.test");
|
|
2558
2637
|
}
|
|
2559
2638
|
function listFlag(raw) {
|
|
2560
2639
|
if (raw === void 0) return [];
|
|
@@ -2564,14 +2643,14 @@ function parseAuthFlags(raw) {
|
|
|
2564
2643
|
const out = [];
|
|
2565
2644
|
for (const entry of listFlag(raw)) {
|
|
2566
2645
|
const r = parseCredential(entry);
|
|
2567
|
-
if (!r.ok) fail2(r.reason === "password" ? "
|
|
2568
|
-
if (out.some((c) => c.user === r.cred.user)) fail2(`
|
|
2646
|
+
if (!r.ok) fail2(r.reason === "password" ? "Password must be 8 to 128 characters" : r.reason === "user" ? "Invalid user: 1 to 128 characters, no spaces" : "Invalid credential: use user:password");
|
|
2647
|
+
if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
|
|
2569
2648
|
out.push(r.cred);
|
|
2570
2649
|
}
|
|
2571
2650
|
return out;
|
|
2572
2651
|
}
|
|
2573
2652
|
function parseAllowFlags(raw) {
|
|
2574
|
-
return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}"
|
|
2653
|
+
return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" is not a known name (stripe, github) or an IP range`));
|
|
2575
2654
|
}
|
|
2576
2655
|
function wantsUi(flags) {
|
|
2577
2656
|
if (flags.ui === false) return false;
|
|
@@ -2589,7 +2668,7 @@ async function runSession(specs, source, flags) {
|
|
|
2589
2668
|
} catch (err) {
|
|
2590
2669
|
fail2(err instanceof Error ? err.message : String(err));
|
|
2591
2670
|
}
|
|
2592
|
-
const session = new Session({ api, deviceId: config.deviceId, deviceName:
|
|
2671
|
+
const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
|
|
2593
2672
|
let inspector = null;
|
|
2594
2673
|
let inspectorError;
|
|
2595
2674
|
if (flags.inspect !== false) {
|
|
@@ -2600,14 +2679,14 @@ async function runSession(specs, source, flags) {
|
|
|
2600
2679
|
}
|
|
2601
2680
|
}
|
|
2602
2681
|
if (wantsUi(flags)) {
|
|
2603
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
2604
|
-
const code = await runFullscreen(session, specs, "
|
|
2682
|
+
const { runFullscreen } = await import("./fullscreen-ECEH3R2V.js");
|
|
2683
|
+
const code = await runFullscreen(session, specs, "1.1.0", inspector, inspectorError);
|
|
2605
2684
|
await inspector?.close().catch(() => void 0);
|
|
2606
2685
|
process.exit(code);
|
|
2607
2686
|
}
|
|
2608
|
-
console.log(`${mark()} ${paint("muted", `${"
|
|
2609
|
-
if (inspector) console.log(paint("muted", `
|
|
2610
|
-
if (inspectorError) console.log(warn(`
|
|
2687
|
+
console.log(`${mark()} ${paint("muted", `${"1.1.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
|
|
2688
|
+
if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
|
|
2689
|
+
if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
|
|
2611
2690
|
const stopLog = runPlain(session, (l) => console.log(l));
|
|
2612
2691
|
const stopped = new Promise((resolve) => {
|
|
2613
2692
|
const onSignal = () => {
|
|
@@ -2629,9 +2708,9 @@ async function runSession(specs, source, flags) {
|
|
|
2629
2708
|
}
|
|
2630
2709
|
async function tunnel(rawPort, flags) {
|
|
2631
2710
|
if (rawPort === void 0) {
|
|
2632
|
-
if (flags.host !== void 0) fail2("Use --host
|
|
2633
|
-
if (flags.auth !== void 0) fail2("Use --auth
|
|
2634
|
-
if (flags.
|
|
2711
|
+
if (flags.host !== void 0) fail2("Use --host with a port (duto 8080 --host myapp.test) or host: in duto.yml");
|
|
2712
|
+
if (flags.auth !== void 0) fail2("Use --auth with a port (duto 3000 --auth ana:secret) or auth: in duto.yml");
|
|
2713
|
+
if (flags.allow !== void 0) fail2("Use --allow with a port (duto 3000 --allow stripe) or allow: in duto.yml");
|
|
2635
2714
|
let found;
|
|
2636
2715
|
try {
|
|
2637
2716
|
found = findConfig(process.cwd());
|
|
@@ -2641,7 +2720,7 @@ async function tunnel(rawPort, flags) {
|
|
|
2641
2720
|
if (!found) {
|
|
2642
2721
|
cli.outputHelp();
|
|
2643
2722
|
console.log(`
|
|
2644
|
-
${paint("signal", "
|
|
2723
|
+
${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and just run ${paint("strong", "duto")}:
|
|
2645
2724
|
|
|
2646
2725
|
tunnels:
|
|
2647
2726
|
web: 3000
|
|
@@ -2655,30 +2734,67 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
|
|
|
2655
2734
|
const name = parseName(flags.name) ?? null;
|
|
2656
2735
|
const host = parseHostFlag(flags.host);
|
|
2657
2736
|
const keepRedirects = flags.rewriteRedirects === false;
|
|
2658
|
-
if (keepRedirects && !host) fail2("--no-rewrite-redirects
|
|
2737
|
+
if (keepRedirects && !host) fail2("--no-rewrite-redirects only works with --host");
|
|
2659
2738
|
const auth = parseAuthFlags(flags.auth);
|
|
2660
|
-
const allow = parseAllowFlags(flags.
|
|
2661
|
-
if (allow.length && !auth.length) fail2("--
|
|
2662
|
-
return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "
|
|
2739
|
+
const allow = parseAllowFlags(flags.allow);
|
|
2740
|
+
if (allow.length && !auth.length) fail2("--allow only works with --auth");
|
|
2741
|
+
return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
|
|
2663
2742
|
}
|
|
2664
2743
|
var cli = cac("duto");
|
|
2665
|
-
cli.command("[port]", "
|
|
2666
|
-
cli.command("http <port>", "
|
|
2667
|
-
cli.command("login
|
|
2744
|
+
cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.dev)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
|
|
2745
|
+
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));
|
|
2746
|
+
cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
|
|
2668
2747
|
try {
|
|
2748
|
+
if (token === void 0) return await loginWithBrowser();
|
|
2669
2749
|
const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
|
|
2670
2750
|
saveConfig({ ...loadConfig(), authtoken: String(token) });
|
|
2671
|
-
console.log(
|
|
2751
|
+
console.log(loggedInLine(me.username ?? me.email));
|
|
2672
2752
|
} catch (err) {
|
|
2673
2753
|
fail2(err instanceof Error ? err.message : String(err));
|
|
2674
2754
|
}
|
|
2675
2755
|
});
|
|
2676
|
-
|
|
2677
|
-
const
|
|
2756
|
+
async function loginWithBrowser() {
|
|
2757
|
+
const config = loadConfig();
|
|
2758
|
+
let stopSpinner = () => {
|
|
2759
|
+
};
|
|
2760
|
+
try {
|
|
2761
|
+
const { token, username } = await browserLogin({
|
|
2762
|
+
baseUrl: apiUrl(),
|
|
2763
|
+
deviceId: config.deviceId,
|
|
2764
|
+
open: openBrowser,
|
|
2765
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
2766
|
+
out: (line) => {
|
|
2767
|
+
console.log(line);
|
|
2768
|
+
stopSpinner = startSpinner("Waiting for authentication...");
|
|
2769
|
+
}
|
|
2770
|
+
});
|
|
2771
|
+
stopSpinner();
|
|
2772
|
+
saveConfig({ ...config, authtoken: token });
|
|
2773
|
+
for (const line of loginSuccessLines(username)) console.log(line);
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
stopSpinner();
|
|
2776
|
+
throw err;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
cli.command("logout", "Remove the authtoken from this machine").action(async () => {
|
|
2780
|
+
const { authtoken, ...rest } = loadConfig();
|
|
2781
|
+
let reachable = true;
|
|
2678
2782
|
saveConfig(rest);
|
|
2679
|
-
|
|
2783
|
+
if (authtoken) {
|
|
2784
|
+
try {
|
|
2785
|
+
await createApiClient({ baseUrl: apiUrl(), token: authtoken }).revokeSelf();
|
|
2786
|
+
} catch (err) {
|
|
2787
|
+
if (!(err instanceof DutoApiError) || err.code !== "network") fail2(err instanceof Error ? err.message : String(err));
|
|
2788
|
+
reachable = false;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
console.log(
|
|
2792
|
+
ok(
|
|
2793
|
+
reachable ? "Authtoken removed" : "Authtoken removed from this machine. Could not reach duto.dev; revoke it on the Machines page."
|
|
2794
|
+
)
|
|
2795
|
+
);
|
|
2680
2796
|
});
|
|
2681
|
-
cli.command("status", "
|
|
2797
|
+
cli.command("status", "Show your account and live tunnels").action(async () => {
|
|
2682
2798
|
const config = loadConfig();
|
|
2683
2799
|
if (!config.authtoken) fail2(LOGIN_HINT);
|
|
2684
2800
|
try {
|
|
@@ -2686,23 +2802,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
|
|
|
2686
2802
|
const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
|
|
2687
2803
|
console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
|
|
2688
2804
|
const live = endpoints.filter((e) => e.connected);
|
|
2689
|
-
if (!live.length) console.log(paint("muted", "
|
|
2805
|
+
if (!live.length) console.log(paint("muted", "No live tunnels."));
|
|
2690
2806
|
for (const e of live)
|
|
2691
2807
|
console.log(
|
|
2692
|
-
`${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "
|
|
2808
|
+
`${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "another machine"}`}`)}`
|
|
2693
2809
|
);
|
|
2694
2810
|
} catch (err) {
|
|
2695
2811
|
fail2(err instanceof DutoApiError ? err.message : String(err));
|
|
2696
2812
|
}
|
|
2697
2813
|
});
|
|
2698
|
-
cli.help(
|
|
2699
|
-
|
|
2700
|
-
...s,
|
|
2701
|
-
title: s.title === "Usage" ? "Uso" : s.title === "Commands" ? "Comandos" : s.title === "Options" ? "Op\xE7\xF5es" : s.title,
|
|
2702
|
-
body: s.body.replace(/^\s*For more info, run any command with the `--help` flag:/m, " Mais detalhes de um comando: duto <comando> --help").replace("Display this message", "Mostra esta ajuda").replace("Display version number", "Mostra a vers\xE3o")
|
|
2703
|
-
}))
|
|
2704
|
-
);
|
|
2705
|
-
cli.version("0.7.0");
|
|
2814
|
+
cli.help();
|
|
2815
|
+
cli.version("1.1.0");
|
|
2706
2816
|
try {
|
|
2707
2817
|
cli.parse(process.argv, { run: false });
|
|
2708
2818
|
await cli.runMatchedCommand();
|