@dutodev/cli 0.7.0 → 1.0.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-BPOXISIM.js} +7 -7
- package/dist/{fullscreen-YK365XSO.js → fullscreen-IWZCH3IP.js} +53 -53
- package/dist/index.js +227 -248
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
normalizeName,
|
|
11
11
|
statusColor,
|
|
12
12
|
validateName
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-BPOXISIM.js";
|
|
14
14
|
|
|
15
15
|
// src/ui/brand.ts
|
|
16
16
|
function colorOn(stream = process.stdout) {
|
|
@@ -20,7 +20,6 @@ function colorOn(stream = process.stdout) {
|
|
|
20
20
|
}
|
|
21
21
|
var SGR = {
|
|
22
22
|
signal: "38;2;45;212;191",
|
|
23
|
-
// #2dd4bf
|
|
24
23
|
muted: "38;2;138;143;139",
|
|
25
24
|
strong: "1",
|
|
26
25
|
ok: "38;2;74;222;128",
|
|
@@ -52,9 +51,9 @@ var DutoApiError = class extends Error {
|
|
|
52
51
|
code;
|
|
53
52
|
};
|
|
54
53
|
var MESSAGES = {
|
|
55
|
-
unauthorized: "
|
|
56
|
-
suspended: "
|
|
57
|
-
network: "
|
|
54
|
+
unauthorized: "Invalid authtoken. Generate a new one at https://duto.dev and run: duto login <token>",
|
|
55
|
+
suspended: "Your account is suspended.",
|
|
56
|
+
network: "Could not reach duto.dev. Check your connection."
|
|
58
57
|
};
|
|
59
58
|
function createApiClient(o) {
|
|
60
59
|
const doFetch = o.fetchImpl ?? fetch;
|
|
@@ -80,7 +79,7 @@ function createApiClient(o) {
|
|
|
80
79
|
const code = data.error ?? "http_error";
|
|
81
80
|
if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
|
|
82
81
|
if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
|
|
83
|
-
throw new DutoApiError(data.message ?? `
|
|
82
|
+
throw new DutoApiError(data.message ?? `duto.dev returned HTTP ${res.status}.`, res.status, code);
|
|
84
83
|
}
|
|
85
84
|
async function json(method, path, body) {
|
|
86
85
|
const res = await call(method, path, body);
|
|
@@ -169,7 +168,7 @@ function assetFor(platform, arch) {
|
|
|
169
168
|
case "win32/x64":
|
|
170
169
|
return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
|
|
171
170
|
default:
|
|
172
|
-
throw new Error(`
|
|
171
|
+
throw new Error(`Unsupported platform: ${key}`);
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
|
|
@@ -184,13 +183,13 @@ async function ensureCloudflared(opts = {}) {
|
|
|
184
183
|
opts.onDownload?.();
|
|
185
184
|
mkdirSync2(binDir, { recursive: true });
|
|
186
185
|
const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
|
|
187
|
-
if (!res.ok) throw new Error(`
|
|
186
|
+
if (!res.ok) throw new Error(`Could not download cloudflared (HTTP ${res.status}).`);
|
|
188
187
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
189
188
|
const expected = (opts.checksums ?? CHECKSUMS)[asset];
|
|
190
189
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
191
190
|
if (!expected || actual !== expected) {
|
|
192
191
|
rmSync(target, { force: true });
|
|
193
|
-
throw new Error("
|
|
192
|
+
throw new Error("cloudflared checksum mismatch. Try again; if it keeps failing, update duto.");
|
|
194
193
|
}
|
|
195
194
|
const tmp = mkdtempSync(join2(binDir, ".download-"));
|
|
196
195
|
try {
|
|
@@ -217,13 +216,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
|
|
|
217
216
|
...opts,
|
|
218
217
|
onDownload: () => {
|
|
219
218
|
downloading = true;
|
|
220
|
-
write("\u2193
|
|
219
|
+
write("\u2193 Downloading cloudflared (first run only)... ");
|
|
221
220
|
}
|
|
222
221
|
});
|
|
223
222
|
if (downloading) write("ok\n");
|
|
224
223
|
return path;
|
|
225
224
|
} catch (err) {
|
|
226
|
-
if (downloading) write("
|
|
225
|
+
if (downloading) write("failed\n");
|
|
227
226
|
throw err;
|
|
228
227
|
}
|
|
229
228
|
}
|
|
@@ -274,8 +273,8 @@ var SOURCES = {
|
|
|
274
273
|
github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
|
|
275
274
|
};
|
|
276
275
|
function splitCidr(raw) {
|
|
277
|
-
const [ip, p, ...
|
|
278
|
-
if (!ip ||
|
|
276
|
+
const [ip, p, ...extra] = raw.split("/");
|
|
277
|
+
if (!ip || extra.length) return null;
|
|
279
278
|
const v = isIP(ip);
|
|
280
279
|
if (!v) return null;
|
|
281
280
|
const max = v === 4 ? 32 : 128;
|
|
@@ -311,9 +310,9 @@ async function fetchRanges(name, f) {
|
|
|
311
310
|
const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
|
|
312
311
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
313
312
|
const list = src.pick(await res.json());
|
|
314
|
-
if (!Array.isArray(list)) throw new Error("
|
|
313
|
+
if (!Array.isArray(list)) throw new Error("unexpected format");
|
|
315
314
|
const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
|
|
316
|
-
if (!out.length) throw new Error("
|
|
315
|
+
if (!out.length) throw new Error("empty list");
|
|
317
316
|
return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
|
|
318
317
|
}
|
|
319
318
|
async function resolveAllow(entries, o = {}) {
|
|
@@ -325,7 +324,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
325
324
|
const cached = readCache(cacheFile);
|
|
326
325
|
const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
|
|
327
326
|
const lists = {};
|
|
328
|
-
|
|
327
|
+
const coversEveryName = fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length);
|
|
328
|
+
if (fresh && coversEveryName) {
|
|
329
329
|
for (const n of names) lists[n] = fresh.lists[n];
|
|
330
330
|
} else {
|
|
331
331
|
const net = {};
|
|
@@ -336,7 +336,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
336
336
|
lists[name] = ALLOW_SNAPSHOT[name];
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
|
-
|
|
339
|
+
const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
|
|
340
|
+
if (everyListFromNetwork) {
|
|
340
341
|
try {
|
|
341
342
|
mkdirSync3(dirname(cacheFile), { recursive: true });
|
|
342
343
|
writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
|
|
@@ -366,7 +367,7 @@ import { dirname as dirname2, join as join4 } from "path";
|
|
|
366
367
|
import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
|
|
367
368
|
var ConfigError = class extends Error {
|
|
368
369
|
constructor(message2, line, file = CONFIG_FILES[0]) {
|
|
369
|
-
super(!file ? message2 : line ? `${file},
|
|
370
|
+
super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
|
|
370
371
|
this.line = line;
|
|
371
372
|
this.name = "ConfigError";
|
|
372
373
|
}
|
|
@@ -376,7 +377,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
|
|
|
376
377
|
var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
377
378
|
var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
|
|
378
379
|
var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
379
|
-
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "
|
|
380
|
+
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
|
|
380
381
|
var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
|
|
381
382
|
function parseHost(raw) {
|
|
382
383
|
const host = raw.trim().toLowerCase();
|
|
@@ -402,11 +403,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
402
403
|
const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
|
|
403
404
|
if (doc.errors.length) {
|
|
404
405
|
const e = doc.errors[0];
|
|
405
|
-
throw fail3("YAML
|
|
406
|
+
throw fail3("invalid YAML. Check the indentation and the colons.", lineAt(e.pos[0]));
|
|
406
407
|
}
|
|
407
408
|
const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
|
|
408
409
|
if (!isMap(tunnels) || tunnels.items.length === 0) {
|
|
409
|
-
throw fail3('declare
|
|
410
|
+
throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
|
|
410
411
|
}
|
|
411
412
|
const specs = [];
|
|
412
413
|
for (const pair of tunnels.items) {
|
|
@@ -416,40 +417,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
416
417
|
const v = validateName(name);
|
|
417
418
|
if (!v.ok) {
|
|
418
419
|
throw fail3(
|
|
419
|
-
v.reason === "reserved" ? `
|
|
420
|
+
v.reason === "reserved" ? `reserved name: "${raw}". Choose another.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
|
|
420
421
|
line
|
|
421
422
|
);
|
|
422
423
|
}
|
|
423
424
|
const port = portOf(pair.value);
|
|
424
|
-
if (port === null) throw fail3(`
|
|
425
|
+
if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
|
|
425
426
|
const spec = { name, port };
|
|
426
427
|
if (isMap(pair.value)) {
|
|
427
428
|
const body = pair.value;
|
|
428
429
|
for (const item of body.items) {
|
|
429
430
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
430
|
-
if (!TUNNEL_KEYS.includes(key)) throw fail3(`
|
|
431
|
+
if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
|
|
431
432
|
}
|
|
432
433
|
const hostNode = body.get("host", true);
|
|
433
434
|
if (hostNode !== void 0) {
|
|
434
435
|
const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
|
|
435
|
-
if (!host) throw fail3(`host
|
|
436
|
+
if (!host) throw fail3(`invalid host for "${raw}": use only the domain, like myapp.test`, line);
|
|
436
437
|
spec.host = host;
|
|
437
438
|
}
|
|
438
439
|
const headersNode = body.get("headers", true);
|
|
439
440
|
if (headersNode !== void 0) {
|
|
440
|
-
if (!isMap(headersNode)) throw fail3(`headers
|
|
441
|
+
if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
|
|
441
442
|
const headers = {};
|
|
442
443
|
for (const h of headersNode.items) {
|
|
443
444
|
const hName = isScalar(h.key) ? String(h.key.value) : "";
|
|
444
445
|
const hLine = lineAt(h.key?.range?.[0]);
|
|
445
|
-
if (!HEADER_NAME_RE.test(hName)) throw fail3(`
|
|
446
|
-
if (hName.toLowerCase() === "host") throw fail3(`
|
|
446
|
+
if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
|
|
447
|
+
if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
|
|
447
448
|
const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
|
|
448
449
|
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
449
|
-
throw fail3(`
|
|
450
|
+
throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
450
451
|
}
|
|
451
452
|
const str = value === null ? "" : String(value);
|
|
452
|
-
if (/[\r\n]/.test(str)) throw fail3(`
|
|
453
|
+
if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
453
454
|
headers[hName.toLowerCase()] = str;
|
|
454
455
|
}
|
|
455
456
|
spec.headers = headers;
|
|
@@ -457,50 +458,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
457
458
|
const rewriteNode = body.get("rewrite_redirects", true);
|
|
458
459
|
if (rewriteNode !== void 0) {
|
|
459
460
|
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
|
|
461
|
+
if (typeof value !== "boolean") throw fail3(`rewrite_redirects in "${raw}" must be true or false`, line);
|
|
462
|
+
if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
|
|
462
463
|
if (value === false) spec.rewriteRedirects = false;
|
|
463
464
|
}
|
|
464
465
|
const authNode = body.get("auth", true);
|
|
465
466
|
if (authNode !== void 0) {
|
|
466
|
-
if (!isSeq(authNode)) throw fail3(`auth
|
|
467
|
+
if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
|
|
467
468
|
const auth = [];
|
|
468
469
|
for (const item of authNode.items) {
|
|
469
470
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
470
471
|
const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
|
|
471
|
-
if (text2 === null) throw fail3(`
|
|
472
|
+
if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
472
473
|
const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
|
|
473
474
|
const val = env[name2];
|
|
474
|
-
if (val === void 0) throw fail3(`
|
|
475
|
+
if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
|
|
475
476
|
return val;
|
|
476
477
|
});
|
|
477
478
|
const parsed = parseCredential(expanded);
|
|
478
479
|
if (!parsed.ok) {
|
|
479
|
-
if (parsed.reason === "format") throw fail3(`
|
|
480
|
-
if (parsed.reason === "user") throw fail3(`
|
|
481
|
-
throw fail3(`
|
|
480
|
+
if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
481
|
+
if (parsed.reason === "user") throw fail3(`invalid user in "${raw}": 1 to 128 characters, no spaces`, iLine);
|
|
482
|
+
throw fail3(`password for "${expanded.slice(0, expanded.indexOf(":"))}" in "${raw}" must be 8 to 128 characters`, iLine);
|
|
482
483
|
}
|
|
483
|
-
if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`
|
|
484
|
+
if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`user "${parsed.cred.user}" appears twice in "${raw}"`, iLine);
|
|
484
485
|
auth.push(parsed.cred);
|
|
485
486
|
}
|
|
486
|
-
if (!auth.length) throw fail3(`auth
|
|
487
|
+
if (!auth.length) throw fail3(`auth in "${raw}" cannot be empty`, line);
|
|
487
488
|
spec.auth = auth;
|
|
488
489
|
}
|
|
489
|
-
const allowNode = body.get("
|
|
490
|
+
const allowNode = body.get("allow", true);
|
|
490
491
|
if (allowNode !== void 0) {
|
|
491
|
-
if (!isSeq(allowNode)) throw fail3(`
|
|
492
|
-
if (!spec.auth) throw fail3(`
|
|
492
|
+
if (!isSeq(allowNode)) throw fail3(`allow in "${raw}" must be a list`, line);
|
|
493
|
+
if (!spec.auth) throw fail3(`allow in "${raw}" only works together with auth:`, line);
|
|
493
494
|
const allow = [];
|
|
494
495
|
for (const item of allowNode.items) {
|
|
495
496
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
496
497
|
const value = isScalar(item) ? String(item.value) : "";
|
|
497
|
-
if (!parseAllowEntry(value)) throw fail3(`
|
|
498
|
+
if (!parseAllowEntry(value)) throw fail3(`allow in "${raw}": "${value}" is not a known name (stripe, github) or an IP range`, iLine);
|
|
498
499
|
allow.push(value);
|
|
499
500
|
}
|
|
500
501
|
if (allow.length) spec.allow = allow;
|
|
501
502
|
}
|
|
502
503
|
}
|
|
503
|
-
if (specs.some((s) => s.name === name)) throw fail3(`
|
|
504
|
+
if (specs.some((s) => s.name === name)) throw fail3(`the name "${raw}" appears twice`, line);
|
|
504
505
|
specs.push(spec);
|
|
505
506
|
}
|
|
506
507
|
return specs;
|
|
@@ -509,7 +510,7 @@ function findConfig(cwd) {
|
|
|
509
510
|
let dir = cwd;
|
|
510
511
|
for (; ; ) {
|
|
511
512
|
const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
|
|
512
|
-
if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join("
|
|
513
|
+
if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
|
|
513
514
|
if (present.length === 1) {
|
|
514
515
|
const path = join4(dir, present[0]);
|
|
515
516
|
return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
|
|
@@ -522,6 +523,9 @@ function findConfig(cwd) {
|
|
|
522
523
|
}
|
|
523
524
|
|
|
524
525
|
// src/inspector/jsonView.ts
|
|
526
|
+
function countLabel(n, one, many) {
|
|
527
|
+
return n + " " + (n === 1 ? one : many);
|
|
528
|
+
}
|
|
525
529
|
function jsonSyntaxError(text) {
|
|
526
530
|
try {
|
|
527
531
|
JSON.parse(text);
|
|
@@ -697,14 +701,15 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
|
697
701
|
}
|
|
698
702
|
`;
|
|
699
703
|
var SCRIPT = String.raw`"use strict";
|
|
704
|
+
var countLabel = (${countLabel.toString()});
|
|
700
705
|
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
701
706
|
var jsonTokens = (${jsonTokens.toString()});
|
|
702
707
|
var jsonIndent = (${jsonIndent.toString()});
|
|
703
708
|
var COLORS = ${JSON.stringify(C)};
|
|
704
709
|
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
705
|
-
var TABS = ["
|
|
710
|
+
var TABS = ["Body", "Headers", "Response", "Raw"];
|
|
706
711
|
|
|
707
|
-
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "
|
|
712
|
+
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
|
|
708
713
|
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
709
714
|
var es = null, lost = false, replaying = false;
|
|
710
715
|
function fail(e) { if (!state.ended) console.error(e); }
|
|
@@ -725,12 +730,12 @@ function banner(text) {
|
|
|
725
730
|
}
|
|
726
731
|
function ended() {
|
|
727
732
|
state.ended = true;
|
|
728
|
-
banner("
|
|
733
|
+
banner("Session ended. Press i in duto to open the inspector again.");
|
|
729
734
|
if (es) es.close();
|
|
730
735
|
}
|
|
731
736
|
|
|
732
737
|
function api(path, method) {
|
|
733
|
-
return fetch(path, { method: method || "GET", headers: { "X-Duto-
|
|
738
|
+
return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
|
|
734
739
|
if (r.status === 403) { ended(); throw new Error("403"); }
|
|
735
740
|
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
|
736
741
|
});
|
|
@@ -742,22 +747,20 @@ function statusInfo(it) {
|
|
|
742
747
|
var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
|
|
743
748
|
return { text: String(s), color: color };
|
|
744
749
|
}
|
|
745
|
-
if (it.error) return { text: "
|
|
750
|
+
if (it.error) return { text: "error", color: COLORS.err };
|
|
746
751
|
return { text: "…", color: COLORS.muted };
|
|
747
752
|
}
|
|
748
753
|
function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
|
|
749
754
|
function size(n) {
|
|
750
755
|
if (n < 1024) return n + " B";
|
|
751
|
-
if (n < 1024 * 1024) return (n / 1024).toFixed(1)
|
|
752
|
-
return (n / 1024 / 1024).toFixed(1)
|
|
756
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
757
|
+
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
753
758
|
}
|
|
754
759
|
function methodOf(it) { return it.websocket ? "WS" : it.method; }
|
|
755
760
|
function header(h, name) {
|
|
756
761
|
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
757
762
|
return "";
|
|
758
763
|
}
|
|
759
|
-
|
|
760
|
-
// Mesma regra do filtro da tela do terminal.
|
|
761
764
|
function visible() {
|
|
762
765
|
var f = $("filter").value.trim().toLowerCase();
|
|
763
766
|
if (!f) return state.items;
|
|
@@ -768,13 +771,13 @@ function visible() {
|
|
|
768
771
|
|
|
769
772
|
function renderTop() {
|
|
770
773
|
var n = state.items.length;
|
|
771
|
-
$("count").textContent =
|
|
774
|
+
$("count").textContent = countLabel(n, "request", "requests");
|
|
772
775
|
var box = $("tunnels");
|
|
773
776
|
clear(box);
|
|
774
777
|
state.tunnels.forEach(function (t) {
|
|
775
778
|
var s = el("span");
|
|
776
779
|
s.appendChild(el("span", "k", t.key));
|
|
777
|
-
s.appendChild(document.createTextNode(" " + (t.url || "
|
|
780
|
+
s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
|
|
778
781
|
box.appendChild(s);
|
|
779
782
|
});
|
|
780
783
|
}
|
|
@@ -786,7 +789,7 @@ function renderList() {
|
|
|
786
789
|
clear(list);
|
|
787
790
|
var items = visible();
|
|
788
791
|
if (items.length === 0) {
|
|
789
|
-
list.appendChild(el("div", "empty", state.items.length === 0 ? "
|
|
792
|
+
list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
|
|
790
793
|
return;
|
|
791
794
|
}
|
|
792
795
|
items.forEach(function (it) {
|
|
@@ -812,7 +815,7 @@ function renderList() {
|
|
|
812
815
|
|
|
813
816
|
function loadList() {
|
|
814
817
|
var seq = ++listSeq;
|
|
815
|
-
return Promise.all([api("/api/
|
|
818
|
+
return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
|
|
816
819
|
if (seq !== listSeq) return;
|
|
817
820
|
state.items = rs[0].body.items || [];
|
|
818
821
|
state.tunnels = rs[1].body.tunnels || [];
|
|
@@ -837,13 +840,13 @@ function select(id) {
|
|
|
837
840
|
|
|
838
841
|
function loadDetail(id) {
|
|
839
842
|
var seq = ++detailSeq;
|
|
840
|
-
return api("/api/
|
|
843
|
+
return api("/api/requests/" + id).then(function (r) {
|
|
841
844
|
if (seq !== detailSeq || state.selected !== id) return;
|
|
842
845
|
var key = JSON.stringify(r.body);
|
|
843
|
-
if (key === state.detailKey) return;
|
|
846
|
+
if (key === state.detailKey) return;
|
|
844
847
|
state.detailKey = key;
|
|
845
848
|
state.detail = r.status === 200 ? r.body : null;
|
|
846
|
-
if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "
|
|
849
|
+
if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
|
|
847
850
|
else renderDetail();
|
|
848
851
|
}).catch(fail);
|
|
849
852
|
}
|
|
@@ -872,7 +875,7 @@ function renderDetail() {
|
|
|
872
875
|
if (duration(d)) parts.push(duration(d));
|
|
873
876
|
parts.push(size(d.bytes));
|
|
874
877
|
parts.push(d.tunnel);
|
|
875
|
-
if (d.replayOf !== undefined) parts.push("↻
|
|
878
|
+
if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
|
|
876
879
|
head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
|
|
877
880
|
if (d.error) head.appendChild(el("div", "msg err", d.error));
|
|
878
881
|
|
|
@@ -891,12 +894,12 @@ function renderContent() {
|
|
|
891
894
|
var box = $("content");
|
|
892
895
|
var top = box.scrollTop;
|
|
893
896
|
clear(box);
|
|
894
|
-
if (state.tab === "
|
|
895
|
-
else if (state.tab === "
|
|
896
|
-
else if (state.tab === "
|
|
897
|
-
box.appendChild(el("div", "sect", "
|
|
897
|
+
if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
|
|
898
|
+
else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
|
|
899
|
+
else if (state.tab === "Response") {
|
|
900
|
+
box.appendChild(el("div", "sect", "Headers"));
|
|
898
901
|
box.appendChild(table(d.resHeaders));
|
|
899
|
-
box.appendChild(el("div", "sect", "
|
|
902
|
+
box.appendChild(el("div", "sect", "Body"));
|
|
900
903
|
renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
|
|
901
904
|
} else {
|
|
902
905
|
var lines = [d.method + " " + d.path + " HTTP/1.1"];
|
|
@@ -910,7 +913,7 @@ function renderContent() {
|
|
|
910
913
|
|
|
911
914
|
function table(h) {
|
|
912
915
|
var keys = Object.keys(h || {});
|
|
913
|
-
if (keys.length === 0) return el("div", "note", "
|
|
916
|
+
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
914
917
|
var t = el("table");
|
|
915
918
|
keys.forEach(function (k) {
|
|
916
919
|
var tr = el("tr");
|
|
@@ -927,7 +930,7 @@ function renderBody(box, body, note, truncNote, headers) {
|
|
|
927
930
|
return;
|
|
928
931
|
}
|
|
929
932
|
if (body === null || body === undefined) {
|
|
930
|
-
box.appendChild(el("div", "note", "
|
|
933
|
+
box.appendChild(el("div", "note", "No body."));
|
|
931
934
|
} else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
|
|
932
935
|
var err = jsonSyntaxError(body);
|
|
933
936
|
if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
|
|
@@ -947,8 +950,6 @@ function renderBody(box, body, note, truncNote, headers) {
|
|
|
947
950
|
}
|
|
948
951
|
|
|
949
952
|
var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
|
|
950
|
-
|
|
951
|
-
// JSON colorido; clicar no { ou [ recolhe para "{ ▸ N campos }".
|
|
952
953
|
function jsonTree(text) {
|
|
953
954
|
var pre = el("pre");
|
|
954
955
|
var stack = [{ node: pre }];
|
|
@@ -973,8 +974,8 @@ function jsonTree(text) {
|
|
|
973
974
|
var n = c.has ? c.count + 1 : 0;
|
|
974
975
|
if (n > 0) {
|
|
975
976
|
c.wrap.dataset.n = String(n);
|
|
976
|
-
c.open.title = "
|
|
977
|
-
c.sum.textContent = " ▸ " +
|
|
977
|
+
c.open.title = "collapse or expand";
|
|
978
|
+
c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
|
|
978
979
|
} else c.open.className = "";
|
|
979
980
|
c.wrap.appendChild(document.createTextNode(t.text));
|
|
980
981
|
return;
|
|
@@ -1006,13 +1007,11 @@ function renderButtons() {
|
|
|
1006
1007
|
var notes = [];
|
|
1007
1008
|
if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
|
|
1008
1009
|
var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
|
|
1009
|
-
if (d.curl !== null && tunnel && tunnel.auth) notes.push("
|
|
1010
|
+
if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
|
|
1010
1011
|
$("curlNote").textContent = notes.join(" ");
|
|
1011
1012
|
}
|
|
1012
|
-
|
|
1013
|
-
// "Copiar corpo" copia o corpo da aba ativa: a resposta na aba Resposta, senão a requisição.
|
|
1014
1013
|
function copyable(d) {
|
|
1015
|
-
return state.tab === "
|
|
1014
|
+
return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
|
|
1016
1015
|
}
|
|
1017
1016
|
|
|
1018
1017
|
function showMsg(text, isErr) {
|
|
@@ -1031,10 +1030,10 @@ function hideMsg() {
|
|
|
1031
1030
|
|
|
1032
1031
|
function copy(text) {
|
|
1033
1032
|
navigator.clipboard.writeText(text).then(function () {
|
|
1034
|
-
showMsg("
|
|
1033
|
+
showMsg("Copied.", false);
|
|
1035
1034
|
msgTimer = setTimeout(hideMsg, 2000);
|
|
1036
1035
|
}, function () {
|
|
1037
|
-
showMsg("
|
|
1036
|
+
showMsg("Could not copy.", true);
|
|
1038
1037
|
});
|
|
1039
1038
|
}
|
|
1040
1039
|
|
|
@@ -1045,14 +1044,14 @@ function replay() {
|
|
|
1045
1044
|
if (replaying) return;
|
|
1046
1045
|
replaying = true;
|
|
1047
1046
|
$("replay").disabled = true;
|
|
1048
|
-
api("/api/
|
|
1047
|
+
api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
|
|
1049
1048
|
replaying = false;
|
|
1050
1049
|
if (state.detail) renderButtons();
|
|
1051
1050
|
if (r.body && r.body.ok) {
|
|
1052
1051
|
hideMsg();
|
|
1053
1052
|
return loadList().then(function () { return select(r.body.id); });
|
|
1054
1053
|
}
|
|
1055
|
-
showMsg(r.body && r.body.reason ? r.body.reason : "
|
|
1054
|
+
showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
|
|
1056
1055
|
}).catch(function (e) {
|
|
1057
1056
|
replaying = false;
|
|
1058
1057
|
if (state.detail) renderButtons();
|
|
@@ -1071,11 +1070,10 @@ function move(delta) {
|
|
|
1071
1070
|
|
|
1072
1071
|
function connect() {
|
|
1073
1072
|
if (state.ended) return;
|
|
1074
|
-
es = new EventSource("/api/
|
|
1073
|
+
es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
|
|
1075
1074
|
es.addEventListener("open", function () {
|
|
1076
1075
|
if (state.ended) return;
|
|
1077
1076
|
banner("");
|
|
1078
|
-
// Reconectou: o que mudou no meio do caminho não chegou por evento.
|
|
1079
1077
|
if (lost) {
|
|
1080
1078
|
lost = false;
|
|
1081
1079
|
loadList();
|
|
@@ -1085,22 +1083,18 @@ function connect() {
|
|
|
1085
1083
|
es.addEventListener("error", function () {
|
|
1086
1084
|
if (state.ended) return;
|
|
1087
1085
|
lost = true;
|
|
1088
|
-
banner("
|
|
1089
|
-
// Resposta não 200 (ex.: 403 de outra sessão) fecha o EventSource de vez.
|
|
1086
|
+
banner("Disconnected from the session.");
|
|
1090
1087
|
if (es.readyState === 2) probe();
|
|
1091
1088
|
});
|
|
1092
|
-
es.addEventListener("
|
|
1089
|
+
es.addEventListener("changed", function (ev) {
|
|
1093
1090
|
var ids = [];
|
|
1094
1091
|
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1095
1092
|
loadList();
|
|
1096
|
-
// Lista vazia de ids: algo mudou fora da assinatura; busca o detalhe de novo também.
|
|
1097
1093
|
if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
|
|
1098
1094
|
});
|
|
1099
1095
|
}
|
|
1100
|
-
|
|
1101
|
-
// EventSource fechado: a API diz se a sessão acabou (403 → ended) ou se dá para reconectar.
|
|
1102
1096
|
function probe() {
|
|
1103
|
-
api("/api/
|
|
1097
|
+
api("/api/session").then(function () {
|
|
1104
1098
|
if (!state.ended) setTimeout(connect, 3000);
|
|
1105
1099
|
}, function () {
|
|
1106
1100
|
if (!state.ended) setTimeout(connect, 3000);
|
|
@@ -1150,35 +1144,35 @@ function init() {
|
|
|
1150
1144
|
init();
|
|
1151
1145
|
`;
|
|
1152
1146
|
function inspectorPage(nonce) {
|
|
1153
|
-
const tabs = ["
|
|
1147
|
+
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
1148
|
return `<!doctype html>
|
|
1155
|
-
<html lang="
|
|
1149
|
+
<html lang="en">
|
|
1156
1150
|
<head>
|
|
1157
1151
|
<meta charset="utf-8">
|
|
1158
1152
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1159
|
-
<title>duto \xB7
|
|
1153
|
+
<title>duto \xB7 inspector</title>
|
|
1160
1154
|
<link rel="icon" href="data:,">
|
|
1161
1155
|
<style nonce="${nonce}">${STYLE}</style>
|
|
1162
1156
|
</head>
|
|
1163
1157
|
<body>
|
|
1164
1158
|
<div class="app">
|
|
1165
|
-
<div class="top"><span><b>\u25B0 duto</b> \xB7
|
|
1159
|
+
<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
1160
|
<div class="banner" id="banner" role="status" hidden></div>
|
|
1167
1161
|
<div class="main">
|
|
1168
1162
|
<div class="side">
|
|
1169
|
-
<input class="search" id="filter" type="search" placeholder="
|
|
1170
|
-
<div class="list" id="list" role="listbox" aria-label="
|
|
1163
|
+
<input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
|
|
1164
|
+
<div class="list" id="list" role="listbox" aria-label="Requests"></div>
|
|
1171
1165
|
</div>
|
|
1172
1166
|
<div class="detail">
|
|
1173
|
-
<div class="empty" id="none">
|
|
1167
|
+
<div class="empty" id="none">Select a request from the list.</div>
|
|
1174
1168
|
<div class="detail pane" id="detail" hidden>
|
|
1175
1169
|
<div class="head" id="head"></div>
|
|
1176
1170
|
<div class="tabs" role="tablist">${tabs}</div>
|
|
1177
1171
|
<div class="content" id="content"></div>
|
|
1178
1172
|
<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">
|
|
1173
|
+
<span><button type="button" class="btn pri" id="replay">\u21BB Replay</button></span>
|
|
1174
|
+
<span><button type="button" class="btn" id="copyBody">Copy body</button></span>
|
|
1175
|
+
<span><button type="button" class="btn" id="copyCurl">Copy as curl</button></span>
|
|
1182
1176
|
<span class="msg" id="curlNote"></span>
|
|
1183
1177
|
<span class="msg" id="msg" role="status" hidden></span>
|
|
1184
1178
|
</div>
|
|
@@ -1197,21 +1191,21 @@ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto"
|
|
|
1197
1191
|
import { createServer } from "http";
|
|
1198
1192
|
|
|
1199
1193
|
// src/session/replay.ts
|
|
1200
|
-
var tunnelClosedReason = (tunnel2) => `
|
|
1194
|
+
var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
|
|
1201
1195
|
function replayBlock(r) {
|
|
1202
|
-
if (!r) return "
|
|
1203
|
-
if (r.websocket) return "WebSocket
|
|
1196
|
+
if (!r) return "That request is no longer in the list.";
|
|
1197
|
+
if (r.websocket) return "WebSocket requests cannot be replayed.";
|
|
1204
1198
|
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 "
|
|
1199
|
+
if (r.access?.kind === "denied") return "Blocked by the tunnel password; cannot replay.";
|
|
1200
|
+
if (!r.reqComplete) return "The request has not fully arrived yet.";
|
|
1201
|
+
if (r.reqTruncated) return "Body over 64 KB; cannot replay.";
|
|
1202
|
+
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
|
|
1209
1203
|
return null;
|
|
1210
1204
|
}
|
|
1211
|
-
var
|
|
1205
|
+
var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1212
1206
|
function replayHeaders(r) {
|
|
1213
1207
|
const out = {};
|
|
1214
|
-
for (const [k, v] of Object.entries(r.reqHeaders)) if (!
|
|
1208
|
+
for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
1215
1209
|
const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
|
|
1216
1210
|
if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
|
|
1217
1211
|
return out;
|
|
@@ -1232,7 +1226,7 @@ function toCurl(r, o) {
|
|
|
1232
1226
|
for (const [k, v] of Object.entries(r.reqHeaders)) {
|
|
1233
1227
|
if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
|
|
1234
1228
|
}
|
|
1235
|
-
if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:
|
|
1229
|
+
if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
|
|
1236
1230
|
if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
|
|
1237
1231
|
return parts.join(" \\\n ");
|
|
1238
1232
|
}
|
|
@@ -1240,12 +1234,12 @@ function toCurl(r, o) {
|
|
|
1240
1234
|
// src/inspector/server.ts
|
|
1241
1235
|
var DEFAULT_PORT = 4141;
|
|
1242
1236
|
var PORT_TRIES = 20;
|
|
1243
|
-
var NO_TOKEN = "
|
|
1244
|
-
var GONE = "
|
|
1245
|
-
var NO_URL = "
|
|
1246
|
-
var CURL_BYTES = "
|
|
1237
|
+
var NO_TOKEN = "Open the inspector with the i key in the duto screen, or with the address printed in the terminal.";
|
|
1238
|
+
var GONE = "That request is no longer in the list.";
|
|
1239
|
+
var NO_URL = "The tunnel has no public URL yet.";
|
|
1240
|
+
var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 bytes).";
|
|
1247
1241
|
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1248
|
-
var
|
|
1242
|
+
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1249
1243
|
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1250
1244
|
function listItem(r) {
|
|
1251
1245
|
return {
|
|
@@ -1308,7 +1302,7 @@ async function startInspector(o) {
|
|
|
1308
1302
|
if (seen.get(r.id) !== s) ids.push(r.id);
|
|
1309
1303
|
}
|
|
1310
1304
|
seen = next;
|
|
1311
|
-
const msg = `event:
|
|
1305
|
+
const msg = `event: changed
|
|
1312
1306
|
data: ${JSON.stringify({ ids })}
|
|
1313
1307
|
|
|
1314
1308
|
`;
|
|
@@ -1339,19 +1333,19 @@ data: ${JSON.stringify({ ids })}
|
|
|
1339
1333
|
const handle = (req, res) => {
|
|
1340
1334
|
base(res);
|
|
1341
1335
|
const host = first(req.headers.host)?.toLowerCase();
|
|
1342
|
-
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host
|
|
1336
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
|
|
1343
1337
|
let url2;
|
|
1344
1338
|
try {
|
|
1345
1339
|
url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1346
1340
|
} catch {
|
|
1347
|
-
return text(res, 404, "
|
|
1341
|
+
return text(res, 404, "Not found.");
|
|
1348
1342
|
}
|
|
1349
1343
|
const path = url2.pathname;
|
|
1350
1344
|
const method = req.method ?? "GET";
|
|
1351
1345
|
const queryToken = url2.searchParams.get("t") ?? "";
|
|
1352
|
-
const headerToken = first(req.headers["x-duto-
|
|
1346
|
+
const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
|
|
1353
1347
|
if (method === "GET" && path === "/") {
|
|
1354
|
-
if (!
|
|
1348
|
+
if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
|
|
1355
1349
|
const nonce = randomBytes(16).toString("base64url");
|
|
1356
1350
|
res.writeHead(200, {
|
|
1357
1351
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -1359,11 +1353,11 @@ data: ${JSON.stringify({ ids })}
|
|
|
1359
1353
|
});
|
|
1360
1354
|
return void res.end(o.page(nonce));
|
|
1361
1355
|
}
|
|
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 (!
|
|
1356
|
+
const isEvents = method === "GET" && path === "/api/events";
|
|
1357
|
+
const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
|
|
1358
|
+
const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
|
|
1359
|
+
if (!known) return text(res, 404, "Not found.");
|
|
1360
|
+
if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
|
|
1367
1361
|
if (isEvents) {
|
|
1368
1362
|
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
|
|
1369
1363
|
res.write(": duto\n\n");
|
|
@@ -1372,10 +1366,10 @@ data: ${JSON.stringify({ ids })}
|
|
|
1372
1366
|
res.on("close", () => clients.delete(res));
|
|
1373
1367
|
return;
|
|
1374
1368
|
}
|
|
1375
|
-
if (path === "/api/
|
|
1369
|
+
if (path === "/api/session") {
|
|
1376
1370
|
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
|
|
1377
1371
|
}
|
|
1378
|
-
if (path === "/api/
|
|
1372
|
+
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1379
1373
|
const id = Number(detailId[1]);
|
|
1380
1374
|
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1381
1375
|
if (method === "POST") return json(res, 200, session.replay(id));
|
|
@@ -1387,7 +1381,7 @@ data: ${JSON.stringify({ ids })}
|
|
|
1387
1381
|
try {
|
|
1388
1382
|
handle(req, res);
|
|
1389
1383
|
} catch {
|
|
1390
|
-
if (!res.headersSent) text(res, 500, "
|
|
1384
|
+
if (!res.headersSent) text(res, 500, "Internal error.");
|
|
1391
1385
|
else res.end();
|
|
1392
1386
|
}
|
|
1393
1387
|
});
|
|
@@ -1460,10 +1454,11 @@ async function isPortListening(port, timeoutMs = 500) {
|
|
|
1460
1454
|
|
|
1461
1455
|
// src/session/auth.ts
|
|
1462
1456
|
import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1463
|
-
var TUNNEL_COOKIE = "__Host-
|
|
1457
|
+
var TUNNEL_COOKIE = "__Host-duto_tunnel";
|
|
1458
|
+
var SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED = "same-site";
|
|
1464
1459
|
var QR_TTL_MS = 5 * 6e4;
|
|
1465
1460
|
var sha2 = (s) => createHash3("sha256").update(s).digest();
|
|
1466
|
-
var
|
|
1461
|
+
var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
|
|
1467
1462
|
function splitPair(raw) {
|
|
1468
1463
|
const i = raw.indexOf(":");
|
|
1469
1464
|
return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
|
|
@@ -1475,7 +1470,7 @@ function parseBasic(value) {
|
|
|
1475
1470
|
var first2 = (v) => Array.isArray(v) ? v[0] : v;
|
|
1476
1471
|
var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
|
|
1477
1472
|
function sameHostOrigin(h) {
|
|
1478
|
-
if (first2(h["sec-fetch-site"])?.toLowerCase() ===
|
|
1473
|
+
if (first2(h["sec-fetch-site"])?.toLowerCase() === SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED) return false;
|
|
1479
1474
|
const origin = first2(h.origin);
|
|
1480
1475
|
if (origin === void 0) return true;
|
|
1481
1476
|
let host;
|
|
@@ -1510,16 +1505,16 @@ var TunnelAuth = class {
|
|
|
1510
1505
|
}
|
|
1511
1506
|
const cookieUser = this.cookieUser(h.cookie);
|
|
1512
1507
|
if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
|
|
1513
|
-
const
|
|
1514
|
-
const
|
|
1515
|
-
if (
|
|
1508
|
+
const dutoAuthHeader = first2(h["x-duto-auth"]);
|
|
1509
|
+
const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
|
|
1510
|
+
if (candidate && this.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
|
|
1516
1511
|
return { kind: "denied" };
|
|
1517
1512
|
}
|
|
1518
1513
|
checkPassword(user, password) {
|
|
1519
1514
|
let ok2 = false;
|
|
1520
1515
|
for (const c of this.credentials) {
|
|
1521
|
-
const userOk =
|
|
1522
|
-
const passOk =
|
|
1516
|
+
const userOk = constantTimeEqual2(c.user, user);
|
|
1517
|
+
const passOk = constantTimeEqual2(c.password, password);
|
|
1523
1518
|
if (userOk && passOk) ok2 = true;
|
|
1524
1519
|
}
|
|
1525
1520
|
return ok2;
|
|
@@ -1536,7 +1531,7 @@ var TunnelAuth = class {
|
|
|
1536
1531
|
}
|
|
1537
1532
|
consumeQrToken(token) {
|
|
1538
1533
|
const qr = this.qr;
|
|
1539
|
-
if (!qr || !
|
|
1534
|
+
if (!qr || !constantTimeEqual2(qr.token, token)) return false;
|
|
1540
1535
|
this.qr = null;
|
|
1541
1536
|
return this.now() <= qr.expiresAt;
|
|
1542
1537
|
}
|
|
@@ -1554,7 +1549,6 @@ var TunnelAuth = class {
|
|
|
1554
1549
|
}
|
|
1555
1550
|
return out;
|
|
1556
1551
|
}
|
|
1557
|
-
// Valor: usuário em base64url + "." + HMAC(túnel, usuário).
|
|
1558
1552
|
cookieValue(user) {
|
|
1559
1553
|
return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
|
|
1560
1554
|
}
|
|
@@ -1567,10 +1561,10 @@ var TunnelAuth = class {
|
|
|
1567
1561
|
for (const p of list) {
|
|
1568
1562
|
const item = p.trim();
|
|
1569
1563
|
if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
|
|
1570
|
-
const [b64, sig2, ...
|
|
1571
|
-
if (!b64 || !sig2 ||
|
|
1564
|
+
const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
|
|
1565
|
+
if (!b64 || !sig2 || extra.length) continue;
|
|
1572
1566
|
const user = Buffer.from(b64, "base64url").toString("utf8");
|
|
1573
|
-
if (
|
|
1567
|
+
if (constantTimeEqual2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
|
|
1574
1568
|
}
|
|
1575
1569
|
return null;
|
|
1576
1570
|
}
|
|
@@ -1622,14 +1616,14 @@ var WAIT_SCRIPT = `
|
|
|
1622
1616
|
}
|
|
1623
1617
|
function again() { delay = Math.min(delay * 2, 30000); poll(); }
|
|
1624
1618
|
if (!window.EventSource) return poll();
|
|
1625
|
-
var fails = 0, es = new EventSource("/__duto/
|
|
1619
|
+
var fails = 0, es = new EventSource("/__duto/wait");
|
|
1626
1620
|
es.addEventListener("up", function () { es.close(); location.reload(); });
|
|
1627
1621
|
es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
|
|
1628
1622
|
})();
|
|
1629
1623
|
</script>`;
|
|
1630
1624
|
function pageShell(title, body, css = "") {
|
|
1631
1625
|
return `<!doctype html>
|
|
1632
|
-
<html lang="
|
|
1626
|
+
<html lang="en">
|
|
1633
1627
|
<head>
|
|
1634
1628
|
<meta charset="utf-8">
|
|
1635
1629
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -1661,13 +1655,13 @@ function unavailablePage(o) {
|
|
|
1661
1655
|
const tunnel2 = esc(o.tunnel);
|
|
1662
1656
|
const local = `localhost:${o.port}`;
|
|
1663
1657
|
return pageShell(
|
|
1664
|
-
`
|
|
1658
|
+
`Nothing responding on ${local} \xB7 duto`,
|
|
1665
1659
|
`<main>
|
|
1666
1660
|
<div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
|
|
1667
|
-
<h1>
|
|
1668
|
-
<p>
|
|
1669
|
-
<p>
|
|
1670
|
-
<div class="retry">${o.retry ? `<b>
|
|
1661
|
+
<h1>Nothing is responding on <code>${local}</code></h1>
|
|
1662
|
+
<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>
|
|
1663
|
+
<p>If you were only sent this link, let whoever shared it know.</p>
|
|
1664
|
+
<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
1665
|
</main>${o.retry ? WAIT_SCRIPT : ""}`
|
|
1672
1666
|
);
|
|
1673
1667
|
}
|
|
@@ -1680,35 +1674,35 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
|
|
|
1680
1674
|
.error{margin:14px 0 0;color:#b42318;font-weight:600}
|
|
1681
1675
|
@media (prefers-color-scheme:dark){.error{color:#f97066}}
|
|
1682
1676
|
`;
|
|
1683
|
-
function
|
|
1677
|
+
function safeNext(raw) {
|
|
1684
1678
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
1685
1679
|
return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
|
|
1686
1680
|
}
|
|
1687
1681
|
function loginPage(o) {
|
|
1688
1682
|
return pageShell(
|
|
1689
|
-
"
|
|
1683
|
+
"Sign in \xB7 duto",
|
|
1690
1684
|
`<main>
|
|
1691
1685
|
<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">
|
|
1686
|
+
<h1>This tunnel is password protected</h1>
|
|
1687
|
+
<p>Ask whoever sent you the link for access.</p>
|
|
1688
|
+
<form method="post" action="/__duto/login">
|
|
1689
|
+
<input type="hidden" name="next" value="${esc(o.next)}">
|
|
1690
|
+
<label for="username">Username</label>
|
|
1691
|
+
<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
|
1692
|
+
<label for="password">Password</label>
|
|
1693
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
1694
|
+
${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
|
|
1695
|
+
` : ""}<button type="submit">Sign in</button>
|
|
1702
1696
|
</form>
|
|
1703
|
-
<div class="retry">
|
|
1697
|
+
<div class="retry">You stay signed in until the tunnel closes.</div>
|
|
1704
1698
|
</main>`,
|
|
1705
1699
|
CSS
|
|
1706
1700
|
);
|
|
1707
1701
|
}
|
|
1708
1702
|
|
|
1709
1703
|
// src/session/proxy.ts
|
|
1710
|
-
var DENIED_NO_REPLAY = "
|
|
1711
|
-
var PREFLIGHT_NO_REPLAY = "
|
|
1704
|
+
var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
|
|
1705
|
+
var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
|
|
1712
1706
|
var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
|
|
1713
1707
|
var MAX_BODY = 64 * 1024;
|
|
1714
1708
|
var LOCAL_AGENT = new Agent({ keepAlive: false });
|
|
@@ -1726,7 +1720,6 @@ var Capture = class {
|
|
|
1726
1720
|
max;
|
|
1727
1721
|
chunks = [];
|
|
1728
1722
|
size = 0;
|
|
1729
|
-
/** Bytes vistos, capturados ou não. */
|
|
1730
1723
|
total = 0;
|
|
1731
1724
|
truncated = false;
|
|
1732
1725
|
push(c) {
|
|
@@ -1749,6 +1742,7 @@ var Capture = class {
|
|
|
1749
1742
|
return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
|
|
1750
1743
|
}
|
|
1751
1744
|
};
|
|
1745
|
+
var HOST_CACHE_MS = 5e3;
|
|
1752
1746
|
var hostCache = /* @__PURE__ */ new Map();
|
|
1753
1747
|
function probe(host, port) {
|
|
1754
1748
|
return new Promise((resolve) => {
|
|
@@ -1764,7 +1758,7 @@ function probe(host, port) {
|
|
|
1764
1758
|
}
|
|
1765
1759
|
async function localHost(port) {
|
|
1766
1760
|
const hit = hostCache.get(port);
|
|
1767
|
-
if (hit && Date.now() - hit.at <
|
|
1761
|
+
if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
|
|
1768
1762
|
const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
|
|
1769
1763
|
if (!host) return "127.0.0.1";
|
|
1770
1764
|
hostCache.set(port, { host, at: Date.now() });
|
|
@@ -1793,13 +1787,16 @@ function hostOf(req) {
|
|
|
1793
1787
|
return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
1794
1788
|
}
|
|
1795
1789
|
var DUTO_PREFIX = "/__duto/";
|
|
1796
|
-
var WAIT_PATH = "/__duto/
|
|
1790
|
+
var WAIT_PATH = "/__duto/wait";
|
|
1797
1791
|
var WAIT_MAX_MS = 10 * 6e4;
|
|
1798
|
-
var
|
|
1799
|
-
var
|
|
1792
|
+
var DEFAULT_ROUTE_WAIT_MS = 3e3;
|
|
1793
|
+
var DEFAULT_WAIT_CHECK_MS = 1e3;
|
|
1794
|
+
var SSE_KEEPALIVE_MS = 25e3;
|
|
1795
|
+
var LOGIN_PATH = "/__duto/login";
|
|
1796
|
+
var LOGOUT_PATH = "/__duto/logout";
|
|
1800
1797
|
var QR_PREFIX = "/__duto/qr/";
|
|
1801
1798
|
var MAX_FORM = 8 * 1024;
|
|
1802
|
-
var DENIED_TEXT = "
|
|
1799
|
+
var DENIED_TEXT = "This tunnel is password protected. Send Authorization: Basic or X-Duto-Auth: user:password.";
|
|
1803
1800
|
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
1801
|
function readForm(req, max) {
|
|
1805
1802
|
return new Promise((resolve) => {
|
|
@@ -1822,9 +1819,9 @@ function readForm(req, max) {
|
|
|
1822
1819
|
}
|
|
1823
1820
|
async function authRoute(req, res, route, auth) {
|
|
1824
1821
|
const url = new URL(req.url ?? "/", "http://duto");
|
|
1825
|
-
const page = (status,
|
|
1822
|
+
const page = (status, next, error) => {
|
|
1826
1823
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1827
|
-
res.end(loginPage({ tunnel: route.tunnel,
|
|
1824
|
+
res.end(loginPage({ tunnel: route.tunnel, next, error }));
|
|
1828
1825
|
};
|
|
1829
1826
|
const go = (location, cookie) => {
|
|
1830
1827
|
res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
|
|
@@ -1832,21 +1829,21 @@ async function authRoute(req, res, route, auth) {
|
|
|
1832
1829
|
};
|
|
1833
1830
|
const { pathname } = url;
|
|
1834
1831
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
1835
|
-
page(200,
|
|
1832
|
+
page(200, safeNext(url.searchParams.get("next")), false);
|
|
1836
1833
|
return true;
|
|
1837
1834
|
}
|
|
1838
1835
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
1839
1836
|
const raw = await readForm(req, MAX_FORM);
|
|
1840
1837
|
if (raw === null) {
|
|
1841
1838
|
res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
|
|
1842
|
-
res.end("
|
|
1839
|
+
res.end("Form too large");
|
|
1843
1840
|
return true;
|
|
1844
1841
|
}
|
|
1845
1842
|
const form = new URLSearchParams(raw);
|
|
1846
|
-
const user = form.get("
|
|
1847
|
-
const
|
|
1848
|
-
if (auth.checkPassword(user, form.get("
|
|
1849
|
-
else page(401,
|
|
1843
|
+
const user = form.get("username") ?? "";
|
|
1844
|
+
const next = safeNext(form.get("next"));
|
|
1845
|
+
if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
|
|
1846
|
+
else page(401, next, true);
|
|
1850
1847
|
return true;
|
|
1851
1848
|
}
|
|
1852
1849
|
if (pathname === LOGOUT_PATH && req.method === "GET") {
|
|
@@ -1862,7 +1859,7 @@ async function authRoute(req, res, route, auth) {
|
|
|
1862
1859
|
}
|
|
1863
1860
|
function deny(req, res, html) {
|
|
1864
1861
|
const status = html ? 303 : 401;
|
|
1865
|
-
const out = html ? { location: `${LOGIN_PATH}?
|
|
1862
|
+
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
1863
|
const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
|
|
1867
1864
|
res.writeHead(status, out);
|
|
1868
1865
|
res.end(body);
|
|
@@ -1904,7 +1901,7 @@ function waitForService(res, port, checkMs, pingMs) {
|
|
|
1904
1901
|
async function startProxy(o) {
|
|
1905
1902
|
const max = o.maxBody ?? MAX_BODY;
|
|
1906
1903
|
let nextId = 1;
|
|
1907
|
-
const routeWaitMs = o.routeWaitMs ??
|
|
1904
|
+
const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
|
|
1908
1905
|
async function routeFor(req) {
|
|
1909
1906
|
const host = hostOf(req);
|
|
1910
1907
|
for (let waited = 0; ; waited += 50) {
|
|
@@ -1965,7 +1962,7 @@ async function startProxy(o) {
|
|
|
1965
1962
|
function replay(id) {
|
|
1966
1963
|
const r = o.store.get(id);
|
|
1967
1964
|
const blocked = replayBlock(r);
|
|
1968
|
-
if (blocked || !r) return { ok: false, reason: blocked ?? "
|
|
1965
|
+
if (blocked || !r) return { ok: false, reason: blocked ?? "That request is no longer in the list." };
|
|
1969
1966
|
const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
|
|
1970
1967
|
if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
|
|
1971
1968
|
const newId = nextId++;
|
|
@@ -2006,17 +2003,17 @@ async function startProxy(o) {
|
|
|
2006
2003
|
const route = await routeFor(req);
|
|
2007
2004
|
if (!route) {
|
|
2008
2005
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2009
|
-
res.end("
|
|
2006
|
+
res.end("Tunnel not found in this session");
|
|
2010
2007
|
return;
|
|
2011
2008
|
}
|
|
2012
2009
|
if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
|
|
2013
2010
|
if (req.url === WAIT_PATH && req.method === "GET") {
|
|
2014
2011
|
if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
|
|
2015
|
-
return waitForService(res, route.port, o.waitCheckMs ??
|
|
2012
|
+
return waitForService(res, route.port, o.waitCheckMs ?? DEFAULT_WAIT_CHECK_MS, o.waitPingMs ?? SSE_KEEPALIVE_MS);
|
|
2016
2013
|
}
|
|
2017
2014
|
if (route.auth && await authRoute(req, res, route, route.auth)) return;
|
|
2018
2015
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2019
|
-
res.end("
|
|
2016
|
+
res.end("Reserved Duto route");
|
|
2020
2017
|
return;
|
|
2021
2018
|
}
|
|
2022
2019
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
@@ -2059,7 +2056,7 @@ async function startProxy(o) {
|
|
|
2059
2056
|
req.resume();
|
|
2060
2057
|
if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
|
|
2061
2058
|
const html = wantsHtml(req.headers.accept);
|
|
2062
|
-
const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `
|
|
2059
|
+
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
2060
|
if (!res.headersSent) {
|
|
2064
2061
|
res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
|
|
2065
2062
|
res.end(req.method === "HEAD" ? void 0 : msg);
|
|
@@ -2223,7 +2220,6 @@ var RecordStore = class {
|
|
|
2223
2220
|
get(id) {
|
|
2224
2221
|
return this.items.find((x) => x.id === id);
|
|
2225
2222
|
}
|
|
2226
|
-
/** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
2227
2223
|
list() {
|
|
2228
2224
|
this.snapshot ??= [...this.items].reverse();
|
|
2229
2225
|
return this.snapshot;
|
|
@@ -2243,8 +2239,8 @@ var READY = "Registered tunnel connection";
|
|
|
2243
2239
|
var CLOSE_TIMEOUT_MS = 3e3;
|
|
2244
2240
|
var HEARTBEAT_MS = 3e4;
|
|
2245
2241
|
var RESTART_CF_MS = 2e3;
|
|
2246
|
-
var ALREADY_OPEN = "
|
|
2247
|
-
var
|
|
2242
|
+
var ALREADY_OPEN = "Already open in another session on this machine";
|
|
2243
|
+
var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
|
|
2248
2244
|
function message(err) {
|
|
2249
2245
|
return err instanceof Error ? err.message : String(err);
|
|
2250
2246
|
}
|
|
@@ -2252,7 +2248,7 @@ function withTimeout(p, ms) {
|
|
|
2252
2248
|
return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
|
|
2253
2249
|
}
|
|
2254
2250
|
function keyOf(spec) {
|
|
2255
|
-
return spec.name ?? "(
|
|
2251
|
+
return spec.name ?? "(default)";
|
|
2256
2252
|
}
|
|
2257
2253
|
var Session = class {
|
|
2258
2254
|
constructor(o) {
|
|
@@ -2264,8 +2260,7 @@ var Session = class {
|
|
|
2264
2260
|
tunnels = [];
|
|
2265
2261
|
routes = /* @__PURE__ */ new Map();
|
|
2266
2262
|
specs = /* @__PURE__ */ new Map();
|
|
2267
|
-
|
|
2268
|
-
auths = /* @__PURE__ */ new Map();
|
|
2263
|
+
persistentAuths = /* @__PURE__ */ new Map();
|
|
2269
2264
|
proxy = null;
|
|
2270
2265
|
child = null;
|
|
2271
2266
|
token = null;
|
|
@@ -2276,7 +2271,6 @@ var Session = class {
|
|
|
2276
2271
|
connection = "connecting";
|
|
2277
2272
|
listeners = /* @__PURE__ */ new Set();
|
|
2278
2273
|
snapshot = null;
|
|
2279
|
-
/** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
2280
2274
|
getState() {
|
|
2281
2275
|
this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
|
|
2282
2276
|
return this.snapshot;
|
|
@@ -2285,23 +2279,18 @@ var Session = class {
|
|
|
2285
2279
|
this.listeners.add(fn);
|
|
2286
2280
|
return () => this.listeners.delete(fn);
|
|
2287
2281
|
}
|
|
2288
|
-
/** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
|
|
2289
2282
|
async start(specs) {
|
|
2290
2283
|
if (this.o.runDir) reapOrphans(this.o.runDir);
|
|
2291
2284
|
this.proxy = await startProxy({ routes: this.routes, store: this.records });
|
|
2292
2285
|
this.tunnels = specs.map((s) => this.blank(s, false));
|
|
2293
2286
|
this.emit();
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
this.account = me.username ?? me.email;
|
|
2297
|
-
} catch {
|
|
2298
|
-
}
|
|
2287
|
+
const me = await this.o.api.me().catch(() => null);
|
|
2288
|
+
if (me) this.account = me.username ?? me.email;
|
|
2299
2289
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
2300
2290
|
for (const t of this.tunnels) await this.open(t, existing);
|
|
2301
2291
|
const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
|
|
2302
2292
|
this.timer = setInterval(() => void this.beat(), every);
|
|
2303
2293
|
}
|
|
2304
|
-
/** Túnel avulso, só nesta sessão. */
|
|
2305
2294
|
async add(spec) {
|
|
2306
2295
|
if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
|
|
2307
2296
|
const t = this.blank(spec, true);
|
|
@@ -2310,33 +2299,29 @@ var Session = class {
|
|
|
2310
2299
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
2311
2300
|
await this.open(t, existing);
|
|
2312
2301
|
}
|
|
2313
|
-
/** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
|
|
2314
2302
|
qrLink(key) {
|
|
2315
2303
|
const t = this.tunnels.find((x) => x.key === key);
|
|
2316
2304
|
if (!t?.url) return null;
|
|
2317
|
-
const auth = this.
|
|
2305
|
+
const auth = this.persistentAuths.get(key);
|
|
2318
2306
|
if (!auth) return { url: t.url, expiresAt: null };
|
|
2319
2307
|
const { token, expiresAt } = auth.issueQrToken();
|
|
2320
2308
|
return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
|
|
2321
2309
|
}
|
|
2322
|
-
/** Primeiro usuário configurado do túnel, ou null quando ele não tem senha. */
|
|
2323
2310
|
authUser(key) {
|
|
2324
|
-
return this.
|
|
2311
|
+
return this.persistentAuths.get(key)?.qrUser ?? null;
|
|
2325
2312
|
}
|
|
2326
|
-
/** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
|
|
2327
2313
|
async authFor(key) {
|
|
2328
|
-
const existing = this.
|
|
2314
|
+
const existing = this.persistentAuths.get(key);
|
|
2329
2315
|
if (existing) return existing;
|
|
2330
2316
|
const spec = this.specs.get(key);
|
|
2331
2317
|
if (!spec?.auth?.length) return void 0;
|
|
2332
2318
|
const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
|
|
2333
2319
|
const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
|
|
2334
|
-
this.
|
|
2320
|
+
this.persistentAuths.set(key, auth);
|
|
2335
2321
|
return auth;
|
|
2336
2322
|
}
|
|
2337
|
-
/** Reenvia uma requisição da lista ao serviço local. */
|
|
2338
2323
|
replay(id) {
|
|
2339
|
-
return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "
|
|
2324
|
+
return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Session has not started yet." };
|
|
2340
2325
|
}
|
|
2341
2326
|
async stop() {
|
|
2342
2327
|
if (this.stopped) return;
|
|
@@ -2355,10 +2340,10 @@ var Session = class {
|
|
|
2355
2340
|
}
|
|
2356
2341
|
async open(t, existing, retrying = false) {
|
|
2357
2342
|
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 <
|
|
2343
|
+
const takenByLiveSession = existing?.some(
|
|
2344
|
+
(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
2345
|
);
|
|
2361
|
-
if (
|
|
2346
|
+
if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
|
|
2362
2347
|
try {
|
|
2363
2348
|
const res = await this.o.api.openTunnel({
|
|
2364
2349
|
deviceId: this.o.deviceId,
|
|
@@ -2534,7 +2519,7 @@ function runPlain(session, out) {
|
|
|
2534
2519
|
}
|
|
2535
2520
|
|
|
2536
2521
|
// src/index.ts
|
|
2537
|
-
var LOGIN_HINT = "
|
|
2522
|
+
var LOGIN_HINT = "Log in first: duto login <token> (get your token at https://duto.dev)";
|
|
2538
2523
|
function fail2(message2) {
|
|
2539
2524
|
console.error(fail(message2));
|
|
2540
2525
|
process.exit(1);
|
|
@@ -2542,19 +2527,19 @@ function fail2(message2) {
|
|
|
2542
2527
|
function parsePort(raw) {
|
|
2543
2528
|
const s = String(raw ?? "");
|
|
2544
2529
|
const n = Number(s);
|
|
2545
|
-
if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`
|
|
2530
|
+
if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Invalid port: ${s} (use 1\u201365535)`);
|
|
2546
2531
|
return n;
|
|
2547
2532
|
}
|
|
2548
2533
|
function parseName(raw) {
|
|
2549
2534
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
2550
2535
|
const name = normalizeName(String(raw));
|
|
2551
2536
|
const v = validateName(name);
|
|
2552
|
-
if (!v.ok) fail2(v.reason === "reserved" ? `
|
|
2537
|
+
if (!v.ok) fail2(v.reason === "reserved" ? `Reserved name: "${name}". Pick another.` : "Invalid name: use lowercase letters and numbers only (no hyphens)");
|
|
2553
2538
|
return name;
|
|
2554
2539
|
}
|
|
2555
2540
|
function parseHostFlag(raw) {
|
|
2556
2541
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
2557
|
-
return parseHost(String(raw)) ?? fail2("
|
|
2542
|
+
return parseHost(String(raw)) ?? fail2("Invalid host: use the domain only, like myapp.test");
|
|
2558
2543
|
}
|
|
2559
2544
|
function listFlag(raw) {
|
|
2560
2545
|
if (raw === void 0) return [];
|
|
@@ -2564,14 +2549,14 @@ function parseAuthFlags(raw) {
|
|
|
2564
2549
|
const out = [];
|
|
2565
2550
|
for (const entry of listFlag(raw)) {
|
|
2566
2551
|
const r = parseCredential(entry);
|
|
2567
|
-
if (!r.ok) fail2(r.reason === "password" ? "
|
|
2568
|
-
if (out.some((c) => c.user === r.cred.user)) fail2(`
|
|
2552
|
+
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");
|
|
2553
|
+
if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
|
|
2569
2554
|
out.push(r.cred);
|
|
2570
2555
|
}
|
|
2571
2556
|
return out;
|
|
2572
2557
|
}
|
|
2573
2558
|
function parseAllowFlags(raw) {
|
|
2574
|
-
return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}"
|
|
2559
|
+
return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" is not a known name (stripe, github) or an IP range`));
|
|
2575
2560
|
}
|
|
2576
2561
|
function wantsUi(flags) {
|
|
2577
2562
|
if (flags.ui === false) return false;
|
|
@@ -2600,14 +2585,14 @@ async function runSession(specs, source, flags) {
|
|
|
2600
2585
|
}
|
|
2601
2586
|
}
|
|
2602
2587
|
if (wantsUi(flags)) {
|
|
2603
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
2604
|
-
const code = await runFullscreen(session, specs, "0.
|
|
2588
|
+
const { runFullscreen } = await import("./fullscreen-IWZCH3IP.js");
|
|
2589
|
+
const code = await runFullscreen(session, specs, "1.0.0", inspector, inspectorError);
|
|
2605
2590
|
await inspector?.close().catch(() => void 0);
|
|
2606
2591
|
process.exit(code);
|
|
2607
2592
|
}
|
|
2608
|
-
console.log(`${mark()} ${paint("muted", `${"0.
|
|
2609
|
-
if (inspector) console.log(paint("muted", `
|
|
2610
|
-
if (inspectorError) console.log(warn(`
|
|
2593
|
+
console.log(`${mark()} ${paint("muted", `${"1.0.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
|
|
2594
|
+
if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
|
|
2595
|
+
if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
|
|
2611
2596
|
const stopLog = runPlain(session, (l) => console.log(l));
|
|
2612
2597
|
const stopped = new Promise((resolve) => {
|
|
2613
2598
|
const onSignal = () => {
|
|
@@ -2629,9 +2614,9 @@ async function runSession(specs, source, flags) {
|
|
|
2629
2614
|
}
|
|
2630
2615
|
async function tunnel(rawPort, flags) {
|
|
2631
2616
|
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.
|
|
2617
|
+
if (flags.host !== void 0) fail2("Use --host with a port (duto 8080 --host myapp.test) or host: in duto.yml");
|
|
2618
|
+
if (flags.auth !== void 0) fail2("Use --auth with a port (duto 3000 --auth ana:secret) or auth: in duto.yml");
|
|
2619
|
+
if (flags.allow !== void 0) fail2("Use --allow with a port (duto 3000 --allow stripe) or allow: in duto.yml");
|
|
2635
2620
|
let found;
|
|
2636
2621
|
try {
|
|
2637
2622
|
found = findConfig(process.cwd());
|
|
@@ -2641,7 +2626,7 @@ async function tunnel(rawPort, flags) {
|
|
|
2641
2626
|
if (!found) {
|
|
2642
2627
|
cli.outputHelp();
|
|
2643
2628
|
console.log(`
|
|
2644
|
-
${paint("signal", "
|
|
2629
|
+
${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and just run ${paint("strong", "duto")}:
|
|
2645
2630
|
|
|
2646
2631
|
tunnels:
|
|
2647
2632
|
web: 3000
|
|
@@ -2655,30 +2640,30 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
|
|
|
2655
2640
|
const name = parseName(flags.name) ?? null;
|
|
2656
2641
|
const host = parseHostFlag(flags.host);
|
|
2657
2642
|
const keepRedirects = flags.rewriteRedirects === false;
|
|
2658
|
-
if (keepRedirects && !host) fail2("--no-rewrite-redirects
|
|
2643
|
+
if (keepRedirects && !host) fail2("--no-rewrite-redirects only works with --host");
|
|
2659
2644
|
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 } : {} }], "
|
|
2645
|
+
const allow = parseAllowFlags(flags.allow);
|
|
2646
|
+
if (allow.length && !auth.length) fail2("--allow only works with --auth");
|
|
2647
|
+
return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
|
|
2663
2648
|
}
|
|
2664
2649
|
var cli = cac("duto");
|
|
2665
|
-
cli.command("[port]", "
|
|
2666
|
-
cli.command("http <port>", "
|
|
2667
|
-
cli.command("login <token>", "
|
|
2650
|
+
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));
|
|
2651
|
+
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));
|
|
2652
|
+
cli.command("login <token>", "Save your authtoken (get it at https://duto.dev)").action(async (token) => {
|
|
2668
2653
|
try {
|
|
2669
2654
|
const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
|
|
2670
2655
|
saveConfig({ ...loadConfig(), authtoken: String(token) });
|
|
2671
|
-
console.log(ok(`
|
|
2656
|
+
console.log(ok(`Logged in as ${paint("strong", me.username ?? me.email)}`));
|
|
2672
2657
|
} catch (err) {
|
|
2673
2658
|
fail2(err instanceof Error ? err.message : String(err));
|
|
2674
2659
|
}
|
|
2675
2660
|
});
|
|
2676
|
-
cli.command("logout", "Remove
|
|
2661
|
+
cli.command("logout", "Remove the authtoken from this machine").action(() => {
|
|
2677
2662
|
const { authtoken: _removed, ...rest } = loadConfig();
|
|
2678
2663
|
saveConfig(rest);
|
|
2679
|
-
console.log(ok("Authtoken
|
|
2664
|
+
console.log(ok("Authtoken removed"));
|
|
2680
2665
|
});
|
|
2681
|
-
cli.command("status", "
|
|
2666
|
+
cli.command("status", "Show your account and live tunnels").action(async () => {
|
|
2682
2667
|
const config = loadConfig();
|
|
2683
2668
|
if (!config.authtoken) fail2(LOGIN_HINT);
|
|
2684
2669
|
try {
|
|
@@ -2686,23 +2671,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
|
|
|
2686
2671
|
const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
|
|
2687
2672
|
console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
|
|
2688
2673
|
const live = endpoints.filter((e) => e.connected);
|
|
2689
|
-
if (!live.length) console.log(paint("muted", "
|
|
2674
|
+
if (!live.length) console.log(paint("muted", "No live tunnels."));
|
|
2690
2675
|
for (const e of live)
|
|
2691
2676
|
console.log(
|
|
2692
|
-
`${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "
|
|
2677
|
+
`${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "another machine"}`}`)}`
|
|
2693
2678
|
);
|
|
2694
2679
|
} catch (err) {
|
|
2695
2680
|
fail2(err instanceof DutoApiError ? err.message : String(err));
|
|
2696
2681
|
}
|
|
2697
2682
|
});
|
|
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");
|
|
2683
|
+
cli.help();
|
|
2684
|
+
cli.version("1.0.0");
|
|
2706
2685
|
try {
|
|
2707
2686
|
cli.parse(process.argv, { run: false });
|
|
2708
2687
|
await cli.runMatchedCommand();
|